Files
felhom.eu/documentation/audits/DIAG-agent-channel-2026-07-26.md
T
admin 9e94479449 DIAG: agent-channel banner is a stale controller.yaml endpoint, not island fallout
Read-only diagnostic; nothing restarted, reconfigured or deployed.

CAUSE (H2, supported): the island migration rewrote bootstrap.json
(169.254.253.1:8443) but the controller reads controller.yaml, which still
holds the pre-island LAN address on both boxes -- mtimes 2026-07-21 and
2026-07-23, i.e. days BEFORE the migration. ensureLocalAPI
(bootstrap.go:254) returns early when local_api.endpoint is non-empty, so it
fills a MISSING endpoint and never reconciles a STALE one. The agent no longer
binds the LAN address, hence "connection refused".

H1 refuted: every island leg is healthy on both hosts -- agent active and bound
169.254.253.1:8443, vmbr9 up, guest eth1 up, TCP from the guest to the island
is OPEN. The island works; nothing is using it.

H3 refuted: hub-persisted events put the first alarm at 2026-07-25 12:44/12:46,
~17.5h before the v0.172.0 deploy. Today's re-fires are restart artifacts --
channel state is in-memory by design.

H4 refuted: felhom-agent is active on both hosts.

Also: the logged "up->down" is an ARTIFACT -- the debounce branch seeds an
unseeded state to "up", so the channel was born-down and has never been up.

Symptom B confirmed: EffectiveProtected gates cloudflared on a token predicate
but samba on smb.Enabled alone, so sharing-without-password reports FAIL for a
deliberately-undeployed stack. The function's own doc comment asserts the parity
that is broken. Caused by leg 4 of the R-75 task. Blast radius: zero emails so
far, but demo-hp flipped ok->fail and the health_critical path has reached the
CUSTOMER channel historically. The English issue strings are whole-surface, not
a one-off.

Fixes described, none applied.
2026-07-26 08:51:45 +02:00

354 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DIAG — agent-channel banner + felhom-samba false alarm (2026-07-26)
**Read-only diagnostic. Nothing was restarted, reloaded, reconfigured or deployed.** No
`systemctl` action, no `ifreload`/`ifup`, no config edit, no runbook leg, no sharing change, no
container created or removed. Every probe was a read, a log tail, or a kernel *route lookup*
(`ip route get`, which sends no packets). Two TCP connect attempts were made from guest shells —
explicitly sanctioned by the brief as non-mutating.
**Verdict up front, and it is unambiguous:**
> The island migration rewrote **`bootstrap.json`** but the controller reads **`controller.yaml`**,
> and `ensureLocalAPI` only fills a `local_api.endpoint` that is **missing** — never one that is
> **stale**. Both controllers are still dialling the pre-island LAN address, where the agent no
> longer listens. **H2 SUPPORTED. H1, H3, H4 all REFUTED by direct observation.**
Symptom B is confirmed, and is a **side-effect of leg 4 of the R-75 task** (sharing enabled on
demo-hp without a household password) — see §5, including the honest note that this diagnostic's own
author caused it.
---
## 1. D0 — access inventory
| Target | Reachable | Path | User | sudo |
|---|---|---|---|---|
| demo-felhom PVE host | **YES** | `ssh felhom-pve` (Tailscale alias) | `root` (uid 0) | n/a — already root |
| demo-felhom guest 9201 | **YES** | `pct exec 9201` from the host | root in guest | n/a |
| demo-hp PVE host | **YES** | `sshpass … root@demo-hp` (TS `100.76.96.79`) | `root` (uid 0) | n/a |
| demo-hp guest 9201 | **YES** | `pct exec 9201` from the host | root in guest | n/a |
**How demo-hp was reached, stated plainly:** it has no baked operator key. I used the **documented
G1 break-glass** — the root password vaulted in the hub `host_recovery/demo-hp-bb76ea`, per
`felhom-controller/CLAUDE.md:133` and `operations/nodes.md`. That is the sanctioned and only access
path to that box, not an escalation. The hub DB copy and the plaintext password were `shred -u`'d
immediately after use, and no credential appears in this document.
**No access gaps.** Everything §D1–§D6 asked for was collectable except one item, recorded in §7.
**Exit-code discipline.** Most probes ran through `ssh → pct exec → bash -c` pipelines where `$?` is
the last stage's status, so I read **stderr and content**, not rc. Three probes failed *visibly* and
were re-run: two quoting failures (fixed by base64-encoding the probe script — the
`ssh → pct exec → bash -c` escaping chain mangles `(`, `'` and `$`), and one wrong SQLite column
name. `agent.json` reported "unreadable" — recorded in §7 rather than glossed.
---
## 2. D1/D2/D3 — Symptom A evidence table
| | **demo-felhom** | **demo-hp** |
|---|---|---|
| Reason | `unreachable` | `unreachable` |
| Which branch | `checker.go:94-96` (`connection refused`) — **NOT** the `default` unknown case | same |
| Raw error | `agentapi: GET /storage: Get "https://192.168.0.162:8443/storage": dial tcp 192.168.0.162:8443: connect: connection refused` | `agentapi: GET /storage: Get "https://192.168.0.87:8443/storage": dial tcp 192.168.0.87:8443: connect: connection refused` |
| Transition logged | `up->down:unreachable` | `up->down:unreachable` (preceded by `transient down (unreachable, 1/2)`) |
| **Actually born-down?** | **YES — see §2.1** | **YES** |
| Ever recovered? | **No** `agent_channel_recovered` since 2026-07-20 | **No**, ever |
| First seen (hub, persisted) | **2026-07-25 12:46:42 UTC** | **2026-07-25 12:44:19 UTC** |
| `controller.yaml` endpoint | `192.168.0.162:8443` **(old LAN)** | `192.168.0.87:8443` **(old LAN)** |
| `controller.yaml` mtime | **2026-07-21 08:29:31** | **2026-07-23 09:53:39** |
| `bootstrap.json` endpoint | `169.254.253.1:8443` **(island)** | `169.254.253.1:8443` **(island)** |
| `bootstrap.json` mtime | **2026-07-25 12:44:41** | **2026-07-25 12:42:17** |
| **Agree?** | **NO** | **NO** |
| token / fingerprint | both present; fingerprint 64 chars | both present; fingerprint 64 chars |
| Agent listening | `169.254.253.1:8443` **only** | `169.254.253.1:8443` **only** |
| `felhom-agent` | `active`, since 2026-07-25 14:44:37 CEST | `active`, since 2026-07-25 14:42:13 CEST |
| `vmbr9` | `169.254.253.1/30`, up | `169.254.253.1/30`, up |
| `vmbr0` | `192.168.0.162/24`, normal | `192.168.0.87/24`, normal |
| Persistent vmbr9 stanza | present in `/etc/network/interfaces` | present |
| Guest `net1` | `eth1,bridge=vmbr9,ip=169.254.253.2/30` | same |
| Guest `eth1` | `169.254.253.2/30`, up | `169.254.253.2/30`, up |
| Host uptime | up since **2026-07-22 19:13** (no reboot since migration) | up since **2026-07-23 20:30** (no reboot since migration) |
| TCP **from guest** → island `169.254.253.1:8443` | **OPEN** | **OPEN** |
| TCP **from guest** → old LAN `:8443` | **REFUSED** | **REFUSED** |
| TCP **from container** | **not collectable** — the image has no `nc`/`wget`/`ip`; answered by route simulation instead (§2.2) | same |
*(Tokens and fingerprints were redacted at the point of collection — the probe script rewrote them to
`<REDACTED>` before the value ever left the box.)*
### 2.1 `up->down` is an ARTIFACT — the channel was never up
`checker.go` seeds an unseeded state to `"up"` inside the debounce branch:
```go
if cls.debounce && c.consecutiveDown < debounceThreshold {
if c.state == "" { c.state = "up" } // <-- unseeded assumed up until confirmed
```
So the sequence is: unseeded → probe 1 fails → state artificially set `"up"` → probe 2 fails →
logged as `up->down`. **Neither controller has ever reached the agent since it restarted.** This is
not a degradation of a working channel; it is a wrong address read at startup. Do not read the
`up->` in that log line as evidence the channel once worked.
### 2.2 Container → island: route-simulated, not packet-proven
The controller runs `NetworkMode=bridge` (`172.17.0.2` on `docker0`, `172.18.0.4` on
`traefik-public`), so the guest-shell result does not automatically transfer. The image ships no
`nc`, `wget` or `ip`, and creating a probe container would have been a mutation, so I asked the
kernel for its forwarding decision instead — a pure lookup:
```
ip route get 169.254.253.1 from 172.17.0.2 iif docker0
-> 169.254.253.1 from 172.17.0.2 dev eth1 cache iif docker0
ip route get 169.254.253.1 from 172.18.0.4 iif br-<traefik>
-> 169.254.253.1 from 172.18.0.4 dev eth1 cache iif br-<traefik>
```
Identical on both boxes. Supporting conditions all present: `net.ipv4.ip_forward=1`; `rp_filter=2`
(loose, will not drop the asymmetric-looking reply); `-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j
MASQUERADE` (and the same for `172.18.0.0/16`), so the source becomes `169.254.253.2`;
`DOCKER-FORWARD` ACCEPTs `-i docker0` and `-i br-<traefik>`.
**This is strong but not conclusive** — it is a routing-table verdict, not a delivered packet. It
matters because the R-50 spike chose link-local *precisely because* RFC-3927 "structurally does not
route" (`SPIKE-island-bridge-2026-07-25.md:155-161`), and that spike's P5 proved the island reachable
**from the guest**, never from a bridged container. **The fix in §6.1 should be validated by watching
for `agent channel recovered`, not assumed.**
---
## 3. D2 — when it started (the H3 killer)
`agent_channel_unreachable`, from the **hub's persisted `events` table**, which survives the
container-log truncation the brief warned about:
```
2026-07-25 12:31:55 drill-r50 agent_channel_unreachable
2026-07-25 12:44:19 demo-hp agent_channel_unreachable <-- FIRST
2026-07-25 12:46:42 demo-felhom agent_channel_unreachable <-- FIRST
2026-07-26 06:16:07 demo-felhom (re-fire after v0.172.0 restart)
2026-07-26 06:19:24 demo-hp (re-fire)
2026-07-26 06:25:16 demo-felhom (re-fire)
2026-07-26 06:25:41 demo-hp (re-fire)
```
Alongside, on demo-hp: `controller_started (0.167.1)` at **2026-07-25 12:42:24** — i.e. the migration
restarted the controller, which re-read the stale `controller.yaml`, two minutes before the first
alarm. `bootstrap.json` mtime is 12:42:17 on that box: **the migration wrote bootstrap.json and
restarted the controller within seven seconds, and the controller ignored the new file.**
### VERDICT: the banner PRE-DATES today's v0.172.0 deploy by ~17.5 hours. **H3 is dead.**
Today's 06:1606:25 events are re-fires: channel state is in-memory (`checker.go`: *"No persistence —
the state is re-derived each run"*), so every controller restart re-alarms. Four controller restarts
today (two per box, from the two rebuilds) explain them exactly.
---
## 4. Hypothesis verdicts
| | Verdict | The observation that decided it |
|---|---|---|
| **H1 — island fallout** | **REFUTED** | Every island leg is healthy on both hosts: `felhom-agent` `active`, bound `169.254.253.1:8443`; `vmbr9` up at `169.254.253.1/30` with a persistent `/etc/network/interfaces` stanza; guest `net1`/`eth1` up at `169.254.253.2/30`; `vmbr0` untouched. **TCP from the guest to `169.254.253.1:8443` is OPEN on both boxes.** The island works; nothing is using it. |
| **H2 — endpoint in two places** | **SUPPORTED — this is the cause** | `bootstrap.json` = `169.254.253.1:8443` (mtime = migration). `controller.yaml` = the old LAN address (mtime **predates** the migration by 24 days). The controller dials exactly what `controller.yaml` says. Mechanism named in §4.1. |
| **H3 — R-75 / v0.172.0** | **REFUTED** | First alarm 2026-07-25 12:44/12:46; first v0.172.0 deploy 2026-07-26 ~06:14. R-75 touched no agent, network or `local_api` surface. It is a coincidence of *noticing*, not of cause. |
| **H4 — genuinely down** | **REFUTED** | `systemctl is-active felhom-agent``active` on both hosts, since the migration, listening on the island address. The agent is up; the controller is knocking on the wrong door. |
### 4.1 The exact mechanism (source-confirmed)
`internal/bootstrap/bootstrap.go:254`:
```go
func ensureLocalAPI(configPath string, cfg *config.Config, logger *log.Logger) *config.Config {
if cfg == nil || cfg.LocalAPI.Endpoint != "" {
return cfg // already has the agent path → nothing to do
}
```
`MaybeIngest` short-circuits for an already-configured guest (`cfg.Customer.ID != ""`) and delegates
to `ensureLocalAPI`, whose contract is documented as *"merges it in from bootstrap.json **if
missing**"*. It fills an **empty** endpoint; it never reconciles a **stale** one.
So: **`controller.yaml` is generated from `bootstrap.json` exactly once, at Day-0, and maintained
independently thereafter.** Anything that later rewrites `bootstrap.json` — the island migration —
silently fails to reach the controller. The mtimes are the proof: `bootstrap.json` 2026-07-25,
`controller.yaml` 2026-07-21 / 2026-07-23.
The stale comment the brief flagged is real and is a contributing signpost —
`config/config.go:75` still documents the field as *"host bridge IP:port, e.g. 192.168.0.162:8443"*,
i.e. the pre-island shape, and that is literally the value now sitting in the file.
---
## 5. Symptom B — `Protected container not running: felhom-samba`
**Mechanism CONFIRMED.**
- `docker ps -a --filter name=felhom-samba` on demo-hp → **empty**; the container was never created.
- Settings: `{'enabled': True, 'server_name': 'FELHOM-HP'}`**no `user_set` key**, i.e. the
household password has never been set. (Read only; nothing changed.)
- `reconcileSambaAt` correctly refuses to deploy: `if !smb.UserSet { … "deploy skipped — household
SMB password not set yet" … return nil }`.
- `EffectiveProtected` (`healthcheck.go:267`) nevertheless adds it:
```go
for _, name := range cfg.Stacks.Protected {
if name == "cloudflared" && cfg.Infrastructure.CFTunnelToken == "" { continue } // <-- has a predicate
out = append(out, name)
}
if smb.Enabled { // <-- no password / deployed predicate
out = append(out, infra.SambaContainerName)
}
```
Live confirmation:
```
[DEBUG] [monitor] Checking 5 protected containers: [traefik cloudflared felhom-controller filebrowser felhom-samba]
[DEBUG] [monitor] Protected containers missing: [felhom-samba]
[DEBUG] [monitor] Final status: fail (issues=1, warnings=0, info=5)
```
**The diagnosis holds:** sharing enabled + no password → samba deliberately not deployed → the health
monitor reports FAIL for an intentional state.
**A sharper finding than the brief anticipated: the function's own doc comment asserts the invariant
that is broken.** `healthcheck.go:260-261` says *"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."* That was true when written. `reconcileSambaAt`
now has a **second** early return on `!smb.UserSet` that the protected set never mirrored, so the two
sides silently diverged and the comment documents a guarantee the code no longer provides.
**I caused this state.** Sharing was enabled on demo-hp at ~06:20 UTC today by leg 4 of the R-75 task
(explicitly authorised by that brief). The hub data shows the box was `ok` for 124 consecutive
reports and flipped at the first cycle after that change.
---
## 6. D6 — blast radius
### Symptom A — 4 operator emails, no customer email
`notification_log`, `event_type LIKE 'agent_channel%'`:
| when (UTC) | box | channel | status |
|---|---|---|---|
| 2026-07-25 12:44:20 | demo-hp | operator | sent |
| 2026-07-25 12:46:43 | demo-felhom | operator | sent |
| 2026-07-26 06:16:08 | demo-felhom | operator | sent |
| 2026-07-26 06:19:24 | demo-hp | operator | sent |
Four, not one per cycle — the checker's `alerted` flag suppresses repeats within a down-spell, and
the hub applies its own cooldown (7 hub *events* today produced 2 emails). `warning` severity is
operator-only, so **no customer was emailed**. `drill-r50` also alarmed twice on 2026-07-25, which is
consistent: it is on the same island and was the migration's first subject.
### Symptom B — zero emails so far, but a customer-facing one is plausibly pending
- **Zero** notifications mentioning `samba` or `Protected`, ever.
- demo-hp report health: `ok` ×124 through 2026-07-26 06:23:47, then **`fail` at 06:38:42** — one
fail cycle at the time of the snapshot. demo-felhom stayed `ok` throughout (control: sharing there
has a password and samba is running).
- **But the path exists and has fired before.** `health_degraded` / `health_critical` are real
notification types, and `health_critical` has gone to the **customer** channel three times
historically (2026-02-24, 2026-06-15, 2026-07-18: *"Rendszer állapot kritikus (volt: ok)"*). A
sustained `fail` on demo-hp is therefore likely to produce an operator **and possibly a customer**
email on the next transition evaluation. It had not fired as of the last snapshot. **This is a live,
pending consequence, not a closed one.**
### The English-string defect is WHOLE-SURFACE, not one-off
Every `report.Issues` and `report.Warnings` string in the codebase is English:
```
healthcheck.go:56 "SSD disk usage critical: %.0f%%"
healthcheck.go:82 "HDD disk usage critical: %.0f%%"
healthcheck.go:150 "Docker: %v"
healthcheck.go:169 "Protected container not running: %s"
healthcheck.go:64/87/97/115/133 "SSD/HDD disk usage high", "Memory usage high", "CPU usage high", "Temperature high"
```
So this is not a slip in one line — the whole health-issue surface is operator-English and is
rendered on a customer-facing Hungarian page. Nothing was changed.
---
## 7. Not collected, and what it would take
| Item | Why | What would get it |
|---|---|---|
| Live TCP connect **from inside the controller container** to `169.254.253.1:8443` | the image ships no `nc`/`wget`/`ip`, and creating a probe container would be a mutation this task forbids | one `docker run --rm --network container:felhom-controller alpine nc -z 169.254.253.1 8443` — a throwaway container, no touch to the controller. Answered indirectly by the route simulation in §2.2, which is strong but not a delivered packet |
| `agent.json` `listen_addr` value | `/etc/felhom-agent/agent.json` read as "unreadable" on both hosts even as root — the path is likely different in v0.96.0 | `systemctl cat felhom-agent` for the actual config path, or the agent's own startup log. **`ss` already proves the effective bind** (`169.254.253.1:8443`), so this is corroboration, not a gap in the conclusion |
| Whether a `health_critical` email will fire for demo-hp | only one `fail` cycle had elapsed at snapshot time | re-check `notification_log` in an hour, or read the hub's health-transition checker thresholds |
---
## 8. Proposed fixes — DESCRIBED, NOT APPLIED
### 8.1 The endpoint (Symptom A) — the actual outage
**Immediate, per box:** set `local_api.endpoint` in `controller.yaml` to `169.254.253.1:8443` on both
guests and restart the controller. Two boxes, one line each. **Safe-obvious in shape, but validate
rather than assume** — §2.2's link-local routability from a bridged container is route-simulated, not
packet-proven. Success signal: `[INFO] [channel] agent channel recovered` plus an
`agent_channel_recovered` hub event.
**The real fix — `ensureLocalAPI` (`internal/bootstrap/bootstrap.go:254`) reconciles rather than only
fills.** Its guard `if cfg.LocalAPI.Endpoint != "" { return cfg }` is what made the migration a no-op
for the controller. If `bootstrap.json` carries a `local_api` block that differs from
`controller.yaml`, `bootstrap.json` should win (it is the per-guest source of truth the agent side
writes) and the file should be rewritten. **Needs a small spike, not a one-liner:** it changes which
file is authoritative on every boot, so it must not clobber a legitimately setup-wizard-configured or
hand-repaired guest, and the token/fingerprint must be reconciled atomically with the endpoint or a
half-updated triple is worse than a stale one. Also worth deciding whether a mismatch should be a
loud operator event in its own right — right now the two files can disagree indefinitely in silence.
**Adjacent, trivial:** `config/config.go:75`'s comment still documents the pre-island shape
(*"host bridge IP:port, e.g. 192.168.0.162:8443"*). Safe-obvious doc fix.
**Process, not code:** `RUNBOOK-island-migration.md` should gain a post-migration verification step
that asserts `controller.yaml` and `bootstrap.json` agree, and that the channel recovered. The
migration reported success while leaving every controller on the fleet blind to its agent for 18 h.
### 8.2 `EffectiveProtected` (Symptom B)
`internal/monitor/healthcheck.go:267` — gate samba on the same predicate the bring-up uses, i.e.
`smb.Enabled && smb.UserSet`, mirroring the cloudflared branch five lines above. **Safe-obvious**, one
condition, and it makes the function's own doc comment true again — that comment at L260-261 must be
updated in the same change, since it currently asserts a parity that does not hold. A red-proof is
easy: enabled-without-password must yield a protected set excluding samba, and enabled-with-password
must include it.
### 8.3 The English strings on a Hungarian page
Whole-surface (§6), so this is a **copy/i18n arc, not a one-line fix** — every `report.Issues` and
`report.Warnings` producer, plus a decision about where translation belongs (at the producer, or at
the render/notification boundary where operator-English and customer-Hungarian already diverge). The
`health_critical` customer email suggests the boundary already exists and these strings simply bypass
it. **Needs a spike** to pick the seam; the strings themselves are then mechanical.
---
## 9. Observations — noticed, not acted on
1. **`drill-r50` is also alarming** (2026-07-25 10:04, 10:11, 10:14, 12:31) and was the migration's
first subject. If that VM still exists it is likely in the same stale-endpoint state. Out of scope
here; worth a look when the fix lands. Its 10:0410:14 alarms *precede* the demo boxes' 12:4x, so
it may also carry earlier, unrelated spike churn.
2. **`expected_backup_missed` is firing nightly on all three customers** (2026-07-26 03:00, including
a **customer**-channel email to demo-felhom: *"newest backup is 176h0m0s old"* = 7.3 days). That is
a separate, louder problem than either symptom here, and it reached a customer channel. Almost
certainly downstream of the same outage — the agent channel is how PBS backup state is learned —
but I did not verify the causal link.
3. **The channel checker has no persistence by design**, so a restart re-alarms and the operator sees
a "new" alert for an old condition. That is defensible (state re-derivation is the model) but it
is why this looked like it started today.
4. **`rp_filter=2` (loose) on both guests** is what makes the container→island path viable at all;
under strict `rp_filter=1` the asymmetry could bite. Worth knowing before anyone hardens it.
5. **demo-hp has been up 2 d 12 h and demo-felhom 3 d 13 h — neither has rebooted since the
migration.** So the persistent `vmbr9` stanza in `/etc/network/interfaces` is present but has
**never been exercised across a boot** on either box. The island's reboot-survival remains
untested in the field.
6. **Sharing is still ON with no password on demo-hp**, which is what keeps Symptom B live. Left
exactly as found, per the prime directive — but it means the box will keep reporting `fail` until
either the fix lands or sharing is turned off.