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:
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user