Files
ctrld/cmd/cli/api_maintenance.go
T
Dev Scribe cee5bbe7ae fix(cli): journal that allowed destinations fail closed on a maintenance start
A maintenance fallback start holds no resolver config, and Allowed
Destinations (a master-only Firewall Mode feature) are not persisted with
the configuration on disk. Under Firewall Mode the exception set is
therefore empty until the API answers, so raw-IP destinations the
organization allowed stay blocked for the window, and a persisted pf
table from the previous run is replaced by the empty set.

Keep that fail-closed on purpose: the list is the organization's network
topology and is kept out of persisted state. Journal a Warn when a
fallback start applies the empty set under Firewall Mode so the blocked
destinations are explained, and pin that the first answer after recovery
re-applies the list.

Also capitalize the two managed-config marker log messages to match the
rest of master.
2026-10-07 14:18:04 +07:00

252 lines
11 KiB
Go

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")
}
}