Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

admin: Secure socket for remote management #3994

Merged
merged 6 commits into from
Jan 27, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
563 changes: 451 additions & 112 deletions admin.go

Large diffs are not rendered by default.

176 changes: 151 additions & 25 deletions caddy.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ func changeConfig(method, path string, input []byte, forceReload bool) error {
newCfg, err := json.Marshal(rawCfg[rawConfigKey])
if err != nil {
return APIError{
Code: http.StatusBadRequest,
Err: fmt.Errorf("encoding new config: %v", err),
HTTPStatus: http.StatusBadRequest,
Err: fmt.Errorf("encoding new config: %v", err),
}
}

Expand All @@ -146,14 +146,14 @@ func changeConfig(method, path string, input []byte, forceReload bool) error {
err = indexConfigObjects(rawCfg[rawConfigKey], "/"+rawConfigKey, idx)
if err != nil {
return APIError{
Code: http.StatusInternalServerError,
Err: fmt.Errorf("indexing config: %v", err),
HTTPStatus: http.StatusInternalServerError,
Err: fmt.Errorf("indexing config: %v", err),
}
}

// load this new config; if it fails, we need to revert to
// our old representation of caddy's actual config
err = unsyncedDecodeAndRun(newCfg)
err = unsyncedDecodeAndRun(newCfg, true)
if err != nil {
if len(rawCfgJSON) > 0 {
// restore old config state to keep it consistent
Expand Down Expand Up @@ -233,8 +233,10 @@ func indexConfigObjects(ptr interface{}, configPath string, index map[string]str
// it as the new config, replacing any other current config.
// It does NOT update the raw config state, as this is a
// lower-level function; most callers will want to use Load
// instead. A write lock on currentCfgMu is required!
func unsyncedDecodeAndRun(cfgJSON []byte) error {
// instead. A write lock on currentCfgMu is required! If
// allowPersist is false, it will not be persisted to disk,
// even if it is configured to.
func unsyncedDecodeAndRun(cfgJSON []byte, allowPersist bool) error {
// remove any @id fields from the JSON, which would cause
// loading to break since the field wouldn't be recognized
strippedCfgJSON := RemoveMetaFields(cfgJSON)
Expand All @@ -245,6 +247,19 @@ func unsyncedDecodeAndRun(cfgJSON []byte) error {
return err
}

// prevent recursive config loads; that is a user error, and
// although frequent config loads should be safe, we cannot
// guarantee that in the presence of third party plugins, nor
// do we want this error to go unnoticed (we assume it was a
// pulled config if we're not allowed to persist it)
if !allowPersist &&
newCfg != nil &&
newCfg.Admin != nil &&
newCfg.Admin.Config != nil &&
newCfg.Admin.Config.LoadRaw != nil {
return fmt.Errorf("recursive config loading detected: pulled configs cannot pull other configs")
}

// run the new config and start all its apps
err = run(newCfg, true)
if err != nil {
Expand All @@ -259,7 +274,8 @@ func unsyncedDecodeAndRun(cfgJSON []byte) error {
unsyncedStop(oldCfg)

// autosave a non-nil config, if not disabled
if newCfg != nil &&
if allowPersist &&
newCfg != nil &&
(newCfg.Admin == nil ||
newCfg.Admin.Config == nil ||
newCfg.Admin.Config.Persist == nil ||
Expand Down Expand Up @@ -311,14 +327,14 @@ func run(newCfg *Config, start bool) error {

// start the admin endpoint (and stop any prior one)
if start {
err = replaceAdmin(newCfg)
err = replaceLocalAdminServer(newCfg)
if err != nil {
return fmt.Errorf("starting caddy administration endpoint: %v", err)
}
}

if newCfg == nil {
return nil
newCfg = new(Config)
}

// prepare the new config for use
Expand Down Expand Up @@ -400,7 +416,7 @@ func run(newCfg *Config, start bool) error {
}

// Start
return func() error {
err = func() error {
var started []string
for name, a := range newCfg.apps {
err := a.Start()
Expand All @@ -420,6 +436,64 @@ func run(newCfg *Config, start bool) error {
}
return nil
}()
if err != nil {
return err
}

// now that the user's config is running, finish setting up anything else,
// such as remote admin endpoint, config loader, etc.
return finishSettingUp(ctx, newCfg)
}

// finishSettingUp should be run after all apps have successfully started.
func finishSettingUp(ctx Context, cfg *Config) error {
// establish this server's identity (only after apps are loaded
// so that cert management of this endpoint doesn't prevent user's
// servers from starting which likely also use HTTP/HTTPS ports;
// but before remote management which may depend on these creds)
err := manageIdentity(ctx, cfg)
if err != nil {
return fmt.Errorf("provisioning remote admin endpoint: %v", err)
}

// replace any remote admin endpoint
err = replaceRemoteAdminServer(ctx, cfg)
if err != nil {
return fmt.Errorf("provisioning remote admin endpoint: %v", err)
}

// if dynamic config is requested, set that up and run it
if cfg != nil && cfg.Admin != nil && cfg.Admin.Config != nil && cfg.Admin.Config.LoadRaw != nil {
val, err := ctx.LoadModule(cfg.Admin.Config, "LoadRaw")
if err != nil {
return fmt.Errorf("loading config loader module: %s", err)
}
loadedConfig, err := val.(ConfigLoader).LoadConfig(ctx)
if err != nil {
return fmt.Errorf("loading dynamic config from %T: %v", val, err)
}

// do this in a goroutine so current config can finish being loaded; otherwise deadlock
go func() {
Log().Info("applying dynamically-loaded config", zap.String("loader_module", val.(Module).CaddyModule().ID.Name()))
currentCfgMu.Lock()
err := unsyncedDecodeAndRun(loadedConfig, false)
currentCfgMu.Unlock()
if err == nil {
Log().Info("dynamically-loaded config applied successfully")
} else {
Log().Error("running dynamically-loaded config failed", zap.Error(err))
}
}()
}

return nil
}

// ConfigLoader is a type that can load a Caddy config. The
// returned config must be valid Caddy JSON.
type ConfigLoader interface {
LoadConfig(Context) ([]byte, error)
}

// Stop stops running the current configuration.
Expand Down Expand Up @@ -462,20 +536,6 @@ func unsyncedStop(cfg *Config) {
cfg.cancelFunc()
}

// stopAndCleanup calls stop and cleans up anything
// else that is expedient. This should only be used
// when stopping and not replacing with a new config.
func stopAndCleanup() error {
if err := Stop(); err != nil {
return err
}
certmagic.CleanUpOwnLocks()
if pidfile != "" {
return os.Remove(pidfile)
}
return nil
}

// Validate loads, provisions, and validates
// cfg, but does not start running it.
func Validate(cfg *Config) error {
Expand All @@ -486,6 +546,72 @@ func Validate(cfg *Config) error {
return err
}

// exitProcess exits the process as gracefully as possible,
// but it always exits, even if there are errors doing so.
// It stops all apps, cleans up external locks, removes any
// PID file, and shuts down admin endpoint(s) in a goroutine.
// Errors are logged along the way, and an appropriate exit
// code is emitted.
func exitProcess(logger *zap.Logger) {
if logger == nil {
logger = Log()
}
logger.Warn("exiting; byeee!! 👋")

exitCode := ExitCodeSuccess

// stop all apps
if err := Stop(); err != nil {
logger.Error("failed to stop apps", zap.Error(err))
exitCode = ExitCodeFailedQuit
}

// clean up certmagic locks
certmagic.CleanUpOwnLocks(logger)

// remove pidfile
if pidfile != "" {
err := os.Remove(pidfile)
if err != nil {
logger.Error("cleaning up PID file:",
zap.String("pidfile", pidfile),
zap.Error(err))
exitCode = ExitCodeFailedQuit
}
}

// shut down admin endpoint(s) in goroutines so that
// if this function was called from an admin handler,
// it has a chance to return gracefully
// use goroutine so that we can finish responding to API request
go func() {
defer func() {
logger = logger.With(zap.Int("exit_code", exitCode))
if exitCode == ExitCodeSuccess {
logger.Info("shutdown complete")
} else {
logger.Error("unclean shutdown")
}
os.Exit(exitCode)
}()

if remoteAdminServer != nil {
err := stopAdminServer(remoteAdminServer)
if err != nil {
exitCode = ExitCodeFailedQuit
logger.Error("failed to stop remote admin server gracefully", zap.Error(err))
}
}
if localAdminServer != nil {
err := stopAdminServer(localAdminServer)
if err != nil {
exitCode = ExitCodeFailedQuit
logger.Error("failed to stop local admin server gracefully", zap.Error(err))
}
}
}()
}

// Duration can be an integer or a string. An integer is
// interpreted as nanoseconds. If a string, it is a Go
// time.Duration value such as `300ms`, `1.5h`, or `2h45m`;
Expand Down
8 changes: 8 additions & 0 deletions caddyconfig/configadapters.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ type Warning struct {
Message string `json:"message,omitempty"`
}

func (w Warning) String() string {
var directive string
if w.Directive != "" {
directive = fmt.Sprintf(" (%s)", w.Directive)
}
return fmt.Sprintf("%s:%d%s: %s", w.File, w.Line, directive, w.Message)
}

// JSON encodes val as JSON, returning it as a json.RawMessage. Any
// marshaling errors (which are highly unlikely with correct code)
// are converted to warnings. This is convenient when filling config
Expand Down
Loading