feat(cli): keep serving DNS through Control D API maintenance

A hard maintenance window took DNS down on endpoints that already had a
working configuration: the bootstrap preflight read the maintenance answer
as a failed fetch and exited, turning a temporary API outage into an
endpoint outage.

Startup now falls back to the configuration on disk when a content- and
identity-bound marker records that the API produced it. Without one, ctrld
stops with API_MAINTENANCE (exit 37). Maintenance never self-uninstalls or
counts as a permanent rejection, a fallback run retries every 5-7 minutes
until a recovered configuration is applied, and deactivation is refused
with 503 while the PIN cannot be verified.

Master adaptation: pending-recovery and PIN-known handling live in
applyFetchedResolverConfig; logging uses *ctrld.Logger; the reload fetch
seam is fetchResolverConfigFn; the control-server and self-uninstall
probes use the existing fetchResolverConfig seam with loggerCtx.
This commit is contained in:
Cuong Manh Le committed 2026-10-07 14:17:46 +07:00
1 parent 7c1184a799
commit c7b2134977
10 files changed
+1339 -21

No files matched your search

+251
View File
@@ -0,0 +1,251 @@
package cli
import (
"crypto/sha256"
"encoding/hex"
"math/rand"
"os"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
// configReadFromDisk records that startup read a configuration file this host
// already had, instead of generating a default one because none existed.
//
// The distinction only matters while the API is in maintenance. A generated
// default carries the public freedns upstreams, so falling back to it would
// answer queries outside the Control D profile the operator provisioned —
// quietly serving a policy nobody chose, which is worse than not starting.
// Only a file the host already had can stand in for the configuration the API
// would have returned.
var configReadFromDisk bool
// maintenanceNoConfigDetail is what ctrld reports when maintenance stops a
// start that has nothing to fall back on. It names the operation that cannot
// happen rather than the fetch that failed, because "could not fetch resolver
// config" reads as a network fault and sends the reader after connectivity
// that is not broken.
const maintenanceNoConfigDetail = "cannot provision during Control D API maintenance: " +
"this host has no previously working configuration to fall back on. Retry once maintenance ends."
// managedConfigMarkerFile records that the configuration file next to it was
// written from an API answer, and for which identity.
//
// File presence cannot carry this on its own. A start that has no
// configuration writes the seeded default before the API is ever reached, so a
// provisioning that failed leaves a file behind that the next start reads
// successfully. That file holds the public freedns upstreams, so trusting it
// would run the endpoint outside the Control D profile it was provisioned for
// — the exact outcome the fallback exists to avoid.
const managedConfigMarkerFile = ".managed_config"
// managedConfigMarkerPath is a var so tests can place the marker in a temp dir.
var managedConfigMarkerPath = func() string { return absHomeDir(managedConfigMarkerFile) }
// managedConfigMarker fingerprints both the identity and the configuration file
// the marker is recorded for.
//
// The identity alone cannot carry it. A marker that names only the identity
// outlives the file it vouched for: delete or move that file and the next start
// regenerates the seeded default, which the stale marker then certifies. The
// same happens when --config points somewhere else under the same home. Binding
// to the contents means any file the API did not produce fails the check,
// without having to detect how it got there.
//
// The raw identity is kept off disk: it is redacted from retained logs, and a
// marker only ever has to answer whether this is the same identity.
func managedConfigMarker(uid, path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
identity := sha256.Sum256([]byte(uid))
contents := sha256.Sum256(data)
return hex.EncodeToString(identity[:]) + ":" + hex.EncodeToString(contents[:]), nil
}
// recordManagedConfig notes that the configuration on disk came from the API
// for uid. The caller must have persisted that configuration first, or the
// marker would vouch for a file that does not match it.
func recordManagedConfig(uid string) error {
if uid == "" {
return nil
}
marker, err := managedConfigMarker(uid, defaultConfigFile)
if err != nil {
return err
}
return os.WriteFile(managedConfigMarkerPath(), []byte(marker), 0600)
}
// managedConfigRecordedFor reports whether the configuration file in use was
// written from an API answer for uid.
func managedConfigRecordedFor(uid string) bool {
if uid == "" {
return false
}
recorded, err := os.ReadFile(managedConfigMarkerPath())
if err != nil {
return false
}
want, err := managedConfigMarker(uid, defaultConfigFile)
if err != nil {
return false
}
return strings.TrimSpace(string(recorded)) == want
}
// lastKnownConfigUsable reports whether cfg can carry this start by itself.
//
// It is deliberately quiet: this is a probe, and the caller reports either
// outcome. Startup validates the configuration again further on and reports
// its own errors there, so a failure here must not also print them.
func lastKnownConfigUsable(cfg *ctrld.Config) bool {
if !configReadFromDisk || cfg == nil {
return false
}
// A file this host already had is necessary but not sufficient: it must
// also be one the API produced, for the identity being asked for now.
if !managedConfigRecordedFor(cdUID) {
return false
}
return ctrld.ValidateConfig(validator.New(), cfg) == nil
}
// useLastKnownConfigDuringMaintenance reports whether startup may continue on
// the configuration already on disk because the API is in maintenance.
//
// Maintenance says nothing about this host's configuration — the same request
// succeeds once the window ends — so stopping DNS for its duration turns a
// temporary API outage into an endpoint outage. Continuing is only honest when
// the configuration on disk is one the API itself produced on an earlier run:
// configReadFromDisk establishes that this host already had the file, and the
// managed-config marker that the API produced it for this identity. The marker
// is the load-bearing half - a file alone can be a generated default.
//
// The resolver config stays nil: this run has none, and the reload path
// already treats a nil one as "no previous response to compare against", so
// the first successful reload after maintenance adopts the fresh answer.
func useLastKnownConfigDuringMaintenance(err error, cfg *ctrld.Config) bool {
if !controld.IsMaintenance(err) {
return false
}
logger := mainLog.Load().With().Str("mode", "cd")
if !lastKnownConfigUsable(cfg) {
journal(logger.Error()).Str("reason", "api_maintenance").
Msg("Control D API is in maintenance and no previously working configuration is on disk")
return false
}
journal(logger.Warn()).Str("reason", "api_maintenance").
Msg("Control D API is in maintenance; starting with the last known working configuration on disk")
return true
}
// logResolverConfigFetchFailure reports a resolver-config fetch that failed on
// a path which keeps serving DNS afterwards: the periodic reload and the
// self-uninstall probe.
//
// Maintenance gets its own line, and it is journaled. "could not fetch
// resolver config" reads as a fault on this host and sends the reader after
// connectivity that is not broken, which is how the reported incident was
// first triaged. It also leaves a later "why is my configuration stale"
// question unanswerable once the debug stream has rotated. The reload runs
// hourly by default, so one retained line per window is bounded.
func logResolverConfigFetchFailure(logger *ctrld.Logger, err error) {
if controld.IsMaintenance(err) {
journal(logger.Warn()).Err(err).Str("reason", "api_maintenance").
Msg("Control D API is in maintenance; keeping the current configuration until it returns")
return
}
logger.Warn().Err(err).Msg("Could not fetch resolver config")
}
const (
// maintenanceReloadInterval is how soon a run that started on the
// configuration on disk asks the API again. It is deliberately far below
// refetch_time: such a run holds no answer from the API at all, so its
// exclude list, Internal Domains, deactivation pin and self-upgrade check
// are all still those of the previous run until one arrives.
maintenanceReloadInterval = 5 * time.Minute
// maintenanceReloadJitter spreads those retries across the hosts that
// started during the same window.
//
// The hourly ticker needs no jitter because each host starts it at its own
// boot time, which spreads the fleet on its own. A maintenance window
// removes that spread: every host that falls back does so within the same
// few minutes, so a fixed retry would put them all in lockstep and land as
// one burst on the API exactly as it comes back.
maintenanceReloadJitter = 2 * time.Minute
)
// maintenanceReloadDelay returns the wait before the next retry of a run that
// started on the configuration on disk. It is a var so a test can drive the
// reload loop without waiting out a real interval.
var maintenanceReloadDelay = func() time.Duration {
return maintenanceReloadInterval + time.Duration(rand.Int63n(int64(maintenanceReloadJitter)))
}
// maintenanceConfigApplied records that a run which started on the
// configuration on disk has handed an answer from the API to the reload path.
//
// It is deliberately not called when the fetch succeeds, nor when the response
// is handed to the reload. The reload receiver reads the configuration and
// fetches again, and either step can fail; until it does not, the endpoint is
// still serving what it started with. Clearing the flag earlier would stop the
// faster retry and report a recovery that had not happened.
func (p *prog) maintenanceConfigApplied(logger *ctrld.Logger) {
if p.startedInAPIMaintenance.CompareAndSwap(true, false) {
journal(logger.Info()).Str("reason", "api_maintenance").
Msg("Control D API answered again; this run applied the configuration it returned")
}
}
// persistManagedConfig writes cfg and, on a run the API manages, records that
// the file it just wrote came from there.
//
// The two belong together. The marker is bound to the file's contents, and
// every API-driven change rewrites that file, so a write that does not refresh
// the marker leaves the host ineligible for the very fallback its own
// configuration is meant to provide - including recovery from a maintenance
// start. A failed write certifies nothing, and a run with no resolver UID
// writes nothing the API produced: generated defaults and NextDNS
// configurations must never be certified.
func persistManagedConfig(cfg *ctrld.Config, logger *ctrld.Logger) {
if err := writeConfigFile(cfg); err != nil {
logger.Error().Err(err).Msg("Could not write new config")
return
}
if cdUID == "" {
return
}
if err := recordManagedConfig(cdUID); err != nil {
logger.Warn().Err(err).Msg("could not record that this configuration came from the API")
}
}
// recordManagedConfigForStart refreshes the marker once a start has finished
// writing its configuration.
//
// fetchedThisRun is whether the API answered this run. wasManagedBeforeWrite is
// whether the file already carried a marker for this identity before anything
// this start rewrote it. Either is enough: a maintenance fallback fetches
// nothing, yet still rewrites the file through the listener and intercept-mode
// updates, and the marker is bound to the file's contents - so without the
// second the next start in the same window loses the fallback this one used.
// Neither means the file is a generated default or otherwise unmanaged, which
// must never be certified.
func recordManagedConfigForStart(fetchedThisRun, wasManagedBeforeWrite bool) {
if !fetchedThisRun && !wasManagedBeforeWrite {
return
}
if err := recordManagedConfig(cdUID); err != nil {
// Not fatal: this run is fine. It only costs the next start its
// fallback if the API is in maintenance then.
mainLog.Load().Warn().Err(err).Msg("could not record that this configuration came from the API")
}
}
+544
View File
@@ -0,0 +1,544 @@
package cli
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/spf13/viper"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
// TestMaintenanceFallbackRejectsGeneratedDefaultAcrossStarts covers the gap a
// boolean set by hand cannot: a first start with no configuration writes the
// seeded default before the API is ever reached, so a provisioning that failed
// leaves a file the next start reads successfully. Without the managed-config
// marker that file is accepted as a "last known configuration" and the
// endpoint runs on the public freedns upstreams instead of its profile.
func TestMaintenanceFallbackRejectsGeneratedDefaultAcrossStarts(t *testing.T) {
dir := t.TempDir()
oldV, oldFile, oldPath := v, defaultConfigFile, configPath
oldRead, oldUID, oldMarker := configReadFromDisk, cdUID, managedConfigMarkerPath
t.Cleanup(func() {
v, defaultConfigFile, configPath = oldV, oldFile, oldPath
configReadFromDisk, cdUID, managedConfigMarkerPath = oldRead, oldUID, oldMarker
})
configPath = ""
cdUID = "provisioned-uid"
managedConfigMarkerPath = func() string { return filepath.Join(dir, managedConfigMarkerFile) }
cfgPath := filepath.Join(dir, "ctrld.toml")
// One process start, using the real config reader and default generation.
start := func(t *testing.T) (*ctrld.Config, bool) {
t.Helper()
v = viper.NewWithOptions(viper.KeyDelimiter("::"))
ctrld.InitConfig(v, "ctrld")
ctrld.SetConfigNameWithPath(v, "ctrld", dir)
defaultConfigFile = cfgPath
configReadFromDisk = false
// writeDefaultConfig is false: the file exists by now, and default
// generation is covered above.
readConfigFile(false, false)
started := &ctrld.Config{}
if err := v.Unmarshal(started); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return started, lastKnownConfigUsable(started)
}
// The failed first start: no configuration existed, so ctrld generated the
// seeded default and wrote it. Reproduced by writing exactly what
// readConfigFile writes there, minus its listener probe, which binds
// privileged ports and cannot run in a test. What matters for the guard is
// the file's content and that no read set configReadFromDisk.
v = viper.NewWithOptions(viper.KeyDelimiter("::"))
ctrld.InitConfig(v, "ctrld")
defaultConfigFile = cfgPath
configReadFromDisk = false
generated := &ctrld.Config{}
if err := v.Unmarshal(generated); err != nil {
t.Fatalf("unmarshal defaults: %v", err)
}
if err := writeConfigFile(generated); err != nil {
t.Fatalf("write generated default: %v", err)
}
if configReadFromDisk {
t.Fatal("generating a default must not count as reading one")
}
if lastKnownConfigUsable(generated) {
t.Fatal("the first start accepted a configuration it had just generated")
}
// Prove the generated default is the thing that must never be trusted.
var endpoints []string
for _, uc := range generated.Upstream {
endpoints = append(endpoints, uc.Endpoint)
}
if !strings.Contains(strings.Join(endpoints, " "), "freedns.controld.com") {
t.Fatalf("expected the seeded freedns default, got %v", endpoints)
}
// Second start: the leftover file now reads back through the real reader,
// but no API answer ever produced it.
second, ok := start(t)
if !configReadFromDisk {
t.Fatal("the leftover file was not read, so the boundary is not being exercised")
}
if ok {
t.Fatal("a generated default was accepted as a last known configuration on a later start")
}
// A genuine provisioning records the marker; the fallback then applies.
if err := recordManagedConfig(cdUID); err != nil {
t.Fatalf("recordManagedConfig: %v", err)
}
if _, ok := start(t); !ok {
t.Fatal("a configuration the API produced was rejected")
}
// The marker belongs to one identity and must not carry to another.
cdUID = "a-different-uid"
if _, ok := start(t); ok {
t.Fatal("a marker for another identity authorized this one")
}
_ = second
}
// TestMaintenanceRecoveryDeliversReload covers what recovery has to mean. The
// first answer after a maintenance start is the only one that differs from the
// nil configuration the run holds; if it is compared away, every later answer
// equals the stored one and the excludes and Internal Domains of the previous
// run stay in place for good.
func TestMaintenanceRecoveryDeliversReload(t *testing.T) {
oldFetch, oldDelay, oldUID := fetchResolverConfigFn, maintenanceReloadDelay, cdUID
t.Cleanup(func() {
fetchResolverConfigFn, maintenanceReloadDelay, cdUID = oldFetch, oldDelay, oldUID
})
cdUID = "test-uid"
maintenanceReloadDelay = func() time.Duration { return time.Millisecond }
var calls atomic.Int64
fetchResolverConfigFn = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
calls.Add(1)
// A plain answer: no custom config, and excludes the run has never seen.
return &controld.ResolverConfig{Exclude: []string{"excluded.example"}}, nil
}
p := &prog{
cfg: &ctrld.Config{Service: ctrld.ServiceConfig{RefetchTime: ptrTo(3600)}},
stopCh: make(chan struct{}),
runAbortCh: make(chan struct{}),
apiForceReloadCh: make(chan struct{}),
apiReloadCh: make(chan *ctrld.Config),
}
p.logger.Store(discardLogger())
p.startedInAPIMaintenance.Store(true)
done := make(chan struct{})
go func() { defer close(done); p.apiConfigReload() }()
t.Cleanup(func() {
close(p.stopCh)
<-done
})
select {
case <-p.apiReloadCh:
// Stand in for the reload receiver applying it.
p.maintenanceConfigApplied(discardLogger())
case <-time.After(10 * time.Second):
t.Fatal("recovery never delivered a reload, so the endpoint kept the configuration it started with")
}
deadline := time.Now().Add(5 * time.Second)
for p.startedInAPIMaintenance.Load() {
if time.Now().After(deadline) {
t.Fatal("the run stayed in maintenance after applying the configuration")
}
time.Sleep(time.Millisecond)
}
if calls.Load() == 0 {
t.Fatal("no fetch happened")
}
}
// TestDeactivationPinUnknownDuringMaintenance covers the PIN state a
// maintenance start leaves behind. cdDeactivationPin's default means "the API
// says there is none"; a run that never asked must not present that as
// permission to deactivate.
func TestDeactivationPinUnknownDuringMaintenance(t *testing.T) {
oldUID, oldPin, oldKnown := cdUID, cdDeactivationPin.Load(), deactivationPinKnown.Load()
t.Cleanup(func() {
cdUID = oldUID
cdDeactivationPin.Store(oldPin)
deactivationPinKnown.Store(oldKnown)
deactivationLockedUntil.Store(0)
})
cdUID = "test-uid"
deactivationLockedUntil.Store(0)
// The handler re-fetches before it checks the PIN. Without this the test
// reaches the production API with a made-up uid, and the statuses below
// would depend on that call failing rather than on the maintenance answer
// the case is about.
oldFetch := fetchResolverConfig
t.Cleanup(func() { fetchResolverConfig = oldFetch })
var fetches atomic.Int64
fetchResolverConfig = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
fetches.Add(1)
return nil, maintenanceAPIErr(http.StatusServiceUnavailable)
}
post := func(t *testing.T) int {
t.Helper()
p := &prog{cs: &controlServer{mux: http.NewServeMux()}, pinCodeValidCh: make(chan struct{}, 1)}
p.logger.Store(discardLogger())
p.registerControlServerHandler()
body, _ := json.Marshal(&deactivationRequest{Pin: defaultDeactivationPin})
req := httptest.NewRequest(http.MethodPost, deactivationPath, strings.NewReader(string(body)))
w := httptest.NewRecorder()
p.cs.mux.ServeHTTP(w, req)
return w.Code
}
t.Run("a run that never asked refuses", func(t *testing.T) {
cdDeactivationPin.Store(defaultDeactivationPin)
deactivationPinKnown.Store(false)
if got := post(t); got != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d: an unknown PIN authorized deactivation", got, http.StatusServiceUnavailable)
}
})
t.Run("a confirmed no-PIN configuration still allows it", func(t *testing.T) {
cdDeactivationPin.Store(defaultDeactivationPin)
deactivationPinKnown.Store(true)
if got := post(t); got != http.StatusOK {
t.Fatalf("status = %d, want 200: a device with no PIN can no longer be deactivated", got)
}
})
t.Run("an answering API settles the question", func(t *testing.T) {
// The re-fetch is what makes the PIN knowable again mid-run, so the
// stub has to be able to answer, not only fail.
fetchResolverConfig = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
return &controld.ResolverConfig{}, nil
}
cdDeactivationPin.Store(defaultDeactivationPin)
deactivationPinKnown.Store(false)
if got := post(t); got != http.StatusOK {
t.Fatalf("status = %d, want 200: an API answer did not settle the PIN", got)
}
if !deactivationPinKnown.Load() {
t.Fatal("an API answer left the PIN unknown")
}
})
if fetches.Load() == 0 {
t.Fatal("the handler never reached the fetch seam, so the stub proved nothing")
}
}
// The client must not report an unverifiable PIN as a wrong one, and must
// still refuse the operation.
func TestUnverifiableDeactivationPinIsARefusal(t *testing.T) {
if !isCheckDeactivationPinErr(errUnverifiableDeactivationPin) {
t.Fatal("an unverifiable PIN did not stop stop/uninstall")
}
}
// TestManagedMarkerDoesNotOutliveItsConfig covers the provenance residual: a
// marker naming only the identity survives the file it vouched for, so a
// previously managed host whose config goes missing regenerates the seeded
// default and the stale marker certifies it.
func TestManagedMarkerDoesNotOutliveItsConfig(t *testing.T) {
dir := t.TempDir()
oldV, oldFile, oldPath := v, defaultConfigFile, configPath
oldRead, oldUID, oldMarker := configReadFromDisk, cdUID, managedConfigMarkerPath
t.Cleanup(func() {
v, defaultConfigFile, configPath = oldV, oldFile, oldPath
configReadFromDisk, cdUID, managedConfigMarkerPath = oldRead, oldUID, oldMarker
})
configPath = ""
cdUID = "provisioned-uid"
managedConfigMarkerPath = func() string { return filepath.Join(dir, managedConfigMarkerFile) }
cfgPath := filepath.Join(dir, "ctrld.toml")
start := func(t *testing.T) bool {
t.Helper()
v = viper.NewWithOptions(viper.KeyDelimiter("::"))
ctrld.InitConfig(v, "ctrld")
ctrld.SetConfigNameWithPath(v, "ctrld", dir)
defaultConfigFile = cfgPath
configReadFromDisk = false
readConfigFile(false, false)
started := &ctrld.Config{}
if err := v.Unmarshal(started); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return lastKnownConfigUsable(started)
}
// A managed configuration and its marker, written the way a successful run
// writes them.
managed := provisionedConfig()
defaultConfigFile = cfgPath
if err := writeConfigFile(managed); err != nil {
t.Fatalf("write managed config: %v", err)
}
if err := recordManagedConfig(cdUID); err != nil {
t.Fatalf("recordManagedConfig: %v", err)
}
if !start(t) {
t.Fatal("positive control: a managed configuration was rejected")
}
// The file goes missing and the next start regenerates the seeded default,
// while the marker for the same identity stays behind.
if err := os.Remove(cfgPath); err != nil {
t.Fatal(err)
}
v = viper.NewWithOptions(viper.KeyDelimiter("::"))
ctrld.InitConfig(v, "ctrld")
defaultConfigFile = cfgPath
regenerated := &ctrld.Config{}
if err := v.Unmarshal(regenerated); err != nil {
t.Fatalf("unmarshal defaults: %v", err)
}
if err := writeConfigFile(regenerated); err != nil {
t.Fatalf("write regenerated default: %v", err)
}
var endpoints []string
for _, uc := range regenerated.Upstream {
endpoints = append(endpoints, uc.Endpoint)
}
if !strings.Contains(strings.Join(endpoints, " "), "freedns.controld.com") {
t.Fatalf("expected the seeded freedns default, got %v", endpoints)
}
if start(t) {
t.Fatal("a stale marker certified a regenerated default")
}
}
// TestMaintenanceRecoveryOutlivesAFailedApply covers the other residual: the
// reload receiver fetches again and can fail. Acknowledging recovery when the
// trigger is sent would clear the flag while the endpoint still serves what it
// started with, and every later identical answer would then compare equal to
// the already-stored response and never be applied again.
func TestMaintenanceRecoveryOutlivesAFailedApply(t *testing.T) {
oldFetch, oldDelay, oldUID := fetchResolverConfigFn, maintenanceReloadDelay, cdUID
t.Cleanup(func() {
fetchResolverConfigFn, maintenanceReloadDelay, cdUID = oldFetch, oldDelay, oldUID
})
cdUID = "test-uid"
maintenanceReloadDelay = func() time.Duration { return time.Millisecond }
// The same answer every time, as a recovered API would give.
fetchResolverConfigFn = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
return &controld.ResolverConfig{Exclude: []string{"excluded.example"}}, nil
}
p := &prog{
cfg: &ctrld.Config{Service: ctrld.ServiceConfig{RefetchTime: ptrTo(3600)}},
stopCh: make(chan struct{}),
runAbortCh: make(chan struct{}),
apiForceReloadCh: make(chan struct{}),
apiReloadCh: make(chan *ctrld.Config),
}
p.logger.Store(discardLogger())
p.startedInAPIMaintenance.Store(true)
// A receiver that takes the trigger and then fails to apply, the way the
// real one does when its own fetch cannot reach the API.
var delivered atomic.Int64
go func() {
for {
select {
case <-p.apiReloadCh:
delivered.Add(1)
case <-p.stopCh:
return
}
}
}()
done := make(chan struct{})
go func() { defer close(done); p.apiConfigReload() }()
t.Cleanup(func() {
close(p.stopCh)
<-done
})
// A failed apply must leave the run pending and keep redelivering, even
// though every answer after the first is identical.
deadline := time.Now().Add(10 * time.Second)
for delivered.Load() < 3 {
if time.Now().After(deadline) {
t.Fatalf("redelivery stopped after %d triggers; an identical answer was compared away", delivered.Load())
}
time.Sleep(time.Millisecond)
}
if !p.startedInAPIMaintenance.Load() {
t.Fatal("recovery was acknowledged without the configuration being applied")
}
}
// TestManagedReloadKeepsFallbackEligibility covers the provenance residual on
// the reload path: the marker is bound to the file's contents, and every
// API-driven change rewrites that file. A reload that persists a new managed
// configuration without refreshing the marker leaves the host unable to fall
// back on the configuration it just successfully applied.
func TestManagedReloadKeepsFallbackEligibility(t *testing.T) {
dir := t.TempDir()
oldV, oldFile, oldPath := v, defaultConfigFile, configPath
oldRead, oldUID, oldMarker := configReadFromDisk, cdUID, managedConfigMarkerPath
t.Cleanup(func() {
v, defaultConfigFile, configPath = oldV, oldFile, oldPath
configReadFromDisk, cdUID, managedConfigMarkerPath = oldRead, oldUID, oldMarker
})
configPath = ""
cdUID = "provisioned-uid"
managedConfigMarkerPath = func() string { return filepath.Join(dir, managedConfigMarkerFile) }
cfgPath := filepath.Join(dir, "ctrld.toml")
// A restart that reads whatever is on disk now, as a maintenance start does.
restart := func(t *testing.T) bool {
t.Helper()
v = viper.NewWithOptions(viper.KeyDelimiter("::"))
ctrld.InitConfig(v, "ctrld")
ctrld.SetConfigNameWithPath(v, "ctrld", dir)
defaultConfigFile = cfgPath
configReadFromDisk = false
readConfigFile(false, false)
started := &ctrld.Config{}
if err := v.Unmarshal(started); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return lastKnownConfigUsable(started)
}
// Managed startup.
defaultConfigFile = cfgPath
if err := writeConfigFile(provisionedConfig()); err != nil {
t.Fatal(err)
}
if err := recordManagedConfig(cdUID); err != nil {
t.Fatal(err)
}
if !restart(t) {
t.Fatal("a managed startup was not eligible to fall back")
}
// An API-driven reload persists a changed managed configuration, through
// the same call the reload receiver makes.
changed := provisionedConfig()
changed.Upstream["0"].Endpoint = "https://dns.controld.com/changed5678"
logger := mainLog.Load()
defaultConfigFile = cfgPath
persistManagedConfig(changed, logger)
onDisk, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(onDisk), "changed5678") {
t.Fatal("the reload did not persist the changed configuration")
}
if !restart(t) {
t.Fatal("a successfully applied managed configuration was not eligible to fall back")
}
// The guards must survive: a write with no resolver UID certifies nothing.
cdUID = ""
stillChanged := provisionedConfig()
stillChanged.Upstream["0"].Endpoint = "https://dns.controld.com/unmanaged9999"
persistManagedConfig(stillChanged, logger)
cdUID = "provisioned-uid"
if restart(t) {
t.Fatal("a write outside managed mode refreshed the marker")
}
}
// TestFallbackStartRewriteKeepsEligibility covers the startup half of the
// content-bound marker. A maintenance fallback fetches nothing, so it records
// no new marker, but it can still rewrite the file through the listener or
// intercept-mode updates. If the marker is left on the pre-write bytes, the
// next start in the same window is denied the fallback this one just used.
func TestFallbackStartRewriteKeepsEligibility(t *testing.T) {
dir := t.TempDir()
oldV, oldFile, oldPath := v, defaultConfigFile, configPath
oldRead, oldUID, oldMarker := configReadFromDisk, cdUID, managedConfigMarkerPath
t.Cleanup(func() {
v, defaultConfigFile, configPath = oldV, oldFile, oldPath
configReadFromDisk, cdUID, managedConfigMarkerPath = oldRead, oldUID, oldMarker
})
configPath = ""
cdUID = "provisioned-uid"
managedConfigMarkerPath = func() string { return filepath.Join(dir, managedConfigMarkerFile) }
cfgPath := filepath.Join(dir, "ctrld.toml")
restart := func(t *testing.T) bool {
t.Helper()
v = viper.NewWithOptions(viper.KeyDelimiter("::"))
ctrld.InitConfig(v, "ctrld")
ctrld.SetConfigNameWithPath(v, "ctrld", dir)
defaultConfigFile = cfgPath
configReadFromDisk = false
readConfigFile(false, false)
started := &ctrld.Config{}
if err := v.Unmarshal(started); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return lastKnownConfigUsable(started)
}
// An earlier successful run left a managed configuration and its marker.
defaultConfigFile = cfgPath
if err := writeConfigFile(provisionedConfig()); err != nil {
t.Fatal(err)
}
if err := recordManagedConfig(cdUID); err != nil {
t.Fatal(err)
}
if !restart(t) {
t.Fatal("a managed configuration was not eligible to fall back")
}
// A maintenance start: no fetch happened, so nothing new is recorded, but
// the listener/intercept updates still rewrite the file. This mirrors the
// startup ordering in run(): remember provenance, rewrite, then re-record.
managedConfigFetched := false
wasManagedConfig := managedConfigRecordedFor(cdUID)
if !wasManagedConfig {
t.Fatal("the pre-write file was not recognized as managed")
}
rewritten := provisionedConfig()
rewritten.Listener["0"].Port = 5354 // e.g. the macOS intercept port fallback
if err := writeConfigFile(rewritten); err != nil {
t.Fatal(err)
}
recordManagedConfigForStart(managedConfigFetched, wasManagedConfig)
if !restart(t) {
t.Fatal("a rewrite during a maintenance fallback denied the next start its fallback")
}
// The guard must survive: a rewrite with no prior managed provenance and no
// fetch still certifies nothing.
if err := os.Remove(managedConfigMarkerPath()); err != nil {
t.Fatal(err)
}
if managedConfigRecordedFor(cdUID) {
t.Fatal("the marker survived its own removal")
}
if restart(t) {
t.Fatal("a file with no managed provenance was accepted")
}
}
+376
View File
@@ -0,0 +1,376 @@
package cli
import (
"context"
"net/http"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
// maintenanceAPIErr builds the API's hard maintenance answer: the dedicated
// code with the maintenance message.
func maintenanceAPIErr(status int) error {
e := &controld.ErrorResponse{StatusCode: status}
e.ErrorField.Code = controld.MaintenanceCode
e.ErrorField.Message = controld.MaintenanceMessage + " Please try again later."
return e
}
// legacyMaintenanceAPIErr is the answer of a deployment that predates the
// dedicated code: the maintenance message alone.
func legacyMaintenanceAPIErr(status int) error {
e := &controld.ErrorResponse{StatusCode: status}
e.ErrorField.Message = controld.MaintenanceMessage + " Please try again later."
return e
}
// deviceInvalidErr models the API refusing a device that no longer exists.
func deviceInvalidErr() error {
e := &controld.ErrorResponse{StatusCode: http.StatusNotFound}
e.ErrorField.Code = controld.InvalidConfigCode
e.ErrorField.Message = "device does not exist"
return e
}
// provisionedConfig models the configuration an earlier successful run wrote.
func provisionedConfig() *ctrld.Config {
return &ctrld.Config{
Listener: map[string]*ctrld.ListenerConfig{
"0": {IP: "127.0.0.1", Port: 53},
},
Network: map[string]*ctrld.NetworkConfig{
"0": {Name: "Network 0", Cidrs: []string{"0.0.0.0/0"}},
},
Upstream: map[string]*ctrld.UpstreamConfig{
"0": {Name: "Control D", Type: ctrld.ResolverTypeDOH, Endpoint: "https://dns.controld.com/abcd1234", Timeout: 5000},
},
}
}
// withConfigOnDisk sets up what startup would have left behind: whether a
// configuration file was read, and whether a previous run recorded that the
// API produced it. Both are required for a fallback, and the marker lives in a
// temp dir so a real one on this host cannot influence the result.
func withConfigOnDisk(t *testing.T, read, managed bool) {
t.Helper()
oldRead, oldMarker, oldUID := configReadFromDisk, managedConfigMarkerPath, cdUID
t.Cleanup(func() {
configReadFromDisk, managedConfigMarkerPath, cdUID = oldRead, oldMarker, oldUID
})
dir := t.TempDir()
managedConfigMarkerPath = func() string { return filepath.Join(dir, managedConfigMarkerFile) }
oldFile := defaultConfigFile
t.Cleanup(func() { defaultConfigFile = oldFile })
defaultConfigFile = filepath.Join(dir, "ctrld.toml")
cdUID = "test-uid"
configReadFromDisk = read
if managed {
// The marker is bound to the file it vouches for, so one has to exist.
if err := writeConfigFile(provisionedConfig()); err != nil {
t.Fatalf("write config: %v", err)
}
if err := recordManagedConfig(cdUID); err != nil {
t.Fatalf("recordManagedConfig: %v", err)
}
}
}
// Test_apiFailureCodeClassifiesMaintenance covers the classification the whole
// fallback rests on. Maintenance must not be reported as unreachability, which
// is what the incident showed (API_UNREACHABLE after a successful connection),
// and must never reach the device-invalid path that self-uninstalls.
func Test_apiFailureCodeClassifiesMaintenance(t *testing.T) {
for _, answer := range []func(int) error{maintenanceAPIErr, legacyMaintenanceAPIErr} {
for _, status := range []int{http.StatusServiceUnavailable, http.StatusBadGateway, http.StatusBadRequest, http.StatusNotFound} {
code, ok := apiFailureCode(answer(status))
if !ok || code != provisionCodeAPIMaintenance {
t.Fatalf("status %d: code = %q ok = %v, want API_MAINTENANCE", status, code, ok)
}
}
}
}
// Test_maintenanceIsNotAPermanentRejection covers the status trap: a
// maintenance answer carrying a client-error status would otherwise be read as
// "this request will be refused again" and stop the service for good.
func Test_maintenanceIsNotAPermanentRejection(t *testing.T) {
for _, answer := range []func(int) error{maintenanceAPIErr, legacyMaintenanceAPIErr} {
for _, status := range []int{http.StatusBadRequest, http.StatusNotFound, http.StatusForbidden} {
if _, permanent := permanentAPIRejection(answer(status)); permanent {
t.Fatalf("status %d classified as a permanent rejection", status)
}
}
}
}
func Test_useLastKnownConfigDuringMaintenance(t *testing.T) {
t.Run("a previously working config carries the start", func(t *testing.T) {
withConfigOnDisk(t, true, true)
if !useLastKnownConfigDuringMaintenance(maintenanceAPIErr(http.StatusServiceUnavailable), provisionedConfig()) {
t.Fatal("maintenance did not fall back to the config on disk")
}
})
t.Run("a generated default is not a fallback", func(t *testing.T) {
// A fresh install: no file existed, so startup wrote the freedns
// default. Serving it would answer outside the operator's profile.
withConfigOnDisk(t, false, false)
if useLastKnownConfigDuringMaintenance(maintenanceAPIErr(http.StatusServiceUnavailable), provisionedConfig()) {
t.Fatal("a generated default was accepted as a last known configuration")
}
})
t.Run("an unusable config on disk is not a fallback", func(t *testing.T) {
withConfigOnDisk(t, true, true)
cfg := provisionedConfig()
cfg.Upstream = nil
if useLastKnownConfigDuringMaintenance(maintenanceAPIErr(http.StatusServiceUnavailable), cfg) {
t.Fatal("an invalid config was accepted as a last known configuration")
}
})
t.Run("a file with no record of an API answer is not a fallback", func(t *testing.T) {
withConfigOnDisk(t, true, false)
if useLastKnownConfigDuringMaintenance(maintenanceAPIErr(http.StatusServiceUnavailable), provisionedConfig()) {
t.Fatal("a configuration no API answer produced was accepted")
}
})
t.Run("other failures never take this path", func(t *testing.T) {
withConfigOnDisk(t, true, true)
for _, err := range []error{retryableNetworkErr(), deviceInvalidErr()} {
if useLastKnownConfigDuringMaintenance(err, provisionedConfig()) {
t.Fatalf("%v was treated as maintenance", err)
}
}
})
}
// Test_maintenanceWithoutFallbackFails covers the other half of the request:
// when there is nothing to fall back on, ctrld still stops, but says what
// cannot be done rather than reporting a fetch or connectivity failure.
func Test_maintenanceWithoutFallbackFails(t *testing.T) {
exitCode, notified := stubProvisionGlobals(t)
uninstalled := false
uninstallInvalidCdUIDFn = func(*prog, *ctrld.Logger, bool) bool { uninstalled = true; return true }
handleAPIPreflightFailure(&prog{}, maintenanceAPIErr(http.StatusServiceUnavailable), func() { *notified = true })
if want := provisionExitCodeForCode[provisionCodeAPIMaintenance]; *exitCode != want {
t.Errorf("exit = %d, want %d", *exitCode, want)
}
if uninstalled {
t.Error("maintenance reached the self-uninstall path")
}
if !*notified {
t.Error("notify not called")
}
r, err := readProvisionResult()
if err != nil {
t.Fatal(err)
}
if r.Code != string(provisionCodeAPIMaintenance) {
t.Errorf("code = %q, want API_MAINTENANCE", r.Code)
}
if r.Message != maintenanceNoConfigDetail {
t.Errorf("message = %q, want the maintenance guidance", r.Message)
}
}
// maintenanceWithDeviceCode is the answer that makes the guard load-bearing:
// maintenance carried alongside the device-invalid code. Without the guard in
// selfUninstallCheck, this uninstalls a working install over a temporary API
// outage.
func maintenanceWithDeviceCode() error {
e := &controld.ErrorResponse{StatusCode: http.StatusServiceUnavailable}
e.ErrorField.Code = controld.InvalidConfigCode
e.ErrorField.Message = controld.MaintenanceMessage + " Please try again later."
return e
}
func stubSelfUninstall(t *testing.T) *bool {
t.Helper()
old := selfUninstallFn
t.Cleanup(func() { selfUninstallFn = old })
called := false
selfUninstallFn = func(*prog, *ctrld.Logger) { called = true }
return &called
}
func newSelfUninstallTestProg() *prog {
return &prog{dnsWatcherStopCh: make(chan struct{})}
}
func Test_selfUninstallCheckSkipsMaintenance(t *testing.T) {
t.Run("maintenance never uninstalls", func(t *testing.T) {
called := stubSelfUninstall(t)
p := newSelfUninstallTestProg()
selfUninstallCheck(maintenanceWithDeviceCode(), p, discardLogger())
if *called {
t.Fatal("maintenance triggered a self-uninstall")
}
select {
case <-p.dnsWatcherStopCh:
t.Fatal("maintenance stopped the DNS watchers")
default:
}
})
t.Run("a real device rejection still uninstalls", func(t *testing.T) {
called := stubSelfUninstall(t)
selfUninstallCheck(deviceInvalidErr(), newSelfUninstallTestProg(), discardLogger())
if !*called {
t.Fatal("a deleted device no longer self-uninstalls")
}
})
t.Run("an unrelated failure does nothing", func(t *testing.T) {
called := stubSelfUninstall(t)
selfUninstallCheck(retryableNetworkErr(), newSelfUninstallTestProg(), discardLogger())
if *called {
t.Fatal("a network failure triggered a self-uninstall")
}
})
}
// Test_logResolverConfigFetchFailure covers what the reload paths say. The
// incident was first triaged as a connectivity fault because the only line was
// "could not fetch resolver config" after a successful connection.
func Test_logResolverConfigFetchFailure(t *testing.T) {
t.Run("maintenance is named and retained", func(t *testing.T) {
logs := captureDebugMainLog(t)
logger := mainLog.Load().With().Str("mode", "api-reload")
logResolverConfigFetchFailure(logger, maintenanceAPIErr(http.StatusServiceUnavailable))
events := jsonLogEvents(t, logs, "Control D API is in maintenance; keeping the current configuration until it returns")
if len(events) != 1 {
t.Fatalf("maintenance events: got %d, want 1", len(events))
}
if events[0]["reason"] != "api_maintenance" {
t.Errorf("reason = %v, want api_maintenance", events[0]["reason"])
}
if events[0]["journal"] != true {
t.Error("the maintenance line is not retained in the journal")
}
})
t.Run("other failures keep the existing line", func(t *testing.T) {
logs := captureDebugMainLog(t)
logger := mainLog.Load().With().Str("mode", "api-reload")
logResolverConfigFetchFailure(logger, retryableNetworkErr())
if got := len(jsonLogEvents(t, logs, "Could not fetch resolver config")); got != 1 {
t.Fatalf("generic fetch-failure events: got %d, want 1", got)
}
})
}
// Test_maintenanceReloadDelay covers the two properties the retry depends on:
// it is far below refetch_time so a degraded run recovers promptly, and it is
// spread so hosts that fell back together do not return as one burst.
func Test_maintenanceReloadDelay(t *testing.T) {
const refetchDefault = time.Hour
seen := make(map[time.Duration]bool)
for range 200 {
d := maintenanceReloadDelay()
if d < maintenanceReloadInterval || d >= maintenanceReloadInterval+maintenanceReloadJitter {
t.Fatalf("delay %s outside [%s, %s)", d, maintenanceReloadInterval, maintenanceReloadInterval+maintenanceReloadJitter)
}
if d >= refetchDefault {
t.Fatalf("delay %s is no faster than the normal refetch interval", d)
}
seen[d] = true
}
if len(seen) < 2 {
t.Fatal("every delay was identical; the fleet would retry in lockstep")
}
}
// Test_maintenanceRetryRecoversBeforeRefetchInterval drives the real reload
// loop: a run that started on the configuration on disk must ask the API again
// without waiting out refetch_time, and must stop asking once it gets an
// answer.
func Test_maintenanceRetryRecoversBeforeRefetchInterval(t *testing.T) {
oldFetch, oldDelay, oldUID := fetchResolverConfigFn, maintenanceReloadDelay, cdUID
t.Cleanup(func() {
fetchResolverConfigFn, maintenanceReloadDelay, cdUID = oldFetch, oldDelay, oldUID
})
cdUID = "test-uid"
maintenanceReloadDelay = func() time.Duration { return time.Millisecond }
var calls atomic.Int64
recovered := make(chan struct{})
var once sync.Once
fetchResolverConfigFn = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
// Stay in maintenance for the first few retries, then answer.
if calls.Add(1) < 3 {
return nil, maintenanceAPIErr(http.StatusServiceUnavailable)
}
once.Do(func() { close(recovered) })
return &controld.ResolverConfig{}, nil
}
p := &prog{
cfg: &ctrld.Config{
// An hour: the test must pass because of the maintenance retry,
// never because the normal ticker happened to fire.
Service: ctrld.ServiceConfig{RefetchTime: ptrTo(3600)},
},
stopCh: make(chan struct{}),
runAbortCh: make(chan struct{}),
apiForceReloadCh: make(chan struct{}),
apiReloadCh: make(chan *ctrld.Config),
}
p.logger.Store(discardLogger())
p.startedInAPIMaintenance.Store(true)
// Recovery now means the configuration reached the reload, so something
// has to receive it.
go func() {
for {
select {
case <-p.apiReloadCh:
// Stand in for the reload receiver applying it.
p.maintenanceConfigApplied(discardLogger())
case <-p.stopCh:
return
}
}
}()
done := make(chan struct{})
go func() { defer close(done); p.apiConfigReload() }()
t.Cleanup(func() {
close(p.stopCh)
<-done
})
select {
case <-recovered:
case <-time.After(10 * time.Second):
t.Fatal("the maintenance retry never reached the API")
}
// The flag clears on the successful fetch, which is what stops the retry.
deadline := time.Now().Add(5 * time.Second)
for p.startedInAPIMaintenance.Load() {
if time.Now().After(deadline) {
t.Fatal("the run stayed in maintenance after the API answered")
}
time.Sleep(time.Millisecond)
}
settled := calls.Load()
time.Sleep(50 * time.Millisecond)
if extra := calls.Load() - settled; extra > 1 {
t.Fatalf("the faster retry kept running after recovery: %d more fetches", extra)
}
}
func ptrTo[T any](v T) *T { return &v }
+75 -4
View File
@@ -390,6 +390,9 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
p.cs = cs
oldLogPath := cfg.Service.LogPath
// Whether this run got its configuration from the API, which is what the
// managed-config marker may claim once the configuration is on disk.
managedConfigFetched := false
if uid := cdUIDFromProvToken(); uid != "" {
cdUID = uid
p.initLoggingAfterProvisioning()
@@ -422,16 +425,34 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
return
}
handleAPIPreflightFailure(p, pf.err, p.notifyExitToLogServer)
return
// Maintenance is temporary and says nothing about this host's
// configuration, so a start that already has a working one carries
// on with it rather than taking DNS down for the window.
if !useLastKnownConfigDuringMaintenance(pf.err, &cfg) {
handleAPIPreflightFailure(p, pf.err, p.notifyExitToLogServer)
return
}
// This run holds no answer from the API, so the reload loop asks
// again well before refetch_time would.
p.startedInAPIMaintenance.Store(true)
default:
p.mu.Lock()
p.rc = pf.rc
p.mu.Unlock()
managedConfigFetched = true
}
}
updated := updateListenerConfig(&cfg, p.notifyExitToLogServer)
// Whether the file on disk, before anything below rewrites it, is already
// one the API produced for this identity.
wasManagedConfig := managedConfigRecordedFor(cdUID)
// The marker must never vouch for a configuration that was not written.
// Without this, a first managed run whose listener needed no change would
// record the marker over the seeded default still on disk.
if managedConfigFetched && !wasManagedConfig {
updated = true
}
// Bootstrap and listener binding both succeeded, so an earlier run's
// recorded failure no longer describes this install.
@@ -475,6 +496,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
}
p.Info().Msg("Writing config file to: " + defaultConfigFile)
}
recordManagedConfigForStart(managedConfigFetched, wasManagedConfig)
if newLogPath := cfg.Service.LogPath; newLogPath != "" && oldLogPath != newLogPath {
p.switchLogPath(oldLogPath, newLogPath)
@@ -587,6 +609,9 @@ func readConfigFile(writeDefaultConfig, notice bool) bool {
// If err == nil, there's a config supplied via `--config`, no default config written.
err := v.ReadInConfig()
if err == nil {
// Only a file the host already had can stand in for the API answer
// during maintenance; the default written below cannot.
configReadFromDisk = true
if notice {
mainLog.Load().Notice().Msg("Reading config: " + v.ConfigFileUsed())
}
@@ -740,6 +765,15 @@ func processNoConfigFlags(noConfigStart bool) {
// If cdDeactivationPin equals to this default, it means the pin code is not set from Control D API.
const defaultDeactivationPin = -1
// deactivationPinKnown reports whether this run ever had an answer from the API
// about the deactivation PIN.
//
// cdDeactivationPin cannot carry this: its default means "the API said there is
// no PIN", and a run that started on the configuration on disk during
// maintenance never asked. Without the distinction such a run would present an
// unknown PIN as no PIN and hand out deactivation to anyone who asked.
var deactivationPinKnown atomic.Bool
// cdDeactivationPin is used in cd mode to decide whether stop and uninstall commands can be run.
var cdDeactivationPin atomic.Int64
@@ -813,6 +847,11 @@ func permanentAPIRejection(err error) (*controld.ErrorResponse, bool) {
if !errors.As(err, &uer) {
return nil, false
}
// Maintenance is the API being away, not the API refusing this request, so
// it stays retryable even when it arrives with a client-error status.
if controld.IsMaintenance(err) {
return nil, false
}
switch uer.StatusCode {
case http.StatusRequestTimeout, http.StatusTooManyRequests:
return nil, false
@@ -832,6 +871,11 @@ func apiFailureCode(err error) (provisionFailureCode, bool) {
if err == nil {
return "", false
}
// Before the device check: maintenance must never reach the self-uninstall
// path, because it says nothing about whether this device still exists.
if controld.IsMaintenance(err) {
return provisionCodeAPIMaintenance, true
}
var uer *controld.ErrorResponse
if errors.As(err, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode {
return provisionCodeAPIDeviceInvalid, true
@@ -924,6 +968,12 @@ func handleAPIPreflightFailure(p *prog, err error, notify func()) {
}
cdLogger := logger.With().Str("mode", "cd")
code, _ := apiFailureCode(err)
if code == provisionCodeAPIMaintenance {
// The caller already established there is nothing to fall back on.
cdLogger.Error().Err(err).Msg("Failed to fetch resolver config, the Control D API is in maintenance")
failProvision(newProvisionResult(code, maintenanceNoConfigDetail, nil, provisionSecrets()...), notify)
return
}
var uer *controld.ErrorResponse
if errors.As(err, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode {
r := newProvisionResult(code, apiRejectionSummary(uer.StatusCode), nil, provisionSecrets()...)
@@ -1046,10 +1096,21 @@ func processCDFlags(ctx context.Context, cfg *ctrld.Config) (*controld.ResolverC
if isMobile() {
return nil, err
}
logger.Warn().Err(err).Msg("Could not fetch resolver config")
if controld.IsMaintenance(err) {
// The caller reports this: only it knows whether a fallback is
// available. Saying "Could not fetch resolver config" here puts a
// connectivity-shaped failure at the top of the bootstrap log,
// which is how the incident behind this was first mis-triaged.
logger.Debug().Err(err).Msg("Resolver config fetch answered with maintenance")
} else {
logger.Warn().Err(err).Msg("Could not fetch resolver config")
}
return nil, err
}
// The answer settles the PIN either way: a response without one means the
// API says there is none, which is not the same as never having asked.
deactivationPinKnown.Store(true)
if resolverConfig.DeactivationPin != nil {
logger.Debug().Msg("Saving deactivation pin")
cdDeactivationPin.Store(*resolverConfig.DeactivationPin)
@@ -2430,6 +2491,12 @@ var errRequiredDeactivationPin = errors.New("deactivation pin is required to sto
// errTooManyDeactivationPin represents an error indicating excessive deactivation PIN request attempts.
var errTooManyDeactivationPin = errors.New("too many request attempts")
// errUnverifiableDeactivationPin reports that this run never had an answer from
// the API about the PIN, so it can neither check one nor establish that none is
// required. Refusing is the only safe answer: the alternative hands a
// PIN-protected device to whoever asks for the duration of an API outage.
var errUnverifiableDeactivationPin = errors.New("cannot verify the deactivation pin while the Control D API is unavailable, try again once it returns")
// checkDeactivationPin validates if the deactivation pin matches one in ControlD config.
func checkDeactivationPin(s service.Service, stopCh chan struct{}) error {
mainLog.Load().Debug().Msg("Checking deactivation pin")
@@ -2466,6 +2533,9 @@ func checkDeactivationPin(s service.Service, stopCh chan struct{}) error {
return nil // valid pin
case http.StatusNotFound:
return nil // the server is running older version of ctrld
case http.StatusServiceUnavailable:
mainLog.Load().Error().Msg(errUnverifiableDeactivationPin.Error())
return errUnverifiableDeactivationPin
}
}
mainLog.Load().Error().Err(err).Msg(errInvalidDeactivationPin.Error())
@@ -2476,7 +2546,8 @@ func checkDeactivationPin(s service.Service, stopCh chan struct{}) error {
func isCheckDeactivationPinErr(err error) bool {
return errors.Is(err, errInvalidDeactivationPin) ||
errors.Is(err, errRequiredDeactivationPin) ||
errors.Is(err, errTooManyDeactivationPin)
errors.Is(err, errTooManyDeactivationPin) ||
errors.Is(err, errUnverifiableDeactivationPin)
}
// ensureUninstall ensures that s.Uninstall will remove ctrld service from system completely.
+12 -2
View File
@@ -241,7 +241,8 @@ func (p *prog) registerControlServerHandler() {
Version: appVersion,
Metadata: ctrld.SystemMetadataRuntime(context.Background()),
}
if rc, err := controld.FetchResolverConfig(loggerCtx, rcReq, cdDev); rc != nil {
if rc, err := fetchResolverConfig(loggerCtx, rcReq, cdDev); rc != nil {
deactivationPinKnown.Store(true)
if rc.DeactivationPin != nil {
cdDeactivationPin.Store(*rc.DeactivationPin)
} else {
@@ -261,8 +262,17 @@ func (p *prog) registerControlServerHandler() {
p.Warn().Err(err).Msg("Could not re-fetch deactivation pin code")
}
// If pin code not set, allowing deactivation.
// If pin code not set, allowing deactivation - but only when the API
// actually said so. A run that started on the configuration on disk
// during maintenance never asked, and the re-fetch above leaves the
// default untouched while the API stays away, so an unknown PIN would
// otherwise read as no PIN and authorize the deactivation it protects.
if !deactivationPinSet() {
if !deactivationPinKnown.Load() {
p.Warn().Msg("Cannot verify deactivation pin: no answer from the Control D API this run")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
return
}
+2 -2
View File
@@ -1645,12 +1645,12 @@ func (p *prog) doSelfUninstall(pr *proxyResponse) {
Version: appVersion,
Metadata: ctrld.SystemMetadata(loggerCtx),
}
_, err := controld.FetchResolverConfig(loggerCtx, req, cdDev)
_, err := fetchResolverConfig(loggerCtx, req, cdDev)
logger.Debug().Msg("Maximum number of refused queries reached, checking device status")
selfUninstallCheck(err, p, logger)
if err != nil {
logger.Warn().Err(err).Msg("Could not fetch resolver config")
logResolverConfigFetchFailure(logger, err)
}
// Cool-of period to prevent abusing the API.
go p.selfUninstallCoolOfPeriod()
+72 -11
View File
@@ -164,10 +164,17 @@ type prog struct {
lastNAT64Mu sync.Mutex
lastNAT64Logged string
selfUninstallMu sync.Mutex
refusedQueryCount int
canSelfUninstall atomic.Bool
checkingSelfUninstall bool
selfUninstallMu sync.Mutex
refusedQueryCount int
canSelfUninstall atomic.Bool
// startedInAPIMaintenance records that this run began on the configuration
// already on disk because the API was in maintenance, so it holds no answer
// from the API at all. The reload loop retries faster while it is set, and
// clears it once a recovered configuration has been applied - not when a
// fetch returns, because the reload receiver fetches again and can fail.
startedInAPIMaintenance atomic.Bool
checkingSelfUninstall bool
loopMu sync.Mutex
loop map[string]bool
@@ -585,14 +592,16 @@ func (p *prog) runWait() {
}
addExtraSplitDnsRule(newCfg)
if err := writeConfigFile(newCfg); err != nil {
p.Error().Err(err).Msg("Could not write new config")
}
persistManagedConfig(newCfg, p.logger.Load())
// This needs to be done here, otherwise, the DNS handler may observe an invalid
// upstream config because its initialization function have not been called yet.
p.Debug().Msg("Setup upstream with new config")
p.setupUpstream(newCfg)
// The configuration is in effect now. Every failure above continues the
// loop without applying anything, so a run that started in maintenance
// stays pending and keeps retrying until one gets this far.
p.maintenanceConfigApplied(p.logger.Load())
p.mu.Lock()
oldUpstreams := p.cfg.Upstream
@@ -762,7 +771,7 @@ func (p *prog) apiConfigReload() {
}
selfUninstallCheck(err, p, logger)
if err != nil {
logger.Warn().Err(err).Msg("Could not fetch resolver config")
logResolverConfigFetchFailure(logger, err)
return
}
@@ -773,12 +782,37 @@ func (p *prog) apiConfigReload() {
lastUpdated = p.applyFetchedResolverConfig(loggerCtx, logger, resolverConfig, forced, lastUpdated)
}
// A run that started in maintenance holds no answer from the API, so it
// waits out the whole refetch interval before it has one. Retry faster
// until it does, without touching refetch_time: that interval governs
// every endpoint, and shortening it fleet-wide would multiply load on an
// API that is by definition already struggling.
var maintenanceRetry *time.Timer
var maintenanceRetryC <-chan time.Time
if p.startedInAPIMaintenance.Load() {
maintenanceRetry = time.NewTimer(maintenanceReloadDelay())
defer maintenanceRetry.Stop()
maintenanceRetryC = maintenanceRetry.C
logger.Debug().Msg("Started on the configuration on disk, retrying the API faster until it answers")
}
for {
select {
case <-p.apiForceReloadCh:
doReloadApiConfig(true, logger.With().Bool("forced", true))
case <-ticker.C:
doReloadApiConfig(false, logger)
case <-maintenanceRetryC:
// The normal ticker or a forced reload may have recovered first.
if !p.startedInAPIMaintenance.Load() {
maintenanceRetryC = nil
continue
}
doReloadApiConfig(false, logger.With().Bool("maintenance_retry", true))
if p.startedInAPIMaintenance.Load() {
maintenanceRetry.Reset(maintenanceReloadDelay())
continue
}
maintenanceRetryC = nil
case <-ctx.Done():
return
}
@@ -806,6 +840,9 @@ func (p *prog) applyFetchedResolverConfig(
if loggerCtx.Err() != nil {
return lastUpdated
}
// The answer settles the PIN either way: a response without one means the
// API says there is none, which is not the same as never having asked.
deactivationPinKnown.Store(true)
if resolverConfig.DeactivationPin != nil {
newDeactivationPin := *resolverConfig.DeactivationPin
curDeactivationPin := cdDeactivationPin.Load()
@@ -835,11 +872,22 @@ func (p *prog) applyFetchedResolverConfig(
noExcludeListChanged := true
// Regenerate Internal Domains on add, removal, domain, mode or resolver change.
noInternalDomainsChanged := true
if rc != nil {
// A run that started on the configuration on disk has applied no answer
// from the API yet, so nothing here is known to be current and the
// response has to reach the apply path instead of being compared
// against a configuration that was never applied. This holds until the
// apply lands, not until a fetch returns: the receiver fetches again
// and can fail, and p.rc is already replaced above, so comparing would
// make every later response equal the stored one and leave the excludes
// and Internal Domains of the previous run in place for good.
pendingRecovery := p.startedInAPIMaintenance.Load()
if rc != nil && !pendingRecovery {
slices.Sort(rc.Exclude)
slices.Sort(resolverConfig.Exclude)
noExcludeListChanged = slices.Equal(rc.Exclude, resolverConfig.Exclude)
noInternalDomainsChanged = internalDomainsEqual(rc.SplitDNS, resolverConfig.SplitDNS)
} else {
noExcludeListChanged, noInternalDomainsChanged = false, false
}
if noCustomConfig && noExcludeListChanged && noInternalDomainsChanged {
return lastUpdated
@@ -860,7 +908,7 @@ func (p *prog) applyFetchedResolverConfig(
return lastUpdated
}
if resolverConfig.Ctrld.CustomLastUpdate > lastUpdated || forced {
if resolverConfig.Ctrld.CustomLastUpdate > lastUpdated || forced || pendingRecovery {
lastUpdated = time.Now().Unix()
cfg := &ctrld.Config{}
var cfgErr error
@@ -2389,13 +2437,26 @@ func (p *prog) dnsChanged(iface *net.Interface, nameservers []string) bool {
}
// selfUninstallCheck checks if the error dues to controld.InvalidConfigCode, perform self-uninstall then.
// selfUninstallFn is the uninstall itself, indirected so a test can prove what
// does and does not reach it. The real one ends in os.Exit, which a test cannot
// survive to make an assertion about.
var selfUninstallFn = selfUninstall
func selfUninstallCheck(uninstallErr error, p *prog, logger *ctrld.Logger) {
// Maintenance is the API being away; it establishes nothing about whether
// this device still exists. Guarded here rather than at each caller because
// this is the one place that acts, and acting would uninstall a working
// install over a temporary outage.
if controld.IsMaintenance(uninstallErr) {
logger.Debug().Msg("API is in maintenance, skipping device status check")
return
}
var uer *controld.ErrorResponse
if errors.As(uninstallErr, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode {
p.stopDnsWatchers()
// Perform self-uninstall now.
selfUninstall(p, logger)
selfUninstallFn(p, logger)
}
}
+4
View File
@@ -38,6 +38,7 @@ const (
provisionCodeAPIUnreachable provisionFailureCode = "API_UNREACHABLE"
provisionCodeAPIRejected provisionFailureCode = "API_REJECTED"
provisionCodeAPIDeviceInvalid provisionFailureCode = "API_DEVICE_INVALID"
provisionCodeAPIMaintenance provisionFailureCode = "API_MAINTENANCE"
provisionCodeTokenInvalid provisionFailureCode = "TOKEN_INVALID"
provisionCodeTokenExpired provisionFailureCode = "TOKEN_EXPIRED"
provisionCodeTokenLimitReached provisionFailureCode = "TOKEN_LIMIT_REACHED"
@@ -58,6 +59,7 @@ var allProvisionFailureCodes = []provisionFailureCode{
provisionCodeAPIUnreachable,
provisionCodeAPIRejected,
provisionCodeAPIDeviceInvalid,
provisionCodeAPIMaintenance,
provisionCodeTokenInvalid,
provisionCodeTokenExpired,
provisionCodeTokenLimitReached,
@@ -78,6 +80,7 @@ var provisionStageForCode = map[provisionFailureCode]provisionStage{
provisionCodeAPIUnreachable: provisionStageBootstrap,
provisionCodeAPIRejected: provisionStageBootstrap,
provisionCodeAPIDeviceInvalid: provisionStageBootstrap,
provisionCodeAPIMaintenance: provisionStageBootstrap,
provisionCodeTokenInvalid: provisionStageBootstrap,
provisionCodeTokenExpired: provisionStageBootstrap,
provisionCodeTokenLimitReached: provisionStageBootstrap,
@@ -102,6 +105,7 @@ var provisionExitCodeForCode = map[provisionFailureCode]int{
provisionCodeAPIUnreachable: 30,
provisionCodeAPIRejected: 31,
provisionCodeAPIDeviceInvalid: 32,
provisionCodeAPIMaintenance: 37,
provisionCodeTokenInvalid: 33,
provisionCodeTokenExpired: 34,
provisionCodeTokenLimitReached: 35,
+2 -2
View File
@@ -68,8 +68,8 @@ func TestProvisionCodesMapToOneStageAndInRangeExit(t *testing.T) {
}
seenExits[exit] = code
}
if len(allProvisionFailureCodes) != 17 {
t.Errorf("expected 17 codes, got %d", len(allProvisionFailureCodes))
if len(allProvisionFailureCodes) != 18 {
t.Errorf("expected 18 codes, got %d", len(allProvisionFailureCodes))
}
}
+1
View File
@@ -42,6 +42,7 @@ The **Next action / evidence** column lists what to look at next.
| `API_UNREACHABLE` | bootstrap | 30 | The Control D API could not be reached or answered with a retryable error (network failure, proxy interference, 5xx, timeout) and retries ran out. The service manager may retry the service later. | Make sure that the device has network access to `api.controld.com` (DNS, proxy, firewall, captive portal). `ctrld-client diag`'s `api_reachability` block already ran this same probe. Ask whether other TLS traffic works. |
| `API_REJECTED` | bootstrap | 31 | The API answered and permanently rejected the configuration (4xx other than 408/429): bad or revoked token, malformed request. ctrld exits without burning service-manager restarts because retrying cannot change the answer. This is also the fallback for the four `TOKEN_*` codes below. Those codes need the API response to carry `error.metadata.reason`. An API deployment that does not yet populate that field reports every provisioning-code rejection as `API_REJECTED` instead. | Verify the provision token / org configuration in the Control D dashboard. Re-push after fixing credentials. Evidence: HTTP status in the result file `message`. |
| `API_DEVICE_INVALID` | bootstrap | 32 | The API reports the device/resolver no longer exists (error code 40402). When the daemon's own bootstrap preflight discovers this, it self-uninstalls, because the identity is gone server-side. The direct `--cd <uid>` install path (`ctrld-client start --cd`) can also hit this code before the service exists. On that path there is nothing to uninstall yet. | Confirm the device was deleted or re-provisioned in the dashboard; re-provision with a current token. No local evidence needed beyond the code. |
| `API_MAINTENANCE` | bootstrap | 37 | The API answered that it is in hard maintenance (HTTP 503, `error.code` 50302, or on older deployments the message `Maintenance is in progress.`), and this host had no previously working configuration on disk to fall back on. A host that does have one keeps serving DNS from it and never reaches this code. Maintenance is temporary and says nothing about the configuration, so ctrld does not self-uninstall and does not treat it as a permanent rejection. | Wait for the maintenance window to end and re-run. Nothing to fix on the device: the same request succeeds once the API returns. A device that was already provisioned keeps resolving throughout. |
| `TOKEN_INVALID` | bootstrap | 33 | The API rejected the `--cd-org` provisioning code with reason `token_invalid`: the code is not recognized. This code needs the deployed API to send `error.metadata.reason: "token_invalid"`. See `API_REJECTED` above for the fallback when it does not. | Make sure that the code is correct. Re-enter it exactly as given. |
| `TOKEN_EXPIRED` | bootstrap | 34 | The API rejected the `--cd-org` provisioning code with reason `token_expired`. This has the same API-reason dependency as `TOKEN_INVALID`. | Get a new provisioning code from your administrator. |
| `TOKEN_LIMIT_REACHED` | bootstrap | 35 | The API rejected the `--cd-org` provisioning code with reason `token_limit_reached`: it reached its device limit. This has the same API-reason dependency as `TOKEN_INVALID`. | Free up a device slot or use a different provisioning code. |