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:
2026-07-26 09:13:52 +02:00
parent c7a3a90782
commit 9056f01fae
11 changed files with 648 additions and 19 deletions
@@ -1,10 +1,12 @@
package channelhealth
import (
"bytes"
"context"
"errors"
"io"
"log"
"strings"
"testing"
)
@@ -272,3 +274,99 @@ func TestProbe_SeamOnly(t *testing.T) {
t.Fatalf("checker should call the probe seam exactly once, got %d", p.calls)
}
}
// R-77 Scenario F — the confirmed-down line must distinguish BORN-DOWN from a real transition.
//
// The debounce branch used to seed an unseeded state to "up", so a channel that had NEVER reached
// its agent logged `up->down:unreachable` and orUnseeded was dead code. On 2026-07-25 that made the
// log imply a working channel degrading, when in truth neither controller had ever been up — it
// actively misdirected the first read of the incident.
//
// THE OTHER HALF OF THIS TEST IS THE POINT: alerting, debounce and dashboard behaviour must be
// byte-for-byte unchanged. A logging fix that shifts alerting is a regression in a cosmetic disguise,
// so the sink calls are asserted in COUNT and ARGUMENTS, not just the log string.
func TestScenarioF_BornDownLogsUnseededNotUp(t *testing.T) {
var logbuf bytes.Buffer
sink := &fakeSink{}
c := New(nil, sink, log.New(&logbuf, "", 0))
// A debounced reason (connection refused → unreachable), failing from the very first probe.
p := &scriptedProbe{steps: []struct {
cons bool
err error
}{step(false, errors.New("dial tcp 192.168.0.87:8443: connect: connection refused"))}}
c.probe = p.fn
// Probe 1: debounced, suppressed — no dashboard flip, no alert.
if err := c.Check(context.Background()); err != nil {
t.Fatal(err)
}
if sink.dashDown || len(sink.downs) != 0 {
t.Fatalf("probe 1 must be suppressed by debounce: dashDown=%v downs=%d", sink.dashDown, len(sink.downs))
}
// The placeholder must NOT be the string "up".
if got := c.State(); got == "up" {
t.Error("an unseeded checker must not report state \"up\" after a suppressed first failure")
}
// Probe 2: confirmed down.
if err := c.Check(context.Background()); err != nil {
t.Fatal(err)
}
logged := logbuf.String()
if !strings.Contains(logged, "unseeded->down:unreachable") {
t.Errorf("born-down must log \"unseeded->down:unreachable\"; got:\n%s", logged)
}
if strings.Contains(logged, "up->down") {
t.Errorf("a channel that was never up must NOT log \"up->down\"; got:\n%s", logged)
}
// --- the non-change assertions ---
if len(sink.downs) != 1 {
t.Fatalf("exactly ONE down alert must fire (F2 born-down behaviour unchanged), got %d", len(sink.downs))
}
got := sink.downs[0]
if got.reason != ReasonUnreachable || got.eventType != "agent_channel_unreachable" || got.severity != "warning" {
t.Errorf("alert arguments changed: %+v — Part 3 is a LOGGING fix only", got)
}
if !sink.dashDown || sink.dashReason != ReasonUnreachable {
t.Errorf("dashboard must be set down/unreachable, got down=%v reason=%q", sink.dashDown, sink.dashReason)
}
if sink.recovered != 0 {
t.Errorf("no recovery must fire, got %d", sink.recovered)
}
if p.calls != 2 {
t.Errorf("debounce threshold unchanged: expected 2 probes, got %d", p.calls)
}
}
// A REAL up->down transition must still log "up->down" — the fix must not relabel everything.
func TestScenarioF_RealTransitionStillLogsUp(t *testing.T) {
var logbuf bytes.Buffer
sink := &fakeSink{}
c := New(nil, sink, log.New(&logbuf, "", 0))
p := &scriptedProbe{steps: []struct {
cons bool
err error
}{
step(false, nil), // observed UP for real
step(false, errors.New("connect: connection refused")),
step(false, errors.New("connect: connection refused")),
}}
c.probe = p.fn
for i := 0; i < 3; i++ {
if err := c.Check(context.Background()); err != nil {
t.Fatal(err)
}
}
logged := logbuf.String()
if !strings.Contains(logged, "up->down:unreachable") {
t.Errorf("a genuine transition must still log \"up->down:unreachable\"; got:\n%s", logged)
}
if strings.Contains(logged, "unseeded->down") {
t.Errorf("an observed-up channel must not log \"unseeded->down\"; got:\n%s", logged)
}
if len(sink.downs) != 1 {
t.Errorf("exactly one down alert, got %d", len(sink.downs))
}
}