v0.173.0 — R-77: endpoint-drift detection, samba protected-set gate, channel log honesty
Source: felhom.eu/documentation/audits/DIAG-agent-channel-2026-07-26.md bootstrap.DetectEndpointDrift names a controller.yaml vs bootstrap.json local_api.endpoint divergence -- one ERROR carrying BOTH values and BOTH paths, its own event type local_api_endpoint_drift, and its own Hungarian banner shown ABOVE the channel banner because drift is the cause and "agent unreachable" the symptom. It writes NOTHING: reconciling from bootstrap.json would clobber a correct controller.yaml on any half-provisioned or hand-repaired guest, so the authority ruling is deferred to R-78. Fail-safe silent on absent/unparseable/ incomplete bootstrap and on an empty endpoint (ensureLocalAPI's fill-if-missing path is untouched). Fingerprint compared as a BOOLEAN only; token never compared, logged or exposed. EffectiveProtected now gates samba on Enabled && UserSet, mirroring BOTH of reconcileSambaAt's early returns, and the doc comment is corrected in the same change -- it claimed "detection and deployment agree in both directions" while citing only !smb.Enabled, an assertion that went false when !smb.UserSet was added. Not over-suppressed: sharing on WITH a password and a dead container still alarms. Channel log: the debounce placeholder is stateUnconfirmed (rendered "unseeded") instead of "up", so a born-down channel no longer logs "up->down" and orUnseeded stops being dead code. Logging only -- the placeholder is still matched in the re-arm condition, so F2 born-down alerting is byte-for-byte unchanged and all nine pre-existing channelhealth tests pass. Tests 951 -> 959, all green. Red-proofs A (both directions), E and F. MinAgent unchanged; felhom-agent untouched.
This commit is contained in:
@@ -320,3 +320,95 @@ func writeFileAtomic(path string, b []byte) error {
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// --- Endpoint-drift detection (R-77, from DIAG-agent-channel-2026-07-26) ---------------------
|
||||
//
|
||||
// THE OUTAGE THIS EXISTS FOR: the R-50 island migration rewrote bootstrap.json's
|
||||
// local_api.endpoint to the island address; controller.yaml kept the pre-island LAN address on the
|
||||
// whole fleet; the agent no longer binds that address. Both controllers went dark for ~17.5 h, and
|
||||
// the only alert said "agent unreachable" — indistinguishable from a dead agent or a network blip,
|
||||
// so it read as infrastructure noise rather than a config fault. ensureLocalAPI could not catch it:
|
||||
// it fills an ABSENT local_api block and returns early on a present one, stale or not.
|
||||
//
|
||||
// This is DETECTION AND NAMING ONLY. It deliberately does NOT reconcile the two files:
|
||||
//
|
||||
// the mirror-image failure is just as bad — on a guest whose controller.yaml is correct and whose
|
||||
// bootstrap.json is stale, auto-reconcile would clobber a WORKING channel, fleet-wide, on the next
|
||||
// restart. Which file is authoritative is a real, unresolved question and is tracked as R-78.
|
||||
//
|
||||
// Naming it is enough to have converted that outage into a specific, actionable alert on the first
|
||||
// health cycle, which is the whole lesson of the incident.
|
||||
|
||||
// EndpointDrift is a detected divergence between the two local_api sources. It carries no secrets:
|
||||
// the fingerprint is reported as an agreement BOOLEAN and the token is not compared or exposed at
|
||||
// all (a token mismatch is a different failure — see the field comment).
|
||||
type EndpointDrift struct {
|
||||
ConfigPath string // controller.yaml
|
||||
BootstrapPath string // bootstrap.json
|
||||
ConfigEndpoint string // what the controller is actually dialling
|
||||
BootstrapEndpoint string // what the provisioning side last wrote
|
||||
// FingerprintAgrees is false when the pin ALSO moved. That is a materially different (and worse)
|
||||
// situation than a moved address — fixing the endpoint alone would then fail closed on the pin —
|
||||
// so it is surfaced, as a boolean, never as a value.
|
||||
FingerprintAgrees bool
|
||||
}
|
||||
|
||||
// DetectEndpointDrift compares controller.yaml's live local_api.endpoint against bootstrap.json's.
|
||||
// Returns nil (silent, no alert) in every ambiguous or not-applicable case:
|
||||
//
|
||||
// - cfg is nil, or its endpoint is EMPTY — that is the fill-if-missing path ensureLocalAPI owns,
|
||||
// not drift;
|
||||
// - bootstrap.json is absent, unreadable or unparseable — a legacy / manually-configured /
|
||||
// unprovisioned guest is not a drifted one;
|
||||
// - the bootstrap local_api block is incomplete (any of endpoint/fingerprint/token empty) — the
|
||||
// same completeness bar ensureLocalAPI applies before it will merge;
|
||||
// - the endpoints agree.
|
||||
//
|
||||
// It reads two files and writes NOTHING. Emitting the ERROR here (rather than at the call site)
|
||||
// keeps the diagnosis in one line of log even when the alert path is unavailable.
|
||||
func DetectEndpointDrift(configPath string, cfg *config.Config, logger *log.Logger) *EndpointDrift {
|
||||
if cfg == nil || cfg.LocalAPI.Endpoint == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(Path())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var b Bootstrap
|
||||
if err := json.Unmarshal(data, &b); err != nil {
|
||||
return nil
|
||||
}
|
||||
if b.LocalAPI.Endpoint == "" || b.LocalAPI.Fingerprint == "" || b.LocalAPI.Token == "" {
|
||||
return nil
|
||||
}
|
||||
if cfg.LocalAPI.Endpoint == b.LocalAPI.Endpoint {
|
||||
return nil
|
||||
}
|
||||
d := &EndpointDrift{
|
||||
ConfigPath: configPath,
|
||||
BootstrapPath: Path(),
|
||||
ConfigEndpoint: cfg.LocalAPI.Endpoint,
|
||||
BootstrapEndpoint: b.LocalAPI.Endpoint,
|
||||
FingerprintAgrees: cfg.LocalAPI.Fingerprint == b.LocalAPI.Fingerprint,
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Printf("[ERROR] bootstrap: local_api endpoint DRIFT — %s says %q but %s says %q; "+
|
||||
"the controller is dialling the FORMER. Pin agrees: %v. Not auto-corrected (R-78 owns the "+
|
||||
"authority ruling) — fix the intended file and restart the controller.",
|
||||
d.ConfigPath, d.ConfigEndpoint, d.BootstrapPath, d.BootstrapEndpoint, d.FingerprintAgrees)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// EnglishMessage is the operator-tier alert body (operator events are English by convention).
|
||||
func (d *EndpointDrift) EnglishMessage() string {
|
||||
return fmt.Sprintf("local_api endpoint drift: controller.yaml=%s bootstrap.json=%s (pin agrees: %v) "+
|
||||
"— the controller is dialling controller.yaml's value; the agent may be listening on the other.",
|
||||
d.ConfigEndpoint, d.BootstrapEndpoint, d.FingerprintAgrees)
|
||||
}
|
||||
|
||||
// HungarianMessage is the customer-facing dashboard line, matching channelhealth's tone (short,
|
||||
// no addresses — the operator gets those in the event and the log).
|
||||
func (d *EndpointDrift) HungarianMessage() string {
|
||||
return "A tárolókezelő ügynök címe elavult a beállításokban."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user