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
@@ -42,13 +42,19 @@ func TestEffectiveProtectedDropsCloudflaredWithoutToken(t *testing.T) {
// R-7b Scenario E, BOTH directions. Sharing is a customer-toggled feature, so the samba container can
// never be in the golden controller.yaml — the effective set must add it dynamically when sharing is
// ON (so a dead sharing service raises the same protected-container issue as a dead traefik) and must
// live (so a dead sharing service raises the same protected-container issue as a dead traefik) and must
// leave it out when sharing is OFF (so a box that never enabled it never reports a missing container).
// Red-proof: delete the `if smb.Enabled` append and the enabled case fails.
// Red-proof: delete the samba append and the enabled case fails.
//
// R-77 TIGHTENED THE "ON" CASE, and this test was updated with it: "on" now means
// Enabled AND UserSet, because reconcileSambaAt refuses to deploy without a household password. The
// previous fixture used Enabled alone and therefore asserted the very behaviour that produced the
// live false alarm on demo-hp (2026-07-26). The three-state matrix is in
// TestScenarioE_SambaProtectedOnlyWhenActuallyDeployed below.
func TestEffectiveProtectedTracksSharingToggle(t *testing.T) {
cfg := &config.Config{Stacks: config.StacksConfig{Protected: []string{"traefik", "felhom-controller"}}}
on := EffectiveProtected(cfg, settings.SMBSettings{Enabled: true})
on := EffectiveProtected(cfg, settings.SMBSettings{Enabled: true, UserSet: true})
if !contains(on, infra.SambaContainerName) {
t.Errorf("sharing ON: %q must be watched, got %v", infra.SambaContainerName, on)
}
@@ -65,3 +71,44 @@ func TestEffectiveProtectedTracksSharingToggle(t *testing.T) {
}
}
}
// R-77 Scenario E — the samba protected-set gate must mirror reconcileSambaAt's BOTH early returns.
//
// The live false alarm (demo-hp, 2026-07-26): sharing was enabled without a household password, so
// reconcileSambaAt deliberately did not deploy the container, but EffectiveProtected added it anyway
// and the box reported health=fail for a state the controller itself had chosen.
func TestScenarioE_SambaProtectedOnlyWhenActuallyDeployed(t *testing.T) {
cfg := &config.Config{Stacks: config.StacksConfig{
Protected: []string{"traefik", "cloudflared", "felhom-controller", "filebrowser"}}}
cfg.Infrastructure.CFTunnelToken = "tok" // keep cloudflared in, so the samba change is isolated
for _, tc := range []struct {
name string
smb settings.SMBSettings
want bool
why string
}{
{"(1) sharing OFF", settings.SMBSettings{Enabled: false, UserSet: false}, false,
"a box that never enabled sharing must stay quiet"},
{"(2) sharing ON, no password", settings.SMBSettings{Enabled: true, UserSet: false}, false,
"THE BUG: reconcileSambaAt refuses to deploy without a password — a deliberate state, not a fault"},
{"(3) sharing ON, password set", settings.SMBSettings{Enabled: true, UserSet: true}, true,
"the container really should be running — a dead one must STILL alarm (do not over-suppress)"},
} {
if got := contains(EffectiveProtected(cfg, tc.smb), infra.SambaContainerName); got != tc.want {
t.Errorf("%s: samba protected = %v, want %v — %s", tc.name, got, tc.want, tc.why)
}
}
// Not over-suppressed: the rest of the protected set is untouched in every sharing state.
for _, smb := range []settings.SMBSettings{
{Enabled: false}, {Enabled: true}, {Enabled: true, UserSet: true},
} {
set := EffectiveProtected(cfg, smb)
for _, must := range []string{"traefik", "cloudflared", "felhom-controller", "filebrowser"} {
if !contains(set, must) {
t.Errorf("smb=%+v: %q must always stay protected, got %v", smb, must, set)
}
}
}
}
+23 -8
View File
@@ -251,14 +251,28 @@ func checkDocker() error {
//
// - cloudflared is dropped when no tunnel token is configured (a LAN-only node legitimately runs
// without it, so it must not be reported as a missing protected container forever);
// - the samba container is ADDED when network sharing is switched on (R-7b). Sharing is a
// customer-toggled feature, so it can never appear in the golden controller.yaml — but once it
// IS on, a dead sharing service is exactly as customer-visible as a dead traefik and must raise
// the same protected-container issue → alert → Hungarian degradation e-mail. When sharing is
// off the container is absent from the set, so a box that never enabled it stays quiet.
// - the samba container is ADDED only when network sharing is switched on AND the household
// password has been set (R-7b, tightened by R-77). Sharing is a customer-toggled feature, so it
// can never appear in the golden controller.yaml — but once it is actually RUNNING, a dead
// sharing service is exactly as customer-visible as a dead traefik and must raise the same
// protected-container issue → alert → Hungarian degradation e-mail.
//
// The bring-up applies the same conditions (stacks.EnsureBaseStack for cloudflared, ensureSamba's
// `if !smb.Enabled { return }` for samba), so detection and deployment agree in both directions.
// THE COUPLING, and why it is spelled out: this set must mirror EVERY early return in
// stacks.reconcileSambaAt, because that function decides whether the container exists at all. It has
// TWO:
//
// if !smb.Enabled { return } // feature off
// if !smb.UserSet { return } // on, but no household password yet → deliberately NOT deployed
//
// R-77 exists because this comment previously claimed "detection and deployment agree in both
// directions" while citing only the first. The second was added later and never mirrored here, so
// enabling sharing without setting a password made the box report health=fail forever for a state
// the controller had deliberately chosen (observed live on demo-hp, 2026-07-26). A THIRD early
// return in reconcileSambaAt would need the same mirror — and this comment must be updated with it,
// because a comment asserting a guarantee the code no longer provides is how the bug came back.
//
// Deliberately NOT over-suppressed: sharing on WITH a password and a dead container still raises the
// issue. That is the case the protected set exists for.
//
// NOTE: the entries are CONTAINER names (checkProtectedContainers docker-inspects them). For the
// base stacks the container name happens to equal the stack name; for samba it does NOT — the stack
@@ -272,7 +286,8 @@ func EffectiveProtected(cfg *config.Config, smb settings.SMBSettings) []string {
}
out = append(out, name)
}
if smb.Enabled {
// Mirrors reconcileSambaAt's two early returns — see the coupling note above.
if smb.Enabled && smb.UserSet {
out = append(out, infra.SambaContainerName)
}
return out