Skip to content
Open
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
17 changes: 17 additions & 0 deletions admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,23 @@ func (admin AdminConfig) allowedOrigins(addr NetworkAddress) []*url.URL {
return allowed
}

// LocalAdminAddress returns the configured local admin API listen address
// and whether it is enabled.
func (ctx Context) LocalAdminAddress() (NetworkAddress, bool, error) {
if !ctx.validateAdmin {
return NetworkAddress{}, false, nil
}
if ctx.cfg != nil && ctx.cfg.Admin != nil && ctx.cfg.Admin.Disabled {
return NetworkAddress{}, false, nil
}
listen := DefaultAdminListen
if ctx.cfg != nil && ctx.cfg.Admin != nil {
listen = ctx.cfg.Admin.Listen
}
addr, err := parseAdminListenAddr(listen, DefaultAdminListen)
return addr, true, err
}

// replaceLocalAdminServer replaces the running local admin server
// according to the relevant configuration in cfg. If no configuration
// for the admin endpoint exists in cfg, a default one is used, so
Expand Down
55 changes: 55 additions & 0 deletions admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,61 @@ var testCfg = []byte(`{
}
`)

func TestContextLocalAdminAddress(t *testing.T) {
for _, tc := range []struct {
name string
ctx Context
wantAddress string
wantEnabled bool
}{
{
name: "provision-only context does not validate admin address",
ctx: Context{cfg: &Config{}},
},
{
name: "validation context uses default admin address",
ctx: Context{cfg: &Config{}, validateAdmin: true},
wantAddress: DefaultAdminListen,
wantEnabled: true,
},
{
name: "disabled admin is not validated",
ctx: Context{
cfg: &Config{Admin: &AdminConfig{Disabled: true}},
validateAdmin: true,
},
},
} {
t.Run(tc.name, func(t *testing.T) {
addr, enabled, err := tc.ctx.LocalAdminAddress()
if err != nil {
t.Fatal(err)
}
if enabled != tc.wantEnabled {
t.Fatalf("enabled: got %t, want %t", enabled, tc.wantEnabled)
}
if enabled && addr.String() != tc.wantAddress {
t.Fatalf("address: got %q, want %q", addr, tc.wantAddress)
}
})
}
}

func TestDerivedContextPreservesAdminValidation(t *testing.T) {
ctx := Context{Context: context.Background(), cfg: &Config{}, validateAdmin: true}

child, cancel := NewContext(ctx)
cancel()
if _, enabled, err := child.LocalAdminAddress(); err != nil || !enabled {
t.Fatalf("NewContext did not preserve admin validation: enabled=%t, err=%v", enabled, err)
}

withValue := ctx.WithValue("test-key", "test-value")
if _, enabled, err := withValue.LocalAdminAddress(); err != nil || !enabled {
t.Fatalf("WithValue did not preserve admin validation: enabled=%t, err=%v", enabled, err)
}
}

type testAdminPublicKey string

func (k testAdminPublicKey) Equal(x crypto.PublicKey) bool {
Expand Down
69 changes: 38 additions & 31 deletions caddy.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ func unsyncedDecodeAndRun(cfgJSON []byte, allowPersist bool) error {
// will want to use Run instead, which also
// updates the config's raw state.
func run(newCfg *Config, start bool) (Context, error) {
ctx, err := provisionContext(newCfg, start)
ctx, err := provisionContext(newCfg, true)
if err != nil {
globalMetrics.configSuccess.Set(0)
return ctx, err
Expand All @@ -441,28 +441,35 @@ func run(newCfg *Config, start bool) (Context, error) {
}()

// Start
err = func() error {
started := make([]string, 0, len(ctx.cfg.apps))
for name, a := range ctx.cfg.apps {
err := a.Start()
if err != nil {
// an app failed to start, so we need to stop
// all other apps that were already started
for _, otherAppName := range started {
err2 := ctx.cfg.apps[otherAppName].Stop()
if err2 != nil {
err = fmt.Errorf("%v; additionally, aborting app %s: %v",
err, otherAppName, err2)
}
started := make([]string, 0, len(ctx.cfg.apps))
for name, a := range ctx.cfg.apps {
err = a.Start()
if err != nil {
// an app failed to start, so we need to stop
// all other apps that were already started
for _, otherAppName := range started {
err2 := ctx.cfg.apps[otherAppName].Stop()
if err2 != nil {
err = fmt.Errorf("%v; additionally, aborting app %s: %v",
err, otherAppName, err2)
}
return fmt.Errorf("%s app module: start: %v", name, err)
}
started = append(started, name)
return ctx, fmt.Errorf("%s app module: start: %v", name, err)
}
return nil
}()
started = append(started, name)
}

// replace the local admin endpoint only after every app has started;
// otherwise a rejected config could replace the admin endpoint while the
// previous app config remains active
err = replaceLocalAdminServer(ctx.cfg, ctx)
if err != nil {
return ctx, err
for _, appName := range started {
if stopErr := ctx.cfg.apps[appName].Stop(); stopErr != nil {
err = fmt.Errorf("%v; additionally, aborting app %s: %v", err, appName, stopErr)
}
}
return ctx, fmt.Errorf("starting caddy administration endpoint: %v", err)
}
globalMetrics.configSuccess.Set(1)
globalMetrics.configSuccessTime.SetToCurrentTime()
Expand All @@ -479,9 +486,9 @@ func run(newCfg *Config, start bool) (Context, error) {
// provisionContext creates a new context from the given configuration and provisions
// storage and apps.
// If `newCfg` is nil a new empty configuration will be created.
// If `replaceAdminServer` is true any currently active admin server will be replaced
// with a new admin server based on the provided configuration.
func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error) {
// If `validateAdmin` is true apps may validate their listeners against the
// configured admin listener.
func provisionContext(newCfg *Config, validateAdmin bool) (Context, error) {
// because we will need to roll back any state
// modifications if this function errors, we
// keep a single error value and scope all
Expand All @@ -501,7 +508,11 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error)
// cleanup occurs when we return if there
// was an error; if no error, it will get
// cleaned up on next config cycle
ctx, cancelCause := NewContextWithCause(Context{Context: context.Background(), cfg: newCfg})
ctx, cancelCause := NewContextWithCause(Context{
Context: context.Background(),
cfg: newCfg,
validateAdmin: validateAdmin,
})
defer func() {
if err != nil {
globalMetrics.configSuccess.Set(0)
Expand Down Expand Up @@ -561,14 +572,6 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error)
return ctx, err
}

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

// Load and Provision each app and their submodules
err = func() error {
for appName := range newCfg.AppsRaw {
Expand All @@ -578,6 +581,10 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error)
}
return nil
}()
if err != nil {
return ctx, err
}

return ctx, err
}

Expand Down
9 changes: 8 additions & 1 deletion context.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type Context struct {

moduleInstances map[string][]Module
cfg *Config
validateAdmin bool
ancestry []Module
cleanupFuncs []func() // invoked at every config unload
exitFuncs []func(context.Context) // invoked at config unload ONLY IF the process is exiting (EXPERIMENTAL)
Expand All @@ -70,7 +71,12 @@ func NewContext(ctx Context) (Context, context.CancelFunc) {
// NewContextWithCause is like NewContext but returns a context.CancelCauseFunc.
// EXPERIMENTAL: This API is subject to change.
func NewContextWithCause(ctx Context) (Context, context.CancelCauseFunc) {
newCtx := Context{moduleInstances: make(map[string][]Module), cfg: ctx.cfg, metricsRegistry: prometheus.NewPedanticRegistry()}
newCtx := Context{
moduleInstances: make(map[string][]Module),
cfg: ctx.cfg,
validateAdmin: ctx.validateAdmin,
metricsRegistry: prometheus.NewPedanticRegistry(),
}
c, cancel := context.WithCancelCause(ctx.Context)
wrappedCancel := func(cause error) {
cancel(cause)
Expand Down Expand Up @@ -673,6 +679,7 @@ func (ctx *Context) WithValue(key, value any) Context {
Context: context.WithValue(ctx.Context, key, value),
moduleInstances: ctx.moduleInstances,
cfg: ctx.cfg,
validateAdmin: ctx.validateAdmin,
ancestry: ctx.ancestry,
cleanupFuncs: ctx.cleanupFuncs,
exitFuncs: ctx.exitFuncs,
Expand Down
28 changes: 28 additions & 0 deletions listeners.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,34 @@ func (na NetworkAddress) PortRangeSize() uint {
return (na.EndPort - na.StartPort) + 1
}

// OverlapsWith reports whether na and other use the same Caddy listener.
// It compares configured listener identity rather than resolving hostnames or
// predicting platform-specific IP wildcard behaviour. Unix socket types share
// the same path namespace, so equal normalized paths overlap across types.
func (na NetworkAddress) OverlapsWith(other NetworkAddress) bool {
if na.IsUnixNetwork() || other.IsUnixNetwork() {
return na.IsUnixNetwork() && other.IsUnixNetwork() && na.bindPath() == other.bindPath()
}
Comment thread
steadytao marked this conversation as resolved.
if na.IsFdNetwork() || other.IsFdNetwork() {
return na.IsFdNetwork() && other.IsFdNetwork() && na.Host == other.Host
}
return na.Network == other.Network &&
na.Host == other.Host &&
na.StartPort <= other.EndPort &&
other.StartPort <= na.EndPort
}

func (na NetworkAddress) bindPath() string {
if !na.IsUnixNetwork() {
return na.Host
}
path, _, err := internal.SplitUnixSocketPermissionsBits(na.Host)
if err != nil {
return na.Host
}
return path
}

func (na NetworkAddress) isLoopback() bool {
if na.IsUnixNetwork() || na.IsFdNetwork() {
return true
Expand Down
90 changes: 90 additions & 0 deletions listeners_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -710,3 +710,93 @@ func TestSplitUnixSocketPermissionsBits(t *testing.T) {
}
}
}

func TestOverlapsWith(t *testing.T) {
for i, tc := range []struct {
a, b NetworkAddress
expect bool
}{
{
a: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2019, EndPort: 2019},
b: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2019, EndPort: 2019},
expect: true,
},
{
a: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2019, EndPort: 2020},
b: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2020, EndPort: 2021},
expect: true,
},
{
a: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2019, EndPort: 2019},
b: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2020, EndPort: 2020},
expect: false,
},
{
a: NetworkAddress{Network: "tcp", Host: "127.0.0.1"},
b: NetworkAddress{Network: "tcp", Host: "127.0.0.1"},
expect: true,
},
{
a: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2019, EndPort: 2019},
b: NetworkAddress{Network: "tcp", Host: "", StartPort: 2019, EndPort: 2019},
expect: false,
},
{
a: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2019, EndPort: 2019},
b: NetworkAddress{Network: "tcp", Host: "::1", StartPort: 2019, EndPort: 2019},
expect: false,
},
{
a: NetworkAddress{Network: "plugin", Host: "same", StartPort: 2019, EndPort: 2019},
b: NetworkAddress{Network: "plugin6", Host: "same", StartPort: 2019, EndPort: 2019},
expect: false,
},
{
a: NetworkAddress{Network: "tcp4", Host: "::1", StartPort: 2019, EndPort: 2019},
b: NetworkAddress{Network: "tcp", Host: "::1", StartPort: 2019, EndPort: 2019},
expect: false,
},
{
a: NetworkAddress{Network: "tcp6", Host: "127.0.0.1", StartPort: 2019, EndPort: 2019},
b: NetworkAddress{Network: "tcp", Host: "127.0.0.1", StartPort: 2019, EndPort: 2019},
expect: false,
},
{
a: NetworkAddress{Network: "unix", Host: "/run/caddy/admin.sock"},
b: NetworkAddress{Host: "127.0.0.1", StartPort: 2019, EndPort: 2019},
expect: false,
},
{
a: NetworkAddress{Network: "unix", Host: "/run/caddy/admin.sock"},
b: NetworkAddress{Network: "unix", Host: "/run/caddy/admin.sock"},
expect: true,
},
{
a: NetworkAddress{Network: "unix", Host: "/run/caddy/admin.sock|0222"},
b: NetworkAddress{Network: "unix", Host: "/run/caddy/admin.sock|0777"},
expect: true,
},
{
a: NetworkAddress{Network: "unix", Host: "/run/caddy/admin.sock"},
b: NetworkAddress{Network: "unixpacket", Host: "/run/caddy/admin.sock"},
expect: true,
},
{
a: NetworkAddress{Network: "fd", Host: "3"},
b: NetworkAddress{Network: "fdgram", Host: "3"},
expect: true,
},
} {
assertOverlap(t, i, tc.a, tc.b, tc.expect)
}
}

func assertOverlap(t *testing.T, i int, a, b NetworkAddress, expect bool) {
t.Helper()
if got := a.OverlapsWith(b); got != expect {
t.Errorf("Test %d: expected %v for %v vs %v, got %v", i, expect, a, b, got)
}
if got := b.OverlapsWith(a); got != expect {
t.Errorf("Test %d: expected %v (symmetric) for %v vs %v, got %v", i, expect, b, a, got)
}
}
Loading
Loading