core: Reloading with SIGUSR1 if config never changed via admin (#7258)
Some checks failed
Tests / test (./cmd/caddy/caddy, ~1.25.0, ubuntu-latest, 0, 1.25, linux) (push) Failing after 54s
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.25.0, 1.25, aix) (push) Failing after 15s
Cross-Build / build (~1.25.0, 1.25, darwin) (push) Failing after 15s
Cross-Build / build (~1.25.0, 1.25, dragonfly) (push) Failing after 16s
Cross-Build / build (~1.25.0, 1.25, freebsd) (push) Failing after 15s
Cross-Build / build (~1.25.0, 1.25, illumos) (push) Failing after 14s
Cross-Build / build (~1.25.0, 1.25, linux) (push) Failing after 28s
Cross-Build / build (~1.25.0, 1.25, netbsd) (push) Failing after 18s
Cross-Build / build (~1.25.0, 1.25, openbsd) (push) Failing after 16s
Cross-Build / build (~1.25.0, 1.25, solaris) (push) Failing after 14s
Cross-Build / build (~1.25.0, 1.25, windows) (push) Failing after 13s
Lint / lint (ubuntu-latest, linux) (push) Failing after 14s
Lint / govulncheck (push) Successful in 1m38s
Lint / dependency-review (push) Failing after 15s
Tests / test (./cmd/caddy/caddy, ~1.25.0, macos-14, 0, 1.25, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.25.0, windows-latest, True, 1.25, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Has started running

This commit is contained in:
Francis Lavoie 2025-09-26 12:50:15 -04:00 committed by GitHub
parent b2ab419922
commit 65e0ddc221
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 167 additions and 20 deletions

View File

@ -1029,6 +1029,13 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error {
return err
}
// If this request changed the config, clear the last
// config info we have stored, if it is different from
// the original source.
ClearLastConfigIfDifferent(
r.Header.Get("Caddy-Config-Source-File"),
r.Header.Get("Caddy-Config-Source-Adapter"))
default:
return APIError{
HTTPStatus: http.StatusMethodNotAllowed,

View File

@ -1197,6 +1197,91 @@ var (
rawCfgMu sync.RWMutex
)
// lastConfigFile and lastConfigAdapter remember the source config
// file and adapter used when Caddy was started via the CLI "run" command.
// These are consulted by the SIGUSR1 handler to attempt reloading from
// the same source. They are intentionally not set for other entrypoints
// such as "caddy start" or subcommands like file-server.
var (
lastConfigMu sync.RWMutex
lastConfigFile string
lastConfigAdapter string
)
// reloadFromSourceFunc is the type of stored callback
// which is called when we receive a SIGUSR1 signal.
type reloadFromSourceFunc func(file, adapter string) error
// reloadFromSourceCallback is the stored callback
// which is called when we receive a SIGUSR1 signal.
var reloadFromSourceCallback reloadFromSourceFunc
// errReloadFromSourceUnavailable is returned when no reload-from-source callback is set.
var errReloadFromSourceUnavailable = errors.New("reload from source unavailable in this process") //nolint:unused
// SetLastConfig records the given source file and adapter as the
// last-known external configuration source. Intended to be called
// only when starting via "caddy run --config <file> --adapter <adapter>".
func SetLastConfig(file, adapter string, fn reloadFromSourceFunc) {
lastConfigMu.Lock()
lastConfigFile = file
lastConfigAdapter = adapter
reloadFromSourceCallback = fn
lastConfigMu.Unlock()
}
// ClearLastConfigIfDifferent clears the recorded last-config if the provided
// source file/adapter do not match the recorded last-config. If both srcFile
// and srcAdapter are empty, the last-config is cleared.
func ClearLastConfigIfDifferent(srcFile, srcAdapter string) {
if (srcFile != "" || srcAdapter != "") && lastConfigMatches(srcFile, srcAdapter) {
return
}
SetLastConfig("", "", nil)
}
// getLastConfig returns the last-known config file and adapter.
func getLastConfig() (file, adapter string, fn reloadFromSourceFunc) {
lastConfigMu.RLock()
f, a, cb := lastConfigFile, lastConfigAdapter, reloadFromSourceCallback
lastConfigMu.RUnlock()
return f, a, cb
}
// lastConfigMatches returns true if the provided source file and/or adapter
// matches the recorded last-config. Matching rules (in priority order):
// 1. If srcAdapter is provided and differs from the recorded adapter, no match.
// 2. If srcFile exactly equals the recorded file, match.
// 3. If both sides can be made absolute and equal, match.
// 4. If basenames are equal, match.
func lastConfigMatches(srcFile, srcAdapter string) bool {
lf, la, _ := getLastConfig()
// If adapter is provided, it must match.
if srcAdapter != "" && srcAdapter != la {
return false
}
// Quick equality check.
if srcFile == lf {
return true
}
// Try absolute path comparison.
sAbs, sErr := filepath.Abs(srcFile)
lAbs, lErr := filepath.Abs(lf)
if sErr == nil && lErr == nil && sAbs == lAbs {
return true
}
// Final fallback: basename equality.
if filepath.Base(srcFile) == filepath.Base(lf) {
return true
}
return false
}
// errSameConfig is returned if the new config is the same
// as the old one. This isn't usually an actual, actionable
// error; it's mostly a sentinel value.

View File

@ -121,6 +121,13 @@ func (adminLoad) handleLoad(w http.ResponseWriter, r *http.Request) error {
}
}
// If this request changed the config, clear the last
// config info we have stored, if it is different from
// the original source.
caddy.ClearLastConfigIfDifferent(
r.Header.Get("Caddy-Config-Source-File"),
r.Header.Get("Caddy-Config-Source-Adapter"))
caddy.Log().Named("admin.api").Info("load complete")
return nil

View File

@ -231,8 +231,9 @@ func cmdRun(fl Flags) (int, error) {
}
// we don't use 'else' here since this value might have been changed in 'if' block; i.e. not mutually exclusive
var configFile string
var adapterUsed string
if !resumeFlag {
config, configFile, err = LoadConfig(configFlag, configAdapterFlag)
config, configFile, adapterUsed, err = LoadConfig(configFlag, configAdapterFlag)
if err != nil {
logBuffer.FlushTo(defaultLogger)
return caddy.ExitCodeFailedStartup, err
@ -249,6 +250,19 @@ func cmdRun(fl Flags) (int, error) {
}
}
// If we have a source config file (we're running via 'caddy run --config ...'),
// record it so SIGUSR1 can reload from the same file. Also provide a callback
// that knows how to load/adapt that source when requested by the main process.
if configFile != "" {
caddy.SetLastConfig(configFile, adapterUsed, func(file, adapter string) error {
cfg, _, _, err := LoadConfig(file, adapter)
if err != nil {
return err
}
return caddy.Load(cfg, true)
})
}
// run the initial config
err = caddy.Load(config, true)
if err != nil {
@ -295,7 +309,7 @@ func cmdRun(fl Flags) (int, error) {
// if enabled, reload config file automatically on changes
// (this better only be used in dev!)
if watchFlag {
go watchConfigFile(configFile, configAdapterFlag)
go watchConfigFile(configFile, adapterUsed)
}
// warn if the environment does not provide enough information about the disk
@ -350,7 +364,7 @@ func cmdReload(fl Flags) (int, error) {
forceFlag := fl.Bool("force")
// get the config in caddy's native format
config, configFile, err := LoadConfig(configFlag, configAdapterFlag)
config, configFile, adapterUsed, err := LoadConfig(configFlag, configAdapterFlag)
if err != nil {
return caddy.ExitCodeFailedStartup, err
}
@ -368,6 +382,10 @@ func cmdReload(fl Flags) (int, error) {
if forceFlag {
headers.Set("Cache-Control", "must-revalidate")
}
// Provide the source file/adapter to the running process so it can
// preserve its last-config knowledge if this reload came from the same source.
headers.Set("Caddy-Config-Source-File", configFile)
headers.Set("Caddy-Config-Source-Adapter", adapterUsed)
resp, err := AdminAPIRequest(adminAddr, http.MethodPost, "/load", headers, bytes.NewReader(config))
if err != nil {
@ -582,7 +600,7 @@ func cmdValidateConfig(fl Flags) (int, error) {
fmt.Errorf("input file required when there is no Caddyfile in current directory (use --config flag)")
}
input, _, err := LoadConfig(configFlag, adapterFlag)
input, _, _, err := LoadConfig(configFlag, adapterFlag)
if err != nil {
return caddy.ExitCodeFailedStartup, err
}
@ -797,7 +815,7 @@ func DetermineAdminAPIAddress(address string, config []byte, configFile, configA
loadedConfig := config
if len(loadedConfig) == 0 {
// get the config in caddy's native format
loadedConfig, loadedConfigFile, err = LoadConfig(configFile, configAdapter)
loadedConfig, loadedConfigFile, _, err = LoadConfig(configFile, configAdapter)
if err != nil {
return "", err
}

View File

@ -100,7 +100,12 @@ func handlePingbackConn(conn net.Conn, expect []byte) error {
// there is no config available. It prints any warnings to stderr,
// and returns the resulting JSON config bytes along with
// the name of the loaded config file (if any).
func LoadConfig(configFile, adapterName string) ([]byte, string, error) {
// The return values are:
// - config bytes (nil if no config)
// - config file used ("" if none)
// - adapter used ("" if none)
// - error, if any
func LoadConfig(configFile, adapterName string) ([]byte, string, string, error) {
return loadConfigWithLogger(caddy.Log(), configFile, adapterName)
}
@ -138,7 +143,7 @@ func isCaddyfile(configFile, adapterName string) (bool, error) {
return false, nil
}
func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([]byte, string, error) {
func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([]byte, string, string, error) {
// if no logger is provided, use a nop logger
// just so we don't have to check for nil
if logger == nil {
@ -147,7 +152,7 @@ func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([
// specifying an adapter without a config file is ambiguous
if adapterName != "" && configFile == "" {
return nil, "", fmt.Errorf("cannot adapt config without config file (use --config)")
return nil, "", "", fmt.Errorf("cannot adapt config without config file (use --config)")
}
// load initial config and adapter
@ -158,13 +163,13 @@ func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([
if configFile == "-" {
config, err = io.ReadAll(os.Stdin)
if err != nil {
return nil, "", fmt.Errorf("reading config from stdin: %v", err)
return nil, "", "", fmt.Errorf("reading config from stdin: %v", err)
}
logger.Info("using config from stdin")
} else {
config, err = os.ReadFile(configFile)
if err != nil {
return nil, "", fmt.Errorf("reading config from file: %v", err)
return nil, "", "", fmt.Errorf("reading config from file: %v", err)
}
logger.Info("using config from file", zap.String("file", configFile))
}
@ -179,7 +184,7 @@ func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([
cfgAdapter = nil
} else if err != nil {
// default Caddyfile exists, but error reading it
return nil, "", fmt.Errorf("reading default Caddyfile: %v", err)
return nil, "", "", fmt.Errorf("reading default Caddyfile: %v", err)
} else {
// success reading default Caddyfile
configFile = "Caddyfile"
@ -191,14 +196,14 @@ func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([
if yes, err := isCaddyfile(configFile, adapterName); yes {
adapterName = "caddyfile"
} else if err != nil {
return nil, "", err
return nil, "", "", err
}
// load config adapter
if adapterName != "" {
cfgAdapter = caddyconfig.GetAdapter(adapterName)
if cfgAdapter == nil {
return nil, "", fmt.Errorf("unrecognized config adapter: %s", adapterName)
return nil, "", "", fmt.Errorf("unrecognized config adapter: %s", adapterName)
}
}
@ -208,7 +213,7 @@ func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([
"filename": configFile,
})
if err != nil {
return nil, "", fmt.Errorf("adapting config using %s: %v", adapterName, err)
return nil, "", "", fmt.Errorf("adapting config using %s: %v", adapterName, err)
}
logger.Info("adapted config to JSON", zap.String("adapter", adapterName))
for _, warn := range warnings {
@ -226,11 +231,11 @@ func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([
// validate that the config is at least valid JSON
err = json.Unmarshal(config, new(any))
if err != nil {
return nil, "", fmt.Errorf("config is not valid JSON: %v; did you mean to use a config adapter (the --adapter flag)?", err)
return nil, "", "", fmt.Errorf("config is not valid JSON: %v; did you mean to use a config adapter (the --adapter flag)?", err)
}
}
return config, configFile, nil
return config, configFile, adapterName, nil
}
// watchConfigFile watches the config file at filename for changes
@ -256,7 +261,7 @@ func watchConfigFile(filename, adapterName string) {
}
// get current config
lastCfg, _, err := loadConfigWithLogger(nil, filename, adapterName)
lastCfg, _, _, err := loadConfigWithLogger(nil, filename, adapterName)
if err != nil {
logger().Error("unable to load latest config", zap.Error(err))
return
@ -268,7 +273,7 @@ func watchConfigFile(filename, adapterName string) {
//nolint:staticcheck
for range time.Tick(1 * time.Second) {
// get current config
newCfg, _, err := loadConfigWithLogger(nil, filename, adapterName)
newCfg, _, _, err := loadConfigWithLogger(nil, filename, adapterName)
if err != nil {
logger().Error("unable to load latest config", zap.Error(err))
return

View File

@ -36,7 +36,7 @@ type storVal struct {
// determineStorage returns the top-level storage module from the given config.
// It may return nil even if no error.
func determineStorage(configFile string, configAdapter string) (*storVal, error) {
cfg, _, err := LoadConfig(configFile, configAdapter)
cfg, _, _, err := LoadConfig(configFile, configAdapter)
if err != nil {
return nil, err
}

View File

@ -18,6 +18,7 @@ package caddy
import (
"context"
"errors"
"os"
"os/signal"
"syscall"
@ -48,7 +49,31 @@ func trapSignalsPosix() {
exitProcessFromSignal("SIGTERM")
case syscall.SIGUSR1:
Log().Info("not implemented", zap.String("signal", "SIGUSR1"))
logger := Log().With(zap.String("signal", "SIGUSR1"))
// If we know the last source config file/adapter (set when starting
// via `caddy run --config <file> --adapter <adapter>`), attempt
// to reload from that source. Otherwise, ignore the signal.
file, adapter, reloadCallback := getLastConfig()
if file == "" {
logger.Info("last config unknown, ignored SIGUSR1")
break
}
logger = logger.With(
zap.String("file", file),
zap.String("adapter", adapter))
if reloadCallback == nil {
logger.Warn("no reload helper available, ignored SIGUSR1")
break
}
logger.Info("reloading config from last-known source")
if err := reloadCallback(file, adapter); errors.Is(err, errReloadFromSourceUnavailable) {
// No reload helper available (likely not started via caddy run).
logger.Warn("reload from source unavailable in this process; ignored SIGUSR1")
} else if err != nil {
logger.Error("failed to reload config from file", zap.Error(err))
} else {
logger.Info("successfully reloaded config from file")
}
case syscall.SIGUSR2:
Log().Info("not implemented", zap.String("signal", "SIGUSR2"))