R-270: the local_api drift detector also names a TOKEN-only divergence

A rotation that reached bootstrap.json but not controller.yaml left the
agent channel at 401 across restarts while the endpoint-only detector
stayed silent. The tokens are now compared (constant time, boolean only);
the token case gets its own banner key and operator message. Still
detection only - nothing is reconciled (R-78).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 21:36:34 +02:00
parent 33c397380e
commit 6fac245d24
6 changed files with 89 additions and 4 deletions
+45 -2
View File
@@ -12,6 +12,7 @@
package bootstrap
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
@@ -351,6 +352,11 @@ type EndpointDrift struct {
// 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
// EndpointAgrees is true when only the TOKEN moved (R-270): the address is right, the agent
// answers 401, and before R-270 nothing named it. TokenAgrees is a BOOLEAN for the same reason
// FingerprintAgrees is one — the token value is never stored, logged or sent.
EndpointAgrees bool
TokenAgrees bool
}
// DetectEndpointDrift compares controller.yaml's live local_api.endpoint against bootstrap.json's.
@@ -362,7 +368,12 @@ type EndpointDrift struct {
// 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.
// - the endpoints agree AND the tokens agree.
//
// R-270: a token rotation that rewrote bootstrap.json but not controller.yaml left the channel at
// HTTP 401 across restarts, and this detector — endpoint-only — stayed silent. It now also compares
// the token (constant-time, as a boolean only) and names that case; it still reconciles nothing
// (R-78). Pinned by TestR270_TokenOnlyDriftIsNamedNotLeaked.
//
// 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.
@@ -381,7 +392,9 @@ func DetectEndpointDrift(configPath string, cfg *config.Config, logger *log.Logg
if b.LocalAPI.Endpoint == "" || b.LocalAPI.Fingerprint == "" || b.LocalAPI.Token == "" {
return nil
}
if cfg.LocalAPI.Endpoint == b.LocalAPI.Endpoint {
endpointAgrees := cfg.LocalAPI.Endpoint == b.LocalAPI.Endpoint
tokenAgrees := subtle.ConstantTimeCompare([]byte(cfg.LocalAPI.Token), []byte(b.LocalAPI.Token)) == 1
if endpointAgrees && tokenAgrees {
return nil
}
d := &EndpointDrift{
@@ -390,6 +403,18 @@ func DetectEndpointDrift(configPath string, cfg *config.Config, logger *log.Logg
ConfigEndpoint: cfg.LocalAPI.Endpoint,
BootstrapEndpoint: b.LocalAPI.Endpoint,
FingerprintAgrees: cfg.LocalAPI.Fingerprint == b.LocalAPI.Fingerprint,
EndpointAgrees: endpointAgrees,
TokenAgrees: tokenAgrees,
}
if endpointAgrees {
if logger != nil {
logger.Printf("[ERROR] bootstrap: local_api TOKEN drift — %s and %s carry DIFFERENT tokens "+
"(endpoint %q agrees; pin agrees: %v). The controller presents %s's token, so the agent "+
"answers 401 if it was rotated. Not auto-corrected (R-78) — write the rotated token into "+
"%s too and restart the controller (R-270).",
d.ConfigPath, d.BootstrapPath, d.ConfigEndpoint, d.FingerprintAgrees, d.ConfigPath, d.ConfigPath)
}
return d
}
if logger != nil {
logger.Printf("[ERROR] bootstrap: local_api endpoint DRIFT — %s says %q but %s says %q; "+
@@ -402,6 +427,12 @@ func DetectEndpointDrift(configPath string, cfg *config.Config, logger *log.Logg
// EnglishMessage is the operator-tier alert body (operator events are English by convention).
func (d *EndpointDrift) EnglishMessage() string {
if d.EndpointAgrees {
return fmt.Sprintf("local_api token drift: controller.yaml and bootstrap.json carry different tokens "+
"(endpoint %s agrees, pin agrees: %v) — the controller presents controller.yaml's token; after a "+
"rotation write it there too and restart the controller (R-270).",
d.ConfigEndpoint, d.FingerprintAgrees)
}
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)
@@ -410,5 +441,17 @@ func (d *EndpointDrift) EnglishMessage() string {
// 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 {
if d.EndpointAgrees {
return "A tárolókezelő ügynök hozzáférési kulcsa elavult a beállításokban."
}
return "A tárolókezelő ügynök címe elavult a beállításokban."
}
// AlertKey is the dashboard banner's bundle key: the address case keeps its key; the token-only case
// (R-270) has its own, so the banner never claims the ADDRESS is stale when it is not.
func (d *EndpointDrift) AlertKey() string {
if d.EndpointAgrees {
return "alert.local_api_token_drift"
}
return "alert.endpoint_drift"
}