🏠 Homelab Known Fixes

167 fixes
AdGuard DNS listener frozen — process up, port bound, queries dropped critical
DNS queries time out from all clients • dig @192.168.42.27 times out • AdGuardHome process is running and port 53 is bound
adguard dns frozen sessions-db   last seen: 2026-06-23

Symptoms


Cause

Corrupted sessions database (/opt/AdGuardHome/data/sessions.db) causes AdGuard to freeze internally after startup. The process binds the port but the DNS handler never becomes functional.

Diagnosis

ssh root@192.168.42.27 "dig @127.0.0.1 google.com +short +time=3"

Times out → DNS handler frozen

ssh root@192.168.42.27 "systemctl is-active AdGuardHome && ss -ulnp | grep 53"

active + port bound → process up but non-functional

Confirm adguard2 is carrying the load

dig @192.168.42.89 google.com +short

Fix

ssh root@192.168.42.27 << 'EOF'
systemctl stop AdGuardHome
pkill -9 AdGuardHome 2>/dev/null; sleep 1
mv /opt/AdGuardHome/data /opt/AdGuardHome/data.bak
mkdir /opt/AdGuardHome/data
systemctl start AdGuardHome
sleep 5
dig @127.0.0.1 google.com +short +time=3
EOF

Restore data (filters, query log, stats)

ssh root@192.168.42.27 << 'EOF' systemctl stop AdGuardHome cp /opt/AdGuardHome/data.bak/stats.db /opt/AdGuardHome/data/ cp /opt/AdGuardHome/data.bak/querylog.json /opt/AdGuardHome/data/ cp /opt/AdGuardHome/data.bak/querylog.json.1 /opt/AdGuardHome/data/ mv /opt/AdGuardHome/data.bak/filters /opt/AdGuardHome/data/filters systemctl start AdGuardHome sleep 3 dig @127.0.0.1 google.com +short +time=3 EOF ssh root@192.168.42.27 "rm -rf /opt/AdGuardHome/data.bak"

Verify

dig @192.168.42.27 google.com +short
dig @192.168.42.27 seeder-daemon.compellinglylowbrow.org +short

Must return 100.64.0.4

Note on upstream DNS

Use 9.9.9.9 (plain UDP) not https://dns10.quad9.net/dns-query (DoH hostname). Plain IP avoids the bootstrap catch-22 where AdGuard needs DNS to resolve its own upstream. DoH via IP (https://9.9.9.9/dns-query) is also safe.
AdGuard wildcard DNS rewrite reverted to LAN IP critical
most services unreachable from remote network • dig @100.64.0.5 returns 192.168.42.45 instead of 100.64.0.4 • cycling Tailscale doesn't fix it
adguard dns wildcard-rewrite remote-access   last seen: 2026-06-02

Symptoms

See also: adguard-headscale-must-resolve-to-lan-ip, client-tailscale-session-down-blocks-fqdn

Cause

The AdGuard DNS rewrite for *.compellinglylowbrow.org reverted from 100.64.0.4 (Caddy's Headscale IP) to 192.168.42.45 (Caddy's LAN IP). From a remote network, 192.168.42.45 is unreachable. Before assuming this is the cause: if dig @192.168.42.27 (or @adguard2) already returns the correct 100.64.0.4 answer, AdGuard is fine and this is not your issue -- see client-tailscale-session-down-blocks-fqdn.md instead, which produces the same surface symptom (FQDN broken, direct LAN access to hosts still works) but from the querying client's own Tailscale session being down, not from AdGuard's config.

Correct state (both AdGuard instances must match)

rewrites:
  - domain: '*.compellinglylowbrow.org'
    answer: 100.64.0.4               # Caddy's Headscale IP — DO NOT change to LAN IP
  - domain: adguard.compellinglylowbrow.org
    answer: 192.168.42.27            # Recovery hatch
  - domain: adguard2.compellinglylowbrow.org
    answer: 192.168.42.89            # adguard2 recovery hatch
  - domain: headscale.compellinglylowbrow.org
    answer: 192.168.42.45            # MUST be Caddy's LAN IP

Diagnosis

ssh root@192.168.42.27 "grep -A2 'compellinglylowbrow' /opt/AdGuardHome/AdGuardHome.yaml"

Fix

sed -i 's/answer: 192.168.42.45/answer: 100.64.0.4/g' /opt/AdGuardHome/AdGuardHome.yaml

Fix adguard recovery entry back (sed above will have changed it)

sed -i '/domain: adguard.compellinglylowbrow.org/{n;s/answer: 100.64.0.4/answer: 192.168.42.27/}' /opt/AdGuardHome/AdGuardHome.yaml grep -A2 "compellinglylowbrow" /opt/AdGuardHome/AdGuardHome.yaml systemctl restart AdGuardHome
Repeat on adguard2 (192.168.42.89).

Verify

dig @100.64.0.5 seeder-daemon.compellinglylowbrow.org

Must return 100.64.0.4

Automated remediation

The watchdog daemon (192.168.42.229) matches this pattern automatically via watchdog/playbooks/adguard-wildcard-rewrite-reversion.yaml (playbook_status: complete) and applies the fix above to both AdGuard instances without waiting for a human, then verifies and notifies via ntfy. This is the one entry in known-fixes/ currently marked auto_remediate: automated — everything else is none or candidate.
headscale.compellinglylowbrow.org must resolve to Caddy's LAN IP critical
multiple nodes drop off Headscale simultaneously • tailscale up hangs or fails with connection timeout • all nodes last-seen timestamps match same time
adguard dns headscale bootstrap catch22   last seen: 2026-06-03

Symptoms

See also: tailscale-up-hangs-adguard-dns, adguard-dns-rewrite-reversion

Cause

headscale.compellinglylowbrow.org is caught by the wildcard rewrite and resolves to 100.64.0.4. Nodes not yet on the tailnet can't reach 100.64.0.4, so they can't reach the Headscale control server to register. Why 192.168.42.45 and not 192.168.42.177: Headscale itself does not terminate TLS. Caddy does — it proxies to headscale on the backend.

Required AdGuard rewrite (both AdGuard AND AdGuard2)

- Domain: headscale.compellinglylowbrow.org - Answer: 192.168.42.45 (Caddy's LAN IP)

Verify

nslookup headscale.compellinglylowbrow.org

Must return 192.168.42.45, NOT 100.64.0.4

curl -s https://headscale.compellinglylowbrow.org/health

Must return {"status":"pass"}

Re-register a node after fixing DNS

ssh headscale headscale preauthkeys create --user 1 --reusable --expiration 1h
tailscale up --login-server https://headscale.compellinglylowbrow.org --authkey <key> --accept-dns=false --force-reauth
Caddy custom binary replaced by apt — Porkbun DNS module missing critical
Caddy fails to start after apt upgrade • module not registered: dns.providers.porkbun • caddy list-modules | grep porkbun returns empty
caddy apt xcaddy porkbun tls   last seen: 2026-06-06

Symptoms


Cause

apt upgrade overwrote the custom xcaddy-built binary (which includes the Porkbun DNS-01 plugin) with the stock Caddy package.

Verify

/usr/bin/caddy list-modules | grep porkbun

Empty output = binary has been replaced

Fix — rebuild with xcaddy

# 1. Clean Go build cache first (LXC rootdisk is ~8GB)
go clean -cache
df -h   # verify at least 600MB free

2. Reinstall xcaddy if missing

go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest export PATH=$PATH:$(go env GOPATH)/bin

3. Build directly to /usr/bin/caddy

xcaddy build --with github.com/caddy-dns/porkbun --output /usr/bin/caddy

4. Clean cache after build

go clean -cache && go clean -modcache

5. Restore permissions and restart

chown root:root /usr/bin/caddy chmod 755 /usr/bin/caddy setcap cap_net_bind_service=+ep /usr/bin/caddy systemctl start caddy && systemctl status caddy

6. Verify

/usr/bin/caddy list-modules | grep porkbun

Should return: dns.providers.porkbun

Prevention

apt-mark hold caddy
apt-mark showhold | grep caddy   # verify
Malformed cron schedule causes service cascade failure across proxmox-nuc critical
multiple unrelated services crash simultaneously • services self-recover within minutes with no intervention • DNS returns no answer at all (not misrouted)
cron cascade proxmox-nuc rsync   last seen: 2026-06-20

Symptoms

Root cause of: caddy-down, adguard-down, headscale-down

Cause

Cron lines written as * 1 * * * instead of 0 1 * * *. Cron fires a brand-new rsync invocation every minute for the entire hour. With no lockfile, 60 rsync processes stack up against the same source tree, overwhelming the host.

Diagnosis

ssh root@192.168.42.25 "crontab -l | grep rsync"

Look for "* 1 * * *" instead of "0 1 * * *"

ssh root@192.168.42.25 "journalctl --since '<window>' | grep -i rsync"

60 invocations per hour = confirmed

Fix

ssh root@192.168.42.25 "crontab -l > /tmp/crontab.bak"

ssh root@192.168.42.25 "crontab -l | sed \
  -e 's|^\* 1 \* \* \* rsync|0 1 * * * /usr/bin/flock -n /run/lock/nuc-ssd-rsync.lock ionice -c2 -n7 nice -n10 rsync|' \
  -e 's|^\* 2 \* \* \* rsync|0 2 * * * /usr/bin/flock -n /run/lock/nuc-ssd-rsync.lock ionice -c2 -n7 nice -n10 rsync|' \
  | crontab -"

ssh root@192.168.42.25 "crontab -l | grep rsync"   # verify
Also update /root/rsync_music_to_nastynas.sh's LOCKFILE to /run/lock/nuc-ssd-rsync.lock so all three jobs serialize against each other.

Note

Dropping -z from rsync calls is a free efficiency win — FLAC/SHN files are already compressed.
Tailscale won't connect on Mac — bootstrap DNS failure critical
tailscale up hangs indefinitely on Mac • dial tcp <public-ip>:443 connect operation timed out • curl to Headscale works fine but tailscale up hangs
tailscale mac dns bootstrap   last seen: 2026-06-06

Symptoms


Cause

The Tailscale network extension uses system DNS directly, bypassing /etc/resolver/ files. If system DNS has 100.64.0.5 (AdGuard's Headscale IP) first and Tailscale isn't connected yet, 100.64.0.5 is unreachable. The extension falls through to public DNS which returns the public IP, which is blocked or unreachable.

Diagnosis

scutil --dns | grep nameserver | head -5
dscacheutil -q host -a name headscale.compellinglylowbrow.org

If this returns the public IP, system DNS order is wrong

Fix

1. System Settings → Network → Wi-Fi → Details → DNS 2. Set DNS order: 192.168.42.27, then 100.64.0.5, then 1.1.1.1 3. Do the same for all interfaces (Ethernet, iPhone tether) 4. Flush DNS cache:
sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder
5. Try tailscale up again

Also check for conflicting VPN profiles

scutil --nc list

Multiple Tailscale entries → remove extras via System Settings → VPN

Prevention

Always set system DNS with 192.168.42.27 first. Verify after any macOS or Tailscale reinstall.
AdGuard headscale.compellinglylowbrow.org -> 192.168.42.45 is CORRECT and REQUIRED (bootstrap catch-22 fix) — do NOT delete high
headscale.compellinglylowbrow.org resolves to 192.168.42.45 while other services resolve to 100.64.0.4 — this is the intended, required state, NOT a fault
adguard headscale dns dns-rewrite bootstrap catch22 working-as-intended do-not-delete   last seen: 2026-08-05

Symptoms

See also: tailscale-up-hangs-adguard-dns, adguard-headscale-must-resolve-to-lan-ip, adguard2-mismatched-recovery-hatch-rewrite

Status: RESOLVED 2026-08-05 — NOT A BUG. Working as intended.

This entry was originally (2026-08-05, commit 291ceaa) filed as a suspected misconfiguration with a planned "corrective" to delete the specific headscale -> 192.168.42.45 rewrite. **That diagnosis was wrong. Do not delete the rewrite.** This file is retained (not deleted) as a signpost so the same correct-looking-but-wrong conclusion isn't reached a third time.

Why .45 is correct and required

headscale is the ONE service that must be reachable WITHOUT the Headscale tunnel — a node that isn't on the tailnet yet (bootstrap) or whose tunnel has dropped (re-register) cannot reach 100.64.0.4, because that IP only exists through the tunnel it is trying to establish. On-LAN it must therefore reach the control server via Caddy's LAN IP (192.168.42.45); Caddy terminates TLS and proxies to headscale. This is the documented permanent fix for the Tailscale/AdGuard bootstrap catch-22. Authoritative entries (this file defers to both): - tailscale-up-hangs-adguard-dns ("permanent fix is the .45 rewrite") - adguard-headscale-must-resolve-to-lan-ip (severity: critical; required on BOTH instances; verify must return .45 NOT 100.64.0.4) Provenance confirms deliberate, not drift: .45 present in the collected AdGuard config since 2026-06-03 (commit 04a6602), and the .45 verify expectation was written into the first Group 1 update-schema commit (81320c8). The hosts-config.yaml verify steps expecting .45, and the adguard remediate step that restores .45 if missing, are CORRECT — do not "fix" them.

The off-LAN concern that drove the wrong diagnosis, and why it's moot

Deleting .45 helps no real off-LAN case. A client reaching homelab AdGuard (100.64.0.5) is already on the tunnel, so already registered; a tunnel-down client can't reach AdGuard at all. Off-LAN nodes resolve headscale via PUBLIC DNS (Porkbun A record) — verified continuously by wildwood/check_external.py — never via homelab AdGuard. So .45 costs nothing off-LAN and is required on-LAN.

For drift-triage / future sessions

If you see headscale.compellinglylowbrow.org answering 192.168.42.45 on either AdGuard: that is the REQUIRED state. Leave it. The wildcard -> 100.64.0.4 rule is for every OTHER service; headscale is the documented exception.
backup-music.sh on proxmox-nuc silently corrupted to a 404 error page, breaking all three music-backup legs for ~6.5 weeks undetected high
cat /root/backup-music.sh on proxmox-nuc shows only the literal text '404: Not Found', 14 bytes • Running /root/backup-music.sh directly: bash: line 1: 404:: command not found (exit 127) • /var/log/rsync/backup-music.log has no new entries despite the cron job (0 1 * * *) supposedly running every night
backup rsync cron placeholder-corruption silent-failure music-backup known-fixes   last seen: 2026-08-09

Symptoms

See also: wiki-server-placeholder-corruption, backup-music-mid-run-redeploy-self-corruption

Symptom

The nightly music-backup cron job (/root/backup-music.sh on proxmox-nuc) appeared to be running on schedule with zero alerts, but none of the three destination mirrors (pibox, nastynas, wildwood) had received any new content in weeks. No ntfy failure notification was ever sent — the job looked completely silent, not failing. Found accidentally, while responding to an unrelated request to adjust the job's cron timing to avoid scheduling collisions with other jobs.

Cause

/root/backup-music.sh on proxmox-nuc — the single orchestrator script that is the *only* thing invoking the three leg scripts (rsync_music_to_pibox.sh, rsync_music_to_nastynas.sh, rsync_music_to_wildwood.sh; there is no independent per-host cron fallback) — had been silently replaced with a 14-byte file containing the literal text 404: Not Found, dated 2026-06-24 08:53. Almost certainly a curl -o style deploy that fetched a GitHub 404 error page (wrong URL, branch, or a moment where the file hadn't been pushed yet) and wrote that response body straight to disk with no validation that the fetched content was actually a shell script — the exact same failure class as wiki-server-placeholder-corruption.md, just a different script and a different (longer) undetected window. Once corrupted, every nightly cron invocation failed instantly, on the very first line (bash: line 1: 404:: command not found, exit 127) — before the real script's own set -uo pipefail, logging, or ntfy_failure-style alerting had any chance to run. Cron has no MAILTO configured on this host and the crontab entry has no output redirection, so the failure was completely invisible: no log line, no alert, nothing. This is a strictly worse failure mode than a script that runs and then fails partway through, since none of this repo's usual "the job ran and something inside it broke" safety nets ever engage.

Fix

# From the repo (developer-env), redeploy the real script and verify the write:
scp bin/backup-music.sh proxmox-nuc:/root/backup-music.sh
ssh proxmox-nuc "chmod +x /root/backup-music.sh"
sha256sum bin/backup-music.sh
ssh proxmox-nuc "sha256sum /root/backup-music.sh"   # must match

Trigger a real run to catch up all three legs:

ssh proxmox-nuc "/root/backup-music.sh"
The catch-up run itself is slow (rsync reconciling weeks of accumulated change across a 37,585+-file library, throttled by each leg's own --bwlimit) — that's an expected one-time cost, not a sign anything is still wrong. Nightly runs return to being fast (seconds) once caught up, matching the pre-corruption log history. **Don't redeploy further script changes while a catch-up run triggered by this fix is still in flight** — doing exactly that mid-incident caused a second, distinct failure; see known-fixes/backup-music-mid-run-redeploy-self-corruption.md.

Prevention / open follow-up

Built same session (2026-08-09/10): a "backup-music — Freshness" Uptime Kuma push monitor (26h interval), with bin/backup-music.sh sending it an unconditional heartbeat at the end of every run regardless of leg outcome — same pattern as bin/collect-homelab's own freshness heartbeat (see CLAUDE.md's Developer Environment notes, added 2026-07-15). This closes the exact gap that let this incident run 6.5 weeks undetected: the existing per-leg failure ntfy only ever fired for a run that starts and fails, never for a run that never starts at all because the entry script itself is broken — the push monitor catches that class by flagging DOWN on a *missing* heartbeat, not a reported failure. Token lives in /etc/backup-music.env (BACKUP_MUSIC_PUSH_TOKEN) on proxmox-nuc. More generally: any script deployed via curl -o (or scp sourced from a build step that could itself silently produce empty/wrong content) should have its write verified — at minimum a shebang check (head -c2 == #!) or a size sanity check, ideally a checksum compare against the known-good source, immediately after deploy. See CLAUDE.md's "After any create_or_update_file/push_files call, fetch the file back and confirm its content matches" mandate — the GitHub-MCP-specific version of this same lesson, extended here to any deploy path, not just that one API.
CrowdSec watchdog crons (digest, health-check, alerts-collect) died silently for ~2 weeks — state dir was root-owned but the crons run as the watchdog user high
Daily crowdsec-digest ntfy never arrived ('set up a reminder but never saw it'); assumed to be just low priority, was actually not running at all • Running crowdsec-digest.sh as the watchdog user exits non-zero at the state-write line: '/var/lib/crowdsec-health-check/appsec-block-total-prev: Permission denied', before the ntfy curl is ever reached • Every file in /var/lib/crowdsec-health-check/ has an mtime frozen at its 2026-07-20 creation time, despite heavy recent alert activity
crowdsec watchdog cron permissions ownership set-e silent-failure state-dir ntfy eacces   last seen: 2026-08-03

Symptoms

See also: crowdsec-digest-set-e-pipefail-silent-exit, crowdsec-docker-migration-environ-leak, crowdsec-doh-crs-920420-and-ts2021-ban-loop

Summary

Three CrowdSec cron scripts on watchdog — crowdsec-health-check.sh (*/10), crowdsec-digest.sh (daily 07:00), and crowdsec-alerts-collect.py (*/5) — all write state into /var/lib/crowdsec-health-check/. That directory and every file in it was owned root:root (created on setup day, 2026-07-20 14:34), but all three run from the watchdog user's crontab (root has no crontab). The watchdog user could read the state files (mode 644) but not overwrite them, so each script died at its first state-write — the two bash scripts under set -euo pipefail before the work that mattered (digest's ntfy post, health-check's stall/alert logic), the Python collector on an uncaught PermissionError. Silent, because none of the three crontab lines redirect stderr anywhere. Undetected for ~2 weeks until crowdsec-digest.sh was run by hand as watchdog on 2026-08-03 and surfaced Permission denied on appsec-block-total-prev.

Evidence

- ls -ld /var/lib/crowdsec-health-checkdrwxr-xr-x root root, every file inside -rw-r--r-- root root, all mtimes frozen 2026-07-20 (14:11–14:49). - crontab -l (watchdog user) lists all three scripts; sudo crontab -l → "no crontab for root". They run as watchdog, against root-owned state. - Manual crowdsec-digest.sh as watchdog: `line 101: .../appsec-block-total-prev: Permission denied`, non-zero exit, no ntfy. - Two weeks of alert activity (the 2026-07-31 → 08-03 bans visible in `cscli alerts list) never reached alerts-log.jsonl` — stuck at 3703 bytes.

Root cause

Ownership/executor mismatch, amplified by set -e. The scripts were almost certainly tested once as root on setup day (creating the root-owned dir and files), then wired into the watchdog user's crontab. From the first scheduled run onward every state write was EACCES, and: - set -euo pipefail (both bash scripts) turns a failed echo > file or touch flag into an immediate silent exit. - The Python collector's LOG_FILE.open("a") / write_text raise PermissionError, uncaught in main(), so it tracebacks and exits 1. Because no crontab line redirects output, none of this was logged anywhere.

Fix (applied 2026-08-03)

Make the state dir owned by the user the crons actually run as:
sudo chown -R watchdog:watchdog /var/lib/crowdsec-health-check
Verified immediately: all three scripts run as watchdog exit 0; alerts-log.jsonl jumped 3703 → 163055 bytes (collector caught up on ~2 weeks), heartbeat-state and appsec-block-total-prev updated to now. Do not run these three with sudo. Doing so recreates root-owned state files and silently re-breaks the watchdog-user cron. This exact re-break happened mid-diagnosis: a sudo test run left appsec-block-total-prev root-owned again until the chown -R above.

Why it stayed invisible for two weeks

Same class as crowdsec-digest-set-e-pipefail-silent-exit.md: a script under set -e dies before its ntfy call, so its own failure has no voice. There the trigger was a grep on a not-yet-existent metric; here it's EACCES on a state write. The general shape — *the thing that would have told you it broke is the thing that broke* — is worth pattern-matching whenever a notify/heartbeat script "just stopped." Two compounding factors specific to this one: - The digest posts at ntfy priority 2, so "no digest" reads as "quiet," not "broken" — there's no natural alarm on its absence. - The health-check that might have caught a sibling failure was itself one of the three dead scripts.

Prevention / hardening

Status: all four implemented and verified live 2026-08-03. 1. Ownership is the actual fix — DONE — `chown -R watchdog:watchdog /var/lib/crowdsec-health-check` (this session). Keep STATE_DIR owned by watchdog. 2. Redirect the crons' output — DONE (watchdog crontab, 2026-08-03). The memory-guardian-alert.sh line already did this; the three crowdsec lines now do too. Correction to the original recommendation: the log path must be watchdog-*writable*, and /var/log is root-owned — redirecting there from a watchdog-user cron would itself fail to open the file, reintroducing a silent failure in the act of fixing one. Deployed instead into the now-watchdog-owned STATE_DIR:
   */10 * * * * /usr/local/bin/crowdsec-health-check.sh   >> /var/lib/crowdsec-health-check/cron.log 2>&1
   0 7 * * *    /usr/local/bin/crowdsec-digest.sh          >> /var/lib/crowdsec-health-check/cron.log 2>&1
   */5 * * * *  /usr/local/bin/crowdsec-alerts-collect.py  >> /var/lib/crowdsec-health-check/cron.log 2>&1
   
Verified writable: cron.log created watchdog:watchdog, mode 664. It stays empty on success (the scripts are silent when healthy), so a non-empty cron.log is itself the signal. 3. Degrade, don't die, on state I/O failure — DONE (commit 346e81e). Every STATE_DIR write is guarded: digest and collector warn to stderr (now captured by the cron.log above) and continue; health-check fires one loud priority-5 ntfy, its suppression flag deliberately in /tmp so it survives the very STATE_DIR failure it reports. 4. Deploy comments — DONE (commit 346e81e) — all three script headers now state: runs as the watchdog user; STATE_DIR must be watchdog-owned; never run with sudo.

Verification

After the chown, run each as watchdog (not sudo) and confirm exit 0 + fresh mtimes:
/usr/local/bin/crowdsec-digest.sh;         echo "exit=$?"
/usr/local/bin/crowdsec-health-check.sh;   echo "exit=$?"
/usr/local/bin/crowdsec-alerts-collect.py; echo "exit=$?"
ls -l /var/lib/crowdsec-health-check   # mtimes should be 'now', owner watchdog
CRS rule 920420 rejected RFC 8484 DoH content-type and banned our own phone; the bouncer's own 403s on Headscale's /ts2021 then renewed those bans indefinitely high
iPhone on cellular repeatedly locked out of Headscale/tailnet; ban clears on manual cscli decisions delete, then returns within minutes • cscli alerts list shows repeating 'anomaly score out-of-band: anomaly: 5' entries against your own carrier IP, kind=waf, AS 7018 ATT-INTERNET4 • Those are followed minutes-to-hours later by clusters of LePresidente/http-generic-403-bf bans against the same IP
crowdsec appsec crs waf caddy headscale tailscale doh dns false-positive feedback-loop self-inflicted ban whitelist pre_eval   last seen: 2026-08-03

Symptoms

See also: crowdsec-self-sustaining-ban-loop-shared-wan-ip, crowdsec-docker-migration-environ-leak, crowdsec-trixie-apt-repo-and-arm64-appsec-gap, adguard-doh-public-encrypted-dns, host-repo-clone-uncommitted-drift, crowdsec-reputation-ban-shared-wan-ip-partial-lockout

Summary

Two independent bugs, in series, produced the recurring "my own phone is banned from my own homelab" symptom. Both are now fixed and verified. Neither is the bug described in known-fixes/crowdsec-self-sustaining-ban-loop-shared-wan-ip.md (that entry's DNS root causes were real and were fixed 2026-07-22) --- this is what was still happening *after* those fixes landed. Stage 1 (ignition). CrowdSec's AppSec/CRS collection includes OWASP CRS rule 920420 ("Request content type is not allowed by policy"). CRS's default allowed content-type list does not include application/dns-message, the RFC 8484 DNS-over-HTTPS content type. Every DoH request from an off-LAN client therefore scored 5 against CRS's inbound anomaly threshold of exactly 5, tripping 949110 (Inbound Anomaly Score Exceeded) and pouring into crowdsecurity/crowdsec-appsec-outofband --- a scenario which, despite the "out-of-band" name, has remediation enabled and issues real 4h bans. Out-of-band means the request is not blocked inline; it does not mean no ban results. Stage 2 (renewal). Once any IP is banned, caddy-crowdsec-bouncer returns 403 to every subsequent request from that IP, short-circuiting before the backend. Tailscale clients retry Headscale's /ts2021 control-plane endpoint continuously. Those bounced 403s are written to the same /var/log/caddy/access.log that CrowdSec's own LePresidente/http-generic-403-bf scenario parses --- so the ban's own enforcement generated the evidence that renewed the ban, indefinitely, regardless of whether the original cause was still active.

Evidence

From a 27-hour access.log window (2026-07-22 20:44 -> 2026-07-23 23:47 UTC): - 1924 POST 403s total. 1920 of them (99.8%) were /ts2021 on headscale. The other 4 were genuine probes (/api/graphql, /aaa, etc.). - 1922 of 1924 had duration < 1ms --- i.e. bouncer-generated, never reached Headscale. Only 2 requests in 27 hours were actually 403'd by Headscale itself. LePresidente/http-generic-403-bf needs 6 hits in ~50s to overflow (capacity 5, leakspeed 10s), so **none of the bans in this window could have originated from real Headscale 403s.** The scenario was running entirely on its own exhaust. - Alert timeline for 108.147.93.117 (iPhone on AT&T) shows the chain exactly: 15:19 appsec-outofband ban -> 16:22, 16:22, 16:24 http-generic-403-bf bans. All 62 of that IP's POST-403s fall in the 16:00 hour, all bouncer-generated. - cscli metrics on watchdog: rule 920420 triggered 277 times, 949110 332 times, against 332 lines poured into crowdsec-appsec-outofband. Rule 920420 accounted for roughly 83% of everything feeding that scenario. - Home's WAN (135.180.79.96, Sonic fiber) stopped generating POST-403s cleanly at 07:00 on 07-23, confirming the 2026-07-22 DNS fixes worked. The AT&T addresses did not stop, because a phone on cellular is *legitimately* an off-LAN client of a public endpoint --- no split-DNS fix can change that.

Fix 1 --- CRS 920420 scoped to /dns-query (watchdog)

A custom AppSec config containing only hooks and no rules is the documented pattern for this; the rules stay loaded by the hub config, and nothing gets tainted. Repo copy: watchdog/appsec-configs/mos-doh-crs-tuning.yaml. Deployed to /etc/crowdsec/appsec-configs/mos-doh-crs-tuning.yaml **inside the crowdsec container** (it lives in the crowdsec-config named volume, so it persists across restarts but NOT across a volume delete).
name: mos/doh-crs-tuning
pre_eval:
  - filter: 'IsOutBand == true && req.URL.Path == "/dns-query"'
    apply:
      - RemoveOutBandRuleByID(920420)
Then appended to appsec_configs in /etc/crowdsec/acquis.d/appsec.yaml, **last in the list** (hooks execute in the order configs are listed):
appsec_configs:
  - crowdsecurity/virtual-patching
  - crowdsecurity/crs
  - mos/doh-crs-tuning
Deliberately narrow: only 920420, only on that path. Every other CRS rule still scores /dns-query normally, so a genuine SQLi/RCE attempt smuggled onto that path is still caught. Confirmed from the original alert that 920420 contributed all 5 points (SQLI=0, XSS=0, RFI=0, LFI=0, RCE=0).

Fix 2 --- Headscale control-plane 403 whitelist (caddy LXC)

A new local parser file, never an edit to whitelists.yaml (that is a hub symlink --- editing it taints the collection and it gets overwritten on hub upgrade). Repo copy: caddy/crowdsec-parsers/mos-headscale-control-plane.yaml. Deployed to /etc/crowdsec/parsers/s02-enrich/mos-headscale-control-plane.yaml.
name: mos/headscale-control-plane-403
filter: "evt.Meta.log_type == 'http_access-log'"
whitelist:
  reason: "headscale noise-protocol handshake; also breaks the bouncer-403 ban renewal loop"
  expression:
    - "evt.Meta.http_status == '403' && evt.Meta.http_path in ['/ts2021', '/key'] && evt.Meta.target_fqdn in ['headscale.compellinglylowbrow.org', 'headscale.compellinglylowbrow.org:443']"
Why path-scoped rather than IP-scoped. /ts2021 is Tailscale's Noise protocol handshake --- authorization is key-based, there is no credential to guess. A brute-force scenario pointed at it cannot detect a real attack, so whitelisting costs zero detection. Scoping by path also means it never goes stale when a dynamic WAN IP changes or you connect from a hotel, which an IP whitelist would. This single change fixes both the trigger and the loop. Because a whitelist at s02-enrich sets evt.Whitelisted *before* any bucket is fed, it also removes this traffic from crowdsecurity/http-probing, which was pouring too. It further means that *any* future ban origin --- including a CAPI community-blocklist hit on a shared carrier IP --- can no longer self-renew off /ts2021 traffic. Note evt.Meta.target_fqdn carries the port (...:443) when the client sends an explicit port in the Host header, which Tailscale does and browsers do not. Both forms are listed.

Verification method (worth reusing)

Every claim above was A/B tested with a negative control, because "the counter didn't move" is indistinguishable from "the test never reached the thing being tested." Fix 1: counters reset on container restart, so both readings were taken in the same post-restart window. | Request | Path | 920420 | 949110 | |---|---|---|---| | DoH query, Content-Type: application/dns-message | /dns-query | 0 | 0 | | Identical header | / | 1 | 1 | The homelabAppSec Processed counter incremented on both, proving the request reached the engine in each case. The control firing on / is what proves the rule is loaded and active, and that the path filter is what suppressed it. Fix 2: cscli explain against a real captured log line, before and after. - Before: Scenarios section listed LePresidente/http-generic-403-bf and crowdsecurity/http-probing. - After: update evt.Whitelisted : false -> true, our reason string attached, and no Scenarios section at all. - Negative control (a .php scanner hit): parser success, whitelist *not* applied, still reaches crowdsecurity/http-crawl-non_statics. Display quirk worth knowing: cscli explain renders a whitelisted event as parser failure in red, because the pipeline short-circuits rather than completing. With evt.Whitelisted -> true and the reason attached, that is expected output, not an error. explain also attributes the reason string to the crowdsecurity/whitelists node while showing the custom parser as (unchanged) --- a cosmetic attribution quirk; the reason text is definitively the custom one.

Corrections to prior documentation and prior beliefs

Recorded explicitly so they are not carried forward: - **crowdsec-self-sustaining-ban-loop-shared-wan-ip.md correction (a) --- APPLIED 2026-07-23** (commit ce9b3f5). All four options in its "Real fix --- scoped, not yet implemented" section were superseded: the path whitelist above kills both the trigger and the renewal without needing log_skip, a named-logger split, a profiles.yaml dedup, or a leakspeed change. That section is now headed do-not-implement, with the four candidates retained below it as historical context, and the ignition-source gap (CRS 920420) recorded there as well. - Correction (b) --- WITHDRAWN 2026-07-23. It was a false premise. This entry originally claimed that file "attributes the banned IP to wildwood's WAN." It does not --- wildwood is not mentioned anywhere in it. The only wildwood / 73.93.163.170 references in the repo are in docs/security-crowdsec-plan.md (rollout verification section, and §9 incident 9), and they describe something entirely different and entirely correct: a deliberate burst test run during the 2026-07-18 Phase 1 rollout to prove the ban pipeline enforced end-to-end from a genuine public IP, then cleaned up immediately via cscli decisions delete --ip 73.93.163.170 so wildwood's own access was not left blocked. That same passage independently names 135.180.79.96 (home's WAN) as the *organic* catch --- which agrees with the traffic analysis above rather than contradicting it. **Nothing needs correcting.** The lesson: a cross-file correction written from memory of what another document says is worth re-reading the target before acting on. This one would have introduced an error into a file that was already right, and named the wrong file while doing it. - **"Hours of cellular use with zero bans" was not evidence --- WITHDRAWN 2026-07-24.** Carried forward from the 2026-07-23 session in support of item 1 below. The iPhone was in airplane mode for those hours, so no AT&T traffic reached the endpoint and there was nothing to ban --- the observation was true and vacuous. Superseded by the real test now recorded in item 1. Same shape as the two errors above: a measurement quoted as evidence without first checking whether the thing being measured was happening at all. - A log_skip discriminator does exist, if it is ever wanted for another purpose: bouncer-generated 403s carry Content-Type: text/plain; charset=utf-8 and duration ~60us, while Caddy's own path-restriction 403s show Content-Type: []. Not used here --- depending on an undocumented module behavior that could change on a bouncer upgrade is worse than a path filter. - The Docker migration does NOT amplify this loop. An earlier concern that fixing caddy-logs parsing would feed more traffic into 403-bf was wrong: all 1924 POST-403s in the window carried a User-Agent, so zero UA-less POST 403s exist. The migration is engine hygiene, not a prerequisite or a risk here. - caddy's 100%-unparsed sshd metric is not a v1.4.6 artifact. watchdog on v1.7.7 shows the identical crowdsecurity/sshd-logs 0-parsed pattern. The likeliest reading is that neither host logs parseable auth events (session open/close noise only), so this is correct behavior, not lost protection --- and the migration will not change it. - The unparsed-traffic "blind spot" is ~90% our own DoH. Of UA-less lines, 1792 were dns.compellinglylowbrow.org/dns-query; the remainder was a real .php scanner sweep (~10%) that should be getting caught. Both true; the migration plan's framing of a structural blind spot on attack traffic overstates it.

Other findings from the same session

- crowdsecurity/crowdsec-appsec-outofband bans despite the name. Worth internalizing: "out-of-band" describes inline blocking behavior, not remediation. Do not read Remediation: false on an individual CRS rule hit as "this cannot ban me" --- the rule hits pour into a scenario that can. - **The July 27 at reminder about promoting appsec-crs to in-band is obsolete** and should be cancelled. The observation window it was scheduled to review already answered the question: CRS was generating bans, not just alerts. - **The stray /home/watchdog/watchdog/compose.yaml decoy is not harmless clutter.** crowdsec-docker-migration-environ-leak.md incident 6 left it in place on that assessment. Both it and the real file live in directories named watchdog, so Compose infers the same project name from either --- the decoy is a partial definition of the live project, missing netdata and crowdsec, which it therefore treats as orphans. A docker compose down --remove-orphans from that directory would delete the CrowdSec LAPI and Netdata. Container labels re-checked with docker ps -aq on 2026-07-24 confirm the split: ntfy and uptime-kuma created from the decoy, netdata and crowdsec from the real file. Service definitions are currently byte-identical, so nothing had broken. hosts-config.yaml and bin/update-docker-compose were corrected to the real path in commit 438faf3; labels self-heal on the next real update. Decoy deletion deferred until one Group 2 update runs green against the corrected path. - **Correction (2026-07-24): the orphan set above previously read netdata/crowdsec/adguard-exporter.** That was wrong. adguard-exporter has never had a container in any state --- confirmed by docker ps -aq, which lists stopped containers too and returned only four. Orphan detection compares *running containers* against the compose file, not service blocks against service blocks, so a never-deployed service cannot be an orphan of anything. This bullet is also the passage later mis-cited as INFRASTRUCTURE.md's. known-fixes/host-repo-clone-uncommitted-drift.md and the HISTORY NOTE in watchdog/compose.yaml both name that file as the document that listed adguard-exporter among the live project's services --- it never did, and does not now. The claim originated here. See that entry for the abandoned-deployment incident itself. - The container entrypoint runs cscli hub upgrade on every start, so hub content (parsers, scenarios, CRS rule files) floats even though the image tag is pinned. Any in-place edit to a hub item is silently reverted on restart --- a second, independent reason the local-config approach was correct. - Backup naming inside acquis.d/ matters. CrowdSec globs *.yaml there, so appsec.yaml.bak-YYYYMMDD is safely ignored (suffix after the extension), but appsec.bak.yaml would load as a second AppSec datasource, collide on port 7422, and fail the engine at startup. - **A 401 heartbeat from caddy-lxc immediately after a LAPI restart is benign** --- the agent re-logs-in within ~60s (POST /v1/watchers/login 200 followed by GET /v1/heartbeat 200). This looks identical in the logs to the 2026-07-19/20 silent-deregistration incident, which does NOT self-heal. The discriminator is whether a successful login follows within about a minute.

Still open

1. ~~Observation window: confirm no new appsec-outofband or 403-bf decisions land against our own addresses, ideally with a real off-Wi-Fi phone test.~~ DONE 2026-07-24 --- deliberate test performed: iPhone on cellular only, Wi-Fi off, Tailscale fully disconnected, AdGuard encrypted-DNS profile active. Caddy's access log confirms arrival: five /dns-query requests from 107.127.14.11 (public AT&T) at 15:39 UTC, each carrying `Content-Type: application/dns-message` --- the exact header CRS 920420 rejected --- all answered 200 with 12-34ms backend round trips, not the ~60us bouncer short-circuit. cscli decisions list returned "No active decisions" both before and after re-enabling Tailscale. The test was arrival-gated, so "no ban" cannot be confused with "no request": the 200s are themselves proof the address was not banned, since a banned IP is 403'd on this path too. Fix 1 is confirmed end-to-end, not only on the A/B bench result. 2. ~~Apply the two corrections to known-fixes/crowdsec-self-sustaining-ban-loop-shared-wan-ip.md.~~ DONE 2026-07-23 --- (a) applied in commit ce9b3f5; (b) withdrawn as a false premise. See the Corrections section above for both. 3. ~~Cancel the July 27 at reminder on watchdog.~~ DONE 2026-07-23 --- was job ID 1 in the watchdog user's queue; atrm 1, queue confirmed empty. (Note for future triage: sudo atq lists every user's jobs, so the same job appearing under both atq and sudo atq is one job, not two.) 4. ~~Delete the decoy compose file, after one green Group 2 update.~~ DONE 2026-08-05. Gate met: a green Group 2 update on ntfy re-created it from the real compose, moving its com.docker.compose.project.config_files label onto /home/watchdog/homelab/watchdog/compose.yaml (labels self-heal on recreate, exactly as predicted). The decoy /home/watchdog/watchdog/compose.yaml was then deleted. The footgun was never a live-container property --- it was specifically docker compose down --remove-orphans *run from the decoy directory*, so deleting the decoy file removes the only thing that could invoke it; structurally closed regardless of any container's label. Tidied fully the same day: a docker inspect of all four containers' config_files labels found crowdsec/netdata/ntfy already on the real path and only uptime-kuma still carrying the deleted decoy path (a plain docker compose up -d uptime-kuma had reported it *Running*, not *Recreated*, so its label had not moved --- compose only rewrites labels on create). A docker compose up -d --force-recreate uptime-kuma moved it, and all four are now cleanly owned by the real compose with zero residual. 5. ~~Uptime Kuma monitor is hitting GET / on dns.compellinglylowbrow.org every 120s and getting 403 forever (the 2026-07-17 path restriction working as intended against our own monitor). Either it is checking the wrong path or it is a legacy monitor that wants deleting.~~ **RESOLVED 2026-07-26 --- the premise was a misread; the monitor is correct and was kept, not deleted.** It is the intentional DNS --- Admin surface blocked (expect 403) regression check defined in bin/setup-uptime-kuma.py (accepted_statuscodes: ["403"], so Uptime Kuma reports it UP, not DOWN) --- its whole job is to catch AdGuard's admin surface silently reopening to the public internet, the exact 2026-07-17 finding it guards, so deleting it would remove a live security check. Log analysis 2026-07-26 (3000-line access.log window) showed its /-403s sourced from the watchdog tailnet IP (100.64.0.24, not in the default RFC1918 whitelist) plus a few WAN hairpins (135.180.75.5), so they were pipeline-eligible for http-generic-403-bf though far sub-threshold at the monitor's 120s rate. Fixed not by touching the monitor but with a path-scoped whitelist parser mos/dns-admin-surface-403 on the caddy LXC (commit e773029, repo copy caddy/crowdsec-parsers/mos-dns-admin-surface-403.yaml) --- a twin of Fix 2's mos/headscale-control-plane-403, whitelisting only `http_status == '403' && http_path == '/' && the dns FQDN. Deployed live (crowdsec -t` clean -> restart) and verified by cscli explain A/B: the /-403 whitelists from a tunnel source, a WAN hairpin, and a public DigitalOcean scanner (209.38.70.134) alike --- source-agnostic as designed --- while that same scanner's /favicon.ico still reaches Scenarios, so multi-path scanners keep self-banning. Path-based confirmed by measurement, not argument. 6. ~~Prune crowdsecurity/apache2 and crowdsecurity/nginx collections on the caddy LXC --- that host runs neither.~~ DONE 2026-07-23 --- both removed; crowdsecurity/base-http-scenarios correctly retained, since caddy and http-cve still depend on it (cscli says so explicitly during removal). 7. ~~Consider scoping the bouncer so /ts2021 and /key bypass IP-reputation entirely (AppSec/virtual-patching retained).~~ **DONE 2026-08-03 (commit ec9a276).** Deployed live on the headscale site block. Scope was widened from the original /ts2021 + /key to also exempt /derp + /derp/* (DERP relay is key-authed and E2E-encrypted, same reasoning; a false positive there severs a relay-dependent node's entire data plane). Matcher: crowdsec @reputation with @reputation not path /ts2021 /key /derp /derp/*. Key-authenticated endpoints gain almost nothing from IP reputation while a false positive costs remote access to the whole homelab, so this makes the lockout class structurally impossible rather than merely unlikely; IP-reputation stays enforced on every other path, so AppSec/virtual-patching coverage is unchanged. Proven end-to-end before close-out: caddy adapt clean on v2.11.4 -> CI green -> LXC caddy validate with the real env keys -> **live A/B from wildwood's then-banned WAN IP, every request over the public WAN**: /health -> 403 (IP-reputation still enforced on a non-exempt path, i.e. the ban was real and active), /ts2021 -> 500 (bypassed reputation and reached headscale, which 500s on a bare probe with no Noise handshake --- the "reached the backend" signal), /derp/probe -> 200 (bypassed and served). The 403-vs-500/200 split is the whole proof: one banned IP simultaneously blocked on a non-exempt path and let through on the exempt ones. Then clean teardown (ban decision deleted so wildwood's own access was not left blocked), caddy-bouncer registration confirmed intact, and post-change group1-preflight 70/70. The partial-lockout symptom this structurally prevents --- tunnel up but DoH and fresh registration blocked --- is mapped in known-fixes/crowdsec-reputation-ban-shared-wan-ip-partial-lockout.md (landed commit 1e32444), which also records the deliberate decision NOT to parser-whitelist /derp 403s (real relay-abuse signal) and the concrete trigger to revisit that. 8. ~~docs/crowdsec-caddy-agent-docker-migration-plan.md --- reframe justification per the corrections above, and fold in the prerequisite findings.~~ DONE 2026-08-05 (commit c8571c1). The "Why" section's "structural blind spot" framing is dialed back to engine-hygiene-closing-a- modest-gap (the ~32.8% unparsed is ~90% our own DoH, ~10% a real scanner sweep). A new "Corrections & added prerequisites (2026-08-05)" section folds in the ban-loop-non-amplification and sshd-0-parsed corrections from above, plus the four host prerequisites (unprivileged + nesting=1 / no keyctl; vfs storage-driver hard gate on the loop-mounted rootfs vs the 2.4G budget; image runs a LAPI by default so it needs agent-only env; Docker install rewrites iptables). The tainted-collections note is recorded there as resolved (item 10 below), and the two stale "pending known-fixes entry" forward-references now name this landed file. Migration steps themselves are unchanged and still not executed. 9. ~~CrowdSec v1.7.8 is available on watchdog (currently v1.7.7-981e6166).~~ DONE 2026-07-23 --- tag bumped to v1.7.8-debian in watchdog/compose.yaml, then docker compose up -d crowdsec. Now running v1.7.8-63227459 (BuildDate 2026-05-11), confirmed by cscli version on 2026-07-24. Closes CVE-2026-44982 (high-severity AppSec/WAF bypass) and CVE-2026-44981 (LAPI DoS); four post-upgrade checks green. Item 11 --- the missing hosts-config.yaml entry that let a ~10-week-old security release go unnoticed --- was closed the same day. 10. ~~**crowdsecurity/linux and crowdsecurity/sshd are tainted on the caddy LXC**~~ --- discovered 2026-07-23 via cscli collections list; linux also showed version ?, meaning cscli could not match it to any hub version at all. Tainted items receive no hub updates, so both were silently frozen. Almost certainly residue from the wip_lapi hub-branch episode. RESOLVED IN PLACE 2026-08-05 --- detainted rather than deferred into the item-8 migration, since it was a non-destructive ~8-second fix versus weeks frozen. Low impact confirmed live first, with evidence not assertion: HTTP detection runs entirely through the untainted caddy (0.1) / http-cve (1.9) / base-http-scenarios (0.6); the tainted sshd collection had nothing to lose (crowdsecurity/sshd-logs parsed 0 of 1279 ssh.service journal lines --- session open/close noise, not auth events, identical to watchdog on v1.7.7); and the shared enrichers still worked despite the taint (syslog-logs 1279/1279, dateparse-enrich 6293/6293 on caddy's access.log --- taint freezes updates, it does not break current function). Fix, from developer-env (caddy is root, no sudo):
    ssh caddy "cscli collections upgrade crowdsecurity/linux crowdsecurity/sshd --force && systemctl reload crowdsec"
    
upgrade --force re-downloads the hub version over the tainted local files and clears the taint with no removal, so there is no dependency cascade; the systemctl reload is required for the running native agent to pick up the re-downloaded files (cscli prints its generic "Run 'sudo systemctl reload crowdsec'" advisory regardless of context). Verified post-reload: systemctl is-active crowdsec = active, and cscli collections list showed both linux and sshd back to enabled with no tainted flag and linux's ? resolved to 0.2. The ?-version escalation was not needed --- recorded in case a future taint on a ?-versioned item does not clear via plain upgrade: the escalation is cscli collections remove --force && cscli collections install , but it must first verify the shared enrichers (dateparse-enrich/syslog-logs/geoip-enrich) are co-owned by an HTTP collection before removing, since caddy's live pipeline uses dateparse-enrich. Item-8 fresh-init would have reset both anyway, so this fix is not load-bearing for the migration --- it just avoided leaving them frozen in the weeks until then. 11. ~~crowdsec is not tracked in inventory/hosts-config.yaml, contrary to INFRASTRUCTURE.md's claim that it was.~~ DONE 2026-07-23 --- both halves closed the same day and re-verified 2026-07-24: the crowdsec entry is present in hosts-config.yaml carrying update_group: 2, auto_update: false, verify steps and the tag-pinning rationale; and INFRASTRUCTURE.md's paragraph now opens with an explicit Correction (2026-07-23): recording that the entry had never been written despite the claim. auto_update must stay false while the tag is pinned or the flag is a no-op --- the same trap documented in uptime-kuma's entry. 12. ~~bin/crowdsec-digest.sh's "new alert count (24h)" is a poor signal. Measured 2026-07-23: exactly 50 alerts in 24h --- and all 50 were one scanner's single one-second burst (alert IDs 717-766, all 44.243.220.7, alternating vpatch-env-access and `anomaly score out-of-band: lfi: 5`). AppSec emits one alert per matching request with no bucketing, unlike log-based scenarios which emit one per bucket overflow, so this number tracks "did one scanner visit today" rather than anything about threat level. Count decisions instead (they dedup --- cscli decisions list reported 8 duplicated entries skipped) or count distinct source IPs. Note the raw volume is a non-issue at this scale: 50/day is no storage or performance concern for the alerts JSONL on a Pi.~~ DONE --- code already live, deployed copy verified 2026-08-04. The digest no longer prints a raw "new alert count" as its signal; the headline is now distinct source IPs (`Actors seen (24h): N distinct source IPs (M raw alerts)`), with the single noisiest IP surfaced (Noisiest: (K alerts)) so a one-scanner burst reads as one actor rather than a scary count. Distinct IPs was chosen over the "count decisions" option on purpose: local decisions carry a 4h TTL and are gone by the 07:00 run, whereas the 24h alert window still holds them --- rationale recorded in the script's own comment. Live /usr/local/bin/crowdsec-digest.sh on watchdog sha256-matches the repo copy (099e0266). 13. ~~**watchdog resolves dns.compellinglylowbrow.org to the public A record and hairpins ~0.7% of its own monitor traffic out over the WAN** --- surfaced 2026-07-26 in the same log analysis that closed item 5 (the 135.180.75.5 rows carrying the monitor's exact hardcoded q80... DoH query from bin/setup-uptime-kuma.py, i.e. Uptime Kuma's own requests egressing via the home WAN instead of the tunnel). Harmless for these two monitors specifically --- they are the intentional public-surface checks, so reaching Caddy via WAN vs tunnel both count, and both are now whitelisted anyway --- but it means the Pi's resolver occasionally falls through to public DNS for the domain, the same class just closed on developer-env (known-fixes/systemd-resolved-stale-cache.md). Worth a resolver-hygiene check on watchdog (AdGuard pinned for the domain, no 1.1.1.1 sticky-failover, split-DNS present) in case it does the same for FQDNs where a public-IP hairpin is *not* harmless. Its own small item; not chased mid-session on 2026-07-26.~~ RESOLVED 2026-08-05 (nmcli pin). Cause confirmed on the watchdog Pi, which runs NetworkManager + glibc (NOT systemd-resolved -- a different mechanism from the developer-env sibling): Wired connection 1 was ipv4.method auto with ipv4.ignore-auto-dns no, so DHCP handed glibc both AdGuard *and* 1.1.1.1, and glibc fell through to 1.1.1.1, which answers the domain with Porkbun's public A record -- the hairpin. Fix, run on watchdog: `sudo nmcli con mod "Wired connection 1" ipv4.ignore-auto-dns yes ipv4.dns "192.168.42.27 192.168.42.89" then sudo nmcli con up "Wired connection 1"`. method auto kept, so the DHCP reservation still assigns the IP; only DNS is pinned. Verified same day: /etc/resolv.conf shows only the two AdGuards (no 1.1.1.1); dns/grafana resolve to 100.64.0.4. (headscale still answers 192.168.42.45, correct per adguard-headscale-must-resolve-to-lan-ip -- its persistence after the pin confirms the two issues are independent.) Trade-off accepted, same as developer-env: both AdGuards down = no resolution rather than wrong-but-public. Sibling/general diagnosis: known-fixes/systemd-resolved-stale-cache.md.
Two independent client-side DNS bugs sent Tailscale control-plane traffic out over the public WAN IP instead of the LAN — and CrowdSec's own bounced 403s kept re-banning that IP indefinitely, long after both bugs were fixed high
Uptime Kuma 'DNS — Public DoH path (expect 200)' flaps DOWN with 'Request failed with status code 403' (a real HTTP response, not a timeout — distinct from known-fixes/dns-caddy-monitor-transient-flap.md, which is a silent connection gap) • cscli decisions list on watchdog shows repeating LePresidente/http-generic-403-bf bans against your own home WAN IP, each ~4h duration, renewing every 5-25 minutes even after the apparent cause is fixed • Caddy access.log shows bursts of 6-20 POST /ts2021 (or /key) 403s in a few seconds from the same public IP, User-Agent Go-http-client/1.1 (Tailscale's shared Go control-plane client — indistinguishable between platforms/devices from this log alone)
crowdsec headscale tailscale dns split-dns caddy homeassistant haos supervisor feedback-loop self-inflicted false-positive ban remediation   last seen: 2026-07-23

Symptoms

See also: crowdsec-doh-crs-920420-and-ts2021-ban-loop, dns-caddy-monitor-transient-flap, headscale-ha-companion-app-external-url-direct-tailscale, adguard-doh-public-encrypted-dns, tailscale-dns-override-after-upgrade

Symptom

The "DNS — Public DoH path (expect 200)" Uptime Kuma monitor started flapping DOWN with a real 403 response (not a timeout/silence — see the *Why this is a different bug* note below). cscli decisions list on watchdog showed LePresidente/http-generic-403-bf bans landing repeatedly against the homelab's own public WAN IP, each with a fresh ~4-hour duration, recurring every few minutes to a few hours all evening — including recurring *after* every fix applied during the investigation.

Why this is a different bug than `dns-caddy-monitor-transient-flap.md`

That earlier known-fix covers the same two monitors going DOWN with a timeout and zero trace in any log — concluded as a sub-2-minute tailnet/WAN packet-loss blip, nothing actionable. This incident is a real, logged 403 HTTP response — something is actively receiving and rejecting the request, not silently dropping it. Different signature, different root cause, do not conflate the two.

Root causes — two independent client-side DNS misconfigurations

Both produced the identical symptom via the identical mechanism: a client resolved headscale.compellinglylowbrow.org (a public-facing FQDN, intentionally so — Headscale must be internet-reachable for off-LAN enrollment) via a public resolver instead of AdGuard's internal rewrite, hairpinning its Tailscale control-plane handshake out through the home router and back in over the public internet instead of going directly over the LAN/tailnet. Caddy (correctly) has no reason to treat that differently from any other public request; CrowdSec's LePresidente/http-generic-403-bf scenario (generic "too many 403s in a short window") fires on the resulting retry burst, and — since this is literally your own home's WAN IP under NAT — the ban hits *everything* egressing through that shared IP, including watchdog's own DoH health check running from inside the LAN.

Cause 1 — HAOS Supervisor's own internal DNS plugin

Every add-on container on a HAOS host resolves DNS via Docker's embedded resolver (127.0.0.11), which forwards to Supervisor's own DNS plugin (hassio_dns, 172.30.32.3) — **this is a separate layer from both the HAOS host's own Settings → System → Network panel (which only configures the host's own interface) and from whatever Headscale pushes to an authenticated tailnet client.** ha dns info showed:
locals:
  - dns://1.1.1.1
  - dns://192.168.42.27
  - dns://192.168.42.89
servers: []
1.1.1.1 listed ahead of AdGuard in locals, servers empty — meaning nothing constrained resolution order, and any add-on (the Tailscale add-on in this case) could get the real public A record instead of AdGuard's rewrite. Confirmed via getent hosts headscale.compellinglylowbrow.org from a Supervisor-managed terminal returning the public IP. Fix:
ha dns options --servers dns://192.168.42.27 --servers dns://192.168.42.89 --servers dns://1.1.1.1
ha dns restart
servers (explicit, ordered) takes precedence over the auto-detected locals list. Verified fixed via getent hosts returning the correct internal IP (192.168.42.45), and via the Tailscale add-on's own log showing a clean `RegisterReq: got response; nodeKeyExpired=false, machineAuthorized=true` login immediately after an add-on restart — no further 403 Forbidden retry loop. Caveat worth remembering: this is a Supervisor-level setting, not a git-tracked config file — it won't show up in cjsdaddy/homeassistant's repo and won't survive being rediscovered by reading config alone. If this recurs, check ha dns info directly; don't assume the host network panel settings are the whole story.

Cause 2 — Headscale's global DNS push had no split-DNS entry for its own domain

Headscale's config.yaml:
dns:
  override_local_dns: true
  nameservers:
    global: [192.168.42.27, 1.1.1.1]
    split: {}
override_local_dns: true means every tailnet client uses *only* these two nameservers, globally, for everything — and with split: {} empty, there's no per-domain routing. If AdGuard is even momentarily slow or the client's resolver library races/falls through, 1.1.1.1 can answer for compellinglylowbrow.org just as easily as for any other domain, handing back the real public A record. Confirmed on iPhone: Tailscale app's own DNS screen showed the flat two-resolver list with no per-domain route. Fix — add a split entry pinning your own domain to AdGuard only, leaving the global fallback intact for everything else:
dns:
  nameservers:
    global: [192.168.42.27, 1.1.1.1]
    split:
      compellinglylowbrow.org:
        - 192.168.42.27
Deployed via direct edit + systemctl restart headscale on the headscale LXC (this file isn't in the cjsdaddy/homelab repo — only acl.hujson is tracked there; config.yaml lives on the LXC only). Verified on both iPhone and MacBook Air's Tailscale app DNS screens, which now show a Route: compellinglylowbrow.org → 192.168.42.27 entry. A Headscale restart bounces every connected node's control session — plan for it deliberately rather than mid-incident if avoidable; each client needs to reconnect and pull the new netmap (which carries the new DNS config) before the fix actually takes effect for that client. A server restart alone does not force this — an already-connected client may keep using its previously-cached DNS behavior until its own next reconnect/foreground/netmap refresh.

The part that made this hard to confirm fixed: a self-sustaining ban-renewal loop

After fixing both causes above, individually confirming each device clean (HAOS: fixed and logging in successfully; iPhone: split route present in Tailscale app; MacBook Air: dig resolving correctly; watchdog itself: clean DNS, tailscaled healthy for a week; phone fully powered off to rule it out entirely) — fresh bans kept appearing anyway, every few minutes, long after every plausible client-side cause had been eliminated and verified. Root cause: **CrowdSec's own bounced responses feed back into the same detection loop.** Once an IP is banned, the caddy-crowdsec-bouncer module returns a 403 for *every* subsequent request from that IP, almost instantly (duration: 0.00007s in the access log — too fast to be Headscale's own application logic, a clear tell it's the bouncer short-circuiting before ever reaching the backend). That bounced 403 gets written to /var/log/caddy/access.log in the exact same shape as a genuine failed-auth 403 — and CrowdSec's own log-based http-generic-403-bf scenario re-reads that same log file, sees another 403 from the same IP, and renews the ban again — indefinitely, driven by nothing more than the shared WAN IP's completely ordinary, unrelated Tailscale keepalive/reconnect traffic from any of a dozen-plus healthy devices sharing that IP under NAT. Neither original bug needed to still be active for this to continue. Confirmed via direct test: manually clearing the ban (cscli decisions delete --ip ) and waiting 5 minutes produced zero new bans — proving both DNS fixes had actually worked, and the loop itself was the only thing left artificially sustaining the incident.
ssh watchdog "docker exec crowdsec cscli decisions delete --ip <ip>"

Real fix — SUPERSEDED 2026-07-23, do not implement the candidates below

**None of the four candidate approaches originally listed here was used, and none should be.** The actual fix, deployed and verified 2026-07-23, is a path-scoped whitelist parser on the caddy LXC (a new local parser file at /etc/crowdsec/parsers/s02-enrich/, never an edit to the hub-symlinked whitelists.yaml). Because a whitelist at s02-enrich sets evt.Whitelisted *before* any bucket is fed, one change stops both the trigger and the renewal — no log_skip, no named-logger split, no profiles.yaml dedup, no leakspeed reduction, and no loss of detection sensitivity anywhere else. It also survives a dynamic WAN IP change or connecting from a hotel, which an IP-scoped whitelist would not. Full writeup, exact deployed config, and the A/B verification method (with negative controls): known-fixes/crowdsec-doh-crs-920420-and-ts2021-ban-loop.md. This entry was also incomplete about the ignition source. Everything above about the renewal *mechanism* is correct, but a renewal loop cannot start itself — something had to issue the first ban, and the two DNS bugs documented above were not it. That first ban came from OWASP CRS rule 920420 ("request content type is not allowed by policy") rejecting the RFC 8484 application/dns-message content type on every off-LAN DoH request, scoring exactly the inbound anomaly threshold of 5 and pouring into crowdsecurity/crowdsec-appsec-outofband — a scenario that issues real 4h bans despite the "out-of-band" name, which describes inline blocking behavior, not remediation. Fixing only the loop would have left the trigger firing. See the linked entry for the CRS tuning that fixed it. A log_skip discriminator does exist if it's ever wanted for some other purpose: bouncer-generated 403s carry `Content-Type: text/plain; charset=utf-8 and duration` ~60us, while Caddy's own path-restriction 403s show Content-Type: []. Deliberately not used — depending on an undocumented module behavior that could change on a bouncer upgrade is worse than a path filter. The original candidate list is retained below as historical context only, because the reasoning about each remains useful if a similar remediation feedback loop shows up elsewhere in this homelab. Do not implement any of them for *this* problem — it is already fixed. 1. **Split Caddy's logging so bounced requests never reach the log CrowdSec parses.** If caddy-crowdsec-bouncer sets a distinguishing response header on its own short-circuited responses (needs confirming against the hslatman/caddy-crowdsec-bouncer source/docs — not yet verified), use Caddy's log_skip directive with a matcher on that header to exclude those lines from the access log entirely:
   @already-remediated header <marker-header> <marker-value>
   log_skip @already-remediated
   
This is the most surgical option if the header exists — it stops the feedback loop at the source without touching CrowdSec's scenario logic or reducing detection sensitivity for genuine attacks. 2. If no such header exists, an alternative is scoping the bouncer handler and the backend reverse_proxy/log directive as separate named loggers, so only requests that actually reached the backend handler get logged to the file CrowdSec's acquis.yaml reads. 3. CrowdSec-side: investigate whether profiles.yaml supports a condition suppressing a new decision when an active decision of the same type/scope already exists for that IP (not confirmed to exist as a built-in option — needs a documentation check before assuming this path is viable). 4. Weakest option, stopgap only: increase LePresidente/http-generic-403-bf's leakspeed/capacity so a handful of bounced 403s from routine traffic don't reach the trigger threshold. Doesn't fix the mechanism, just raises the bar — a genuine attacker generating real 403s would still eventually retrigger it, so this only masks the shared-WAN-IP collateral case, and reduces legitimate detection sensitivity. Not recommended as the actual fix. Scoping note (historical, now answered): option 1 was the expected target. The next session on this did start by looking for the bouncer's response marker — found one, deliberately rejected it as too fragile, and implemented the path-scoped whitelist instead, then re-ran the exact manual-delete-then-wait test to confirm the loop no longer self-sustains.

Broader lesson — other remediation feedback-loop patterns worth watching for

This incident is one instance of a general failure class: **a security control's own remediation action gets re-ingested by the same detection pipeline that triggered it**, turning a one-time event into a self- perpetuating lockout. Related patterns worth keeping in mind elsewhere in this homelab (or any future security tooling added): - fail2ban/CrowdSec reading a firewall's own DROP/REJECT log lines — the same shape as this incident, just at the packet-filter layer instead of the HTTP layer, if a future ufw/iptables logging setup ever feeds the same log a ban-triggering jail also reads. - **WAF/AppSec rules scoring your own legitimate health-check traffic as anomalous** — already seen once tonight in passing: CrowdSec's AppSec outofband rule flagged the DoH monitor's own base64 wire-format query string (native_rule:901340) as suspicious. Not currently blocking (out-of-band = detect-only), but worth excluding before ever promoting that rule to blocking mode. Correction, 2026-07-23: the "not currently blocking" half of that observation was wrong — crowdsecurity/crowdsec-appsec-outofband issues real 4h bans, and this exact pattern (CRS scoring our own DoH traffic) turned out to be the ignition source for the whole incident. See the linked entry. - **Rate limiters whose own 429/403 responses count toward the same bucket that decides further limiting** — same feedback shape as this incident, common in reverse proxies and API gateways, not unique to CrowdSec. - **A client's retry/backoff interval being faster than a ban's decay time** — independent of any log-feedback bug: even with the loop above fixed, a misbehaving client that retries every few seconds indefinitely will keep re-triggering a fresh ban the moment the previous one expires, forever, unless something upstream (the client itself, or a longer ban/backoff pairing) breaks that cadence. - Shared NAT/CGNAT WAN IP as the ban scope — IP-based banning cannot distinguish individual devices behind one NAT gateway. One misbehaving or misconfigured device can lock out every other device on the same home network from a public service, including your own monitoring. Worth remembering as a structural limitation of IP-scope decisions specifically for home/residential networks, distinct from the bug in this writeup.
DDNS -- a second, untracked ddns-update.sh on the caddy LXC raced the real updater, pushing garbage (empty string, Cloudflare error page body) to the live wildcard A record and unconditionally reloading Caddy high
Wildcard A record for the domain intermittently held garbage values -- an empty string, or a literal Cloudflare error page body ("error code: 522") -- instead of a valid IPv4 address • Caddy reloaded unexpectedly outside any known deploy/config-change event • A second copy of a DDNS updater script (/usr/local/bin/ddns-update.sh, .sh suffix -- distinct from this repo's tracked bin/ddns-update, no suffix) found running via its own cron entry on the caddy LXC, with zero IP validation before writing to the live public record
dns ddns porkbun caddy untracked-script validation-gap race-condition   last seen: 2026-07-21

Symptoms

See also: ddns-duplicate-wildcard-a-record, adguard-dns-rewrite-reversion

Symptom

Prior to the 2026-07-21 rewrite of this repo's bin/ddns-update, the wildcard A record for the domain was found holding invalid values -- including an outright empty string and, at least once, the literal text of a Cloudflare error page ("error code: 522"). Caddy was also observed reloading at times not tied to any known config push or deploy.

Root cause

A second, completely untracked DDNS updater script, /usr/local/bin/ddns-update.sh (note the .sh suffix -- distinct from this repo's own bin/ddns-update, which has no extension), was found running on the caddy LXC via its own independent cron entry. It had never been added to this repository, so nothing about its existence, logic, or schedule was visible to collect-homelab, hosts-config.yaml, or any prior session's context. This script: - Detected the WAN IP with no validation whatsoever before writing it to the live public wildcard A record -- confirmed pushing both an empty string and, on at least one occasion, the raw body of a Cloudflare error page (rather than an actual IP address) directly into the DNS record. - Unconditionally reloaded Caddy on every detected "change" (including these garbage writes), regardless of whether the change was valid. - Contained logic that mutated a Caddyfile allowlist IP literal as part of its "update" routine -- a mechanism that had already been retired in favor of the CrowdSec bouncer (2026-07-18), meaning this script was actively fighting a security control that had superseded it, unbeknownst to whoever/whatever had left it running. - Ran in parallel with this repo's own bin/ddns-update (also targeting the same wildcard record), with neither script aware of the other, producing an uncoordinated race on every cycle where both happened to run close together. The combination of zero-validation writes plus an unconditional Caddy reload made this considerably more dangerous than a simple stale-record problem: a single bad detection (e.g., a transient network error returning an HTML error page instead of an IP) could push that garbage straight to the live public DNS record and then reload the reverse proxy against it, compounding a transient blip into an actual outage.

Remediation

- The second script and its cron entry were removed entirely from the caddy LXC. - bin/ddns-update (this repo's tracked, canonical version) was rewritten the same day with substantially more hardening -- see that script's own header comment for the full list, including: IP detection via Porkbun's own authenticated /ping endpoint rather than a third-party echo service, strict IPv4 regex validation before acting on any detected IP regardless of source, credentials piped via stdin heredoc rather than command-line arguments (avoiding exposure in ps aux), flock against overlapping runs, and an always-re-verify-against-live-state design (no local cache that could mask drift from any other source). - Confirmed via direct SSH (`ssh caddy "crontab -l; ls -la /usr/local/bin/ddns-update*"`) during the 2026-07-21 duplicate-wildcard investigation (see known-fixes/ddns-duplicate-wildcard-a-record.md) that the old script is renamed to .retired with no active crontab entry referencing it -- confirming the removal held.

Why this is worth its own writeup, separate from the duplicate-record incident

This and the wildcard-duplicate-A-record incident on the same day (known-fixes/ddns-duplicate-wildcard-a-record.md) are related but distinct failure modes against the same record: - This incident: an entirely separate, untracked script writing unvalidated garbage, discovered and removed. - The later incident: after this script's removal and the rewrite's own strict validation were in place, the *canonical* script's own editByNameType call against the wildcard appears to have created a duplicate record rather than updating in place -- a bug in the legitimate tooling, not a rogue second writer. Both produced symptoms that could plausibly be mistaken for the other (unexpected wildcard record state, repeated corrections) -- worth checking which one actually applies before assuming a recurrence is "the same bug again." The diagnostic pattern that distinguishes them: check for a second running script/cron entry FIRST (as done here) before assuming the canonical script's own logic is at fault (as was actually the case in the later incident).

Lesson for future untracked-script hunts

- Always check for scripts with suffix variants of a known name (.sh, .old, .bak, a differently-cased copy) when hunting for a second writer -- ls -la /usr/local/bin/* (glob, not exact match) catches this; an exact-name check would have missed it. - A script mutating security-adjacent config (a Caddyfile allowlist, in this case) as a side effect of an unrelated task (DNS updates) is a red flag on its own, independent of whether it's currently causing visible symptoms -- it means removing/retiring a security mechanism elsewhere (the CrowdSec migration, in this case) doesn't fully retire it if something else still depends on the old mechanism's artifacts. - "Removed entirely" is worth re-verifying directly (crontab -l, ls) in any future session touching the same area, rather than trusting a past session's own claim that a cleanup was completed -- which is exactly what the 2026-07-21 duplicate-record investigation did before moving on to other hypotheses.
Headscale v0.29 upgrade — config key removed, strict upgrade path high
headscale refuses to start after upgrade • randomize_client_port config error • skipping minor version upgrade blocked
headscale upgrade   last seen: 2026-06-18

Symptoms


Pre-upgrade checks (v0.28 → v0.29)

1. Remove `randomize_client_port` from config.yaml

Headscale v0.29 refuses to start if this key is present.
grep -i randomize /etc/headscale/config.yaml
sed -i 's/^randomize_client_port:.*$/# randomize_client_port: removed — moved to policy file in v0.29/' \
  /etc/headscale/config.yaml

2. Strict minor-version upgrade path

v0.29 enforces sequential minor upgrades. If on v0.27 or earlier, upgrade to v0.28 first.

3. ACL wildcard `*` behavior changed

* now resolves to tailnet-only CGNAT range (100.64.0.0/10). Use autogroup:danger-all if you need all IPs.

Verify after upgrade

systemctl status headscale
curl -s https://headscale.compellinglylowbrow.org/health

Must return {"status":"pass"}

tailscale status # all nodes should still show connected
homelab-switch: tagging only the trunk port to VLAN 30 (not the access ports too) split the switch into two non-bridging VLANs -- full LAN outage. Fix looked complete but a second outage followed -- PVID is a separate, unset setting. Eventually closed via workaround: editing port 10's PVID reproducibly wedges the switch's web daemon, root cause never found -- port 10 stayed at PVID 1 permanently, which turned out not to matter functionally high
Rebooting homelab-switch after applying an 802.1Q VLAN config that set only port 10 (the UCG-Fiber trunk uplink) to native/untagged VLAN 30 took down the entire LAN • Every device physically connected to the switch (ports 1-9) lost reachability to the gateway/DNS/internet simultaneously • The switch's own management IP (192.168.42.35) may or may not remain reachable depending on which port the admin session is on and what the Management VLAN setting is -- not a reliable signal that the config is fine
network switch layer2 vlan 802.1q trunk management-vlan pvid omada homelab-switch ucg-fiber outage   last seen:

Symptoms

See also: nastynas-pibox-lan-ip-unreachable-post-vlan-work, homelab-switch-lost-management-ip-after-power-cycle, omada-switch-config-not-saved-to-flash

Symptom

While redoing docs/ucg-fiber-session2-step1-vlan-checklist.md's Phase D (tagging homelab-switch's trunk port for VLANs 10/20/50/60, after an earlier attempt got wiped by a factory reset the night before -- see known-fixes/nastynas-pibox-lan-ip-unreachable-post-vlan-work.md), applying and rebooting a VLAN config that only touched port 10 (the UCG-Fiber uplink) took the entire LAN down. Every device connected to the switch's other ports lost reachability to the gateway, DNS, and the internet at the same moment.

Root cause

A factory reset resets every port on the switch back to default VLAN 1 -- not just the port being actively reconfigured. The Phase D checklist (written before this was understood) only described reconfiguring port 10: native/untagged VLAN 30, tagged VLANs 10/20/50/60. It implicitly assumed the other access ports (1-9) were "already" on VLAN 30 in some sense, because before the VLAN rollout the whole LAN was one flat, untagged network. That assumption was wrong for a standalone switch after a factory reset: ports 1-9 were untagged/native on VLAN 1, and only port 10 moved to native VLAN 30. A switch does not bridge traffic between different VLANs -- that's the entire point of 802.1Q. The moment this config took hold, the switch was split into two non-communicating broadcast domains: VLAN 1 (everything physically plugged in) and VLAN 30 (only the trunk uplink to the gateway). Nothing on ports 1-9 could reach the gateway/DNS/internet any more, because their untagged traffic was landing in the wrong VLAN entirely relative to where the uplink now lived. Second, related landmine found during the fix: this switch has a separate Management VLAN setting (System settings, not the 802.1Q VLAN table) that was still 1. Once every access port stops being an untagged member of VLAN 1 (as part of the actual fix, below), a management interface still bound to VLAN 1 becomes unreachable from any access port -- a second, independent way to lock yourself out of the switch's own admin UI, on top of the LAN-wide outage. This has to move to 30 in the same change.

Fix

Every populated access port must move to the trunk's native VLAN together with the trunk port, not in isolation: - VLAN 30 (Servers): Untagged member ports = 1-10 (all of them, not just port 10) - VLAN 10, 20, 50, 60: Tagged member ports = 10 only (trunk uplink; no access port is a member of these yet -- nothing migrates to them until a later phase) - Management VLAN (System settings): 1 → 30 -- must match wherever untagged/native admin traffic now actually lands Recovery procedure used when the bad config had already been saved to flash: full factory reset (management IP .35 and the homelab-switch name both need re-setting after this), confirm baseline connectivity, then reapply the corrected full-port config above.

Prevention

- **A factory reset wipes VLAN state on every port, not just the one you're actively working on.** After any factory reset, treat 802.1Q config as needing to be rebuilt for every populated port, not just the port that motivated the reset in the first place. - **A trunk port's native VLAN must match every access port's native VLAN for that L2 domain to keep bridging.** Moving a trunk uplink to a new native VLAN without moving the access ports along with it silently splits the switch, even though each individual port's config looks locally correct. - **Check for a separate Management VLAN setting before changing which VLAN carries untagged/native traffic.** It's easy to only look at the 802.1Q VLAN table and miss that admin-plane reachability is gated by a different setting entirely. - Test live (Apply, no reboot yet) before committing to flash. This device requires an explicit separate "Save Config" action beyond Apply (known-fixes/omada-switch-config-not-saved-to-flash.md) -- use that gap deliberately: verify connectivity while the change is only live, not yet persisted, so a mistake can be corrected with another Apply instead of requiring a full factory reset. - Full corrected procedure, with checkboxes, lives in docs/ucg-fiber-session2-step1-vlan-checklist.md's Phase D.

UPDATE 2026-08-23, same day: the fix above was NOT actually sufficient

Everything in "Fix" and "Current impact" above was applied, and verified clean from developer-env (ping/ARP to .35, HTTP 200 through Caddy, admin UI reachable both ways, config confirmed surviving a UI-initiated reboot). Despite that, a second outage followed shortly after: household WiFi/internet, Tailscale on a laptop, and the gateway's own admin UI (192.168.42.1) all became unreachable. This was never fully root-caused live -- another factory reset was needed to recover before the exact mechanism could be confirmed on the device itself. Two things came out of chasing it: **Leading theory (found via TP-Link's own documentation, not yet verified against this switch's live state): PVID is a separate setting from the 802.1Q VLAN membership table.** TP-Link's Easy Smart Switch FAQ (, redirects to ) is explicit that VLAN membership (Tagged/Untagged/Not Member per port) and PVID are configured on two different pages -- PVID lives under its own menu, VLAN → 802.1Q VLAN PVID Setting -- and that **PVID defaults to 1 on every port** independent of whatever the membership table shows. Setting a port "Untagged" on VLAN 30 in the membership table does not appear to automatically set that port's PVID to 30. If PVID silently stayed at 1 on every port through both attempts documented above, incoming untagged traffic on every access port would still be internally classified as VLAN 1 -- which, once VLAN 1 lost its forwarding path across the trunk, would isolate exactly the way both outages actually looked. This was never confirmed by actually opening that page before the second outage forced a reset; treat it as the leading, not confirmed, explanation. **Separate, confirmed gap: verification from developer-env does not prove the switch's access ports are healthy.** Every check run from developer-env (ping, ARP, curl) stayed clean through both outages -- because it's unconfirmed whether developer-env/proxmox-nuc's own uplink even runs through homelab-switch at all. The only thing that actually caught the second outage was a real client (a laptop) physically behind the switch failing to reach the gateway. Any future verification of this switch's access-port behavior needs to come from a device that's actually downstream of it, not developer-env. Also exposed: no real physical port map exists. What's actually plugged into ports 1-9 beyond port 10 (the trunk) has never been documented in this repo. docs/ucg-fiber-session2-step1-vlan-checklist.md now has a pre-flight checklist (port map, PVID page check, PVID explicitly in the plan, real-client verification) to close this before Phase D is attempted a fourth time. Mitigation applied, not a fix: u7-pro (household WiFi) was relocated off homelab-switch directly onto the gateway during this outage -- deliberately permanent, not a temporary revert (see its entry in hosts-config.yaml). This means household WiFi can no longer be affected by a future Phase D attempt going wrong again, even though Phase D itself still isn't built correctly.

Research addendum (2026-08-23, before third Phase D attempt)

Before retrying Phase D a third time, researched this switch's VLAN/PVID model against official TP-Link/Omada documentation and independent community reports, to verify the plan is sound rather than retrying with the same blind spots. Findings, none yet verified live on this exact device: - **PVID theory confirmed by TP-Link's own documentation, not just inferred.** Their documented trunk-port recipe is verbatim: make the port UNTAGGED in the default/native VLAN, set PVID to that VLAN, and TAGGED in every other VLAN — exactly this fix's target config. PVID is confirmed to be a genuinely separate setting from the 802.1Q VLAN membership table on this switch family, defaulting to 1 on every port, and is not auto-set by marking a port "untagged" in the membership table (confirmed by TP-Link's own PVID docs and by an independent debugging writeup: ). Target config is correct; the gap is making sure PVID is actually touched, not just membership. - GUI menu path is probably stale. "VLAN → 802.1Q VLAN PVID Setting" as a standalone top-level menu is old-GUI language (). This is 2025 hardware — current TP-Link docs () put PVID as a sub-tab of the same 802.1Q VLAN section (`L2 Features → VLAN → 802.1Q VLAN → Port Config, sibling to the VLAN Config` tagged/untagged tab), not a separate page. Confirm the switch's actual live menu labels before hunting for a page that may not exist in this GUI generation. - **New, previously undocumented risk: Save may not be reliable across multiple passes.** A TP-Link community report on this switch family (, and a related thread) describes Save persisting correctly for one pass, but a second incremental Save (Apply → verify → more changes → Save again) not surviving reboot. Both prior Phase D attempts involved multiple Apply/Save rounds. Not confirmed as the actual cause of either outage, but cheap to avoid: complete every edit (VLAN membership + PVID on all four ports + Management VLAN) in one sitting, Save exactly once at the end, then reboot. - **New, previously undocumented candidate contributing cause: Loop Prevention.** This switch has an "Auto Loop Prevention" feature (per its datasheet). Omada's own best-practices doc () states loopback detection alone can false-positive block a port during topology changes, with effects that can propagate to upstream switches — a plausible alternate/contributing explanation for outage #2's broader blast radius (household WiFi/Tailscale/gateway UI, not just this switch), which was never fully root-caused. Worth checking this setting's current state before the next attempt, and checking port status for a "blocked by loop protection" indicator (not just assuming pure VLAN misconfig) if a similar outage recurs. Confirmed live via a dry-run walkthrough the same day (before any real changes): this switch's actual GUI has VLAN Config and Port Config (PVID) as sibling tabs under VLAN → 802.1Q VLAN (matching the new-GUI prediction above, not the old-GUI standalone-page guess), plus a separate management config tab for Management VLAN. PVID showed 1 on all 10 ports and Management VLAN showed 1 going into this change, both as expected at factory default. Loop Prevention (under Monitoring) is confirmed disabled — so that risk doesn't apply to this attempt, no action needed there. **Target config revised the same day, after "what happens if a new device gets plugged into an open port" came up:** untagged VLAN 30 + PVID 30 now go on all 10 ports, not just the 4 currently-populated ones (4/5/9/10). The original narrower plan would have left ports 1/2/3/6/7/8 on VLAN 1 with no trunk path off the switch at all (port 10 carries no VLAN 1 traffic once it's native 30 + tagged 10/20/50/60) — anything plugged into one of those ports would silently get no DHCP, no internet, no LAN reachability, with no error to point at (JetKVM returning to service was the concrete example that surfaced this). Setting all 10 ports to VLAN 30 costs nothing since nothing migrates off VLAN 30 onto the other VLANs on this switch until Session 2 Steps 2-5, much later. None of this changes the trunk port's own tagged-VLAN config (10/20/50/60 tagged on port 10 only) — it's execution-discipline risk mitigation plus a broadened access-port target, folded into docs/ucg-fiber-session2-step1-vlan-checklist.md's Phase D.

UPDATE 2026-08-23, later the same night: Phase D closed out via workaround

Third attempt (after the dry-run walkthrough and research addendum above) rebuilt the full config carefully, a few ports at a time with checks between each step. VLAN Config (VLAN 30 untagged on all 10 ports; VLANs 10/20/50/60 tagged-only on port 10) went in cleanly and has been reliable every time since. PVID on ports 1-9 also went in cleanly, saved, survived reboots. **Port 10's PVID reproducibly wedged the switch's web-management daemon, 3-for-3, across three meaningfully different conditions:** 1. Batched as the last of 9 sequential PVID edits in one session 2. Isolated as the *only* action, on a fresh login, immediately after a reboot 3. Isolated with the trunk cable physically unplugged first (to test whether a live-traffic burst on reclassification was the trigger -- it wedged anyway, ruling that theory out) Also tried and ruled out: editing port 10's row in VLAN 1's own membership table (Untagged -> Non-member) -- wedged identically, so this isn't specific to the Port Config/PVID page, it's specific to changing port 10's *native/untagged VLAN identity* by any path. Firmware was already at the latest version (1.0.1 build 20251128, confirmed live on the device), which rules out the specific "MMU throughput" and "UI display anomalies" bugs that release's notes mention fixing -- if related, it's a bug that release didn't catch. Every wedge had the identical signature: LAN/forwarding stayed completely healthy throughout (nastynas, proxmox-nuc, gateway, DNS, and Caddy-to-everything-else all confirmed reachable via direct ping/curl from developer-env during every single wedge) -- only the switch's own HTTP web server became unresponsive (curl genuine TCP timeout, not refused). Also newly confirmed: this same hang makes the gateway's UniFi Network app show homelab-switch as disconnected/grayed-out on its port 6 -- resolving the open question from the very first incident above about what causes that display state. It tracks the web-daemon hang (almost certainly because device discovery/announcement rides the same management-plane stack as the web UI), not a real link or trunk failure. Recovery was a simple power-cycle every time, consistently restoring the web UI within about a minute with zero LAN impact and zero loss of anything already through Save Config. **Root cause was never found -- this switch has no SSH/API surface and therefore no logs to actually diagnose it with.** Given that, and given recovery was cheap and repeatable every time, the decision was to design around the bug rather than keep chasing it blind. The workaround: stop trying to move port 10 to PVID 30 at all. This turned out to cost nothing functionally. Untagged/native traffic carries no VLAN tag on the wire -- the specific number the switch privately calls its native domain is invisible to the gateway and everything else, and only needs to be *consistent across the switch's own ports* for bridging to work. VLAN 1's membership table was never actually edited successfully in any attempt (every edit to it also wedged and reverted) -- it still lists all 10 ports as untagged members, exactly as at factory default. That means VLAN 1 has been quietly functioning as the real shared native domain across every port this entire time, completely overlapping with VLAN 30's identical membership list, regardless of what each port's PVID says. Ports 1-9 reclassify their untagged ingress traffic as VLAN 30 (via PVID), port 10 still reclassifies as VLAN 1 -- but since every port remains a valid untagged egress member of *both* VLANs, traffic keeps bridging correctly across the whole switch either way. Management VLAN was left at its default (1) for the same reason -- no functional need to move it once port 10 no longer needed to reach VLAN 30. **Final accepted config, confirmed surviving a clean UI-initiated reboot:** - VLAN 30 (Servers): untagged member ports 1-10 (all) - VLANs 10/20/50/60: tagged member ports = 10 only - PVID: 30 on ports 1-9, 1 on port 10 (deviation, accepted) - Management VLAN: 1 (deviation, accepted) - VLAN 1: untouched, still lists all 10 ports as untagged members (never successfully edited -- this is what makes the deviation harmless) - Loop Prevention: disabled throughout, never touched Verified functionally complete: proxmox-nuc (port 4), the qbittorrent LXC (port 5), and nastynas (port 9) all reachable from developer-env; gateway reachable, confirming the trunk (port 10) is carrying traffic correctly. Phase E (an actual device tested live on VLANs 10/20/50/60) is separate and still not done. If this is ever revisited: try a firmware re-flash/downgrade cycle, or file a TP-Link support ticket with the three reproduction conditions above -- all three wedging identically on port 10's native-VLAN identity specifically is a clean, reportable repro. Not urgent; the current state is stable and fully functional.

Current impact

Resolved via workaround, not a full fix. Phase D's actual goal (trunk carrying VLANs 10/20/50/60, Servers network intact) is done and stable as of 2026-08-23 night. The specific bug that blocked the originally-planned clean config (port 10 reaching PVID 30) remains unexplained and un-fixed -- worked around instead, at zero functional cost. Household WiFi remains permanently moved off this switch onto the gateway directly, independent of this resolution. See docs/ucg-fiber-session2-step1-vlan-checklist.md's Phase D for the full final checklist and closing note. **Recurrence, 2026-08-24, unprompted -- no PVID/config edit involved this time.** Found while MOS tested off-LAN access to `homelab-switch. compellinglylowbrow.org` (unrelated goal -- checking whether the same accept-routes/ACL approach just built for the gateway was needed here too). The FQDN failed to load; traced to the switch's web daemon on port 80 being completely unresponsive (curl to 192.168.42.35:80 timed out from developer-env) while the device itself was fully healthy on the network (ping 0% loss, sub-ms latency, correct ARP entry, `b8:fb:b3:49: aa:db`) -- Caddy's config and DNS were both confirmed correct and uninvolved. Same signature as the Phase D wedges: only the web-management daemon hangs, LAN traffic is unaffected. This time nothing in this repo or MOS's recent activity touched port 10's PVID or any other switch setting -- strengthens the case that this daemon can wedge spontaneously, not only as a direct reaction to a PVID edit. **No remote recovery exists** -- this device has no SSH/API surface at all, so a stuck web daemon can only be cleared by a physical power-cycle. This is the actual limit on "access the switch while away": even a fully correct remote- access setup (which this switch already has, via Caddy) can't help during the exact failure mode most worth reaching it for. If unattended recovery from this class of wedge ever matters enough, the fix is a remote-controllable power source (smart plug/PDU) between the switch and the wall, not a networking/ACL change -- not yet built, worth a deliberate decision before pursuing.
nastynas + pibox unreachable by raw LAN IP -- RESOLVED: stray Tailscale-accepted subnet route for their own directly-connected LAN high
nastynas (192.168.42.200) and pibox (192.168.42.117) both unreachable via raw LAN IP (ping, TCP) from every vantage point tested (developer-env, proxmox-nuc via both NICs, the ucg-fiber gateway itself) • Both hosts remain fully reachable via their Headscale/Tailscale IPs the entire time (SSH, Caddy-fronted UIs) -- this is a LAN-path-specific failure, not a host-down failure • proxmox-nuc (also physically on the same switch/gateway) is completely unaffected
network tailscale headscale subnet-router accept-routes policy-routing switch gateway ucg-fiber homelab-switch vlan bridge unifi nastynas pibox resolved   last seen:

Symptoms

See also: shared-unmanaged-switch-wedge-multi-host-outage, homelab-switch-lost-management-ip-after-power-cycle, jetkvm-webrtc-lan-only-ice-candidate

Root cause (found 2026-08-23, after an exhaustive multi-hour sweep)

Both nastynas and pibox are directly, physically connected to 192.168.42.0/24 and are Tailscale members with --accept-routes enabled. caddy (node id 4) is this tailnet's subnet router, advertising the full 192.168.42.0/24 -- set up during the same extended session, for off-LAN JetKVM video access (known-fixes/jetkvm-webrtc-lan-only-ice-candidate.md). Both hosts silently accepted that advertisement and installed a route for it in Tailscale's own policy-routing table (ip route show table 52 showed 192.168.42.0/24 dev tailscale0), and that table is consulted at a **higher priority** than the normal main routing table (ip rule show: `5270: from all lookup 52 sits ahead of 32766: from all lookup main`). The result: for traffic to their own directly-connected LAN, the kernel's routing decision pointed at tailscale0 instead of the correct local interface (vmbr0/eth0) -- a self-referential routing loop where a host tries to reach its own subnet *through the tailnet* instead of directly. Confirmed precisely via strace -f -e trace=socket,sendto,connect ping ... on nastynas: ping's internal connect()+getsockname() trick (used to learn which local source IP to use) returned 100.64.0.113 (its Tailscale IP) instead of 192.168.42.200 (its real LAN IP) for a destination on its own subnet. The sendto() itself genuinely succeeded (packet transmitted), but with the wrong source address for the interface it physically exited on -- almost certainly treated as a spoofed/martian packet by the receiving gateway, or answered back down a path that never reaches the ping socket. A raw Python ICMP socket to the same destination worked instantly, which is what broke the case open: it proved the network path itself was never the problem, only the kernel's own source-IP selection was. Likely trigger for why this surfaced now, not earlier: not proven with certainty, but nastynas's NIC was bounced up/down repeatedly across four physical moves during Session 2 Step 1's Phase D troubleshooting -- each interface event can make tailscaled re-derive its routing table, plausibly the point it picked up and mis-prioritized caddy's already-standing /24 advertisement. caddy's subnet-router role itself predates this incident and was never the thing that changed.

Fix

Disabled route-accepting on both affected hosts -- they're always LAN-resident servers, never roaming, so they get zero benefit from being able to reach 192.168.42.0/24 through the tailnet (they're already directly on it) and only downside (this exact loop):
tailscale set --accept-routes=false
Run as root on nastynas, via sudo on pibox (its SSH user is pibox, not root). Confirmed via ip route show table 52 that the stray 192.168.42.0/24 dev tailscale0 entry disappeared from both immediately, leaving only the individual tailnet peer host routes. Verified fully recovered from every vantage point tested throughout the incident (developer-env, proxmox-nuc, the gateway itself) -- clean 0% packet loss, normal sub-millisecond LAN latency, Proxmox UI (8006) returning HTTP 200. Do not disable accept-routes tailnet-wide -- every other member is a genuinely roaming device (personal laptops/phones, and JetKVM's own off-LAN access path) that specifically *needs* accept-routes to reach 192.168.42.0/24 addresses through caddy's subnet-router role when off-LAN. This fix is scoped correctly only to hosts that are themselves permanently, directly resident on the exact subnet being advertised.

Prevention

- **Any host that is both a direct member of a LAN subnet and a Tailscale client with accept-routes on is at risk of this exact loop**, the moment any tailnet subnet router starts advertising that same subnet -- which happened here specifically because of the JetKVM work earlier the same session. Audit other permanently-LAN-resident hosts on this tailnet (proxmox-nuc, watchdog, developer-env, etc.) for the same ip route show table 52 | grep 192.168.42.0/24 signature if this class of symptom recurs elsewhere -- none showed it during this incident, but none were exhaustively checked either, only the two that actually broke. - **When a symptom looks like a broken network path (packets sent but no reply) and every conventional layer (firewall, bridging, ARP, sysctls) checks out clean, check kernel routing table *selection* specifically -- strace on the actual failing command is the fastest way to see exactly which source IP/route the kernel picked**, rather than continuing to infer it indirectly from tcpdump/iptables output alone. A raw hand-built socket (bypassing whatever tool is failing) is a cheap, decisive way to separate "the network is broken" from "this specific tool or code path is choosing wrong." - Adding a new subnet router (or changing what's advertised on one) is not a fully isolated, no-side-effect operation on this tailnet if any node with accept-routes on happens to also be a direct member of the advertised subnet -- worth remembering before the next subnet-router change.

Diagnostic trail (everything else checked and ruled out along the way)

This took an unusually long, thorough sweep before the real cause surfaced -- keeping the full trail here since every one of these was a legitimate, correctly-executed check, not wasted effort, and the pattern (exhaust every conventional layer, then check kernel routing selection specifically) is worth remembering for a future incident that looks similar. **Timeline of remediation attempts, all before the real fix, none of which worked on their own:** 1. Switch power-cycle (matching two prior, superficially similar incidents) -- no change, and revealed pibox was down too. 2. Moved nastynas to a different switch port (9 -> 7, confirmed via real speed renegotiation) -- no change, ruling out a bad port. 3. Full factory reset of the switch (wipes all forwarding/VLAN state unconditionally) -- no change even after reconnecting to port 9. 4. Moved both hosts entirely off the switch, directly onto ucg-fiber -- no change, including from the gateway's own host stack. This conclusively ruled out the switch, but also surfaced a real, separate, genuine gap: 5. The gateway's second SFP+ port had never been assigned a LAN role -- it defaulted to secondary/failover-WAN, which explains why it showed real physical link but zero bridge membership. Fixed via the UniFi Network app's port-role setting (a different setting entirely from a Network/VLAN assignment) -- necessary and correct, but not sufficient on its own to fix the actual incident. 6. A full gateway reboot (to rule out a hardware-offload/flow-table sync gap) -- role change survived correctly, traffic still didn't pass. 7. Deep packet-level diagnosis (tcpdump on both ends, interface TX counters, all 5 iptables tables, nftables, ebtables/bridge- netfilter, tc filters, AppArmor, routing table at a glance, sysctls, container-context verification, NIC offload features including VLAN hardware offload, XDP program check) -- every layer came back clean. 8. A full clean reboot of nastynas itself (up 0 min confirmed) -- same failure reproduced immediately, ruling out transient/corrupted state. 9. The JetKVM (independently documented in this repo as a prior cause of this exact nastynas+pibox-together symptom on the *old* unmanaged switch) was physically disconnected entirely, then the gateway rebooted again -- no change. A strong, well-motivated theory given precedent, but not this incident's cause. 10. Comparing an interface TX-counter delta against tcpdump confirmed a genuinely locally-initiated ping produced no visible ARP/ICMP traffic for any destination, including a never-before-seen IP -- while other background traffic on the same interface clearly did transmit. This was the thread that eventually led to strace-ing ping directly, which is what actually broke the case.

Current impact

None -- fully resolved. Both hosts confirmed reachable by raw LAN IP from developer-env, proxmox-nuc, and the gateway itself, with normal latency.

Related

known-fixes/shared-unmanaged-switch-wedge-multi-host-outage.md and known-fixes/homelab-switch-lost-management-ip-after-power-cycle.md -- both describe superficially similar nastynas+pibox-together outages, both switch-side and unrelated to this incident's actual cause. known-fixes/jetkvm-webrtc-lan-only-ice-candidate.md -- the session that set up caddy as the subnet router whose advertisement both affected hosts ended up accepting.

Recurrence on a roaming device, same night -- different tradeoff, not just "run the same fix"

The identical mechanism (kernel/policy-routing selecting Tailscale's utun0/tailscale0 over the physical LAN interface for a directly- connected LAN destination) also hit MOS's Mac later the same session, diagnosed the same way (route get 192.168.42.1 showed `interface: utun0) -- see known-fixes/trusted-vlan-gateway-ui-firewall-gap.md` for the full incident it was tangled up in. **The blanket fix from this file (tailscale set --accept-routes=false) does not apply the same way to a roaming device.** nastynas/pibox are permanently LAN-resident and get zero benefit from accept-routes -- pure downside. The Mac is a genuinely roaming device that specifically *needs* accept-routes=true to reach 192.168.42.0/24 by raw IP through caddy's subnet-router role while off-LAN (direct SSH by IP, Proxmox UI direct, SMB by IP, Kuma/watchdog UI direct -- anything not going through Caddy's *.compellinglylowbrow.org HTTPS domains, which are unaffected either way since those resolve straight to Caddy's own tailnet IP). Permanently disabling it on the Mac would trade away that off-LAN capability to fix a problem that only manifests while the Mac is physically home. As of 2026-08-23 night this was left as true (unchanged) pending a real decision -- not yet resolved, tracked as an open follow-up, not a repeat of this file's fix.
nastynas → wildwood bulk transfer stalls at ~112 kB/s high
rsync from nastynas to wildwood stalls at ~100-120 kB/s • small commands complete instantly but bulk transfers hang • tailscale status shows direct connection but transfer is broken
nastynas wildwood rsync tailscale   last seen: 2026-06-25

Symptoms

See also: nastynas-wildwood-derp-relay

Cause

Unknown. The nastynas Tailscale client has a broken bulk transfer path to wildwood specifically. proxmox-nuc → wildwood achieves 88 MB/s over the same infrastructure.

Diagnosis

# Confirm it's nastynas-specific
ssh root@192.168.42.25 "dd if=/dev/zero bs=1M count=1024 | ssh -i /root/.ssh/id_ed25519 mos@100.64.0.3 'cat > /dev/null'"

proxmox-nuc → wildwood: ~88 MB/s → path is fine

ssh nastynas "dd if=/dev/zero bs=1M count=100 | ssh wildwood 'cat > /dev/null'"

nastynas → wildwood: hangs → confirmed broken

Resolution

Do not use nastynas as the source for offsite backups. All transfers to wildwood originate from proxmox-nuc. See BACKUP-HARDENING-CONTEXT.md.

SUPERSEDED (2026-08-09) — do not follow the resolution above as current guidance

This finding predated Headscale's 2026-07-12 ACL tightening, which (unrelatedly) also removed nastynas↔wildwood as an ACL-reachable pair entirely — the original ~112 kB/s measurement was never re-testable from 2026-06-25 until Thread 3 of BACKUP-HARDENING-CONTEXT.md added it back (ACL rule 12) on 2026-08-09 and retested with real data: **~85 MB/s raw (scp), 267 MiB/s via PBS's own sync protocol** — three orders of magnitude faster than this entry's number. Whatever caused the original stall either no longer applies or was specific to a network state that no longer exists. **nastynas is now a live, working offsite-sync source** (bin/nastynas-self-offsite-sync.sh, bin/proxmox-nuc-offsite-sync.sh — both push nastynas→wildwood nightly). Left this entry in place for its historical diagnosis method, not its conclusion — see BACKUP-HARDENING-CONTEXT.md Threads 2/3 for current architecture.
OS-package pseudo-services could never auto-execute -- release_is_old_enough() had no release date to find, plus two related format bugs caught by the first real collect-homelab run high
A <host>-os service gets a SAFE verdict but exec_skipped_reason always says 'could not determine release date -- skipping auto-execute', forever • update-advisor log shows identical-looking 'calling Claude (13.8 -> 13.8)' or similar for an OS-package service -- looks like a no-op version comparison • A host and its own <host>-os alias entry both get checked separately by os-update-checker, producing a junk <host>-os-os.txt
update-advisor os-update-checker age-gate release-date regex hosts-config   last seen: 2026-08-08

Symptoms

See also: hosts-config-yaml-step-schema-gotchas

Symptom 1 (the important one): normal auto-execution silently never fires

bin/update-advisor's SAFE-verdict auto-execution path requires release_is_old_enough() to find a Release: ... (ISO8601-date) line in the service's notes file (7-day default via MIN_RELEASE_AGE_DAYS, gives time for bug reports to surface against a tracked app's GitHub release before auto-applying). bin/os-update-checker's notes format never wrote that line at all -- only a # fetched: header, which the regex doesn't match. Every -os service therefore always got `(False, "could not determine release date -- skipping auto-execute"), regardless of verdict, forever -- --force-execute` was the only way anything would ever apply. Caught on the very first real collect-homelab run with the full -os rollout live; didn't show up in earlier testing because those tests all used --force-execute (which bypasses this check entirely), never exercising the normal unattended path the execute_window feature exists for.

Root Cause

There's no single "release date" for a bundle of N different apt packages the way there is for one tracked app's GitHub release. The fix: os-update-checker now tracks "time since this exact pending-package set was first seen" per host (get_stable_since(), a small -os.stable-since.json state file keyed by a hash of the package set) and embeds that as a Release: line, so release_is_old_enough() works unmodified for OS-package services -- it doesn't need to know or care what kind of date it's reading. The clock resets whenever the actual pending set changes (any package added/removed, or any target version changing) and otherwise persists across runs. Also deliberately uses a **shorter threshold (2 days, OS_PACKAGE_MIN_AGE_DAYS) than the tracked-app default (7 days, MIN_RELEASE_AGE_DAYS)**: the reasoning behind 7 days -- giving the community time to find bugs in a just-cut release -- is much weaker for Debian stable apt packages, which have already been through Debian's own vetting pipeline before reaching the repo at all. update-advisor's execution loop picks the threshold by checking whether svc.get("execute_window") is set (only true for -os pseudo-services) rather than adding a new service-type flag.

Symptom 2: the Release: line format is stricter than it looks

The obvious first attempt, Release: pending-package-set (since 2026-08-08T21:02:42Z), still produced "could not determine release date" even though the line was clearly present. update-advisor's regex is Release:.*?\((\d{4}-\d{2}-\d{2}T[\d:]+Z?)\) -- the capture group must start immediately after the opening (, with digits. changelog-fetcher's own existing usage (Release: {tag} ({published})) confirms the expected shape: nothing but the date between the parens. Fix: `Release: pending-package-set stable-since ({date})` -- move the descriptive word outside the parens.

Symptom 3: a host and its `-os` alias got double-checked

os-update-checker dedups hosts by SSH IP so a host's alias entries (webmin/ headplane sharing an LXC, or any -os pseudo-service entry) don't get probed twice. This broke for vaultwarden specifically because its -os entry was deliberately given a *different*, better IP (its LAN address, to avoid depending on tailscaled -- see known-fixes/tailscaled-magicsock-network-down-stuck.md) than its primary entry, which still fell back to headscale_ip since its own lan_ip was left ~ (dynamic). Fix: record the confirmed-stable LAN IP on the primary vaultwarden entry too (same pattern already used by qui/bentopdf in this file) -- restores the dedup for free without touching os-update-checker's logic. General lesson: if a -os alias intentionally uses a different IP than its primary entry, the primary entry's own IP fields need to agree, or the IP-based dedup silently stops working for that one host.

Verification

# Confirm the age actually accumulates across runs (doesn't reset every 6h)
python3 bin/os-update-checker "$HOMELAB_DIR" --host <name>
grep 'Release:' collected/update-notes/<name>-os.txt   # note the timestamp
python3 bin/os-update-checker "$HOMELAB_DIR" --host <name>   # run again
grep 'Release:' collected/update-notes/<name>-os.txt   # timestamp unchanged if pending set is identical

Confirm the parser reads it correctly at both thresholds

python3 -c " from importlib.machinery import SourceFileLoader ua = SourceFileLoader('ua', 'bin/update-advisor').load_module() from pathlib import Path print(ua.release_is_old_enough(Path('collected/update-notes/<name>-os.txt'), False, ua.OS_PACKAGE_MIN_AGE_DAYS)) "
PBS prune fails — token permissions capped by parent user high
backup completes but prune step fails • permission check failed - missing Datastore.Modify|Datastore.Prune • DatastoreAdmin granted to token but still fails
pbs acl prune permissions   last seen: 2026-06-25

Symptoms

See also: pbs-storage-add-403-cannot-find-datastore

Cause

PBS token permissions are the intersection of the token's ACLs and the parent user's ACLs. A token can never have more permissions than its parent user. Adding DatastoreAdmin to the token has no effect if the parent user only has DatastoreBackup.

Diagnosis

# If only Datastore.Backup shows, the user is the bottleneck
ssh nastynas "proxmox-backup-manager user permissions backup@pbs!nuc"

Fix

Grant DatastoreAdmin to the *user* at root, not just the token:
ssh nastynas "proxmox-backup-manager acl update / DatastoreAdmin --auth-id backup@pbs"

Verify — should now show Datastore.Prune and Datastore.Modify

ssh nastynas "proxmox-backup-manager user permissions backup@pbs!nuc"

Pattern

When PBS auth fails unexpectedly, always check user permissions to see effective permissions rather than just acl list.
raspi4 (guardian node) hard-hung and needed a physical power cycle -- no ARP reply anywhere, no Uptime Kuma monitor to catch it fast high
ping/ssh to raspi4's LAN IP fails with 'No route to host', from developer-env AND from ucg-fiber (the gateway itself, which straddles every VLAN) • ip neigh on the gateway's Trusted-VLAN bridge shows raspi4's IP as FAILED/INCOMPLETE -- no ARP reply at all, not a hang • other hosts on the same VLAN/AP are REACHABLE in the same ARP table -- the VLAN/AP itself is healthy, only raspi4 is dark
raspi4 guardian watchdog hardware-watchdog uptime-kuma hang power-cycle vlan monitoring-gap   last seen: 2026-08-25

Symptoms

See also: raspi4-followed-wifi-ssid-onto-trusted-vlan, trusted-vlan-return-traffic-toggle-missing, pibox-tailscale-selfheal (see tailscaled-magicsock-network-down-stuck.md)

Symptom

daily-fleet-digest's 6:30am apt-get update health check ntfy flagged raspi4 as the one FAILED host out of 19 tracked. At first glance this looked like it could be a repeat of the previous day's VLAN work (known-fixes/raspi4-followed-wifi-ssid-onto-trusted-vlan.md and known-fixes/trusted-vlan-return-traffic-toggle-missing.md -- raspi4 had just moved to VLAN 10 Trusted on 2026-08-24) -- but the diagnostic signature was different, and turned out to be a different problem entirely.

Root cause

raspi4 hard-hung sometime around 03:20 (the last timestamp Headscale saw it) and stayed unresponsive until MOS physically power-cycled it around 07:30. Confirmed via multiple independent vantage points, all pointing at "host is actually down," not a routing/firewall gap: - ping/ssh failed even from ucg-fiber (the gateway itself, which straddles every VLAN and isn't subject to any zone-firewall rule). - The gateway's own ARP table for the Trusted-VLAN bridge (br10) showed raspi4's IP as FAILED/INCOMPLETE -- a genuine no-reply at L2, not a hang behind a firewall rule (which would show as a normal ARP entry with TCP connections stuck in SYN_RECV, the signature of the *previous* day's incident). - Every other host on the same VLAN/AP was REACHABLE in that same ARP table -- ruling out an AP-wide or VLAN-wide problem. - headscale nodes list showed raspi4 offline, last seen 03:20:23. - SSH to raspi4's Headscale IP got Connection refused -- tailscaled itself wasn't answering, consistent with the whole host being down rather than just a WiFi association drop (a WiFi-only drop with the OS still running would still answer on the wired eth0 path if anything were plugged in, and would still hold its Tailscale session state even if unreachable). - After the power cycle, journalctl --list-boots showed only the new boot -- no record of the boot that was running before 03:20, even though persistent journal (/var/log/journal) is enabled. Journal entries not yet fsynced to the SD card get lost exactly this way on a hard hang or real power loss, and would normally survive a clean reboot. This is circumstantial but consistent with a genuine kernel/system hang rather than a graceful WiFi-only issue -- if it had just lost WiFi while the OS kept running, the previous boot's journal would still be there. Exact root cause of the hang itself is unconfirmed -- no logs survived to show what led up to it. Candidates not ruled out: SD card issue, WiFi driver/firmware wedge that took the whole kernel down with it, power blip, thermal. Nothing pointed at a specific one over the others.

Diagnosis

Don't assume every raspi4 connectivity problem the week after the VLAN migration is a repeat of the VLAN/firewall gaps. Distinguish "host down" from "reachable but blocked":
# From the gateway (sees every VLAN, not subject to any zone rule):
ssh ucg-fiber "ping -c2 -W2 <ip>; ip neigh show <ip>"

FAILED/INCOMPLETE = no L2 reply at all = host is actually down.

A normal ARP entry + a timeout on the actual service = firewall/routing gap instead.

Cross-check via Headscale, independent of any LAN path:

ssh headscale "headscale nodes list | grep <hostname>"

offline + stale last-seen corroborates "host down," not just "this one path is blocked."

Check whether other clients on the same VLAN/AP are also affected:

ssh ucg-fiber "ip neigh show dev <bridge>"

If everything else is REACHABLE, it's isolated to the one host, not the network.

Fix

No remote fix was possible -- raspi4 has no smart plug/PDU or KVM (unlike nastynas's JetKVM), so a genuine hard hang needs hands-on-it. MOS power-cycled it directly. Two follow-up fixes applied the same day to reduce recurrence risk and detection lag: 1. Armed the Raspberry Pi's built-in BCM2835 hardware watchdog via systemd. /dev/watchdog0 already existed (the driver is built in) but nothing was petting it -- RuntimeWatchdogSec was commented out (off) in /etc/systemd/system.conf and no watchdog daemon package was installed. Fix:
   sudo sed -i 's/^#RuntimeWatchdogSec=off/RuntimeWatchdogSec=20s/' /etc/systemd/system.conf
   sudo systemctl daemon-reexec   # applies immediately, no reboot needed
   
Confirmed live via journalctl: `Watchdog running with a hardware timeout of 1min` -- the bcm2835_wdt driver doesn't support a custom timeout (wdctl's SETTIMEOUT flag is 0), so systemd is bound to the hardware's fixed 60s window regardless of the configured RuntimeWatchdogSec; that setting just controls how often systemd pets it (well within the 60s margin). If systemd or the kernel hangs again, the SoC force-resets on its own within 60 seconds instead of needing another manual power cycle. This is the first host in the fleet with the hardware watchdog armed this way -- worth considering for watchdog (Pi5) and pibox (also SBCs, also physically inconvenient to reach) if this recurs elsewhere. ⚠️ Gotcha found live: simply *querying* the watchdog device with wdctl (even just to read its status) opens it, and bcm2835_wdt doesn't support magic-close -- opening the device without something actively petting it afterward leaves it armed and counting down to a real reset. Don't run wdctl as a "harmless" check on a host that doesn't have RuntimeWatchdogSec (or an equivalent petting daemon) already active -- arm the petting mechanism in the same breath as any query, not as a follow-up step. 2. Added an Uptime Kuma monitor for raspi4 itself -- despite being the guardian node (watching watchdog/Pi5's own health), raspi4 had no monitor of its own in bin/setup-uptime-kuma.py. The only thing that caught this outage was daily-fleet-digest's once-daily 6:30am apt-get update check -- up to ~24h of detection lag. Added raspi4 (guardian) — SSH (PORT monitor, 192.168.12.74:22, 60s interval, Watchdog group) alongside the existing watchdog — SSH monitor. Probed from watchdog (192.168.42.229, Servers VLAN) across the VLAN 10 boundary added 2026-08-24 -- reachability confirmed live (nc -zv 192.168.12.74 22 from watchdog succeeds, riding the Servers -> Trusted SSH rule from known-fixes/raspi4-followed-wifi-ssid-onto-trusted-vlan.md). bin/setup-uptime-kuma.py needs to be run interactively by MOS (it deliberately prompts for the Uptime Kuma password via getpass rather than accepting it any other way -- see that script's own comment, known-fixes/memory precedent: keep new-service credentials out of chat/automation) -- Claude added the monitor definition but couldn't run the script itself non-interactively.

Current impact

Resolved -- raspi4 back up, SSH confirmed working, hardware watchdog armed and confirmed persistent (/etc/systemd/system.conf edit survives reboot). MOS ran python3 bin/setup-uptime-kuma.py; the new monitor is live and confirmed via the /metrics endpoint (monitor id 41, monitor_status=1, 100% uptime since creation).

Prevention

- A guardian/monitoring node needs to be monitored too. raspi4 exists specifically to catch watchdog (Pi5) going dark, but nothing was watching raspi4 itself beyond a once-daily incidental check. Any future "independent vantage point" node added to this design should get its own fast Uptime Kuma monitor from day one, not as an afterthought found during an actual outage. - **"No ARP reply from the gateway itself" is the tell that distinguishes a real host-down event from a VLAN/firewall misconfiguration** -- the latter (see the two related 2026-08-24 known-fixes) leaves the host answering ARP and even accepting the TCP SYN, just never getting the reply back across the zone boundary. Check from the gateway before assuming any raspi4 connectivity issue is a repeat of that class of bug. - **SBCs on WiFi with no remote power control are a real single point of failure** -- raspi4 has no smart plug/PDU/KVM, so a hard hang has no remote recovery path at all (same gap already flagged for homelab-switch in known-fixes/memory, parked as not urgent there). The hardware watchdog closes the *software-hang* half of this gap for raspi4 specifically; it does nothing for a real power loss or dead SD card. Worth reconsidering a smart plug for raspi4 if this recurs.
Node DNS overridden by Headscale client after host upgrade high
VMs or LXCs lose DNS after Proxmox host reboot • resolvectl shows 100.100.100.100 as upstream • Tailscale magic DNS overriding resolver
tailscale dns proxmox   last seen: 2026-06-03

Symptoms


Cause

The Headscale/Tailscale client overrides the node's DNS settings after upgrade/reboot.

Fix

sudo tailscale set --accept-dns=false
Also fix the Proxmox host DNS via UI (System → DNS): - DNS server 1: 192.168.42.27 (AdGuard) - DNS server 2: 8.8.8.8 (fallback) - Search domain: blank or compellinglylowbrow.org

Note

100.100.100.100 and fd7a:115c:a1e0::53 are Tailscale magic DNS addresses — if you see these, replace them.
tailscale up hangs on LXC that uses AdGuard for DNS high
tailscale up hangs indefinitely on LXC • no output from tailscale up • LXC uses AdGuard as DNS
tailscale dns adguard bootstrap catch22   last seen: 2026-06-02

Symptoms

See also: adguard-headscale-must-resolve-to-lan-ip

Cause

Catch-22: LXC uses AdGuard as DNS, which rewrites headscale.compellinglylowbrow.org → 100.64.0.4 (Caddy's Headscale IP). But the LXC isn't on the tailnet yet, so 100.64.0.4 is unreachable.

Fix

The permanent fix is ensuring AdGuard has the headscale.compellinglylowbrow.org → 192.168.42.45 rewrite. If that rewrite is missing and you need to bootstrap immediately:
echo "192.168.42.45 headscale.compellinglylowbrow.org" >> /etc/hosts
tailscale up --login-server https://headscale.compellinglylowbrow.org --authkey <key> --accept-dns=false --force-reauth

After successful registration:

sed -i '/headscale.compellinglylowbrow.org/d' /etc/hosts

Note

Always use --accept-dns=false on DNS-serving LXCs (adguard, adguard2).
Trusted->Servers firewall rule accepted the forward SYN but had no return-path mirror -- connection hangs in SYN_RECV forever high
A newly-added Trusted->Servers allow rule (UniFi zone-based firewall) looks correct in the UI, but any connection through it just hangs -- no error, no refusal, just a timeout • conntrack -L on the gateway shows the connection stuck in SYN_RECV, with the server side retransmitting SYN-ACK repeatedly (packets=N>1) and never reaching ESTABLISHED • The forward chain (e.g. UBIOS_CUSTOM1_LAN_USER) has the expected ACCEPT rule for the destination ip/port; the reverse chain (e.g. UBIOS_LAN_CUSTOM1_USER) has no matching RELATED,ESTABLISHED rule for the same ip/port pair
network firewall vlan ucg-fiber unifi zone-based-firewall tailscale headscale uptime-kuma trusted   last seen: 2026-08-24

Symptoms

See also: trusted-vlan-gateway-ui-firewall-gap, nastynas-pibox-lan-ip-unreachable-post-vlan-work

Symptom

Two separate incidents, same underlying gap, found back-to-back the same night: 1. Tailscale on MOS's Mac stuck "logged out," unable to re-authenticate after GDTRFB (WiFi) moved onto the Trusted VLAN. tailscale status showed: `You are logged out. The last login error was: fetch control key: Get "https://headscale.compellinglylowbrow.org/key?v=138": dial tcp 192.168.42.45:443: connect: operation timed out`. This is expected to route over plain LAN, not Tailscale (see known-fixes/adguard-headscale-must-resolve-to-lan-ip.md for why headscale.compellinglylowbrow.org deliberately resolves to Caddy's LAN IP, not its Headscale IP) -- so a Trusted-VLAN client needs an explicit allow to reach 192.168.42.45:443 to ever log in at all. 2. **Uptime Kuma's UI (192.168.42.229:3001, on watchdog) found to have the identical gap while auditing the fix for #1** -- a rule existed in the forward chain but had no return-path mirror. Nothing had actually tried to use it yet that night, so it hadn't surfaced as a live incident, just a dormant one waiting to bite. Both looked, from the UI, like a complete and correctly-saved rule. Both were not.

Root cause

UniFi's zone-based firewall UI has a per-rule toggle (labeled "allow return traffic" in this UI version) that generates the *second*, opposite-direction rule -- the one that lets the reply packets (SYN-ACK, and later ESTABLISHED/RELATED traffic) back across the zone boundary. It is not implied by creating a normal "Allow" rule and is easy to miss, especially when copying the pattern of an existing rule by eye rather than by literally duplicating it. Without it: - The forward chain accepts the client's SYN (UBIOS_CUSTOM1_LAN_USER, Trusted -> Servers) -- the destination service genuinely receives the request and replies. - The reverse chain (UBIOS_LAN_CUSTOM1_USER, Servers -> Trusted) hits its default-deny before the reply's SYN-ACK can reach back, because no RELATED,ESTABLISHED rule exists for that ip/port pair. - The client sees nothing at all -- not a refusal, a silent hang -- because the far side genuinely never stops trying (visible in conntrack -L as SYN_RECV with the reply-side packet count climbing on every retransmit). This is the *inverse* of the already-documented trusted-vlan-gateway-ui-firewall-gap.md (a rule missing entirely) -- here the rule exists and even looks complete in the UI, it's just one-directional. Both produce the same symptom class (timeout, not refusal) for a different reason, so don't stop investigating just because a matching Allow rule is visible in the UI.

Diagnosis

Don't trust the UI's confirmation that a rule saved correctly -- verify both chains directly on the gateway:
ssh ucg-fiber "iptables -S UBIOS_CUSTOM1_LAN_USER"   # forward: Trusted -> Servers
ssh ucg-fiber "iptables -S UBIOS_LAN_CUSTOM1_USER"   # reverse: Servers -> Trusted
Every dst_ip_N/dst_port_N pair that appears in the forward chain should have a matching entry in the reverse chain with the same N, src instead of dst, and -m conntrack --ctstate RELATED,ESTABLISHED. Any forward-only entry is this bug. Confirm live breakage (as opposed to just a theoretical gap) via conntrack:
ssh ucg-fiber "conntrack -L | grep SYN_RECV"
A SYN_RECV entry with a climbing reply-side packet count for the suspect ip/port is the smoking gun -- the destination is receiving and replying, the client just never sees it.

Fix

In the UniFi Network app, open the affected rule and enable "allow return traffic" (or recreate it if that option isn't visible on the existing rule -- toggling it after the fact worked cleanly both times tonight, no rule deletion needed). Verify immediately via the iptables -S check above, both directions -- don't trust the UI's own "saved" confirmation, that's exactly what looked fine both times this actually wasn't.

What else was audited the same night

Every existing Trusted->Servers rule was checked both directions after finding this twice: | Rule | Destination | Forward | Reverse (before fix) | Reverse (after fix) | |---|---|---|---|---| | 1 | AdGuard :53 (TCP+UDP) | yes | yes | yes | | 4 | Proxmox :8006 (proxmox-nuc, nastynas) | yes | yes | yes | | 5 | Uptime Kuma :3001 (watchdog) | yes | no | yes (fixed) | | 6 | SSH :22 (any Servers host) | yes | yes | yes | | 7 | nuc-fileserver SMB :445 | yes | yes | yes | | 8 | Gateway UI :443 (192.168.42.1) | yes | yes | yes | | 9 | Caddy :443 (192.168.42.45) | yes (added same night) | no (fixed same night) | yes (fixed) | Rules 1/4/6/7/8 were already correct -- this isn't a universal defect in every rule ever created, just an easy-to-miss step that silently produced two real gaps (one immediately live, one dormant) out of the eight rules that existed. Worth re-running this same both-directions check after any future rule is added to this or any other zone pair, rather than trusting the UI save confirmation alone.

Prevention

docs/vlan-gateway-migration-plan.md's Trusted->Servers audit list should treat "does the return-path toggle exist and is it on" as a mandatory step for every future rule, not just the four originally planned ones -- see that doc's updated Firewall Rules section. Any future zone-pair work (Trusted->Management once VLAN 50 migrates, Servers->IoT for Home Assistant) should verify both chains via iptables -S, not just the UI, before considering the rule done.
update-advisor SECURITY verdict cited a CVE ID not present in the source release notes — fabricated high
a service's -summary.txt SECURITY section cites a specific CVE ID that does not appear anywhere in the release notes text update-advisor was actually given • spot-checking a SECURITY verdict against the project's real GitHub releases/advisories page finds no matching CVE
update-advisor claude-api security hallucination cve guardrail   last seen: 2026-07-08

Symptoms


Cause

bin/update-advisor calls the Claude API with release-notes text and asks it to scan for security signals and, when found, name the CVE ID and attack vector. This was the first session a SECURITY verdict got spot-checked against the actual upstream release — and the CVE ID it quoted did not appear in the source notes at all. The prompt asked the model to name a CVE if one existed, but didn't explicitly forbid supplying one from general pattern-matching/training knowledge when the notes were vague about specifics, so it filled the gap with something plausible instead of saying "not specified."

Fix

Two-layer guardrail added to bin/update-advisor: 1. Prompt-level: SYSTEM_PROMPT gained a "DO NOT FABRICATE SECURITY DETAILS" block — every CVE ID / attack vector / affected component stated must be copied or directly paraphrased from the release notes text given in that call. If the notes say "security fix" without naming specifics, the model must say exactly that ("release notes mention a security fix but do not specify a CVE...") and point to the upstream advisory, rather than inventing a mechanism. 2. Code-level: new find_cve_ids() + check_for_fabricated_cves() extract CVE-YYYY-NNNNN-style identifiers from both the source notes and the model's response via regex. Any CVE the response cites that isn't in the notes gets a loud stdout warning plus an "⚠ AUTOMATED CHECK" note appended to the summary output, instead of being silently trusted.
# bin/update-advisor
def find_cve_ids(text: str) -> set[str]:
    return {m.upper() for m in re.findall(r'CVE-\d{4}-\d{4,7}', text, re.IGNORECASE)}

Verify

python3 -c "
import importlib.machinery, importlib.util
loader = importlib.machinery.SourceFileLoader('ua', 'bin/update-advisor')
spec = importlib.util.spec_from_loader('ua', loader)
ua = importlib.util.module_from_spec(spec); loader.exec_module(ua)
notes = 'Fixes a security issue (CVE-2026-1234) allowing auth bypass.'
bad = 'VERDICT: SECURITY\n\nSECURITY:\nCVE-2026-9999 allows RCE.\n'
print(ua.check_for_fabricated_cves('svc', notes, bad, print))
"
Should print a non-empty warning naming CVE-2026-9999 as not found in the source notes.

Follow-up

This only catches fabricated CVE identifiers — it's regex-checkable. It does not verify fabricated attack-vector prose (e.g. an invented "unauthenticated RCE via the web UI" description attached to a real CVE). That half still relies on the prompt instruction alone. Keep spot-checking SECURITY verdicts against the project's actual release/advisory page before treating the attack-vector detail as confirmed, especially before using --force-execute to bypass the age gate on one.
wiki-server disk file corrupted to a bare placeholder string, masked for 9 days by a long-running process; compounded by a missing ufw rule high
bin/health-check / group1-preflight FAILs: 'wiki (192.168.42.31:5001) — no response (backend down or wrong IP)' • systemctl status wiki-server shows active (running), uptime measured in days -- service looks completely healthy • curl http://127.0.0.1:5001/... from developer-env itself succeeds; curl from Caddy or any other LAN host times out (not connection refused -- hangs to timeout)
wiki-server flask systemd ufw firewall yaml frontmatter placeholder-corruption generate-wiki-static known-fixes   last seen: 2026-07-13

Symptoms

See also: health-check-stale-hardcoded-lists, git-execute-bit-stripped-on-push

Symptom

A Group 1 group1-preflight run FAILed on a backend called wiki (192.168.42.31:5001) that had never been checked before -- it was added to bin/health-check's backend list the same day, via hosts-config.yaml's caddy_extra (see health-check-stale-hardcoded-lists). At first glance this looked like a simple "service is down" case, but systemctl status wiki-server showed active (running), uptime in days, and journalctl was full of successful 200 responses -- all logged from 192.168.42.31 (developer-env) itself, never from Caddy or any other LAN host.

Root Cause (two independent problems, both real)

**1. The bin/wiki-server source file on disk had been corrupted to a bare PLACEHOLDER string** (11 bytes) -- identical in git and on developer-env's disk, confirmed via git status (clean) and cat. This is the same failure class documented for hosts-config.yaml on 2026-07-12 (commit 5858ecb, *"previous commit accidentally wrote placeholder text"*) -- a write operation that was supposed to contain real file content wrote the literal placeholder string instead. Unlike hosts-config.yaml, which is read fresh by scripts on every run and so broke immediately and got caught the same day, wiki-server is a long-running Flask process (Restart=on-failure in its systemd unit). CPython loads a script's source once at process start and never re-reads it; the file being corrupted on disk had **zero effect on the already-running process** and went completely unnoticed for 9 days. The corruption would only have surfaced the moment the process actually restarted (crash, reboot, systemctl restart) -- at which point it would have crash-looped forever on NameError: name 'PLACEHOLDER' is not defined, with no working copy of the source recoverable from git history, disk, ~/bin/ (a symlink to the same corrupted file), or PBS backup (developer-env is not a PBS backup target). **2. Independently, ufw on developer-env had a default-deny-incoming policy and no rule for port 5001.** Port 5000 ("Flask dev server") had an explicit ufw allow from whenever that service was onboarded; 5001 never got the equivalent rule when wiki-server was set up. This is why curl from Caddy hung to a timeout rather than failing fast: ufw drops non-matching incoming traffic silently by default rather than sending a TCP RST, so the client just waits. curl from 127.0.0.1 was never affected because loopback traffic doesn't traverse the filtered interface, and ping (ICMP) worked because ufw's default policy doesn't block it -- both of which made the symptom look like "the app works, so it can't be a network problem" right up until an ssh caddy "curl -v ..." test proved otherwise. A third, smaller bug surfaced while reconstructing the file: 4 known-fixes/*.md files had a root_cause_of field hand-written as a bare string (e.g. root_cause_of: apt unusable on the affected host) instead of a YAML list. Python strings are iterable, so code written as [str(i) for i in val] silently explodes a bare string into one-character list items instead of raising -- no exception, just quietly wrong data, for as long as the file existed. This also caused a live TypeError in the running process's / route (markdown_to_html received something it didn't expect downstream of this) once one of the malformed fields flowed somewhere the original code didn't guard against.

Fix

Corruption: no original source was recoverable. Rebuilt bin/wiki-server from scratch using (a) the live process's own JSON/HTML responses, captured via curl *before* any restart, as a behavioral spec, and (b) the already-correct parse_fix_file/rendering logic in bin/generate-wiki-static, shared rather than reimplemented a second time. Tested by running the new file on a scratch port (5002, copied to a path that preserves the real file's relative position under the repo root -- Path(__file__).resolve().parent.parent depends on that) side-by-side with the still-running original on 5001, diffing /api/fixes output and confirming / returned 200 before ever touching the live systemd service. Only then: `sudo systemctl restart wiki-server`. ufw:
sudo ufw allow 5001/tcp comment "wiki-server"
Verify from the actual caller's vantage point, not just locally:
ssh caddy "curl -v --max-time 5 http://192.168.42.31:5001/health"
Bare-string frontmatter fields: fixed the 4 affected known-fixes/*.md files' root_cause_of to [] (none of the strings matched a real fix ID -- they were free-text impact descriptions, not cross-references, so there was nothing valid to convert them *to* as a list). Hardened strlist() in both bin/generate-wiki-static and the new bin/wiki-server to check isinstance(val, str) and wrap it as a single-item list rather than iterating it character-by-character, so a future instance of this mistake degrades gracefully instead of silently corrupting output.

Debugging notes for next time

- **A healthy systemctl status does not prove the on-disk source file is intact** for any long-running interpreted-language service. If a script's file was corrupted after its process started, everything looks fine until the next restart -- which may be a crash, a reboot, or an unattended upgrade, not something you control. Long-running Python/Ruby/Node services are a blind spot for "did this file survive an accidental overwrite" auditing precisely because they don't re-read their own source. - **"Bound to 0.0.0.0, works locally, fails from elsewhere" almost always means a firewall, not an app config problem** once the bind-address explanation is ruled out via ss -tlnp. Test from the actual caller (in this case, ssh caddy "curl ..."), not just from the host running the service. - **ufw's silent-drop default (vs. an explicit reject) makes firewall gaps look like hangs, not permission errors** -- a curl timeout with no other symptom is a reasonable firewall tripwire to check early, not late. - When a YAML frontmatter field is supposed to be a list, guard the parser against a bare string explicitly (isinstance(val, str)) rather than trusting for i in val to fail loudly if someone gets the syntax wrong -- it won't.
iPhone encrypted DNS never worked — installed profile pointed at AdGuard's cloud service, not self-hosted AdGuard Home medium
iPhone with an 'AdGuard DNS' profile installed fails to resolve some or all *.compellinglylowbrow.org FQDNs • Same profile appears to work fine for ordinary public-internet browsing • MacBook resolves the same internal FQDNs fine over the tailnet, only the phone fails
dns adguard doh ios encrypted-dns caddy   last seen: 2026-07-07

Symptoms


Symptom history

An "AdGuard DNS" configuration profile was installed on the iPhone on 2026-07-07 to get DNS resolution/filtering while off the home network. From that point on, some internal *.compellinglylowbrow.org hostnames intermittently failed to resolve on the phone specifically, while the same names worked fine on other devices and ordinary public-internet browsing on the phone was unaffected. Initially investigated as a possible conflict between this profile and Tailscale's own MagicDNS push (Headscale's dns.nameservers config) — that theory was reasonable but turned out not to be the actual root cause.

Root cause

**The installed profile was never capable of resolving anything on this network, by construction.** "AdGuard DNS" (the profile name/product) is AdGuard's own public/cloud DNS-filtering service — a completely separate product from AdGuard *Home*, the self-hosted instance this homelab actually runs (adguard/adguard2 LXCs). A public third-party resolver has no knowledge whatsoever of this network's private wildcard DNS rewrite (*.compellinglylowbrow.org → 100.64.0.4), so it could never have resolved internal names correctly — not intermittently, not under specific conditions, just never. Confirmed independently via AdGuard Home's own config (collected/adguard/service-config.txt): tls.enabled: false at the time this was investigated — meaning DNS-over-HTTPS/DNS-over-TLS were not even being served by the self-hosted instance yet. There was nothing here for a profile to point at even if someone had tried to configure one manually.

Fix — built a real self-hosted encrypted DNS path (2026-07-17)

Rather than trying to reconcile network-dependent mechanisms (Tailscale's DNS push, a per-Wi-Fi manual DNS override) with the goal of "encrypted DNS that works the same on LAN and cellular," built the one mechanism that's actually network-independent: a public DNS-over-HTTPS endpoint on AdGuard Home itself, reachable identically regardless of network. AdGuard Home config (/opt/AdGuardHome/AdGuardHome.yaml, both adguard 192.168.42.27 and adguard2 192.168.42.89): - doh.insecure_enabled: true — AdGuard's documented pattern for "TLS is terminated by a reverse proxy in front of me," confirmed via AdGuard's own wiki (`insecure_enabled: If true, allow DoH queries via unencrypted HTTP, for example to use with reverse proxies`). Without this, AdGuard only serves DoH over its own TLS listener, which stays disabled here by design — Caddy already owns TLS for everything else on this network via the Porkbun wildcard cert, no reason to duplicate that on AdGuard. - trusted_proxies — added 192.168.42.45/32 (Caddy's LAN IP) so AdGuard can read X-Forwarded-For and attribute the real client IP instead of Caddy's. Known incomplete: query log entries for DoH traffic through this path still show Caddy's IP (192.168.42.45), not the real client — worth a follow-up look, since it means per-client filtering/stats can't currently distinguish DoH clients from each other. Does not affect resolution correctness. Caddy (caddy/Caddyfile): new dns.compellinglylowbrow.org block, deliberately without import private — the only other precedent for a fully public (non-tailnet-gated) block in this file is headscale.compellinglylowbrow.org, for the same reason (must be reachable from anywhere, not just LAN/tailnet). Load-balances across both AdGuard instances in one reverse_proxy directive (192.168.42.27:3000 192.168.42.89:3000) for redundancy in a single hostname, since a phone DNS profile wants one URL, not two. iPhone: removed the old "AdGuard DNS" profile; installed a proper com.apple.dnsSettings.managed configuration profile (DNSProtocol: HTTPS, ServerURL: https://dns.compellinglylowbrow.org/dns-query).

Verification

- curl -v https://dns.compellinglylowbrow.org/dns-query → `400 Bad Request` directly from AdGuard (not a Caddy error, not a login redirect) — correct response to a bare GET with no encoded query, confirms the full chain is wired up. - curl --doh-url https://dns.compellinglylowbrow.org/dns-query ... https://www.google.com200 - curl --doh-url https://dns.compellinglylowbrow.org/dns-query -v https://seeder-daemon.compellinglylowbrow.org → resolved to 100.64.0.4 (Caddy's Headscale IP — the correct private answer) and successfully loaded the real backend page. This is the proof that matters: the same query via AdGuard's own cloud service would have had no way to know this domain existed at all. - MacBook: confirmed resolving correctly both on the remote LAN (physically at wildwood's location) and tethered. - iPhone: one hostname (qui.compellinglylowbrow.org) briefly failed to resolve on 5G even after the profile swap and an Airplane Mode toggle — turned out to be a stale client-side negative-DNS-cache entry from before the fix, not a new problem. A full device restart cleared it; confirmed via AdGuard's query log that the phone's query never reached AdGuard at all before the restart (all logged qui queries in that window were from the MacBook's Headscale IP, none from the phone), and did reach it — with a correct answer — after.

Known follow-up (not yet done)

inventory/hosts-config.yaml's caddy_extra section doesn't yet have an entry for dns.compellinglylowbrow.org — every other Caddyfile block has a matching entry there, and bin/generate-network-deps's Caddyfile cross-check may flag this one as drift until it's added. Low priority (doesn't affect functionality), skipped in the same session that built this to avoid a large, error-prone hand-edit of that file; worth doing next time that file is touched for another reason.
AdGuard rewrite table gives inconsistent answer for seeder-daemon.compellinglylowbrow.org medium
adguard dns caddy   last seen:

Symptom

Querying seeder-daemon.compellinglylowbrow.org from the same off-LAN device, via the same nameserver, within seconds, returns inconsistent answers depending on the tool: dig returned 100.64.0.4 (Caddy's Headscale IP, the expected wildcard-rewrite answer) while curl (via macOS's normal resolver path) returned 192.168.42.45 (Caddy's LAN IP, unreachable off-LAN without a subnet route). A dscacheutil -flushcache + killall -HUP mDNSResponder made no difference, ruling out simple client-side caching as the explanation.

Root cause (not yet fully diagnosed — this file documents the lead,

not a confirmed fix) collected/adguard/service-config.txt's filtering.rewrites list contains, among the expected wildcard rule:
rewrites:
    - domain: '*.compellinglylowbrow.org'
      answer: 100.64.0.4
    - domain: headscale.compellinglylowbrow.org
      answer: 192.168.42.45          # Caddy's LAN IP, not the wildcard's answer
    - domain: seeder-daemon           # missing .compellinglylowbrow.org suffix
      answer: 192.168.42.240
    - domain: developer-env           # missing .compellinglylowbrow.org suffix
      answer: 192.168.42.31
    ...
Two issues visible here: 1. headscale.compellinglylowbrow.org has its own specific rewrite to 192.168.42.45 (Caddy's LAN IP) rather than the wildcard's 100.64.0.4 — a deliberate on-LAN-performance override, presumably, but not client-IP-aware, so it would also misdirect an off-LAN client asking for that exact name. 2. seeder-daemon and developer-env both have rewrite entries missing their .compellinglylowbrow.org suffix — malformed/leftover entries that shouldn't match the actual FQDNs at all under simple exact-match rewrite semantics, but something about AdGuard's rewrite precedence or matching logic is producing the 192.168.42.45 (headscale's answer, not seeder-daemon's own listed answer of 192.168.42.240) for seeder-daemon.compellinglylowbrow.org queries at least some of the time. The exact mechanism (rule ordering, a matching bug, or AdGuard's own query cache interacting with these entries) hasn't been isolated yet.

Why this was invisible before 2026-07-12

The wide-open Headscale ACL (pre-2026-07-12) didn't restrict which of Caddy's two IPs a client could reach, so whichever answer AdGuard gave — 100.64.0.4 or 192.168.42.45 — the connection succeeded either way (both are reachable given caddy's approved Headscale subnet route for 192.168.42.0/24 — confirmed via headscale nodes list-routes). The newly-tightened ACL's rule 1 originally only granted caddy:443 (100.64.0.4:443 specifically), which turned this pre-existing DNS inconsistency into a hard connection failure the moment AdGuard answered with the LAN IP instead.

Current mitigation (not a real fix)

headscale/acl.hujson rule 1 now grants * access to both caddy:443 and 192.168.42.45:443, tolerating either answer. This papers over the symptom without fixing AdGuard's actual rewrite table.

To actually fix, next session

- Check AdGuard's query log (UI or querylog — enabled per service-config.txt) for the actual matched rule on a repeated seeder-daemon.compellinglylowbrow.org query, to see which rewrite entry is actually firing and why. - Fix or remove the malformed seeder-daemon and developer-env rewrite entries (add the missing .compellinglylowbrow.org suffix, or delete them if they're stale leftovers no longer serving a purpose). - Decide whether headscale.compellinglylowbrow.org's LAN-IP-specific rewrite is intentional (on-LAN performance) and, if so, whether it needs its own ACL carve-out too, or should instead rely on the same 192.168.42.45:443 tolerance rule 1 already grants. - Once fixed, consider whether the 192.168.42.45:443 tolerance in rule 1 can be removed again, narrowing the ACL back to just caddy:443 (100.64.0.4 only).
AdGuard/AdGuard2 had self-referential DNS rewrites bypassing Caddy medium
adguard adguard2 dns caddy   last seen:

Symptom

adguard.compellinglylowbrow.org and adguard2.compellinglylowbrow.org returned connection-refused (curl exit 7 / 000) from off-LAN and tailnet clients, despite both services being reachable fine on their direct LAN IPs and both having correct, working (private)-gated site blocks in caddy/Caddyfile.

Root cause

AdGuardHome.yaml's filtering.rewrites list had explicit per-domain entries overriding the *.compellinglylowbrow.org wildcard rule for exactly these two hostnames:
rewrites:
    - domain: '*.compellinglylowbrow.org'
      answer: 100.64.0.4
      enabled: true
    - domain: adguard.compellinglylowbrow.org
      answer: 192.168.42.27      # AdGuard's own LAN IP, not Caddy's
      enabled: true
    - domain: adguard2.compellinglylowbrow.org
      answer: 192.168.42.89      # AdGuard2's own LAN IP, not Caddy's
      enabled: true
Both entries pointed the hostname directly at the service's own LAN IP instead of Caddy's Headscale IP (100.64.0.4). Since neither AdGuard instance terminates TLS on 443 or is reachable off-LAN, any client resolving to these answers (anyone not on the same LAN segment) got a connection refusal, not a wrong page — the DNS answer itself was for an unreachable target. This is the same class of issue documented in known-fixes/adguard-rewrite-inconsistent-answer-for-seeder-daemon.md (2026-07-12) — a per-domain rewrite silently shadowing the wildcard — just two more hostnames hitting it, discovered during a full ~25-service reachability sweep as part of the CrowdSec-on-Caddy rollout (docs/security-crowdsec-plan.md).

Fix

Removed both per-domain rewrite entries entirely, letting adguard and adguard2 fall through to the wildcard rule like every other private service. Both are already gated behind the (private) Caddyfile snippet (LAN/tailnet-only), so there was no remaining reason for either to bypass Caddy via a direct-LAN-IP shortcut — unlike headscale, which keeps its own deliberate LAN-IP override for on-LAN DERP relay performance (left untouched, out of scope for this fix).
pct exec 102 -- cp /opt/AdGuardHome/AdGuardHome.yaml /opt/AdGuardHome/AdGuardHome.yaml.pre-rewrite-fix

(removed the two rewrite blocks via a small Python script rather than

hand-editing YAML with sed, to avoid list-indentation mistakes)

pct exec 102 -- systemctl restart AdGuardHome
Confirmed via dig from developer-env: both hostnames now resolve to 100.64.0.4 (matching the wildcard), and both return a real 302 to /login.html via Caddy instead of a connection refusal.

Note

headscale.compellinglylowbrow.org's own LAN-IP override, and the malformed developer-env/seeder-daemon rewrite entries (missing their .compellinglylowbrow.org suffix), were left untouched — out of scope for this fix, already tracked separately in the 2026-07-12 known-fix above. Worth revisiting those in a dedicated pass per that doc's original "to actually fix, next session" list.
adguard2 has a mismatched rewrite entry for adguard's (not its own) hostname medium
adguard dns   last seen:

Symptom

Surfaced during the CrowdSec rollout's ~25-service reachability sweep (see docs/security-crowdsec-plan.md §9, incident 10): querying adguard.compellinglylowbrow.org — the primary adguard's own hostname — against adguard2 (192.168.42.89) returned adguard2's LAN IP instead of falling through to the wildcard rewrite. From an off-LAN client with no route to 192.168.42.0/24, this is a hard connection failure instead of the expected Caddy-proxied response.

Root cause

collected/adguard2/service-config.txt's filtering.rewrites list contained two entries that looked like a clone artifact:
- domain: adguard.compellinglylowbrow.org     # the PRIMARY's hostname
  answer: 192.168.42.89                        # but adguard2's own IP
- domain: adguard2.compellinglylowbrow.org     # its own hostname
  answer: 192.168.42.89                        # correct, intentional
The second entry is the documented, intentional recovery hatch (see INFRASTRUCTURE.md / inventory/hosts-config.yaml's adguard2 notes) — working as designed, and only ever meant to be reachable on LAN/tailnet (admin UI, public: false). The first entry is the actual bug: it looks like adguard2's config was cloned from adguard's at some point, and the domain field for this entry was never updated to match the answer — leaving adguard2 answering for the *primary's* hostname with its own IP. The primary (adguard, 192.168.42.27) has no equivalent entry for its own hostname at all currently — querying adguard.compellinglylowbrow.org against the primary directly falls through to the wildcard rule cleanly and routes through Caddy (and therefore through CrowdSec) like every other service. Decided 2026-07-18 to leave it that way rather than reinstate a direct-to-LAN-IP hatch on the primary too, since routing through Caddy is simpler and gets CrowdSec's bouncer protection for free.

Fix

On adguard2 only:
ssh adguard2
sudo cp /opt/AdGuardHome/AdGuardHome.yaml /opt/AdGuardHome/AdGuardHome.yaml.pre-rewrite-fix
sudo sed -i '/domain: adguard\.compellinglylowbrow\.org$/,+2d' /opt/AdGuardHome/AdGuardHome.yaml
sudo systemctl restart AdGuardHome
Verify:
dig @192.168.42.89 adguard.compellinglylowbrow.org +short    # expect 100.64.0.4
dig @192.168.42.89 adguard2.compellinglylowbrow.org +short   # expect 192.168.42.89 (unchanged)
Rollback: restore AdGuardHome.yaml.pre-rewrite-fix and restart. No CrowdSec/Caddy state involved — this is AdGuard-only.

Related

Same general class of issue as known-fixes/adguard-rewrite-inconsistent-answer-for-seeder-daemon.md (2026-07-12) — malformed/mismatched rewrite entries producing wrong answers for specific hostnames. That file's seeder-daemon/developer-env malformed entries (missing .compellinglylowbrow.org suffix) are a separate, still-open issue on the primary adguard and were not touched by this fix.
apt.conf Post-Invoke hooks break with 'Malformed tag' when using backslash-continued multi-line quoted strings medium
apt_pkg.Error: E:Syntax error /etc/apt/apt.conf.d/<file>:N: Malformed tag • apt-get update/upgrade fails entirely after adding a custom Post-Invoke or Pre-Invoke hook • command-not-found, pip install --break-system-packages, or anything touching apt_pkg fails the same way
apt apt.conf dpkg hooks ntfy syntax   last seen: 2026-06-30

Symptoms

See also: community-scripts-apt-99-ntfy-malformed

Symptom

Any apt operation (apt-get update, apt-config dump, command-not-found, etc.) fails with:
E: Syntax error /etc/apt/apt.conf.d/<file>:N: Malformed tag
The line number reported is misleading — it points near the start of the offending block, not necessarily the exact broken token.

Root Cause

apt.conf's parser does not support backslash-newline (\ followed by a literal line break) as a line continuation inside a quoted string value. This is different from shell, where \ is a standard continuation. A hook written like this looks valid at a glance (it's syntactically correct *shell*, wrapped in apt.conf quotes) but breaks apt's own tokenizer:
DPkg::Post-Invoke {
    "if [ $DPKG_HOOK_ACTION = unpack ]; then \
        echo hi; \
    fi";
};
This was confirmed by direct bisection: stripping the hook down to a single guard condition with no ${}, no $(), no embedded quotes — but still using \ continuations — still produced the identical Malformed tag error. Collapsing the exact same logic onto one physical line made it parse cleanly. Variable braces (${VAR}) and command substitution ($(...)) were both independently ruled out as causes during this process; they are not the problem. Note: this produces the *same* error message and file name (99-ntfy) as community-scripts-apt-99-ntfy-malformed.md, but is a different root cause — that doc covers a malformed file dropped by a community-scripts installer artifact on a known list of LXCs. This issue is a hand-written hook with valid intent but invalid apt.conf syntax, and isn't limited to any specific install method.

Fix

Don't put multi-line shell logic directly in the apt.conf quoted string. Move it to a real script file and call that from a single-line hook instead: Script (e.g. /usr/local/bin/ntfy-apt-notify.sh):
#!/bin/bash
if [ "$DPKG_HOOK_ACTION" = unpack ] || [ "$DPKG_HOOK_ACTION" = install ] || [ "$DPKG_HOOK_ACTION" = configure ]; then
    HOST=$(hostname)
    LOG=/var/log/unattended-upgrades/unattended-upgrades.log
    SUMMARY=""
    if [ -f "$LOG" ]; then
        SUMMARY=$(tail -20 "$LOG" | grep -E 'Packages that will be upgraded|packages upgraded' | tail -1)
    fi
    BODY=$(printf 'Host: %s\nUnattended-upgrades applied packages.\n%s' "$HOST" "$SUMMARY")
    curl -s -X POST http://192.168.42.229:2586/homelab-alerts \
        -H "Title: OS updates applied: $HOST" \
        -H 'Priority: 2' \
        -H 'Tags: homelab,os-updates' \
        -d "$BODY" > /dev/null 2>&1 || true
fi
chmod +x /usr/local/bin/ntfy-apt-notify.sh
Hook (/etc/apt/apt.conf.d/99-ntfy) — single physical line, just calls the script:
DPkg::Post-Invoke {"/usr/local/bin/ntfy-apt-notify.sh";};
This is also generally better practice: the script is independently testable (bash /usr/local/bin/ntfy-apt-notify.sh), diffable, and doesn't risk apt.conf syntax pitfalls regardless of how complex the logic grows.

Debugging notes for next time

apt-config dump -o Dir::Etc::parts= does not reliably isolate testing to a sandbox directory — apt reads Dir::Etc::parts during early init, before command-line -o overrides are applied, so it silently keeps reading the real /etc/apt/apt.conf.d/ regardless of the override. Don't trust a clean apt-config dump result against a custom parts directory as proof the candidate file is valid. The reliable test loop is: back up/remove the current file, drop the candidate into the real /etc/apt/apt.conf.d/, run apt-get update, then immediately restore on any failure. Bisect by deleting content (not adding) until you find the smallest reproducer — in this case, removing the \ continuations were the deciding factor, not the embedded $()/${}/quote characters that seemed like more obvious suspects. Also note: ~ (tilde) is not expanded by bash inside key=value arguments to a command like -o Dir::Etc::parts=~/path — only $HOME is reliable there.
Redeploying backup-music.sh/leg scripts to proxmox-nuc while the old version was still mid-execution caused a spurious leg failure and silently killed the rest of the run medium
A leg fails with an exit code that doesn't correspond to any real error in that leg's own log (e.g. exit=127, 'command not found', with no such command anywhere in the script) • The leg's own detailed log shows the actual work (e.g. rsync's --stats footer, 'speedup is N') completed cleanly, but the wrapping script's own log/summary lines after it never printed • The orchestrator's log simply stops after the failed leg -- no 'leg: <next> START', no final DONE line, no ntfy alert, no heartbeat -- as if the process just vanished mid-run
backup rsync deploy self-modification race-condition music-backup known-fixes   last seen: 2026-08-09

Symptoms

See also: backup-music-orchestrator-placeholder-corruption

Symptom

While recovering from the backup-music-orchestrator-placeholder-corruption incident, the first live catch-up run (using the still-running, pre-fix version of the orchestrator) reported its pibox leg as FAILED (exit=127), and the run never progressed to the nastynas or wildwood legs at all -- the orchestrator's log simply ended after the pibox failure line, with no further activity, no final ntfy alert, and no heartbeat. The pibox leg's own detailed log (music_to_pibox.log) told a different story: the rsync transfer itself completed fully and cleanly -- full --stats footer, --delete-delay pass already applied, speedup is 260.99 printed (rsync only prints this on clean completion). The failure clearly happened *after* the data transfer succeeded, in the handful of trailing log/summary lines of the wrapper script.

Cause

The fixed and feature-upgraded versions of bin/backup-music.sh and all three rsync_music_to_*.sh leg scripts were deployed (via scp, which overwrites the destination file's content in place rather than writing-then-atomically-renaming) to /root/ on proxmox-nuc while the old catch-up run was still actively executing -- confirmed by comparing the deployed scripts' mtimes (all Aug 9 23:20) against the run's timeline (pibox leg started 23:01:50, its rsync alone took until 00:53:46). Overwriting a script file out from under a bash interpreter that is still in the middle of running it is a classic self-corruption bug. Non-interactive bash reads a script file via buffered reads keyed to a byte offset in that file's underlying inode/content. While the long-running rsync command is executing, the parent shell doesn't need to read further -- but the moment rsync returns and the interpreter needs its *next* statement, it resumes reading from its last known offset. If the file has been truncated and rewritten with different content in the meantime, that offset no longer lines up with anything meaningful in the new file, and the interpreter can end up trying to execute a fragment of the new script as if it were a command name -- producing exactly the kind of "command not found" (exit 127) seen here, on a line that doesn't correspond to anything actually written in either version of the script. This also explains why the orchestrator (backup-music.sh itself) never progressed to the nastynas/wildwood legs: it was overwritten mid-flight too (same mtime), so once `run_leg "pibox"` returned, the orchestrator's own next read was against the new file and it died silently instead of continuing its loop -- no error handling in the interpreter itself catches "my own source changed under me." Underlying data integrity was never actually at risk: the leg that appeared to "fail" had already finished its real work (the rsync transfer + delete-delay pass) before the corruption manifested. Confirmed by re-running afterward: the pibox leg transferred 0 files on the clean re-run, meaning nothing had actually been missed.

Fix

Wait until nothing is executing any of the affected scripts before redeploying:
# Confirm nothing is running first
ssh proxmox-nuc "ps aux | grep -E 'backup-music|rsync_music' | grep -v grep"

Only once that's empty, deploy

scp bin/backup-music.sh bin/rsync_music_to_*.sh proxmox-nuc:/root/ ssh proxmox-nuc "chmod +x /root/backup-music.sh /root/rsync_music_to_*.sh"

Then trigger a clean run

ssh proxmox-nuc "/root/backup-music.sh"
If a redeploy is genuinely urgent while an old run is in flight, kill the old run's process tree first (or let it finish) rather than overwriting its source file underneath it.

Prevention / open follow-up

General rule, not specific to this script: never redeploy a shell script to a host while any live invocation of it (or an orchestrator whose child scripts are being updated) is still running. This is a real risk for any of this repo's longer-running scripts (anything that shells out to rsync, vzdump, or similar slow operations) -- a redeploy mid-run doesn't just risk "the new code doesn't take effect until next time," it can actively corrupt the *currently running* invocation. Built 2026-08-10: bin/deploy-backup-music. Replaces the manual scp steps in the Fix section above with a script that automates exactly the check documented there -- ps aux | grep -E 'backup-music|rsync_music' | grep -v grep on proxmox-nuc -- and refuses to deploy by default if it finds a live run, printing the matching process(es) and the reason. --wait polls every 30s until clear instead of refusing; --force proceeds anyway for the "genuinely urgent" case this file's Fix section already allowed for. Once clear, it copies all 4 scripts, chmods them, and sha256-verifies the deployed content against the local repo copy (closing the same "confirm the write, don't assume it landed" gap as the sibling backup-music-orchestrator-placeholder-corruption.md incident). Verified live against proxmox-nuc 2026-08-10: real clean deploy (hashes matched, zero drift from the 2026-08-09 recovery), plus a synthetic running-process test (`exec -a rsync_music_to_pibox.sh sleep 45) confirmed all three modes -- default refuse, --force` proceed-with-warning, --wait poll-then-proceed -- behave as designed. Scoped to this script's own deploy path only. The broader idea floated here originally -- bin/deploy-os-updates and other deploy tooling adopting the same live-process guard -- remains open if a similar mid-run corruption risk shows up elsewhere; not generalized into a shared helper since no other current deploy script targets a long-running orchestrator + leg-script pattern like this one.
Caddy returns 502 Bad Gateway — wrong backend IP medium
service URL returns 502 Bad Gateway
caddy 502 caddyfile   last seen: 2026-06-06

Symptoms


Cause

Caddyfile points to wrong LAN IP, or Headscale IP was used where LAN IP needed (or vice versa).

Note

developer-env is proxied via its Headscale IP (100.64.0.111), not LAN IP. All other services use LAN IPs.

Fix

ssh caddy
cat /etc/caddy/Caddyfile | grep -A2 <service-name>

Verify the backend IP matches the actual container IP

Caddy proxy to Home Assistant returns 400 Bad Request medium
Home Assistant loads but returns 400 errors • 400 errors intermittent or on login
caddy homeassistant trusted-proxies   last seen: 2026-06-06

Symptoms


Cause

HA's built-in security rejects requests it can't verify as coming from a trusted proxy.

Fix

Add the Caddy LXC's LAN IP to HA's trusted proxies in configuration.yaml:
http:
  use_x_forwarded_for: true
  trusted_proxies:
    - 192.168.42.45
Restart Home Assistant after saving.
onboard-container generates plain HTTP Caddy block for TLS backend medium
service unreachable after onboarding • redirect loop after onboarding • backend enforces HTTPS internally
caddy onboard-container tls redirect-loop   last seen: 2026-06-16

Symptoms


Cause

The Claude recommendation step in onboard-container didn't detect the service uses TLS on its backend and generated a plain HTTP reverse_proxy block. When Caddy proxies to an HTTP port that immediately redirects to HTTPS, the client gets a redirect to the backend IP directly.

Diagnosis

# From Caddy LXC — does the backend redirect HTTP to HTTPS?
curl -v http://<container-ip>:<port> 2>&1 | grep -E "< HTTP|Location:"

301/302 with Location: https:// → TLS backend, use HTTPS port instead

Fix

Update the Caddyfile to proxy to the HTTPS port with tls_insecure_skip_verify:
service.compellinglylowbrow.org {
        import private
        reverse_proxy https://<container-ip>:<https-port> {
                transport http {
                        tls_insecure_skip_verify
                        dial_timeout 3s
                }
        }
}
Do NOT use import timeout with the transport block — the transport block replaces it.
caddy validate --config /etc/caddy/Caddyfile && caddy reload --config /etc/caddy/Caddyfile

Affected services

BentoPDF, Nextcloud, Proxmox UI, Vaultwarden — all redirect HTTP→HTTPS internally.
bare-SSH `caddy validate` false-FAILs — crowdsec API key empty without systemd EnvironmentFiles medium
health-check caddy crowdsec group1 update-pipeline secrets   last seen:
Root cause of: group1-preflight-false-abort
See also: health-check-adguard-hatch-expectation-drift.md, crowdsec-docker-migration-environ-leak.md

Symptom

bin/health-check's Caddy section reported, on a healthy chain:
PASS  caddy service is active
FAIL  Caddyfile invalid: Error: loading crowdsec app module: crowdsec: invalid configuration: crowdsec API key must not be empty
Contributed (with the adguard hatch FAIL) to a group1-preflight abort. The running caddy service was active and proxying all backends including the two crowdsec-protected public blocks — which it could not do if the key were actually empty at runtime, since the crowdsec module errors on empty key at config LOAD (both run and validate).

Root cause

The check ran ssh caddy "caddy validate --config /etc/caddy/Caddyfile" in a bare, non-login SSH shell. That shell does NOT source the systemd unit's EnvironmentFile= directives, so the Caddyfile's global crowdsec { api_key {env.CROWDSEC_BOUNCER_API_KEY} } placeholder expanded to empty and the crowdsec module rejected it. This began false-FAILing only once the crowdsec block landed on Caddy (2026-07-18/20) — before that there was no env-placeholder-dependent module that hard-erroring on empty at validate time. This was likely the first Group 1 preflight run since the crowdsec block landed. The key is genuinely present at runtime: the caddy unit's override drop-in (/etc/systemd/system/caddy.service.d/override.conf) declares two EnvironmentFiles — /etc/caddy/porkbun.env and /etc/caddy/crowdsec.env — and the bouncer key lives in the second. (porkbun's acme module doesn't hard-fail on an empty key at validate time, which is why only crowdsec surfaced the gap.) Verified live 2026-07-29: `tr '\0' '\n' < /proc/$(MainPID)/environ | grep -i crowdsec showed a non-empty CROWDSEC_BOUNCER_API_KEY`, and sourcing both env files before validate returned Valid configuration. cscli decisions list showed no active decisions — confirming this was NOT a CrowdSec self-bounce, just a validation-context env gap.

Fix

In bin/health-check (commit 08c2292), source every EnvironmentFile declared on the caddy unit before validating (parsing systemctl cat so it stays correct if a file is added/renamed):
CADDY_VALIDATE=$(ssh_cmd "$CADDY_IP" 'set -a; for _ef in $(systemctl cat caddy | sed -n "s/^EnvironmentFile=-\?//p"); do [ -f "$_ef" ] && . "$_ef"; done; caddy validate --config /etc/caddy/Caddyfile 2>&1' || true)
The leading - on EnvironmentFile=- (optional file) is stripped; missing files are skipped. Verified: re-run 2026-07-29 → PASS Caddyfile syntax valid, preflight 70/0/0.

Generalizes to

Any bare-SSH invocation of a validate/dry-run that must see systemd-injected secrets. If a future Group 1 service gains an env-placeholder-dependent module that hard-fails on empty at validate time, the same source-the-EnvironmentFiles pattern applies — don't hardcode paths, parse systemctl cat.
Client's own Tailscale session down blocks all FQDN access (no LAN fallback) medium
FQDN access broken from one client only; other clients/services healthy • AdGuard resolves *.compellinglylowbrow.org correctly when queried directly • Headscale, Caddy, AdGuard all show healthy; other peers connected
tailscale dns client-side wildcard-rewrite   last seen: 2026-07-04

Symptoms

See also: adguard-dns-rewrite-reversion, tailscale-dns-override-after-upgrade

Cause

The AdGuard wildcard rewrite for *.compellinglylowbrow.org always returns Caddy's Tailscale/Headscale IP (100.64.0.4), regardless of where the query originates. There is no LAN-based fallback answer — the DNS answer is identical whether the client is remote or sitting on the same LAN segment as Caddy. If the *querying client's own* Tailscale session is disconnected (sleep/wake, network change, silent drop), FQDN resolution and connection both fail — even though: - Headscale, AdGuard, and Caddy are all healthy - Other clients continue to resolve and connect normally - Being on the same physical LAN as Caddy does not matter, since the answer is never a LAN IP This can look identical to a server-side DNS/rewrite failure (adguard-dns-rewrite-reversion.md) at first glance — same "FQDN broken, direct LAN access to hosts still fine" symptom — but the fix is entirely different (client-side, not server-side).

Diagnosis

Confirm AdGuard is healthy and answering correctly first:
dig @192.168.42.27 <any-service>.compellinglylowbrow.org

should return 100.64.0.4

If that's correct, check the affected client's Tailscale session from a *different, healthy* node's point of view:
ssh <healthy-node> "tailscale status"

look for the affected client -- "offline, last seen Nm ago" is the signal

Then confirm from the affected client itself:
tailscale status
tailscale ping caddy

Fix

Reconnect Tailscale on the affected client:
sudo tailscale down && sudo tailscale up

or toggle Tailscale off/on via the menu bar app on macOS

Verify:
tailscale ping caddy
dig grafana.compellinglylowbrow.org

Note

This is a real architectural tradeoff, not a misconfiguration: giving every client the same URL regardless of location means the client's own Tailscale session becomes a single point of failure for all FQDN access, with no LAN fallback path. Tailscale will usually negotiate a direct LAN connection under the tunnel when both ends are local (confirmed via tailscale ping showing direct :41641 rather than a DERP relay), so there's normally no latency cost -- but a dropped client session still presents as "everything is broken" until you check tailscale status on the client itself. Worth checking this *before* assuming a server-side break, especially after sleep/wake or after any Proxmox host reboot (which can make it look coincidentally related, as it did on 2026-07-04, when it wasn't). There's no automated remediation candidate here, unlike the AdGuard/ Headplane entries — the watchdog daemon runs on the LAN and has no way to act on a remote client's own Tailscale session. This is why auto_remediate is none rather than candidate: it's not "not yet built", it's "can't be built this way" — the fix has to happen on the client itself.
collect-homelab cron commented out — repo goes stale silently medium
inventory/hosts.yaml and collected/ stop updating • last commit timestamp in GitHub is days old • running bin/collect-homelab manually works fine
collect-homelab cron stale   last seen: 2026-06-27

Symptoms


Cause

The cron entry on developer-env was prefixed with #, disabling it.

Diagnosis

crontab -l | grep collect

Shows: #0 */6 * * * ... → leading # disables it

Fix

(crontab -l | sed 's|^#\(0 \*/6 \* \* \* cd /home/mos/projects/homelab && bin/collect-homelab\)|\1|') | crontab -
crontab -l | grep collect   # verify # is gone
SPECIAL_COLLECT set falls out of sync with host renames — silent collection failures medium
standalone host collected/ files are empty (0 bytes) • collect log shows (static) instead of (special handler) for a standalone host • collect log shows SKIP <name> -- no IP resolved for special handler
collect-homelab resolve-hosts rename drift   last seen: 2026-06-20

Symptoms


Cause

bin/resolve-hosts maintains a SPECIAL_COLLECT set of host names that need dedicated collect_* functions. Nothing enforces this set stays in sync with inventory/hosts-config.yaml standalone entry names. When a standalone host is renamed, SPECIAL_COLLECT still has the old name.

Fix

Keep SPECIAL_COLLECT in bin/resolve-hosts, the collect_* function name, and the $HOST_ reference in bin/collect-homelab all in lock-step with inventory/hosts-config.yaml standalone entry names. When renaming a standalone host, grep both files for the old name before considering the rename complete:
grep -n 'raspi5\|old-name' bin/resolve-hosts bin/collect-homelab

Prevention

No automated check yet. A future detect-drift check diffing hosts-config.yaml standalone names against SPECIAL_COLLECT would catch this.
SSH consumes stdin inside while-read loop — only first host collected medium
collect-homelab only processes the first host then stops • subsequent hosts silently skipped • pibox and haos (called after loop) still run fine
collect-homelab ssh bash stdin   last seen: 2026-06-13

Symptoms


Cause

ssh reads from stdin by default. When called inside a while read loop, it consumes the remaining lines of the file descriptor the loop is reading from.

Fix

Redirect SSH stdin from /dev/null in every ssh_cmd helper:
ssh_cmd() {
    local host="$1"
    shift
    ssh $SSH_OPTS "root@$host" "$@" < /dev/null 2>/dev/null
}
Apply the same < /dev/null to ssh_cmd_haos and ssh_cmd_pibox.
CrowdSec AppSec crashes with "duplicated rule id 100" when combining virtual-patching + generic-rules in one acquisition source medium
crowdsec appsec waf watchdog caddy   last seen:

Summary

Rolling out CrowdSec's AppSec (WAF) component (docs/security-crowdsec-appsec-plan.md), the original intent was to reference three appsec-configs in one acquisition source for broad coverage during the observation period: crowdsecurity/virtual-patching, crowdsecurity/crs, and crowdsecurity/generic-rules. Loading all three together causes a hard crash at CrowdSec init:
level=fatal msg="crowdsec init: while loading acquisition config:
/etc/crowdsec/acquis.d/appsec.yaml: datasource of type appsec: unable to
initialize runner: unable to initialize inband engine : invalid WAF
config from string: failed to compile the directive \"secrule\":
duplicated rule id 100"
Since this container also runs Phase 1's LAPI (caddy-bouncer's decision source), a crash-loop here degrades IP-reputation blocking too, not just AppSec -- this is not an isolated AppSec-only failure.

Root cause

cscli collections inspect on each collection shows both crowdsecurity/appsec-virtual-patching and crowdsecurity/appsec-generic-rules independently declare crowdsecurity/base-config as an inband-rules dependency:
# appsec-virtual-patching's generated config

(/etc/crowdsec/appsec-configs/virtual-patching.yaml)

inband_rules: - crowdsecurity/base-config - crowdsecurity/vpatch-*

appsec-generic-rules' generated config

(/etc/crowdsec/appsec-configs/generic-rules.yaml)

inband_rules: - crowdsecurity/base-config - crowdsecurity/generic-*
When both appsec-configs are referenced in the same acquisition source's appsec_configs: list, the WAF engine compiles base-config's ModSecurity-style ruleset twice, and rule ID 100 collides. Neither collection's own docs, nor CrowdSec's official Caddy/AppSec guides, warn about this -- the officially documented pairing example only ever shows virtual-patching + crs together. Stacking a third inband-rules collection on top is not a combination CrowdSec has published as tested or supported. crowdsecurity/appsec-crs does not hit this, since its own generated config (/etc/crowdsec/appsec-configs/crs.yaml) has no inband_rules at all -- it's purely outofband_rules: [crowdsecurity/crs], so it never touches base-config.

Fix

Dropped crowdsecurity/appsec-generic-rules from the acquisition file entirely (see watchdog/appsec-acquis.yaml), keeping only crowdsecurity/virtual-patching and crowdsecurity/crs -- the exact pairing CrowdSec's own guides document as tested together. The collection itself was left uninstalled on the system (`cscli collections delete crowdsecurity/appsec-generic-rules`), rather than staying installed-but- unreferenced, to keep installed state matching actual active state (same reasoning as prior drift issues in this repo -- stray compose files, vestigial Caddy blocks). Why not a custom merged config instead (e.g. hand-writing a single appsec-config with base-config referenced once, plus both vpatch-* and generic-* globs): considered and rejected. generic-rules' actual rules (generic-freemarker-ssti, generic-wordpress-uploads-php, generic-wordpress-uploads-listing) have no relevance to this deployment's two protected endpoints (Headscale control protocol, DNS- over-HTTPS) -- neither is WordPress or Freemarker-based. A custom merge would need a hand-maintained rule list that silently drifts out of sync with the hub on future cscli hub update runs (the same failure class as known-fixes/health-check-stale-hardcoded-lists.md), for zero real protective gain against this specific traffic. Not worth it.

Recovery procedure used (for a crash-looping crowdsec container)

Since the container was crash-looping on unless-stopped, there was no usable exec window to fix the acquisition file directly on the running container. Recovery:
# Rename the acquisition file out of the way via a still-alive exec window

during the brief post-restart-attempt window, or via the disposable-

container pattern from known-fixes/crowdsec-docker-migration-environ-leak.md

(incident 5) if the container won't stay up long enough:

docker exec crowdsec mv /etc/crowdsec/acquis.d/appsec.yaml \ /etc/crowdsec/acquis.d/appsec.yaml.disabled docker restart crowdsec

confirm stable and Phase 1 recovered before proceeding:

docker ps --filter name=crowdsec --format '{{.Status}}' docker exec crowdsec cscli metrics show lapi # caddy-bouncer hits should resume
Only after confirming the container was genuinely stable (not just docker ps reporting healthy -- confirm via the fuller docker logs tail and real bouncer traffic in cscli metrics show lapi, since a container can report healthy on its Docker healthcheck while still having crashed and restarted moments earlier) was the acquisition file rewritten with the two-config combination and reintroduced.

Verification

- docker logs crowdsec showing both `loading inband rule crowdsecurity/base-config and loading inband rule crowdsecurity/vpatch-* exactly once each, plus loading outofband rule crowdsecurity/crs, with no level=fatal` afterward. - cscli metrics show appsec showing a real, climbing Processed count for homelabAppSec under live traffic. - A live test against dns.compellinglylowbrow.org with a syntactically valid, base64-encoded DoH query (the traffic shape this whole rollout was staged cautiously around) processed cleanly with `cscli alerts list` showing no active alerts.
caddy LXC's local CrowdSec agent silently dead for 2 days -- boot-time network race, no Restart= policy to self-heal medium
crowdsec caddy watchdog systemd boot-race monitoring-gap   last seen:

Symptom

Uptime Kuma / Prometheus alert: cs_lapi_machine_requests_total for machine="caddy-lxc" completely absent from watchdog's /metrics. docker exec crowdsec cscli machines list on watchdog confirmed: caddy-lxc present but Last Heartbeat: ⚠️ 51h2m2s — i.e. genuinely silent for over two days, not a metrics-scraping glitch.

Root cause

caddy-lxc runs its own local CrowdSec engine (the apt-installed v1.4.6, distinct from Caddy's bouncer plugin and from watchdog's Docker-based central LAPI — see known-fixes/crowdsec-docker-migration-environ-leak.md for that distinction) that watches Caddy's own logs and pushes signals up to watchdog's LAPI at 192.168.42.229:8080. journalctl -u crowdsec on caddy showed the actual crash, timed almost exactly to the physical rack move documented in this session's own physical-rack-move-plan notes (2026-08-19/20):
Aug 20 02:06:14 caddy crowdsec[339]: level=fatal msg="starting outputs error : authenticate watcher (caddy-lxc): ... dial tcp 192.168.42.229:8080: connect: network is unreachable"
Aug 20 02:06:14 caddy systemd[1]: crowdsec.service: Main process exited, code=exited, status=1/FAILURE
Aug 20 02:06:14 caddy systemd[1]: crowdsec.service: Failed with result 'exit-code'.
Two compounding gaps in the shipped unit file (/usr/lib/systemd/system/crowdsec.service, apt package default, never overridden until now):
[Unit]
After=network.target remote-fs.target nss-lookup.target
[Service]
Type=notify
ExecStartPre=/usr/bin/crowdsec -c /etc/crowdsec/config.yaml -t
ExecStart=/usr/bin/crowdsec -c /etc/crowdsec/config.yaml
1. After=network.target, not network-online.target. network.target only means networking *infrastructure* (interfaces, routing tables) is set up in principle — it does not wait for DHCP/an actual route to exist. During the rack move's reboot, crowdsec.service started before the LXC actually had a working route to 192.168.42.229, hit a fatal auth error, and exited. 2. No Restart= directive at all. A one-time boot-race failure should be exactly the kind of thing a few retries clears on its own once networking finishes coming up seconds later — but with no restart policy, systemd just left it in failed state permanently. It sat dead for 2 days until this alert caught it; nothing else in the fleet's monitoring (bin/health-check, collect-homelab, Uptime Kuma's basic port checks) was watching this specific machine-registration metric until whatever created this alert rule. This is very likely the third, previously undiscovered casualty of the 2026-08-19/20 physical rack move's network blip — the same session already found and fixed two others (adguard2's DHCP lease stuck in backoff, headplane losing a docker startup race against headscale's restart) but this one didn't surface until a dedicated CrowdSec-metrics alert existed to catch it.

Fix

Immediate: systemctl restart crowdsec on the caddy LXC — clean start, confirmed via cscli machines list on watchdog showing Last Update advance to the restart timestamp (proof it authenticated and made a real API request again). Structural fix, so a future boot-time network blip self-heals instead of requiring another alert + manual discovery — systemd override at /etc/systemd/system/crowdsec.service.d/override.conf:
[Unit]
After=network-online.target
Wants=network-online.target

[Service]
Restart=on-failure
RestartSec=15
StartLimitIntervalSec=300
StartLimitBurst=10
systemctl daemon-reload picked it up without needing another restart. Confirmed via systemctl show crowdsec -p Restart -p After -p Wants.

Fleet sweep for the same pattern (2026-08-21, same session)

Swept all 24 live hosts (systemctl --failed plus every enabled service whose After= includes network.target but not network-online.target *and* has no Restart= policy) for the same structural gap. Result: nothing else matched the actual failure class. Everything else the sweep flagged was noise once triaged — postfix/unattended-upgrades (self-healing/timer-driven, don't crash-fatal on a boot network race), core Proxmox/OMV subsystem units (lxc-monitord, pve-firewall, openmediavault-engined, etc. — vendor-shipped, deliberately not touched), and sys-kernel-config.mount (expected, permanent LXC limitation, unrelated). The caddy-lxc crowdsec.service fix above no longer appears in the sweep now that it's fixed — a useful sanity check that the detection logic actually works. One related-but-distinct gap found and fixed the same pass: vaultwarden.service also had zero Restart= policy. It doesn't actually share CrowdSec's specific trigger (no remote auth at boot, so it's not exposed to *this* race), but "no restart policy on a single-point-of-failure custom app" is its own gap worth closing, especially for a password vault. Same override pattern applied at /etc/systemd/system/vaultwarden.service.d/override.conf:
[Service]
Restart=on-failure
RestartSec=15
StartLimitIntervalSec=300
StartLimitBurst=10
Confirmed applied (systemctl show vaultwarden -p Restarton-failure) without needing a restart — daemon-reload was enough since the service was already healthy and running.

Prevention / notes for next time

- network.target alone is not a connectivity guarantee — this is a well-known, frequently-relearned systemd gotcha ([systemd's own docs explicitly warn about this](https://www.freedesktop.org/wiki/Software/systemd/NetworkTarget/)). Any service with a hard runtime dependency on reaching another host (not just being able to bind a local socket) should use After=network-online.target + Wants=network-online.target, not network.target. - **Any apt-shipped unit file is a candidate for missing a sane Restart= policy** — this is the second time in this repo a base package unit needed a .service.d/override.conf on top of it, not just once (see the caddy.service.d/override.conf layering pattern in known-fixes/crowdsec-docker-migration-environ-leak.md, incident 4). Worth a quick systemd-unit audit across the fleet's other apt-installed daemons for the same gap, rather than assuming this was a one-off. - This gap was invisible to every existing checkbin/health-check only probes HTTP endpoints, collect-homelab doesn't touch CrowdSec machine state, and Uptime Kuma's port monitor for 192.168.42.229:8080 (added per crowdsec-docker-migration-environ-leak.md) only proves the LAPI itself is up, not that every registered machine is actually talking to it. The alert that caught this (cs_lapi_machine_requests_total absence) is doing a real job no other check does — worth keeping, and worth considering the same pattern (per-machine liveness, not just central-service liveness) for other fan-in monitoring relationships in this fleet.
set -e + pipefail + grep on a not-yet-existing metric silently kills a bash script medium
bash script with set -euo pipefail produces zero output and no error • cron job never posts to ntfy, with no log entry or exit code to explain why • script appears to work when run manually, but a later section never executes
bash set-e pipefail grep crowdsec ntfy   last seen: 2026-07-20

Symptoms


Cause

bin/crowdsec-digest.sh used set -euo pipefail and pulled several Prometheus counters out of /metrics with a curl | grep | awk pipeline assigned straight to a variable, e.g.:
appsec_block_total=$(curl -s "$METRICS_URL" \
    | grep '^cs_appsec_block_total{' \
    | awk '{sum+=$NF} END {print sum+0}')
cs_appsec_block_total doesn't exist in /metrics at all until the first real AppSec block happens (Prometheus client libraries commonly don't expose a labeled counter until it's incremented at least once). With zero matching lines, grep exits 1. awk's END block still runs fine and would print 0 — but with pipefail active, the pipeline's reported exit status is the last (rightmost) non-zero status among all stages, so grep's 1 propagates even though awk itself succeeded. A bare var=$(...) assignment is a plain simple command in bash, so under set -e a non-zero status there terminates the whole script immediately — silently, with no error printed, and before any later command (in this case, the final curl posting to ntfy) ever runs. This is a general bash gotcha, not specific to CrowdSec: any script combining set -e, pipefail, and a grep against data that can legitimately have zero matches (a metric that hasn't fired yet, a log line that hasn't occurred yet, an empty result set) is exposed to it.

Diagnosis

The symptom is maximally unhelpful: no error, no log line, cron just doesn't do the thing. Confirm by running with bash -x and watching where execution actually stops:
sudo bash -x /usr/local/bin/crowdsec-digest.sh 2>&1 | tail -30
If the trace stops partway through with no error message and no + line for the commands that should follow, a var=$(pipeline) assignment earlier in the trace is the culprit. Cross-check by grepping the metric by hand:
curl -s http://192.168.42.229:6060/metrics | grep '^cs_appsec_block_total{'

zero output confirms the metric doesn't exist yet -- this is expected,

not itself a bug -- the bug is the script not tolerating it

Fix

Append || true to any assignment whose pipeline might legitimately return non-zero, and fall back to a sane default value explicitly rather than trusting an empty variable:
appsec_block_total=$(curl -s "$METRICS_URL" \
    | grep '^cs_appsec_block_total{' \
    | awk '{sum+=$NF} END {print sum+0}') || true
appsec_block_total="${appsec_block_total:-0}"
|| true only suppresses the exit-status propagation to set -e — it does not affect what got captured into the variable, so awk's correct 0 output is preserved either way. Applied to all three fragile extractions in bin/crowdsec-digest.sh (active_summary, appsec_block_total, appsec_reqs_total). bin/crowdsec-health-check.sh's heartbeat_count line already had this guard from the start and was unaffected.

Note

Worth auditing any other set -e/pipefail script in this repo that greps Prometheus output or any other source that can plausibly return zero matches — bin/crowdsec-health-check.sh is fine, but this pattern hasn't been swept across the rest of bin/.
CrowdSec migrated to Docker on watchdog (v1.4.6 apt → v1.7.7-debian); found and fixed a Caddy --environ journal secret leak along the way medium
crowdsec appsec watchdog caddy docker systemd secrets journald   last seen:

Summary

Migrated watchdog's CrowdSec from Debian's stale apt package (v1.4.6, see known-fixes/crowdsec-trixie-apt-repo-and-arm64-appsec-gap.md) to the official crowdsecurity/crowdsec:v1.7.7-debian Docker image, matching the existing docker-compose pattern already used for ntfy/uptime-kuma on this host. This unblocks CrowdSec AppSec/WAF (docs/security-crowdsec-appsec-plan.md), which requires engine >= 1.5.6. Along the way, found and fixed a real, previously-undiscovered secret leak: Caddy's systemd unit was running caddy run --environ, which dumps every environment variable — including PORKBUN_API_KEY, PORKBUN_API_SECRET_KEY, and the CrowdSec bouncer API key — to stdout on every start, landing in plaintext in the systemd journal. This appears to have been present since Porkbun's key was first configured, unrelated to tonight's migration, but became urgent because the freshly-rotated bouncer key was about to be exposed the same way. Same session, later: added monitoring (Uptime Kuma port monitor + Grafana dashboard) and, in the process of getting the dashboard to show real data, found and fixed two further gaps — an empty acquisition file caused by a missing docker exec -i flag, and a genuinely surprising discovery that watchdog's own journald has never actually been using persistent storage despite Storage=auto and /var/log/journal existing. Both are documented below (incidents 5 and 6).

What was done (in order)

1. Recon before touching anything: cscli collections/parsers/hub list, docker --version, df -h, docker volume ls, ss -tlnp | grep 8080, systemctl status crowdsec. Confirmed Docker 29.6.1/Compose v5.3.1 already present (matching ntfy/uptime-kuma), 44G disk headroom, no port conflicts. 2. Decided against migrating old state. Two major-version jump (v1.4.6 → v1.7.x) plus the exact class of problem that blocked AppSec in the first place (config/schema incompatibility) made carrying the old /etc/crowdsec config and SQLite DB forward riskier than a fresh init. Backed up the old install anyway before touching it: sudo tar czf ~/crowdsec-preDocker-backup-$(date +%Y%m%d).tar.gz /etc/crowdsec /var/lib/crowdsec 3. Pinned version, not latest-debian. Matches the same reasoning already applied to the uptime-kuma:2 floating-tag problem in "Known Limitations" — a pinned tag is what makes update-advisor's SAFE/age-gated auto-update path actually work; a floating tag gives version-checker nothing concrete to diff. 4. Explicit persistent volumes decided up front, before writing any compose block: crowdsec-config:/etc/crowdsec and crowdsec-data:/var/lib/crowdsec/data. This is what makes it safe to eventually flip auto_update: true for this service — a version bump via docker compose pull && up -d won't wipe bouncer registrations or decisions the way an anonymous/ephemeral volume would. 5. Collections fresh-init, deliberately pruned. The apt install had apache2, nginx, sshd, linux, http-cve, base-http-scenarios enabled — but watchdog runs neither Apache nor nginx, so those two collections had never matched a single real log line. The actual HTTP-attack detection for headscale/dns traffic happens on the caddy LXC's own separate CrowdSec install (unaffected by this migration), not on watchdog's local instance. Reinstalled only crowdsecurity/sshd and crowdsecurity/linux — the two that protect watchdog's own SSH/system exposure, which is real. This cuts maintenance surface (fewer update-available/tainted warnings) with zero loss of actual protection. 6. A stray decoy file caused a false drift alarm. Recon initially read ~/watchdog/compose.yaml (a leftover file from before the repo was cloned to this host, dated Jul 8, never used by anything) instead of the real, compose-managed file at ~/homelab/watchdog/compose.yaml. The stray file was missing the netdata service, which briefly looked like real drift between git and the live host. `docker inspect netdata`'s own labels (com.docker.compose.project.config_files=/home/watchdog/homelab/watchdog/compose.yaml) settled it immediately — the real file matched git byte-for-byte the whole time. **Lesson: when multiple compose-looking files might exist on a host, trust docker inspect 's com.docker.compose.project.config_files label over a bare cat of an assumed path.** The stray file was left in place (harmless, just clutter) rather than deleted mid-session.

Incidents hit during rollout (real issues, not smooth on paper)

1. Bouncer key generated once, then lost before being placed

cscli bouncers add caddy-bouncer was run once early, its key was never captured into /etc/caddy/crowdsec.env, and CrowdSec never displays a key after its one-time creation print. Result: cscli bouncers list showed caddy-bouncer registered but with every other column (IP Address/Last API pull/Type/Version) empty — the signature of a bouncer that has never once successfully authenticated. Fixed by cscli bouncers delete caddy-bouncer + cscli bouncers add caddy-bouncer again, this time placing the key immediately with zero delay. **Same lesson as the original rollout's incident 8: regenerate-and-place- once beats trying to retrieve or verify a key after the fact.**

2. `caddy validate` failing on `crowdsec API key must not be empty` was a false alarm

Running caddy validate by hand in an interactive shell does not load EnvironmentFile= — that mechanism is systemd-specific, wired through the unit file, and only takes effect when the *service* starts/reloads via systemctl. A bare shell has no idea /etc/caddy/crowdsec.env exists, so {env.CROWDSEC_BOUNCER_API_KEY} resolves empty during a standalone validate even when the real running config is fine. Confirmed the real env file was well-formed first (cat -A ... | grep CROWDSEC — clean $ line ending, no ^M, no quotes) before concluding this was a shell-context issue rather than a real config problem.

3. The actual bug: `systemctl reload` does not refresh a running process's environment

After placing the new key and reloading, Caddy's own crowdsec module kept logging `failed to connect to LAPI, retrying in 10s: API error: access forbidden — even though a manual curl -H "X-Api-Key: ..."` with the *same* key against the *same* LAPI succeeded cleanly. Root cause: EnvironmentFile= is read once, at process start, by systemd — it is not re-read on systemctl reload (ExecReload=caddy reload ... just sends the new Caddyfile to Caddy's already-running process via its Admin API; the process itself, and its inherited environment, never restarts). systemctl status caddy showing an Active: ... since timestamp *older* than the key rotation was the tell. **Same class of bug as incident 7 in the original CrowdSec rollout (binary swaps needing restart not reload), except for an env var instead of a binary — the general principle holds for both: if the running process itself needs to pick up a change (new binary, new env var), reload is not enough, only restart re-execs the process.** A misleading intermediate signal: a manual curl using the correct key did register a Last API pull timestamp against the bouncer — but its Type/Version columns showed curl/8.14.1, proving it was our own test client authenticating, not Caddy's module. Worth checking those columns specifically, not just whether the timestamp moved, when verifying which client actually made a given pull.

4. Found: `caddy run --environ` leaking secrets to the systemd journal

While chasing incident 3, journalctl -u caddy turned up the full plaintext values of PORKBUN_API_KEY, PORKBUN_API_SECRET_KEY, and CROWDSEC_BOUNCER_API_KEY logged at every service start — sourced from the base package unit's `ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile. The --environ` flag exists for debugging and dumps the full process environment to stdout, which systemd captures straight into the journal. This predates tonight's work (Porkbun's key has apparently been leaking this way since it was first configured) but became urgent because the freshly-rotated bouncer key was about to be exposed the same way on the very restart needed to fix incident 3. Fix: added a further override to /etc/systemd/system/caddy.service.d/override.conf (same file already holding both EnvironmentFile= lines — `systemctl edit caddy without --full` edits this file in place, replacing its entire contents with what's in the editor, so the existing lines had to be kept, not just the new ones added):
[Service]
EnvironmentFile=/etc/caddy/porkbun.env
EnvironmentFile=/etc/caddy/crowdsec.env
ExecStart=
ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile
The empty ExecStart= line first is required systemd syntax to clear the inherited base-unit value before redeclaring it without --environ — omitting it causes systemd to attempt running both directives and fail. systemctl daemon-reload && systemctl restart caddy picked this up cleanly; systemctl status caddy after showed no key/secret lines in its output at all on the new start. Not yet done: rotating Porkbun's key. Its exposure predates tonight, and rotating a DNS provider credential is a more invasive change (touches cert issuance) than a bouncer key — intentionally scoped out of tonight's session as its own future task, not because it's lower-severity.

5. Empty acquisition file from a missing `docker exec -i` flag

While setting up the sshd/linux collections to actually read something (see incident 6 for the fuller story), the first attempt to write a real acquis.yaml used docker exec crowdsec sh -c 'cat > ...' <<'EOF' ... EOF without -i. Without that flag, docker exec never attaches stdin to the container's process at all, so the heredoc content silently never arrived — the command exited successfully but wrote an empty file. Result: `level=fatal msg="crowdsec init: while loading acquisition config: no datasource enabled" and a crash-loop (Restarting (1) in docker ps`), since an empty acquisition file has zero valid sources. **This is the exact same class of gotcha already documented in docs/pve-exporter.md** ("docker exec -i, not -it... no TTY is available over a non-interactive SSH command") — just missed here despite being previously known. Worth internalizing as a standing rule: **any docker exec that pipes content via a heredoc or stdin needs -i, full stop, no exceptions** — it's easy to type the command correctly in every other respect and still have it silently do nothing. Complication: because the container was crash-looping on unless-stopped, there was no usable window to docker exec into it to fix the file before it crashed again. Worked around by writing directly into the named config volume via a disposable container instead of the real one:
docker run --rm -i --entrypoint sh -v watchdog_crowdsec-config:/etc/crowdsec \
  crowdsecurity/crowdsec:v1.7.7-debian -c "cat > /etc/crowdsec/acquis.yaml" <<'EOF'
...
EOF
Verifying the write also needs --entrypoint sh — a second attempt to verify by running the image with a plain cat ... argument (no entrypoint override) instead invoked the image's real docker_start.sh entrypoint, which ignores any passed command entirely and always execs crowdsec, hitting an unrelated /var/lib/crowdsec/data volume-mount check and exiting before ever touching the requested file. Easy to misread that exit as "the write failed" when it was actually "the verification command itself was wrong."

6. Discovered: watchdog's journald has never actually used persistent storage

Once the acquisition file itself was fixed (see incident 5), CrowdSec started cleanly but logged "Got stderr: No journal files were found." for the journalctl datasource. `docker exec crowdsec ls -la /var/log/journal/ (with the host's /var/log` correctly bind-mounted) showed a real, correctly-permissioned directory (drwxr-sr-x+ root systemd-journal) that was completely empty — no machine-id subdirectory, no .journal files at all. Checked on the host directly (not through the container): /var/log/journal/ was indeed empty, while /run/log/journal/ (the volatile, RAM-backed location) had a real, actively-written machine-id directory (journalctl --disk-usage confirmed 141.7M of real journal data existing — just not where Storage=auto in /etc/systemd/journald.conf should have put it). Storage=auto only switches to persistent (/var/log/journal) storage if that directory exists **and journald sees it at its own startup** — most likely this directory was created at some point after journald had already initialized in volatile mode, and nothing has triggered a re-check since (no systemctl restart systemd-journald since whenever the directory appeared). Fix applied (pragmatic, not the "proper" one): bind-mounted /run/log/journal:/run/log/journal:ro into the crowdsec container alongside the existing /var/log:/var/log:ro, matching where the data actually lives right now rather than fixing journald's storage mode mid-session. Deliberately not fixed tonight: forcing journald to actually switch to persistent storage (systemctl restart systemd-journald after confirming /var/log/journal permissions) would be the more "correct" long-term fix, matching what the config already claims to want — but restarting a core system logging daemon while several other things were mid-flight this session felt like an unnecessary compounding of risk for a problem that already has a working pragmatic fix. Worth a dedicated look in a future session: if /run/log/journal fills up and rotates on tmpfs pressure, or watchdog reboots, the RAM-backed journal is fully lost — persistent storage would survive both. **Once both incidents 5 and 6 were fixed, end-to-end verification succeeded cleanly:** cscli metrics showed nonzero Lines read/Lines parsed from the journalctl source, and cscli explain against a real log line confirmed the crowdsecurity/sshd-logs parser's filter (evt.Parsed.program in ['sshd-session', 'sshd']) already correctly handles OpenSSH's newer sshd-session process-name split — no parser update needed. The specific lines seen during testing were benign post-auth disconnects (no [preauth] tag), correctly left unparsed by design, not evidence of a bug. Two lines *did* match a real failure-grok pattern and got flagged whitelisted — traced to Docker's internal bridge IP (172.18.0.3), correctly excluded by crowdsecurity/whitelists' RFC1918 exclusion (same intentional behavior as incident 9 in the original Phase 1 rollout, docs/security-crowdsec-plan.md).

Verification (final state, confirmed)

- docker exec crowdsec cscli version — engine now v1.7.7, well above AppSec's >= 1.5.6 floor. - docker exec crowdsec cscli bouncers listcaddy-bouncer shows a real IP (192.168.42.45), Type: caddy-cs-bouncer, Version: v0.13.2-0.20260601072652-8814a3118b73 (the actual Caddy module, not a manual curl), and a Last API pull timestamp seconds old. - systemctl status caddy after the --environ removal — clean, no secret values printed on start. - Prometheus metrics endpoint reachable at 192.168.42.229:6060/metrics, scraped successfully by the grafana LXC's prometheus.service (confirmed "health": "up" via the Prometheus API), and visualized via the imported CrowdSec Metrics dashboard (grafana.com ID 21419) showing real Version/Up Since data. - sshd/linux collections on watchdog confirmed genuinely functional end-to-end (not just installed) — real journal data flowing through acquisition → parsing → whitelisting, correctly distinguishing benign disconnects from real failures and correctly excluding private-IP sources from triggering decisions. - Uptime Kuma port monitor added for 192.168.42.229:8080 (Watchdog group) via bin/setup-uptime-kuma.py.

Still open

- Native apt crowdsec.service stop/disable on watchdog — confirmed already stopped/disabled during this session (turned out to have been stopped earlier the same day, before this session even started; just needed the disable to stick). - Porkbun API key rotation (see incident 4) — deferred, more invasive than a bouncer key, own future task. - journald persistent-storage fix (see incident 6) — pragmatic bind-mount workaround in place; the "proper" fix (getting journald to actually honor Storage=auto and write to /var/log/journal) is deferred to its own session. - Resume docs/security-crowdsec-appsec-plan.md Step 1 — explicitly pushed to next session. Engine prerequisite is met; collections (crowdsecurity/appsec-virtual-patching, crowdsecurity/appsec-crs), the AppSec acquisition config, the 7422:7422 port mapping, and the Caddy binary rebuild with the third module are all still ahead.
CrowdSec on both watchdog and caddy is stuck on Debian's stale v1.4.6 (packagecloud has no trixie release) — blocks AppSec medium
crowdsec appsec watchdog caddy apt   last seen:

Symptom

Attempting to install the AppSec Component (docs/security-crowdsec-appsec-plan.md) on watchdog failed at crowdsec startup:
level=fatal msg="crowdsec init: while loading scenarios: scenario loading
failed: bad yaml in /etc/crowdsec/scenarios/appsec-vpatch.yaml : yaml:
unmarshal errors:\n  line 17: cannot unmarshal !!seq into string"
The scenario's classification: field (a YAML list, MITRE ATT&CK-style) uses format: 3.0, but the installed engine declares Constraint_scenario: >= 1.0, < 3.0 (cscli version) — it cannot load scenarios newer than format 2.x at all.

Root cause (multi-layered — traced back fully before stopping)

1. **Installed CrowdSec version is v1.4.6-10+b4-debian on both watchdog (arm64) and caddy (amd64)** — confirmed via cscli version on both hosts. Current upstream stable is v1.7.7/v1.7.8. This is not a fresh-install artifact; it's meaningfully behind (~2 major versions). 2. **apt-cache policy crowdsec shows the version came from Debian's own archive (deb.debian.org/debian trixie/main), not the CrowdSec packagecloud repo** — even though the packagecloud repo *is* configured (/etc/apt/sources.list.d/*crowdsec* present and correct, with a valid signed-by keyring). 3. **The packagecloud repo has no release for trixie at all, on either architecture.** Confirmed via apt update: `Err: https://packagecloud.io/crowdsec/crowdsec/debian trixie Release — does not have a Release file.` This is not an arm64-specific gap — caddy (amd64) hits the identical error. CrowdSec's official apt channel simply hasn't published a trixie release yet, so apt silently falls back to Debian's own (much older) bundled package on both hosts. 4. **The official Docker image does support arm64, but only the -debian tag variant** (crowdsecurity/crowdsec:v1.7.7-debian, latest-debian — confirmed on Docker Hub, arm64 present). The plain :latest/:slim tags are amd64/386/arm-v6 only — this matches a still-open 2024 upstream GitHub issue (#2898) about missing arm64 on the non-debian image. Easy to install the wrong tag and rediscover this gap.

Why this blocks AppSec specifically (but not phase 1)

AppSec requires CrowdSec engine >= 1.5.6 (per official docs) — v1.4.6 predates that entirely, so this isn't fixable by working around the scenario format alone; the source: appsec acquisition type itself may not even exist at this version. Phase 1's IP-reputation bouncer (already live, verified 2026-07-18) does not need this — it uses only the base LAPI/bouncer API, which v1.4.6 fully supports. **Phase 1 is unaffected and does not need to be touched to fix this.**

Recovery performed same day (confirmed working)

The failed scenario load prevented crowdsec.service from starting at all on watchdog — meaning phase 1's LAPI was briefly down (bouncer stayed up regardless, fail-open by design, so no service impact to proxied traffic). Recovered via:
sudo cscli collections remove crowdsecurity/appsec-virtual-patching
sudo cscli collections remove crowdsecurity/appsec-crs
sudo rm /etc/crowdsec/acquis.d/appsec.yaml
sudo systemctl restart crowdsec
Confirmed active and LAPI listening on :8080 again immediately after. One harmless leftover: crowdsecurity/appsec-logs (a parser) didn't get removed because another still-enabled collection depends on it — not causing errors, just inert clutter.

Path forward — COMPLETE as of 2026-07-19

Migrated watchdog's crowdsec from the native apt package to the official crowdsecurity/crowdsec:v1.7.7-debian Docker image, matching watchdog's existing docker-compose-managed pattern (ntfy, uptime-kuma already ran this way on this host). Confirmed: - Sidestepped the packagecloud/trixie gap entirely (Docker Hub publishes independently of apt). - Engine now v1.7.7, well above AppSec's >= 1.5.6 floor. - Caddy's own crowdsec agent install was not touched, as anticipated — no version requirement tied to AppSec there. - Phase 1's bouncer re-registered and confirmed live-authenticating against the new engine end-to-end. Full incident log — including a real systemctl reload env-staleness bug that stalled the bouncer for ~40 minutes, and an unrelated `caddy run --environ` secret-leak finding hit and fixed along the way: known-fixes/crowdsec-docker-migration-environ-leak.md. AppSec (docs/security-crowdsec-appsec-plan.md) is now marked UNBLOCKED and ready to resume — Step 1's collections (crowdsecurity/appsec-virtual-patching, crowdsecurity/appsec-crs) still need a fresh install against the new engine, since this migration deliberately pruned watchdog's collections down to just crowdsecurity/sshd + crowdsecurity/linux (apache2/nginx were dead weight — watchdog runs neither service).
DDNS -- wildcard A record silently ends up with TWO live records; ddns-update 'corrects' the stale one every 5 minutes forever without ever converging medium
ntfy fires 'DDNS: DNS record(s) corrected' for *.compellinglylowbrow.org every single 5-minute cron cycle, for hours, always the exact same direction (stale IP -> current IP), never stopping • bin/ddns-update's own log shows 'OK: *.compellinglylowbrow.org -> <current IP>' on every run with no errors, yet the next run finds the record stale again • dig against the domain's authoritative nameservers (all four) returns TWO different A records for a subdomain that should only resolve to one IP, alternating/round-robining between queries
dns ddns porkbun caddy wildcard duplicate-record alert-noise false-positive   last seen: 2026-07-21

Symptoms

See also: dns-caddy-monitor-transient-flap, systemd-resolved-stale-cache

Symptom

bin/ddns-update runs every 5 minutes via cron on proxmox-nuc. Starting around 09:54 on 2026-07-21, ntfy began firing "DDNS: DNS record(s) corrected" for *.compellinglylowbrow.org on nearly every single cycle, always correcting from the same stale IP (135.180.210.26) to the same current IP (135.180.79.96). This continued for 9+ hours without ever stopping, despite the script's own log showing a clean OK: line on every run with no errors.

Why this looked like a race between two scripts (and wasn't)

This script was rewritten earlier the same day after a genuine incident involving a second, untracked DDNS updater racing on the caddy LXC (see known-fixes/ddns-update-unvalidated-ip-garbage-push.md). Given that history, "a second writer is still active somewhere" was the natural first hypothesis, and each layer was checked and ruled out in turn: 1. caddy LXC -- old /usr/local/bin/ddns-update.sh confirmed renamed to .retired, no crontab entry referencing it. 2. proxmox-nuc crontab -- exactly one line for ddns-update, no duplicate/overlapping cron entries at different offsets. 3. systemd timers -- systemctl list-timers --all | grep -i ddns on both caddy and proxmox-nuc: no output, nothing there. 4. Router (TP-Link BE800) -- built-in DDNS client present but pointed at TP-Link's own service, never actually logged into/activated. Its WAN IP status page correctly showed 135.180.79.96, confirming the real current IP matched what ddns-update was trying to set. 5. collected/ grep (grep -ril porkbun/ddns ~/projects/homelab/collected/) -- only expected hits (Caddyfile's own placeholder, wiki pages, this incident's own watchdog alert echoes). No untracked script or .env copy anywhere else in the fleet's last-collected snapshots. Every plausible "something else is writing this record" theory came back clean. A parallel theory -- that the retrieve step was reading through a stale cache and never actually being wrong -- was also tested and ruled out: a manual editByNameType call against the wildcard returned a clean {"status":"SUCCESS"}, but dig against all four authoritative nameservers still returned the pre-edit stale IP afterward, proving the API's success response could not be trusted at face value for this record.

Root cause

Checking the Porkbun dashboard directly (not just DNS query results) revealed the actual state: **two separate live A records for host ***, one holding 135.180.210.26, one holding 135.180.79.96. This is why dig against different nameservers (or even the same nameserver across repeated queries) returned different answers -- both records were real, answered in round-robin fashion. The giveaway that pinned this down precisely: the wildcard's TTL stayed at 600 (its old value) while headscale/headplane -- plain subdomain records edited by the exact same script, same code path, same cycle -- correctly showed TTL 300 (what ddns-update sets on every edit). If the wildcard's editByNameType calls had ever actually succeeded in-place, its TTL would have updated too. It never did, which means every "OK" logged for the wildcard across 9+ hours was the script's own read of Porkbun's edit-response status field, not a verified live-record outcome -- and editByNameType against %2A appears to have, at least once, silently created an additional record rather than updating the existing stale one, while still returning {"status":"SUCCESS"}. Combined with the script's existing retrieve-side logic (see ddns-update header note #8: retrieveByNameType/%2A returns empty for the wildcard, so it uses the bare dns/retrieve/{domain} endpoint and filters client-side), the retrieve step's matches[0] silently picked one of the now-two matching records -- consistently the stale one -- treated it as "the" live value, found it different from the detected current IP, and "corrected" it via editByNameType every single cycle. That edit call apparently never touched the existing duplicate rows at all, so nothing ever converged; the loop was genuinely infinite until manually broken. The exact mechanism behind why editByNameType created a duplicate rather than updating in place (a possible Porkbun API bug specific to the %2A wildcard encoding, versus something in how this script formed that particular request) was not conclusively pinned down. Worth revisiting if it recurs after the code fix below, since the code fix prevents the *symptom* (infinite silent correction loop) but doesn't explain the underlying API behavior.

Diagnostic path that worked

1. ssh caddy "crontab -l; ls -la /usr/local/bin/ddns-update*" -- confirmed old script retired, no cron entry. 2. ssh proxmox-nuc "crontab -l | grep -i ddns" + `tail -100 /var/log/ddns-update.log` -- confirmed single cron line, clean flock behavior (no overlapping runs), and the repeating "stale -> corrected" pattern with no gaps. 3. Router admin UI -- Internet Status page confirmed real WAN IP and that the router's own DDNS client was inactive/unconfigured. 4. grep -ril porkbun / grep -ril ddns ~/projects/homelab/collected/ -- ruled out any untracked script or credential copy anywhere else in the fleet's last-collected snapshots. 5. ssh caddy "systemctl list-timers --all | grep -i ddns" + same on proxmox-nuc -- ruled out systemd-timer-based writers. 6. dig NS compellinglylowbrow.org to get the domain's actual four authoritative nameservers, then `dig +short test123.compellinglylowbrow.org @` -- querying a subdomain that isn't independently defined, not the bare apex, specifically to hit the wildcard record rather than a possibly-separate apex record. All four nameservers returned both IPs. 7. Porkbun dashboard, DNS records search -- visually confirmed two rows for Host */Type A, with differing TTLs revealing which one the script was actually able to touch. 8. One manual editByNameType call via curl direct against proxmox-nuc, piping credentials through source .env && curl ... < so secrets never appeared in chat/log output -- returned SUCCESS, but the live dig result was unchanged, confirming the API's success response could not be trusted without independent verification.

Remediation

Immediate (manual, done same day): deleted the stale 135.180.210.26 row via the Porkbun dashboard's Edit/delete UI, keeping only the correct 135.180.79.96 row. Verified via dig returning a single, consistent answer afterward. Structural (code fix, same day, bin/ddns-update): the wildcard retrieve step now counts *all* A records matching name == '*.{domain}' instead of blindly taking matches[0]. If more than one is found: - No edit is attempted for that record this run (editing was never the actual bug -- a stray duplicate create was, and attempting another edit while duplicates exist risks producing a third). - An urgent (ntfy priority 5) alert fires immediately with the exact duplicate IP values found and step-by-step manual fix instructions (log into Porkbun, find every */A row, keep the one matching the real current WAN IP, delete the rest, then let the next cron cycle confirm convergence). - The run continues to log an error and increment the run's error count, so $ERRORS -gt 0 still causes a normal exit-1/retry-next-run outcome for the overall script, consistent with how other per-record failures are handled. This does not attempt to auto-delete the duplicate via the API -- Porkbun's dns/delete endpoints for the wildcard would need the specific record ID, which isn't reliably obtainable from the %2A-filtered retrieve response in a way that's been verified safe. Manual resolution via the dashboard remains the intended path until/unless record-ID-based deletion is specifically tested and added.

Diagnostic tooling notes worth keeping

- **Query a definitely-unclaimed subdomain, not the bare apex, when testing a wildcard record.** The apex (compellinglylowbrow.org with no subdomain) can have its own separate record distinct from *. A random subdomain that isn't independently defined anywhere (test123.) is the only way to be sure you're actually hitting the wildcard. - **A {"status":"SUCCESS"} response from Porkbun's editByNameType does not guarantee the live record actually changed.** Verify independently via dig against the authoritative nameservers (not a cached recursive resolver) before trusting an API success response for this specific record type/encoding combination. - **TTL mismatches between records edited by the same script in the same run are a useful tell.** If some records show the script's own known-good TTL value and others don't, the ones that don't were never actually edited in place, regardless of what the log says. - **source /etc/some-credentials.env && curl ... on the remote host, piped straight into a heredoc, keeps secrets out of any output that needs to be pasted back for diagnosis** -- only the API's JSON response (status/requestId, no credentials) needs to leave the remote shell.
DDNS -- editByNameType/%2A returns {"status":"SUCCESS"} but silently never changes the live wildcard record; ddns-update 'corrects' the same single record every 5 minutes forever. Fix: edit-by-id + verify-after-edit medium
ntfy fires 'DDNS: DNS record(s) corrected' (priority 3) for *.compellinglylowbrow.org every 5-minute cron cycle for hours, always the exact same direction (stale IP -> current IP), never converging • This is the priority-3 'corrected' path, NOT the priority-5 duplicate-detected alert -- so the 2026-07-21 duplicate guard is NOT tripping (DUP_COUNT == 1: a single wildcard record, not two) • bin/ddns-update --dry-run reports 'would update *.compellinglylowbrow.org: <stale> -> <current>' with no duplicate warning, run after run
dns ddns porkbun caddy wildcard silent-noop api-lie alert-noise edit-by-id verify-after-edit   last seen: 2026-07-25

Symptoms

  • ntfy fires 'DDNS: DNS record(s) corrected' (priority 3) for *.compellinglylowbrow.org every 5-minute cron cycle for hours, always the exact same direction (stale IP -> current IP), never converging
  • This is the priority-3 'corrected' path, NOT the priority-5 duplicate-detected alert -- so the 2026-07-21 duplicate guard is NOT tripping (DUP_COUNT == 1: a single wildcard record, not two)
  • bin/ddns-update --dry-run reports 'would update *.compellinglylowbrow.org: <stale> -> <current>' with no duplicate warning, run after run
  • dig against all four authoritative Porkbun nameservers returns ONE consistent stale IP (not two round-robining) -- the public record is genuinely stuck, not duplicated
  • The wildcard's TTL is stuck at an old value (e.g. 600) while headscale/headplane -- edited by the same script, same run -- show the TTL ddns-update sets (300); the wildcard's editByNameType/%2A calls were never landing
See also: ddns-duplicate-wildcard-a-record, ddns-update-unvalidated-ip-garbage-push, systemd-resolved-stale-cache

Symptom

Sonic reassigned the home WAN IP (135.180.79.96 -> 135.180.75.5) overnight. bin/ddns-update (every 5 min via cron on proxmox-nuc) detected the new IP, "corrected" the wildcard record, logged `OK: *.compellinglylowbrow.org -> 135.180.75.5`, and fired a priority-3 "corrected" ntfy -- then did the exact same thing on the next cycle, and the next, 70+ times, never converging. Woke up to 70+ identical notifications. Critically, this looks almost identical to known-fixes/ddns-duplicate-wildcard-a-record.md (same "corrected every cycle, never converges" shape) but is a different root cause, and the tell is which alert fires: this incident produced the priority-3 "corrected" notice, not the priority-5 "duplicate detected" alert. That means DUP_COUNT == 1 -- one wildcard record, not two. dig against all four authoritative nameservers confirmed it: a single, consistent, stuck 135.180.79.96 on every NS (not two answers round-robining, which is the duplicate signature).

Root cause

editByNameType/%2A (the Porkbun edit call this script used for the wildcard) returns {"status":"SUCCESS"} but **does not actually change the live wildcard record.** Every "OK" logged across 70+ runs was the script reading the API's status field, never a verified live outcome. So each cycle: read live record (stale 79.96) -> edit to 75.5 -> Porkbun says SUCCESS -> live record still 79.96 -> notify "corrected" -> repeat, forever. Proven the same day with a non-destructive TTL test (edit only the TTL, leave the IP pinned, revert after): - A single dns/edit/{domain}/{id} (edit-by-id) call changed the wildcard's TTL 600 -> 300 and back, and a re-retrieve confirmed each change landed immediately. - The stuck wildcard's TTL had been sitting at 600 the whole time, while headscale/headplane (edited by the same script every run) correctly showed 300 -- direct evidence the wildcard's editByNameType/%2A edits never landed, where the by-name edits for plain subdomains always had. Why editByNameType/%2A no-ops for the wildcard specifically (a Porkbun API quirk around the %2A encoding) was not pinned down further -- edit-by-id sidesteps it entirely, so it didn't need to be. This is the same underlying Porkbun untrustworthiness flagged in the duplicate known-fix ("a SUCCESS response does not guarantee the live record changed"), but here it manifested as a silent no-op on a single record rather than a stray duplicate create. The 2026-07-21 fix only guarded the duplicate case, so this variant sailed straight through it.

Diagnostic path that worked

1. ssh proxmox-nuc 'tail -40 /var/log/ddns-update.log' -- confirmed the repeating single-direction "correcting" pattern and that it was the priority-3 path (no duplicate error lines). 2. ssh proxmox-nuc '/usr/local/bin/ddns-update --dry-run' -- reported would update ... 79.96 -> 75.5 with no duplicate warning => DUP_COUNT == 1. 3. for ns in $(dig +short NS @1.1.1.1); do dig +short probe$RANDOM. @$ns; done -- all four NSes returned a single, consistent stale IP => stuck, not duplicated. (Random subdomain to guarantee hitting the wildcard, not the apex.) 4. Non-destructive TTL test via dns/edit/{domain}/{id} (edit-by-id), pinning content and reverting the TTL, re-retrieving after each edit -- confirmed edit-by-id lands where editByNameType/%2A does not. Credentials piped through a remote heredoc so only the API's JSON response left the host.

Remediation

Immediate (manual, done same day): edited the * A record's content to the current WAN IP in the Porkbun dashboard (a different backend that does land), verified via dig returning the new IP on all four NSes, and confirmed the log went quiet on the next cycle. The dashboard is the reliable manual fallback whenever the API edit won't take. **Structural (code fix, bin/ddns-update, commit d3e4368, deployed 2026-07-25):** 1. EDIT-BY-ID for the wildcard. The wildcard now edits via dns/edit/{domain}/{id}, capturing the record id during the retrieve step it already performs, instead of editByNameType/%2A. headscale/headplane keep editByNameType -- it works for them and always has. 2. VERIFY-AFTER-EDIT (all records). After any edit reports SUCCESS, the script now sleep 2 then re-retrieves and confirms the live value actually equals the target before trusting it. An edit that reports SUCCESS but does not land is treated as an error: it does NOT emit a "corrected" notification (killing the false-positive flood at the source), it withholds the Uptime Kuma freshness heartbeat (so the monitor flags DOWN after its interval -- ONE alert instead of one every 5 minutes), and it fires a single deduped priority-5 pointing at the dashboard fallback. The dedup flag lives in /var/lib/ddns-update (one per record) and clears automatically once that record reads correct again. Deploy is manual (the repo bin/ copy is scp'd to /usr/local/bin/ddns-update on proxmox-nuc; the repo commit does not touch the live cron). Staged via a .new file + --dry-run validation before an atomic mv promote.

Lessons worth keeping

- **A {"status":"SUCCESS"} from Porkbun's editByNameType/%2A is not proof the wildcard changed.** Prefer dns/edit/{domain}/{id} for the wildcard, and verify every edit against a fresh retrieve regardless of endpoint. - TTL is the safest observable for testing whether an edit lands. Editing only the TTL (and reverting) proves the write path without ever risking the live IP content. - "Same symptom, different root cause" is a real trap here. The duplicate incident and this one both present as "corrected every cycle, never converges." The discriminator is the alert priority (3 = corrected path, 1 record; 5 = duplicate guard, 2 records) and whether dig returns one stuck IP or two round-robining. Check that before assuming which known-fix applies. - A guard that fixes one variant of a class does not fix the class. The 2026-07-21 duplicate guard was correct but narrow; verify-after-edit is the general backstop that catches any "reported success, didn't land" failure, whatever its mechanism.
developer-env's snap Prometheus was orphaned cruft with corrupted TSDB storage -- removed, not repaired medium
developer-env prometheus snap tsdb cleanup   last seen:

Symptom

Found incidentally while sweeping the fleet for the CrowdSec boot-race pattern (known-fixes/crowdsec-caddy-lxc-boot-race-no-restart-policy.md): developer-env had a FAILED unit, snap.prometheus.prometheus.service, dead since 2026-08-19 19:07 PDT (same reboot window as the physical rack move). journalctl -u snap.prometheus.prometheus.service showed it crash-looping on start, hitting systemd's restart-rate limit within the same second:
level=error err="opening storage failed: /var/snap/prometheus/common/chunks_head/000465: invalid magic number 0"
...
snap.prometheus.prometheus.service: Start request repeated too quickly.

Cause

A corrupted TSDB chunk file (invalid magic number 0 — classic signature of an unclean shutdown mid-write). Unlike the CrowdSec incident this was found alongside, this is not the network-race pattern — this unit's own restart policy actually worked as designed (`restart counter is at 5) and only stopped because it exhausted systemd's StartLimitBurst` retrying a genuinely-corrupt file, not because of a missing policy.

Investigation before fixing

Checked whether this instance was actually load-bearing before deciding whether to repair or remove: - /var/snap/prometheus/current/prometheus.yml was the **untouched default template** — job_name: prometheus, `targets: ["localhost:9090"]` — it had never been configured to scrape anything real. - The fleet's actual Prometheus lives elsewhere: docs/pve-exporter.md and inventory/hosts-config.yaml both point to a real, configured instance on the grafana LXC (192.168.42.119:9090, added 2026-07-10) — the one genuinely scraping PVE stats, CrowdSec metrics, etc. - Nothing in the repo (Caddyfile, hosts-config.yaml, any doc) referenced developer-env's own :9090 for anything. Conclusion: this was leftover exploration cruft, superseded by the real instance, contributing nothing. Not worth repairing corrupted storage for a service that was never doing real work.

Fix

sudo snap remove prometheus
Confirmed fully gone: snap list prometheus → no matching snaps, /etc/systemd/system/snap.prometheus.prometheus.service and /var/snap/prometheus both removed, nothing listening on 9090. Note: systemctl --failed may keep showing a stale ghost entry (Loaded: not-found) for the removed unit until the next sudo systemctl daemon-reload or reboot — cosmetic only, not evidence anything is still running. Claude's own session on developer-env doesn't have passwordless sudo, so this and the snap remove itself both needed MOS to run directly.

Prevention / notes for next time

- Before repairing a corrupted service, check whether it was ever doing real work in the first place — an unconfigured default-template install is cheaper to remove than to fix, especially once a real instance already exists elsewhere in the fleet. - This is a good instance of "found while looking for something else" — worth keeping the habit of a quick failed-unit sweep as a side effect of any fleet-wide systemd investigation, not just the specific pattern being hunted.
developer-env web/app.py hung — accepts connections, never responds medium
developer-env flask networking   last seen:

Symptom

curl http://localhost:5000 on developer-env accepts the TCP connection, receives the request, then returns zero bytes and times out. Discovered 2026-07-12 while validating Caddy's developer-env:5000 backend after a Headscale ACL change — unrelated to that change; this process had already been running 8+ days without actually serving requests.

Details

- Process: /home/mos/projects/homelab/proxmox-inventory/.venv/bin/python web/app.py, PID 1475 - Listening on 0.0.0.0:5000 (confirmed via ss -tlnp), process alive (confirmed via ps — 8 days 6+ hours uptime), but the Flask event loop is not producing responses — classic hung-application signature (deadlock, stuck lock, or silent exception loop), not a crash. - curl -v shows the connection accepted, request sent in full, then "Operation timed out after 5002 milliseconds with 0 bytes received" — confirms the hang is inside the app, not at the TCP/network layer. - Not the same service as wiki.compellinglylowbrow.org, which runs on port 5001 per caddy/Caddyfile — that one has a documented static fallback for exactly this kind of outage. Port 5000's purpose isn't yet identified in this file — needs a look at web/app.py to confirm what it's for and whether anything depends on it being up.

Status

Not yet fixed. systemctl restart (or find & kill the relevant process and relaunch) untested — likely resolves it, but worth checking why it hung before just restarting blind, in case it recurs.
gen-runbook's generated runbook.sh had a syntax error whenever any hosts-config.yaml step desc: contained an apostrophe medium
Generated collected/runbooks/YYYYMMDD-group1.sh fails 'bash -n' with 'syntax error near unexpected token' pointing at an echo or _log line • A pre_update/post_update/verify desc: field containing a contraction ("it's", "gen-runbook's", "won't") breaks the runbook it's embedded in
gen-runbook shell-quoting group1 runbook   last seen: 2026-08-08

Symptoms

  • Generated collected/runbooks/YYYYMMDD-group1.sh fails 'bash -n' with 'syntax error near unexpected token' pointing at an echo or _log line
  • A pre_update/post_update/verify desc: field containing a contraction ("it's", "gen-runbook's", "won't") breaks the runbook it's embedded in
See also: os-package-stable-since-age-gate, hosts-config-yaml-step-schema-gotchas

Symptom

Adding a Group 1 OS-packages post_update step with the desc text `"Check for pending reboot (informational only -- Group 1 never auto-reboots; gen-runbook's typed-confirm flow handles it)"` (note the apostrophe in "gen-runbook's") caused every generated collected/runbooks/*-group1.sh to fail bash -n with a syntax error at that line. Caught immediately by testing gen-runbook end-to-end against the real hosts-config.yaml entries (not just a synthetic/scratch config) before trusting the multi-item bundling change that introduced it — the earlier scratch-dir smoke test used fabricated desc text with no apostrophe and passed clean, which is exactly why it didn't surface then.

Root Cause

Every free-text desc: field pulled from hosts-config.yaml's pre_update/ post_update/verify steps was embedded directly into a literal single-quoted shell string via plain f-string interpolation, e.g. f"echo ' post: {desc}'" and f"_log 'ABORT: pre-update failed: {desc}'" — never passed through the sh_single_quote() helper that every cmd/condition string already goes through. A desc containing its own single quote closes the shell's quoting early, corrupting the rest of that line (and often subsequent lines, since bash's parser doesn't resync until it finds a legitimate close-quote somewhere further down the file). This is the exact same class of bug sh_single_quote()'s own docstring already documents for cmd/condition strings (see its "Bug history" note) — it just never got applied to desc strings, which nobody had needed an apostrophe in until now. Affected every echo/_log/_verify call site that interpolated desc raw: pre_update's echo + both its abort-log branches (condition and non-condition), post_update's echo + abort-log, and verify's _verify '{desc}' ... first argument + its abort-log. Markdown-only uses of desc (_build_item_detail_block) were never at risk — Markdown doesn't care about shell quoting.

Fix

Route every one of those desc interpolations through sh_single_quote() instead of a literal '...' wrap, e.g.:
# before
lines.append(f"echo '  post: {desc}'")

after

lines.append(f"echo {sh_single_quote(' post: ' + desc)}")
and similarly for the _log 'ABORT: ...: {desc}' and _verify '{desc}' ... call sites.

Verification

# 1. Syntax check against real hosts-config.yaml data (not synthetic) — this is

what actually caught the bug; a scratch-dir test with clean desc text will

NOT catch this class of bug.

python3 bin/gen-runbook # with a real pending -summary.txt in place bash -n collected/runbooks/<latest>-group1.sh

2. Adversarial regression test — desc text WITH an apostrophe in every step

type (pre_update, post_update, verify), confirmed bash -n clean:

python3 -c " from importlib.machinery import SourceFileLoader gr = SourceFileLoader('gr', 'bin/gen-runbook').load_module()

... build a synthetic host with pre_update/post_update/verify desc: fields

each containing an apostrophe, call gr.generate_sh(), bash -n the output

"
Also ran the full typed-REBOOT confirmation flow (added same day as this bug) through a mocked-ssh/mocked-curl harness covering all 4 branches (no reboot needed / NONINTERACTIVE escalate / operator confirms / operator declines) — all behaved correctly, unrelated to this specific bug but verified in the same pass.
GitHub MCP API strips execute bit on bin/ scripts after push medium
after git pull, bin/collect-homelab returns Permission denied • scripts in bin/ lost execute bit after push • bin/ scripts permanently show as modified in git status / VS Code, even after pulling
git github-mcp execute-bit bin   last seen: 2026-07-11

Symptoms

  • after git pull, bin/collect-homelab returns Permission denied
  • scripts in bin/ lost execute bit after push
  • bin/ scripts permanently show as modified in git status / VS Code, even after pulling

Cause

The GitHub MCP API (push_files / create_or_update_file) always uploads files as mode 100644 (non-executable), stripping the execute bit.

Fix

A post-merge hook on developer-env auto-restores execute bits after every git pull. If the hook isn't present:
chmod +x bin/collect-homelab bin/resolve-hosts bin/generate-hosts-yaml
git add bin/collect-homelab bin/resolve-hosts bin/generate-hosts-yaml
git commit -m "chore: restore execute bit on scripts"
git push

Hook setup (one-time on developer-env)

v1 of this hook only ran chmod +x in the working tree, never committing the bit change. That left every bin/ script touched via MCP push permanently showing as modified in git status/VS Code until someone manually committed the chmod. v2 closed that loop: it chmods, stages, and commits the restoration itself, so git pull always leaves a clean tree. v3 (2026-07-11): v2 only scanned files under bin/ (grep '^bin/') — scripts pushed anywhere else (e.g. watchdog/memory-guardian-alert.sh) kept losing their execute bit silently, since the filter never matched them. v3 drops the directory filter entirely and instead detects "is this a script" the same way the OS does: does the file start with a shebang (#!)? This covers bin/, watchdog/, guardian/, macos/, and anywhere else a script might land in the future, with no directory allowlist to maintain.
cat > ~/projects/homelab/.git/hooks/post-merge << 'EOF'
#!/bin/bash

v3: detects scripts by shebang (#!) rather than by directory (bin/ only),

so execute bits get restored regardless of where a script lives in the repo.

changed=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD 2>/dev/null) to_fix="" for f in $changed; do [ -f "$f" ] || continue if head -c 2 "$f" 2>/dev/null | grep -q '^#!'; then to_fix="$to_fix $f" fi done if [ -n "$to_fix" ]; then chmod +x $to_fix git add $to_fix if ! git diff --cached --quiet; then git commit -m "chore: restore execute bit on scripts [post-merge]" --quiet git push --quiet echo "[post-merge] restored execute bits and committed:$to_fix" fi fi EOF chmod +x ~/projects/homelab/.git/hooks/post-merge
Note: this hook pushes automatically. That's intentional — it's a zero-content-decision commit (mode bits only), so auto-pushing keeps the tree clean without waiting on a human commit. If you ever see it push something unexpected, the commit message tag [post-merge] makes it easy to spot and revert in git log.

Also set globally

git config --global rebase.autoStash true
raw.githubusercontent.com returns 404 immediately after GitHub MCP push medium
curl from raw.githubusercontent.com returns 404 page • script fails with 'line 1: 404:: command not found' • file is visible in repo browser but not via raw URL
github cdn deployment proxmox-nuc   last seen: 2026-06-25

Symptoms

  • curl from raw.githubusercontent.com returns 404 page
  • script fails with 'line 1: 404:: command not found'
  • file is visible in repo browser but not via raw URL

Cause

GitHub's raw content CDN has propagation lag — a file pushed via the API may not be available at raw.githubusercontent.com for 30-60+ seconds or more.

Diagnosis

head -3 /root/<script>

Shows HTML or "404: Not Found" → curl fetched the 404 page

Fix

Write the script directly via heredoc instead of curl:
cat > /root/<script>.sh << 'HEREDOC'
#!/usr/bin/env bash

... paste full script content ...

HEREDOC chmod +x /root/<script>.sh head -3 /root/<script>.sh # verify

Prevention

For proxmox-nuc deployments during the same session as a GitHub push, always use heredoc. For deployments in a later session (hours after push), curl is reliable.
grafana apt upgrade: three stacked failures (stdin → disk-full → plugins-bundled) medium
ntfy alert: Update FAILED: grafana • update_cmd exited 100 / dpkg returned an error code (1) • dpkg-preconfigure: unable to re-open stdin (stage 1)
grafana apt dpkg disk-full lxc plugins-bundled update-apt pct-resize undersized   last seen: 2026-07-28

Symptoms

  • ntfy alert: Update FAILED: grafana
  • update_cmd exited 100 / dpkg returned an error code (1)
  • dpkg-preconfigure: unable to re-open stdin (stage 1)
  • cannot copy extracted data ... failed to write (No space left on device) (stage 2)
  • mv: cannot overwrite '/var/lib/grafana/plugins-bundled': Directory not empty (stage 3)

Summary

On 2026-07-28 a single grafana point upgrade (13.1.0 → 13.1.1, LXC 101 on proxmox-nuc) failed three separate ways in sequence. Each failure masked the next: fixing one only exposed the one behind it. Recorded together because that layering is the whole lesson — a mid-upgrade abort leaves the package half-configured, and "the error changed" is progress, not a new unrelated bug. The three stages, in the order they surfaced:

Stage 1 — debconf preconfigure, no stdin (the automated failure)

The scheduled auto-update died with dpkg-preconfigure: unable to re-open stdin / exit 100, before unpacking. Cause + fix are their own entry: known-fixes/update-apt-dpkg-preconfigure-stdin.md (fixed centrally in bin/update-apt, commit 236d0234). Running the upgrade by hand has a real stdin, so it sails past stage 1 — which is exactly how stage 2 got exposed.

Stage 2 — rootfs out of space during unpack

cannot copy extracted data for './usr/share/grafana/bin/grafana' to
'.../grafana.dpkg-new': failed to write (No space left on device)
The LXC rootfs (/dev/loop8) was a 4G disk at 93%, ~268M free. dpkg unpacks the new ~300M grafana payload as .dpkg-new files *alongside* the existing ones before swapping, so a point release needs a few hundred MB of transient headroom even though the net change is ~1.4MB. Cache-trimming does NOT fix this: journalctl --vacuum-size=50M freed ~160M (268M → 428M) and it still failed — the transient peak is bigger than that. The durable fix is to grow the disk from the host:
# on proxmox-nuc (live, non-destructive; runs resize2fs for you):
pct resize 101 rootfs +4G          # 4G → 8G
Note apt-get clean is counterproductive mid-incident: it frees the 300M cached deb but then apt-get upgrade re-downloads the same 300M. Grow the disk, then re-run.

Stage 3 — postinst can't replace a non-empty plugins-bundled dir

After the resize the unpack succeeded, and configure failed with:
mv: cannot overwrite '/var/lib/grafana/plugins-bundled': Directory not empty
dpkg: error processing package grafana (--configure): ... exit status 1
grafana's postinst refreshes /var/lib/grafana/plugins-bundled by mv-ing the newly-unpacked bundled set into place, and mv won't overwrite a non-empty directory. The stale dir held the old bundled plugins (elasticsearch, zipkin, a zero-byte .platform-*.stamp), owned grafana:grafana. plugins-bundled is grafana's built-in plugins, repopulated from the package during configure — it is NOT /var/lib/grafana/plugins (manually-installed plugins, a different dir). Clearing it is safe; the authoritative copy is sitting in /usr/share/grafana/plugins-bundled (the unpacked payload):
mv /var/lib/grafana/plugins-bundled /var/lib/grafana/plugins-bundled.old
dpkg --configure grafana          # resumes the interrupted configure

verify BEFORE cleanup:

grafana --version # 13.1.1 curl -s http://localhost:3000/api/health # "database": "ok" dpkg -l grafana | tail -1 # must be 'ii', not 'iF' rm -rf /var/lib/grafana/plugins-bundled.old apt-get clean
dpkg -l showing ii (not iF = half-configured) is the real completion gate — don't trust the upgrade as done until you see it.

Two standing facts this incident nailed down

1. This LXC is undersized on both axes. Same root cause both times: it was spec'd for grafana alone, then grew Prometheus + pve-exporter onto it. RAM went 512M → 2048M on 2026-07-10 (see known-fixes/lxc-memory-ceiling-swap-starvation.md); disk went 4G → 8G on 2026-07-28 (this entry). Treat "needs headroom on both axes" as settled, not something to rediscover a third time. 2. Stage 3 will recur on every future grafana point upgrade here. plugins-bundled has been a populated real directory (not the symlink layout) since the Jun 30 build, which is precisely the state that trips this postinst. Until/unless it's converted to a symlink, the `mv aside → dpkg --configure grafana` dance is the *expected* recurring remediation, not a one-off.

Related

- known-fixes/update-apt-dpkg-preconfigure-stdin.md — stage 1 in full. - known-fixes/lxc-memory-ceiling-swap-starvation.md — the RAM axis of the same undersizing pattern on this LXC. - Possible update-apt hardening discussed but not yet built: a free-space pre-flight (require free ≥ ~3× deb size, since the transient peak exceeds apt's reported delta) and a dpkg --audit guard (refuse to upgrade on top of an already-broken state). Revisit if this class recurs.
qBittorrent WebUI, qui, and Grafana all showed different transfer speeds -- three independent, correct explanations, no actual bug medium
qbittorrent qui grafana prometheus units diagnosis   last seen:

Symptom

A/B watching qBittorrent's own WebUI against qui (the multi-instance dashboard, LXC 111) during real transfers showed different numbers in three separate ways, investigated across one session: (1) suspected unit mismatch, (2) qui showing higher peaks than qBittorrent's own WebUI, (3) Grafana showing lower, smoother speeds than both.

Root cause -- three independent mechanisms, not one bug

1. Units: both use KiB/s (binary/IEC), confirmed identical. qBittorrent's WebUI has always used binary units (1024-based), correctly labeled KiB/s/ MiB/s -- no bits option, no way to misconfigure it. qui's formatter (web/src/lib/speedUnits.ts) defaults to the same math, KiB/s/MiB/s, but has a per-browser toggle (small button in the status bar, state persisted in localStorage under qui-speed-units) that switches to bits mode -- decimal (1000-based), ×8 multiplier, labeled Mbps. Easy to click by accident. Not what was happening here (both were confirmed already in KiB/s before the next two issues surfaced), but worth checking first on any future qui/qBittorrent speed complaint. **2. qui's Dashboard page and its torrent-list footer read two different qBittorrent-reported values, and they're not the same computation.** Traced via autobrr/qui's own source (gh search code against the repo): - The torrent-list page's bottom status bar (where the KiB/s↔Mbps toggle lives) reads serverState.dl_info_speed straight from qBittorrent's /sync/maindata -- the exact field the WebUI itself displays (web/src/lib/scoped-speeds.ts, resolveFooterSpeeds(), non-aggregate branch). - qui's Dashboard page instead shows stats.totalDownloadSpeed, which qui's own Go backend computes by summing every individual torrent's dlspeed field (internal/qbittorrent/sync_manager.go, calculateStats(): stats.TotalDownloadSpeed += int(torrent.DlSpeed)). Summing N independently-smoothed per-torrent rate counters is not the same number as qBittorrent's own single smoothed session-wide counter -- during a burst, the summed figure can transiently read higher. Neither number is wrong; they're different, legitimate computations of "current total speed." Confirmed only one qBittorrent instance is registered in this qui install (queried instances table in qui's own sqlite DB directly), so this wasn't a multi-instance aggregation artifact. **3. Grafana's number is a rate() over a 30s-scraped cumulative counter, not a live reading at all.** /opt/prometheus/prometheus.yml on the grafana LXC scrapes qui's own /metrics endpoint (QUI__METRICS_PORT=9074) every 30s (global scrape_interval). That endpoint exports only cumulative byte counters (qbittorrent_{alltime,session}_{download,upload}_bytes) -- no speed gauge at all. The "qBittorrent" Grafana dashboard's "Upload / Download Throughput" panel (UID advpkxq) computed rate(qbittorrent_alltime_upload_bytes[5m]) -- a 5-minute rolling average sampled only every 30s. Any burst shorter than that window gets averaged away almost entirely, so the panel reads visibly lower and smoother than either live UI. This is expected Prometheus counter/rate() behavior, not a misconfiguration -- confirmed live: MOS reported Grafana reading lower and smoother, matching the prediction made from the config alone before checking.

Fix

Not a "fix" for #1/#2 -- both are working as designed; documented here so the next A/B comparison isn't re-litigated from scratch. #3 got an actual new capability: built bin/qbittorrent-speed-exporter.py (deployed to the qbittorrent LXC itself, port 9075, systemd unit bin/qbittorrent-speed-exporter.service), which logs into qBittorrent's own WebUI and republishes dl_info_speed/up_info_speed as native Prometheus gauges (qbittorrent_download_speed_bytes, qbittorrent_upload_speed_bytes) -- the same instantaneous numbers the WebUI itself shows, no rate()/ derivative math needed on the Grafana side. Added as a new Prometheus job (qbittorrent-speed, 5s scrape_interval, tighter than the 30s global default) in prometheus.yml. The "Upload / Download Throughput" panel's two queries were repointed at the new gauges instead of the old rate(...alltime...[5m]) exprs. Bug found and fixed while building the exporter: the first version's login logic assumed the older qBittorrent WebUI API convention (200 OK with body "Ok."/"Fails." distinguishing success/failure). Confirmed via direct curl -i against /api/v2/auth/login with both the real password and a deliberately wrong one that this qBittorrent version instead always returns 204 No Content + a Set-Cookie, for correct AND incorrect credentials alike -- the login call alone can't tell you whether it worked. Fixed to treat the POST as provisional and validate the session on the next real call (/transfer/info); a 403 there (even after one re-login retry) is now what actually means "check the credentials," with a clear error message pointing at /etc/default/qbittorrent-speed-exporter rather than a misleading "login rejected" on a login that actually succeeded. Credentials handled the same way as every other secret in this repo: never typed into a Claude session, MOS edited /etc/default/qbittorrent-speed-exporter (root:root, mode 0600, EnvironmentFile= in the systemd unit) directly via sudo nano over SSH, out of band. Full architecture notes: INFRASTRUCTURE.md's qbittorrent LXC entry.
Grafana dashboard edits silently failed to save -- stuck session token, not a save-mechanism bug medium
grafana session auth diagnosis   last seen:

Symptom

Editing a panel's query in the Grafana UI (dashboard "qBittorrent", UID advpkxq) and clicking Save appeared to do nothing -- no visible error, no confirmation, and the change wasn't persisted. Looked at first like a save-mechanism bug (worth suspecting Grafana 13.1.1's newer unified-storage dashboard schema, or a permissions issue), or like the change needed to be made directly against the backend instead.

Root cause

journalctl -u grafana-server showed the session had been returning 401 on essentially every API call for roughly two hours before the save attempt -- /api/ds/query, /api/annotations, the live-data websocket, all of it -- each with error="[session.token.rotate] token needs to be rotated". The dashboard editor UI still rendered normally (mostly cached client-side state), so nothing *looked* broken until an actual write was attempted and silently hit the same 401 wall. Grafana's rotating session-token refresh cycle (auth.token_rotation_interval_minutes) had desynced at some point -- exact trigger not identified (a long-open browser tab, laptop sleep, or a VPN blip are the usual causes for this class of Grafana bug) -- and once that happens the session can't self-heal; it just fails every subsequent request the same way until a fresh login replaces the stuck token. Confirmed via `journalctl -u grafana-server --since '2 hours ago' | grep "token needs to be rotated"` -- if that string is present in the window around a "changes aren't saving" report, this is almost certainly it, not a save-mechanism or permissions problem.

Fix

Log out and back in. That mints a fresh session token and the stuck-401 loop clears immediately -- no backend change needed. Confirmed working same-day: re-login, then the same panel edit (queries repointed at qbittorrent_upload_speed_bytes/qbittorrent_download_speed_bytes, see known-fixes/grafana-qbittorrent-speed-mismatch-derived-vs-gauge.md) saved on the first try and was verified present in grafana.db's resource table afterward. If a re-login somehow doesn't clear it, the next step would be an authenticated API edit (/api/dashboards/uid/... GET + /api/dashboards/db POST) using a short-lived service-account token rather than hand-editing grafana.db's dashboard storage directly -- that table is versioned internally (separate resource_history/resource_version tables back it), so a raw SQL write risks desyncing Grafana's own consistency tracking. Not needed this time.
single-shot _verify aborts a healthy Group 1 update on a transient full-stack blip medium
gen-runbook group1 update-pipeline headscale verify   last seen:
See also: group1-preflight-false-abort

Symptom

The headscale v0.29.2→v0.29.3 Group 1 update installed cleanly, restarted, then aborted at its first verify step:
  verify: Health endpoint returns pass
  FAILED: expected '{"status":"pass"}' in output
  got:
[ABORT: verify failed: headscale — Health endpoint returns pass]
Empty output — not a wrong status. The update had already applied; headscale was active (running) on v0.29.3, the tailnet had reconverged (all nodes reconnected in the journal), and a manual curl of the same FQDN seconds later returned {"status":"pass"} / 200. cscli decisions list showed no active decision — NOT a CrowdSec self-bounce.

Root cause

The verify command was curl -sf https://headscale.compellinglylowbrow.org/health with run_from: local — deliberately full-stack (DNS → Caddy → CrowdSec → TLS → headscale), per the hosts-config.yaml schema note that Group 1 verifies should prove the whole chain, not just that a process bound its port. That intent is correct. The bug was that _verify (in bin/gen-runbook's emitted runbook) ran exactly ONE probe: curl -sf returns empty on any transient hiccup, and the probe fired into the brief restart window before the chain fully settled. A single all-or-nothing probe of a multi-hop path will occasionally catch a momentary gap and abort a genuinely-successful update.

Fix

In bin/gen-runbook (commit 2e5d785), _verify now wraps the probe in a bounded retry (default 8 attempts × 2s = ~16s grace, overridable via _VERIFY_MAX_ATTEMPTS / _VERIFY_RETRY_SLEEP):
_verify() {
    local desc="$1" cmd="$2" expected="$3" run_from="$4" user="$5" host="$6"
    echo "  verify: $desc"
    local out attempt=0
    while : ; do
        if [ "$run_from" = "local" ]; then out=$(eval "$cmd" 2>&1) || true
        else out=$(_ssh "$user" "$host" "$cmd" 2>&1) || true; fi
        if [ -z "$expected" ] || echo "$out" | grep -qF "$expected"; then break; fi
        attempt=$((attempt + 1))
        if [ "$attempt" -ge "$_VERIFY_MAX_ATTEMPTS" ]; then
            echo "  FAILED after ${_VERIFY_MAX_ATTEMPTS} attempts: expected '$expected' in output"
            echo "  got: $out"; return 1
        fi
        sleep "$_VERIFY_RETRY_SLEEP"
    done
    echo "  ✓ $out"
}
Happy-path and genuine-failure behavior are unchanged (first match returns immediately; a real failure emits the same message + return 1 after exhausting retries). Same lesson already applied one layer down in grafana's and ntfy's post_update poll-instead-of-sleep loops.

Deliberately NOT done

Retargeting the verify to headscale's LAN :8080/health (bypassing Caddy/CrowdSec/DNS) was considered and rejected: the full-stack test is intentional for Group 1 (it IS the chain). The fix is to tolerate a transient blip, not to test less.

Applies to

Runbooks generated AFTER 2e5d785. Existing runbook .sh files have the old single-shot _verify baked in — regenerate via bin/gen-runbook to pick up the retry.

Latent gap noted (not fixed here)

expect_exit_zero-only verify steps (empty expected string, e.g. headscale's "Node count unchanged") succeed as long as the command produces ANY output — the count is never actually compared. Preserved as-is by this change; worth a separate fix.

Also observed: group1-preflight false-FAIL on developer-env :5000 (2026-08-03)

Same class, one layer earlier in the pipeline. bin/updates/group1-preflight wraps bin/health-check, whose backend probe curls each Caddy backend once; during the 2026-08-03 item-7 deploy session it returned a transient 000/FAIL against developer-env's :5000 proxmox-inventory dashboard, which was healthy seconds before and after — the same single-shot-probe-catches-a-momentary-gap failure as _verify above, just in the preflight's health-check rather than in the emitted runbook. Not chased, and not a real fault: if a future Group 1 preflight aborts on developer-env :5000 *alone* — nothing else FAILing — the right move is re-run, not investigate. This is the group1-preflight-false-abort sibling the frontmatter related: points at.
Headplane returns 404 / ECONNREFUSED to Headscale API medium
Headplane UI returns 404 • Cannot connect to Headscale API • ECONNREFUSED 127.0.0.1:8080
headplane headscale docker config   last seen: 2026-06-06

Symptoms

  • Headplane UI returns 404
  • Cannot connect to Headscale API
  • ECONNREFUSED 127.0.0.1:8080

Causes

1. Missing /var/lib/headplane mount — Headplane requires a persistent data directory. 2. url: http://127.0.0.1:8080 — Docker containers can't reach 127.0.0.1 on the host. Must use the LXC's LAN IP. 3. cookie_secret not quoted — must be a quoted string in YAML.

Correct `/etc/headplane/config.yaml`

headscale:
  url: http://192.168.42.177:8080    # LAN IP, NOT 127.0.0.1
  api_key: <key>
server:
  host: 0.0.0.0
  port: 3000
  cookie_secret: "<32-char-secret>"  # must be quoted
  base_url: https://headplane.compellinglylowbrow.org

Correct docker run command

docker stop headplane && docker rm headplane
mkdir -p /var/lib/headplane

docker run -d \
  --name headplane \
  --restart unless-stopped \
  -p 3000:3000 \
  -v /etc/headplane:/etc/headplane \
  -v /var/lib/headplane:/var/lib/headplane \
  -v /etc/headscale/config.yaml:/etc/headscale/config.yaml \
  -v /var/run/headscale/headscale.sock:/var/run/headscale/headscale.sock \
  ghcr.io/tale/headplane:latest

Verify

docker logs headplane 2>&1 | tail -5
curl -v http://192.168.42.177:3000/admin/

Should return 302 redirect to /admin/machines

Headplane Docker container doesn't come back after headscale LXC reboot medium
headplane.compellinglylowbrow.org returns 502 • docker ps -a shows headplane Exited (127) after a host/LXC reboot • restart policy is unless-stopped but container is not running
docker headplane headscale reboot restart-policy systemd   last seen: 2026-07-10

Symptoms

  • headplane.compellinglylowbrow.org returns 502
  • docker ps -a shows headplane Exited (127) after a host/LXC reboot
  • restart policy is unless-stopped but container is not running
  • headscale itself (systemd, port 8080) is healthy -- only headplane (docker, port 3000) is down
See also: client-tailscale-session-down-blocks-fqdn

Cause

Headplane runs as a Docker container (ghcr.io/tale/headplane:latest) on the headscale LXC (192.168.42.177), proxied by Caddy at headplane.compellinglylowbrow.org -> 192.168.42.177:3000. It is separate from the headscale systemd service (port 8080) -- a reboot of the LXC can leave Headplane down while Headscale itself comes back up clean, since they have entirely different startup paths. First seen 2026-07-04: the container received a clean SIGTERM at the LXC reboot ([server] INFO: Received SIGTERM, shutting down... in docker logs), and despite RestartPolicy: unless-stopped, it did not come back on its own. docker ps -a showed Exited (127) -- a race between Docker's restart-on-boot logic and Docker daemon/network startup, not a code or config problem, since a manual docker start afterward came up immediately with no errors. Confirmed recurring 2026-07-10: happened again on a second headscale LXC reboot, same symptoms, same manual fix worked. This resolves the "is this a one-off" open question from the first writeup -- it recurs, and unless-stopped alone is not sufficient at boot on this host.

Diagnosis

ssh headscale "sudo ss -tlnp | grep 3000"          # nothing listening = down
ssh headscale "docker ps -a | grep headplane"       # check Exited state/code
ssh headscale "docker logs headplane --tail 50"     # look for SIGTERM near reboot time, then a gap
ssh headscale "docker inspect headplane --format '{{.HostConfig.RestartPolicy.Name}}'"

Manual fix (fallback, if the systemd unit below is ever missing/disabled)

ssh headscale "docker start headplane"
ssh headscale "docker logs headplane --tail 20"   # confirm clean startup, no immediate exit
Then verify: https://headplane.compellinglylowbrow.org should load without a 502.

Permanent fix (2026-07-10) -- systemd unit, survives reboot

Rather than relying on Docker's restart policy racing against Docker daemon/network startup at boot, an explicit systemd unit now starts the container after both docker.service and network-online.target are up:
ssh headscale "sudo tee /etc/systemd/system/headplane-start.service" <<'EOF'
[Unit]
Description=Ensure headplane docker container is running
After=docker.service network-online.target
Requires=docker.service
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/bin/docker start headplane
RemainAfterExit=yes
ExecStartPre=/bin/sleep 5

[Install]
WantedBy=multi-user.target
EOF

ssh headscale "sudo systemctl daemon-reload && sudo systemctl enable headplane-start.service"
Confirmed working 2026-07-10: LXC rebooted, headplane-start.service fired cleanly (ExecStartPre=sleep 5 succeeded, docker start headplane succeeded), and docker ps -a showed the container Up ... (healthy) without any manual intervention. This is the recommended fix going forward -- the container's own unless-stopped policy is left in place as a secondary safety net but is no longer the primary boot-recovery path.

Automated diagnosis (2026-07-06)

The watchdog daemon still matches this pattern via watchdog/playbooks/headplane-docker-not-restarting.yaml (playbook_status: diagnostic_only) as a safety net in case the systemd unit above is ever missing, disabled, or fails: it fingerprints on Headscale being healthy while the Headplane container isn't in the running state, runs the same diagnosis commands as above automatically, and sends an ntfy escalation with the exact fix command from recovery_path. With the systemd unit now in place, this playbook should fire far less often -- if it fires again, check systemctl status headplane-start.service on the headscale LXC first, since that likely means the unit itself failed or was disabled.

Resolved: open question / follow-up

~~unless-stopped should have brought this back automatically... not yet confirmed whether this recurs on every proxmox-nuc reboot~~ -- **Resolved 2026-07-10**: it does recur, confirmed on a second reboot. The systemd unit above is the fix; unless-stopped alone is not trusted for boot-time recovery on this host going forward. Fleet-wide sweep: bin/docker-exited-sweep checks for this same failure mode on *any* Docker container across the fleet, not just Headplane. Note: this script was referenced here before it actually existed in the repo (a documentation-ahead-of-reality gap caught 2026-07-10) -- it has now been written and committed. Run it manually after any host/LXC reboot:
python3 bin/docker-exited-sweep
It walks every host in inventory/hosts.yaml via their ssh aliases, skips hosts without Docker installed, and reports any container in Exited state elsewhere. Worth considering the same systemd-unit pattern for any other container this sweep turns up with the same unless-stopped-doesn't-survive-boot behavior.
Headscale ACL retry, 2026-07-12 — tag-scoped baseline rule matches nobody if no nodes are tagged medium
headscale acl dns networking   last seen:

Symptom

Deploying a tightened Headscale ACL causes total loss of FQDN access (*.compellinglylowbrow.org), both on-LAN and remote. Happened 2026-07-03 (reverted, twice) and initially appeared to happen again 2026-07-12 before being traced to test-methodology issues instead (see below) — the design itself was correct on the first properly-tested attempt.

Root cause (working theory for the 2026-07-03 failures)

headscale nodes list on 2026-07-12 showed **zero nodes carrying any Headscale tag** — the Tags column was empty across the board. If a policy scopes the caddy:443 baseline rule to a tag: selector (e.g. "src": ["tag:trusted"]) rather than "src": ["*"], and no node has ever actually been tagged, that rule matches nothing. Once acls[] is non-empty, Headscale's default flips from allow to deny — so the single most important rule (the front door) silently matches zero traffic, bricking every FQDN address at once. This explains a *total*, not partial, outage, and fits the historical pattern exactly.

Fix

- Declare bare node names in a hosts: block ("caddy": "100.64.0.4/32", etc.) and reference them directly in ACL rules — no tags needed at this node count. - The caddy:443 rule must use "src": ["*"], literally, not a tag. - Don't introduce a tagging scheme unless there's a concrete need for it; it's an extra step (headscale nodes tag) that's easy to forget and silently breaks the policy that depends on it. - AdGuard's wildcard rewrite for *.compellinglylowbrow.org answers with Caddy's Headscale IP even for on-LAN clients, so the caddy:443 rule governs ALL FQDN access, not just remote access — this is why a broken version of that one rule causes a total outage rather than a remote-only one.

Testing gotchas that caused false alarms during the 2026-07-12 retry

1. The headscale LXC itself is not a tailnet node (headscale_ip: ~ in hosts-config.yaml — it's the control server, never ran tailscaled). Testing FQDN reachability *from that box* always times out — no route to 100.64.0.4 — regardless of whether the ACL is correct. Must test from an actual tailnet member (developer-env, a personal device). 2. developer-env's systemd-resolved stub (see known-fixes/systemd-resolved-stale-cache.md and the open backlog item) intermittently serves the stale *public* DNS answer (Porkbun/DDNS IP, no port forward behind it) instead of forwarding to AdGuard. A plain curl https:// from developer-env can time out even when Caddy and the ACL are completely healthy. Bypass with curl --resolve :443:100.64.0.4 https://, or confirm the true answer first with dig @192.168.42.27 +short. 3. A redeploy's timestamp matters. Mid-session, a canary was run against the still-rolled-back (old) policy because the actual cp/systemctl restart redeploy hadn't happened yet when the test was run — both cp and the restart are silent on success, so always check systemctl status headscale | grep Active for a fresh timestamp before trusting a canary result. 4. macOS DNS caching can mask or reveal AdGuard-side inconsistencies — see known-fixes/adguard-rewrite-inconsistent-answer-for-seeder-daemon.md for a related, separate issue this surfaced.

Result

2026-07-12 retry, tested correctly (off-LAN, from real tailnet client devices), succeeded across all 6 rules once each individual gap was found and closed same-session: 1. Universal caddy:443 baseline (plus a same-day addition for 192.168.42.45:443 — see the AdGuard rewrite known-fix) 2. Personal-device DNS (needed both Headscale-IP and LAN-IP destinations — see rule 2's inline comments) 3. Caddy's reach to its 5 Headscale-IP-routed backends 4. developer-env's admin SSH to those same 4 hosts (minus itself) 5. Off-LAN SSH from the MacBook Air to developer-env 6. watchdog's Uptime Kuma probes to nastynas/pibox (see bin/setup-uptime-kuma.py's documented LAN-unreachable exceptions) Live file: /etc/headscale/acl.hujson on the headscale LXC. Tracked copy: headscale/acl.hujson in this repo (mirrors the watchdog/compose.yaml pattern) — new as of 2026-07-12.
Home Assistant Tailscale addon won't connect to Headscale medium
HA addon connects but never authenticates • changing login_server in addon config has no effect
headscale homeassistant tailscale state-file   last seen: 2026-06-03

Symptoms

  • HA addon connects but never authenticates
  • changing login_server in addon config has no effect

Cause

The addon caches the old server in its state file.

Fix

# From HA terminal / SSH addon
rm /mnt/data/supervisor/addons/data/a0d7b954_tailscale/tailscaled.state
Then restart the addon and set login_server to https://headscale.compellinglylowbrow.org.
HA Companion App hangs on iPhone (on/off LAN) — External URL bypasses Caddy via direct Headscale IP medium
Home Assistant Companion App won't load on iPhone, both on and off LAN • Safari via homeassistant.compellinglylowbrow.org works fine on the same iPhone • Direct LAN-IP access to HA (192.168.42.113:8123) works fine
headscale acl homeassistant ios companion-app silent-hang   last seen: 2026-07-16

Symptoms

  • Home Assistant Companion App won't load on iPhone, both on and off LAN
  • Safari via homeassistant.compellinglylowbrow.org works fine on the same iPhone
  • Direct LAN-IP access to HA (192.168.42.113:8123) works fine
  • Issue persists identically regardless of physical network (Wi-Fi vs cellular)

Cause

The HA Companion App has separate "Internal URL" and "External URL" settings and auto-detects which to use. On this iPhone: - Internal URL = LAN IP (192.168.42.113:8123) — bypasses Caddy/Headscale entirely, so the ACL tightening never affected it. - External URL = HA's own Headscale IP directly (100.64.0.110:8123, from HA's own Tailscale add-on identity — see known-fixes/headscale-ha-addon-wont-connect.md) — not the FQDN, not routed through Caddy at all. homeassistant was completely absent from headscale/acl.hujson's hosts{} block — not declared, not a dst in any rule. Same missing-host silent-hang pattern already confirmed for proxmox-nuc, seeder-daemon, and lyrionmusicserver, just triggered this time by a personal device's app configuration rather than a script or SSH alias. Because the App's direct-Tailscale connection landed on a host the policy never mentioned, it hung under implicit-deny — indistinguishable at first glance from a dead host, a stale IP, or a DNS problem. Safari via the FQDN worked fine (covered by rule 1's universal caddy:443 baseline); the Companion App did not (goes direct, covered by nothing). The "same behavior on and off LAN" symptom is the signature of this pattern: the app's connection is Tailscale-mediated regardless of physical network, so LAN/WAN distinctions don't apply.

Diagnosis

1. Confirm Safari via the FQDN works normally (rules out Caddy/DNS/ACL for the FQDN path). 2. Confirm direct LAN-IP access works normally (rules out HA itself being down). 3. Check the Companion App's server settings (Settings → Companion App → General → server section) for Internal URL / External URL. If External URL is a raw Headscale IP (100.64.0.x) rather than the FQDN, that's the direct-Tailscale path. 4. Check whether the target host is declared in headscale/acl.hujson:
ssh developer-env
grep -A2 '"hosts"' -A40 headscale/acl.hujson | grep homeassistant
If it's absent from both hosts{} and every rule's dst, that confirms the silent-hang pattern.

Fix

Add the host to hosts{} and grant the specific device direct reach to the port the app actually uses:
"homeassistant": "100.64.0.110/32"   // added to hosts{}
{
  // Rule 9: HA Companion App's "External URL" connects directly via
  // Tailscale IP (100.64.0.110:8123), bypassing Caddy entirely -- not
  // just control/browsing via homeassistant.compellinglylowbrow.org
  // (rule 1). homeassistant was never declared in this policy at all
  // (not in hosts{}, not a dst anywhere) -- same missing-host pattern
  // as seeder-daemon/lyrionmusicserver, this time triggered by a
  // personal device's app config rather than a service dependency.
  "action": "accept",
  "src": ["iphone"],
  "dst": ["homeassistant:8123"]
}
Deploy per the standard checklist in CLAUDE.md / INFRASTRUCTURE.md: git pull on developer-env → scp to the headscale LXC → `headscale policy check -fsystemctl restart headscale`. Then force the Companion App to reconnect (background/foreground the app, or toggle it) so it retries the External URL. Status: fixed and deployed 2026-07-16homeassistant added to hosts{}, rule 9 added granting iphone (only — no HA app/widget on the MacBook Air) direct reach to port 8123.

Broader lesson

Any personal device app with its own "internal/external" or Tailscale-aware connection setting is a potential undeclared ACL dependency, distinct from the service's normal Caddy/FQDN path. When onboarding or troubleshooting an app like this, check its network settings for direct Headscale-IP usage in addition to the usual Caddyfile/DNS checks — the FQDN path being healthy doesn't guarantee every connection path the app might use is also covered.

Unresolved side note

While diagnosing this, Qui (qui.compellinglylowbrow.org) was also reported briefly unreachable on the same iPhone, then started working again mid-session without any change on our end. Root cause not identified — possibly a transient iPhone-side DNS/connection cache expiring. Not connected to this fix as far as we can tell (Qui has no Companion-App-style direct-Tailscale connection setting), but worth a second look if it recurs.
Node won't connect — stale Tailscale state file medium
tailscale up runs without error but node never appears in Headscale • node does not show after tailscale up
headscale tailscale state-file   last seen: 2026-06-03

Symptoms

  • tailscale up runs without error but node never appears in Headscale
  • node does not show after tailscale up

Cause

Old Tailscale state file conflicts with new login server.

Fix

sudo rm /var/lib/tailscale/tailscaled.state
sudo systemctl restart tailscaled
sudo tailscale up --login-server https://headscale.compellinglylowbrow.org --auth-key <key>
health-check FAILs on adguard recovery-hatch checks that expect a removed LAN-IP hatch medium
health-check adguard dns group1 update-pipeline   last seen:
Root cause of: group1-preflight-false-abort
See also: health-check-stale-hardcoded-lists.md, adguard2-mismatched-recovery-hatch-rewrite.md

Symptom

A Group 1 runbook (collected/runbooks/20260729-060131-group1.sh) aborted at group1-preflight on an otherwise-healthy chain. bin/health-check reported:
FAIL  adguard recovery hatch adguard.compellinglylowbrow.org → '100.64.0.4' (expected 192.168.42.27)
WARN  adguard missing adguard2.compellinglylowbrow.org recovery hatch (got '100.64.0.4', expected 192.168.42.89)
67 PASS, 1 WARN, 2 FAIL (the other FAIL was the separate caddy-validate env issue — see caddy-validate-missing-env-crowdsec-key.md). The single FAIL on check #3 flipped health-check's exit code to 1, which group1-preflight maps to abort-before-touching-adguard2.

Root cause

Stale check expectations, not infrastructure drift. On 2026-07-18 the primary's adguard.compellinglylowbrow.org direct-to-LAN-IP recovery hatch was deliberately removed and NOT reinstated (see adguard2-mismatched-recovery-hatch-rewrite.md and INFRASTRUCTURE.md's adguard entry): the hostname now falls through to the wildcard rule → 100.64.0.4 and routes through Caddy/CrowdSec like every other service. bin/health-check's check #3 was never updated — it still hard-expected 192.168.42.27 and emitted fail (while sibling hatch checks #4/#5 emitted warn). The live dig result (100.64.0.4) was the correct-by-design state; the check's expected value was the outlier. Check #4 (primary answering adguard2's hostname) was the same class — the primary was never meant to carry an adguard2 hatch; the meaningful adguard2 hatch is the self-hatch ON adguard2 (check #5, which passed). group1-preflight's own header comment already listed "a missing recovery-hatch DNS rewrite" as a WARN-level, non-blocking example — so check #3 emitting fail contradicted the wrapper's stated contract. This is the third instance of health-check expectations drifting from a live decision (see health-check-stale-hardcoded-lists.md for the first two: a decommissioned backend still checked, and personal-device node names that had been renamed in Headscale but not here).

Fix

In bin/health-check (commit 08c2292), checks #3 and #4 now expect the wildcard fallthrough and emit warn (not fail) on drift:
# 3. adguard's own hostname — no dedicated recovery hatch BY DESIGN (2026-07-18).
AG_RESULT=$(dns_query "$ADGUARD_IP" "adguard.${DOMAIN}")
if [[ "$AG_RESULT" == "100.64.0.4" ]]; then
    pass "adguard adguard.${DOMAIN} → 100.64.0.4 (wildcard fallthrough, by design)"
else
    warn "adguard adguard.${DOMAIN} → '${AG_RESULT}' (expected wildcard 100.64.0.4; a LAN-IP hatch here was removed 2026-07-18)"
fi
Check #4 mirrors this (expect 100.64.0.4, warn on drift) and the stale "add a rewrite" fix hint was dropped. Check #5 (adguard2 self-hatch → its own LAN IP) is unchanged — that hatch is real and intentional. Verified: re-run of the same runbook 2026-07-29 → preflight `PASS 70 WARN 0 FAIL 0`, proceeded into the headscale update.

Note (still open, deliberately untouched)

inventory/hosts-config.yaml's adguard verify: block STILL contains a remediate step (adguard recovery hatch resolves to its own LAN IP, expect_contains 192.168.42.27) reflecting the pre-2026-07-18 hatch. It didn't fire this session (verify remediates only run on a failed update-verify, not in health-check), but it's the same stale expectation and will mislead if a future adguard update-verify trips it. Reconcile when adguard is next updated.
health-check's caddy_curl() printed a stray duplicate '000' line for any 4xx/5xx or unreachable backend medium
bin/health-check backend-reachability section shows a lone '000' on its own line, right after a PASS/FAIL entry for a backend that returned 4xx/5xx or was unreachable • a genuinely-unreachable backend can show as PASS with 'HTTP 000' instead of FAIL -- this was a real accuracy bug, not cosmetic (see CORRECTION below)
health-check group1-preflight bash pipefail curl   last seen: 2026-07-12

Symptoms

  • bin/health-check backend-reachability section shows a lone '000' on its own line, right after a PASS/FAIL entry for a backend that returned 4xx/5xx or was unreachable
  • a genuinely-unreachable backend can show as PASS with 'HTTP 000' instead of FAIL -- this was a real accuracy bug, not cosmetic (see CORRECTION below)

CORRECTION (2026-07-12, same day as the original entry)

This entry originally said the bug was "cosmetic only" — that was wrong. Confirmed by comparing two live runs of bin/updates/group1-preflight before and after the fix below: stirling-pdf went from PASS stirling-pdf (192.168.42.141:8080) — HTTP 000 (buggy code) to FAIL stirling-pdf (192.168.42.141:8080) — no response (fixed code) for the identical backend state. The classification if block does an exact [[ "$HTTP_CODE" == "000" ]] match; a two-line value like "000\n000" fails that exact-equality test and falls through to the else (PASS) branch instead. So a fully unreachable backend could be silently reported healthy. See the Fix section below for the real mechanism.

Cause

caddy_curl()'s fallback was pipeline || echo "000", which fires based on the exit CODE of the whole ssh/curl/grep/head pipeline, not on whether grep actually found a match. curl -f (--fail) exits non-zero on any 4xx/5xx HTTP response even though it already wrote a valid 3-digit code via -w %{http_code} before erroring, and this script runs under set -o pipefail, so that non-zero exit propagated through the whole pipe. The result: grep's real output (e.g. "404") was already printed to stdout by the pipeline, and then || echo "000" ALSO fired afterward, appending a second, spurious "000" line. The caller captures this via HTTP_CODE=$(caddy_curl ...), so $HTTP_CODE ended up as a two-line string (e.g. "404\n000"), which displays as a stray 000 on its own line when echoed, AND breaks the exact-match classification logic described in the CORRECTION above. First surfaced during the initial live test of bin/updates/group1-preflight (new script wrapping bin/health-check, added the same day as part of the CI/CD integration plan step 2) -- visible as stray 000 lines after the headplane (404) and stirling-pdf (unreachable) backend entries.

Fix

Capture grep's output into a local variable — so only its actual stdout content matters, not the pipeline's exit code — and use ${code:-000}, a pure string-based fallback for "grep found nothing," instead of the exit-code-based ||. || true on the assignment itself keeps this script's own set -e from aborting on a single unreachable backend (same purpose the old || echo "000" served, just without conflating that guard with the fallback value).
caddy_curl() {
    local proto="$1" hostport="$2"
    local url="${proto}://${hostport}/"
    local flags="-sf --max-time 5 -o /dev/null -w %{http_code}"
    [[ "$proto" == "https" ]] && flags="-sf -k --max-time 5 -o /dev/null -w %{http_code}"
    local code
    code=$(ssh_cmd "$CADDY_IP" "curl ${flags} ${url}" 2>/dev/null \
        | grep -oE '[0-9]{3}' | head -1 || true)
    echo "${code:-000}"
}

Verify

Simulated both trigger scenarios (curl -f exiting non-zero on a 4xx response, and a totally unreachable backend) against the fixed function in isolation before pushing — both returned a clean single-line result (404 and 000 respectively, wc -l = 1 in both cases). Confirmed end-to-end same day via a real bin/updates/group1-preflight run on developer-env: stirling-pdf correctly flipped from a false PASS to a genuine FAIL (see CORRECTION above) with no stray line, and other backends (headplane at 404, etc.) showed clean single-line PASS/WARN entries.

Follow-up

None of the other places in bin/health-check that use $(... || true) have this bug — they either don't run through grep/head (e.g. the Headscale health check, which just greps the captured variable directly) or already guard the command substitution itself with || true rather than appending a bare fallback after the pipe. caddy_curl() was the only occurrence of the pattern. Fixing this surfaced that stirling-pdf doesn't actually exist in inventory/hosts-config.yaml anymore — see known-fixes/health-check-stale-hardcoded-lists.md for the follow-on fix (deriving health-check's expected-nodes/backends lists dynamically from hosts-config.yaml instead of hardcoded arrays, which removed stirling-pdf and several other stale entries in one pass).
bin/health-check FAIL misattributed to wiki-server -- was actually a different app (proxmox-inventory dashboard) on the same host medium
health-check developer-env port-confusion diagnosis caddy   last seen:

Symptom

bin/health-check --caddy FAILed developer-env (100.64.0.111:5000) -- "no response (backend down or wrong IP)". First assumption: this must be bin/wiki-server, the known bespoke Flask service on developer-env -- maybe hung, or blocked by ufw (developer-env's own default-deny policy requires an explicit allow rule per bespoke service).

Root cause

Two separate, unrelated Flask apps run on developer-env, both under systemd: wiki-server (unit wiki-server, port 5001 -- fine, actively serving) and a much older proxmox-inventory dashboard (unit proxmox-inventory, port 5000 -- the actual Caddy backend for developer-env.compellinglylowbrow.org, per caddy/Caddyfile and inventory/hosts-config.yaml). The assumption that "developer-env's one bespoke service" meant wiki-server was wrong and cost a wasted diagnostic pass (checking wiki-server's own logs/ufw/ports, all of which were fine because it was never the problem). Verified the real backend owner instead via ss -ltnp (which PID/process is actually listening on the port) cross-checked against /proc//cmdline -- this immediately showed two different PIDs on two different ports, settling it in two commands. The real port-5000 service (proxmox-inventory/web/app.py) turned out to be genuinely slow: ~6s per request (a full serial sweep of every configured Proxmox instance, compounded by macbookpro-pve being unreachable that same day -- see known-fixes/ for that decommission), past bin/health-check's 5s --max-time budget. Not a health-check false-positive.

Fix

Retired the proxmox-inventory dashboard entirely (superseded by Grafana) rather than chasing its speed -- systemd unit stopped/disabled, Caddy site block and hosts-config.yaml caddy: block removed. Full writeup: INFRASTRUCTURE.md's "API Access" section. bin/health-check clean afterward (0 FAIL, 0 WARN, 33/33 backends).

Generalizes to

Don't assume which process owns a port from memory or from "the one bespoke service I already know about on this host" -- ss -ltnp + `/proc// cmdline` settles port ownership in two commands and would have skipped the wrong first guess here. Matches this repo's existing "verify the real dispatch path before diagnosing" convention -- applies to port ownership on a host just as much as to config dispatch paths.
health-check's EXPECTED_NODES and backends arrays had drifted stale in multiple ways -- switched to deriving both from hosts-config.yaml medium
bin/health-check reports stirling-pdf as FAIL/not-found even though it was decommissioned (LXC 105 now hosts alpine-it-tools) • bin/health-check reports vaultwarden, alpine-it-tools, watchdog, raspi4, ntfy, uptime-kuma, and nas as Headscale 'orphans' or missing backend checks even though all are real, active, hosts-config.yaml-tracked services • bin/health-check permanently WARNs on raspi5, debian-test, debian-test-onboard -- none of which exist anywhere in hosts-config.yaml
health-check group1-preflight hosts-config drift inventory   last seen: 2026-07-12

Symptoms

  • bin/health-check reports stirling-pdf as FAIL/not-found even though it was decommissioned (LXC 105 now hosts alpine-it-tools)
  • bin/health-check reports vaultwarden, alpine-it-tools, watchdog, raspi4, ntfy, uptime-kuma, and nas as Headscale 'orphans' or missing backend checks even though all are real, active, hosts-config.yaml-tracked services
  • bin/health-check permanently WARNs on raspi5, debian-test, debian-test-onboard -- none of which exist anywhere in hosts-config.yaml
  • moslaptop (health-check's hardcoded spelling) / mospclaptop (hosts-config.yaml's headscale_devices spelling) / MOSLaptop (the live Headscale registration) are three different spellings of the same device -- still unresolved, see Follow-up

Cause

bin/health-check maintained two hardcoded arrays -- EXPECTED_NODES (Headscale node membership) and backends (Caddy backend reachability targets) -- as plain bash literals inside the script, completely separate from inventory/hosts-config.yaml (the repo's actual canonical source for "what exists"). The two lists inevitably drifted out of sync with reality: services got decommissioned or added in hosts-config.yaml without anyone remembering to also edit health-check's copies. Found while investigating the first live run of bin/updates/group1-preflight (new script wrapping health-check, part of the CI/CD integration plan): - stirling-pdf (old LXC 105) no longer exists in hosts-config.yaml at all -- LXC 105 is now alpine-it-tools. stirling-pdf was still hardcoded into both arrays, generating a permanent FAIL (backend) and WARN (Headscale "not found") for a service that's gone. - vaultwarden, alpine-it-tools, watchdog, raspi4, ntfy, uptime-kuma, and nas are all real, active hosts-config.yaml entries with their own caddy: blocks and/or headscale_ip -- none were ever added to health-check's hardcoded lists, so they generated permanent "orphan"/"not found" WARNs or were simply never checked as backends. - raspi5, debian-test, debian-test-onboard don't exist anywhere in hosts-config.yaml -- leftover names from earlier test/scratch work, generating WARNs for nothing. This is exactly the class of drift the repo's own CLAUDE.md warns about: enumerable, fast-changing facts belong in one live source, never copied into a second hand-maintained list.

Fix

Added bin/health-check-inventory, a small python3 helper that reads inventory/hosts-config.yaml directly and derives both lists on every run: - --nodes: every host across proxmox_nodes / lxc_containers / virtual_machines / standalone / headscale_devices with a non-null headscale_ip, deduplicated by IP. This dedup matters: ntfy and uptime-kuma are Docker containers on the watchdog host and share its headscale_ip rather than being separately-registered Headscale devices -- without dedup, both would generate permanent false "not found" WARNs since Headscale only ever sees watchdog as an actual tailnet member. First-seen-wins per IP naturally picks watchdog as the representative (section order in hosts-config.yaml lists it before ntfy/uptime-kuma). - --backends: every host with a non-null caddy: block (excluding status: planned entries like moode/fedora-server, which would only ever FAIL), resolving each backend IP in priority order: caddy.backend_ip override → lan_ipcollected/resolved-ips.env's HOST_ entry (for proxmox_api-sourced hosts with no static lan_ip, refreshed every 6h by bin/resolve-hosts). bin/health-check now calls this helper via mapfile instead of defining either array inline. Verification against the derived output caught one more real gap while building this fix: bentopdf's caddy: block in hosts-config.yaml was itself missing tls_backend: true -- the old hardcoded array had silently used https for it anyway (correctly, since the endpoint really is HTTPS), masking the gap. Fixed by adding the missing flag to hosts-config.yaml directly, so the derived output matches reality instead of the derivation introducing a new false FAIL.

Verify

Ran the new --nodes/--backends helper against a reconstructed local copy of the real hosts-config.yaml + resolved-ips.env before pushing: 27 expected nodes (down from 26 hardcoded names, net of removing 5 stale entries and adding 8 real ones, with watchdog correctly absorbing ntfy/uptime-kuma via dedup) and 28 backend rows (up from 24, adding vaultwarden, nas, ntfy, uptime-kuma; alpine-it-tools also new). Cross-checked every derived backend's protocol (http/https) against the old hardcoded array's values -- exact match except the bentopdf gap described above, which was fixed at the source rather than worked around. End-to-end mapfile wiring tested by invoking the real bin/health-check-inventory script from a test copy of bin/health-check. Real live confirmation on developer-env still pending as of this writing -- re-run bin/updates/group1-preflight (or bin/health-check directly) and confirm stirling-pdf no longer appears at all, and vaultwarden/ alpine-it-tools/watchdog/raspi4/ntfy/uptime-kuma/nas now show PASS/WARN entries instead of being silently absent or flagged as orphans.

Follow-up

Two residual naming mismatches this fix does NOT resolve, since they're data problems in the source itself, not code bugs: - The old hardcoded list spelled a personal-device entry moslaptop; hosts-config.yaml's headscale_devices section spells it mospclaptop; the live run's "orphan" warning suggests the actual Headscale-registered name is MOSLaptop (mixed case). Three different spellings of (presumably) the same device across three different systems. Needs a manual decision: either rename the live Headscale registration to match hosts-config.yaml, or update hosts-config.yaml to match whatever the live registration actually is. - An "orphan" named MacBook Air appeared in the live run with no obvious corresponding hosts-config.yaml entry (there's a separate macbook entry at a different headscale_ip -- unclear if these are the same physical device or two different ones). Needs manual investigation before it can be added to headscale_devices or safely ignored. Neither blocks anything -- both surface as ordinary WARNs (device-level, never FAIL), same as before this fix, just no longer mixed in with the stale-list noise this fix actually cleaned up.
homelab-switch: JetKVM-triggered forwarding wedge cleared by a power cycle, but the switch's own management IP never came back -- needed a factory reset medium
watchdog ntfy: 'Watchdog: manual intervention required -- Monitors down: Proxmox, nastynas — Web UI -- Pattern does not match any known failure mode' • nastynas and pibox unreachable on LAN (ping/SSH) while individually healthy -- nastynas still reachable and fine over its Headscale IP • every other LAN host (proxmox-nuc, watchdog, developer-env, and initially the switch's own management IP) stays reachable
network switch layer2 jetkvm management-ip dhcp-reservation caddy hardware omada factory-reset   last seen: 2026-08-22

Symptoms

  • watchdog ntfy: 'Watchdog: manual intervention required -- Monitors down: Proxmox, nastynas — Web UI -- Pattern does not match any known failure mode'
  • nastynas and pibox unreachable on LAN (ping/SSH) while individually healthy -- nastynas still reachable and fine over its Headscale IP
  • every other LAN host (proxmox-nuc, watchdog, developer-env, and initially the switch's own management IP) stays reachable
  • after power-cycling the switch: nastynas/pibox forwarding recovers, but the switch's management IP stops answering ping/curl entirely -- 'Destination Host Unreachable' (ARP failure), not a timeout
  • the switch's management IP doesn't appear in the gateway's wired-client list under any address
  • TP-Link's Omada Discovery Utility (L2 broadcast) can't find the device either
See also: shared-unmanaged-switch-wedge-multi-host-outage

Symptom

Same day a new JetKVM was cabled to nastynas (HDMI + USB) and used for several BIOS-key tests plus a Tailscale-on-JetKVM install (both of which reboot a device on the segment), a watchdog P5 alert fired for nastynas and "Proxmox" with no matching playbook. Diagnosis found two distinct problems stacked on top of each other: 1. A forwarding wedge -- nastynas and pibox both unreachable on LAN while healthy at the host level (nastynas confirmed fine over Headscale throughout), everything else on the LAN fine. This is the exact signature already documented in [[shared-unmanaged-switch-wedge-multi-host-outage]], reproduced here on homelab-switch (the managed Omada ES210X-M2 that replaced the old unmanaged switch specifically to prevent this class of failure, 2026-08-13) -- the same two hosts, the same JetKVM-plug-in-adjacent trigger. 2. A lost management IP -- power-cycling the switch (per that known-fix's prescription) fixed the forwarding wedge immediately, but the switch's own management address (192.168.42.184) never came back, even though downstream forwarding was fully healthy again. Confirmed via a real developer-env session, not just tooling: ping/curl both failed with a genuine ARP-resolution failure, not a drop/timeout. It wasn't in the gateway's client list under any address, and TP-Link's own discovery utility -- built for exactly this "lost track of the management IP" scenario -- couldn't find it on the LAN at all.

Root cause

(1) is unconfirmed but consistent with the prior incident. This switch has no SSH/API surface (Easy Smart, web UI only) so there were no logs to pull confirming the trigger, same limitation as the original unmanaged-switch incident. **(2) is suspected to be this switch's known "unsaved config reverts on reboot" pitfall**, already documented from its Phase 1 install (INFRASTRUCTURE.md, 2026-08-13): a setting applied via the web UI isn't written to flash unless you explicitly use the switch's separate "Save Config" action -- it already silently reverted a hostname rename once before. Two things touched this switch's config on 2026-08-22 itself, before the power cycle: the Caddy reverse-proxy setup and the .184 management-IP confirmation. Not proven, but the timing and mechanism both fit: forwarding (ASIC-level, independent of the management CPU's IP stack) recovered fine, while the management-plane IP config specifically did not.

Fix

1. Power-cycle the switch (not the hosts) -- resolved the forwarding wedge immediately, per the existing known-fix. 2. Management IP did not recover on its own. TP-Link's Omada Discovery Utility could not locate the device either, ruling out "it's just on a different IP we haven't found." Physical factory reset was the only remaining path. 3. Switch came back at a new address, 192.168.42.35 (not .184). Set as a proper static reservation and explicitly saved on the gateway this time (confirmed by MOS) -- addressing the suspected root cause of (2) directly. 4. Updated and deployed live: caddy/Caddyfile (reverse_proxy backend 192.168.42.184 -> .35) and inventory/hosts-config.yaml (lan_ip/caddy.backend_ip). Caddy change validated (caddy validate, sourcing the systemd unit's env file first -- caddy validate run bare fails on the CrowdSec API key even when the config is otherwise fine) and reloaded on the live Caddy LXC, verified end-to-end via direct IP and the FQDN through Caddy.

Diagnosis commands

# Forwarding wedge signature: multiple specific hosts down, everything

else on the LAN fine, hosts individually healthy over Headscale

ping -c 3 192.168.42.200 # nastynas ping -c 3 192.168.42.117 # pibox ssh root@100.64.0.113 "uptime" # nastynas over Headscale -- proves the host itself is fine

Confirm it's ARP failure, not a drop -- "Destination Host Unreachable"

from your OWN source IP means the kernel never got an ARP reply, vs. a

plain 100% packet loss timeout which is a silent drop somewhere else

ping -c 3 192.168.42.184

caddy validate needs the same env the systemd unit injects

ssh root@<caddy_ip> 'set -a; for _ef in $(systemctl cat caddy | sed -n "s/^EnvironmentFile=-\?//p"); do [ -f "$_ef" ] && . "$_ef"; done; caddy validate --config /etc/caddy/Caddyfile'

Prevention

1. **The standing rule from the prior incident -- "keep the JetKVM off any port of a switch that carries production" -- was not followed here, and something in this same neighborhood happened again.** The managed switch's loop prevention/storm control didn't visibly prevent whatever caused symptom (1). Either that protection doesn't cover this specific trigger, or (1)'s cause was unrelated to the JetKVM and just coincidental timing -- genuinely unconfirmed given no switch logs exist. Treat the original prevention rule as still standing rather than assuming the managed switch made it obsolete. 2. **Always use "Save Config" after any change to this switch, before any reboot or power event** -- this is the second time an unsaved change on this exact device reverted (hostname rename during Phase 1, now possibly the management IP). Get in the habit of treating every web-UI change as provisional until that button is explicitly clicked and verified to survive a reboot. 3. **A device with no SSH/API surface has no logs to diagnose a future recurrence with.** If this happens a third time, that absence is itself worth weighing against this switch's "standalone, no controller" design choice. 4. When a device's IP is unreachable after a reboot/reset, verifying via TP-Link's Omada Discovery Utility (or equivalent vendor L2-broadcast tool) before assuming a full factory reset is required saved a step here, even though it came up empty this time.

Related

- [[shared-unmanaged-switch-wedge-multi-host-outage]] -- the prior occurrence (old unmanaged switch), same forwarding-wedge signature. - INFRASTRUCTURE.md's homelab-switch Phase 1 note (the Save Config pitfall) and nastynas's Physical Hardware entry (JetKVM attachment, same day). - TROUBLESHOOTING.md's narrative entry for this incident (now closed out, cross-referencing this file).
Uncommitted local edits inside a host's own repo clone are invisible to collect-homelab — and identical-looking edits can require opposite fixes, because git cannot tell you whether the config is actually running medium
git pull on a host with a repo clone aborts: 'Your local changes to the following files would be overwritten by merge' • A service appears in a host's live compose file and in prose documentation, but grep finds it nowhere in the tracked config, and docker ps shows no such container • docker compose config fails with 'env file ... not found' while targeted commands like docker compose up -d <service> keep working normally
git drift collect-homelab compose docker reconciliation silent-failure watchdog source-of-truth abandoned-deployment unverified-assumption   last seen: 2026-07-23

Symptoms

  • git pull on a host with a repo clone aborts: 'Your local changes to the following files would be overwritten by merge'
  • A service appears in a host's live compose file and in prose documentation, but grep finds it nowhere in the tracked config, and docker ps shows no such container
  • docker compose config fails with 'env file ... not found' while targeted commands like docker compose up -d <service> keep working normally
  • A host's repo clone is many commits behind HEAD, so a real divergence shows up mixed in with ordinary staleness and is easy to wave through
See also: crowdsec-doh-crs-920420-and-ts2021-ban-loop, crowdsec-docker-migration-environ-leak

Symptom

A routine git pull on watchdog aborted:
error: Your local changes to the following files would be overwritten by merge:
        watchdog/compose.yaml
Aborting
Nothing routinely runs git status on hosts that hold a clone, so an edit like this can sit indefinitely. This one had been there three days.

The actual lesson: the same symptom, two opposite correct fixes

The diff contained three things, and they needed three different responses. This is the part worth internalizing — **git tells you a file diverged, not whether the divergence is live.** 1. - "7422:7422" on the crowdsec service. *Not drift.* Already committed on 2026-07-20; watchdog's clone was just behind (at 6900682, four days and 80 files back). Correct fix: discard it, the pull restores it. 2. An adguard-exporter service block. *Drift, but not live.* Written 2026-07-20, never committed — and, crucially, never actually deployed. docker ps showed no such container, and the env_file: it depends on (/etc/adguard-exporter/adguard-exporter.env) did not exist on the host, so it could not have started. Correct fix: disable it, do not adopt it. 3. A latent parse failure inside (2). An active env_file: pointing at a nonexistent path makes docker compose config fail, which breaks bare docker compose up -d and anything in the update pipeline that parses the file (bin/update-docker-compose). Targeted commands like docker compose up -d crowdsec are unaffected, because Compose only resolves the named service's env_file — which is exactly why this survived a live CrowdSec upgrade without anyone noticing. Compare against the prior instance of the same git symptom: - caddy/Caddyfile (2026-07-1x) — the CrowdSec global block and per-site directives were live in production and absent from the tracked copy. Correct fix: reconcile *into* git, live-file-wins. - watchdog/compose.yaml (2026-07-23, this entry) — an abandoned half-finished deployment. Correct fix: disable it, git-wins. Identical git status output. Opposite resolutions. **The discriminator is not in git at all** — it is docker ps / systemctl is-active / whatever proves the thing is actually running.

How this entry got it wrong the first time

Recorded because the mistake is more instructive than the incident. The first version of this known-fix asserted that adguard-exporter was "running in production since 2026-07-20," and watchdog/compose.yaml was reconciled into the repo verbatim on that basis (commit 2c904f5). The evidence for that claim was: another known-fix (crowdsec-doh-crs-920420-and-ts2021-ban-loop.md, in its stray-decoy bullet) listed adguard-exporter among the real Compose project's services, and the block's own comments were written in a confident, past-tense, this-is-done voice ("confirmed working on arm64 via a live pull"). **Citation corrected 2026-07-24** --- earlier versions of this paragraph named INFRASTRUCTURE.md as that source. It never contained the claim. The misattribution is itself an instance of this entry's lesson, one layer up: a citation asserted from memory rather than checked against the file. Both are documentation. Neither is the running system. A single docker ps falsified it, and that command was not run until after the claim had been committed to the repo twice — in a code comment and a commit message. Consequence: commit 2c904f5 propagated a docker compose config failure from one host's working copy into the tracked file, where it would have reached anything else parsing it. Fixed in 078b7dd by commenting the block out with explicit finish-it / drop-it instructions. The rule this yields: before treating a host's live file as the source of truth, verify the drifted content is actually running. Confident comments in a config file describe intent at the time of writing, not current state — the adguard-exporter block's comments were accurate about the arm64 image research and simply silent about the manual step that was never completed.

Procedure

# 1. Read the diff. For each hunk, ask: is this live?
ssh <host> "cd ~/homelab && git log --oneline -1 && git status --short && git diff <path>"

2. PROVE it, per divergent item. Documentation does not count.

ssh <host> "docker ps" # containers ssh <host> "systemctl is-active <unit>" # services ssh <host> "test -f <every referenced path> && echo present"

3. Back up the live file BEFORE discarding anything

ssh <host> "cp ~/homelab/<path> /tmp/<file>.live.bak"

4a. If live and untracked -> reconcile INTO git, push, then:

4b. If not live -> fix in git (disable/delete), push, then:

ssh <host> "cd ~/homelab && git checkout <path> && git pull"

5. Gate on the diff: it must show ONLY what you intended

ssh <host> "diff /tmp/<file>.live.bak ~/homelab/<path>"

6. For compose files specifically, confirm the file still parses.

NOTE the single quotes -- with double quotes, $? expands on YOUR shell,

not the remote one, and reports the ssh client's status instead. This

exact mistake produced a misleading `exit=0` during this incident.

ssh <host> 'cd ~/homelab/watchdog && docker compose config >/dev/null 2>&1; echo "exit=$?"'
Step 5 is load-bearing. Anything appearing as a *deletion* that you did not intend means stop and restore from the backup.

Prevention — implemented 2026-07-25

Done: collect_git_state() in bin/collect-homelab. For every host holding a clone — developer-env (local), watchdog, and raspi4 — it records branch + HEAD and a filtered git status --short under collected/git-state/, validates docker compose config on watchdog, and diffs the deployed /usr/local/bin scripts against their bin/ source. It fires ntfy priority 4 ONLY on tracked-file divergence (a modified tracked file, a compose parse failure, or a genuine deployed-vs-tracked content mismatch); never on untracked (??) noise. Uses core.fileMode=false so the execute-bit push/chmod dance is not mistaken for content drift, and compares HEADs clone-vs-clone (no git fetch, staying read-only on the remote clones' .git). The original design sketch, preserved for rationale: Add a per-clone git-state check to bin/collect-homelab: for every host known to have a repo clone, run git status --short and git log --oneline -1, and record both in collected//. That surfaces: - any M entry — a live file has diverged from its tracked copy - any ?? entry — untracked files accumulating (watchdog currently has watchdog/incidents.jsonl and watchdog/snapshots/, both daemon runtime output that probably wants a .gitignore entry rather than a commit) - the clone's HEAD — how far behind that host is, worth knowing before any deploy that assumes a fresh pull This would have surfaced adguard-exporter on 2026-07-20 — the day it was written — and prompted someone to either finish it or bin it, instead of it sitting for three days accumulating documentation that described it as live. Cheap to add, no new credentials; collect-homelab already SSHes to these hosts. Worth pairing with a docker compose config check on any host with a compose file, since that failure is silent under normal targeted use.

Scope

Only hosts holding a working copy of this repo can accumulate this. The rest of the fleet is structurally safe: config gets there by scp, so the repo is unambiguously upstream. The hosts that *do* have clones — currently developer-env, watchdog, and raspi4 (guardian) — are the ones where "edit the live file to fix something quickly, commit it later" is one command away and the reminder never comes. Two independent occurrences on the two most actively worked hosts; treat it as an expected consequence of editing live files, not a one-off lapse. The fix is detection, not discipline.

Related non-issue, recorded so it is not re-investigated

The same pull created bin/crowdsec-health-check.sh, bin/crowdsec-digest.sh, bin/crowdsec-alerts-collect.py, and bin/crowdsec-alerts-server.py in watchdog's clone for the first time, which looked like those cron jobs had been dead for four days. They had not. **Cron on watchdog invokes /usr/local/bin/ copies, not the repo clone** — verified via crontab -l — and all three were confirmed byte-identical to their repo counterparts with diff -q, with crowdsec-alerts-server active. Ordinary staleness, not drift. That said, /usr/local/bin/ holding the executed copy while bin/ holds the tracked copy is the same two-source-of-truth shape, minus the git conflict that announces it. Nothing verifies they stay in sync. The diff -q loop used here is now folded into collect_git_state() (see the Prevention section), covering all five deployed host scripts including ntfy-apt-notify.sh.
Three gotchas writing hosts-config.yaml version: pre/post_update/verify steps medium
A multi-line YAML folded scalar (cmd: >-) with a wrapped ssh/curl invocation splits into two invalid commands • run-update's no_auto_reboot guard blocks a step that never actually reboots • A dist-upgrade update_cmd works fine as root but fails/does nothing for a non-root ssh_user host
yaml hosts-config run-update update-advisor ssh sudo reboot   last seen: 2026-08-08

Symptoms

  • A multi-line YAML folded scalar (cmd: >-) with a wrapped ssh/curl invocation splits into two invalid commands
  • run-update's no_auto_reboot guard blocks a step that never actually reboots
  • A dist-upgrade update_cmd works fine as root but fails/does nothing for a non-root ssh_user host
See also: apt-conf-multiline-quoted-string-malformed-tag

Symptom 1: a wrapped command inside `cmd: >-` silently splits into two commands

Writing a long shell invocation across multiple *visually indented* lines for readability, e.g.:
cmd: >-
  for i in $(seq 1 30); do
    ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new
    root@192.168.42.63 'echo up' 2>/dev/null && exit 0;
    sleep 5;
  done; exit 1
parses without error, but the actual string PyYAML produces has a literal newline between accept-new and root@192.168.42.63 instead of a folded space — which splits one ssh invocation into two invalid ones (ssh -o ... accept-new with no host argument, then a separate, meaningless root@192.168.42.63 'echo up' ... "command").

Root Cause

YAML's > (folded) block scalar only folds a line break into a space when the continuation line has the same indentation as the first content line. A line indented *more* than that (exactly what "wrap for readability, indent the continuation" naturally produces) is preserved as a literal newline instead — this is the mechanism that lets folded scalars embed preformatted text, and it's easy to trigger by accident. Existing entries in this file (e.g. grafana's post_update retry loop) get away with the same wrapped-and-indented style because their line breaks happen to land on statement boundaries after do (a bare newline there is just an ordinary bash statement separator, same as a semicolon) — that's a coincidence of where the wrap happened to fall, not something that generalizes.

Fix

Keep any single command invocation — anything where a line break would land *mid-command* rather than at a statement boundary — on one physical YAML line, no matter how long. Verify by parsing the YAML and printing repr() of the resulting string, and/or bash -n on the extracted command, before trusting it:
python3 -c "
import yaml
cfg = yaml.safe_load(open('inventory/hosts-config.yaml'))

... find the entry, print repr(entry['version']['verify'][0]['cmd'])

"
---

Symptom 2: `no_auto_reboot`'s guard blocks a step that never reboots

A post_update step on a no_auto_reboot: true host that only *checks* for a pending reboot and sends an ntfy notification about it (no reboot command anywhere in the actual logic) still gets blocked by run-update's guard.

Root Cause

The guard's first implementation matched on "reboot" in cmd.lower() — a blunt substring check. The ntfy -H "Title: ...reboot pending..." / `-d "...never auto-reboots... reboot when convenient..."` text legitimately contains the word "reboot" several times despite never executing it. Caught live rolling out the Proxmox-node -os entries (proxmox-nuc-os dry-run failed with `no_auto_reboot is set... refusing to run a step whose cmd contains 'reboot'` on its check-only step).

Fix

Don't text-sniff cmd at all. Steps that genuinely reboot must set an explicit reboots: true field; the guard checks that field, not the command text:
post_update:
  - desc: "Reboot if the update left one pending, otherwise no-op"
    reboots: true   # required for run-update's no_auto_reboot guard to catch this
    cmd: >-
      if [ -f /var/run/reboot-required ]; then nohup bash -c 'sleep 2 && reboot' >/dev/null 2>&1 & echo triggered; else echo none; fi
A check-only step (Proxmox hosts) simply omits reboots: entirely (default false) — it's never blocked, and no_auto_reboot still guarantees nothing *else* could accidentally reboot the host without explicitly opting in first. ---

Symptom 3: `apt-get` works fine over SSH as root, but silently does nothing (or fails) for a non-root `ssh_user` host

A dist-upgrade (or any apt) update_cmd written as plain `apt-get update && apt-get -y dist-upgrade works when ssh_user: root` (most hosts), but for a non-root ssh_user (watchdog, raspi4) needs sudo — and a naive export DEBIAN_FRONTEND=noninteractive; sudo apt-get ... does NOT reliably pass that env var through to the sudo'd process (sudo resets the environment by default unless the host's sudoers has env_keep/!env_reset configured, which shouldn't be assumed).

Fix

Don't embed sudo in update_cmd in hosts-config.yaml at all — let the dispatcher script (bin/update-apt-host) decide based on ssh_user, exactly like bin/deploy-os-updates's existing use_sudo = ssh_user != "root" convention:
if ssh_user != "root":
    # sudo env VAR=val cmd -- env sets its OWN child's environment directly,
    # independent of sudo's env-filtering policy. Safer than a bare export.
    noninteractive_cmd = f"sudo env DEBIAN_FRONTEND=noninteractive bash -c {shlex.quote(update_cmd)}"
else:
    noninteractive_cmd = f"export DEBIAN_FRONTEND=noninteractive; {update_cmd}"
Also confirm passwordless sudo actually exists before relying on this in an unattended context — ssh @ "sudo -n apt-get --version" exits cleanly with no password prompt if NOPASSWD is configured; otherwise it fails fast rather than hanging on a password prompt that will never be answered non-interactively.
HA integrations for Envoy/WiiM/Brother printer all stuck (hung or SETUP_RETRY) -- devices sit on Trusted VLAN's interim Wi-Fi fallback, no Servers->Trusted rule existed for any of them medium
A batch of unrelated-looking HA integrations all broken at once: enphase_envoy stuck in ConfigEntryState.SETUP_IN_PROGRESS for days (bootstrap log: 'Waiting for integrations to complete setup' with an elapsed time in the hundreds of thousands of seconds), wiim/ipp/brother in SETUP_RETRY • Every entity under the affected integration shows unavailable/unknown -- not a handful, the whole device tree (Envoy: 32 entities; WiiM: media_player + button; Brother: printer status sensors) • ping/curl/nc from any Servers-VLAN host (developer-env, or HA itself) to the device's LAN IP times out -- no refusal, a hang, same signature as ufw default-deny or a missing Headscale ACL entry
network firewall vlan ucg-fiber unifi zone-based-firewall home-assistant mcp enphase_envoy wiim ipp brother lyrionmusicserver   last seen: 2026-08-25

Symptoms

  • A batch of unrelated-looking HA integrations all broken at once: enphase_envoy stuck in ConfigEntryState.SETUP_IN_PROGRESS for days (bootstrap log: 'Waiting for integrations to complete setup' with an elapsed time in the hundreds of thousands of seconds), wiim/ipp/brother in SETUP_RETRY
  • Every entity under the affected integration shows unavailable/unknown -- not a handful, the whole device tree (Envoy: 32 entities; WiiM: media_player + button; Brother: printer status sensors)
  • ping/curl/nc from any Servers-VLAN host (developer-env, or HA itself) to the device's LAN IP times out -- no refusal, a hang, same signature as ufw default-deny or a missing Headscale ACL entry
  • The device's IP is on 192.168.12.0/24 (Trusted) even though it's a Wi-Fi IoT-class device (solar monitor, printer, multiroom speaker) that was never meant to live there long-term
See also: raspi4-followed-wifi-ssid-onto-trusted-vlan, trusted-vlan-return-traffic-toggle-missing, unifi-zone-rule-creation-gotchas, trusted-vlan-gateway-ui-firewall-gap

Context

Discovered via the new home-assistant MCP server (ganhammar/hass-mcp-server, see INFRASTRUCTURE.md's haos VM Notes entry) during a routine get_system_status check -- a long tail of unavailable/unknown entities that turned out to share one root cause, not many.

Root cause

Same underlying mechanism as known-fixes/raspi4-followed-wifi-ssid-onto-trusted-vlan.md, but hitting Wi-Fi *IoT*-class devices instead of an infra node: docs/vlan-gateway-migration-plan.md documents that every Wi-Fi device -- Trusted and IoT alike -- lands on a single SSID trunked into VLAN 10 (Trusted, 192.168.12.0/24) as an interim fallback, because the BE800 AP can't do VLAN-tagged SSIDs. This was always going to affect envoy, WiiM_Ultra-0934, and BROTHER-2370DW (all named explicitly in that doc's VLAN 20 target list), but nobody had gone back to open the Servers -> Trusted reachability those devices' HA integrations need, since homeassistant (VM 121) stays on VLAN 30 Servers per the same plan. Default-deny between zones means every poll just hangs -- which is why enphase_envoy sat in SETUP_IN_PROGRESS for 5+ days: HA's bootstrap doesn't time out that wait on its own. Don't guess which port an integration actually uses -- capture it live. General knowledge said Envoy talks 443 and WiiM/LinkPlay devices talk 443 or 80. Both assumptions were wrong or incomplete. The reliable method used throughout this incident:
ssh ucg-fiber "timeout 20 tcpdump -ni any 'host <ha-ip> and host <device-ip>' -c 60"

then, in HA:

homeassistant.reload_config_entry # entry_id: <the stuck integration>
Reading the actual destination port off the wire caught real surprises: WiiM's native wiim integration hits three separate ports for one device (8443 for its main polling API, 443 for a separate "default HTTP API" getStatusEx status call, 49152 for UPnP control) -- not the single 443 a docs-based guess would have produced. The Brother printer, by contrast, only ever used 631 (IPP) in every capture across this incident, never SNMP/161 as originally hypothesized.

Fix -- six new UCG-Fiber zone-firewall rules

All narrowly IP-scoped (never zone-wide), all with "allow return traffic" enabled and verified live via iptables -S + ipset list on both directions (see known-fixes/unifi-zone-rule-creation-gotchas.md / trusted-vlan-return-traffic-toggle-missing.md for why the UI's own "saved" confirmation isn't trustworthy on its own): | short_id | Name | Direction | Source | Destination | Port | Result | |---|---|---|---|---|---|---| | 17 | Servers → Trusted Envoy | Servers→Trusted | 192.168.42.113 (homeassistant) | 192.168.12.58 (envoy) | tcp/443 | Fixed. All 32 Envoy/Encharge/Collar/Inverter/C6-Combiner entities live after one reload_config_entry. | | 18 | Servers → Trusted Brother Printer | Servers→Trusted | 192.168.42.113 | 192.168.12.26 (Brother printer) | tcp/631 | Fixed. ipp domain → ConfigEntryState.LOADED, sensor.brother_hl_l2370dw_series live. | | 19 | Servers → Trusted WiiM API | Servers→Trusted | 192.168.42.113 | 192.168.12.82 (WiiM Ultra) | tcp/8443 | Needed but not sufficient alone -- see below. | | 20 | Servers → Trusted WiiM UPnP | Servers→Trusted | 192.168.42.113 | 192.168.12.82 | tcp/49152 | Confirmed listening pre- *and* post-reboot on the same port (embedded UPnP stacks typically fix this by convention, not per-boot random) -- opened with confidence after that check. | | 21 | Trusted → Servers WiiM LMS | Trusted→Servers (reverse direction) | 192.168.12.82 (WiiM) | 192.168.42.55 (lyrionmusicserver) | tcp/3483 (SlimProto) | Built; not verified end-to-end -- no confirmed traffic in the capture windows tried. Bonus: this traffic pattern is what confirmed 192.168.42.55 really is lyrionmusicserver, independently verified via pct exec 109 -- ip addr on proxmox-nuc, resolving docs/vlan-gateway-migration-plan.md's long-standing "mystery IP" open item. | | 22 | Servers → Trusted WiiM HTTP | Servers→Trusted | 192.168.42.113 | 192.168.12.82 | tcp/443 | The missing piece from rule 19 -- wiim.sdk's own log line named the exact failing call: Timeout for https://192.168.12.82/httpapi.asp?command=getStatusEx. |

Outstanding, not fixed

**brother domain (the separate SNMP/status-oriented config entry -- distinct from ipp, which *is* fixed) stayed in SETUP_RETRY** through every reload and a full HA restart, with no new error ever logged for it. All observed live traffic toward the printer only ever used 631 (shared successfully with ipp), so there's no confirmed evidence of a second port it's blocked on -- unconfirmed whether it needs SNMP/161 or has some other unrelated issue. Not chased further this session. WiiM's media_player.living_room_wiim_3 entity never came back, even after all three ports were confirmed open and a direct curl from the gateway proved the device answers getStatusEx correctly in ~150ms. The wiim config entry itself reached ConfigEntryState.LOADED cleanly with no further errors -- but the entity's last_updated timestamp stayed frozen through two reload_config_entry calls, a full HA restart, *and* a targeted homeassistant.update_entity forced-poll call that should bypass normal coordinator scheduling. That combination points at an orphaned/stale entity-registry entry no longer bound to any active coordinator, not a remaining connectivity gap -- the fix is deleting and re-adding the integration via **Settings → Devices & Services → the *Integrations* tab** (not the *Devices* tab -- the device page only offers "Disable," which just toggles the same stale registry entry and won't help; the actual "Delete" action lives on the integration's own card under Integrations). Not completed -- MOS separately decided to drop the WiiM HA integration entirely rather than pursue it further (doesn't integrate well with the actual music library in use), so this was left as-is.

Prevention

- **Any device inheriting the Trusted-VLAN Wi-Fi interim fallback needs its own Servers → Trusted rule(s) for whatever ports its specific HA integration actually polls.** This isn't derivable from the VLAN plan alone -- verify with a live packet capture each time, per device, per integration; don't assume a "should be 443" guess is correct. - These rules are a stopgap, not a final home. Once the affected device actually migrates to its real destination (VLAN 20 IoT, once the U7 Pro AP unblocks Session 2 Steps 3-5), the Servers → Trusted rules built here point at a Trusted-VLAN IP the device will no longer have -- they should be torn down and rebuilt against the new VLAN 20 IP/zone pair at that time, not left dangling. - A SETUP_IN_PROGRESS state that persists for an unreasonable time (not just the ordinary few seconds) is the enphase_envoy-style signature of a genuinely hung setup task, not a transient retry -- check homeassistant.get_error_log's bootstrap "Waiting for integrations" line for the elapsed-seconds figure before assuming it'll clear itself.
iPhone's Tailscale silently disconnected -- HA app / *.compellinglylowbrow.org traffic leaked out as real 100.64.0.x packets and hung, degrading all browsing medium
iPhone on home WiFi (GDTRFB, Trusted VLAN): general internet felt slow, some external sites failed to load entirely • iOS Wi-Fi settings showed a normal, correct LAN IP (192.168.12.240) and full signal -- looked nothing like a WiFi/AP problem • Live tcpdump on the gateway showed the phone reaching ARP/mDNS instantly -- L2 connectivity was never the issue
tailscale headscale iphone ios vpn dns adguard wildcard-rewrite wan-leak trusted-vlan   last seen: 2026-08-24

Symptoms

  • iPhone on home WiFi (GDTRFB, Trusted VLAN): general internet felt slow, some external sites failed to load entirely
  • iOS Wi-Fi settings showed a normal, correct LAN IP (192.168.12.240) and full signal -- looked nothing like a WiFi/AP problem
  • Live tcpdump on the gateway showed the phone reaching ARP/mDNS instantly -- L2 connectivity was never the issue
  • Gateway conntrack showed a genuine SYN_SENT/UNREPLIED entry: 192.168.12.240 -> 100.64.0.110:8123 (Home Assistant's Headscale IP), packets sent, zero replies, indefinitely
  • Tailscale app on the iPhone was found disconnected; reconnecting it (status -> Connected) fixed browsing immediately, no other change needed
See also: nastynas-pibox-lan-ip-unreachable-post-vlan-work, trusted-vlan-gateway-ui-firewall-gap, tailscaled-magicsock-network-down-stuck, raspi4-followed-wifi-ssid-onto-trusted-vlan, phone-ssh-jump-host-to-gateway

Symptom

MOS reported the iPhone's internet as slow, with some sites not loading at all, on home WiFi. The phone was correctly on GDTRFB (Trusted VLAN 10, confirmed both via the UniFi controller's own wlanconf/networkconf records and via iOS Settings showing a correct 192.168.12.240 address) -- nothing was wrong with the WiFi network or VLAN assignment itself.

Root cause

Tailscale on the iPhone (a registered Headscale device, 100.64.0.117, added 2026-08-22 for the SSH-jump-host path -- see known-fixes/phone-ssh-jump-host-to-gateway.md) had disconnected, silently, with no obvious user-facing notification. Two independent things route the phone toward 100.64.0.x addresses even on plain home WiFi with no VPN involved: 1. AdGuard's wildcard DNS rewrite (`*.compellinglylowbrow.org -> 100.64.0.4`, Caddy's Headscale IP -- see known-fixes/adguard-dns-rewrite-reversion.md and INFRASTRUCTURE.md's Remote Access section) answers identically for every querying client, LAN or tailnet. A LAN client with no active Tailscale session gets the exact same DNS answer a genuine remote client would. 2. The iOS Home Assistant app is configured to reach HA at its Headscale IP directly (100.64.0.110:8123), not its LAN IP. 100.64.0.0/10 is RFC 6598 Shared Address Space -- not usable on the open internet, but also not something a generic router (ucg-fiber, which is not itself a Headscale node) recognizes as inherently unroutable. With Tailscale down, the phone's connection attempts to these addresses don't fail fast: they're treated as ordinary destinations, forwarded to the WAN gateway, masqueraded, and handed to the ISP -- which silently drops them, since the range isn't globally routable. The result is a TCP SYN that goes out and simply never gets a reply: no RST, no ICMP unreachable, just an open-ended hang from the client's point of view. Beyond the direct 100.64.x hits, a disconnected/wedged VPN NetworkExtension on iOS is also a known general source of degraded networking while in that state, which plausibly explains the broader "external sites felt slow too" symptom, not just failures isolated to Headscale-addressed destinations.

Diagnosis

**A real methodology trap hit mid-investigation, worth flagging for next time.** The first lookup pass (grepping the gateway's DHCP lease file + ip neigh show) found the phone's MAC sitting on the IoT VLAN (192.168.22.149), and that became the leading theory for several exchanges -- entirely wrong. That IP was a stale leftover: the phone really had been joined to IoT_GDTRFB the day before (2026-08-23) to test an unrelated WPA-handshake fix (known-fixes/unifi-enhanced-iot-blocks-iphone-handshake.md), and its DHCP lease hadn't aged out of the lease file yet. Two signals were already present that should have caught this sooner and didn't: - The ARP entry was explicitly flagged STALE, not REACHABLE -- Linux's neighbor-cache staleness state, not just cosmetic text. - conntrack showed zero active entries for that IP at the time -- a device that's genuinely live right now usually has *something* in flight. Both were noted in the moment but under-weighted; the lease was treated as current instead of being checked against its own timestamp or refreshed with a live probe. MOS caught the error directly (Settings -> Wi-Fi -> the network's own IP Address field on the phone itself), which gave the real, current IP and let the investigation restart on solid ground. From there, live tools -- not cached state -- gave the real answer fast:
ssh ucg-fiber "ip neigh show | grep 192.168.12.240"        # REACHABLE, not STALE
ssh ucg-fiber "conntrack -L | grep 192.168.12.240"          # live SYN_SENT/UNREPLIED to 100.64.0.110:8123
ssh ucg-fiber "tcpdump -i br10 -n host 192.168.12.240"      # confirmed ARP/mDNS fine, ruled out L2/WiFi

Fix

Reconnect Tailscale on the iPhone (tap Connect in the app). No gateway-side, AdGuard-side, or ACL change was needed -- the phone's own VPN state was the entire problem.

Follow-up: systemic fix landed same day

The immediate fix only addressed this one occurrence. A gateway-level backstop was added the same day so the *class* of bug (any device, any app, Tailscale down, pointed at a 100.64.x address) fails fast instead of hanging silently, regardless of which app or device hits it next time -- see INFRASTRUCTURE.md's Security section, "UCG-Fiber: Reject 100.64.0.0/10 leaking out to WAN." daily-fleet-digest's personal-device Tailscale freshness check (added the same session, see that script's module docstring) remains a separate, complementary layer -- catches the disconnected-session state itself, while the gateway rule catches its consequence.

Prevention

- **A DHCP lease or ARP entry is a snapshot, not a live fact -- check its freshness before trusting it as current state.** ip neigh's REACHABLE/STALE/FAILED distinction and a lease file's own expiry timestamp both carry this signal already; don't skip past them under time pressure. Cross-checking against conntrack (does this address have *any* current traffic?) is a fast, free sanity check before building a theory on an IP-to-device mapping pulled from gateway-side state. - **A device's own network settings (here: iOS Settings -> Wi-Fi -> (i) -> IP Address) are ground truth for its current address** -- faster and more reliable than reconstructing it from gateway-side DHCP/ARP state when the two disagree. - **A wedged or disconnected personal-device VPN that has any legitimate reason to be on the device** (here: the SSH-jump-host path, known-fixes/phone-ssh-jump-host-to-gateway.md) **is a real, silent failure mode for that device's *general* networking** -- not just for the tailnet-specific traffic it's nominally responsible for. Worth checking early for "device X on my home network is slow/broken" reports, alongside the usual WiFi/firewall/DNS suspects. This is the third occurrence of a Tailscale-routing-state bug producing exactly this confusing symptom class in this homelab (known-fixes/nastynas-pibox-lan-ip-unreachable-post-vlan-work.md, known-fixes/trusted-vlan-gateway-ui-firewall-gap.md), now confirmed on iOS as well as macOS. - AdGuard's wildcard rewrite answering 100.64.0.4 for *.compellinglylowbrow.org regardless of which client asks (LAN or tailnet) is working as designed, not a bug -- but it does mean any client with an inactive or misbehaving Tailscale session gets an address it can't currently reach for every homelab service, indistinguishable at the DNS layer from a real outage.
JetKVM login succeeds but video session fails with "Connection Issue" off-LAN — missing ACL grant, not routing or DNS medium
jetkvm headscale acl webrtc tailnet subnet-router   last seen:

Symptom

jetkvm.compellinglylowbrow.org resolves fine and the login page loads and authenticates — but the KVM video/control session then fails with JetKVM's own "Connection Issue" banner. Going directly to jetkvm's Headscale IP (100.64.0.11) instead of the Caddy domain hit the exact same banner. Neither symptom occurs when connecting via http://192.168.42.82 (LAN IP) while physically on the LAN — that path works cleanly end to end.

Root cause

Two independent things, both required for a fix: 1. **JetKVM's WebRTC media session only ever offers its bare LAN IP (192.168.42.82) as an ICE host candidate** — no TURN relay, no tailnet-aware candidate. This is a known upstream limitation, not specific to this homelab's Caddy config — see [jetkvm/kvm#429](https://github.com/jetkvm/kvm/issues/429) and [jetkvm/kvm#484](https://github.com/jetkvm/kvm/issues/484) for the same "works on LAN, fails behind any reverse proxy" report, and [tutman96/jetkvm-plugin-tailscale#3](https://github.com/tutman96/jetkvm-plugin-tailscale/issues/3) confirming JetKVM's built-in Tailscale support only puts the *web UI* on the tailnet — it does not tunnel the WebRTC media through it. JetKVM's own free STUN/TURN only activates if you opt into their cloud remote-access feature (Google OIDC), which this homelab deliberately does not use for a BIOS/console-level device. - This explains why the HTTP-layer login worked via the Caddy domain (Caddy's leg to 192.168.42.82 is plain LAN-to-LAN, no tailnet involved — see hosts-config.yaml's jetkvm entry) but the actual video session, whose ICE candidate points at that same LAN IP, needed a real tailnet-side route to 192.168.42.82 to succeed for an off-LAN client. 2. **No ACL rule granted any client a path to 192.168.42.82 over the tailnet.** The one existing jetkvm ACL entry (headscale/acl.hujson rule 4) is scoped to developer-env → jetkvm:22 only — confirmed live via a direct test: curl http://100.64.0.11/ from developer-env timed out with zero response (the classic "silently dropped, not refused" under-scoped-ACL signature — same shape as CLAUDE.md's "host missing from acl.hujson" note, just scoped-too-narrow instead of missing-entirely), while the same test against port 22 (which *does* have a rule) connected instantly. Important side-finding while investigating this: the L3 path to 192.168.42.82 from the tailnet *already existed* the whole time and needed no new subnet router. headscale nodes list-routes showed caddy (node id 4) already an approved, actively-serving (Primary) subnet router for the full 192.168.42.0/24 — this had never been documented anywhere in this repo despite being load-bearing for every rule that targets a raw LAN IP as dst (Caddy's own outbound Rule 3, Rule 6, Rule 7, etc. all ride on it). A same-day attempt to fix this by making nastynas advertise a redundant /32 route for just 192.168.42.82 was tried first (before this was discovered), found unnecessary once caddy's existing /24 route was found, and fully reverted (`tailscale set --advertise-routes=, removed the net.ipv4.ip_forward` sysctl file added for it) — see git history same day for the add+revert. jetkvm's own embedded Tailscale (BusyBox appliance, not the reverted nastynas attempt) was also briefly considered as the subnet-router node and ruled out: its tailscale status health check reported `create table: get table: get tables: socket: protocol not supported`, suggesting its minimal kernel may not fully support the netlink routing-table operations subnet routing needs — untested further since it turned out to be unnecessary anyway.

Fix

Added a scoped ACL rule (rule 19) granting the personal/admin devices that actually need JetKVM access a path to jetkvm's LAN IP on any port:
{
  "action": "accept",
  "src": ["macbook", "iphone", "developer-env"],
  "dst": ["192.168.42.82:*"]
}
:* (all ports) because the WebRTC media itself binds a different ephemeral UDP port per session (confirmed via ss -tulnp on the device — port 80 is fixed but the media port isn't), not a fixed one worth trying to pin down — scope stays tight since it's a single device's IP either way. Deployed via the same validate → backup → swap → restart → byte-diff read-back sequence bin/onboard-container's acl_deploy() uses. No routing changes were needed or made (see side-finding above) — caddy's existing /24 subnet route already covers 192.168.42.82. Client-side requirement, unverified from this session: a Tailscale client only actually uses an approved subnet route if that device has "accept subnet routes" enabled locally — off by default for most non-mobile Tailscale clients. If the ACL fix alone doesn't resolve it, check this setting on the client next, before assuming the ACL rule itself is wrong.

Prevention

- **A "connection succeeds partway, then fails" symptom against any WebRTC-based self-hosted appliance (KVM-over-IP, some camera/NVR UIs, etc.) behind Headscale/Caddy is very likely this same shape**: the HTTP/signaling layer goes through Caddy fine, but the actual media negotiates toward the device's bare LAN IP, which needs its own ACL grant (and, if no subnet router already covers that LAN, a route too — check headscale nodes list-routes first, don't assume one is needed). - **headscale nodes list-routes is the authoritative source for what subnet routes actually exist and are approved** — this found a significant piece of live architecture (caddy as full-/24 subnet router) that no doc in this repo mentioned. Check it before adding a new subnet router for anything that touches a raw LAN IP over the tailnet; the path may already exist. - Same silent-hang ACL signature as the acl.hujson "host missing entirely" note in CLAUDE.md — a scoped-too-narrow rule for an already-declared host produces the identical hang-not-refuse behavior as a host missing from hosts{} entirely. Test with a direct port-by-port curl//dev/tcp comparison (a port *with* a rule vs. the one you're adding) to confirm the ACL diagnosis before touching anything else.

Related

See INFRASTRUCTURE.md's Headscale section for the now-documented caddy-as-subnet-router note, and inventory/hosts-config.yaml's jetkvm entry for the client-side "accept routes" reminder.
In-place LXC Debian 12→13 (trixie) upgrade: ffmpeg -C flag breaks LMS FLAC transcoding medium
ffmpeg exits with 'Unrecognized option C. Error splitting the argument list' after a Debian 12→13 upgrade • LMS FLAC-route transcode (custom-convert.conf 'flc' rules) fails after OS upgrade; PCM/wav route still works • bin/version-checker intermittently reports 'SSH failed or no output' for a host right after collect-homelab's parallel collection phase, even though the host is healthy
lxc debian trixie dist-upgrade ffmpeg lyrion lms custom-convert.conf   last seen: 2026-08-07

Symptoms

  • ffmpeg exits with 'Unrecognized option C. Error splitting the argument list' after a Debian 12→13 upgrade
  • LMS FLAC-route transcode (custom-convert.conf 'flc' rules) fails after OS upgrade; PCM/wav route still works
  • bin/version-checker intermittently reports 'SSH failed or no output' for a host right after collect-homelab's parallel collection phase, even though the host is healthy

Context

First in-place major-version Debian dist-upgrade performed on an existing LXC in this homelab (LXC 109, lyrionmusicserver, Debian 12 bookworm → 13 trixie). Nothing in hosts-config.yaml's update-lifecycle pipeline covers this — it's purely app-version tracking (version: block), not an OS-level operation. This entry documents the procedure and the one real regression found.

Procedure (worked cleanly)

1. Fresh vzdump backup to PBS immediately before starting (in addition to the existing nightly 21:00 job that already covers VMID 109) — rollback point. 2. apt full-upgrade -y on bookworm first (fully current before touching sources). 3. Repoint /etc/apt/sources.list and /etc/apt/sources.list.d/tailscale.list: sed -i 's/bookworm/trixie/g' on both. (Debian's -security suite naming is unchanged between bookworm and trixie, so a blanket sed is safe.) 4. apt update && apt upgrade -y -o Dpkg::Options::='--force-confold', then apt full-upgrade -y with the same options, then apt autoremove -y. Note: --force-confold must be passed via -o Dpkg::Options::=, not as a bare apt-get flag (apt-get --force-confold errors: "not understood"). 5. pct reboot from the Proxmox host (not a container-internal reboot — needed to swap the running dbus-daemon/kernel-adjacent userspace pieces). 6. Transient, expected, self-resolving noise during the full-upgrade trigger phase — do not treat as failure: `Failed to restart postfix.service: Transaction contains conflicting jobs, and systemctl: error while loading shared libraries: libcrypto.so.3` (systemctl briefly can't find the just-upgraded libssl3 mid-transaction). Both clear on reboot.

The real regression: ffmpeg `-C 0`

LMS's custom-convert.conf (found live at /etc/squeezeboxserver/custom-convert.conf, also mirrored at /usr/share/squeezeboxserver/custom-convert.conf and an older/ incomplete copy at /etc/slimserver/custom-convert.conf — all three should be kept in sync) has a shn flc/shnf flc FLAC transcode route:
[/usr/bin/ffmpeg] -v 0 -i $FILE$ -f flac -C 0 -
-C 0 is not, and has never been, a real ffmpeg option — there is no global -C flag in ffmpeg at any version checked. Bookworm's ffmpeg 5.1.9 silently tolerated the unrecognized flag; trixie's ffmpeg 7.1.5 hard-rejects it:
Unrecognized option 'C'.
Error splitting the argument list: Option not found
exit code 8, zero bytes written — every FLAC-route transcode (Squeezebox2+/ software players, per the config's own comment) breaks silently until a player tries to stream a FLAC-routed SHN/SHNF file. The pcm/wav route (no -C flag) and the aif route are unaffected. Fix: replace -C 0 with the real modern equivalent, -compression_level 0 (0 = fastest/least compression, matching the evident original intent), in all three custom-convert.conf copies, then systemctl restart lyrionmusicserver. Verified via both a direct manual ffmpeg transcode of a real .shn file from the library (exit 0, valid FLAC output) and confirming LMS itself restarts clean (server.log shows Server done init with no errors, JSON-RPC responds). Sources confirming this is a known Debian-13-transition failure class for LMS in general (a *different*, Perl-version-based failure mode than the one found here — LMS < 9.0.2 doesn't support Perl 5.40, which trixie ships; this container was already on 9.1.1 so that specific failure mode didn't apply, but the forum threads are useful background for anyone hitting a post-upgrade LMS problem): [LMS won't start after upgrade from debian 12 to debian 13](https://forums.lyrion.org/forum/user-forums/logitech-media-server/1781314-lms-won-t-start-after-upgrade-from-debian-12-bookworm-to-debian-13-trixie), [Lyrionmusicserver crashing after upgrade to debian trixie](https://forums.lyrion.org/forum/user-forums/logitech-media-server/1788875-lyrionmusicserver-crashing-after-upgrade-to-debian-trixie).

Minor unresolved observation (not a real problem)

bin/version-checker intermittently logged `lyrionmusicserver: SSH failed or no output across several collect-homelab` runs immediately following the reboot, while adjacent hosts checked in the same run (caddy, adguard, grafana, qui, ntfy) succeeded within the same second. Directly replicating version-checker's exact subprocess.run(["ssh", ...]) call in isolation succeeded instantly and repeatedly (v9.1.1, exit 0, clean stdout) — so this is not a broken command, a broken host, or a config regression. collect-homelab's own per-host collection data (os-release.txt, running-services.txt) collected correctly in the same runs that showed this flake, confirming the underlying host was healthy throughout. Best working theory: transient contention from collect-homelab's parallel per-host collection jobs (all Collecting ... lines print at the same timestamp — they're backgrounded) competing for SSH connection slots right as version-checker's serial phase starts. Not chased further — cosmetic (one unknown entry in a version report), self-resolves, unrelated to the OS upgrade itself. Worth a closer look only if it starts happening for other hosts or every run.
LXC memory ceiling too low causes silent swap starvation medium
proxmox lxc memory swap grafana adguard adguard2 immich qbittorrent headscale dns performance   last seen:

Symptom

A container feels sluggish with no obvious error in any service log. free -h inside the container shows swap nearly or fully exhausted (Swap: 512Mi used of 512Mi), even though the host (proxmox-nuc) has abundant free RAM. Escalated case (same day, second occurrence): the *entire* homelab became unreachable via FQDN. adguard (LXC 102, primary DNS) had hit the same memory-ceiling/swap pattern, badly enough that it stopped answering DNS queries at all (dig @192.168.42.27 ... +short timed out — not a wrong answer, no answer). adguard2 (LXC 103) was healthy and had the correct wildcard rewrite the whole time, but that didn't help: the client's /etc/resolver/compellinglylowbrow.org only points at adguard's Headscale IP (100.64.0.5) — there is no client-side DNS failover to adguard2. A healthy secondary DNS server does not protect against the primary going unresponsive if nothing is configured to fail over to it. developer-env (VM 118, also on proxmox-nuc) being unreachable at the same time was the tell that this wasn't a DNS-config issue — ping to proxmox-nuc itself (192.168.42.25) timed out too, while the Proxmox API (curl to port 8006) still returned 200. That split — ICMP dead, TCP fine — is consistent with a host under heavy memory/swap pressure deprioritizing ICMP rather than the hypervisor itself being down.

Root cause

LXC memory: in Proxmox is a cgroup ceiling, not a reservation. Unused RAM inside that ceiling is available to the rest of the host — it is not "reserved" for the container the way a VM's memory allocation is. This means having spare RAM on the hypervisor tells you nothing about whether any given LXC has enough headroom; a container can be starving for real memory while the host as a whole looks comfortable. Concretely: the grafana LXC (VMID 101) had a memory: 512 ceiling set from before it ran anything beyond Grafana itself. Adding a Prometheus server (~35–90MB RSS) and prometheus-pve-exporter (~20–50MB RSS) on 2026-07-10 pushed total real usage right up against that 512MB ceiling, forcing essentially everything into swap (Swap: 512Mi used of 512Mi) rather than being killed outright — swap absorbed the overflow, at the cost of performance. Later the same day, adguard (and to a lesser extent adguard2) hit the same pattern at their own 512MB ceilings — this time severe enough to break DNS service entirely rather than just cause sluggishness, since a DNS listener under swap pressure can stop responding to queries altogether.

Fix

1. Raise the ceiling (live, no reboot required for LXCs — cgroup limits apply immediately):
   pct set <vmid> -memory <new_MB>
   
Swap ceiling can be raised the same way if swap itself (not just memory) is undersized relative to what the process wants:
   pct set <vmid> -swap <new_MB>
   
2. Clear stale swap usage. Growing the ceiling does *not* automatically pull already-swapped pages back into RAM — Linux doesn't proactively reclaim swap just because more RAM becomes available. swapoff -a from inside the container will fail with Not superuser even as root — LXCs are unprivileged with respect to host swap devices (no CAP_SYS_ADMIN over that resource), regardless of in-container root. The clean fix is a full container reboot, which resets the cgroup's memory/swap accounting from zero:
   pct reboot <vmid>
   
3. Verify via free -h inside the container, or `pct status --verbose from the host (fields: mem, maxmem, swap, maxswap`, all in bytes) — expect swap at or near 0 and a healthy mem/maxmem ratio.

Proactive headroom pass (2026-07-10, same day as the adguard outage)

Rather than wait for the next container to hit this the hard way, pulled pct status --verbose for all 15 proxmox-nuc LXCs and bumped ceilings on every container carrying real swap usage or thin headroom on a critical-path service, given the host has 96GB RAM to spare: | CTID | Name | Before (mem/swap MB) | Change | |------|------------|-----------------------|----------------------------------| | 102 | adguard | 2048 / 512 | swap → 1024, reboot to clear stale swap | | 103 | adguard2 | 2048 / 512 | swap → 1024, reboot to clear stale swap | | 104 | immich | 10240 / 512 | swap → 1024, reboot to clear stale swap (16.9% swap despite huge mem headroom — stale accounting, not real pressure) | | 113 | qbittorrent| 4096 / 1024 | mem → 6144, swap → 2048 (real pressure: 37% swap used against only 7.7% mem used — swap ceiling was the actual constraint, not memory) | | 114 | headscale | 512 / 512 | mem → 1024, swap → 1024 (critical-path VPN control server sitting at 38% mem on a small ceiling) | Containers at high mem% but ~0% swap (caddy, headscale pre-bump, seeder-daemon) were not touched for that reason alone — that pattern is page cache doing its job, not pressure. Swap% (not mem%) is the signal that actually distinguishes real starvation from efficient cache usage; see qbittorrent above for the clearest example (low mem%, high swap% = genuine ceiling mismatch on swap specifically, not memory).

Prevention / detection

Implemented 2026-07-11 (two complementary pieces, not one script): - watchdog/memory-guardian-alert.sh — near-real-time detection. Cron job on the watchdog Pi5 (every 5 min), SSHes into proxmox-nuc as root and reads pct status --verbose for every running LXC. Triggers on swap-ceiling ratio ≥ 25% (not memory percentage — see below), alerts via ntfy with a suggested pct set/pct reboot fix, per-container hourly cooldown. Detect-and-alert only — no auto-execution (Phase 2, deferred). - bin/collect-homelab — historical trend record. The existing 6-hourly Proxmox API collection already fetches each LXC's mem/swap/maxswap fields (same underlying data pct status --verbose reads) as part of the /nodes//lxc call it was already making for maxmem — it just wasn't extracting them before. Now writes mem_pct/swap_pct into inventory/proxmox-nuc.yaml on every run, giving a fully git-committed history with no new script, cron, or SSH connection. This replaced a planned standalone swap-sample.sh, which would have duplicated collect-homelab's existing data pull for no real benefit. This is exactly the failure mode both pieces are scoped to catch: sustained high memory-ceiling ratio combined with nonzero swap usage, read directly via SSH pct status --verbose/the Proxmox API across proxmox-nuc LXCs, not just inferred from Prometheus's pve_memory_usage_bytes / pve_memory_size_bytes ratio alone (that exporter has no swap metric at all — confirmed via its metric-name list, /api/v1/label/__name__/values, which contains no swap key). A cgroup memory ratio near 100% doesn't by itself prove something is wrong (it may just be using its ceiling efficiently), but nonzero swap on top of a high ratio is close to unambiguous — and, per the adguard case above, swap pressure can escalate to full service outage, not just sluggishness. The 25% swap-ceiling-ratio alert threshold is a starting point, not a fixed value: it sits between the two data points above (immich's 16.9%, judged stale/non-urgent, and qbittorrent's 37%, judged real pressure), leaning toward not missing anything trending toward the qbittorrent case. Worth revisiting once it's actually fired a few times against real data. Growing a ceiling is treated as SAFE to auto-execute (non-disruptive, cgroup applies live, worst case is a wasted bump). Shrinking a ceiling is never auto-executed — shrinking below current live usage triggers the OOM killer inside the container immediately, with no grace period. Detection gap worth noting: Uptime Kuma did alert on the adguard outage via ntfy — the pipeline worked as designed. The delay in response was purely human (not looking at the phone at the time), not a monitoring failure. No action needed there, but worth keeping in mind that ntfy delivery and human availability are two separate things when reviewing response times for any future incident.

Reference

See inventory/hosts-config.yaml's grafana entry notes for the original incident writeup in context (what was added that day, why Prometheus was deployed there, and the exact commands used).
lyrionmusicserver autorescan pref was always a no-op -- the feature is hardcoded off for every Linux install upstream, fixed with a one-line vendor patch that must be reapplied after every OS-package update medium
Newly downloaded albums don't appear in LMS/lyrtui search or browse until a manual rescan • autorescan pref shows 1 via CLI (`pref autorescan ?`) but new files still aren't picked up automatically • Settings > Performance in the LMS web UI doesn't show an Autorescan checkbox at all
lyrionmusicserver autorescan inotify perl vendor-patch hosts-config update-automation   last seen: 2026-08-15

Symptoms

  • Newly downloaded albums don't appear in LMS/lyrtui search or browse until a manual rescan
  • autorescan pref shows 1 via CLI (`pref autorescan ?`) but new files still aren't picked up automatically
  • Settings > Performance in the LMS web UI doesn't show an Autorescan checkbox at all
  • scanner.log shows exactly one 'Discovering audio files' entry (from server startup / from toggling the pref), then nothing, even hours later with real new files added
See also: lyrtui-connection-failures-acl-and-config-typo

Symptom

autorescan was flipped 01 via the CLI protocol on 2026-08-15 (see INFRASTRUCTURE.md's lyrionmusicserver entry) and documented as working based on seeing one scan fire. Hours later, two newly downloaded albums didn't show up in search at all -- the pref still read 1, but nothing had auto-scanned since.

Cause -- three layers deep, only the last one actually mattered

1. Setting the pref via raw CLI doesn't start the watcher. The only code path that calls Slim::Utils::AutoRescan->init() on a pref *change* is Slim::Web::Settings::Server::Performance.pm's web-UI save handler -- the CLI pref autorescan 1 command just writes the DB value with no side effect. This looked like the whole story at first (a restart should fix it, since init() also runs at server startup if the pref is already 1), but restarting alone still didn't produce a working watcher. 2. A restart alone didn't reveal the real problem either, because of layer 3. 3. **canAutoRescan is hardcoded 0 in the base OS class and no installed subclass overrides it — on any Linux platform.** Slim::Utils/OS.pm:455: sub canAutoRescan { 0 }. Every consumer of this feature gates on it: Slim::Utils::AutoRescan::init() (`return unless ...getOS->canAutoRescan), the scan-complete hook in SQLiteHelper.pm`, the CLI hooks in Control/Commands.pm, and critically the Settings UI itself (Performance.pm's prefs() only includes autorescan/ autorescan_stat_interval if canAutoRescan -- so the checkbox doesn't even render on this platform). Grepping the entire installed /usr/share/perl5/Slim/Utils/OS/*.pm tree confirms no override exists in Debian.pm, Linux.pm, or Unix.pm. The pref and its polling interval (autorescan_stat_interval, already correctly 10) were always inert -- not a config mistake, a dead code path upstream. Confirmed this is a real gap, not a local misconfiguration: the actual watcher implementations (Slim::Utils::AutoRescan::Linux using Linux::Inotify2, Slim::Utils::AutoRescan::Stat as a pure-Perl polling fallback) are both present and functional in the shipped package -- canAutoRescan is the only thing standing between them and actually running.

Fix

One-line vendor patch to /usr/share/perl5/Slim/Utils/OS/Debian.pm, added next to the existing canAutoUpdate override in the same style:
sub canAutoRescan { 1 }
Then systemctl restart lyrionmusicserver (Perl doesn't hot-reload changed .pm files). Confirmed end-to-end same day with real inotify events -- not just a pref read-back: - server.log showed `Slim::Utils::AutoRescan::Linux::canWatch (75) inotify init, max_user_watches: 4194304 and thousands of inotify watching: ...` lines as it registered a watch per already-known directory - Creating a new top-level test directory logged Inotify event: create ... and New directory ... created, watching it within ~1 second - Deleting it logged Inotify event: delete ... immediately, followed by an automatic Discovering audio files in ... scan ~15s later (the BATCH_DELAY debounce in Slim::Utils::AutoRescan.pm) -- with zero manual rescan command issued Note: Linux::Inotify2 is not installed as a system Perl module (`perl -M Linux::Inotify2` fails standalone) but works fine here because Slim::Utils::OS::Debian::initDetails() prepends LMS's own bundled CPAN directory (/usr/share/squeezeboxserver/CPAN) to @INC before loading it -- don't use a standalone perl -MLinux::Inotify2 check to sanity-test this; it'll give a false negative outside the app's own bootstrap context. Same caution applies to perl -c on any of these .pm files directly -- they reference main::-namespace globals (e.g. main::SCANNER) that only exist once squeezeboxserver's own launcher has run, so a standalone syntax check fails identically on the untouched original file too. Don't treat that failure as a sign your edit broke something -- diff against the unmodified file's perl -c output first.

Making the patch durable across OS-package updates (added 2026-08-15)

lyrionmusicserver-os (the OS-package pseudo-service for this LXC, `apt-get dist-upgrade, Group 3, auto_update: true, execute_window: 05:00-07:30`) will silently overwrite Debian.pm back to stock the next time a Debian package update touches it -- same category of regression as custom-convert.conf's -C 0 flag after the trixie upgrade (known-fixes/lxc-inplace-debian13-trixie-upgrade-lyrion.md). Fixed via hosts-config.yaml's existing post_update/verify mechanism (no new tooling needed -- this is exactly what those fields are for): - A post_update step reapplies the one-line patch idempotently (`grep -q ... || sed -i ...) right after update_cmd` runs, before the existing reboot-if-pending step - A second post_update step restarts lyrionmusicserver so the reapplied patch actually loads even when no reboot happens - A verify step confirms the patch is still present after the run This rides the same automated pipeline that already handles this host's Group 3 updates -- no separate cron job or apt hook was needed.

Prevention / lesson

**"The pref shows the right value" and "one scan happened once" are not proof a background feature is working continuously.** The original 2026-08-15 "Auto-rescan enabled" note was written after seeing exactly one scanner.log entry following the pref change -- plausible-looking evidence that turned out to be a one-time side effect, not proof of an ongoing watcher. The only real confirmation is a live create/delete test with no manual trigger, checked against the log lines the watcher itself emits when it's actually running (inotify watching:, Inotify event:) -- which is what caught this the second time around.
MOS's Mac auto-joined Guest_GDTRFB (not GDTRFB) on reboot despite GDTRFB reportedly holding top preferred-network priority -- Tailscale/DNS/homelab access all appeared broken, general internet was fine medium
After a normal reboot, Tailscale won't connect (stuck), nothing homelab-related resolves -- looks like a full Tailscale/DNS outage • Claude Desktop app and general internet browsing work fine the whole time -- only homelab-specific reachability is broken • networksetup -getairportnetwork en0 shows the laptop joined to Guest_GDTRFB, not GDTRFB (the household SSID, now on Trusted VLAN) or IoT_GDTRFB
wifi ssid guest mac tailscale dns vlan network ucg-fiber   last seen: 2026-08-25

Symptoms

  • After a normal reboot, Tailscale won't connect (stuck), nothing homelab-related resolves -- looks like a full Tailscale/DNS outage
  • Claude Desktop app and general internet browsing work fine the whole time -- only homelab-specific reachability is broken
  • networksetup -getairportnetwork en0 shows the laptop joined to Guest_GDTRFB, not GDTRFB (the household SSID, now on Trusted VLAN) or IoT_GDTRFB
  • GDTRFB was already the top-priority (slot 0) entry in the Mac's preferred-networks list, yet a lower-priority SSID was joined instead
See also: raspi4-followed-wifi-ssid-onto-trusted-vlan, guest-ssid-passphrase-not-persisting, tailscale-mac-dns-bootstrap, tailscaled-magicsock-network-down-stuck, unifi-enhanced-iot-blocks-iphone-handshake

Symptom

Rebooted the Mac laptop; on return, Tailscale was stuck and nothing homelab-related resolved -- looked like a dead Tailscale stack or a DNS outage. The Claude Desktop app and general internet browsing worked fine throughout, which in hindsight was the tell: this wasn't a broken Tailscale/DNS stack, it was a broken *path to the homelab specifically*. Checking the actual Wi-Fi connection showed the laptop had joined Guest_GDTRFB -- a network with real internet egress but no route to Servers/Trusted -- instead of GDTRFB (now on Trusted VLAN, see known-fixes/raspi4-followed-wifi-ssid-onto-trusted-vlan.md) or IoT_GDTRFB.

Root cause

Confirmed: the laptop had Guest_GDTRFB and IoT_GDTRFB in its known Wi-Fi networks at all because MOS had manually joined both during the 2026-08-23 VLAN rollout testing (the same session that created these SSIDs -- see known-fixes/guest-ssid-passphrase-not-persisting.md and known-fixes/unifi-enhanced-iot-blocks-iphone-handshake.md). Both stuck around in the saved-networks list well after that testing was done, alongside GDTRFB itself -- three homelab SSIDs known to one laptop, only one of which actually routes anywhere useful. Not confirmed: why macOS chose Guest_GDTRFB over GDTRFB on this particular reboot when GDTRFB was reportedly already priority slot 0 in the preferred-networks list -- by the ordered-list model, GDTRFB should have won outright. Live preferred-network order wasn't captured with networksetup -listpreferredwirelessnetworks *at the time* of the incident, only recalled afterward, so it's possible the actual live order didn't match what was remembered. Two other unverified candidates worth ruling in/out if this recurs: macOS's auto-join isn't guaranteed to be a strict ordered-list walk in every version (RSSI/last-joined can factor in independently of list position), and all three SSIDs are broadcast from the same UniFi AP(s) at effectively identical signal strength, which removes "stronger signal won" as an explanation without removing whatever else macOS is actually weighting.

Fix

Removed Guest_GDTRFB and IoT_GDTRFB entirely from the Mac's known Wi-Fi networks, leaving GDTRFB as the only homelab SSID it knows at all -- this eliminates the ambiguity outright instead of depending on priority ordering (whose behavior is exactly what's unconfirmed here) to save the next reboot.

Diagnosis commands worth keeping

# Which SSID is actually joined right now
networksetup -getairportnetwork en0

Preferred-network order (first entry = highest priority, in theory)

networksetup -listpreferredwirelessnetworks en0

Remove a stale/test SSID from the known-networks list entirely

sudo networksetup -removepreferredwirelessnetwork en0 "Guest_GDTRFB" sudo networksetup -removepreferredwirelessnetwork en0 "IoT_GDTRFB"

Prevention

- After manually joining a new SSID on a personal device to test network rollout work (as with Guest_GDTRFB/IoT_GDTRFB during the 2026-08-23 VLAN session), remove it from that device's known-networks list once testing is done. A test SSID left in a laptop's saved-network list is a live auto-join candidate on every future reboot/wake, regardless of intended priority -- it doesn't stop being a candidate just because testing moved on. - If a device loses homelab reachability (Tailscale, DNS, SSH) right after a reboot/wake but general internet still works fine, check which SSID it actually joined *before* troubleshooting Tailscale or DNS at all. Guest and IoT VLANs have real WAN egress but no route to Servers/Trusted -- from inside the device this looks identical to a broken Tailscale/DNS stack, and chasing that instead wastes time on a healthy stack.

Current impact

Resolved for this device -- GDTRFB is now the only homelab SSID the laptop knows, removing the ambiguity. The mechanism behind why macOS picked the lower-priority SSID in the first place is still open; if a device with only one known homelab SSID ever mis-joins something else, that would point away from the "extra known SSID" explanation entirely and this file should be revisited.
Stale /etc/hosts entry on the Mac silently overrode DNS for one hostname, sending it to Caddy's LAN IP -- Caddy's own IP allowlist then 403'd it medium
One specific *.compellinglylowbrow.org hostname fails to load while every other one on the same domain works fine, from the same client, at the same time • sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder does not change anything for the affected hostname • curl -v shows the hostname resolving to Caddy's LAN IP (192.168.42.45) instead of its Headscale IP (100.64.0.4)
dns hosts-file caddy macos vlan   last seen: 2026-08-24

Symptoms

  • One specific *.compellinglylowbrow.org hostname fails to load while every other one on the same domain works fine, from the same client, at the same time
  • sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder does not change anything for the affected hostname
  • curl -v shows the hostname resolving to Caddy's LAN IP (192.168.42.45) instead of its Headscale IP (100.64.0.4)
  • The connection succeeds at the TCP/TLS layer but the server returns 403 Forbidden
  • AdGuard's own query log shows zero queries for the affected hostname from this client, even though every other hostname is being queried normally
See also: trusted-vlan-return-traffic-toggle-missing, non-tailnet-host-hangs-on-wildcard-fqdn

Symptom

During the same night's Trusted-VLAN/Tailscale incident (trusted-vlan-return-traffic-toggle-missing.md), after Tailscale on the Mac was fully restored and every other *.compellinglylowbrow.org hostname resolved and loaded correctly, seeder-daemon.compellinglylowbrow.org specifically still failed -- first appearing not to resolve at all, then (after a DNS cache flush did nothing) returning 403 Forbidden once actually tested with curl.

Root cause

The Mac had a stale line in /etc/hosts:
192.168.42.45 seeder-daemon.compellinglylowbrow.org
left over from an earlier, unrelated troubleshooting session (the mechanism is the same intentional workaround documented in known-fixes/non-tailnet-host-hangs-on-wildcard-fqdn.md -- pointing one FQDN directly at Caddy's LAN IP to bypass the wildcard rewrite -- just never removed once whatever it was fixing no longer applied). A hosts-file entry is checked by the OS resolver *before* any DNS query is made, so: - It fully explains why AdGuard's query log showed zero queries for this hostname from the Mac -- the Mac was never asking AdGuard, at all, for this one name. - It explains why dscacheutil -flushcache did nothing -- that flushes the DNS *cache*, not /etc/hosts, which isn't a cache and isn't affected by it. - It explains why only this one hostname was affected while every other one on the same domain worked -- the override was per-hostname, not domain-wide. - Once resolved to 192.168.42.45 (Caddy's LAN IP) instead of the correct wildcard answer 100.64.0.4 (Caddy's Headscale IP), the connection routed over the Mac's physical/VLAN path rather than through Tailscale -- reaching Caddy with a Trusted-VLAN source IP. Caddy's (private) snippet (caddy/Caddyfile) only allows 100.64.0.0/10, 192.168.42.0/24, and the Tailscale IPv6 range -- a Trusted-VLAN IP (192.168.12.0/24) is outside all three, so respond @blocked 403 fired. The TLS handshake completing and a real HTTP response coming back (rather than a connection timeout) is what made this look like progress rather than the same underlying DNS problem -- it was progress, just to a different, still-wrong destination.

Diagnosis

grep -i <hostname> /etc/hosts
If a *.compellinglylowbrow.org hostname resolves to 192.168.42.45 (or any other LAN IP) via curl -v's "Trying ..." line, when every other hostname on the domain correctly resolves to 100.64.0.4, check /etc/hosts before suspecting AdGuard's rewrite table or Caddy's config -- both were completely healthy here.

Fix

Remove the stale line from /etc/hosts (sudo required to edit it). No DNS cache flush, Tailscale restart, or Caddy change needed once it's gone -- resolution falls through to AdGuard immediately and returns the correct wildcard answer.

Prevention

Any time a /etc/hosts override is added as a temporary workaround (the pattern in non-tailnet-host-hangs-on-wildcard-fqdn.md is a *permanent, intentional* use of this same mechanism for a host with no tailnet membership -- that one is fine to leave in place) -- note it somewhere findable (a comment in the hosts file itself, or a line in whatever known-fix motivated it) so it gets cleaned up once the underlying problem it was working around is actually fixed. This one had no such trail, which is exactly why it went unnoticed until it silently broke something unrelated weeks/months later. Not a Caddy config gap: (private)'s allowlist deliberately excludes the Trusted/IoT/Guest VLAN ranges -- that's correct, intentional segmentation (those VLANs are meant to reach internal services via Tailscale/Caddy's Headscale IP, not by hitting Caddy's LAN IP directly). Widening it to include the new VLAN subnets would paper over this class of bug rather than fix it, and would weaken the actual security boundary it exists for. The fix here is removing the bad client-side override, not loosening Caddy.
nastynas → wildwood uses DERP relay instead of direct connection medium
bulk transfer from nastynas to wildwood stalls at ~100-140 kB/s • tailscale ping shows via public DERP relay • nastynas to wildwood never achieves direct connection
tailscale derp nastynas wildwood nat-traversal   last seen: 2026-06-27

Symptoms

  • bulk transfer from nastynas to wildwood stalls at ~100-140 kB/s
  • tailscale ping shows via public DERP relay
  • nastynas to wildwood never achieves direct connection
See also: nastynas-wildwood-bulk-transfer-stalls

Current state (2026-06-27)

The Caddyfile HTTP/1.1 fix has been applied and confirmed working. The home DERP server responds correctly to authenticated HTTP/1.1 connections from nastynas. However nastynas still uses the public sfo relay due to NAT hole-punching failure.

Root causes

1. Caddyfile HTTP/1.1 fix — APPLIED. Caddy was proxying /derp over HTTP/2, returning 426. Fixed with HTTP/1.1 transport block for /derp. Confirmed live. 2. STUN (UDP/3478) — already port-forwarded. BE800 has headscale-stun: UDP 3478 → 192.168.42.177. 3. verify_clients: true means tailscale netcheck always shows blank latency for clb: — unauthenticated probes are rejected. This is not a reliable health indicator. 4. NAT hole-punch failing — nastynas has no inbound WireGuard port forward on BE800. The existing headscale-wireguard forward goes to headscale LXC (192.168.42.177), not nastynas (192.168.42.200).

Decision (2026-06-27)

Leave as-is. The relay path (~14ms via sfo) is acceptable. All offsite backups originate from proxmox-nuc via the known-fast direct path. Adding more port forwards increases fragility.

SUPERSEDED (2026-08-09) — "all offsite backups originate from proxmox-nuc" is no longer true

As of BACKUP-HARDENING-CONTEXT.md Threads 2/3 (2026-08-09), nastynas is a live offsite-sync source in its own right (bin/nastynas-self-offsite-sync.sh, bin/proxmox-nuc-offsite-sync.sh), retested at ~85 MB/s raw / 267 MiB/s via PBS's sync protocol — see the SUPERSEDED note on nastynas-wildwood-bulk-transfer-stalls.md for the full retest. Whether that traffic still rides the DERP relay or achieved direct connection was not re-verified as part of that work; the throughput numbers above suggest it's a non-issue in practice either way, but treat this entry's diagnosis steps as still valid if a *new* nastynas↔wildwood slowness shows up, not its "all backups originate from proxmox-nuc" framing.

Verify DERP fix is intact

# Confirm HTTP/1.1 transport for /derp in Caddyfile
ssh caddy "grep -A8 'headscale.compellinglylowbrow.org {' /etc/caddy/Caddyfile"

Must show @derp matcher and versions 1.1

Confirm DERP responds to HTTP/1.1 from nastynas

ssh nastynas "curl -v --http1.1 --max-time 5 -H 'Upgrade: WebSocket' -H 'Connection: Upgrade' https://headscale.compellinglylowbrow.org/derp 2>&1 | grep 'HTTP/1'"

Must return HTTP/1.1 200 OK (not 426)

A host with no Tailscale/Headscale client hangs (not fails fast) resolving any *.compellinglylowbrow.org name medium
curl/Prometheus scrape to an internal *.compellinglylowbrow.org hostname times out after the full timeout window instead of failing immediately • DNS resolves correctly (dig/nslookup returns an answer), the connection itself hangs • curl -v shows 'Trying <IP>...' then nothing until the timeout
headscale tailscale dns prometheus routing   last seen: 2026-07-20

Symptoms

  • curl/Prometheus scrape to an internal *.compellinglylowbrow.org hostname times out after the full timeout window instead of failing immediately
  • DNS resolves correctly (dig/nslookup returns an answer), the connection itself hangs
  • curl -v shows 'Trying <IP>...' then nothing until the timeout
  • the target IP is in the 100.64.0.0/10 range (a Headscale/Tailscale address)
  • `tailscale` command not found, or `tailscale status` shows not connected, on the host making the request

Cause

AdGuard's wildcard DNS rewrite for *.compellinglylowbrow.org always answers with Caddy's Headscale IP (100.64.0.4) — this is correct and intentional for any host that is itself a Tailscale/Headscale peer (see INFRASTRUCTURE.md's Remote Access section). But a host that was never joined to the tailnet has no network interface capable of routing to a 100.64.0.0/10 address at all. The connection attempt isn't rejected — it just has nowhere to go, and hangs until the client's own timeout fires. This is the mirror image of the well-documented "remote client resolves to Caddy's *LAN* IP and gets nothing" failure — same root shape (DNS answer doesn't match what this particular host can actually reach), opposite direction. Confirmed live 2026-07-20: Prometheus (on the grafana LXC) was given a new scrape target, metrics.compellinglylowbrow.org (Caddy's native per-host metrics, added the same day). The scrape consistently failed with context deadline exceeded at exactly the configured scrape_timeout (10s) — the same *silent hang* signature this repo's Headscale ACL notes already associate with missing-host ACL gaps (see headscale/acl.hujson's FOLLOW-UP FIX 2/3/4/5 comments). That similarity is a red herring here: the ACL's rule 1 already grants src: ["*"] reach to caddy:443 -- any node, named or not, is already permitted. The real cause was one level down, at routing rather than policy: tailscale: command not found on the grafana LXC confirmed it was never a tailnet peer in the first place, matching acl.hujson's own header comment ("grafana, and most LXCs have no tailnet dependency today and get none"). Nothing before this had ever needed grafana to *initiate* a connection to anything over the tailnet -- every prior interaction went the other direction (browser -> Caddy -> reverse proxy -> grafana's LAN IP).

Diagnosis

Three checks, in order of how quickly they isolate the layer:
# is this host even a tailnet peer?
tailscale status 2>&1 | head -5

what does its own resolver actually return?

dig +short <hostname>.compellinglylowbrow.org

the real, unabstracted symptom -- don't trust an abstraction layer

(Prometheus's target status page, in this case) as the first signal

curl -v --max-time 5 https://<hostname>.compellinglylowbrow.org/<path> 2>&1 | tail -20
If tailscale is missing/not connected AND the resolved IP is in 100.64.0.0/10, this is the routing-layer problem above, not an ACL gap -- don't go check acl.hujson first, despite the symptom's surface resemblance to those incidents.

Fix

Give the non-tailnet host a permanent /etc/hosts override pointing that one FQDN at Caddy's LAN IP instead of relying on AdGuard's wildcard rewrite. This is the same mechanism already used as a *temporary* fixture elsewhere in this repo (the DNS bootstrap catch-22 workaround during initial Tailscale registration) -- here it's permanent, since the host has no other reason to ever join the tailnet:
echo "192.168.42.45 metrics.compellinglylowbrow.org" | sudo tee -a /etc/hosts
This only affects DNS resolution *on this one host* for *this one name* -- AdGuard's wildcard rewrite is untouched for every other consumer. Caddy still receives the correct SNI/Host header (only the destination IP changed, not the request itself), so TLS certificate selection and per-site routing both work exactly as they would via the Headscale IP.

Note

Any future Prometheus scrape target (or any other grafana-LXC-initiated request) that resolves through AdGuard's wildcard rewrite will hit this same wall. Rather than adding a one-off /etc/hosts line per future target, it may be worth deciding once whether grafana should actually join the tailnet (bringing it in line with watchdog, developer-env, etc.) or whether the /etc/hosts override pattern should just be the standing convention for this host. Not resolved as part of this incident -- flagging for a future session.
Emoji or other non-ASCII text in an ntfy Title header silently drops the notification, even though the caller logs success medium
Script log says 'ntfy sent' but nothing arrives in the ntfy app/UI • ntfy send failed: 'latin-1' codec can't encode character ... in position 0: ordinal not in range(256) • A HOLD/CAUTION verdict with an emoji-prefixed title never shows up, but SAFE (no icon, or an icon that happens to work) does
ntfy unicode http-headers python urllib check-dev-upgrades silent-failure   last seen: 2026-07-22

Symptoms

  • Script log says 'ntfy sent' but nothing arrives in the ntfy app/UI
  • ntfy send failed: 'latin-1' codec can't encode character ... in position 0: ordinal not in range(256)
  • A HOLD/CAUTION verdict with an emoji-prefixed title never shows up, but SAFE (no icon, or an icon that happens to work) does

Cause

Python's http.client (used under the hood by urllib.request) encodes HTTP header *values* as latin-1/ASCII regardless of how the request body itself is encoded. bin/check-dev-upgrades's send_ntfy() built its Title header as f"{icon} developer-env: ...", where icon was an emoji (\u2705, \u26a0\ufe0f, \U0001f6d1, \u2753) selected by verdict. The moment a HOLD verdict actually fired — 2026-07-22, the first real HOLD this script had ever produced — urlopen() raised UnicodeEncodeError trying to encode that header, which was caught by a bare except Exception inside send_ntfy() and only logged, never raised. The caller (main()) then logged "ntfy sent (HOLD, priority=4)" unconditionally on the very next line, with no check of whether the send actually succeeded. Net result: a real HOLD alert (36 packages, including linux-image-generic) never reached ntfy, and the log actively claimed it had. This is a general trap, not specific to this one script: **any header value with non-ASCII characters (emoji, curly quotes, accented letters, etc.) will raise the same error** in any script built the same way. The notification *body* (POST payload) is a different code path — it's .encode()-ed as UTF-8 bytes, not a header, so emoji there is completely safe. Only header values (Title, Priority, Tags, or any custom header) are at risk.

Diagnosis

# Look for this specific error in a script's output/log:
grep "codec can't encode character" <script-output>

Confirm the title that was attempted contained non-ASCII:

python3 -c "print('<the title string>'.isascii())"

False => confirmed cause

If a script's log claims a notification was sent but nothing shows up in ntfy, check whether the send function's return value (if any) is actually being checked by the caller — a caught-and-logged exception with no propagated failure signal will produce exactly this "sent but not really" symptom.

Fix

Applied to bin/check-dev-upgrades 2026-07-22: - Move the verdict icon out of the Title header entirely and into the message *body* instead (first line: f"{icon} {verdict}\n\n...") — body encoding as UTF-8 bytes handles emoji fine. - send_ntfy() now returns bool (True/False for success/failure) and defensively asserts title.isascii(), aborting with a clear log line rather than attempting a doomed urlopen() call. - All three call sites in the script now log the real outcome ("ntfy sent" vs "ntfy FAILED to send") based on the return value, instead of assuming success unconditionally.

Prevention

For any future script that builds ntfy (or any HTTP) notifications: - Never put emoji, curly quotes, or other non-ASCII characters directly into a header value. If you want an icon/symbol in front of a title, either keep the title itself plain ASCII and put the icon in the body, or (if the title absolutely needs it) RFC 2047-encode the header value properly — the former is simpler and was the fix chosen here. - Any function that sends a notification and can fail should return a success/failure signal, and callers should log/branch on that signal rather than assuming success right after the call. A bare except Exception: log(...) with no re-raise and no return value is exactly what let this go unnoticed — the failure was recorded, but only as an easy-to-miss line sitting right next to a false "success" line.
nuc-fileserver Samba silently falls back to guest for unrecognized users, masking auth failures as broken access; missing macOS interop modules compound perceived slowness medium
samba smb nuc-fileserver macos turnkey guest-access   last seen:

Symptom

MOS's Mac (now his main machine, was previously a PC) reported the nuc-fileserver SMB share as "pretty slow" and, more recently, having "problems accessing it at all" — connections that don't clearly fail, but don't actually let him read/write files either.

Root cause

Two independent issues, both real: 1. Silent guest fallback masking a missing Samba account. pdbedit -L (Samba's own user database, tdbsam backend) listed only root, nobody, smb-music, cdripper, winedotcom — **mos, the actual Unix account and the only personal one in the users group that the nuc-ssd share's write list = @users grants access to, had no Samba password ever set** (never run through smbpasswd/pdbedit -a). Because smb.conf has map to guest = Bad User, an unrecognized username/password doesn't get a clean auth failure — it gets silently mapped to the guest account instead. Confirmed via journalctl -u smbd: every recent connection (from both Bonjour discovery, FILESERVER._smb._tcp.local, and the tailnet IP, 100.64.0.7) ended with session closed for user nobody. Since neither nuc-ssd nor cdripper grants guest access, these guest sessions connect fine but can't actually do anything — which from the Mac side looks exactly like "having problems accessing it," not like an obvious login failure. **2. No macOS interop VFS modules — contributes to real slowness even once auth is fixed.** smb.conf only had vfs object = recycle (note: singular vfs object, an older/alias spelling of vfs objects that Samba still accepts and that testparm -s normalizes in its dump — this tripped up a first sed attempt at editing the raw file, since the raw file doesn't match the effective-config spelling testparm shows). No fruit/streams_xattr modules were loaded. Apple's own documented recommendation for Samba+macOS is to load these — without them, macOS falls back to client-side ._* AppleDouble sidecar files for resource forks/metadata, which is a well-known source of sluggish Finder browsing and copies, especially against a tree this large (the nuc-ssd share exposes the same 7.3TB bind-mounted drive that lyrionmusicserver's 37,585+ file SHN library lives on). Not a root cause, just noise: `parse_dfs_path: can't parse hostname from path FILESERVER._smb._tcp.local` warnings in the smbd log are Avahi/Bonjour mDNS discovery artifacts — smbd recovers each time ("trying to convert X to a local path") and this doesn't appear to correlate with the actual guest-fallback failures on its own. Separately, already fixed same day: off-LAN (Headscale/tailnet) access to nuc-fileserver was completely blocked at the network layer until this session — the host was never declared in headscale/acl.hujson at all. See that file's FOLLOW-UP FIX 8 note and INFRASTRUCTURE.md's matching entry. Independent bug from the two above, but likely compounded the "can't access it at all" impression if off-LAN access was ever attempted before that fix landed.

Fix

vfs_fruit/streams_xattr (applied 2026-08-08): backed up /etc/samba/smb.conf, changed the [global] vfs line from
vfs object = recycle
to
vfs objects = fruit streams_xattr recycle
fruit:metadata = stream
fruit:posix_rename = yes
fruit:veto_appledouble = no
fruit:wipe_intentionally_left_blank_rfork = yes
fruit:delete_empty_adfiles = yes
Validated with testparm -s (no errors), systemctl restart smbd nmbd, confirmed both active and listening on 445/139 afterward. **Missing Samba account for mos — needs to be done interactively by MOS, not scripted here:**
ssh root@192.168.42.63
smbpasswd -a mos
followed by clearing any stale saved credentials in macOS Keychain Access (search FILESERVER / 192.168.42.63 / 100.64.0.7) and reconnecting via Finder → Go → Connect to Server with `Connect As: Registered User`, not guest. Deliberately not run by Claude — setting an interactive password over a scripted SSH session would put the credential in the tool-call transcript.

Prevention

- When a share's write list/valid users references a Unix group, check that every intended member actually has a Samba passdb entry (pdbedit -L) — group membership alone does nothing for SMB auth. - map to guest = Bad User (TurnKey's default) turns every credential mistake into a silent, hard-to-diagnose "connects but doesn't work" symptom instead of a clear auth failure. Worth considering map to guest = Never if guest access is never actually wanted on this box, so future credential problems fail loudly instead. - Any Samba share serving macOS clients should get fruit/ streams_xattr from the start — this is Apple's own documented recommendation, not a homelab-specific quirk. - The raw smb.conf and testparm -s's effective-config dump don't always share directive spelling (vfs object vs vfs objects here) — edit the raw file, not what testparm prints, and verify with grep/cat -A before assuming a sed pattern will match.

Related

See INFRASTRUCTURE.md's "Known gap surfaced..." entries near the nuc-fileserver ACL fix for the network-layer half of this incident. This box is TurnKey FileServer (Debian 12 bookworm base), which also runs an unusually large stock service set (apache2, postfix, pure-ftpd, fail2ban, avahi-daemon, inetd, wsdd, webmin — 20 services total for what's meant to be a simple file share) and a loop-mounted rootfs (/dev/loop7) — both flagged during this investigation as candidates for a future rebuild onto a plain minimal Debian LXC via onboard-container, not yet decided/scheduled.
Omada switch config (rename, etc.) applied but not saved to flash -- reverts on reboot/reset medium
network switch omada tp-link hardware config-persistence   last seen:

Symptom

Renamed the new Omada ES210X-M2 switch and confirmed port/VLAN state via its web UI the night before cutover (2026-08-12). The next day, after logging back in, the router's DHCP client list still showed the switch under its model-number hostname, not the rename.

Root cause

The switch's web UI separates "Apply" (per-setting, takes effect on the running config) from a distinct "Save Config" / save-to-flash action. The rename was applied to the running config only; nothing wrote it to startup config, so it silently reverted on the next reboot. No error was shown at either the apply step or the eventual revert.

Fix / prevention

After any config change on this switch, explicitly find and click the separate Save/Save-to-flash action (often a disk icon, distinct from each setting's own Apply), then prove persistence with a UI-initiated reboot (not a power pull) before relying on the change or moving cabling. Verified working via this exact test on 2026-08-13: renamed, explicitly saved, rebooted from the UI, hostname and DHCP-reserved IP both survived.

Related incident, same session

Two login attempts against the wrong credentials (guessing ES210X-M2, the switch's own model number, as a username, then trying admin/admin) left the account with limited attempts remaining before a lockout. Since the switch was still factory-fresh at that point (Phase 1 cutover hadn't started), a full factory reset was the safe recovery path (recessed reset pinhole, hold ~5-10s) rather than risking a third guess -- there was no real config to lose yet. After the reset, logged in as a newly created mos user/password (saved to Vaultwarden), which is when the persistence gap above was discovered on the *second* config attempt.

Generalizes to

Any managed-switch/router web UI with a Cisco-style running-vs-startup-config split -- don't assume a plain "Save" button inside a settings panel writes to flash; look for a dedicated, separate save/commit action, and prove persistence with a reboot before trusting a change.
GPG key trust failure during Tailscale install on Debian trixie medium
apt-get update fails with BADSIG during Tailscale install • sqv: No binding signature valid under the policy • Tailscale install fails on Debian 13 trixie
onboard-container tailscale trixie debian gpg   last seen: 2026-06-16

Symptoms

  • apt-get update fails with BADSIG during Tailscale install
  • sqv: No binding signature valid under the policy
  • Tailscale install fails on Debian 13 trixie

Cause

Debian trixie uses sqv as its OpenPGP verifier instead of gpg. sqv applies stricter policy checks and rejects the manual GPG key setup approach.

Fix

Use Tailscale's officially supported .list file method:
. /etc/os-release
mkdir -p /usr/share/keyrings
curl -fsSL -4 "https://pkgs.tailscale.com/stable/${ID}/${VERSION_CODENAME}.noarmor.gpg" \
    -o /usr/share/keyrings/tailscale-archive-keyring.gpg
curl -fsSL -4 "https://pkgs.tailscale.com/stable/${ID}/${VERSION_CODENAME}.tailscale-keyring.list" \
    -o /etc/apt/sources.list.d/tailscale.list
apt-get update -qq
apt-get install -y tailscale

Note

This issue is specific to trixie (Debian 13). The .list file method works on all versions and is the preferred approach regardless.
Container not SSHable after onboarding — SSH key not injected medium
ssh root@<container-ip> returns Permission denied (publickey) after onboarding • container is reachable by IP and Headscale registration succeeded
onboard-container ssh authorized-keys   last seen: 2026-06-16

Symptoms

  • ssh root@<container-ip> returns Permission denied (publickey) after onboarding
  • container is reachable by IP and Headscale registration succeeded

Cause

Tailscale install does not inject any SSH authorized keys. The container starts with an empty /root/.ssh/authorized_keys.

Fix

pub=$(cat ~/.ssh/id_ed25519.pub)
ssh root@<proxmox-node-ip> "pct exec <vmid> -- sh -c \
  'mkdir -p /root/.ssh && chmod 700 /root/.ssh && \
   echo \"$pub\" >> /root/.ssh/authorized_keys && \
   chmod 600 /root/.ssh/authorized_keys'"

Verify

ssh root@<container-ip> hostname

Note

SSH key injection is non-fatal in onboard-container — a failure prints a warning but does not abort onboarding. Check onboard-logs/ if SSH access doesn't work after onboarding.
Tailscale install fails on LXC with no IPv6 route — curl hangs medium
onboard-container Phase 5 stalls during Tailscale installation • curl commands hanging indefinitely on secondary Proxmox node • same container onboards fine on proxmox-nuc
onboard-container tailscale ipv6 curl   last seen: 2026-06-16

Symptoms

  • onboard-container Phase 5 stalls during Tailscale installation
  • curl commands hanging indefinitely on secondary Proxmox node
  • same container onboards fine on proxmox-nuc

Cause

curl defaults to trying IPv6 first. Containers on secondary Proxmox nodes (macbookpro-pve, nastynas) may have no IPv6 route. When curl attempts IPv6, it hangs rather than falling back to IPv4.

Diagnosis

curl -v https://pkgs.tailscale.com/stable/debian/bookworm.noarmor.gpg 2>&1 | head -10

Hangs after "Trying 2620:..." → IPv6 no route

curl -4 -v https://pkgs.tailscale.com/stable/debian/bookworm.noarmor.gpg 2>&1 | head -5

Connects immediately → confirmed IPv4-only container

Fix

Pass -4 to all curl calls to force IPv4. onboard-container includes -4 on all curl calls by default.
onboard-container/offboard-container never wrote to headscale/acl.hujson -- root cause of FOLLOW-UP FIX 7, fixed at the source 2026-08-13 medium
headscale acl onboard-container offboard-container lxc-lifecycle   last seen:

Summary

bin/onboard-container registers a new container's live Tailscale node (gets it a Headscale IP) but never declared it in headscale/acl.hujson -- no hosts{} entry, no rule granting anything access to it. bin/offboard-container's Headscale deregistration was the mirror-image gap: it removed the live node registration but never touched the policy file either. Both scripts have always had this gap. This is the documented root cause of the 2026-08-08 "FOLLOW-UP FIX 7" incident (see TROUBLESHOOTING.md / INFRASTRUCTURE.md): searxng, apache-guacamole, immich, and vaultwarden were all onboarded through this exact tool and then silently hung on SSH via their Headscale IP for weeks -- implicit-deny, not a fast error, matching this repo's "host missing entirely from acl.hujson produces a silent hang" pattern (already confirmed independently on proxmox-nuc and seeder-daemon before this). That incident patched the four affected hosts by hand; this fix closes the gap at the source so it can't recur on the next onboard.

Fix

Both scripts now handle ACL directly, via a shared acl_deploy() pattern (SCP candidate to the headscale LXC, headscale policy check -f before touching anything live, backup, swap in, restart, confirm active, read back and byte-diff): - onboard-container (Phase 5b): adds a hosts{} entry + developer-env SSH access in rule 4's dst[] right after Headscale registration succeeds. Deliberately conservative baseline -- only what every onboarded host has actually needed so far. Detects a commented-out historical entry from a prior decommission and adds a fresh one anyway rather than treating the comment as "already registered." - offboard-container (Phase 4b): comments out (never deletes -- matches the hosts-config.yaml/Caddyfile decommission convention already established elsewhere in this repo) the active hosts{} entry and any active dst[] references. Idempotent -- a second run against an already-offboarded name is a clean no-op. Both are soft-fail: a failed ACL deploy prints the manual-fix path and lets the rest of onboard/offboard proceed, rather than blocking the whole run over a policy-file write.

Two more bugs found while testing this fix, before it shipped

Tested via a real round-trip against live infra (safe because apache-guacamole's LXC no longer exists, so nothing real was on the other end): re-added its ACL entry via the new onboard path, then removed it via the new offboard path. **1. Unanchored removal regex matched mid-line, past a comment marker, and corrupted an unrelated live entry.** The first version of the removal regex wasn't ^-anchored with re.MULTILINE, so a match could start anywhere the pattern fit -- including right after a // prefix on an *adjacent, already-commented* line. Concretely: removing apache-guacamole's (already-commented) dst[] reference also consumed the *following* line's content, silently commenting out the live "immich:22" entry in the same rule. This actually deployed to the live headscale LXC and restarted the service before being caught -- briefly taking down developer-env's real Headscale SSH access to immich in production, during the test. headscale policy check didn't catch it because the result was still syntactically valid HuJSON -- just semantically wrong. Caught immediately by the round-trip's before/after diff; both repo and live copies restored via git checkout -- (confirmed first that the file had no other pre-existing uncommitted drift, per [[git-checkout-discards-preexisting-uncommitted-drift]]) and a redeploy. Fix: every removal/detection regex now anchored with ^ + re.MULTILINE so a match can only start at a real line start. **2. headscale policy check -f can transiently fail right after a prior restart.** It talks to the running daemon over `/var/run/headscale/ headscale.sock`, which isn't guaranteed to be back yet a couple seconds after a previous acl_deploy() call's systemctl restart headscale -- hit for real when the round-trip test called add then remove back-to-back. Fix: narrow retry (4x, 2s apart) in acl_deploy(), but only for that specific socket-connection error string -- a genuine policy syntax error still fails fast on the first attempt, same as before.

Verification

Round-trip re-run clean after both fixes: add correctly detected the commented historical entry and added a fresh active one; remove commented it back out; a second remove call was a clean no-op; immich:22 stayed untouched throughout; live headscale LXC file matched the repo copy at every step. bin/health-check: 65 PASS / 0 FAIL before and after.

Prevention / lesson

A regex-based text edit near existing comments needs ^/re.MULTILINE anchoring as a default, not an afterthought -- an unanchored pattern can match a syntactically-plausible substring in the wrong place and silently produce output that's valid at the file-format level but wrong at the semantic level, which no schema/policy validator downstream will catch. Testing this kind of change against real infra (not just eyeballing the regex) is what caught it here -- see the full design and the lxc-lifecycle skill this fix supports: docs/lxc-auto-onboard-detection.md, .claude/skills/lxc-lifecycle/SKILL.md.
PBS enterprise repo missing Enabled: false → apt-get update fails 401, unattended-upgrades silently broken medium
Proxmox UI task log shows 'Update package database' failing: command 'apt-get update' failed: exit code 100 • apt-get update shows: Err:N https://enterprise.proxmox.com/debian/pbs trixie InRelease 401 Unauthorized • E: The repository 'https://enterprise.proxmox.com/debian/pbs trixie InRelease' is not signed
apt pbs proxmox enterprise-repo unattended-upgrades nastynas   last seen: 2026-07-06

Symptoms

  • Proxmox UI task log shows 'Update package database' failing: command 'apt-get update' failed: exit code 100
  • apt-get update shows: Err:N https://enterprise.proxmox.com/debian/pbs trixie InRelease 401 Unauthorized
  • E: The repository 'https://enterprise.proxmox.com/debian/pbs trixie InRelease' is not signed
  • unattended-upgrades has been failing for days/weeks with no other visible symptom -- the box otherwise looks healthy
See also: pbs-repo-not-in-apt, proxmox-mirror-release-file-transient

Cause

Proxmox (both PVE and PBS) ships an -enterprise.sources apt source pointing at enterprise.proxmox.com, intended for boxes with a paid subscription. Without a valid subscription, any request to that host returns 401 Unauthorized. APT treats a single failed source as a hard failure for the *entire* apt-get update run (exit code 100) -- even though every other source (Debian, security, the -no-subscription repo, Tailscale, etc.) fetched fine. Since unattended-upgrades runs `apt-get update` under the hood, this silently breaks all automatic updates on the box, with no symptom other than a task-log entry in the Proxmox UI that's easy to never look at. On Trixie-based Proxmox installs, these live as deb822-format /etc/apt/sources.list.d/*.sources files (not the older single-line .list format), each ending with an explicit Enabled: false line to keep the enterprise source present-but-inert. On nastynas, pve-enterprise.sources and ceph.sources both had this line (last touched 2026-01-25), but pbs-enterprise.sources (untouched since 2025-07-11) did not -- it was missed when the other enterprise repos were disabled, and defaulted to enabled ever since. First observed failure in the Proxmox task history predates 2026-06-25; likely been broken since whenever the PVE-side repos were disabled without the PBS-side one getting the same treatment. Grepping for .list files will miss this -- the enterprise repo config lives in the .sources (deb822) file, not sources.list.d/*.list.

Diagnosis

ssh nastynas "cat /etc/apt/sources.list.d/*.sources"
ssh nastynas "apt-get update"   # look for Err: ... 401 Unauthorized on an enterprise.proxmox.com URL
Check specifically whether the failing file's stanza ends with Enabled: false or not -- compare against sibling files (e.g. pve-enterprise.sources) that are already correctly disabled.

Fix

ssh nastynas "echo 'Enabled: false' >> /etc/apt/sources.list.d/pbs-enterprise.sources"
ssh nastynas "apt-get update"   # confirm all Hit:/Get:, no Err:

Fleet-wide sweep

Confirmed 2026-07-06: proxmox-nuc and macbookpro-pve were both clean -- every *enterprise*.sources file on those hosts already had Enabled: false. This appears to have been an isolated miss on nastynas, not a fleet-wide pattern. Re-check after onboarding any new Proxmox node or after any manual repo/subscription change:
for h in proxmox-nuc macbookpro-pve nastynas; do
  echo "=== $h ==="
  ssh $h "grep -L 'Enabled: false' /etc/apt/sources.list.d/*enterprise*.sources 2>/dev/null || echo none-missing"
done
Any filename printed (other than none-missing) is a live 401-in-waiting.

Follow-up (not yet done)

This sat broken for at least ~2 weeks before the Proxmox UI task log was checked manually. Worth adding an explicit detect-drift check that greps every collected node's *enterprise*.sources for a missing Enabled: false line, so this surfaces on the normal 6h collect-homelab cycle instead of requiring someone to notice the UI task log. Not yet built as of 2026-07-06 -- discuss whether it's worth the addition given this is a rare, one-time-per-node-setup class of drift rather than something that re-breaks on its own.
PBS offsite sync-jobs fail under cron: proxmox-backup-manager: command not found medium
ntfy: 'nastynas offsite sync: FAILED', 'proxmox-nuc offsite sync: FAILED', and/or 'wildwood offsite sync: FAILED' (exit 127) • sync.log shows: <script>.sh: line N: proxmox-backup-manager: command not found • manually re-running the same script over SSH works fine
cron path pbs nastynas wildwood offsite-backup sync-job   last seen: 2026-08-12

Symptoms

  • ntfy: 'nastynas offsite sync: FAILED', 'proxmox-nuc offsite sync: FAILED', and/or 'wildwood offsite sync: FAILED' (exit 127)
  • sync.log shows: <script>.sh: line N: proxmox-backup-manager: command not found
  • manually re-running the same script over SSH works fine
  • local vzdump/backup jobs are unaffected -- only the offsite push fails
See also: pbs-sync-job-direction, pbs-storage-add-403-cannot-find-datastore

Cause

proxmox-backup-manager lives at /usr/sbin/proxmox-backup-manager on nastynas. Cron's default minimal PATH (/usr/bin:/bin, no user-specific PATH= line set in the crontab) does not include /usr/sbin. An interactive/login SSH shell finds it fine -- root's PATH includes /usr/sbin via /etc/login.defs' ENV_SUPATH -- which is exactly why every manual test run of bin/nastynas-self-offsite-sync.sh / bin/proxmox-nuc-offsite-sync.sh passed during development (2026-08-09) but both jobs failed with exit 127 on their first two *unsupervised* cron firings (2026-08-10 and 2026-08-11, 04:00/04:30). Local vzdump/backup jobs (03:00 nastynas-self, proxmox-nuc's own 21:00 job) were never affected -- those are PVE-native scheduled jobs, not shell scripts calling a bare command name under cron's PATH.

Diagnosis

ssh root@100.64.0.113 "tail -30 /var/log/nastynas-self-offsite-sync/sync.log"
ssh root@100.64.0.113 "tail -30 /var/log/proxmox-nuc-offsite-sync/sync.log"

Look for: "<script>.sh: line N: proxmox-backup-manager: command not found"

Confirm it's a PATH issue, not a missing/broken install:

ssh root@100.64.0.113 "which proxmox-backup-manager" # -> /usr/sbin/proxmox-backup-manager (found) ssh root@100.64.0.113 "env -i /bin/sh -c 'PATH=/usr/bin:/bin; proxmox-backup-manager version'"

-> not found, reproduces cron's exact failure

Resolution

Affected scripts now call the binary by absolute path (PBM="/usr/sbin/proxmox-backup-manager" set near the top, invoked as "$PBM" sync-job run ...) instead of relying on PATH — sidesteps the cron-vs-interactive-shell PATH difference entirely rather than trying to set PATH= correctly in the crontab (fewer moving parts, and matches this repo's general preference for scripts that don't depend on caller environment). Fixed in bin/nastynas-self-offsite-sync.sh and bin/proxmox-nuc-offsite-sync.sh on 2026-08-11, deployed to /root/ on nastynas, sha256-verified against the repo copies. Both scripts re-run manually same day to catch up the ~1.5-day offsite gap this caused (nastynas-self: 2.5GiB/2 snapshots; proxmox-nuc: 123.5GiB across all 18 groups) — confirmed TASK OK on both, verified via sync.log. Missed on the first pass: bin/wildwood-self-offsite-sync.sh is a third sibling script with the exact same bare-proxmox-backup-manager-call bug, not covered by the 2026-08-11 fix even though it was written against the same design and by the same person as the other two. It failed silently (well, loudly via ntfy, but unactioned) for 3 straight nights — 2026-08-10, 08-11, 08-12 — before being caught 2026-08-12 while triaging ntfy backup alerts. Fixed the same way, deployed to /root/ on wildwood, sha256-verified, re-run manually same day (pushed 2.679 GiB / 721 chunks / 3 snapshots, TASK OK). Lesson: when a bug is found in one of a family of near- identical sibling scripts, grep the whole family for the same pattern before closing the loop — don't assume "fixed the ones I was looking at" == "fixed everywhere it exists." All three offsite-sync scripts (nastynas-self-, proxmox-nuc-, wildwood-self-offsite-sync.sh) are now confirmed on the absolute-path pattern as of 2026-08-12.

Prevention checklist for any new cron-triggered script on nastynas, wildwood

(or any Debian/PVE host) that shells out to a PVE/PBS binary - pvesh, pveam, pvesm, proxmox-backup-manager, proxmox-backup-client, etc. commonly live in /usr/sbin or /usr/bin depending on the tool -- don't assume cron's PATH matches an interactive shell's. - Either hardcode the absolute path (this repo's convention going forward) or set an explicit PATH= line in the crontab entry itself. - **Test the actual cron-triggered invocation, not just a manual SSH run of the same script** — a manual run inherits the login shell's PATH and will pass even when the identical cron firing would fail. run-update's existing hosts-config.yaml pre_update/post_update steps run over ssh host "cmd" (which also gets root's full interactive PATH) — this class of bug is specific to cron, not to SSH-driven automation generally. - When fixing this bug in one script, grep -rl "proxmox-backup-manager\|proxmox-backup-client\|pvesh\|pvesm\|pveam" bin/ for sibling scripts with the same pattern before calling the fix done — the 2026-08-11 pass fixed two of three near-identical offsite-sync scripts and missed the third (wildwood-self-offsite-sync.sh), which then failed unnoticed for 3 more nights. A bug found in one member of a copy-pasted script family is present in all of them until proven otherwise.
PBS package not found — repo not in apt sources medium
apt-get install proxmox-backup-server returns E: Unable to locate package
pbs apt repo   last seen: 2026-06-24

Symptoms

  • apt-get install proxmox-backup-server returns E: Unable to locate package

Cause

PBS is in a separate apt repository, not in the PVE repo.

Fix

Add in deb822 format (.sources extension — one-liner format causes "Malformed stanza" error):
cat > /etc/apt/sources.list.d/pbs.sources << 'EOF'
Types: deb
URIs: http://download.proxmox.com/debian/pbs
Suites: trixie
Components: pbs-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOF
apt-get update && apt-get install -y proxmox-backup-server

Note

The existing Proxmox keyring (proxmox-archive-keyring.gpg) covers the PBS repo — no separate key needed.
PBS storage add fails — 401 Unauthorized medium
pvesm add pbs returns 401 Unauthorized • correct credentials but still 401
pbs acl auth   last seen: 2026-06-24

Symptoms

  • pvesm add pbs returns 401 Unauthorized
  • correct credentials but still 401

Cause

The --username flag must be the full token ID including the !tokenname suffix, not just the user.

Fix

# Wrong:
pvesm add pbs ... --username backup@pbs

Correct:

pvesm add pbs ... --username backup@pbs!nuc
PBS storage add fails — Cannot find datastore (403 Forbidden) medium
Cannot find datastore 'backups', check permissions and existence • datastore exists but 403 on pvesm add
pbs acl datastore permissions   last seen: 2026-06-24

Symptoms

  • Cannot find datastore 'backups', check permissions and existence
  • datastore exists but 403 on pvesm add
See also: pbs-prune-fails-token-perms-capped

Cause

ACL grants to the *user* (backup@pbs) are not inherited by *tokens* (backup@pbs!nuc). The token needs its own explicit ACL grants, and needs both DatastoreBackup AND DatastoreAudit on the specific datastore path.

Fix

ssh nastynas "proxmox-backup-manager acl update /datastore/backups DatastoreBackup --auth-id backup@pbs!nuc"
ssh nastynas "proxmox-backup-manager acl update /datastore/backups DatastoreAudit --auth-id backup@pbs!nuc"

Verify

ssh nastynas "proxmox-backup-manager acl list"
PBS sync job fails — 403 Forbidden querying namespaces medium
proxmox-backup-manager sync-job run fails • Querying namespaces failed - HTTP error 403 Forbidden
pbs sync acl permissions   last seen: 2026-06-24

Symptoms

  • proxmox-backup-manager sync-job run fails
  • Querying namespaces failed - HTTP error 403 Forbidden
See also: pbs-storage-add-403-cannot-find-datastore

Cause

The sync token on the destination PBS needs explicit ACL grants on the destination datastore. Grants to the user are not inherited by the token.

Fix

On the destination PBS node (wildwood), grant both roles to the sync token:
ssh wildwood "proxmox-backup-manager acl update /datastore/pbs DatastoreBackup --auth-id sync@pbs!nastynas"
ssh wildwood "proxmox-backup-manager acl update /datastore/pbs DatastoreAudit --auth-id sync@pbs!nastynas"
PBS sync job syncs in wrong direction — pull only, job lives on destination medium
sync job completes but syncs wrong direction • destination content appears in source
pbs sync   last seen: 2026-06-24

Symptoms

  • sync job completes but syncs wrong direction
  • destination content appears in source

Cause

PBS sync jobs always *pull* — the job runs on the destination node and pulls from the configured remote source. There is no push mode.

Implication

The sync job for nastynas → wildwood must be created on wildwood (the destination), not on nastynas.

Setup

# On wildwood: create remote pointing at nastynas
proxmox-backup-manager remote create nastynas \
  --host 100.64.0.113 \
  --auth-id backup@pbs!nuc \
  --password <token-value> \
  --fingerprint <nastynas-fingerprint>

On wildwood: create sync job

proxmox-backup-manager sync-job create nastynas-nightly \ --store pbs \ --remote nastynas \ --remote-store backups \ --schedule 'daily' \ --remove-vanished false
iPhone had no Headscale ACL rule to SSH into developer-env -- needed for phone-based jump-host access to ucg-fiber and everything else medium
headscale acl ssh iphone ucg-fiber remote-access   last seen:

Symptom

Setting up phone-based SSH access to the gateway (ucg-fiber, LAN-only, 192.168.42.1) while remote -- the plan being an SSH client app on the iPhone, over Headscale, into developer-env, then ssh ucg-fiber from there using the alias/key already configured on developer-env (~/.ssh/config, key auth installed 2026-08-21 -- see known-fixes/ucg-fiber-ssh-login-is-root-not-peruser.md). The iPhone is already a registered Headscale device (100.64.0.117, hosts{} in headscale/acl.hujson), so this looked like it should already work. It wouldn't have: headscale/acl.hujson's Rule 5 ("off-LAN devices can SSH into developer-env and administer everything else from there") only listed macbook as src. iphone was a known, declared Headscale host but had no rule granting it reach to developer-env:22 specifically -- same implicit-deny-hangs-not-refuses failure mode CLAUDE.md already documents for a host missing from acl.hujson entirely, just one level narrower (the host *is* declared, but lacks the one rule this particular use case needs).

Fix

Extended Rule 5's src list to ["macbook", "iphone"] rather than adding a near-duplicate rule for the identical dst -- same pattern already used elsewhere in this file (e.g. Rule 8's extension to add developer-env for lyrtui). Deployed via the standard acl_deploy procedure (SCP candidate, headscale policy check -f, backup, swap, systemctl restart headscale, confirm active, byte-diff read-back) -- all steps passed, confirmed live 2026-08-22.

Prevention / notes for next time

Being present in acl.hujson's hosts{} block does not imply a device can reach any particular destination -- each (src, dst) pair needs its own rule coverage (or inclusion in an existing one). Before trusting a new remote-access path involving an already-known Headscale device, check the specific rule granting that exact src → dst reach, not just that the device is declared at all.
Prometheus config reload via /-/reload returns 403 on the grafana LXC -- Lifecycle API isn't enabled, use systemctl restart instead medium
prometheus grafana gotcha   last seen:

Symptom

After hand-editing /opt/prometheus/prometheus.yml on the grafana LXC (192.168.42.119) to add a new scrape job, `curl -X POST http://192.168.42.119:9090/-/reload returned 403 Forbidden` with body Lifecycle API is not enabled. -- config changes never took effect.

Root cause

This instance's systemd unit starts Prometheus without --web.enable-lifecycle:
ExecStart=/opt/prometheus/prometheus --config.file=/opt/prometheus/prometheus.yml --storage.tsdb.path=/var/lib/prometheus --web.listen-address=192.168.42.119:9090
Without that flag, the /-/reload and /-/quit HTTP endpoints are disabled by design (Prometheus's own default posture) -- not a bug, not misconfiguration, just a flag that was never added when this instance was first set up. Also worth noting while touching this: Prometheus is bound to 192.168.42.119:9090 specifically, not 0.0.0.0 or 127.0.0.1 -- curl http://localhost:9090/... from the box itself fails to connect (connection refused). Always hit it via the LAN IP, even from a shell on the LXC itself.

Fix

Restart the service instead of hitting the reload endpoint:
ssh root@192.168.42.119 "systemctl restart prometheus"
Confirm the new job picked up via the targets API:
curl -s http://192.168.42.119:9090/api/v1/targets | python3 -m json.tool
Brief scrape gap during the restart (a few seconds) -- fine for this fleet's use, not a concern.

Prevention / next time

Don't bother trying /-/reload first -- go straight to `systemctl restart prometheus`. If continuous scraping ever becomes something this homelab needs to avoid interrupting even briefly, adding --web.enable-lifecycle to the unit's ExecStart would fix /-/reload properly, but that's a deliberate future change, not something to do reactively next time this bites.
DNS settings don't persist after Proxmox host upgrade medium
after apt upgrade and reboot, proxmox-nuc DNS reverts to stale Tailscale entries • 100.100.100.100 appears as DNS after upgrade
proxmox dns upgrade tailscale   last seen: 2026-06-03

Symptoms

  • after apt upgrade and reboot, proxmox-nuc DNS reverts to stale Tailscale entries
  • 100.100.100.100 appears as DNS after upgrade

Fix

Reset via Proxmox UI (Node → System → DNS) after every major upgrade: - DNS server 1: 192.168.42.27 - DNS server 2: 8.8.8.8 - Search domain: compellinglylowbrow.org
sudo tailscale set --accept-dns=false
On any affected VMs/LXCs that show stale DNS.
LXC can't reach Headscale / internet after restart medium
LXC loses network or can't resolve DNS after host reboot
proxmox lxc network dns   last seen: 2026-06-03

Symptoms

  • LXC loses network or can't resolve DNS after host reboot

Check

Verify network bridge assignment in Proxmox UI and confirm DNS is set.

Note

AdGuard (192.168.42.27) is the local DNS server — if it's down, other containers lose resolution. Boot order matters: AdGuard should start before other services.
raspi4 (guardian node) silently moved from Servers to Trusted VLAN when its WiFi SSID was reassigned -- looked identical to a dead/unplugged host from every LAN-side check medium
ping/ssh to raspi4's documented LAN IP (192.168.42.83) fails from every Servers-VLAN host (developer-env, watchdog) -- 'Destination Host Unreachable' / 'No route to host' • ip neigh show <ip> on developer-env shows the ARP entry as FAILED / (incomplete) -- not a firewall-style hang, a genuine no-reply • daily-fleet-digest's 06:30 apt-get-update health check ntfy flags exactly one host as FAILED, all others OK
network vlan wifi ssid trusted servers ucg-fiber arp guardian raspi4 zone-firewall   last seen: 2026-08-24

Symptoms

  • ping/ssh to raspi4's documented LAN IP (192.168.42.83) fails from every Servers-VLAN host (developer-env, watchdog) -- 'Destination Host Unreachable' / 'No route to host'
  • ip neigh show <ip> on developer-env shows the ARP entry as FAILED / (incomplete) -- not a firewall-style hang, a genuine no-reply
  • daily-fleet-digest's 06:30 apt-get-update health check ntfy flags exactly one host as FAILED, all others OK
  • the host is 100% physically fine -- pinging/SSHing it from the gateway's own segment (or any Trusted-VLAN host) works instantly
See also: nastynas-pibox-lan-ip-unreachable-post-vlan-work, trusted-vlan-gateway-ui-firewall-gap, trusted-vlan-return-traffic-toggle-missing, watchdog-wifi-bridge-relocated-to-rack

Symptom

developer-env couldn't SSH or ping raspi4 (192.168.42.83); watchdog got the identical result from its own vantage point. `ip neigh show 192.168.42.83 on developer-env showed the ARP entry as FAILED`/ (incomplete) -- not a hang (the signature of ufw default-deny or a missing Headscale ACL entry, both documented look-alikes), a genuine no-reply at L2. The same morning, daily-fleet-digest's 6:30am apt-get update health check ntfy flagged raspi4 as the one FAILED host out of 19 tracked. Every symptom pointed at "host is down" -- physically unplugged, powered off, or a dead switch port.

Root cause

raspi4 is WiFi-connected (household SSID GDTRFB) -- the one node in the watchdog/guardian trio that isn't wired (unlike watchdog, see known-fixes/watchdog-wifi-bridge-relocated-to-rack.md, which is wired specifically to avoid this class of problem). The night before, GDTRFB was reassigned from flat/Servers onto the new VLAN 10 (Trusted, 192.168.12.0/24) as part of the ongoing VLAN rollout (Session 2 Step 2, 2026-08-23 -- docs/ucg-fiber-session2-step1-vlan-checklist.md). raspi4 followed the SSID onto the new subnet like every other WiFi client and picked up a DHCP address there (192.168.12.74) instead of its old static 192.168.42.83 -- nothing about raspi4 itself changed, its L2 segment just moved out from under it. Two things compounded to make this look exactly like a dead host from every LAN-side check: 1. Nothing at 192.168.42.83 exists any more, so any ARP request for it on the Servers VLAN broadcast domain genuinely gets no reply -- indistinguishable, from developer-env's vantage point, from the host being unplugged. 2. Even after finding raspi4 alive at 192.168.12.74, **SSH from developer-env/watchdog to it still timed out** -- because only Trusted → Servers firewall rules had been built during the VLAN rollout (Phase C: DNS, Proxmox, Kuma, SSH, SMB). The reverse direction (Servers → Trusted, needed for developer-env to reach *in* to manage raspi4) had never come up before and had no rule at all.

Diagnosis

Don't stop at "ping/ssh fails from every LAN vantage point I have" -- every vantage point tried here (developer-env, watchdog) was on the same VLAN (Servers). Check from the gateway itself, which straddles every zone:
ssh ucg-fiber "ip neigh show | grep <suspect-mac-OUI-or-recent-lease>"
ssh ucg-fiber "ping -c2 <candidate-ip-on-another-VLAN>"
ssh ucg-fiber "ssh -o BatchMode=yes mos@<candidate-ip> hostname"  # Permission denied = alive, just no key here
A Raspberry Pi Foundation MAC OUI (b8:27:eb, dc:a6:32, e4:5f:01, 28:cd:c1, d8:3a:dd, ...) showing up fresh on an unexpected VLAN's ARP table, right after any WiFi SSID got reassigned to that VLAN, is the tell.

Fix

1. DHCP reservation on VLAN 10 (Trusted) for raspi4's MAC, pinning it to a fixed IP (used 192.168.12.74, its already-leased address) -- without this it's one lease renewal away from moving again. 2. New Servers → Trusted firewall rule, SSH :22, zone-wide (mirroring the existing Trusted → Servers SSH rule's scope) -- created via the UniFi Network app, same as every other zone-pair rule in this rollout. Explicitly enable "allow return traffic" on this rule -- the reverse-direction toggle bit this exact VLAN work twice already for the opposite direction (see known-fixes/trusted-vlan-return-traffic-toggle-missing.md); don't assume a fresh rule in a brand-new zone pair gets it by default. 3. Verify both chains directly on the gateway, not just the UI's "saved" confirmation:
   ssh ucg-fiber "iptables -S | grep -i 192.168.12.74"
   ssh developer-env-or-watchdog "ssh -o ConnectTimeout=5 mos@192.168.12.74 echo up"
   
4. Update inventory/hosts-config.yaml (raspi4 + raspi4-os entries) and INFRASTRUCTURE.md with the new IP -- done same day this file was written. inventory/hosts.yaml picks it up on the next bin/collect-homelab run. All of the above completed and verified same day (2026-08-24): DHCP reservation pinned 192.168.12.74; Servers → Trusted SSH rule added and confirmed live in both iptables chains (forward ACCEPT with no ctstate restriction, reverse RELATED,ESTABLISHED mirror present); real SSH round-trip from developer-env returned raspi4's own hostname and all three of its IPs. Building the rule wasn't itself free of mistakes -- see known-fixes/unifi-zone-rule-creation-gotchas.md for a separate, adjacent incident (the admin-device access rule built the same session) that surfaces UI traps worth knowing before creating the next one.

Current impact

None -- fully resolved. raspi4 reachable from developer-env/watchdog by SSH, hosts-config.yaml/INFRASTRUCTURE.md updated, DHCP reservation pinned so it won't drift again on lease renewal.

Prevention

- **Any device on a WiFi SSID that later gets reassigned to a different VLAN moves with it, silently.** This VLAN rollout has already documented this exact mechanism causing routing/ARP surprises for wired hosts with stale Tailscale route state (known-fixes/nastynas-pibox-lan-ip-unreachable-post-vlan-work.md) -- this is the WiFi-specific version of the same lesson: reassigning an SSID's VLAN is not a no-op for every device already associated with it, including infrastructure nodes nobody thought of as "on WiFi." - **Before reassigning any SSID's VLAN, enumerate what's actually connected to it first** -- the same "no real port map exists" gap already flagged for homelab-switch applies equally to WiFi clients. raspi4 wasn't considered during the GDTRFB migration because nobody had it in mind as a WiFi-connected infra host. - **An ARP FAILED/(incomplete) result from a single VLAN's vantage point proves the host isn't on *that* VLAN's broadcast domain -- it does not prove the host is down.** Check from the gateway (which sees every zone) before concluding a host needs physical hands-on attention. - Every new zone-pair firewall rule needs both the rule itself and its return-traffic toggle verified live (iptables -S, both chains) before being trusted -- not just the UI's save confirmation. Already the documented lesson from known-fixes/trusted-vlan-return-traffic-toggle-missing.md; this incident is a second confirmation of the same discipline mattering.
pibox rsync leg fails with exit=23 — permission denied on delete medium
backup-music.sh reports leg: pibox FAILED (exit=23) • delete_file: unlink failed: Permission denied • cannot delete non-empty directory
rsync pibox permissions backup-music   last seen: 2026-06-24

Symptoms

  • backup-music.sh reports leg: pibox FAILED (exit=23)
  • delete_file: unlink failed: Permission denied
  • cannot delete non-empty directory
  • files owned by different UID than SSH user

Cause

Files on pibox were owned by a different UID than the SSH user (pibox, UID 1000), likely from a previous OMV setup or a root rsync run. The pibox user can't unlink files it doesn't own.

Diagnosis

ssh -i /root/.ssh/id_rsa pibox@100.64.0.116 \
  "ls -lan '/srv/dev-disk-by-uuid-.../PiBoxShared/shared/<failing-dir>/'"

Files owned by UID != 1000 → ownership mismatch

ssh -i /root/.ssh/id_rsa pibox@100.64.0.116 "id"

uid=1000(pibox)

Fix

ssh -i /root/.ssh/id_rsa pibox@100.64.0.116 \
  "sudo chown -R pibox:users '/srv/dev-disk-by-uuid-.../PiBoxShared/shared/'"
Takes a minute to recurse. Next rsync run will clean up stale directories automatically.

Note

The + on directory permissions in ls -l indicates ACLs (OMV sets these). The ownership fix is sufficient.
wildwood rsync leg fails with exit=255 — SSH auth key missing medium
backup-music.sh reports leg: wildwood FAILED (exit=255) • mos@100.64.0.3: Permission denied (publickey,password) • rsync error: unexplained error (code 255)
rsync wildwood ssh authorized-keys backup-music   last seen: 2026-06-24

Symptoms

  • backup-music.sh reports leg: wildwood FAILED (exit=255)
  • mos@100.64.0.3: Permission denied (publickey,password)
  • rsync error: unexplained error (code 255)

Cause

The rsync script uses /root/.ssh/id_ed25519 but that key's public counterpart was not in ~mos/.ssh/authorized_keys on wildwood. The file only had ssh-rsa keys.

Diagnosis

ssh -i /root/.ssh/id_ed25519 -o BatchMode=yes -o ConnectTimeout=10 mos@100.64.0.3 "whoami"

Permission denied → key not authorized

ssh -i /root/.ssh/id_rsa -o BatchMode=no mos@100.64.0.3 "cat ~/.ssh/authorized_keys"

Lists only ssh-rsa keys → id_ed25519.pub is missing

Fix

ssh -i /root/.ssh/id_rsa mos@100.64.0.3 \
  "echo '$(cat /root/.ssh/id_ed25519.pub)' >> ~/.ssh/authorized_keys"

ssh -i /root/.ssh/id_ed25519 -o BatchMode=yes -o ConnectTimeout=10 mos@100.64.0.3 "whoami"

Returns "mos" → fixed

run-update couldn't classify docker-compose update_cmds — new dispatcher + ssh_user threading medium
run-update <service> fails immediately: 'Cannot classify update_cmd ... use gen-runbook for manual updates' • update-advisor auto-execute attempts for a docker-compose-managed service would fail every cycle and fire a false urgent 'Update FAILED' ntfy, if auto_update were ever flipped to true • any SSH call in run-update/update-apt/update-binary hardcoded root@<host>, which is wrong for hosts whose ssh_user isn't root (e.g. watchdog)
run-update update-advisor docker-compose ssh ssh_user watchdog ntfy uptime-kuma   last seen: 2026-07-08

Symptoms

  • run-update <service> fails immediately: 'Cannot classify update_cmd ... use gen-runbook for manual updates'
  • update-advisor auto-execute attempts for a docker-compose-managed service would fail every cycle and fire a false urgent 'Update FAILED' ntfy, if auto_update were ever flipped to true
  • any SSH call in run-update/update-apt/update-binary hardcoded root@<host>, which is wrong for hosts whose ssh_user isn't root (e.g. watchdog)

Cause

bin/run-update's dispatch_update() classified update_cmd strings into three buckets — apt, binary (self-updater, e.g. --update), and download (curl+tar/dpkg) — routing each to bin/update-apt or bin/update-binary. Docker-compose based updates (`docker compose pull && docker compose up -d `) matched none of these patterns and fell through to "cannot classify", failing immediately. This was flagged as a known gap in hosts-config.yaml's ntfy and uptime-kuma entries — both services are Docker containers on the watchdog host, managed via docker compose — and auto_update: false was deliberately left set on both until the gap was fixed. Separately, every SSH call across run-update, update-apt, and update-binary hardcoded root@. This never surfaced before because no update_group: 2 host with a non-root ssh_user (watchdog's `ssh_user: watchdog) had ever actually carried a version:` block with an update_cmd — ntfy and uptime-kuma were the first.

Fix

1. Added a docker_compose classification case to dispatch_update() (checks for "docker compose" or "docker-compose" in update_cmd), routing to a new bin/update-docker-compose dispatcher that mirrors bin/update-binary's contract and JSON output shape (`type: "docker_compose"`). It confirms the container comes back *running* via docker inspect --format '{{.State.Running}}' — deliberately not .State.Health.Status, since not every compose service defines a Docker-level HEALTHCHECK (ntfy doesn't). Deeper health verification (HTTP endpoint, Health.Status) stays in hosts-config.yaml's post_update/verify steps, run separately by run-update itself. 2. Threaded ssh_user through ssh_run(), run_step(), check_condition(), get_version(), and dispatch_update() in run-update, resolved once per service as `host.get("ssh_user") or "root". update-apt and update-binary` each gained an optional 5th positional ssh_user arg (default "root" for backward-compat/manual invocation).

Verify

Real end-to-end test, not just --dry-run:
runtee bin/run-update ntfy --dry-run   # confirm dispatch classification + ssh_user in the JSON first
runtee bin/run-update ntfy             # then the real thing
Result 2026-07-08: ntfy updated v2.24.0 → v2.25.0 via the real (non-dry-run) path — SSH as watchdog@192.168.42.229, `docker compose pull && up -d`, health-endpoint verify all passed.

Follow-up

- uptime-kuma's auto_update was flipped to true the same session, after this fix was proven *and* after the v1→v2 migration (see uptime-kuma-v1-to-v2-migration.md) was completed and verified stable. - ntfy's auto_update was deliberately left false — the mechanism is proven, but flipping it wasn't part of this session's decision. Revisit when ready to trust more Group 2 services with unattended execution.
First-ever ufw enable on a service LXC locks out SSH and monitoring unless every consumer is allowlisted up front medium
ufw firewall ssh uptime-kuma searxng limiter   last seen:

Symptom

During a security hardening pass on the searxng LXC (110), adding a ufw rule for port 8888 (ufw allow from 192.168.42.45 to any port 8888 + ufw --force enable) caused two independent, delayed failures: 1. Every subsequent ssh searxng from developer-env hung indefinitely (ssh -vvv stalled right after `Connecting to 192.168.42.29 [...] port 22. with no Connection established` line — the classic signature of a silently dropped SYN, not a refused connection). 2. Uptime Kuma's SearXNG monitor (on watchdog, checking 192.168.42.29:8888 directly per LAN-IP-not-FQDN health-check convention) went Down with timeout of 48000ms exceeded, and the watchdog daemon logged `Watchdog: manual intervention required — Pattern does not match any known failure mode` since this was the first time this exact failure shape had occurred. Both symptoms present as hangs/timeouts, not clean refusals — ufw DROPs by default rather than REJECTs, so a blocked client just sits there until its own client-side timeout fires. ping and pct exec (LXC) continued working the whole time, which ruled out "container is down" but was initially misleading — it took explicit timeout N wrapping on every diagnostic command to even see clean exit codes instead of more hangs. A related but non-fatal third issue surfaced once SSH access was restored and a limiter: true change (made in the same session, for an unrelated reason) was combined with the same firewall change: the Uptime Kuma health check, once let through by ufw, started getting HTTP 429 from SearXNG's now-active rate limiter, because the Caddyfile had never forwarded real client IPs for this service (unlike immich/qui/lyrionmusicserver) — so real traffic and the limiter were both attributing everything to Caddy's own IP as an accidental side effect of turning the limiter on.

Root cause

ufw --force enable switches a host to default-deny-incoming immediately. Because SearXNG had never had any ufw policy before this session (confirmed via collected/searxng/no-ufw was the baseline), there was no established checklist for "which LAN hosts legitimately talk to this container" — only the reverse-proxy consumer (Caddy) was considered when the rule was written, missing: - developer-env (ad-hoc SSH/admin access) — port 22 - watchdog (Uptime Kuma LAN-IP health check, per this homelab's own monitoring design principle) — port 8888 The 429s were a separate but adjacent gap: SearXNG's limiter: true was flipped on in the same session as part of unrelated security hardening (previously false, with Redis already healthy and unused for this purpose). The limiter buckets by source IP, and the Caddyfile's searxng.compellinglylowbrow.org block had no header_up X-Real-IP / X-Forwarded-For directives, so every request — proxied or direct — was either seen as Caddy's IP or, for watchdog's direct-to-LAN-IP check, correctly seen as 192.168.42.229 but then legitimately rate-limited as a single very chatty client with no allowlist exemption.

Fix

SSH lockout — recovered via pct exec (bypasses the container's network stack via the Proxmox host, unaffected by the LXC's own ufw):
ssh proxmox-nuc 'pct exec 110 -- ufw allow from 192.168.42.31 to any port 22 comment "developer-env ssh"'
(pct exec is proxmox-nuc-specific — this escape hatch does not exist for hosts on other nodes or for VMs; a locked-out VM would need console access instead.) Watchdog/Uptime Kuma lockout — same pattern, different port/source:
ssh proxmox-nuc 'pct exec 110 -- ufw allow from 192.168.42.229 to any port 8888 comment "uptime-kuma health check"'
Limiter 429s — added a pass_ip exemption in /etc/searxng/limiter.toml (this file didn't exist before — SearXNG was running on its embedded default) for the LAN + tailnet ranges, since this instance is never reachable from the public internet:
[botdetection.ip_lists]
pass_ip = [
  "192.168.42.0/24",
  "100.64.0.0/10",
]
Also fixed the underlying blind spot in caddy/Caddyfile's searxng block by adding the same `header_up Host/X-Real-IP/X-Forwarded-For/ X-Forwarded-Proto directives already used by immich/qui`/ lyrionmusicserver, so SearXNG sees real client IPs going forward regardless of the pass_ip exemption.

Prevention — apply to any future first-time `ufw enable` on a service host

1. **Add every legitimate LAN consumer's access rule in the same breath as --force enable, never as a follow-up.** The follow-up itself needs the access that was just cut off. 2. **Enumerate consumers before writing the rule, not after something breaks:** at minimum, (a) the reverse proxy (Caddy) if the service is proxied, (b) watchdog/Uptime Kuma if it has a LAN-IP health check (check bin/setup-uptime-kuma.py's monitor list), (c) developer-env if admin/SSH access is expected, (d) any peer service that calls this one directly rather than through Caddy. 3. **If the service has its own application-level rate limiting/bot detection (e.g. SearXNG's limiter), check whether it's being enabled in the same session** — a firewall change and a limiter change can each look fine in isolation and still combine to break the same consumer for two different reasons, as happened here. 4. pct exec from the LXC's Proxmox host is the standing escape hatch for a self-inflicted SSH lockout on any container on that node — keep in mind this doesn't apply to VMs or to LXCs where you don't have host access.

Related

This is the first LXC in this fleet with a real ufw policy — every other collected host currently returns no-ufw. Treat this file as the starting checklist template the next time one gets one, rather than rediscovering each gap live. Same general "forgot a rule, not `ufw enable` itself, was the mistake" shape as the existing bespoke-service note in CLAUDE.md's Developer Environment section (wiki-server's missing rule) — this incident is the sharper case of actively locking yourself out while adding a rule, not just under-scoping one.
Shared unmanaged switch wedges under a link event, taking every host on it down together — power-cycle the switch, not the hosts medium
network switch layer2 unmanaged-switch cam-table jetkvm single-point-of-failure hardware   last seen:

Symptom

After plugging a JetKVM into the rack earlier the same day (the event that also dropped proxmox-nuc off the LAN — see "Open / unconfirmed" below), three hosts went unreachable at once and stayed that way: - nastynas (192.168.42.200) — PBS / backup target - pibox (192.168.42.117) — CM4 / OpenMediaVault LAN backup NAS - macbookpro-pve (192.168.42.12) — sandbox node bin/collect-homelab failed on all three in the same run. All three had already been checked for power and cabling and had been **power-cycled at the host level with no effect** — the outage survived rebooting every affected box. The one thing the three share: **they are all plugged into the same unmanaged switch.** Nothing else on the LAN was affected — watchdog, developer-env, HA, and the proxmox-nuc LXCs all stayed reachable.

Root cause

The switch itself wedged — not the hosts. Power-cycling *the switch* brought all three back simultaneously; nothing on the hosts had to change. This is the textbook signature of a cheap unmanaged switch getting its Layer-2 forwarding (MAC / CAM) table into a bad state and failing to recover on its own. Likely trigger: the JetKVM. Plugging a device in flaps a link and puts a new (or briefly duplicated) MAC on the segment, and an unmanaged switch has no STP, no loop protection, and no storm control to contain the resulting churn. Once the forwarding logic hangs it keeps mis-forwarding — the hosts are up, cabled, and answering, but frames aren't reaching them — until power removes the state. A host reboot can't fix this because the fault is one hop upstream of every host. Not iptables / not a host firewall. The instinct that "IP tables got messy" points at the wrong layer — no host packet-filter was involved. What got messy was the *switch's* MAC-address table, a Layer-2 hardware thing entirely outside any host. (These hosts don't run host-level iptables policies for this anyway; the one place ufw exists is a single LXC, unrelated.)

Fix

Power-cycle the switch (pull power, wait ~10s, restore). All three hosts recovered on their own the moment the switch came back — no host intervention needed. Confirmed 2026-07-30. Do not keep power-cycling the individual hosts — that tests the wrong layer and burns time. If multiple hosts on one switch are down together and each is individually fine on power/cable, cycle the switch first.

Prevention

1. **Keep the JetKVM off any port of a switch that carries production — not just off production *hosts*.** The standing "KVM stays off production" verdict is now broader: connecting it can wedge the whole shared segment, not only the box it's cabled to. Ethernet included, not just its USB. 2. **Diagnostic reflex: multiple hosts down at the same instant almost never means multiple host failures — find the one thing they share.** Power-cycling the hosts and getting nothing back is *evidence for* a shared-upstream cause, not against it. On this flat LAN the shared thing is usually a switch or the router / DHCP path. 3. This is a real single point of failure worth designing out. nastynas (backup target) and pibox (backup NAS) both hang off one dumb switch that just proved it can drop them together and needs hands-on power to recover — backup infrastructure behind an unrecoverable-without-a-power- cycle single switch. Spreading critical hosts across ports/switches, and bringing forward the already-planned managed switch (INFRASTRUCTURE.md → "Planned / Not Yet Deployed"), adds STP/loop protection, storm control, and — the thing actually wanted mid-incident — visibility into the MAC table and per-port counters, turning power-cycle-and-hope into a 30-second diagnosis.

Open / unconfirmed

- **Whether this same switch wedge also dropped proxmox-nuc earlier the same day is not confirmed — and can no longer be cleanly tested**, because proxmox-nuc was moved to plug directly into the router (2026-07-30), off the shared switch entirely. Two candidate mechanisms remain on the table for that earlier drop: (a) it was on this switch and the wedge took it too, or (b) a host-side USB network-interface enumeration reorder when the JetKVM went into proxmox-nuc's USB (the cold-boot USB/GPU fragility cluster, parked for its own write-up). The router-direct move is a de-facto mitigation regardless of which it was: proxmox-nuc is now insulated from a future wedge of this switch. - The exact L2 trigger (link flap vs. duplicate/rogue MAC vs. transient loop) wasn't captured — an unmanaged switch gives no logs. A managed switch would make the next occurrence diagnosable rather than inferred.

Related

- Parked companion write-up: the cold-boot USB/GPU fragility cluster on proxmox-nuc (qbittorrent USB-NIC udev rule, immich GPU by-path pin, qbittorrent .47/.159 DHCP reservation) plus the open "why did the JetKVM drop proxmox-nuc" question — still to be documented separately. - INFRASTRUCTURE.md → "Planned / Not Yet Deployed" (managed switch — the mitigation for exactly this class of problem).
squeezelite -o <AirPods name> fails with 'Invalid number of channels' -- AirPods register as two identically-named CoreAudio devices, and name-based lookup binds to the wrong one medium
squeezelite log shows: error opening portaudio stream: Invalid number of channels • squeezelite log shows: unable to open output device: <AirPods name>, even though squeezelite -l lists that exact name as a valid output device • Device name matches exactly (no typo/encoding mismatch) but the open still fails
squeezelite macos airpods bluetooth coreaudio portaudio launchd   last seen: 2026-08-15

Symptoms

  • squeezelite log shows: error opening portaudio stream: Invalid number of channels
  • squeezelite log shows: unable to open output device: <AirPods name>, even though squeezelite -l lists that exact name as a valid output device
  • Device name matches exactly (no typo/encoding mismatch) but the open still fails
  • squeezelite -o <index> for the same device (from squeezelite -l) works fine, immediately
See also: squeezelite-mac-duplicate-launch-and-output-device

Symptom

Found while building macos/squeezelite-follow-output.sh (a wrapper that relaunches squeezelite pointed at the system's current default output device, so it follows AirPods instead of staying pinned to a static device — see known-fixes/squeezelite-mac-duplicate-launch-and-output-device.md for that background). The wrapper correctly detected SwitchAudioSource -c reporting Matt's AirPods as the current device and launched squeezelite with -o "Matt's AirPods" — every single launch failed:
test_open:238 error opening portaudio stream: Invalid number of channels
output_init_common:401 unable to open output device: Matt's AirPods
squeezelite -l listed that exact name as a valid output device (1 - Matt's AirPods [Core Audio]), ruling out a typo or Unicode apostrophe mismatch (curly ' vs straight ') as the cause.

Cause

Confirmed via system_profiler SPAudioDataType: AirPods (and presumably any Bluetooth headset with a mic) register as **two separate CoreAudio devices sharing the exact same display name**:
Matt's AirPods:
  Default Input Device: Yes
  Input Channels: 1
  Current SampleRate: 24000        <- HFP / mic side, mono, low quality

Matt's AirPods:
  Default Output Device: Yes
  Output Channels: 2
  Current SampleRate: 48000        <- A2DP / media side, stereo, high quality
squeezelite's PortAudio-based name lookup for -o "" isn't guaranteed to resolve to the output-side entry when two devices share a name — it bound to the 1-channel input-side entry, then failed when requesting a 2-channel (stereo) stream open on it. squeezelite -l's own listing is already filtered to output-only devices, so the same name there unambiguously refers to the correct entry — confirmed live: -o "Matt's AirPods" (by name) failed every time; -o 1 (that device's index per squeezelite -l) opened and played immediately, no error.

Fix

Don't pass a Bluetooth device name straight through to squeezelite's -o. Resolve it to squeezelite's own numeric index first, by grepping the current device name against squeezelite -l's output-devices section (fixed-string match, not regex, to avoid trouble with parentheses/quotes in device names) and using the matched line's index instead:
resolve_output_index() {
    local device_name="$1"
    "$SQUEEZELITE" -l 2>&1 \
        | awk '/^Output devices:/{f=1;next} /^Input devices:/{f=0} f' \
        | grep -F "$device_name" \
        | head -1 \
        | sed -E 's/^[[:space:]]*([0-9]+).*/\1/'
}
Implemented in macos/squeezelite-follow-output.sh — every launch now resolves the current device name to an index via this function before building the squeezelite command line, falling back to the old name-based -o only if no match is found (logged as a warning, since that fallback is known to fail on exactly this class of device).

Prevention / lesson

A device name matching cleanly in one tool's listing (squeezelite -l) doesn't guarantee the *same string* resolves unambiguously through a different lookup path (-o at stream-open time) when the OS-level device namespace has a collision the first tool's listing happened to already filter out. system_profiler SPAudioDataType — which shows Input and Output as fully separate device entries, each with their own channel count — was the tool that actually exposed the collision; squeezelite -l and SwitchAudioSource -c both independently looked completely consistent (same name, no visible ambiguity) right up until actually opening the stream.
Squeezelite on Mac intermittently disappears from LMS web UI medium
Squeezelite player randomly stops appearing in LMS web UI • Only fixed by manually running squeezelite from Terminal (e.g. with -d all=debug) • squeezelite log shows: unable to open output device: default
squeezelite macos launchd lms mac-client   last seen: 2026-07-15

Symptoms

  • Squeezelite player randomly stops appearing in LMS web UI
  • Only fixed by manually running squeezelite from Terminal (e.g. with -d all=debug)
  • squeezelite log shows: unable to open output device: default
  • ps aux shows two squeezelite processes running simultaneously
  • duplicate process reappears after every reboot despite removing Login Item

Cause (two independent bugs stacked together)

1. Duplicate launch mechanisms. Squeezelite had been set up twice over time: once as a proper LaunchAgent (~/Library/LaunchAgents/com.user.squeezelite.plist, with correct -s/-o flags) and once as a macOS Login Item (System Settings → Login Items & Extensions), which launches the bare app bundle with zero flags and relies on LMS auto-discovery. Both processes compete for the same CoreAudio output device; whichever loses is invisible/unusable in the LMS UI even though it hasn't crashed. 2. -a is not an output-device selector on the macOS (PortAudio) build. Unlike Linux/ALSA squeezelite where -a configures ALSA buffer params, on macOS -a : sets **target latency (ms) and OSX-resample allow-flag**. Device selection is -o only. A plist with -a 100:0 and no -o silently falls back to opening a device literally named "default", which fails:
   output_init_common:401 unable to open output device: default
   
List valid device names with:
   /Applications/Squeezelite.app/Contents/MacOS/squeezelite -l
   

Fix (2026-07-06 attempt)

**Remove the Login Item — GUI removal did not reliably persist across reboots in testing.** Use AppleScript and verify:
osascript -e 'tell application "System Events" to delete login item "Squeezelite"'
osascript -e 'tell application "System Events" to get the name of every login item'
Confirm "Squeezelite" is gone from the output before trusting it. Fix the plist to explicitly name the output device:
<key>ProgramArguments</key>
<array>
    <string>/Applications/Squeezelite.app/Contents/MacOS/squeezelite</string>
    <string>-n</string>
    <string>MacBook</string>
    <string>-s</string>
    <string>100.64.0.8</string>
    <string>-o</string>
    <string>MacBook Air Speakers</string>
    <string>-a</string>
    <string>100:0</string>
    <string>-f</string>
    <string>/tmp/squeezelite.log</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
Reload with the modern launchd API, not legacy load/unload. load/unload failed to reliably re-register the job as persistent across a reboot in testing (job vanished with no error after a kill -9 + unload/load cycle). bootstrap/bootout/enable fixed this:
launchctl bootout gui/$(id -u)/com.user.squeezelite 2>/dev/null
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.squeezelite.plist
launchctl enable gui/$(id -u)/com.user.squeezelite

Verification (do a real reboot, not just a reload)

ps aux | grep -i squeezelite | grep -v grep     # exactly one process, with -o flag
cat /tmp/squeezelite.log                        # no "unable to open output device"
launchctl list | grep -i squeeze                 # single com.user.squeezelite entry
osascript -e 'tell application "System Events" to get the name of every login item'
                                                  # no "Squeezelite"
Manual launchctl load/reload tests are not sufficient — this issue only reliably reproduces/resolves across an actual reboot, since the race is specifically about login-time launch behavior.

Prevention

- Never set up both a LaunchAgent and a Login Item for the same launchd-managed process — pick one. LaunchAgent is preferable for anything needing specific flags (server IP, output device). - If Squeezelite is ever reinstalled or reconfigured, re-verify with squeezelite -l that the output device name hasn't changed (e.g. after macOS updates or if switching to a USB DAC/Bluetooth output).

Update 2026-07-15: fix did not hold up on macOS Tahoe; MacBook-as-player abandoned

The 2026-07-06 fix above worked for a while but recurred after a macOS Tahoe update — the underlying complaint (works for a while, then breaks again, especially around output-device switching between built-in speakers and AirPods) reappeared and this time didn't resolve with the same LaunchAgent/-o device-name approach. Not re-diagnosed in detail this session; treated as a Tahoe-era CoreAudio/PortAudio interaction rather than a repeat of either of the two original bugs above (Login Item duplication was re-checked and was NOT present this time). Two alternatives were tried as replacements for squeezelite entirely, both via LMS's browser-based player feature (visiting http://:9000/stream.mp3 registers the browser tab as a player): - Confirmed the registration/control side works (player shows up in the LMS UI, track metadata loads, play/pause toggles) once caddy/Caddyfile's lyrionmusicserver block got flush_interval -1 added (see the Caddy fix below) — Caddy's default buffering was holding the continuous audio stream instead of forwarding it live. - Even with that fix, actual audio playback still did not work. Not further diagnosed — could be a proxy-layer issue beyond buffering (e.g. Host-header/streaming-negotiation quirk specific to this hidden LMS feature when proxied), or something LMS-side. Left as an open unknown rather than chased further, since a good-enough alternative already existed (see below). - SqueezePlay (older Lua/SqueezeOS desktop client, different codebase from squeezelite) was suggested as a further alternative but not actually tried — MOS closed the thread before testing it. Decision: give up on the MacBook as a dedicated LMS player. copyparty (already running on proxmox-nuc, copyparty.compellinglylowbrow.org) has its own local audio player and was confirmed working fine for "play something locally on developer-env/laptop" needs — not as pretty as a proper Squeezebox client, but functional and requires zero further troubleshooting investment. Combined with WiiM (stereo) and LyrPlay on iPhone (confirmed working, see headscale/acl.hujson rule 8 for the direct player-protocol ACL grant added the same session), all practical listening needs are covered without squeezelite. This closes the MacBook-as-squeezelite-player effort — **do not re-open this LaunchAgent/output-device fix as a starting point for future troubleshooting** unless the person explicitly asks to revisit Mac-native LMS playback; the accepted state is that the Mac uses copyparty for local playback instead.

Reopened 2026-08-15 — lyrtui made Mac-native playback relevant again

lyrtui (see INFRASTRUCTURE.md's lyrionmusicserver entry) was set up the same day using the Mac's existing squeezelite player ("MacBook") for actual audio output, which quietly un-abandoned Mac-native playback without this file being reconciled at the time — worth noting as a case of infra moving past a documented "closed, don't reopen" decision without the doc catching up. MOS then hit exactly the symptom this file's 2026-07-15 update predicted: AirPods connect and macOS's own output picker switches correctly, but squeezelite keeps playing out of the laptop speakers regardless. MOS explicitly asked to revisit it, satisfying this file's own reopen condition. Root cause, confirmed this time: -o "MacBook Air Speakers" in com.user.squeezelite.plist is a static device name — squeezelite has no "follow system default" mode on the PortAudio/macOS build, so it was never going to react to a Bluetooth output change no matter how reliably macOS itself switched. This isn't the Tahoe-era CoreAudio mystery the 2026-07-15 update assumed it might be; confirmed via direct question that macOS's own switching works fine, isolating the fault to squeezelite's static -o. Fix: macos/squeezelite-follow-output.sh, a new polling wrapper (switchaudio-osx's SwitchAudioSource -c every 3s) that relaunches squeezelite with -o pointed at the current default output device whenever it changes. The LaunchAgent now launches the wrapper instead of squeezelite directly. Full install/verify steps: macos/README.md's squeezelite section. Two more bugs found getting this actually working, same day (2026-08-15): 1. A month-old zombie squeezelite process (PID running since 2026-07-14, launched via the GUI app bundle path, not our LaunchAgent — the exact "duplicate launch" pattern from the Cause 1 section above, evidently never fully resolved or recurred) was silently competing for the LMS player slot and CoreAudio device the whole time this was being "wonky." Not present in current Login Items, so killing it should be permanent — but check ps aux | grep -i squeezelite again after a future reboot if the same symptom reappears. 2. AirPods register as two identically-named CoreAudio devices (mono input, stereo output — see known-fixes/squeezelite-airpods-duplicate-device-name-channel-mismatch.md), which broke the wrapper's name-based -o even after fix #1. Wrapper updated to resolve the device name to squeezelite's own numeric index (from squeezelite -l, already output-only) before launching. Manual one-shot test confirmed working as of 2026-08-15: squeezelite -o 1 -n MacBook -s 100.64.0.8 opened "Matt's AirPods" cleanly and played audio with normal volume ramping, zero channel errors — the index-based fix that's now baked into the wrapper. **The actual wrapper+LaunchAgent path, redeployed the same session with that fix in place, produced no sound.** Not yet root-caused — session ended here (2026-08-15, late) before diagnosing further. MOS reported ongoing copy/paste friction running the install commands from chat into the Mac terminal (unconfirmed cause — possibly smart-quote/apostrophe mangling in transit, since several commands in this whole thread involved device names containing that exact character; possibly something else) — worth ruling that in or out first before re-diagnosing the wrapper logic itself, since a mis-pasted command could produce almost any symptom including this one. Next session: start here, not from scratch — re-run the verification block (ps aux, both log files, launchctl list) fresh rather than assuming the manual-test fix confirmed above also holds for the deployed wrapper.
systemd-resolved wrong-answer bug — FQDN resolves to public IP on developer-env medium
newly added subdomain resolves to public IP instead of 100.64.0.4 • dig @192.168.42.27 returns correct answer but system resolution is wrong • resolvectl flush-caches does not fix it
dns systemd-resolved developer-env cache netplan dhcp   last seen: 2026-07-13

Symptoms

  • newly added subdomain resolves to public IP instead of 100.64.0.4
  • dig @192.168.42.27 returns correct answer but system resolution is wrong
  • resolvectl flush-caches does not fix it
  • resolvectl status shows Current DNS Server as 1.1.1.1 instead of an AdGuard IP
  • resolvectl status shows a per-link (e.g. ens18) DNS Servers list containing 1.1.1.1 even after resolved.conf.d is fixed

Reproductions

2026-06-18, 2026-07-03, 2026-07-13 — same signature each time, workaround (systemctl restart systemd-resolved) applied but root cause not found until 2026-07-16. First fix attempt on 2026-07-16 (editing resolved.conf.d/adguard.conf alone) turned out to be incomplete — see Part 2 below. Verified still holding 2026-07-26 — live resolvectl status on developer-env showed 1.1.1.1 absent from every scope (Global = the two AdGuard IPs only; ens18, tailscale0, and docker0 all Current Scopes: none), and dig of a wildcard FQDN via the system stub returned the internal 100.64.0.4, not the public Porkbun record. No recurrence since the 2026-07-16 fix.

Root cause — Part 1: global config (found 2026-07-16)

This was never actually a stale cache. /etc/systemd/resolved.conf.d/adguard.conf listed all three DNS servers together under DNS=:
DNS=192.168.42.27 192.168.42.89 1.1.1.1
Domains=~.
systemd-resolved picks one "current DNS server" per link and only fails over to the next entry in the list on a hard timeout — it does not fail back to the top of the list once a later entry starts answering successfully. A single transient blip on either AdGuard instance could stick the resolver on 1.1.1.1 indefinitely. Because Porkbun's public DNS zone genuinely has a wildcard A record for *.compellinglylowbrow.org (needed so Caddy is reachable directly from the internet, not just via Headscale), 1.1.1.1 happily "resolves" the domain — just to the wrong (public) IP instead of the internal Headscale IP. That's why dig @192.168.42.27 always looked fine (AdGuard was never actually broken) while the stub resolver kept returning a real-but-wrong answer, and why resolvectl flush-caches never fixed it — there was no stale entry to flush, the resolver was pinned to the wrong upstream server. Important: moving 1.1.1.1 to FallbackDNS= does not fix this. FallbackDNS= is only consulted when DNS= is entirely empty — it is not a live failover target for when configured DNS= servers are unreachable, so it would never fire in practice while the two AdGuard IPs remain listed. (Verified via systemd docs and multiple corroborating systemd/systemd GitHub issues.)

Root cause — Part 2: DHCP was also injecting 1.1.1.1 (found same day, after Part 1's fix was verified incomplete)

Editing resolved.conf.d/adguard.conf alone was not sufficient. resolvectl status after that fix still showed:
Link 2 (ens18)
       DNS Servers: 192.168.42.27 1.1.1.1
     Default Route: yes
    Current Scopes: DNS
ens18 had its own separate per-link DNS list — sourced from DHCP, not from resolved.conf.d — and since it was the link with Default Route: yes and Current Scopes: DNS, systemd-resolved was actually routing queries through *this* list, not the Global one we'd just fixed. /etc/netplan/00-installer-config.yaml had plain dhcp4: true / dhcp6: true with no use-dns override, so the router's DHCP-supplied DNS servers (192.168.42.27 + 1.1.1.1, missing adguard2 entirely) were flowing straight into the per-link config — recreating the exact same sticky-failover exposure one layer down, just with one fewer AdGuard IP as backup.

Fix (fully applied and verified 2026-07-16)

Step 1 — resolved.conf.d (necessary but not sufficient alone):
[Resolve]
DNS=192.168.42.27 192.168.42.89
Domains=~.
sudo systemctl restart systemd-resolved
Step 2 — netplan, to stop DHCP from re-injecting 1.1.1.1 on ens18:
# /etc/netplan/00-installer-config.yaml
network:
  ethernets:
    ens18:
      dhcp4: true
      dhcp4-overrides:
        use-dns: false
      dhcp6: true
      dhcp6-overrides:
        use-dns: false
      match:
        macaddress: bc:24:11:dc:22:28
      set-name: ens18
  version: 2
sudo netplan apply
Verified post-fix resolvectl status: Global shows Current DNS Server: 192.168.42.27 and DNS Servers: 192.168.42.27 192.168.42.89 only; ens18 shows Current Scopes: none (no DNS servers on that link at all anymore). 1.1.1.1 is gone from every scope. Trade-off accepted: if both adguard (LXC 102) and adguard2 (LXC 103) become unreachable simultaneously, developer-env now has zero DNS resolution (internal and public) until one recovers, instead of silently degrading to public-only via 1.1.1.1. Considered low-risk — both AdGuard LXCs run on proxmox-nuc alongside developer-env itself, so a host-level failure takes out all three regardless, and a dual-AdGuard-only outage is independently caught by Uptime Kuma's port checks against both LAN IPs from the watchdog Pi5. See INFRASTRUCTURE.md's developer-env VM Notes section for the same writeup in infrastructure-reference form (updated 2026-07-25 to cover both steps, including the netplan use-dns: false override — no longer Step 1 only).

Sibling: same class on watchdog (NetworkManager/glibc, not systemd-resolved), fixed 2026-08-05

The watchdog Pi (RPi OS trixie) hit the same sticky-DHCP-DNS failure from a different mechanism entirely -- no systemd-resolved involved. NetworkManager's Wired connection 1 (ipv4.method auto, ignore-auto-dns no) let DHCP hand glibc both AdGuard *and* 1.1.1.1, so glibc fell through to 1.1.1.1 and resolved *.compellinglylowbrow.org to the public Porkbun record (~0.7% of the Pi's monitor traffic hairpinned over the WAN). Fixed the NetworkManager way: `nmcli con mod "Wired connection 1" ipv4.ignore-auto-dns yes ipv4.dns "192.168.42.27 192.168.42.89", keeping method auto` so the DHCP reservation still assigns the address. Full record: known-fixes/crowdsec-doh-crs-920420-and-ts2021-ban-loop.md (item 13). Same lesson as this file -- pin AdGuard and strip 1.1.1.1 from every layer that can inject it (here DHCP->NetworkManager, there DHCP->netplan and resolved.conf.d).

Diagnosis (for reference / if this or a similar bug resurfaces)

dig <fqdn> +short                    # returns public IP → wrong upstream, not necessarily "stale"
dig <fqdn> @192.168.42.27 +short     # returns 100.64.0.4 → AdGuard is correct
dig <fqdn> @127.0.0.53 +short        # returns public IP → stub resolver is using the wrong server
resolvectl status                    # check EVERY link's "DNS Servers" and "Current Scopes",
                                      # not just Global — a per-link list (e.g. from DHCP) can
                                      # override the Global resolved.conf.d config entirely

Why this note previously recommended `systemctl restart` as "the fix"

A restart works because it forces the resolver to re-pick a "current DNS server" from the top of the effective list, which happened to be an AdGuard IP again — but nothing stopped it from re-sticking on a bad entry the next time there was a blip, from either source (global config or per-link/DHCP). That was treating the symptom, not the cause. With 1.1.1.1 removed from both the global config *and* the DHCP-sourced per-link list, there's no wrong server left anywhere to get stuck on.
Node shows connected but SSH / VS Code won't reach it medium
tailscale status shows connected but SSH times out • VS Code can't connect despite node appearing online • tunnel is stale on connecting machine
headscale tailscale ssh   last seen: 2026-06-03

Symptoms

  • tailscale status shows connected but SSH times out
  • VS Code can't connect despite node appearing online
  • tunnel is stale on connecting machine

Cause

The Headscale client on the *connecting* machine (e.g. Mac) has gone stale — it thinks it's connected but the tunnel is dead.

Fix

Restart Tailscale on the Mac, not the target.
# macOS
sudo tailscale down && sudo tailscale up

or restart via the menu bar icon

tailscale up blocks on interactive prompt when run via pct exec medium
onboard-container Phase 5 hangs at tailscale up • tailscale up never returns via pct exec • works fine in Proxmox console but not non-interactive
tailscale onboard-container pct-exec   last seen: 2026-06-16

Symptoms

  • onboard-container Phase 5 hangs at tailscale up
  • tailscale up never returns via pct exec
  • works fine in Proxmox console but not non-interactive

Cause

Tailscale detects the connection path goes through itself and prints an interactive warning about losing SSH access, waiting for confirmation. This blocks non-interactive pct exec indefinitely.

Fix

Pass --accept-risk=lose-ssh to suppress the prompt:
tailscale up \
  --login-server https://headscale.compellinglylowbrow.org \
  --authkey <key> \
  --accept-dns=false \
  --hostname <name> \
  --force-reauth \
  --accept-risk=lose-ssh
This is safe for onboarding — the container's SSH access is via LAN IP, not through Tailscale. onboard-container includes this flag by default.
tailscaled stuck with 'magicsock: network down' -- host unreachable over tailnet despite a healthy container and a current headscale last_seen medium
SSH (or any other port) over a host's Headscale IP times out • headscale nodes list shows the node connected with a current last_seen timestamp anyway • The container/VM itself is otherwise completely healthy -- other services on it are up, LAN access works fine
tailscale tailscaled headscale magicsock derp networking pct-exec pibox nastynas   last seen: 2026-08-15

Symptoms

  • SSH (or any other port) over a host's Headscale IP times out
  • headscale nodes list shows the node connected with a current last_seen timestamp anyway
  • The container/VM itself is otherwise completely healthy -- other services on it are up, LAN access works fine
  • tailscale status on the host itself shows: Health check: - Tailscale could not connect to any relay server
  • journalctl -u tailscaled shows repeating: wg: [...] - Failed to send handshake response: magicsock: network down
See also: headscale-acl-tag-scoped-rule-matches-nobody

Symptom

A host that should be reachable over the tailnet (SSH, or any app port) times out completely, but headscale nodes list shows it connected with a *current* last_seen — looks contradictory: a node headscale considers online, unreachable by every actual protocol. Easy to misdiagnose as another headscale/acl.hujson missing-entry gap (see headscale-acl-tag-scoped-rule-matches-nobody.md and this repo's several "FOLLOW-UP FIX" ACL incidents) since the symptom looks identical from the outside.

Root Cause

tailscaled's magicsock layer (the UDP-based networking engine underneath WireGuard) gets stuck believing the network is down and stops reconnecting to any DERP relay — even though the underlying network, DNS, and outbound internet are all completely fine. Confirmed on an LXC with 8 days of uptime; not tied to any specific trigger observed live (no interface change, reboot, or suspend/resume visible in the available journal window), just an ongoing stuck state once it happens.

Diagnosis

Use pct exec from the container's Proxmox host to bypass the broken tailnet path entirely (same escape hatch as a self-inflicted SSH-lockout — see known-fixes/searxng-ufw-first-enable-lockout.md's pattern, applies here for the same reason: it doesn't depend on the network layer that's actually broken):
pct exec <vmid> -- systemctl is-active ssh <service> tailscaled   # confirm container itself is healthy
pct exec <vmid> -- tailscale status

Health check:

- Tailscale could not connect to any relay server. Check your Internet connection.

pct exec <vmid> -- curl -s -o /dev/null -w '%{http_code}\n' https://1.1.1.1 # confirm real internet works pct exec <vmid> -- journalctl -u tailscaled -n 40 --no-pager

wg: [...] - Failed to send handshake response: magicsock: network down (repeating)

How to tell this apart from a missing ACL entry (same "unreachable" symptom, very different fix): check headscale nodes list's last_seen for the node first. Current/recent last_seen means the control-plane heartbeat is working fine, which points at tailscaled on the host itself (this issue) rather than the ACL. A stale/absent last_seen, or the host missing from hosts{} entirely, points at the ACL or the node's registration instead.

Fix

pct exec <vmid> -- systemctl restart tailscaled
Cleared immediately in the confirmed case — the relay-connection health warning was gone within seconds, and SSH from developer-env worked on the first try afterward. No ACL, ufw, or application-level change was needed; the container and its actual service (vaultwarden.service in the confirmed case) were healthy throughout and never touched by this fix.

Second occurrence (2026-08-11, pibox) — now auto-remediated

Same wedge, different node and different variant of the health-check text: pibox (standalone Pi CM4, not an LXC — pct exec doesn't apply) showed itself offline in its own tailscale status, with `# Health check: - Unable to connect to the Tailscale coordination server to synchronize the state of your tailnet.` — the control-plane-sync variant rather than the DERP-relay variant, same underlying magicsock wedge. Headscale itself was confirmed healthy (200 on both LAN and public /health) and the other peers (caddy, developer-env, watchdog) were all active, ruling out a control-plane outage. systemctl restart tailscaled cleared it immediately, same as every prior occurrence. Given this is now a second occurrence and pibox's Caddy backend is proxied via its Headscale IP (see INFRASTRUCTURE.md's pibox note), a wedge here silently takes down pibox.compellinglylowbrow.org too, not just direct tailnet access — worth automating rather than waiting to notice by hand a third time. bin/pibox-tailscale-selfheal.sh now runs via root cron on pibox itself every 5 min, checks `tailscale status --json's Health field, and restarts tailscaled` locally if non-empty (debounced ntfy notification on restart, and an escalation notification if the restart doesn't clear it — the latter would mean this is a *different* problem than this known wedge). Deliberately local-only, not a watchdog-triggered remediation: watchdog cannot reach pibox's LAN IP at all, only its Headscale IP (see bin/setup-uptime-kuma.py's NETDEP comment on the Pibox monitor) — so the one path a remote fix would need is exactly the one this failure breaks. A local check has no such dependency; its ntfy notification still goes out over plain LAN, unaffected by the wedge. Deployed and verified live 2026-08-11 (clean dry run, cron syntax confirmed accepted via journalctl -u cron). This closes the gap for pibox specifically — the other three confirmed nodes (seeder-daemon, vaultwarden) don't yet have the equivalent local self-heal; revisit if this recurs on one of them too rather than pre-emptively rolling it out everywhere. Confirmed clean via live collected data, same session. The next bin/collect-homelab run (2026-08-11, commit 4dbb10f) pulled a fresh collected/pibox/tailscale-status.txt independently of the manual restart above — pibox shows - status (not offline) with no Health check warning line, matching every other healthy peer. Confirms the wedge actually cleared and stayed clear, not just that the restart command returned success.

Third occurrence (2026-08-15, nastynas) — different node class again, now generalized fleet-wide

Same wedge, same coordination-server-sync variant of the health text as pibox's occurrence, verbatim: `Unable to connect to the Tailscale coordination server to synchronize the state of your tailnet.` Surfaced while diagnosing what looked like a physical-layer regression: MOS moved nastynas's cable to the switch's 10G SFP+ port 9 (see INFRASTRUCTURE.md's switch Phase 1 note) and bin/network-benchmark showed throughput collapse from ~112 MB/s to 2.6 MB/s with ping avg jumping 1.46ms→7.16ms — looked exactly like a bad transceiver/marginal link. It wasn't: reverting the cable back to the known-good 2.5GbE port produced identical bad numbers (2.6 MB/s, same elevated ping), which is what actually flagged this as host-software rather than physical — a real port/cable fix would have shown improvement immediately on revert, and it didn't. tailscale status on nastynas itself then showed the same Health warning as pibox's occurrence, plus several peers listed as relaying through sfo (DERP) instead of direct. systemctl restart tailscaled cleared it immediately, same as every prior occurrence — confirmed both by tailscale status (Health line gone, peers back to direct) and by re-running network-benchmark, which returned nastynas to baseline (actually better — see below, this was mid-flight through a legitimate separate 10G upgrade). Lesson for next time this pattern shows up: a sudden throughput/latency regression that appears right after a physical change (cable, port, cabling swap) is not automatically caused by that change — check tailscale status's Health field *before* assuming the physical layer, especially for any host reached primarily over its Headscale IP (as documented in network-benchmark's own comments, true for nastynas, pibox, and most LXCs). The two problems coincided by timing, not causation, here. Third distinct node class hit by this wedge: an LXC (vaultwarden), a standalone Pi CM4 (pibox), now a standalone bare-metal Proxmox node (nastynas) — not tied to container type, board, or OS variant. Three independent hosts in about a month cleared the "revisit if it happens again" bar this doc originally set for extending self-heal past pibox.

Generalized fleet-wide (2026-08-15, same session as the third occurrence)

bin/pibox-tailscale-selfheal.sh became bin/tailscale-selfheal.sh — same detect/restart/notify loop, host-agnostic (uses $(hostname) in place of hardcoded "pibox" strings, auto-detects systemctl vs rc-service so it works on alpine-it-tools' OpenRC/Alpine setup too, not just systemd). notify() also gained an explicit curl --max-time and wildwood's own local-then-ntfy.sh-fallback pattern (sourced from /root/wildwood/.env, not duplicated) — without this, a genuinely wedged wildwood would have hung trying to reach the home-LAN ntfy IP it can't route to at all, since wildwood sits on a different subnet (192.168.0.0/24) with no direct LAN path home. Found this the hard way: the very first fleet-deploy run hung for 30s on wildwood's smoke test and crashed the deploy loop before reaching the remaining 20 hosts — fixed in the same pass, both the missing timeout and the deploy script's own unguarded exception propagation (one host timing out shouldn't kill the whole run). Rolled out via the new bin/deploy-tailscale-selfheal (imports load_hosts_config from os-update-checker the same way daily-fleet-digest already does — not reimplemented — then walks proxmox_nodes/lxc_containers/virtual_machines/ standalone, deduped by SSH IP): 22/23 in-scope hosts deployed clean. homeassistant (HAOS, no general-purpose root shell over its port-22222 SSH) and macbookpro-pve (powered down indefinitely) are permanently out of scope — see the deploy script's own docstring. developer-env has no root SSH (mos + interactive-only sudo, same reason os-update-checker already special-cases it) — installed locally by hand instead of through the generic root-SSH sweep, same one-time step every other per-host tool on this repo takes for that host. nas (nastynas's own LXC, 100.64.0.114) was unreachable at deploy time (100% ping loss, SSH connection timeout) — a separate, real issue flagged to MOS the same session, not a self-heal gap; retry bin/deploy-tailscale-selfheal nas once it's back. Also wired into bin/onboard-container (deploy_selfheal_lxc/deploy_selfheal_vm, called right after Tailscale registration succeeds, non-fatal on failure) so every future host gets this from day one instead of waiting for a fourth occurrence to notice the gap again. See .claude/skills/lxc-lifecycle/SKILL.md for the onboarding note and docs/backlog-plan.md for the closed backlog item this resolves. Real false-positive found within ~25 minutes of the first rollout, same session. The original get_health() treated ANY non-empty Health field as the wedge. tailscale status --json also carries other, benign, persistent advisory text unrelated to this bug — `"Some peers are advertising routes but --accept-routes is false"` turned out to be present fleet-wide (informational: something on the tailnet advertises subnet routes this host isn't configured to accept — not a stuck state, restarting tailscaled does nothing for it, and it never clears). Every host carrying that warning restarted tailscaled on the deploy-time smoke test AND on every 5-minute cron tick after — worse, the "restart didn't clear it" notify path had **no debounce at all**, so each of those ticks fired a fresh URGENT-priority ntfy. Confirmed via journalctl -u tailscaled on vaultwarden: 4 restarts in ~25 minutes before this was caught. Fixed by narrowing get_health() to match only the confirmed wedge text (coordination server / relay server / magicsock) and adding the missing debounce; redeployed fleet-wide immediately. Lesson: before trusting a "non-empty = broken" health check as a trigger for an automated restart+notify loop, check what that field actually contains on live, currently-healthy hosts across the real fleet — don't assume a health-check API only ever reports the one condition you built the automation for. **Follow-on incident, same day: developer-env kept firing after the fix landed everywhere else.** bin/deploy-tailscale-selfheal permanently excludes developer-env (no root SSH — see that script's own SKIP_NAMES comment), so redeploying the false-positive fix fleet-wide via that script did not reach it — developer-env kept running the old buggy copy already installed there by hand and fired one more real urgent ntfy (`developer-env: tailscaled restart did NOT clear the issue`, 2026-08-15 13:35 PDT) before this was caught and fixed with a manual sudo cp bin/tailscale-selfheal.sh /usr/local/bin/tailscale-selfheal.sh on the host itself. **Any future fix to bin/tailscale-selfheal.sh needs that same manual step repeated on developer-env** — a clean run of bin/deploy-tailscale-selfheal does not cover it and gives no warning that it doesn't.
tailscaled reports online but WireGuard handshakes fail with 'network down' / 'no preferred DERP' medium
SSH to a node times out (not refused) despite the node showing 'online' in headscale nodes list and tailscale status • sshd on the target is healthy and listening; LAN-IP SSH logins to the same host succeed fine in its own journal • journalctl -u tailscaled shows repeated 'wg: [nodekey] - Failed to send handshake response: magicsock: network down'
tailscale tailscaled derp headscale ssh   last seen: 2026-07-19

Symptoms

  • SSH to a node times out (not refused) despite the node showing 'online' in headscale nodes list and tailscale status
  • sshd on the target is healthy and listening; LAN-IP SSH logins to the same host succeed fine in its own journal
  • journalctl -u tailscaled shows repeated 'wg: [nodekey] - Failed to send handshake response: magicsock: network down'
  • tailscale status --peers on the affected node itself shows 'Tailscale could not connect to any relay server. Check your Internet connection.'
  • netmap: suggested exit node: no preferred DERP, try again later, repeating

Symptom

ssh seeder-daemon-cmd "echo test" from developer-env hung, then timed out (Connection timed out, not refused) after connecting to seeder-daemon's Headscale IP (100.64.0.108). Everything *else* checked out fine: - headscale nodes list showed the node online, last-seen seconds ago - tailscale status (from developer-env) showed the peer with an active direct connection line - Diffing the live Headscale ACL against git showed zero drift — the policy correctly grants this exact path - pct exec 120 -- systemctl status ssh showed sshd active (running), listening on *:22, with successful LAN-IP logins in its own journal minutes earlier - IPv4 WAN egress from the container itself was fully healthy (curl -4 to 1.1.1.1 succeeded cleanly) This ruled out ACL, DNS, sshd, the container's own health, IPv6 routing (a red herring initially suspected — see below), and a genuine WAN outage (confirmed via the same test succeeding on other LXCs and the Proxmox host itself) all in turn, before the real cause surfaced in tailscaled's own journal on the *target* node:
wg: [nodekey] - Failed to send handshake response: magicsock: network down
netmap: suggested exit node: no preferred DERP, try again later
And tailscale status's own health check line, run on the affected node itself:
# Health check:

- Tailscale could not connect to any relay server. Check your Internet connection.

Despite that message, the node's actual internet connection was fine (confirmed via the IPv4 curl test above) — tailscaled's internal network-state tracking had simply gotten wedged, most likely after some earlier network blip it never cleanly recovered from, and kept retrying a WireGuard handshake path it believed was down.

False leads ruled out along the way (useful to skip faster next time)

1. Headscale ACL gap — the classic pattern documented elsewhere in this repo (a host missing from hosts{} produces a silent hang). Ruled out: seeder-daemon was already correctly declared, and a direct diff against the live file on the headscale LXC showed no drift at all. 2. IPv6 routing — the first curl -sv test to a DERP-adjacent host only showed IPv6 addresses failing (Network is unreachable) in a truncated view, which briefly looked like a missing IPv6 default route on this one container. Forcing curl -4 proved IPv4 worked identically to every other host — this container, like every LXC on this flat LAN, simply has no IPv6 route, which is normal here and unrelated to the actual problem. 3. Homelab-wide WAN outage — briefly considered given the "solar + battery keeps LAN up during WAN outages" design note elsewhere in this repo, but ruled out immediately: the Proxmox host and the caddy LXC both had fully working WAN via the exact same test.

Fix

Restart tailscaled on the affected node itself (not the connecting machine — see known-fixes/tailscale-connected-but-ssh-fails.md for the opposite-direction case, where it's the *connecting* Mac that's stale):
pct exec <vmid> -- systemctl restart tailscaled
sleep 5
pct exec <vmid> -- tailscale status   # health check line should be clean
Confirmed fixed immediately — the health-check warning disappeared and a retried SSH from developer-env succeeded cleanly on the first attempt.

Why this is worth knowing

The diagnostic dead-ends above (ACL, IPv6, WAN) are each real, previously -documented failure classes in this homelab, and the symptom (SSH hang, not refusal) is genuinely ambiguous between all of them at first glance. The one thing that cut through the ambiguity was checking tailscaled's *own* journal and its *own* self-reported health check on the specific node being connected to — worth doing early next time this exact "online but unreachable" shape shows up, rather than working through the whole ACL/DNS/WAN checklist first. This appears to have been an isolated, one-off wedge on this single node — not caused by, or related to, anything else changed in the same session (a CrowdSec Docker migration on a different host entirely).
Trusted VLAN couldn't reach the UCG-Fiber's own admin UI after GDTRFB moved off flat/Servers -- missing Trusted->Servers:443 firewall rule medium
Immediately after reassigning the GDTRFB WiFi network from the default LAN to the new Trusted network (VLAN 10), devices connected to it could no longer load the UCG-Fiber's own Network app UI at https://192.168.42.1 • Ping to 192.168.42.1 worked fine from the same client the whole time -- this was not a full connectivity loss • Symptom looked identical across three different access attempts, each with a different (wrong) prevailing theory at the time: WiFi client (blank/timeout), then a wired Ethernet client plugged directly into the gateway (also timed out), then Firefox specifically showed a browser-level timeout error
network firewall vlan ucg-fiber unifi zone-based-firewall tailscale macos trusted gateway-ui   last seen:

Symptoms

  • Immediately after reassigning the GDTRFB WiFi network from the default LAN to the new Trusted network (VLAN 10), devices connected to it could no longer load the UCG-Fiber's own Network app UI at https://192.168.42.1
  • Ping to 192.168.42.1 worked fine from the same client the whole time -- this was not a full connectivity loss
  • Symptom looked identical across three different access attempts, each with a different (wrong) prevailing theory at the time: WiFi client (blank/timeout), then a wired Ethernet client plugged directly into the gateway (also timed out), then Firefox specifically showed a browser-level timeout error
  • curl -kv https://192.168.42.1/ from the wired client hung at 'Trying 192.168.42.1:443...' and timed out -- a real TCP-level failure, not a browser rendering/cache issue
See also: nastynas-pibox-lan-ip-unreachable-post-vlan-work

Root cause

Session 2 Step 1's Phase C explicit Trusted->Servers allow list (built and verified the night before) covered Proxmox (8006), Uptime Kuma (3001), SSH (22), and SMB (445) -- but never the gateway's own admin UI (443). Once GDTRFB (the household's only WiFi network at the time) was reassigned from the flat/Servers network to the new Trusted network (VLAN 10) as Session 2 Step 2's first move, every WiFi client -- including the one used to administer the gateway -- lost the ability to reach 192.168.42.1:443, since that traffic now had to cross the Trusted->Servers zone boundary and hit the same default-deny everything else outside the explicit allow list hits. This was not caught by Phase E's own verification pass the night before, because that pass tested a throwaway Kali laptop's *general* default-deny behavior (a single nc to an arbitrary port), not the specific case of a Trusted client needing the gateway's own console. Confirmed via direct inspection of the gateway's own iptables/ipset state (the UCG_API key is read-only and doesn't expose firewall/zone endpoints, same limitation noted in Session 2 Step 1): before the fix, no ipset scoped to 192.168.42.1:443 existed anywhere in the ruleset.

Two false leads chased before the real fix (both real, neither the cause)

1. macOS's per-app "Local Network" privacy permission. Firefox's own timeout page explicitly named this as a possibility, and it's a real mechanism (apps need an OS-level grant to reach 192.168.x.x at all, separate from any firewall). Checked, already granted, not the cause here -- but a legitimate thing to check first for this exact symptom class on any macOS client. 2. Tailscale accept-routes routing loop, identical in mechanism to known-fixes/nastynas-pibox-lan-ip-unreachable-post-vlan-work.md. Confirmed real and reproducible on the Mac specifically: `route get 192.168.42.1 showed traffic going out utun0` (Tailscale's interface) instead of the physical link, and curl timing out matches that incident's signature exactly. This is a genuine, separate bug that would need fixing on its own -- see that known-fix's update below -- but it wasn't why the *wired* client (which was tested at the same time, also failing) couldn't reach the UI. The wired client's failure was purely the missing firewall rule; the WiFi client had *both* problems stacked, which is what made this so confusing to unwind live.

Fix

Added an explicit Trusted->Servers rule, mirroring the existing Proxmox/Kuma/SSH/SMB rules exactly: - Allow, Trusted -> 192.168.42.1, TCP 443 Verified live via the gateway's own iptables -S / ipset list output -- lands in UBIOS_CUSTOM1_LAN_USER (Custom1 = Trusted zone, LAN = Servers zone) with UBIOS_policy_dst_ip_8 scoped to exactly 192.168.42.1 and UBIOS_policy_dst_port_8 scoped to exactly 443, with the mirrored UBIOS_LAN_CUSTOM1_USER return-traffic rule using RELATED,ESTABLISHED, same pattern as the four sibling rules.

Prevention

- The gateway's own admin UI is itself a "LAN-IP-direct" access path and needed the same explicit-allow treatment as Proxmox/Kuma/SSH/SMB -- docs/vlan-gateway-migration-plan.md's Trusted->Servers audit list should have named it from the start, since it's arguably the single most important LAN-IP-direct destination of all (you need it to fix anything else that breaks). Added retroactively; any future zone/VLAN work on this gateway should treat "can the admin zone still reach the gateway's own console" as a pre-flight check, not something discovered by breaking it live. - **Don't reach for a factory reset on a "can't load the web UI" symptom without confirming the device itself is actually unhealthy first.** The gateway was checked via SSH/ping/curl repeatedly throughout this incident and never once showed a real problem (0 failed systemd units, consistent uptime, sub-15ms local HTTP response) -- the entire incident was a client-side routing/firewall problem, never a device problem. A factory reset here would have destroyed WAN config, all 5 VLANs, every Phase A-C firewall rule, and both Headscale port forwards for zero diagnostic benefit. - **When a wired-direct client to the same destination shows the identical symptom as a WiFi client, that's a strong signal the problem isn't WiFi/AP-specific** -- it moved the investigation away from the AP (which was never the cause) and toward something both paths shared (the firewall zone, in this case). - Keep a non-WiFi, non-Tailscale path to the gateway's admin UI in mind as a standing fallback for future VLAN/firewall work on this device -- wired-direct-into-the-gateway bypasses AP and VLAN entirely, but does not bypass zone-based firewall rules the way it might first seem (as this incident demonstrated), so it's a partial escape hatch, not a complete one.
U7 Pro (adopted UniFi AP) has no standing SSH credential -- the Network app's "Debug" button is an on-demand proxied session, not persistent access medium
u7-pro unifi ssh ucg-fiber   last seen:

Symptom

Onboarding u7-pro (192.168.42.30) the same way ucg-fiber was onboarded: SSH port 22 is open (ssh gets a host key, not a timeout/refusal) but root@ is rejected with both key and password auth (Permission denied (publickey,password)) -- no credential from the ucg-fiber SSH setup carries over.

Root cause -- confirmed

ucg-fiber IS the UniFi OS console -- its SSH is the console's own persistent root account, set up directly in Settings during Phase 1 bench-configuration (see known-fixes/ucg-fiber-ssh-login-is-root-not-peruser.md). u7-pro is a device *adopted by* that console's Network app, not a console itself, and has no equivalent persistent account. The only actual access path is the Network app's per-device "Debug" button. MOS confirmed what it does: it opens a terminal that logs
Device U7 Pro: Connecting
Device U7 Pro: Connected
...
mos@U7-Pro:~#
-- an on-demand session the console proxies through the browser, dynamically mapping in the *currently logged-in UI admin* (the prompt shows mos, the console's own local admin username, not any fixed OS account on the AP). This is UniFi's "SSH to device" feature: initiated per-session by the console, tunneled through it, gone when the browser tab closes. There is no persistent credential sitting on the AP that developer-env (or anything else) could authenticate against directly -- confirmed, not just unconfigured.

Fix

None applicable -- this isn't a gap to close. collect_u7_pro() (bin/collect-homelab) doesn't attempt SSH collection at all; status.txt states the limitation explicitly rather than showing a misleading empty result or implying a pending setup step. firmware-status.json (via the read-only UniFi Network Integration API) is unaffected either way -- pure API, no SSH needed, confirmed live 2026-08-21 (firmware_updatable: false at 8.7.11). The ssh_ip/ssh_user fields stay in inventory/hosts-config.yaml purely so ssh u7-pro exists as an alias for manual, ad hoc use (e.g. if a future firmware version exposes a real persistent-SSH toggle) -- collect-homelab itself never tries it.

Prevention / notes for next time

1. A device *adopted by* a UniFi console does not share the console's own SSH surface, even though both are reachable through the same physical gateway. Confirmed here they're two structurally different mechanisms: a real persistent account (console) vs. an on-demand browser-proxied session that maps in whichever UI admin is currently logged in (adopted device). Don't assume the first implies the second for any future UniFi device onboarded this way (switches, other APs, etc.) -- check for a real listening SSH service you can authenticate against directly before writing collection code that assumes one exists. 2. Onboarded the same session ucg-fiber's os-update-checker sweep gap was found and fixed -- u7-pro was added to both resolve-hosts' SPECIAL_COLLECT and os-update-checker's SKIP_NAMES preemptively from the start, rather than waiting to get bitten the same way twice. See known-fixes/ucg-fiber-swept-into-os-update-checker.md.
UCG-Fiber Session 1 Phase 2 cutover -- public HTTPS (443) broke because the BE800's port-forward list was never confirmed exhaustive medium
ucg-fiber gateway cutover port-forward caddy wildwood headscale   last seen:

Symptom

Immediately after Phase 2 cutover (docs/ucg-fiber-session1-cutover-checklist.md -- WAN moved from the BE800 to the UCG-Fiber, public IP changed 135.180.77.225 -> 135.180.72.8), wildwood's automated external checker (wildwood/check_external.py, genuinely offsite vantage point, runs every 10 min) started failing all three of its checks on every run:
headscale /health: FAIL (HTTP 000 -- curl: (28) Connection timed out after 15002 milliseconds)
dns /dns-query: FAIL (HTTP 000 -- curl: (28) Connection timed out after 15002 milliseconds)
openssl s_client failed to run: ... timed out after 15 seconds
wildwood also dropped off the tailnet entirely from developer-env's perspective -- SSH over the Headscale tunnel timed out and 100.64.0.3 stopped answering ICMP, even though headscale nodes list still showed it "online" (a stale cached last-seen from before cutover, not a live signal -- don't trust that column during an active incident). Everything internal-facing looked fine at the same time: proxmox-nuc/ nastynas both correctly kept 192.168.42.1 as default gateway, DDNS self-corrected all 3 records within 5 min, AdGuard's internal DNS rewrites checked out, and bin/health-check (which only probes from the LAN) was clean. This split -- internal fine, external totally dark -- is the tell.

Root cause

The BE800 had been forwarding TCP 443 -> Caddy (192.168.42.45) in addition to the two forwards this migration's planning docs actually tracked (headscale-stun UDP 3478, headscale-wireguard UDP 41641, both -> the headscale LXC 192.168.42.177). That 443 forward was never written down anywhere in this repo, so Phase 1's bench-configuration of the UCG-Fiber only recreated the two documented forwards -- 443 was simply never entered, because nothing in the source data said it needed to be. The Phase 0 checklist item that would have caught this ("Log into the BE800 UI and confirm its port forwards are only the two listed below -- this was never previously confirmed exhaustive, only that these two exist") was written as a caveat, not acted on as a blocking check, during the actual swap. Why wildwood itself dropped off the tailnet (not just the HTTPS checks failing) is presumably a related/downstream effect of the same gap, or independent WireGuard/DERP re-negotiation after the WAN IP change -- it self-recovered in the same window the 443 forward was added and wasn't separately root-caused.

Fix

MOS added the missing forward live in the UCG-Fiber UI: TCP 443 -> 192.168.42.45, named https-caddy. Confirmed fixed within one wildwood check cycle (~10 min) -- next run showed all three checks green (headscale /health: OK (200), dns /dns-query: OK (200), cert-expiry check clean), and wildwood reconnected to the tailnet on its own in the same window. No config automation was used or considered for this -- per INFRASTRUCTURE.md's existing decision, the UCG-Fiber's config surface is deliberately not scripted against (undocumented/version-fragile local REST API, highest blast-radius node in the fleet). This was diagnosed via SSH/journalctl on wildwood and fixed by hand in the UCG-Fiber's own UI.

Prevention / notes for next time

- **A port-forward (or any config surface) list "pulled from the old device's UI" is not exhaustive just because nothing else surfaced in planning.** Before decommissioning a replaced gateway/router/firewall, the more reliable check is cross-referencing every *externally observed working thing* against the new device's config, not just what got written into a migration doc in advance. A forward can exist and work for months without ever being documented anywhere in this repo. - **wildwood/check_external.py did exactly the job it was built for here** -- it caught a real external-only outage that every LAN-side signal (health-check, DDNS, internal DNS) missed entirely, within one 10-minute cycle of the cutover. This is the second time offsite/guardian checks have caught something an on-LAN vantage point structurally cannot see (see also the Pi4 guardian design rationale in INFRASTRUCTURE.md). - Relevant for Session 2 (VLANs + U7 Pro, when it happens): re-verify the full port-forward table again before/after, don't assume Session 1's table was complete just because Session 1 turned out fine.
UCG-Fiber first-boot setup -- subnet-change freeze/recovery-mode loop, and DHCP Fixed-IP CSV bulk import reports 500 but actually succeeds medium
ucg-fiber gateway dhcp unifi hardware recovery-mode   last seen:

Symptom

During Session 1 bench-configuration of the new UCG-Fiber gateway (per docs/ucg-fiber-session1-cutover-checklist.md, Phase 1 -- fully isolated, laptop-direct, nothing touching the live LAN/WAN), two independent problems showed up: 1. UI freeze on LAN subnet change, followed by a recovery-mode loop. Changing the "Default" network's subnet from the factory 192.168.1.0/24 to 192.168.42.0/24 + DNS override (AdGuard) in one batched edit caused the setup UI to hang indefinitely after clicking Apply -- expected *briefly* (the browser session is talking to an IP the device is in the middle of retiring), but this did not recover even after several minutes, a laptop-side DHCP release/renew, and a plain reboot. The device instead came back into recovery mode (LCD showed a fixed 192.168.1.30 address) on every subsequent reboot/reset attempt -- and recovery mode's own network stack never answered ARP or ICMP on that address, on any of the 4 LAN ports tried, even after confirming (via a known-good laptop/cable tested against the live Omada switch) that the laptop and cable were not the problem. A 10-second reset-button hold produced a normal factory reset (back to the setup wizard), not recovery mode -- recovery mode did not engage via a post-boot button hold. **2. DHCP Fixed-IP CSV bulk import fails with a generic error (actually a backend 500) but the import silently succeeds anyway.** Once the device finally came back to normal boot (see Fix below) and Phase 1's LAN/DHCP/DNS config was redone and saved successfully, importing the DHCP reservation list (built from the checklist's table, format matched exactly to this device's own CSV export header: "MAC Address","IP Address",Hostname,"Local DNS Record","Lease Type",Name,"Expiration Time") failed with a generic "import failed, please verify the data" toast, both for a 3-row test file and the full 30-row file. Browser DevTools (Network tab, on the import request) showed the real cause: an HTTP 500 response, and the frontend's error was a JSON.parse failure -- i.e. the backend errored and returned something that wasn't valid JSON, which the UI's generic parser then choked on. Confirmed after the fact: the writes actually went through anyway. Attempting to manually re-add adguard/adguard2/the Omada switch through the normal "Add Fixed IP" form was rejected as duplicates -- meaning the earlier "failed" 3-row import had, in fact, created those records. A logout/login refreshed the UI's stale local state and all of them appeared (oddly, initially under a "WiFi" section/tab despite no AP being configured -- this turned out to be a cosmetic categorization quirk for reservations of devices that have never actually connected, alongside a "vendor: Proxmox" label that is *correct*, not a bug -- bc:24:11 is genuinely Proxmox's OUI -- and an "Uptime: Dec 31 1969" display, which is Unix epoch zero rendered as a fake date for a null "last seen" value). **Functionally confirmed working, not just present as DB rows:** added a Fixed reservation for the admin laptop's actual MAC and forced a DHCP release/renew -- it received exactly the reserved IP. So: the bulk importer's HTTP response handling is broken, but the underlying write is not -- this is a UI/response-layer bug, not a data-integrity or reservation-functionality bug.

Root cause

#1 (unconfirmed, resolved by workaround, not diagnosed): most likely a thermal or transient firmware state issue on a brand-new unit -- the fix that actually worked was a genuine cold power-down (unplugged, physically removed from the rack/enclosure, left off ~10 minutes), not the quicker power-cycles or reset-button holds tried first. No hard evidence of a hardware defect was found once this fixed it (a full RMA/support-ticket path was being prepared before this workaround was found -- see the session transcript if resuming that thread). #2 (unconfirmed): search turned up general community reports of DHCP reservation instability on recent UniFi Network versions (9.3.x), and no exact match for this specific JSON.parse/500 combination. A third-party Python tool (adamgranted/Unifi_IP-Reservation-Import on GitHub) exists specifically to apply reservations via the UniFi API directly rather than the built-in CSV importer -- its existence is a mild signal the native importer has a history of being unreliable, not confirmation of a specific bug. Likely a bug in a relatively new feature (CSV import was only added in Network 9.3), not anything wrong with our CSV's format or content.

Fix

#1: Physically unplug the UCG-Fiber, remove it from the rack/enclosure entirely, and leave it powered off for a genuine 10 minutes (not just a quick unplug/replug) before trying again. This is a materially different action than a quick power-cycle or a reset-button hold and was the only thing that actually broke the loop. #2: No fix needed -- the import already worked. If you hit this "import failed" toast again: don't retry or abandon it for manual entry. Log out and back in (or just refresh) first, then check the reservations list for the rows you just tried to import before concluding anything failed. If they're not there, *then* fall back to manual entry via "Add Fixed IP".

Prevention / notes for next time

1. If a UniFi setup UI hangs after a network-changing Apply and doesn't recover within a few minutes even after DHCP release/renew and a plain reboot -- don't keep cycling quick reboots/resets. Go straight to a full cold power-down (physically unplugged, several minutes, ideally out of any enclosure) before escalating further. 2. Recovery mode's displayed IP (shown on the LCD) is not a reliable guarantee that anything is actually listening/answering there -- verify with ping/arp -a before assuming it's reachable, and don't assume a generic on-device reset-button hold reliably enters recovery mode post-boot (that produced a factory reset instead, here). 3. **Before assuming a rejected bulk import is a CSV content/format problem, check the browser's Network tab for the actual HTTP status code first.** A non-200 (here, 500) means the backend failed -- different problem, different fix -- than a client-side validation rejection. But also don't assume a 500 means nothing happened -- **check whether the data actually landed (try re-adding one entry manually and see if it's rejected as a duplicate, or just refresh/ re-login and look) before spending real time on a manual-entry fallback that may not be necessary at all.** That's exactly what cost real time here: several messages of building split/consolidated CSV files and starting manual entry before discovering the original import had already worked. 4. A device/vendor showing as "Proxmox" for a bc:24:11-prefixed MAC is correct (that's Proxmox's real OUI), and a "Dec 31 1969" timestamp on an unconnected reservation is Unix epoch zero, not corrupted data -- don't chase either as a bug. 5. Session 1 (the gateway swap itself) was still in progress and paused when this was written -- LAN/DHCP/DNS/gateway-IP config done and saved, all 30 reservations imported successfully (see above), WAN still intentionally disconnected (Phase 2 not started). See docs/ucg-fiber-session1-cutover-checklist.md and docs/ucg-fiber-reservations-all-30.csv when resuming.
UCG-Fiber SSH login is `root`, not a per-user account -- password rejected for a plausible username like `mos` medium
ucg-fiber gateway ssh unifi   last seen:

Symptom

ssh mos@192.168.42.1 to the UCG-Fiber (post-cutover, SSH already enabled per docs/ucg-fiber-session1-cutover-checklist.md Phase 1, with developer-env's public key added plus a separate user/password) no longer timed out (network path was fine, SSH was genuinely listening) but rejected the password every time. Looked like a wrong password at first.

Root cause

UniFi OS consoles (UDM-Pro/UDR/UXG-Pro/UCG-Fiber family) have no Linux-style useradd account creation over SSH -- there is no mos account to log into. SSH auth is against the console's root account only. mos was a reasonable guess (matches the developer-env shell username, and the checklist step just says "add developer-env's public key" without naming an account) but was never going to exist on the console itself. Confirmed via ssh -v -o BatchMode=yes mos@192.168.42.1: server offered publickey,keyboard-interactive, rejected the key for mos outright (never got to a password prompt in batch mode), then in normal interactive mode keyboard-interactive is what actually prompts for the password -- functionally the same as a password auth failure, but confirms the rejection is at the *account* level, not a wrong-password problem.

Fix

ssh root@192.168.42.1 -- works with the password set during Phase 1 setup. developer-env's added public key was never actually validated against root in this incident (password login solved it first). Follow-up same day: confirmed the Phase 1 console-UI key add never actually landed in root's authorized_keys (batch-mode key-only test against root failed with Permission denied before this). Fixed with ssh-copy-id root@192.168.42.1 from developer-env (one interactive password entry to install the key) -- verified after with the same batch-mode no-password test, now succeeds. Key auth from developer-env to the UCG-Fiber is fully live; password login still works too (untouched, per the deliberate deferral on key-only tightening -- see INFRASTRUCTURE.md's gateway entry).

Prevention / notes for next time

1. UniFi OS consoles always use root for SSH -- don't guess a per-user account name for any device in this family (same applies to any future UDM/UXG-Pro-class hardware). 2. "Not timing out anymore, but credentials rejected" is a different failure class than "still can't reach it" -- the former means the network/firewall/SSH-daemon side is fine and the problem is purely auth (wrong account or wrong secret), which ssh -v (or -o BatchMode=yes to fail fast without hanging on a password prompt) will show you directly: which auth methods the server offers, and whether the key/account combo was even considered before falling back to password. 3. Root login over password on this specific box (fleet's highest blast-radius node -- see INFRASTRUCTURE.md's gateway entry) is flagged for a key-only tightening pass later, deliberately deferred until Session 1 has settled -- not a gap to "fix" reactively.
Adding ucg-fiber's standalone hosts-config entry silently swept it into os-update-checker's fleet-wide OS-update detection/notification pipeline medium
ucg-fiber gateway os-update-checker update-advisor unifi   last seen:

Symptom

Same night ucg-fiber (the UCG-Fiber gateway) got its first standalone: entry in inventory/hosts-config.yaml (SSH-reachability-only, deliberately no version: block -- see INFRASTRUCTURE.md's gateway entry), the very next 6-hourly collect-homelab cron run picked it up in os-update-checker, found 31 pending OS packages, called Claude for an assessment, got back a SECURITY verdict (a CVE-flagged package among the 31), and fired a per-host ntfy notification about it -- none of which was intended. The version: omission was written specifically to keep this device out of the fleet's automated update pipeline.

Root cause

The omitted version: block only controls *execution* eligibility. bin/update-advisor checks hosts-config.yaml's per-host update_cmd and skips with "no update_cmd in hosts-config" if it's missing -- so nothing was ever going to actually run apt-get dist-upgrade here. But bin/os-update-checker (the detection/notes/Claude-assessment stage that runs *before* update-advisor even looks at execution) scopes itself to "every lxc_containers/standalone/proxmox_nodes entry in hosts-config.yaml, except a short hardcoded SKIP_NAMES set" -- independent of whether the host has a version: block at all. Adding any new standalone SSH-reachable host opts it into detection, Claude assessment, and notification by default; only an explicit SKIP_NAMES entry keeps a host out of that layer entirely. Compounding factor: the assumption behind the version: omission's comment ("no apt/GitHub-release update path on a UniFi OS console") was itself wrong. Confirmed live 2026-08-21: the UCG-Fiber's underlying userspace is genuine Debian 11 (bullseye) with real apt and real pending packages (31 of them). The "don't automate against this device" decision was still the right call (vendor-managed embedded appliance, highest blast-radius node in the fleet), but the reasoning needs updating -- it's not that apt is absent, it's that running it via this pipeline is the wrong trade regardless of package content.

Fix

Added "ucg-fiber" to bin/os-update-checker's SKIP_NAMES set, matching the existing pattern for pibox/homeassistant/nas/webmin/headplane/ developer-env/alpine-it-tools (each excluded for its own documented reason). Corrected the hosts-config.yaml comment on the omitted version: block to state the real reason (deliberate exclusion despite apt being present, not apt being absent).

Prevention / notes for next time

1. **A version: block and a SKIP_NAMES-style detection exclusion are two separate gates, not one.** Omitting version: blocks *execution* (update-advisor); it does nothing to stop *detection and notification* (os-update-checker) for any host generically SSH-reachable via hosts-config.yaml. When a host should be fully out of the update pipeline -- not just non-auto-executing -- it needs both: no version: block AND an explicit exclusion in whichever detection script scopes itself broadly (os-update-checker's SKIP_NAMES here; check pve-update-checker too if the host is proxmox-node-shaped). 2. **Don't assume a vendor appliance lacks a standard package manager just because its config surface is proprietary/opaque.** The UCG-Fiber's UI and config DB are closed UniFi, but the OS underneath is stock Debian -- SSH in and check /etc/os-release plus apt list --upgradable directly before writing a "no apt path here" comment into the repo, the same way known-fixes/ucg-fiber-ssh-login-is-root-not-peruser.md's SSH-user assumption also needed a live check rather than a plausible guess. 3. This surfaced within hours of onboarding the host precisely because collect-homelab runs every 6h automatically in the background -- worth remembering that any hosts-config.yaml change touching a standalone/lxc/proxmox_nodes entry can trigger fleet-wide automation on its very next scheduled run, not just whatever you explicitly wired in the same session. Check collect.log / a live `pgrep -af collect-homelab` if a change like that lands mid-session.
qBittorrent showed NAT'ed on the private tracker post-cutover -- UCG-Fiber's UPnP daemon was silently disabled, and qBittorrent's own "connected" status didn't catch it medium
ucg-fiber qbittorrent upnp port-forward gateway   last seen:

Symptom

MOS's private tracker (which actively verifies inbound connectivity, not just outbound peer traffic) started showing the qBittorrent client as NAT'ed after the UCG-Fiber cutover, instead of the "verified" status it consistently showed on the old BE800 router. Torrent port: 63706. This directly contradicted an earlier same-week finding (docs/qbittorrent-seeder-daemon-nastynas-migration-plan.md, resolved note dated 2026-08-21): qBittorrent's own /api/v2/transfer/info reported connection_status: "connected" with the correct post-cutover WAN IP in last_external_address_v4, and upnp: true in its preferences -- read at the time as "UPnP re-punched the mapping automatically, nothing to do." That read was wrong.

Root cause

connection_status: "connected" in qBittorrent is based on outbound DHT/tracker/peer activity -- it stays "connected" whether or not any inbound port is actually reachable from the internet. It is not a real reachability test, and nothing else in that 2026-08-21 check attempted one (no external connect test was run at the time). The private tracker's own active inbound probe is what actually caught the problem. Live-checked via SSH into the UCG-Fiber (root@192.168.42.1): - systemctl status miniupnpd -- inactive (dead), disabled. UPnP was never actually turned on for this gateway; qBittorrent's upnp: true client-side setting had nothing to talk to. - /etc/miniupnpd/miniupnpd.conf's allow rules were still the factory defaults (192.168.0.0/24, 192.168.1.0/24, 192.168.0.0/23) -- none of which match this LAN's actual 192.168.42.0/24. Even a manually started daemon would have rejected qBittorrent's mapping request via the config's final deny 0-65535 0.0.0.0/0 0-65535 catch-all. This is stock/template config carried by the device image, never touched for this network -- not something that broke during cutover, just never worked on this gateway at all. The BE800 previously handled this via its own working UPnP implementation, so the gap was invisible until the tracker's probe caught it.

Fix

Static port forward added via the Network app UI (Settings -> Internet -> Port Forwarding, the same screen the Headscale STUN/WireGuard forwards live on) instead of debugging/re-enabling miniupnpd: TCP+UDP 63706 -> 192.168.42.47:63706. Verified two ways, not just "forward exists": 1. iptables -t nat -L -n -v on the gateway shows the DNAT rule (`match-set UBIOS_KEY_ADDRv4_eth4 dst tcp/udp dpt:63706 ... to: 192.168.42.47:63706`) -- note this device's actual NAT engine is legacy iptables, not nftables; nft list ruleset shows nothing useful here despite nft being present, which cost some time before switching to iptables -t nat -L. 2. Live external TCP connect test from wildwood (genuine offsite vantage point, different ISP) to 135.180.72.8:63706 -- succeeded. This is the check that actually matters; the DNAT rule alone doesn't prove reachability (a gateway-side firewall/IDS could still block it).

Prevention / notes for next time

1. **A client's own "connected"/"not firewalled" self-report is not a reachability test** -- it's typically based on outbound activity (DHT bootstrap, tracker announces, active peer connections), all of which work fine with zero inbound port forward. Only an actual external connect-back (a tracker's own probe, a third-party port checker, or a deliberate test from a real offsite host like wildwood here) confirms inbound reachability. Don't close out a "is this port forwarded" question on client-side status alone -- this is the second time this exact pattern showed up in this device's cutover (see known-fixes/ucg-fiber-cutover-missing-443-forward.md for the first, a completely missed forward rather than a misleading status). 2. **This gateway's live NAT/port-forward state lives in iptables -t nat, not nft**, despite nft being present on the box -- check the right tool first next time a UCG-Fiber networking question needs a live rule dump. 3. **UPnP was never actually functional on this gateway, is now bypassed entirely via a static forward.** If UPnP-dependent behavior is ever needed again (a different device/app expecting to self-manage its own forward), don't assume it works just because a client reports upnp: true -- check systemctl status miniupnpd on the gateway directly first. 4. This is UPnP-vs-static-forward, unrelated to the DHCP CSV import quirk in known-fixes/ucg-fiber-first-boot-instability-and-dhcp-import-bug.md -- different subsystem, mentioned only because both are "UI said one thing, reality was another" UCG-Fiber gotchas from the same migration.
Three separate traps building a single Trusted->Servers admin-device firewall rule: wrong zone pair by default, an ANY-vs-IP scoping slip, and a stale/misleading device label in UniFi's own client DB medium
A newly-created zone-based firewall rule has 0 packets/0 bytes on every counter no matter how much matching traffic is generated -- it's silently landed in the wrong zone-pair chain (e.g. LAN->LAN / same-zone, when the intent was Trusted->Servers) • A rule intended to scope access to specific devices (by IP) instead grants the entire source zone unrestricted access -- matching_target ended up ANY instead of IP • A device's hostname/label in the UniFi client list (ace.user collection) doesn't match who actually owns it -- confirmed via the device's own network settings screen, not by trusting the controller's label
network firewall vlan ucg-fiber unifi zone-based-firewall trusted servers mongo ipset   last seen: 2026-08-24

Symptoms

  • A newly-created zone-based firewall rule has 0 packets/0 bytes on every counter no matter how much matching traffic is generated -- it's silently landed in the wrong zone-pair chain (e.g. LAN->LAN / same-zone, when the intent was Trusted->Servers)
  • A rule intended to scope access to specific devices (by IP) instead grants the entire source zone unrestricted access -- matching_target ended up ANY instead of IP
  • A device's hostname/label in the UniFi client list (ace.user collection) doesn't match who actually owns it -- confirmed via the device's own network settings screen, not by trusting the controller's label
See also: trusted-vlan-return-traffic-toggle-missing, trusted-vlan-gateway-ui-firewall-gap, raspi4-followed-wifi-ssid-onto-trusted-vlan

Context

Building one rule -- "give MOS's Mac Laptop and iPhone full access from Trusted into Servers, since they're the homelab admin" -- took three attempts, each failing a different way. None of the failures looked like errors in the UI; each one *looked* saved and correct until checked directly on the gateway.

Trap 1: a fresh rule defaults into the wrong zone pair, not just a "toggle you forgot"

Symptom: the rule appeared in the UI as saved, with the right source IPs. But iptables -L UBIOS_LAN_LAN_USER -n -v (Servers->Servers, i.e. same-zone/"LAN Local") showed the rule's ACCEPT lines with 0 packets/0 bytes, and the source IPs (on the Trusted subnet, 192.168.12.0/24) would never physically arrive on a Servers-zone interface in the first place -- the rule could never match anything. Cause: the source-zone picker in the rule-creation UI needs to be explicitly changed to the intended source zone (Trusted). Left alone, it defaults toward the zone the rule was created "from" in the UI flow (here, Servers), producing a same-zone rule that's syntactically valid and looks complete, but is attached to the wrong zone-pair chain entirely and therefore inert for the traffic it was meant to govern. Diagnosis: don't trust the UI's list view. Confirm which chain a rule actually lives in and whether it's being hit:
ssh ucg-fiber "mongo --port 27117 --quiet ace --eval 'db.firewall_policy.find({name:\"<rule name>\"}).forEach(printjson)'"
Check source.zone_id and destination.zone_id against db.firewall_zone.find() to confirm which zones they actually resolve to, then verify hit counts on the live chain:
ssh ucg-fiber "iptables -L <chain> -n -v | grep <rule's ipset name>"
A rule sitting at 0/0 after real matching traffic should have occurred is the tell that it's in the wrong chain, not that traffic simply hasn't happened yet.

Trap 2: fixing the zone pair can silently switch scoping from IP to ANY

Symptom: after moving the rule to the correct zone pair (Trusted->Servers), the live ipset (UBIOS_policy_src_ip_N) contained no devices at all -- because the rule's source.matching_target had become "ANY" (whole zone, no IP set) instead of "IP" (a specific list). The rule was live, in the right chain, and completely unscoped -- every device on Trusted (including devices with a very different risk profile than a personal admin laptop, e.g. a security-testing box) got full unrestricted access to every host in Servers. Cause: "Source: Any" and "Source: specific IP(s)" are a genuinely separate control from which zone is selected, and easy to leave on the wrong setting while focused on fixing the zone. Re-editing a rule to fix one field doesn't guarantee the other fields survive unchanged. Diagnosis: check source.matching_target (and destination. the same way) in the firewall_policy document directly -- "ANY" means zone-wide, "IP" means scoped to the source.ips list. Don't infer scope from the rule's name or from having entered IPs at some earlier point in the edit -- confirm the live document. Fix: re-edit the rule, explicitly set the source (or destination) matching type back to "IP", and re-enter the intended address list.

Trap 3: UniFi's own client label can be stale or simply wrong

Symptom: the intended second device (an iPhone) had its correct current IP (192.168.12.240), but the UniFi client list showed that address under a completely different, plausible-looking name ("MattStensiPhone") -- which reads as someone else's device, not the homelab admin's own phone. Confirmed via the device's own Settings -> Wi-Fi -> (i) screen that 192.168.12.240 genuinely was the right phone; the label was just stale/mislabeled in the controller. Cause: unknown/not investigated -- possibly an old device rename that didn't take, a label inherited from a previous device history, or simply a wrong label entered at some point. UniFi doesn't re-derive hostnames from anything authoritative; whatever was typed (or DHCP's client-supplied hostname) sticks until manually changed. Lesson: **when scoping any rule to a specific device by IP, confirm the address against the device's own display, not just the controller's label for it.** A confident-looking, specific-sounding hostname in the client list is not proof of ownership. Cheap prevention once identified: rename the client's alias in the UniFi UI to something unambiguous (e.g. MOS_iPhone) so this can't recur for the same device.

Fix, fully verified (2026-08-24)

Final live rule (Admin devices → Servers, full access, index 10007):
source.matching_target: IP, ips: [192.168.12.42, 192.168.12.240]
source.zone_id: Trusted
destination.matching_target: ANY
destination.zone_id: Servers
protocol: all, port: any
create_allow_respond: true
Confirmed live in both chains via iptables -S: - Forward (UBIOS_CUSTOM1_LAN_USER, Trusted→Servers): fresh-connection ACCEPT scoped to exactly the two source IPs. - Return (UBIOS_LAN_CUSTOM1_USER, Servers→Trusted): the RELATED,ESTABLISHED mirror is present.

Prevention

- After creating or editing any zone-based firewall rule on this gateway, verify the live firewall_policy document directly (source zone, destination zone, matching_target on both sides, protocol/port) rather than trusting the UI's list view or save confirmation -- this is now the third distinct way a rule has looked right in the UI while being wrong underneath (the other two: missing return-traffic toggle, documented in known-fixes/trusted-vlan-return-traffic-toggle-missing.md; a rule missing entirely, in known-fixes/trusted-vlan-gateway-ui-firewall-gap.md). - Before scoping any rule to a device by IP, confirm that IP against the device's own network settings screen, not just the controller's label for it. - mongo --port 27117 against ace.firewall_policy (rules), ace.firewall_zone (zone-id lookup), and ace.user (client identity/ last-seen) is the direct verification path used throughout this incident and the raspi4 one before it -- faster and more trustworthy than the UI for confirming what's actually live.
update-advisor's classify_update_cmd never learned run-update's curl+tar/dpkg binary case — qui silently hard-blocked medium
update-advisor --force --execute gives qui a SAFE verdict but skips it with 'script-type updates always require manual approval', even with auto_update not set to false and the release well past the 7-day age gate • run-update qui (called directly) classifies and dispatches fine — only update-advisor's own gate is wrong
update-advisor run-update classify_update_cmd qui headscale dispatch-drift   last seen: 2026-07-14

Symptoms

  • update-advisor --force --execute gives qui a SAFE verdict but skips it with 'script-type updates always require manual approval', even with auto_update not set to false and the release well past the 7-day age gate
  • run-update qui (called directly) classifies and dispatches fine — only update-advisor's own gate is wrong

Cause

update-advisor's classify_update_cmd() is a separate copy of run-update's dispatch_update() classifier — same shape of function, no shared import, living in two different scripts. run-update has always had a case for the "download-and-install binary" update_cmd shape (curl a GitHub release, then tar/dpkg install it — this is how qui and headscale update). update-advisor's copy never got the equivalent branch. Any update_cmd matching that shape — no `docker compose, no apt-get, no literal bash, no --update` — fell through to the default "script" bucket, which is hard-blocked from auto-execution regardless of verdict or flags. Same root-cause class as the 2026-07-08 docker_compose gap documented in run-update-docker-compose-dispatch-gap.md: a branch added to one classifier doesn't propagate to the other, and nothing catches the drift until a real service with that exact update_cmd shape gets a SAFE verdict and silently never executes.

Fix

Added the same curl+(tar|dpkg) branch to update-advisor's classify_update_cmd(), positioned before the bash+curl/wget "script" check so a genuine script-installer command containing both curl and tar doesn't get miscategorized as a safe binary install:
if "curl" in cmd and ("tar" in cmd or "dpkg" in cmd):
    return "binary"

Verify

runtee python3 bin/update-advisor --force --execute
Before fix: qui: SAFE | skipped: script-type updates always require manual approval After fix: qui: SAFE | skipped: release is only 1 day(s) old (minimum 7 days before auto-execute) The change in *which* reason it's skipped for confirms the classifier now reaches the age-gate check instead of the hard "script" block — qui's release was fresh (1 day old) at the time of this test, so the age gate correctly held it back rather than auto-executing; it will auto-execute on its own once the release clears 7 days, assuming nothing else changes. A real, non-forced end-to-end execution was confirmed separately via bin/run-update qui directly (bypasses update-advisor's age gate, which run-update itself doesn't have) — real version bump `v1.22.0 → v1.23.0`, all verify steps passed, post-deploy snapshot fired (collected/snapshots/*-post-update-qui.json).

Follow-up

Keep watching for this class of drift: every branch added to run-update's dispatch_update() needs the same branch mirrored into update-advisor's classify_update_cmd(). Worth considering a shared module for both scripts to import from instead of maintaining two copies, if this happens a third time.
update-advisor/run-update reports a SECURITY patch as OK even when the upstream apt repo hasn't published the target version yet medium
update-advisor run-update grafana apt security false-positive   last seen:

Symptom

Running update-advisor --force --execute --force-execute to apply a CVE patch to grafana (v13.1.1 → v13.1.2, CVE-2026-13438) logged a clean success end to end:
grafana: update_cmd ✓
grafana: post [Restart grafana and wait for HTTP to come up] ✓
grafana: verify [Grafana HTTP responding] ✓
grafana: ✓ update complete (v13.1.1 → v13.1.1)
grafana: execution OK [age gate bypassed]
grafana: SECURITY | executed: True
Note the version didn't actually change (v13.1.1 → v13.1.1) — that's visible in the log line itself if you read it closely, but the overall result: OK / executed: True / green-checkmark framing reads as success at a glance, and the CVE was still live. Confirmed via direct SSH: dpkg -l | grep grafana showed 13.1.1 installed, and apt-cache policy grafana showed Candidate: 13.1.1 — same as Installed — even after a fresh sudo apt-get update. Grafana's own apt.grafana.com repo simply hadn't published the 13.1.2 package yet, despite the GitHub release (which changelog-fetcher reads) already being live. ntfy and qui, updated in the same pass, both changed version correctly and are not affected by this issue — this is specific to a case where the *target* apt repo lags its own GitHub releases.

Root cause

run-update's verify step for grafana only checks that the HTTP endpoint responds after restart — it never re-checks the installed version against the intended target. apt-get install grafana (or equivalent) exits 0 whether or not a newer candidate was actually available; if the candidate equals the installed version (upstream repo not yet updated), the command is a legitimate no-op that looks identical, from run-update's perspective, to a real successful upgrade. Nothing in our tooling was misconfigured — this is upstream release-to-repo propagation lag that our verify step has no way to detect as currently written.

Fix applied this incident

None needed on our side to *unblock* — grafana is simply not upgradable yet. No action was taken beyond identifying the gap; retry update-advisor --force --execute --force-execute (or just the grafana slice of it) once apt-cache policy grafana on the LXC shows Candidate: 13.1.2 or higher.

Prevention — for any future CVE/SECURITY-verdict auto-execute

1. **After any SECURITY-verdict --force-execute run, confirm the version actually changed** — don't trust result: OK / executed: True alone. Check collected/update-notes/-summary.txt's EXECUTION: block for version after: matching the target, or SSH in and check directly (dpkg -l, or the service's own version command). 2. This is a real gap in run-update's verify logic for apt-installed services: a version-after-vs-target check would catch this class of false-positive automatically instead of relying on a human noticing the log line. Worth adding to run-update's verify step for Group 2/3 apt-managed services — not yet implemented as of this writing. 3. --force-execute bypasses the 7-day age gate for every service assessed in that pass, not just the one(s) with a SECURITY verdict — confirmed 2026-08-06 when a routine `update-advisor --force --execute --force-execute run also auto-executed qui` (SAFE verdict, 1-day-old release) as a side effect of patching grafana+ntfy. If you only want the SECURITY-verdict services patched, review the digest and be prepared for other pending SAFE updates to go along for the ride in the same invocation.

Recurrence (2026-08-10) — and the generalized fix it prompted

Same class, same service, different version: collect-homelab's 2026-08-10 run flagged grafana v13.1.1 → v13.1.3 as a 🔴 SECURITY PATCH REQUIRED (CVE-2026-13438, mentioned in the v13.1.2 release notes in the fetched range). Checked directly via SSH before attempting anything: apt-get update && apt-cache policy grafana on the LXC still showed Candidate: 13.1.1 — four days after the GitHub v13.1.3 tag went live, apt.grafana.com hadn't published even v13.1.2 yet. Same upstream propagation lag as 2026-08-06, just a version number later — this is evidently a recurring characteristic of grafana's release process, not a one-off. Built same day, generalized beyond grafana: bin/version-checker now takes an optional apt_package: field per service (see inventory/hosts-config.yaml's VERSION TRACKING FIELDS comment block). When set, a behind verdict against GitHub's tag is cross-checked against the host's *actual* apt-cache policy Candidate (via a fresh apt-get update, not a stale local cache) — the same version run-update's own update_cmd would really install. If the candidate hasn't moved past what's installed, the verdict downgrades from behind to a new pending status: still visible in version-report.txt (its own section, plus the all-services table), but deliberately excluded from changelog-fetcher's and update-advisor's behind-keyed filtering, so it can no longer generate a SECURITY/CAUTION escalation, a runbook, or an auto-execute attempt for an update that would just no-op. changelog-fetcher's existing stale-notes cleanup (matched on "no longer behind") handles the reverse transition automatically — confirmed live: re-running it after grafana flipped to pending cleaned up its now-stale update-notes/grafana.* files without any special-casing needed. Set on caddy and grafana — the only two services in hosts-config.yaml whose update_cmd is a plain `apt-get upgrade -y ` against a vendor-hosted (not Debian/Ubuntu official) apt repo. Everything else with a github_repo either installs straight from a GitHub release binary (headscale, qui — the artifact GitHub reports *is* the thing that gets installed, no separate repo to lag) or pulls a Docker image (ntfy, uptime-kuma, crowdsec — a distinct potential registry-lag risk, but a different mechanism this fix doesn't cover). AdGuard's built-in updater is also out of scope — it manages its own update channel independently of both GitHub tags and apt. Verified live 2026-08-10 end-to-end on the real grafana/caddy hosts (not just unit-level): version-checker correctly downgraded grafana to pending (apt candidate confirmed unchanged at v13.1.1), changelog-fetcher correctly found zero behind entries and cleaned up grafana's stale notes, and update-advisor correctly reported "nothing pending" instead of re-emitting the SECURITY digest entry. caddy (currently ok, installed == GitHub latest == apt candidate) was unaffected, confirming the extra check only engages when a behind verdict is already in play. This addresses the recurrence from the *version-checker* end (stops the false alarm before it's ever raised) rather than the *run-update-verify* end (item 2 in the original Prevention section below, "a version-after-vs-target check in run-update's verify step"). That original idea is still valid as a defense-in-depth backstop for any future case that reaches execution anyway (e.g. a service without apt_package set, or a genuinely-available apt update that fails mid-dpkg) — not built as part of this fix, since the earlier check now prevents the specific scenario that motivated it from reaching that point at all.

Related

See bin/update-advisor's own module docstring for the correct one-pass invocation (--force --execute --force-execute together, not --force then a separate --execute --force-execute — the second call sees a matching hash and skips before reaching the execution layer at all; that's a documented, distinct gotcha from this one).
apt update fails: dpkg-preconfigure unable to re-open stdin (exit 100) medium
ntfy alert: Update FAILED: <service> • update_cmd exited 100 • dpkg-preconfigure: unable to re-open stdin: No such file or directory
update-apt apt dpkg debconf ssh stdin run-update update-advisor   last seen: 2026-07-28

Symptoms

  • ntfy alert: Update FAILED: <service>
  • update_cmd exited 100
  • dpkg-preconfigure: unable to re-open stdin: No such file or directory
  • E: Sub-process /usr/bin/dpkg returned an error code (1)

Symptom

An apt-based service update (dispatched by run-updatebin/update-apt) fails, and the ntfy alert body is:
update_cmd failed: update_cmd exited 100: dpkg-preconfigure: unable to
re-open stdin: No such file or directory E: Sub-process /usr/bin/dpkg
returned an error code (1)
First seen when grafana's Group 3 auto-update ran on 2026-07-28.

Cause

bin/update-apt runs the update command over SSH with stdin=subprocess.DEVNULL and no TTY, and did not set DEBIAN_FRONTEND. apt's pre-install hook runs dpkg-preconfigure to pre-answer debconf questions before unpacking. With the default (dialog/readline) debconf frontend and a closed stdin, dpkg-preconfigure tries to reopen stdin to prompt, can't, and aborts — apt exits 100 **before any package is unpacked**, so nothing actually installs. This triggers only when the upgrade pulls in a package (the service or one of its dependencies) whose maintainer scripts register a debconf template. That's why it surfaced on a specific grafana release rather than every run. Headscale's update_cmd already guarded against this with DEBIAN_FRONTEND=noninteractive; grafana and caddy (the two apt-repo services) did not — same latent bug, grafana just hit it first.

Fix

Set DEBIAN_FRONTEND=noninteractive centrally in bin/update-apt so every apt dispatch is covered (grafana, caddy, any future apt service), rather than relying on each update_cmd author to remember. Commit 236d0234, 2026-07-28:
noninteractive_cmd = f"export DEBIAN_FRONTEND=noninteractive; {update_cmd}"
rc_up, out_up, err_up = ssh(ssh_ip, noninteractive_cmd, ssh_user=ssh_user, timeout=300)
Critical detail: use export ...;, NOT a bare VAR=val cmd prefix. A bare prefix (DEBIAN_FRONTEND=noninteractive apt-get update && apt-get upgrade ...) only sets the var for the first command of a compound A && B. The apt-get upgrade half — where preconfigure actually runs — would stay exposed, and the bug would look fixed while still firing. DEBIAN_FRONTEND=noninteractive also gives non-interactive conffile handling (keeps the installed config on conflict), so no customised config is silently overwritten.

Immediate recovery (on the affected LXC, as root)

Because apt aborts at preconfigure before unpacking, usually nothing is half-installed, but dpkg --configure -a is a safe no-op if it is:
export DEBIAN_FRONTEND=noninteractive
dpkg --configure -a
apt-get update && apt-get upgrade -y <service>
systemctl restart <service>   # e.g. grafana-server
Or, once the update-apt fix is deployed to developer-env (git pull), just re-run run-update and let the pipeline restart/verify/snapshot.

Related

- known-fixes/collect-homelab-ssh-consumes-stdin.md — same class (SSH + stdin), different script. - headscale update_cmd in hosts-config.yaml — the pre-existing example of the DEBIAN_FRONTEND=noninteractive guard done right at the per-command level.
Deleted Uptime Kuma monitor still appears in /metrics — stale cache medium
deleted monitor still in /metrics with last known status • watchdog keeps alerting on deleted monitor • monitor gone from get_monitors() but present in /metrics
uptime-kuma metrics cache watchdog   last seen: 2026-06-18

Symptoms

  • deleted monitor still in /metrics with last known status
  • watchdog keeps alerting on deleted monitor
  • monitor gone from get_monitors() but present in /metrics

Cause

Uptime Kuma's Prometheus /metrics endpoint caches its response and does not always flush when a monitor is deleted. The in-memory cache can persist indefinitely until the process is restarted.

Fix

ssh watchdog@192.168.42.229 "sudo docker restart uptime-kuma"
Wait ~30 seconds, then verify:
ssh watchdog@192.168.42.229 'bash -s' << 'EOF'
KEY=$(grep UPTIME_KUMA_API_KEY ~/homelab/.env | cut -d= -f2)
curl -s -u ":$KEY" http://192.168.42.229:3001/metrics | grep -i <monitor-name>
EOF

Note

Uptime Kuma runs as a Docker container (sudo docker restart uptime-kuma), not a systemd service. systemctl restart uptime-kuma will fail with "Unit not found".
Two Uptime Kuma monitors with identical names — one hidden in UI medium
UI shows one monitor but /metrics returns two entries with same name • one monitor UP, one DOWN, same name • duplicate monitor_name in /metrics with different URLs
uptime-kuma duplicate metrics   last seen: 2026-06-18

Symptoms

  • UI shows one monitor but /metrics returns two entries with same name
  • one monitor UP, one DOWN, same name
  • duplicate monitor_name in /metrics with different URLs
See also: uptime-kuma-deleted-monitor-stale-metrics

Cause

setup-uptime-kuma.py uses skip_existing=True keyed on monitor name. If a monitor with the wrong URL was created first, re-running skips it rather than updating the URL.

Diagnosis

ssh watchdog@192.168.42.229 'bash -s' << 'EOF'
KEY=$(grep UPTIME_KUMA_API_KEY ~/homelab/.env | cut -d= -f2)
curl -s -u ":$KEY" http://192.168.42.229:3001/metrics | grep monitor_status
EOF

Look for duplicate monitor_name values with different monitor_url values

Fix

cd ~/projects/homelab
python3 - << 'PYEOF'
import getpass
from uptime_kuma_api import UptimeKumaApi

password = getpass.getpass("Uptime Kuma password: ")
with UptimeKumaApi("http://192.168.42.229:3001") as api:
    api.login("mos", password)
    monitors = api.get_monitors()
    for m in monitors:
        print(f"ID={m['id']} name={m['name']!r} url={m.get('url','?')!r}")
    mid = int(input("Enter ID to delete: "))
    print(api.delete_monitor(mid))
PYEOF
Then restart the Docker container to flush the metrics cache.

Note

Use login(username, password) not login_by_token(api_key) — the /metrics Basic auth API key is not the same credential as the websocket login token.
Uptime Kuma v1 → v2 upgrade — manual migration procedure (breaking, one-time DB migration) medium
version-checker shows uptime-kuma perpetually 'behind' against GitHub's /releases/latest, with update_cmd unable to close the gap • compose.yaml image tag floating at louislam/uptime-kuma:1 only ever pulls new v1.x point releases, never v2
uptime-kuma docker-compose migration watchdog upgrade breaking-change   last seen: 2026-07-08

Symptoms

  • version-checker shows uptime-kuma perpetually 'behind' against GitHub's /releases/latest, with update_cmd unable to close the gap
  • compose.yaml image tag floating at louislam/uptime-kuma:1 only ever pulls new v1.x point releases, never v2
See also: run-update-docker-compose-dispatch-gap

Cause

compose.yaml pinned image: louislam/uptime-kuma:1 (major-version-only floating tag). docker compose pull only ever re-pulls within that major version, so once upstream cut v2, the running instance silently stopped receiving anything past v1.x — version-checker correctly flagged this as "behind" against GitHub's true latest release every cycle, but nothing in the update pipeline could act on it (major-version bumps are intentionally breaking and gated behind human review, not something update_cmd should ever auto-bridge). The gap went unnoticed for a while because the "behind" line in the digest wasn't being checked regularly. v2 is a genuinely breaking release: it drops the JSON backup/restore feature (volume-level backup becomes the *only* supported method going forward), and — critically for how this homelab automates updates — the v1→v2 upgrade triggers a one-time SQLite data migration on first boot that can take anywhere from minutes to (on slow hardware, with a lot of history) much longer, and must not be interrupted.

Why this can't go through `run-update`/`update-advisor` automation

run-update's post_update step for uptime-kuma polls `docker inspect --format '{{.State.Health.Status}}'` up to 30×2s = 60 seconds. A one-time migration on a Raspberry Pi 5 can easily exceed that window, which would report a false failure while the migration is actually still running safely in the background. This has to be a manual, watched process — not something to script into the regular lifecycle.

Fix — migration procedure

1. Backup the data volume off the Pi5's SD card, not just locally:
   ssh watchdog
   docker run --rm --volume uptime-kuma-data:/app/data --volume $(pwd):/backup \
     busybox tar czf /backup/uptime-kuma-backup-v1-$(date +%F).tar.gz -C /app/data .
   # then copy the tarball off-box (developer-env or nastynas)
   
2. Edit compose.yaml (both the live copy at /home/watchdog/watchdog/compose.yaml and the tracked copy at watchdog/compose.yaml in the repo): change image: louislam/uptime-kuma:1image: louislam/uptime-kuma:2. 3. Stop, then start, and watch it — don't detach:
   docker compose stop uptime-kuma
   docker compose up -d uptime-kuma
   docker compose logs -f uptime-kuma
   
Do not Ctrl+C, restart the container, or let cron-triggered collect-homelab/the watchdog daemon's own polling touch it until the logs show the migration finished and the UI is reachable. If it does get interrupted, the documented recovery is restore-from-backup and retry, not resume. 4. Verify:
   docker inspect uptime-kuma --format '{{.State.Health.Status}}'   # expect: healthy
   docker exec uptime-kuma cat /app/package.json | grep '"version"'  # expect: "2.x.x"
   # /metrics endpoint auth (Basic, empty username, UPTIME_KUMA_API_KEY as password):
   curl -s -o /dev/null -w "%{http_code}\n" -u ":$UPTIME_KUMA_API_KEY" \
     http://192.168.42.229:3001/metrics                              # expect: 200
   
Also confirm in the web UI that monitors and history are intact, not reset. Gotcha hit live: testing the /metrics curl in a bare interactive SSH shell gives a false 401, because UPTIME_KUMA_API_KEY is only populated by sourcing proxmox-inventory/.env (which snapshot.py/ daemon.py do automatically, but an ad hoc shell doesn't). Confirm the var isn't empty (echo "[$UPTIME_KUMA_API_KEY]") and export it from .env before concluding the metrics endpoint itself is broken.

Tag-pinning decision

Kept the tag floating at :2 rather than pinning to the exact :2.4.0 that was migrated to. Reasoning: auto_update: true (flipped on for uptime-kuma the same session, once this migration and the docker-compose dispatch fix were both proven) only does something useful if `docker compose pull` can find something newer — pinning to an exact tag would make that flag a permanent no-op. version-checker already compares against GitHub's true /releases/latest regardless of what tag is pinned, so a future v3 jump will get flagged as "behind" the exact same way this v1→v2 gap was. The fix for that recurring risk is watching the digest regularly, not the tag strategy.

Result

Migrated 2026-07-08: v1.23.17 → v2.4.0. All verify steps passed, no data loss. auto_update: true set for future v2.x.y point releases.

Follow-up

If/when upstream ever cuts a v3, expect the exact same "behind, tag capped" signal in version-report.txt — that's expected and correct, not a bug. Repeat this same manual procedure rather than assuming run-update can bridge a major-version jump on its own.
Vaultwarden hangs behind Caddy — ROCKET_TLS enabled in community-scripts default medium
curl to vaultwarden FQDN hangs indefinitely • curl direct to LXC IP:8000 returns blank • Vaultwarden shows active/running and ss shows port 8000 bound
vaultwarden caddy tls community-scripts onboarding   last seen: 2026-06-28

Symptoms

  • curl to vaultwarden FQDN hangs indefinitely
  • curl direct to LXC IP:8000 returns blank
  • Vaultwarden shows active/running and ss shows port 8000 bound
  • Caddy logs show no error — backend silently non-responsive
See also: caddy-onboard-plain-http-tls-backend

Symptom

Vaultwarden is running and ss shows it bound to 0.0.0.0:8000, but: - curl http://:8000 returns blank (no response, no error) - curl https:// hangs indefinitely - Caddy reports no errors

Root Cause

The community-scripts Vaultwarden installer sets ROCKET_TLS in /opt/vaultwarden/.env, enabling HTTPS with a self-signed snake-oil cert on port 8000. Caddy's reverse_proxy block uses plain HTTP by default and gets no response from an HTTPS-only backend.

Fix

Remove the ROCKET_TLS line — Caddy handles TLS externally, the backend should serve plain HTTP:
pct exec 108 -- sed -i "s|^ROCKET_TLS=.*||" /opt/vaultwarden/.env
pct exec 108 -- systemctl restart vaultwarden
Verify:
ssh root@192.168.42.25 "curl -sI http://192.168.42.209:8000" | head -3

Expect: HTTP/1.1 200 OK

Note

The Caddyfile block generated by onboard-container uses plain HTTP (reverse_proxy :8000) which is correct after this fix. No Caddy changes needed.
VS Code Remote SSH stuck in reconnection loop medium
VS Code connects briefly then immediately reconnects • SSH: developer-env shows reconnection spinner • raw ssh developer-env works fine
vscode ssh developer-env   last seen: 2026-06-03

Symptoms

  • VS Code connects briefly then immediately reconnects
  • SSH: developer-env shows reconnection spinner
  • raw ssh developer-env works fine
  • server log shows repeated 'The client has reconnected'

Causes (fix in order)

1. Shell startup output breaks VS Code's env probe

VS Code spawns a loginInteractiveShell to read env vars. If .bashrc prints output, VS Code can't parse the env and drops the connection.
# Guard interactive-only output in ~/.bashrc on developer-env:
if [[ $- == *i* ]] && [[ -z "$VSCODE_INJECTION" ]]; then
    source ~/projects/homelab/start.sh
fi

2. Duplicate `Host developer-env` stanza in `~/.ssh/config` on the Mac

Keep only one stanza with the Headscale IP (100.64.0.111).

3. `Remote.SSH: Use Local Server` enabled

Over a Headscale tunnel, the relay drops connections faster than VS Code can recover. Fix (Mac VS Code settings): - Uncheck Remote.SSH: Use Local Server - Set Remote.SSH: Remote Platform → add developer-env: linux
# Kill the server after changing settings
ssh developer-env 'pkill -f vscode'
watchdog daemon.py never read triage_summary()'s playbook_id -- every inline-matched 'known benign' pattern paged urgent anyway, since inception medium
A known, already-diagnosed, self-resolving pattern (e.g. ddns-update — Freshness flapping on Porkbun 503 clustering, or CrowdSec bouncer-restart blips) still triggers ntfy priority-5 'Watchdog: manual intervention required' every single occurrence • watchdog daemon log shows 'Triage: <a correct, specific, named diagnosis>' immediately followed by 'No playbook matched -- escalating' in the same alert cycle -- the diagnosis and the escalation decision visibly disagree • A known-fixes writeup describes a triage/playbook fix as shipped and live, but the noisy paging continues unchanged after that date
watchdog daemon triage playbook alert-noise false-positive wiring-gap ntfy   last seen: 2026-08-12

Symptoms

  • A known, already-diagnosed, self-resolving pattern (e.g. ddns-update — Freshness flapping on Porkbun 503 clustering, or CrowdSec bouncer-restart blips) still triggers ntfy priority-5 'Watchdog: manual intervention required' every single occurrence
  • watchdog daemon log shows 'Triage: <a correct, specific, named diagnosis>' immediately followed by 'No playbook matched -- escalating' in the same alert cycle -- the diagnosis and the escalation decision visibly disagree
  • A known-fixes writeup describes a triage/playbook fix as shipped and live, but the noisy paging continues unchanged after that date
  • watchdog daemon.py 'Unknown check path' WARNING lines for paths like 'probes.caddy_lan_reachable.reachable' or 'headplane.running' alongside the escalation
See also: ddns-freshness-porkbun-503-clustering, host-repo-clone-uncommitted-drift, collect-homelab-clone-staleness-and-update-log-split

Symptom

Reported as "watchdog is still causing ntfy notifications and then recovering" -- the ddns-update — Freshness push monitor kept flapping DOWN/UP over 2026-08-10 through 2026-08-12, each occurrence paging ntfy priority 5 ("Watchdog: manual intervention required"), despite known-fixes/ddns-freshness-porkbun-503-clustering.md (dated 2026-08-11) describing this exact pattern as already diagnosed and handled.

Two independent bugs stacked on top of each other

Bug 1 — the fix was never deployed (clone drift)

watchdog's own ~/homelab repo clone was 21 commits behind origin/main, missing the commit (ddf69cc) that added watchdog/snapshot.py's collect_ddns_freshness() collector and watchdog/playbooks/ddns-freshness-porkbun-503-blip.yaml. Nothing auto-pulls on watchdog -- bin/collect-homelab runs on developer-env and pushes *to* GitHub, it doesn't pull anything down onto other hosts' clones. The daemon's own boot log gives this away directly: Loaded 2 playbook(s) instead of 3. Same failure class as known-fixes/host-repo-clone-uncommitted-drift.md, just the opposite direction (stale, not diverged) -- worth checking `git log HEAD..origin/main --oneline` on any host clone before trusting that a documented fix is actually live there. Fixed by git stash (a local, uncommitted append to collected/update-log.txt from run-maintenance-window having been run locally on watchdog -- unrelated drift, preserved in the stash, not part of this fix) + git pull origin main (clean fast-forward, 901d04d..117b101) + systemctl restart watchdog.service to reload the playbook list (playbooks are loaded once in main() at startup, not re-read per cycle).

Bug 2 — even deployed, it was structurally disconnected (the real bug)

This is the one that matters for the future: **watchdog/snapshot.py's triage_summary() computes snapshot["triage"]["playbook_id"] via its own inline Python pattern matching, but watchdog/daemon.py's handle_alert() never read that field.** It only logged triage["assessment"] for a human to see, then re-derived a match from scratch by iterating loaded YAML files through match_fingerprint()/resolve_check(). For the two patterns triage_summary() matches purely inline -- crowdsec-bouncer-restart-blip (no YAML file exists for it at all) and ddns-freshness-porkbun-503-blip (a YAML file exists, but its own header comment says explicitly: *"the actual matching logic lives inline in snapshot.py... This file exists for discoverability/documentation, not because daemon.py's YAML fingerprint loader consumes it"*) -- that re-derived match could never succeed. ddns-freshness-porkbun-503-blip.yaml's fingerprint checks (probes.caddy_lan_reachable.reachable, ddns_freshness.clustering_pattern, etc.) don't correspond to resolve_check()'s actual dotted-path grammar (confirmed against the one playbook that *does* work end-to-end, adguard-wildcard-rewrite-reversion.yaml, whose checks are bare caddy_lan_reachable, dns.wildcard_rewrite.correct -- no probes. prefix, no .reachable/.correct suffix on top of what the shorthand already returns) -- every check silently resolved to None (logged as `Unknown check path), guaranteeing a mismatch. Net effect: **triage_summary()` had been correctly diagnosing this pattern (and the CrowdSec one) since 2026-07-19/2026-08-11 respectively, and handle_alert() ignored the diagnosis every single time**, falling through to send_escalation() unconditionally at priority="urgent" with the hardcoded title `"Watchdog: manual intervention required"` regardless of what was actually matched.

Fix

watchdog/daemon.py's handle_alert() now checks triage.get("playbook_id") against a new explicit allowlist, `KNOWN_BENIGN_BLIP_PLAYBOOKS = {"crowdsec-bouncer-restart-blip", "ddns-freshness-porkbun-503-blip"}`, before falling into the YAML-fingerprint loop. A match sends only that playbook's `notifications. on_match` config (or a generic low-priority notice built from triage["assessment"] if no YAML file exists for the id) and returns -- no send_escalation() call, no urgent page. Everything else -- including other matched: True triage branches with no YAML file, like caddy-down and adguard-primary-down, which are real actionable diagnoses, not benign blips -- deliberately falls through to the original escalation path unchanged. This was a conscious design choice, not an oversight: an earlier draft of this fix gated on "matched a triage branch with no YAML file" in general, which a local test immediately caught misfiring on caddy-down (a genuine "go restart Caddy" situation) by silently downgrading it to a low-priority "no action needed"-flavored notice. The allowlist is the fix specifically *because* "no YAML file" isn't a safe proxy for "benign" -- only these two specific, individually-verified self-resolving patterns get the quieter treatment. Verified via a standalone harness (handle_alert() called directly with a monkeypatched run_snapshot() returning hand-built snapshots) against three cases before deploying: the ddns-freshness blip and the CrowdSec bouncer-restart blip both now send a single low-priority informational ntfy and stop; a real caddy-down case and a genuinely-unrecognized monitor name both still page priority="urgent" / `"Watchdog: manual intervention required"` exactly as before.

Related fix bundled in the same pass: mislabeled log timestamps

daemon.py's logging.basicConfig(datefmt="%Y-%m-%dT%H:%M:%SZ", ...) appended a literal Z (Zulu/UTC) suffix while Python's logging module defaults to time.localtime for formatting -- on watchdog (system timezone America/Los_Angeles), every log line was silently printing **local time labeled as UTC**, a 7-hour skew. This actively misled the investigation here: cross-referencing daemon.py's own log against Uptime Kuma's heartbeat table (genuinely UTC) and proxmox-nuc's ddns-update.log (genuinely UTC, self-generated via date -u) initially looked like the daemon had silently stopped detecting real down events for hours, when it had actually caught every one correctly -- just mislabeled by 7 hours. Fixed with logging.Formatter.converter = time.gmtime (a module-level attribute, no per-handler equivalent) right after basicConfig(). Worth remembering for any other script on a non-UTC host that builds its own Z-suffixed timestamp format string without an explicit UTC converter.

Diagnostic path (if a similar disagreement shows up again)

1. Compare the daemon's Loaded N playbook(s): [...] boot-log line against ls watchdog/playbooks/ in the repo -- a count or name mismatch means the live clone is behind. git log HEAD..origin/main --oneline on the host confirms it directly. 2. If the clone is current but the noisy pattern persists: check whether triage["assessment"] (visible in the daemon's Triage: ... log line) already describes the exact situation correctly. If it does, but the very next line is No playbook matched -- escalating, the disagreement is between triage_summary()'s diagnosis and handle_alert()'s separate match -- exactly this bug's shape. Check whether the playbook_id triage_summary() would have returned is in KNOWN_BENIGN_BLIP_PLAYBOOKS in daemon.py; if it's a new benign pattern being added, add it there rather than only adding a YAML file (a YAML file alone, even a syntactically valid diagnostic_only one, is not sufficient -- this entry is the proof).
watchdog packet loss looked like a UCG-Fiber cutover regression -- was actually a pre-existing WiFi-bridge hop, fixed by re-racking medium
watchdog network-benchmark ucg-fiber wifi poe   last seen:

Symptom

A bin/network-benchmark run done shortly after the UCG-Fiber Session 1 Phase 2 cutover (see INFRASTRUCTURE.md's UCG-Fiber section and known-fixes/ucg-fiber-cutover-missing-443-forward.md) showed watchdog had regressed hard versus the most recent pre-cutover baseline (run-output/network-benchmark-post-port9-clean-20260815-130133.txt): | metric | baseline (2026-08-15) | post-cutover (2026-08-21) | |---|---|---| | ping loss | 0% | 12%, +5 errors | | ping avg/max | 2.80 / 35.3 ms | 13.2 / 336 ms | | scp upload | 5.3 MB/s | 4.0 MB/s | | scp download | 2.2 MB/s | 1.3 MB/s | This looked concerning specifically *because* watchdog is one of bin/network-benchmark's two control-group hosts -- the script's own header comment says both controls "plug directly into the router," so a regression there (while the switch-group hosts nastynas/pibox and the other control proxmox-nuc all matched baseline cleanly) pointed at the new gateway itself, not the switch.

Root cause

The script's control-group assumption was stale for watchdog specifically. It was not, in fact, plugged directly into the router -- it sat a floor below the rack (dining room), wired to a PoE switch whose own uplink backhauled over an old AP's WiFi bridge. That WiFi hop was the actual source of the loss/latency, and it predates the UCG-Fiber cutover entirely -- the cutover was coincidental timing, not the cause. (The Aug 15 baseline's own watchdog numbers were already the roughest of the four hosts tested, which in hindsight was the same hop being flaky then too, just not investigated at the time since nothing was regressing yet.)

Fix

MOS re-racked the Pi5 into the homelab rack and powered it via the UCG-Fiber's single PoE+ port (the Pi5 already had its PoE+ HAT installed -- no new hardware needed). This eliminates the WiFi bridge hop entirely; watchdog is now genuinely wired straight into the gateway, matching what the script's control-group comment always assumed. Verified immediately after the move: - ping -c10: 0% loss, avg 0.83ms, max 1.47ms (vs. 12% loss / 13.2ms avg / 336ms max on the WiFi-bridge path) - SCP: ~96 MB/s both directions (vs. 4.0/1.3 MB/s) - ip -brief link on watchdog confirms eth0 UP, wlan0 DOWN

Prevention / notes for next time

- **A benchmark script's "control group" comment is a claim about topology, not a guarantee -- verify it's still true before trusting a result that hinges on it.** bin/network-benchmark's targets list hasn't been revisited since it was written 2026-08-12; watchdog's actual physical setup had drifted from that assumption without the script (or this repo) ever being updated to match. - This is also a good example of the lesson already written into known-fixes/verify-real-dispatch-path-before-diagnosing.md's spirit: a plausible-looking regression right after a change is not the same as a regression *caused by* that change -- the timing correlation here was real, the causation wasn't. Worth a beat of "does the control group still mean what the script says it means" before escalating a finding like this as a cutover-caused bug. - Doesn't change anything about the independent-vantage-point design: raspi4/guardian is the host that has to stay physically/network- independent from watchdog (to catch the case where Pi5 itself goes unreachable) -- untouched by this move. watchdog itself only ever needed a *reliable* link to do its own job (poll Uptime Kuma, snapshot state, remediate, escalate), so wired-and-solid is a straightforward improvement with no architecture trade-off.
apt dist-upgrade looked stuck for 29 minutes on developer-env — was download-bound (slow mirror), not CPU/disk/kernel-count; history.log vs term.log timestamps tell the two apart in one check low
apt dist-upgrade / apt-get install takes 20-30+ minutes for a modest package count with no errors printed • upgrade includes a kernel and/or large packages (build-essential, docker-ce) and just seems to hang • nproc/free -h look fine but the operation still feels absurdly slow
apt mirror ubuntu download-speed timing-diagnosis developer-env history.log term.log   last seen: 2026-07-22

Symptoms

  • apt dist-upgrade / apt-get install takes 20-30+ minutes for a modest package count with no errors printed
  • upgrade includes a kernel and/or large packages (build-essential, docker-ce) and just seems to hang
  • nproc/free -h look fine but the operation still feels absurdly slow

Symptom

On developer-env, sudo apt dist-upgrade covering 36 packages (including a new kernel, build-essential, docker-ce/docker-ce-cli, and related packages) took ~29 minutes end to end. No errors printed, nothing that looked like a genuine hang (no repeated retries, no timeout messages) — just a long, quiet wait.

Diagnosis — split elapsed time into download phase vs. install phase

Instead of guessing at CPU, disk, or old-kernel-count causes, use two apt log files together to see exactly where the time went:
# Full download-vs-install timeline with real timestamps
sudo cat /var/log/apt/history.log | tail -60

apt's own term.log shows every individual unpack/setup step with timestamps

sudo grep -E '^(Log started|Log ended|Preparing to unpack|Unpacking|Setting up|Processing triggers)' /var/log/apt/term.log | tail -100

Rule out resource starvation while you're at it

nproc free -h
history.log's Start-Date/End-Date lines mark when the *actual dpkg transaction* (unpack/configure) ran — not when apt itself was invoked. Comparing that against when the command was actually started (from your own notes, ps aux timestamps, etc.) shows how much of the total elapsed time was spent before dpkg touched anything at all. That gap is download time. What this looked like in practice: the apt dist-upgrade process was started at 10:04. history.log showed the real dpkg transaction running only from 10:31:27 to 10:32:36 — 69 seconds. That's ~27 of the 29 minutes spent purely downloading, and under a minute once packages actually started unpacking. term.log's Unpacking/Setting up lines confirmed the same thing directly — every install-phase line completed inside that same ~60-second window. nproc/free -h (4 cores, 4.3GB free) ruled out the VM being resource-starved.

Cause

us.archive.ubuntu.com was slow for this specific transfer. A kernel + build-essential + docker-ce/docker-ce-cli and friends is a genuinely sizeable payload (likely a few hundred MB), and at a throttled/congested rate that easily stretches to 25+ minutes even though the actual install work is fast.

Fix — switch to Ubuntu's geo-mirror redirector

sudo cat /etc/apt/sources.list.d/ubuntu.sources
Change the URIs: line for the main archive from a hardcoded mirror to Ubuntu's mirror-redirector, which auto-picks the fastest mirror for the actual network path via GeoIP:
sudo sed -i 's|http://us.archive.ubuntu.com/ubuntu/|mirror://mirrors.ubuntu.com/mirrors.txt|' /etc/apt/sources.list.d/ubuntu.sources
Leave the security.ubuntu.com line untouched — that one's already a fixed, fast CDN endpoint, not something to geo-redirect. Applied 2026-07-22, followed immediately by apt update && apt dist-upgrade && apt autoremove --purge.

Verify

Unconfirmed as of 2026-07-22 — no real download-heavy apt operation has run against the new mirror yet. Next time one does (a check-dev-upgrades HOLD assessment, or a manual multi-package apt dist-upgrade), re-run the history.log/term.log timing comparison above and compare the download-phase duration against this incident's ~27-minute baseline for a similarly-sized payload. If the new mirror is working, the download phase for a comparable payload should shrink substantially.

Secondary, unrelated finding from the same session

dpkg -l | grep -E '^ii\s+linux-(image|headers|modules)-[0-9]' turned up 12 leftover kernel-related packages spanning three kernel versions at the time of this incident. Once a new kernel has booted successfully, sudo apt autoremove --purge reclaims that space and reduces future initramfs-regen work. This was not the cause of the slowness in this case — term.log showed unpack/setup finishing in under a minute regardless of kernel count — but old-kernel accumulation is a real, separate cause of slow upgrades elsewhere, which is exactly why splitting download-phase from install-phase (rather than assuming one or the other) matters.

General takeaway — "is it actually stuck, or just slow?"

Whenever an apt operation feels like it's taking too long, compare history.log's Start-Date/End-Date against when the command was invoked, before assuming CPU, disk, or kernel-count overhead: - Download-bound (this case): history.log's dpkg transaction is short; most of the elapsed time happened before it even started. Fix: better mirror, or just wait it out. - Install-bound: term.log's Unpacking/Setting up/`Processing triggers` lines span most of the elapsed time. Likely causes: update-initramfs/GRUB regeneration for a new kernel (worse with more old kernels installed, since each one gets rebuilt too), needrestart scanning every running process after a libc/kernel bump, or genuine CPU/disk constraints on the host. This split takes one cat/grep and a glance at timestamps — much faster and more reliable than guessing and re-running the whole upgrade to test a theory.

Related

Full write-up and the general technique also live in TROUBLESHOOTING.md under "Unattended-Upgrades / OS Security Patch Notifications" — this entry is the structured/searchable counterpart for bin/wiki-server and bin/drift-triage.
check-backup-freshness alerted STALE every quiet night because its 26h wall-clock threshold couldn't tell 'no new content yet' from 'the pipeline is broken' low
ntfy: 'Backup Freshness: ATTENTION' -- pibox/nastynas/wildwood all STALE, newest entry ~2.1d ago (expected within ~26h) • Alert re-fires on consecutive nights even though backup-music.sh's own log shows every leg completing OK and the Uptime Kuma heartbeat firing successfully each run • The nightly rsync legs DO transfer a handful of files each night (tens of MB) despite the alert -- but they're not new show folders
backup rsync false-positive music-backup copyparty known-fixes   last seen: 2026-08-19

Symptoms

  • ntfy: 'Backup Freshness: ATTENTION' -- pibox/nastynas/wildwood all STALE, newest entry ~2.1d ago (expected within ~26h)
  • Alert re-fires on consecutive nights even though backup-music.sh's own log shows every leg completing OK and the Uptime Kuma heartbeat firing successfully each run
  • The nightly rsync legs DO transfer a handful of files each night (tens of MB) despite the alert -- but they're not new show folders
See also: backup-music-orchestrator-placeholder-corruption, backup-music-mid-run-redeploy-self-corruption

Symptom

check-backup-freshness's nightly --ntfy cron run (23:45, watchdog) flagged all three music-backup mirrors STALE, newest entry 2.1 days old against a 26h threshold. Looked like a repeat of the 6.5-week orchestrator-corruption incident, but backup-music.sh's own log on proxmox-nuc showed three consecutive clean nights (all legs OK, Uptime Kuma freshness heartbeat sent each time).

Cause

Two compounding things: 1. The check was time-threshold based. It measured wall-clock time since the newest top-level show directory was created and flagged STALE past 26h. MOS's actual rip/tape cadence is bursty -- new shows land every few days, not nightly -- so any quiet stretch longer than 26h re-triggered this every single night at 23:45 until the next real addition, regardless of whether anything was actually wrong. 2. The files that WERE transferring nightly were noise, not content. copyparty's /music volume (added 2026-08-16, see INFRASTRUCTURE.md's copyparty section) serves the same tree the music backup rsyncs from, and its .hist/ index/cache directory (up2k.db*, thumbnail cache) lives inside that same shared tree with no rsync exclude. copyparty's own housekeeping churn on .hist/ was landing on all three backup targets every night, which is why backup-music.sh kept reporting non-zero file counts even though no real music had been added -- but that churn happens deep inside .hist/'s subdirectories, not at the top level of shared/, so it never actually reset the freshness clock either. Net effect: real signal (a new show folder) hadn't landed in 2+ days, and the only thing masking that in the transfer logs was irrelevant cache noise.

Fix

Excluded .hist/ from all three rsync legs (2026-08-20). rsync_music_to_pibox.sh and rsync_music_to_nastynas.sh (both --delete-delay true mirrors) gained --exclude='.hist/' --delete-excluded, which also retroactively cleaned up the ~135 already- mirrored .hist entries on both targets on the next run. rsync_music_to_wildwood.sh (accumulate-everything, no --delete at all) gained --exclude='.hist/' only -- consistent with its "nothing ever removed" design, it just stops re-accumulating .hist going forward and keeps whatever it already copied. Rewrote check-backup-freshness to drop the time threshold entirely. Instead of measuring elapsed time, it now checks a cadence-independent invariant: pibox and nastynas are both --delete-delay true mirrors of the same source, so their directory sets must always match exactly; wildwood is --ignore-existing/no-delete, so it must always be a superset of the other two. Any divergence, at any time gap, is a real signal -- a quiet week with all three in lockstep now reports clean instead of alerting. .hist is also explicitly excluded from the comparison in the script itself (IGNORE_NAMES), independent of the rsync-level fix, since wildwood retains its already-accumulated .hist copy forever by design.
# Verify consistency directly:
ssh watchdog@192.168.42.229 check-backup-freshness --top 5
This doesn't reduce coverage: a dead/corrupted orchestrator (the original 6.5-week incident) is already caught independently by backup-music.sh's own Uptime Kuma push heartbeat (fires unconditionally every run -- a missing heartbeat means the job never ran) and each leg's ntfy_failure trap (fires on any leg that starts and errors). This check's unique value was always catching a leg that *reports success* without the destination actually matching the source -- and the new cross-target comparison catches exactly that, without needing to guess what counts as a "normal" gap between additions. ntfy titles changed from "Backup Freshness: OK/ATTENTION" to "Backup Consistency: OK/ATTENTION" to reflect what's actually being checked now.

Prevention / open follow-up

If another service ever gets a volume/mount pointed at the same tree this backup rsyncs from (the way copyparty's /music volume did), check whether it writes any metadata/cache directory into that tree and exclude it from the backup legs up front -- .hist/ sat un-excluded for 4 days before anyone noticed the noise it was creating in the transfer logs.
CI — Validate Caddyfile workflow had TWO stacked env-parity gaps (log dir permission, then missing CrowdSec API key stub), both silently broken since the features they validate were added to the Caddyfile low
GitHub Actions 'Validate Caddyfile' workflow fails on the 'Validate Caddyfile syntax' step with exit code 1 • First gap: job log shows Error: setting up default log: opening log writer using &logging.FileWriter{Filename:"/var/log/caddy/access.log"...}: mkdir /var/log/caddy: permission denied • Second gap (only visible after fixing the first): job log shows Error: loading crowdsec app module: crowdsec: invalid configuration: crowdsec API key must not be empty
ci github-actions caddy validate logging permissions crowdsec env-vars false-attribution   last seen: 2026-07-21

Symptoms

  • GitHub Actions 'Validate Caddyfile' workflow fails on the 'Validate Caddyfile syntax' step with exit code 1
  • First gap: job log shows Error: setting up default log: opening log writer using &logging.FileWriter{Filename:"/var/log/caddy/access.log"...}: mkdir /var/log/caddy: permission denied
  • Second gap (only visible after fixing the first): job log shows Error: loading crowdsec app module: crowdsec: invalid configuration: crowdsec API key must not be empty
  • Job annotations show only unrelated warnings (Node.js 20 deprecation notice, go.sum cache-restore miss) -- the actual error is buried in the step's raw log output, not surfaced as an annotation
  • Workflow triggers on any push touching bin/** or caddy/Caddyfile (per its paths filter), so it can fire and fail on a commit that never touched the Caddyfile at all
See also: dns-caddy-monitor-transient-flap

Symptom

The Validate Caddyfile GitHub Actions workflow (.github/workflows/validate-caddyfile.yml) failed on a push that only touched bin/ddns-update and bin/setup-uptime-kuma.py -- no Caddyfile changes at all. The workflow's own paths filter includes bin/**, so it correctly triggered on that push; the failure itself, however, had nothing to do with the content of either changed file. The job's Annotations panel showed only two warnings -- a generic Node.js-20-deprecated notice GitHub now attaches to every job, and a go.sum cache-restore miss (also generic, not caused by anything in this repo) -- neither of which is the actual failure. The real error only appears in the raw output of the Validate Caddyfile syntax step itself. This turned out to be two separate, stacked gaps -- fixing the first and re-running revealed the second, previously masked by the first failure happening earlier in the same step.

Gap 1: log directory permission denied

Error: setting up default log: opening log writer using
&logging.FileWriter{Filename:"/var/log/caddy/access.log", ...}:
mkdir /var/log/caddy: permission denied
caddy validate does not just parse Caddyfile syntax -- it fully provisions the adapted config, which includes opening every log writer the config declares. The Caddyfile's global log {} block (added 2026-07-18, writing to /var/log/caddy/access.log) is provisioned during validation the same as it would be at real startup. On the actual caddy LXC this is a non-issue: Caddy runs as root there, and /var/log/caddy already exists. On the GitHub Actions ubuntu-latest runner, neither is true -- the workflow runs as a non-root user, and /var/log/caddy has never existed on that runner, so attempting to open (and implicitly create) the log file under it fails with a permission error at the OS level, which Caddy surfaces as a fatal provisioning error, not a validation warning. Fix: added a step immediately before Validate Caddyfile syntax to create the directory the runner is missing:
- name: Create log directory to match production caddy LXC
  run: sudo mkdir -p /var/log/caddy && sudo chown "$(whoami)" /var/log/caddy
sudo is passwordless on GitHub-hosted ubuntu-latest runners, so this works without any additional permissions configuration.

Gap 2: CrowdSec bouncer API key not stubbed

Re-running after Gap 1's fix surfaced a second, previously-hidden error:
Error: loading crowdsec app module: crowdsec: invalid configuration:
crowdsec API key must not be empty
The Caddyfile's global crowdsec {} block (also added 2026-07-18) references api_key {env.CROWDSEC_BOUNCER_API_KEY}. The workflow's Validate Caddyfile syntax step already stubs dummy values for PORKBUN_API_KEY/PORKBUN_API_SECRET_KEY (the acme_dns porkbun block has the identical requirement, and that stubbing was added when the Porkbun module was first wired into this workflow) -- but CROWDSEC_BOUNCER_API_KEY was never added to that same env: block when the CrowdSec bouncer module was added later, on 2026-07-18. Fix: added the missing stub alongside the existing two:
CROWDSEC_BOUNCER_API_KEY: dummy-crowdsec-key-for-ci-validation
Never talks to the real LAPI during validate -- this purely satisfies the module's own non-empty check during provisioning, same as the Porkbun stubs do for acme_dns.

Timeline and why both gaps went unnoticed for three days

Both gaps trace back to 2026-07-18, the day the global log {} block and the crowdsec {} block were both added to the Caddyfile in the same session. Neither gap was caught at the time because the workflow only triggers on pushes touching bin/** or caddy/Caddyfile, and apparently no such push happened again until 2026-07-21's session (which only touched bin/, unrelated to Caddy at all) -- so the workflow had been silently non-functional for three days with nobody aware. Worth noting for next time a Caddyfile change adds a new module or global option that reads from {env.SOMETHING}: **that new env var needs its own dummy stub added to this workflow's Validate Caddyfile syntax step in the same commit**, exactly like PORKBUN_API_KEY was stubbed when acme_dns porkbun was added. The CrowdSec bouncer module was added without this step being updated to match -- an easy thing to miss since the workflow doesn't fail *at that point* (there was no push touching bin/** or caddy/Caddyfile again until three days later), so the gap sat invisible.

Why this needed care to diagnose correctly

The temptation was to assume the failure was caused by whatever was pushed in the same commit -- classic recency bias, and exactly the kind of thing CLAUDE.md's own established pattern (check what changed most recently) would normally point toward. In this case that pattern would have been misleading: neither bin/ddns-update nor bin/setup-uptime-kuma.py has anything to do with Caddy, TLS, logging, CrowdSec, or the Caddyfile at all. The workflow's own paths filter made it *look* like a targeted check on the pushed files, when it's actually a broader CI check that happens to share a trigger condition with unrelated bin/ scripts. Worth remembering: a workflow's trigger paths don't imply its failure is about those specific paths' content -- only that a push touching them was the trigger. The two-gap-stacked nature is also worth remembering on its own: fixing the first visible error and re-running is the correct move, but don't assume a green run on the next try -- a second, previously-masked issue can be sitting right behind the first one in the same step.

Lesson

Any Caddyfile directive with a filesystem side effect (log file paths, TLS cert cache paths, etc.) or any global option/module reading from {env.SOMETHING} that caddy validate provisions as part of adapting the config is a potential CI-environment gap, not just a syntax-correctness question. If a future Caddyfile change adds a new directive with its own filesystem dependency, or a new module requiring its own env-var credential, check whether CI's runner can actually satisfy both before assuming caddy validate passing locally/on the real host means CI will pass too -- and update this workflow's env stubs and/or setup steps in the same commit as the Caddyfile change that introduces the new requirement.
collect-homelab's clone-behind ntfy kept re-firing after running the suggested fix -- root cause was no auto-sync, not a broken pull low
collect-homelab: watchdog/raspi4 clone is behind fires repeatedly across multiple days even after running the alert's own `git -C ~/homelab pull` fix each time • SSHing in and checking confirms the clone IS clean and current right after pulling -- the notification just comes back a few runs later
collect-homelab git drift watchdog raspi4 guardian ntfy cron automation   last seen: 2026-08-14

Symptoms

  • collect-homelab: watchdog/raspi4 clone is behind fires repeatedly across multiple days even after running the alert's own `git -C ~/homelab pull` fix each time
  • SSHing in and checking confirms the clone IS clean and current right after pulling -- the notification just comes back a few runs later
See also: clone-staleness-alert-wrong-service-and-watchdog-local-merge-divergence, host-repo-clone-uncommitted-drift

Symptom

MOS reported running the fix commands from `collect-homelab: watchdog clone is behind / collect-homelab: raspi4 clone is behind` "the last few days" and the notification kept coming back anyway.

Diagnosis

SSHed directly into both hosts rather than trusting the alert text:
watchdog: main @ 7ac934b, 0 local-only / 20 origin-only commits, clean
raspi4:   main @ 7ac934b, 0 local-only / 20 origin-only commits, clean
Both reflogs showed a successful pull: Fast-forward from the previous day -- the fix had worked every time it was run. Neither host had any divergence (contrast with clone-staleness-alert-wrong-service-and-watchdog-local-merge-divergence.md, where the same symptom had a different, real root cause). The actual cause: this repo was committing fast (53 commits in the 3 days before this was diagnosed -- 4x/day auto-commits from collect-homelab itself, plus a normal day's dev work landing 15+ commits in a few hours), and neither host had any cron/timer that re-pulled between manual visits. CLONE_STALENESS_THRESHOLD in collect-homelab is 5 commits -- with commits landing at that pace, a clone drifts back past the threshold within hours of being pulled, whether or not anyone forgot to run the fix. The notification was accurately describing routine drift on every single firing; there was nothing to actually fix in the pull/restart procedure.

Fix -- auto-sync instead of manual-pull nagging

Added bin/clone-sync.sh: an hourly cron job on both hosts that does git fetch + git pull --ff-only (fails loudly instead of silently merge-committing on a real divergence -- see the related entry above for why that matters), and on watchdog only, restarts watchdog.service if the pull actually touched watchdog/ (daemon.py / playbooks). raspi4 needs no restart step -- watchdog-guardian.timer re-execs check_targets.py fresh from disk on every fire, so a pulled clone self-corrects with zero extra action.
watchdog: 0 * * * * /home/watchdog/homelab/bin/clone-sync.sh /home/watchdog/homelab watchdog/ watchdog.service >> /home/watchdog/.clone-sync.log 2>&1
raspi4:   0 * * * * /home/mos/homelab/bin/clone-sync.sh /home/mos/homelab >> /home/mos/.clone-sync.log 2>&1
collect-homelab's existing clone-staleness check stays as a safety net (it now mostly means "the auto-sync itself is broken," not "someone forgot to pull") -- not removed, just expected to go quiet in normal operation.

Lesson

A notification that keeps firing after you've done exactly what it told you to do is a signal to check whether the *procedure* is wrong before assuming you're the one dropping the ball. Here the procedure worked every time; the gap was that nothing kept the fix in place between runs. Compare verify-real-dispatch-path-before-diagnosing (Claude's session memory) -- same shape of lesson, different mechanism: don't assume the described fix is broken without checking what state the system was actually left in.
Clone-staleness ntfy hardcoded 'watchdog.service' for both watchdog AND raspi4 (wrong on raspi4); acting on it then hit a real git divergence from an earlier local merge on watchdog low
collect-homelab's clone-staleness ntfy alert (see collect-homelab-clone-staleness-and-update-log-split.md) tells you to 'systemctl restart watchdog.service' regardless of which host (watchdog or raspi4) is stale -- wrong for raspi4, which runs watchdog-guardian.service instead • git -C ~/homelab pull on a host's clone fails with 'You have divergent branches and need to specify how to reconcile them' instead of fast-forwarding cleanly • git rev-list --left-right --count HEAD...origin/main on that host shows nonzero on BOTH sides (host has commits origin doesn't, not just the reverse)
collect-homelab git drift watchdog raspi4 guardian process ntfy alert-text systemctl   last seen: 2026-08-12

Symptoms

  • collect-homelab's clone-staleness ntfy alert (see collect-homelab-clone-staleness-and-update-log-split.md) tells you to 'systemctl restart watchdog.service' regardless of which host (watchdog or raspi4) is stale -- wrong for raspi4, which runs watchdog-guardian.service instead
  • git -C ~/homelab pull on a host's clone fails with 'You have divergent branches and need to specify how to reconcile them' instead of fast-forwarding cleanly
  • git rev-list --left-right --count HEAD...origin/main on that host shows nonzero on BOTH sides (host has commits origin doesn't, not just the reverse)
See also: collect-homelab-clone-staleness-and-update-log-split, git-checkout-discards-preexisting-uncommitted-drift, host-repo-clone-uncommitted-drift

Bug 1 — alert text named the wrong systemd unit on raspi4

check_clone_staleness() in bin/collect-homelab sent the same ntfy body for both hosts it watches, with a hardcoded example command: systemctl restart watchdog.service. That's correct for watchdog (daemon.py really does run as watchdog.service), but **wrong for raspi4** — its actual clone-dependent unit is watchdog-guardian.service (guardian/check_targets.py, triggered by watchdog-guardian.timer). Following the alert literally on raspi4 would restart a unit that doesn't exist there and leave the real guardian check stale. Fix: check_clone_staleness() now takes a 4th arg (service_name), and the two call sites pass the real per-host unit:
check_clone_staleness "watchdog" "$wd_ip" "ssh_cmd_watchdog" "watchdog.service"
check_clone_staleness "raspi4" "$r4_ip" "ssh_cmd_raspi4" "watchdog-guardian.service"
Also added sudo to the suggested command — both hosts SSH in as a non-root user (watchdog, mos) with passwordless sudo, so a bare systemctl restart as printed before would've just failed with a permission error too.

Bug 2 (found while acting on the alert) — watchdog's clone had actually diverged, not just fallen behind

Running the alert's own advice (git -C ~/homelab pull) on watchdog didn't fast-forward — it hit `fatal: Need to specify how to reconcile divergent branches. git rev-list --left-right --count HEAD...origin/main` showed 2 commits local-only, 11 commits origin-only — a true divergence, not the usual "N commits behind." Root cause: earlier the same day, a prior session had resolved a different, unrelated collected/update-log.txt conflict *directly on watchdog's own clone* via git pull (which defaults to merge), producing two merge commits (1c1e7c9, c43f9f0) that were never pushed back to origin/main. Meanwhile origin/main kept moving forward linearly with real work (the daily-fleet-digest/run-fleet-update build, doc updates, several known-fixes/ entries, this very alert-text fix). The clone-staleness alert's own commit-count check doesn't distinguish "purely behind" from "diverged" — it just measures HEAD..origin/main, so it still fired and still said "pull," even though a plain pull couldn't succeed. Diagnosis before acting: git diff origin/main HEAD on watchdog, excluding collected/, showed the 2 local-only commits touched *only* collected/update-log.txt — content already made moot by the very fix (fe26c60) that both branches share as a common ancestor (the gitignored-update-log.local.txt split). Every other file difference ran the other direction (origin ahead), confirming nothing on watchdog's side was unique, valuable, uncommitted work. Fix applied:
ssh watchdog@192.168.42.229
cd ~/homelab
git fetch origin main
git reset --hard origin/main    # safe here: verified no unique content in
                                 # the local-only commits; untracked/gitignored
                                 # files (snapshots, update-log.local.txt)
                                 # are unaffected by --hard
sudo systemctl restart watchdog.service
Confirmed healthy after: watchdog.service active, daemon log shows Loaded 3 playbook(s) (all of them — the same signal watchdog-triage-playbook-id-never-consulted.md used to catch the original staleness incident). raspi4 had no divergence (clean 1-commit fast-forward) and needed no restart at all — watchdog-guardian.service is timer-triggered (watchdog-guardian.timer) and re-executes check_targets.py fresh from disk on every fire, so a stale-then-pulled clone self-corrects on the next tick without any service restart.

Don't skip the "is this actually just behind, or diverged" check

Before running git -C ~/homelab pull on any host clone in response to a staleness alert, check git rev-list --left-right --count HEAD...origin/main first (or just read git pull's own error if it already failed). A nonzero *left* count means the host has local-only commits — don't reset --hard blindly; diff against origin first to make sure nothing unique would be discarded, same discipline as git-checkout-discards-preexisting-uncommitted-drift.md. If the diff shows only content that's superseded/obsolete on both branches (as here), reset --hard + fetch is the clean fix; if it shows anything else, that needs manual reconciliation instead.
Process fix: watchdog/raspi4 clone staleness now self-reports, and update-log.txt no longer conflicts across hosts low
A known-fix or commit claims a fix is 'live' but the host that actually runs the code never pulled it -- discovered only by manually comparing git log HEAD..origin/main on that host • git pull on watchdog's (or raspi4's) ~/homelab clone conflicts on collected/update-log.txt with 'CONFLICT (content): Merge conflict' • watchdog's daemon boot log shows fewer playbooks loaded than exist in watchdog/playbooks/ in the repo
collect-homelab git drift watchdog raspi4 process ntfy alert-noise   last seen: 2026-08-12

Symptoms

  • A known-fix or commit claims a fix is 'live' but the host that actually runs the code never pulled it -- discovered only by manually comparing git log HEAD..origin/main on that host
  • git pull on watchdog's (or raspi4's) ~/homelab clone conflicts on collected/update-log.txt with 'CONFLICT (content): Merge conflict'
  • watchdog's daemon boot log shows fewer playbooks loaded than exist in watchdog/playbooks/ in the repo
See also: watchdog-triage-playbook-id-never-consulted, host-repo-clone-uncommitted-drift

Why this exists

Follow-up to known-fixes/watchdog-triage-playbook-id-never-consulted.md (2026-08-12). That incident took most of a session to diagnose partly because two structural gaps kept re-creating the same kind of confusion: nothing ever said watchdog's clone was 21 commits behind, and popping the one stash that *did* try to reconcile local drift produced a real merge conflict. Both were fixed as generalizable process changes rather than one-off manual cleanup, per MOS's request ("get our habits in sync ... so we don't have to remember").

Fix 1 — `collected/update-log.txt` no longer has two writers

Before: bin/run-update's and bin/update-advisor's append_update_log() functions always wrote to the single tracked collected/update-log.txt, regardless of which host ran them. bin/run-maintenance-window is explicitly meant to be run *from* watchdog for proxmox-nuc reboots (INFRASTRUCTURE.md), so every such run appended lines to watchdog's own clone of that same tracked file -- lines developer-env's copy (the one collect-homelab actually commits and pushes) never had, and watchdog's deploy key can't push to add them there either (confirmed read-only while resolving this). The two copies diverged every time, and a later git pull on watchdog had a real chance of hitting a merge conflict on that file (it did, 2026-08-12 -- see the linked incident's resolution). Fix: mirrors the existing watchdog/incidents.jsonl pattern exactly. - update_log_path() (new helper in bin/run-update, inlined equivalently in bin/update-advisor) checks socket.gethostname(): developer-env writes to collected/update-log.txt as before; every other host writes to collected/update-log.local.txt instead. - .gitignore grew an entry for collected/update-log.local.txt -- it can never become tracked-file drift because git never tracks it in the first place, same reasoning as watchdog/incidents.jsonl/watchdog/snapshots/ just above it in that file. - bin/collect-homelab's collect_watchdog() mirrors it into collected/watchdog/update-log.txt on its normal 6-hourly cycle (a straight overwrite-copy, not a chronological merge -- watchdog's own runs live in their own namespaced file rather than being spliced into the shared one; nothing in the codebase reads update-log.txt back programmatically to reconstruct "latest state," so this split costs nothing except a human checking two files instead of one after a halt). - bin/run-maintenance-window's halt message now names whichever file the process it just ran actually wrote to, instead of always naming the developer-env path. A git pull on watchdog's clone can no longer conflict on this file -- structurally, not by remembering to git stash first.

Fix 2 — clone staleness is now a proactive, low-priority signal

Before: collect-homelab's existing git-state collection recorded each clone's HEAD SHA (collected/git-state/head-consistency.txt) but explicitly did not alert on it ("recorded for the human; not itself an alert trigger"). Finding out a clone was behind meant either noticing a SHA mismatch by eye or, as happened here, discovering it mid-investigation of an unrelated-looking symptom. Fix: check_clone_staleness() (new function in bin/collect-homelab, called for watchdog and raspi4 right after their existing git-state collection) does a real git fetch origin main -q on each host and counts git rev-list --count HEAD..origin/main. Five or more commits behind sends a single low-priority (2) ntfy; the state file (collected/update-notes/-clone-staleness.state, format |) dedups the same way bin/maintenance-window-check already dedups its own per-node nagging -- fires on first crossing the threshold, re-nags at most once every 24h while still stale, and is deleted (so the next staleness episode alerts fresh) the moment the host next reports fewer than 5 commits behind. Immediate finding from testing this against live hosts before deploying: watchdog was freshly caught up (0 behind, expected -- just pulled during the linked incident's resolution), but raspi4 came back 204 commits behind, a long-standing staleness nobody had been tracking. Not fixed as part of this change (out of scope -- this was a process fix, not a "also update raspi4's guardian daemon" task); flagged for a deliberate follow-up decision on whether/when to pull it forward, same as any other host clone.

Net effect

Both problems from the linked incident -- "is a fix actually deployed" and "will pulling it conflict" -- are now either structurally impossible (the merge conflict) or self-reporting (the staleness) instead of depending on someone remembering to check.
collect-homelab: repo drift detected -- false positive from watchdog/raspi4's own collected/ writes low
ntfy: 'collect-homelab: repo drift detected' naming watchdog and/or raspi4 • collected/git-state/watchdog.txt (or raspi4.txt) shows 'M collected/update-log.txt' • the divergence appeared right after running bin/run-update or bin/run-maintenance-window FROM watchdog
collect-homelab drift watchdog raspi4 git false-positive   last seen: 2026-08-11

Symptoms

  • ntfy: 'collect-homelab: repo drift detected' naming watchdog and/or raspi4
  • collected/git-state/watchdog.txt (or raspi4.txt) shows 'M collected/update-log.txt'
  • the divergence appeared right after running bin/run-update or bin/run-maintenance-window FROM watchdog
See also: host-repo-clone-uncommitted-drift, collect-homelab-special-collect-out-of-sync

Cause

collect_git_state() in bin/collect-homelab checks four repo clones for uncommitted tracked-file divergence (see known-fixes/host-repo-clone-uncommitted-drift.md for why this check exists at all). For developer-env's own clone, the check deliberately excludes collected/ and inventory/ — both are regenerated every run and expected to differ locally at any given moment. That same exclusion was never applied to the watchdog and raspi4 remote-clone checks, which ran a bare git status --short with no pathspec. bin/run-update appends to collected/update-log.txt (a tracked file) on whichever host actually runs it — and INFRASTRUCTURE.md's "Proxmox Node Maintenance Windows" section explicitly recommends running bin/run-maintenance-window from watchdog, not developer-env, for any proxmox-nuc reboot (so the operator's own SSH session doesn't go dark mid- reboot). Every such run leaves watchdog's local clone with a modified collected/update-log.txt and new collected/snapshots/*.json files that were never meant to be committed from watchdog — they're host-local operational output, same as developer-env's own collected/ writes. Confirmed live 2026-08-11: a same-morning run-maintenance-window run against all four proxmox_nodes (OS-package updates) appended 28 lines to watchdog's collected/update-log.txt, which the next collect-homelab run correctly flagged as M collected/update-log.txt — a real git diff, but not the kind of drift this check exists to catch (compare: an edited bin/ script or watchdog/compose.yaml, which the check should and does still catch).

Fix

Applied the same ':(exclude)collected' ':(exclude)inventory' pathspec to the watchdog and raspi4 git status --short calls that developer-env's own check already used. bin/collect-homelab runs directly from the repo checkout on developer-env's cron (`cd ~/projects/homelab && bin/collect- homelab`, no separate deploy step) — the fix is live on the next 6-hourly run, nothing to redeploy.

Verify

ssh watchdog@192.168.42.229 "git -C ~/homelab -c core.fileMode=false status --short -- . ':(exclude)collected' ':(exclude)inventory'"

Should be empty/clean even right after a run-update/run-maintenance-window

run on that host, as long as no genuinely-tracked non-collected/inventory

file actually changed.

What this does NOT mask

Genuine drift in bin/, watchdog/compose.yaml, or any other tracked path outside collected//inventory/ on watchdog or raspi4 still trips the alert exactly as before — this only removes the specific false-positive class caused by those two directories' own by-design, per-host regeneration. If a future divergence report from collect-homelab still names watchdog/raspi4 after this fix, treat it as real and follow known-fixes/host-repo-clone-uncommitted-drift.md's procedure (prove it's live before deciding whether to reconcile into or out of git).
apt breaks with malformed 99-ntfy config in /etc/apt/apt.conf.d/ low
apt_pkg.Error: E:Syntax error /etc/apt/apt.conf.d/99-ntfy:3: Malformed tag • command-not-found fails with apt_pkg.Error • pip install fails with apt error
apt community-scripts lxc onboarding   last seen: 2026-06-28

Symptoms

  • apt_pkg.Error: E:Syntax error /etc/apt/apt.conf.d/99-ntfy:3: Malformed tag
  • command-not-found fails with apt_pkg.Error
  • pip install fails with apt error

Symptom

Any command that triggers command-not-found (e.g. running a binary that doesn't exist) fails with:
apt_pkg.Error: E:Syntax error /etc/apt/apt.conf.d/99-ntfy:3: Malformed tag
apt-get itself may also fail or warn.

Root Cause

The community-scripts Tailscale installer (or an earlier community-scripts template) drops a malformed apt config file at /etc/apt/apt.conf.d/99-ntfy in LXCs it provisions. The file is syntactically invalid and breaks apt's config parser. Affected LXCs are those onboarded via community-scripts around the same period. The file is inert (apt ignores broken configs with a warning in some versions) but breaks command-not-found and pip install --break-system-packages in others.

Fix

Remove the file from affected LXCs:
# Check all running LXCs on proxmox-nuc
for id in $(pct list | awk 'NR>1 && $2=="running" {print $1}'); do
  result=$(pct exec $id -- ls /etc/apt/apt.conf.d/99-ntfy 2>/dev/null)
  if [ -n "$result" ]; then
    echo "FOUND in LXC $id"
  fi
done

Remove from each affected LXC

for id in 101 104 106 109 110 111 113 115 120; do pct exec $id -- rm /etc/apt/apt.conf.d/99-ntfy echo "Removed from LXC $id" done
No restart required — apt picks up the change immediately.
DDNS -- "ddns-update — Freshness" push monitor flaps DOWN/UP on its own; root cause is Porkbun API 503 clustering, not a homelab bug low
ntfy priority-5 'Watchdog: manual intervention required' fires for monitor 'ddns-update — Freshness', with assessment 'Pattern does not match any known failure mode' / 'No matching playbook for this failure pattern' • The monitor flips DOWN then UP again on its own within minutes to tens of minutes, repeatedly over a session, with no other monitor affected • proxmox-nuc's /var/log/ddns-update.log shows clusters of 'ERROR: retrieve failed for <record>' across 2-3+ consecutive 5-minute cron cycles, interleaved with normal 'Uptime Kuma freshness heartbeat sent' lines on cycles that succeeded
dns ddns porkbun uptime-kuma watchdog alert-noise false-positive freshness-monitor   last seen: 2026-08-12

Symptoms

  • ntfy priority-5 'Watchdog: manual intervention required' fires for monitor 'ddns-update — Freshness', with assessment 'Pattern does not match any known failure mode' / 'No matching playbook for this failure pattern'
  • The monitor flips DOWN then UP again on its own within minutes to tens of minutes, repeatedly over a session, with no other monitor affected
  • proxmox-nuc's /var/log/ddns-update.log shows clusters of 'ERROR: retrieve failed for <record>' across 2-3+ consecutive 5-minute cron cycles, interleaved with normal 'Uptime Kuma freshness heartbeat sent' lines on cycles that succeeded
  • No 'DDNS: duplicate wildcard A record detected' or 'DDNS: edit reported SUCCESS but did not land' alerts fire alongside it -- those are ddns-update's own correctness detectors and stay silent because nothing is actually wrong with the records
  • Manually re-running the same Porkbun API call a few times in a row (no delay) reproduces it directly: most calls return 200 in ~0.5-0.7s, then one comes back 503 in ~0.1s -- a fast edge-level reject, not a timeout
  • Live check of the wildcard/headscale/headplane A records via the Porkbun API matches the current WAN IP throughout -- no actual drift, despite the alert
See also: ddns-duplicate-wildcard-a-record, ddns-update-unvalidated-ip-garbage-push, watchdog-triage-playbook-id-never-consulted

Symptom

The "ddns-update — Freshness" Uptime Kuma push monitor (created by bin/setup-uptime-kuma.py, heartbeat sent by bin/ddns-update's push_heartbeat() on every run that completes with zero errors) flapped DOWN and back UP repeatedly over the course of an afternoon on 2026-08-11. Watchdog's daemon escalated with priority 5 ("manual intervention required") each time, since nothing in watchdog/playbooks/ (at the time) matched this specific isolated-monitor pattern.

Root cause

Porkbun's DNS API (dns/retrieve, dns/retrieveByNameType) intermittently returns a fast 503 — confirmed live via SSH to proxmox-nuc, both by reading bin/ddns-update's own log and by reproducing it directly:
retrieve attempt 14: http=200 time=0.505974s
retrieve attempt 15: http=503 time=0.109083s
This is a chronic, pre-existing background rate, not something new — ddns-update.log's ERROR: retrieve failed count has run roughly 65-105/day going back to at least 2026-07-28 (well before this specific push-monitor flap). curl -sf treats a 503 the same as any other failure: no output captured, non-zero exit, logged as `ERROR: retrieve failed for . bin/ddns-update` already handles this gracefully — it just retries the affected record(s) on the next 5-minute cycle and only alerts (via its duplicate-record or edit-didn't-land detectors) if a record is actually wrong, which it wasn't here. The push monitor's tolerance (interval: 900 = 15 min = 3 missed cron cycles, see bin/setup-uptime-kuma.py) was sized to absorb *isolated* single-cycle Porkbun blips. What actually happens is occasional **short clusters** — 2, sometimes 3, consecutive 5-minute cycles each losing at least one record's retrieve call — which is enough to push the gap between successful heartbeats past 900s on an unlucky run, flipping the monitor DOWN. The very next successful cycle sends a heartbeat and flips it back UP. Repeat a few times in an afternoon and it looks like a real, unexplained instability.

Why this wasn't obvious at first

- ddns-update's own alerting (ntfy) is deliberately silent on a plain retrieve failure — by design, only a *duplicate record* or an *edit that reported SUCCESS but didn't land* pages, since those are the only conditions that mean a DNS record is actually wrong. A transient 503 on a read-only lookup isn't either of those, so nothing paged from that side — only the freshness monitor's absence-of-heartbeat noticed. - DNS resolution and network path from proxmox-nuc were both fine throughout (Tailscale's 100.100.100.100 resolver answered in 4-11ms every time; TLS/TCP to api.porkbun.com succeeded in ~0.5-0.7s on the large majority of calls) — ruling out a local network or resolver problem before looking at Porkbun's own response codes. - The existing known-fixes/ddns-duplicate-wildcard-a-record.md already documented "ping/retrieve failures observed roughly hourly" as an accepted baseline, but that note didn't anticipate the failures *clustering* densely enough to trip a freshness-style monitor — it was written to explain a different incident (a duplicate record), not this one.

Fix / current state

No code change to bin/ddns-update needed — it already behaves correctly (silent retry, self-corrects, real detectors still armed). Three things were added 2026-08-11 so the *next* occurrence resolves itself instead of paging for a fresh investigation: 1. watchdog/snapshot.py: new collect_ddns_freshness() collector (SSHes to proxmox-nuc, tails ddns-update.log, checks for recent retrieve-failure lines *and* recent successful-heartbeat lines in the same short window — that combination is the fingerprint of "script alive, hitting Porkbun's known transient 503s", as opposed to a genuinely dead cron, which would show errors with no heartbeats at all). A new triage_summary() branch matches on `monitors_down == ["ddns-update — Freshness"]` (isolated), the core remote-access stack otherwise healthy, and clustering_pattern true — matched as ddns-freshness-porkbun-503-blip, blast_radius: none. 2. watchdog/playbooks/ddns-freshness-porkbun-503-blip.yaml — documents the same fingerprint for discoverability (the actual matching runs inline in snapshot.py, same pattern as the older crowdsec-bouncer-restart-blip triage branch). 3. Uptime Kuma monitor interval loosened for "ddns-update — Freshness" (bin/setup-uptime-kuma.py's tracked definition, plus the live monitor via edit_monitor()) from 900s (3 cycles) to give more room to absorb a short cluster without flapping, while staying well inside the ~30 min this write-up treats as the "actually worth looking at again" threshold. See the git history for the exact value chosen and bin/setup-uptime-kuma.py's comment for the reasoning.

Manual verification steps (if this recurs and you want to confirm it's this, not something else)

ssh proxmox-nuc "tail -n 60 /var/log/ddns-update.log"
Look for ERROR: retrieve failed lines interleaved with `Uptime Kuma freshness heartbeat sent` lines in the same window — that combination is this pattern, not a dead script. To directly confirm records are still correct (doesn't require waiting for a cron cycle):
ssh proxmox-nuc bash -s <<'EOF'
source /etc/ddns-porkbun.env
curl -sf --max-time 10 -H 'Content-Type: application/json' --data-binary @- \
  "https://api.porkbun.com/api/json/v3/dns/retrieve/compellinglylowbrow.org" <<PAYLOAD > /tmp/rr.json
{"apikey":"$PORKBUN_API_KEY","secretapikey":"$PORKBUN_API_SECRET_KEY"}
PAYLOAD
python3 -c "
import json
d=json.load(open('/tmp/rr.json'))
for r in d['records']:
    if r['type']=='A' and r['name'] in ('*.compellinglylowbrow.org','headscale.compellinglylowbrow.org','headplane.compellinglylowbrow.org'):
        print(r['name'], r['content'], 'ttl='+r['ttl'])
"
rm -f /tmp/rr.json
EOF
Compare against the current WAN IP (Porkbun's own ping endpoint, or any "what's my IP" check). If they match, there is no real problem — this is alert noise.

Correction (2026-08-12): the fix above was not actually live until this date

This entry's "Fix / current state" section was written 2026-08-11 in confident, past-tense, this-is-done voice, but only the interval change (item 3) had actually taken effect on the live monitor. Items 1 and 2 -- collect_ddns_freshness() in watchdog/snapshot.py and watchdog/playbooks/ddns-freshness-porkbun-503-blip.yaml -- were committed to git the same day but never pulled onto watchdog's own ~/homelab clone (nothing auto-pulls there), so the daemon kept booting with `Loaded 2 playbook(s)`, missing this one, straight through 2026-08-10/11/12's repeat flapping. Worse, once actually deployed on 2026-08-12, a second, deeper bug surfaced: watchdog/daemon.py's alerting code never consulted triage_summary()'s computed match at all (a pre-existing gap, not introduced by this fix -- it silently affected crowdsec-bouncer-restart-blip the same way since 2026-07-19). Both are now fixed; full diagnosis: known-fixes/watchdog-triage-playbook-id-never-consulted.md. Lesson for the next known-fix writeup that says "current state: fixed": that phrase should mean "confirmed live on the host that runs it," not "the commit exists on main." A git log HEAD..origin/main check against the actual runtime host is the cheap way to tell the difference.

Only escalate for real if

The monitor stays DOWN continuously for longer than ~30 minutes (no successful heartbeat at all in that window), or ddns-update.log shows errors with no interleaved successful heartbeats — that's a genuinely dead cron/script, not this pattern, and should be diagnosed as a real failure (check crontab -l, systemctl status cron, and whether the script even starts: bash -x /usr/local/bin/ddns-update --dry-run).
DNS — Public DoH path / Admin surface monitors flap DOWN for ~1-2min with zero trace in any log — likely a sub-2-minute tailnet/WAN blip, not CrowdSec/Caddy/Headscale low
Uptime Kuma 'DNS — Public DoH path (expect 200)' and 'DNS — Admin surface blocked (expect 403)' go DOWN together with 'timeout of 48000ms exceeded', escalating the 'Critical' group monitor too, then both recover on their own within roughly a minute or two with no intervention • CrowdSec metrics (cscli metrics show appsec), watchdog's docker stats/top, Caddy's systemd status, Caddy's own access.log, Headscale's request journal, and tailscaled's journal all show clean, uninterrupted, healthy activity through the exact same window • Caddy's access.log (JSON, /var/log/caddy/access.log) has a genuine gap of one full missed check cycle exactly during the down window on both affected site blocks — the request never arrived at Caddy at all; it wasn't logged as slow, blocked, or erroring
dns caddy crowdsec appsec watchdog headscale tailscale tailnet monitoring false-positive uptime-kuma alert-noise   last seen: 2026-07-21

Symptoms

  • Uptime Kuma 'DNS — Public DoH path (expect 200)' and 'DNS — Admin surface blocked (expect 403)' go DOWN together with 'timeout of 48000ms exceeded', escalating the 'Critical' group monitor too, then both recover on their own within roughly a minute or two with no intervention
  • CrowdSec metrics (cscli metrics show appsec), watchdog's docker stats/top, Caddy's systemd status, Caddy's own access.log, Headscale's request journal, and tailscaled's journal all show clean, uninterrupted, healthy activity through the exact same window
  • Caddy's access.log (JSON, /var/log/caddy/access.log) has a genuine gap of one full missed check cycle exactly during the down window on both affected site blocks — the request never arrived at Caddy at all; it wasn't logged as slow, blocked, or erroring
See also: tailscaled-stuck-derp-network-down, searxng-ufw-first-enable-lockout

Symptom

Two Uptime Kuma monitors that check dns.compellinglylowbrow.org from watchdog (Pi5) — one hitting /dns-query expecting a 200 DoH response, one hitting / expecting a 403 (confirming the admin surface stays blocked) — went DOWN together with timeout of 48000ms exceeded, dragging down the parent "Critical" group monitor with them. Both recovered on their own well within the time it took to start investigating. This is apparently a recurring, low-frequency pattern (multiple flaps per day per the person reporting it), not a one-off.

Why this needed a full investigation instead of a quick glance

AppSec/WAF (docs/security-crowdsec-appsec-plan.md) had gone live on these exact two public site blocks (headscale, dns) the day before this was first reported — a strong prior for "the thing that just changed is the thing that broke," per this repo's own established pattern (see CLAUDE.md's "seventh failure mode" note and known-fixes/searxng-ufw-first-enable-lockout.md). That prior did not hold up once actually checked against evidence — worth recording specifically *because* it was a reasonable first guess that turned out to be wrong, so a future session doesn't have to re-walk the same path.

Elimination process (all checked against the actual incident window, not just "looks fine now")

1. watchdog resource load (docker stats, top) — idle, load average under 0.5, plenty of free memory. AppSec's Docker container was not under any CPU/memory pressure. 2. CrowdSec AppSec metrics (cscli metrics show appsec) — 1.98k processed, 1 blocked (a virtual-patching hit, in-band as designed, one single event unrelated to this flap). No error/timeout counters. 3. Direct latency test, caddy LXC → watchdog:7422 (the AppSec listener) — 17ms round trip. Not a bottleneck. 4. CrowdSec container logs on watchdog, scoped precisely to 2026-07-21T08:15:00Z08:25:00Z (the actual incident window, not "recent tail" — docker logs --since/--until needs a fully-qualified RFC3339 timestamp with a Z/offset or it silently ignores the bound and returns the tail instead) — a perfectly normal, gapless stream of /v1/decisions/stream, /v1/heartbeat, and /v1/watchers/login calls straight through 08:20:27, no restart, no slow call, nothing. 5. Caddy's own processsystemctl status caddy showed it running continuously since the day before the incident (no restart), memory nowhere near its 1GB ceiling. 6. Caddy LXC resource RRD data (`pvesh get .../rrddata --timeframe day`, filtered to the actual incident timestamp in Python rather than eyeballing a huge unsorted table) — CPU/network/disk all unremarkable at 08:20. 7. Caddy's real access log (/var/log/caddy/access.log, JSON, configured via the global log {} block added 2026-07-18 — NOT journald; a global log {} block does not auto-enable per-site logging, each site needs its own bare log directive, and only headscale/dns have one). Filtered by actual Unix-epoch range (ts is a raw float, not an ISO string — a text grep for a date pattern will false-positive-match the human-readable Date: response header from a *different day*, which is exactly what happened on the first attempt here) — confirmed a genuine ~119-second gap in logged requests to dns.compellinglylowbrow.org spanning 08:19:27–08:21:26, i.e. one full missed check cycle exactly where Uptime Kuma's DOWN alert fired. **This is the key finding: the request never reached Caddy at all.** If Caddy had received it and been slow, or CrowdSec had blocked it, that would show up as a logged entry with a long duration or a non-200/403 status. Neither appeared — just silence. 8. Headscale's own request journal, scoped to the window — routine, healthy /health and /machine/map polling from the usual sources (adguard, watchdog, developer-env), nothing abnormal. 9. tailscaled's journal on watchdog, same window — only routine disco/netmap churn (peer re-keying, exit-node suggestion refresh), no error, no reconnect event, no DERP fallback. 10. Live tailnet path checktailscale ping 100.64.0.4 from watchdog: 2ms, direct connection (active; direct 192.168.42.45:41641), not relayed. Ruled out the persistent-wedge pattern documented in known-fixes/tailscaled-stuck-derp-network-down.md — that failure mode leaves an explicit network down/no preferred DERP signature in tailscaled's own log; this window had none.

Conclusion

Every layer that produces a log or a queryable metric — CrowdSec/AppSec, Caddy's process and access log, the caddy LXC's resources, Headscale, and tailscaled — is confirmed clean and uninterrupted through the exact incident window. The only remaining candidate is a genuine, sub-2-minute packet-loss or transient-routing blip somewhere between watchdog and Caddy over the tailnet (or the WAN link underneath it) that resolved before either endpoint's own health-tracking ever considered anything wrong — the kind of event that, by nature, doesn't leave a log entry anywhere, because nothing on either side failed hard enough to log an error. **This is a case where "no further evidence to pull" is a legitimate stopping point**, not giving up early — every layer with a log has been checked against the actual timestamp, not just glanced at after the fact.

Remediation — not a config bug, an alerting-threshold tuning problem

Nothing here needs fixing at the CrowdSec/Caddy/Headscale/tailnet level. The two Uptime Kuma checks run on a ~60s interval; the gap seen was exactly one missed cycle, self-healed by the very next one. **Setting Retries: 1** on these two specific monitors (requiring two consecutive failures, ~2 minutes, before declaring DOWN and alerting) would have absorbed this exact pattern silently, while still catching any real sustained outage within ~2 minutes instead of ~1. Recommended as a one-off UI change in Uptime Kuma for now (not yet applied as of this writing — pending confirmation from MOS). **Once applied, also back-fill into bin/setup-uptime-kuma.py** — the script currently creates these two monitors without a retry count, so a future setup-uptime-kuma.py re-run would recreate them without this setting unless the script is updated. Worth doing next time that script is touched for any other reason (same "fix it properly when the file is next opened" pattern used elsewhere in this repo, e.g. the caddy_extra/dns.compellinglylowbrow.org gap noted in known-fixes/adguard-doh-public-encrypted-dns.md).

Diagnostic tooling notes worth keeping (mistakes made and fixed mid-investigation)

- docker logs --since/--until requires a fully RFC3339-qualified timestamp (trailing Z or offset) — a bare 2026-07-21T08:15:00 without one is silently ignored, returning the recent tail instead of erroring, which looks like "nothing happened in that window" when really the window was never applied. - Caddy's access log timestamps are raw Unix-epoch floats (ts), not ISO strings — filter by actual epoch range in code, not by grepping for a human-readable date substring. A date-pattern grep can and did false-positive-match an unrelated Date: response header from a different day. - pvesh get .../rrddata requires --timeframe (not optional) and returns rows that are not necessarily in chronological order when rendered as a table in a terminal — filter/sort programmatically (e.g. pipe --output-format json through a small Python filter on time) rather than scanning a large table by eye, especially over SSH where output can also get truncated mid-scroll. - A global log {} block in Caddy configures *where* the default logger writes, not *which* sites use it — each site block needs its own bare log directive. Confirmed only headscale and dns have one in this Caddyfile; nothing else is logged at the request level, which is a real visibility gap worth knowing about for any other service someday needing the same kind of retroactive incident forensics done here.
Envoy's 'today' energy sensor exactly matches lifetime production after a multi-day integration outage -- daily reset didn't fire, not a doubling bug low
HA's Power dashboard (or the raw sensor.envoy_..._energy_production_today entity) shows an implausibly large 'today' figure -- thousands of kWh for a residential system • sensor.envoy_..._energy_production_today and sensor.envoy_..._lifetime_energy_production report the identical value (to 3+ decimal places) • The person independently notices HA's number doesn't match the Envoy's own app/local UI for the same day
home-assistant enphase_envoy energy-dashboard mcp   last seen: 2026-08-26

Symptoms

  • HA's Power dashboard (or the raw sensor.envoy_..._energy_production_today entity) shows an implausibly large 'today' figure -- thousands of kWh for a residential system
  • sensor.envoy_..._energy_production_today and sensor.envoy_..._lifetime_energy_production report the identical value (to 3+ decimal places)
  • The person independently notices HA's number doesn't match the Envoy's own app/local UI for the same day
  • history shows the 'today' sensor jumping straight from unavailable to a large non-zero value the moment the integration reconnects, rather than starting near zero
See also: interim-trusted-vlan-wifi-devices-need-per-service-servers-rules

Context

Found the same night enphase_envoy was fixed after 5+ days stuck in SETUP_IN_PROGRESS (see known-fixes/interim-trusted-vlan-wifi-devices-need-per-service-servers-rules.md). MOS separately noticed HA showing roughly double what the Envoy's own app reported for the day and asked for a sanity check.

Root cause

sensor.envoy_482534013828_energy_production_today is a total_increasing sensor whose daily reset comes from the Envoy device/integration, not from HA itself. After the multi-day outage, the moment the integration reconnected the sensor's history showed it jump straight from unavailable to 2826.099 kWh — not a small "today so far" number. Cross-checked against sensor.envoy_..._lifetime_energy_production at the same moment: identical to 3 decimal places (2831.145 / 2.831145 MWh a couple hours later, still tracking in lockstep). The "today" counter is currently just mirroring lifetime production, not resetting at local midnight — almost certainly because the extended outage/reconnect left the Envoy's or the integration's day-boundary bookkeeping in a bad state. Not a doubling bug in the traditional sense — nothing is being counted twice in any dashboard or sum (see the sibling finding same night: HA's actual Energy dashboard Solar Panels source list only references this one entity, correctly, no duplicate source). The "2x vs. the Envoy's own app" MOS observed lines up with this: the app shows a real, small today's-total while HA's stuck-at-lifetime sensor shows a much larger, unrelated number that happens to look roughly proportioned like a doubling at a glance. The *increments* on top of the frozen base are legitimate — production continued accruing normally after reconnect (~1.8kW instantaneous, ~5kWh over the following ~2 hours), it's only the starting base value that's wrong.

Diagnosis

# Compare the two directly -- if they match, the reset didn't fire
homeassistant.get_state sensor.envoy_<id>_energy_production_today
homeassistant.get_state sensor.envoy_<id>_lifetime_energy_production

Confirm the jump-on-reconnect signature

homeassistant.get_history sensor.envoy_<id>_energy_production_today # look for: unavailable -> large non-zero value, not unavailable -> ~0

Fix

None applied — expected to self-correct at the next local-midnight rollover now that the connection is stable. Flagged to MOS to come back if tomorrow's figure is still wrong, since a fix that doesn't self-resolve after one clean day would point at something more persistent (integration bug, Envoy firmware state) rather than a one-off outage artifact.

Update 2026-08-26 -- confirmed persistent, not a one-off outage artifact

MOS reported HA's solar numbers still "way off" vs. the Enlighten app the next day. Checked live: - **The local-midnight rollover (07:00 UTC / America/Los_Angeles) never fired.** Pulled energy_production_today's full history across the 2026-08-25→26 boundary: the value climbed continuously and monotonically straight through local midnight with no reset (2835.18 -> 2835.215, crossing 07:00 UTC mid-climb, no dip to zero, no gap-then-restart-near- zero signature). Still exactly mirroring lifetime_energy_production to 6 significant figures as of this check: 2838.221 kWh / 2.838221 MWh. - A real HA restart also didn't clear it. The error log shows a full HA restart ~2026-08-25 22:04-22:05 (Ended unfinished session, "waiting for integrations to complete setup: enphase_envoy... 43s"). The today sensor resumed after that restart at the *same* frozen value it had before, not a fresh baseline. - **Reloading just the enphase_envoy config entry (homeassistant.reload_config_entry, entry_id 01KR2WS2ZZ5BQW5ZHVN8GHH64N) also didn't clear it** -- state before and after the reload was continuous with no re-baseline, further narrowing this to the Envoy device's own local-API value, not an HA-side coordinator/cache artifact. HA is faithfully reporting what the Envoy itself is returning. - energy_production_last_seven_days is *also* mirroring lifetime (2838.221, identical to the other two) -- the "today" counter isn't the only stuck one; whatever period-boundary bookkeeping broke on the Envoy took more than just the daily rollover with it. - The actual numbers behind the Energy Dashboard look fine. Long-term statistics (get_statistics, period=day) on this sensor show real, sane per-day deltas via the sum field (~22-25 kWh/day in the days before the outage) -- the dashboard computes period totals from statistics deltas, not by reading the raw entity state, so it should NOT be showing the same giant wrong number. If what MOS is comparing against Enlighten is the Energy Dashboard's own daily tile, that's likely correct; if it's a raw entity/gauge card reading sensor.envoy_..._energy_production_today directly, that one is genuinely wrong (~2838 kWh vs. Enlighten's real ~20-25 kWh/day) -- worth confirming with MOS exactly which number he's looking at. - Root cause is now believed to be on the Envoy device itself (its own local system clock/day-boundary bookkeeping, not HA), most likely its clock/NTP sync -- plausible given the Envoy (192.168.22.2) only just migrated onto the IoT VLAN days before the original multi-day outage (see known-fixes/iot-vlan-wireless-client-migration-via-network-override.md), and this repo has repeatedly hit IoT-VLAN devices missing a specific egress firewall rule for their own protocol this same week (WiiM, IPP, Brother -- see known-fixes/interim-trusted-vlan-wifi-devices-need-per-service-servers-rules.md). NTP (UDP 123) egress from the IoT VLAN was never specifically checked. - **Could not reach the Envoy directly from developer-env to check its local diagnostics/system-time page** -- curl to 192.168.22.2 (both :80 and :443) hangs to a full timeout rather than refusing, the same silent-hang signature CLAUDE.md documents for missing ACL/firewall coverage, not a dead device (HA's own local-API polling of it is clearly working fine). Needs either a firewall rule opened for developer-env (or whatever zone MOS uses) -> IoT Envoy, or MOS checking its local web UI directly from a device that's already allowed to reach the IoT VLAN.

Update 2026-08-26 (later same day) -- Energy Dashboard tile was fine, raw entity still confirmed broken

MOS initially thought this had resolved itself after a browser hard-reload (cmd-shift-R) made the Energy Dashboard's "today" tile show a sane number (~3.15 kWh, close to Enlighten's ~3.3 kWh). Re-checked the raw entity directly via the API at that same moment (not through the browser): energy_production_today = 2838.552 kWh, still exactly mirroring lifetime, still climbing in lockstep. So the two are dissociated exactly as predicted above -- the Energy Dashboard tile (built from long-term stats deltas) was never actually wrong, and the browser refresh just cleared a stale/stuck frontend render of that tile. The raw _today / _last_seven_days entities are still broken as of this check and would still mislead if used directly (a gauge card, an automation, etc.). Agreed next step, not yet attempted (MOS was off-LAN): 1. Try first -- power-cycle the Envoy itself (Utilities -> Restart Device in its local UI if reachable, or pull physical power ~30s otherwise). This exact stuck-counter-after-outage symptom has a well-known community workaround of a full device reboot forcing a recompute, independent of confirming the clock/NTP theory below. 2. If that doesn't clear it, check the Envoy's own system time/NTP from a device that can actually reach the IoT VLAN (developer-env cannot -- 192.168.22.2 times out, same silent-hang firewall-gap signature as other recent IoT VLAN incidents this week). Browse to https://192.168.22.2 or http://envoy.local; newer IQ Gateway models may require an installer login/local token MOS may not have on hand, in which case the reboot is the more realistic fix.

Status

open, blocked on physical/local-network access -- MOS to retry from LAN later 2026-08-26.
git checkout -- <file> discards ALL uncommitted changes, not just the ones you just made -- lost ~28 lines of pre-existing collected/update-log.txt drift this way low
A file had uncommitted local changes (e.g. collect-homelab drift) before Claude touched it this session; Claude made its own edit, decided to undo it via `git checkout -- <file>` or `git restore -- <file>`, and the pre-existing changes vanished along with Claude's own edit • git status shows the file as clean immediately after, with no record of what the pre-existing diff contained
git process mistake data-loss collect-homelab working-practice   last seen: 2026-08-12

Symptoms

  • A file had uncommitted local changes (e.g. collect-homelab drift) before Claude touched it this session; Claude made its own edit, decided to undo it via `git checkout -- <file>` or `git restore -- <file>`, and the pre-existing changes vanished along with Claude's own edit
  • git status shows the file as clean immediately after, with no record of what the pre-existing diff contained
See also: collect-homelab-clone-staleness-and-update-log-split, host-repo-clone-uncommitted-drift

What happened

While resolving an unrelated question ("should this watchdog-local update-log.txt diff be reconciled into the canonical repo?"), I appended content to developer-env's own collected/update-log.txt via the Edit tool, then found bin/collect-homelab's own comment explaining that this exact class of write was deliberately meant to stay local/unreconciled. I reverted with git checkout -- collected/update-log.txt to undo my change. **That command doesn't undo "my change" — it restores the file to exactly match HEAD, discarding *every* uncommitted modification, including whatever was already there before I touched anything.** This session's own starting git status (captured in the conversation's initial context) showed M collected/update-log.txt *before I did anything at all* — a pre-existing, uncommitted local diff, almost certainly a routine run-update/update-advisor "OK" check result written on developer-env between the last collect-homelab auto-commit and this session's start, waiting on the next 6-hourly cycle to be committed. The file was 853 lines at that point; HEAD (and the file after my checkout --) is 825. Roughly 28 lines — about 4 log entries, timestamped after the last commit's tail entry (2026-08-12 01:03:52 UTC, watchdog-os) — were discarded and are not recoverable: the content was never git added, so no blob exists for it anywhere in .git/objects, and git reflog only tracks ref/commit history, not working-tree edits. Practical impact: low. These are routine append-only audit-log entries ("service checked, result OK, no version change") — not state anything else reads back programmatically (confirmed while building the fix below: nothing in this codebase parses update-log.txt for "current state," only ever appends to or greps it for a human). The underlying real-world events already happened; only this specific textual record of their exact timestamps is gone. Still a real, avoidable mistake, and worth a permanent process fix rather than a one-off "sorry, it's minor."

Fix — the rule going forward

**Before running git checkout -- / git restore -- / git reset --hard on any file, check whether it already had uncommitted changes before this session's own edits** — git status at session start (or git diff immediately before discarding) tells you. If it did: - Never discard blind. git stash first (even a throwaway stash you intend to drop) so the pre-existing content is at least recoverable via git fsck --unreachable/git stash list for a while, rather than gone the instant the command runs. - Or, if you specifically need to undo *only* your own edit on top of pre-existing uncommitted content: manually diff/edit back to the prior state rather than reaching for a blanket discard command, which doesn't have a concept of "just my part of the diff." This is the same underlying lesson as known-fixes/host-repo-clone-uncommitted-drift.md and CLAUDE.md's "silent overwrite" precedent history (hosts-config.yaml, bin/wiki-server, a prior INFRASTRUCTURE.md partial-write) — a different mechanism, same family: an action that looks purely corrective can destroy real state that was never being tracked as "at risk" in the first place, because it wasn't part of what the current task was thinking about.

The actual fix for *this* file's underlying problem

Coincidentally, the real structural fix landed the same session anyway: collected/update-log.txt no longer accumulates uncommitted local drift on developer-env between collect-homelab cycles in the same way once other hosts stopped writing to the shared tracked path (see known-fixes/collect-homelab-clone-staleness-and-update-log-split.md) -- though that fix is about *cross-host* writers, not developer-env's own normal check-then-commit lag, so this specific race (an uncommitted local append sitting there when a session starts) can still happen on developer-env itself. The process rule above is the actual mitigation.
Git conflict on config.yaml (seeder-daemon) low
git pull conflicts on config.yaml on seeder-daemon
git seeder-daemon config   last seen: 2026-06-03

Symptoms

  • git pull conflicts on config.yaml on seeder-daemon

Fix

Always keep the production version — it has live credentials.
git checkout --ours config.yaml
git add config.yaml
git commit -m "Keep production config.yaml"
Guest_GDTRFB WLAN wouldn't save a WPA passphrase -- UI accepted input but ace.wlanconf still showed security:open, no passphrase field at all low
Editing Guest_GDTRFB's Security setting from Open to WPA2/3-Personal and entering a passphrase in the UniFi Network app appeared to work (no visible error), but reopening the network's settings kept showing it as unconfigured • Confirmed via direct query against the gateway's own config database (mongo, port 27117, db 'ace', collection 'wlanconf') that the live record still showed "security": "open" with no x_passphrase field present at all, immediately after the UI edit -- this is not a stale-UI-display issue, the change never reached the database • The same session successfully saved a passphrase on a different new WLAN (IoT_GDTRFB) using what appeared to be the identical UI flow -- so this isn't a universal bug in passphrase-saving, something specific to this network/session/timing didn't take
unifi wifi guest wpa ucg-fiber wlanconf persistence open-issue   last seen:

Symptoms

  • Editing Guest_GDTRFB's Security setting from Open to WPA2/3-Personal and entering a passphrase in the UniFi Network app appeared to work (no visible error), but reopening the network's settings kept showing it as unconfigured
  • Confirmed via direct query against the gateway's own config database (mongo, port 27117, db 'ace', collection 'wlanconf') that the live record still showed "security": "open" with no x_passphrase field present at all, immediately after the UI edit -- this is not a stale-UI-display issue, the change never reached the database
  • The same session successfully saved a passphrase on a different new WLAN (IoT_GDTRFB) using what appeared to be the identical UI flow -- so this isn't a universal bug in passphrase-saving, something specific to this network/session/timing didn't take
See also: unifi-enhanced-iot-blocks-iphone-handshake

Status: worked around by plain retry, root cause never confirmed

Update, later the same night: recreated Guest_GDTRFB from scratch, same steps, no special workaround applied -- it saved correctly on the first attempt, confirmed via the same ace.wlanconf query (`security: "wpapsk", x_passphrase present, is_guest/l2_isolation` both still true). This supports the "console under heavy edit load" theory below over anything specific to Guest networks, but doesn't prove it -- treat as worked around, not root-caused. If it recurs, the diagnostic trail below still applies. Originally not root-caused. The SSID was deleted rather than debugged further at the time, since it was brand new (no real traffic depended on it) and leaving an open/unsecured SSID broadcasting overnight while chasing a UI bug wasn't worth it. The underlying Guest network/VLAN itself (br60, 192.168.62.0/24, DHCP scope, firewall zone/isolation) was already independently proven working via Phase E's throwaway-device test and a live isolation check the same night this bug surfaced -- only the WiFi SSID object and its passphrase field are implicated.

What's confirmed, for whoever picks this back up

- The console had been under unusually heavy load/edit churn for over an hour by the time this was attempted (Trusted network reassignment, a new IoT SSID, a new firewall rule, several page reloads following the Trusted-VLAN-can't-reach-UI incident earlier the same session) -- plausible but unconfirmed contributing factor. - Verification method worth reusing directly rather than trusting the UI: mongo --port 27117 ace --eval 'db.wlanconf.find({name: //}).forEach(printjson)' run on the gateway itself shows the actual persisted config, including security, x_passphrase, and is_guest/l2_isolation -- bypasses any UI rendering/caching question entirely.

If this recurs

1. Try from a different browser / a hard-refreshed session before assuming it's the same bug -- rule out ordinary UI staleness first. 2. Re-run the mongo query above immediately after the save attempt to confirm whether it's a true persistence failure (matches this incident) or just a display issue. 3. If it's a true persistence failure again, consider whether it's specific to editing a is_guest: true network's security settings (the one common factor here) vs. the general WLAN-creation flow, which worked fine for IoT_GDTRFB the same session.
Deleting a node from Headscale CLI low
need to remove a node from headscale • -f flag not valid
headscale node-management   last seen: 2026-06-03

Symptoms

  • need to remove a node from headscale
  • -f flag not valid

Cause

-f is not a valid shorthand — use --force.

Fix

# List nodes to get the numeric ID
ssh root@192.168.42.177 "headscale nodes list"

Delete by ID (--force skips confirmation prompt)

ssh root@192.168.42.177 "headscale nodes delete --identifier <ID> --force"
Alternatively, delete via the Headplane UI at headplane.compellinglylowbrow.org.
Node hostname shows as invalid-<random> in Headscale low
headscale nodes list shows invalid-sonq4yxk • hostname is random string
headscale node-management   last seen: 2026-06-03

Symptoms

  • headscale nodes list shows invalid-sonq4yxk
  • hostname is random string

Cause

Headscale rejected the hostname (invalid characters or too long) and generated a random fallback. The givenName field (shown as Name) is what matters for routing — Hostname is cosmetic.

Impact

None for functionality. scan-containers matches on givenName, not Hostname.

Note

In v0.29+, the random-suffix fallback (invalid-sonq4yxk) was replaced with a numeric suffix (node, node-1, node-2). MagicDNS names change on upgrade for nodes that previously had random-suffix hostnames.
headscale preauthkeys create fails with user error low
preauthkeys create fails • key created under wrong namespace
headscale preauthkey   last seen: 2026-06-03

Symptoms

  • preauthkeys create fails
  • key created under wrong namespace

Cause

Newer Headscale versions require --user (numeric), not --user .

Fix

headscale users list          # get the numeric ID
headscale preauthkeys create --user <ID> --reusable --expiration 1h
health-check silently died mid-run (rc=1, no summary printed) under load -- an unguarded dig|grep pipeline tripped the script's own set -e, not a real SSH/DNS failure low
bin/health-check (or anything that shells out to it, e.g. daily-fleet-digest) returns exit code 1 with no PASS/WARN/FAIL summary line in its output at all • Looks like 'SSH flakiness' or a real infra FAIL at first glance, but re-running the same check moments later passes cleanly with no FAIL anywhere • More likely to reproduce when health-check runs immediately after a burst of other SSH activity from the same host (e.g. daily-fleet-digest's 19-host apt-get-update sweep right before it)
health-check bash set-e pipefail dig dns daily-fleet-digest   last seen: 2026-08-14

Symptoms

  • bin/health-check (or anything that shells out to it, e.g. daily-fleet-digest) returns exit code 1 with no PASS/WARN/FAIL summary line in its output at all
  • Looks like 'SSH flakiness' or a real infra FAIL at first glance, but re-running the same check moments later passes cleanly with no FAIL anywhere
  • More likely to reproduce when health-check runs immediately after a burst of other SSH activity from the same host (e.g. daily-fleet-digest's 19-host apt-get-update sweep right before it)
See also: notification-consolidation-two-tier-digest

Symptom

While testing the notification-consolidation work (see the related entry), daily-fleet-digest's full pipeline produced one run where run_health_check() returned rc=1 ("ACTION NEEDED") with the body (summary line not found in health-check output). Standalone reruns of bin/health-check immediately after, and every rerun since, returned the correct rc=2 (one pre-existing WARN, no FAIL). MOS's first instinct was right to be suspicious of "SSH flakiness" specifically because health-check's core infra checks (CADDY_IP/ADGUARD_IP/ADGUARD2_IP/ HEADSCALE_IP) are all LAN IPs, never Headscale/tailscale addresses -- which ruled out the usual tunnel-flakiness explanation and pointed at something else.

Root cause

Not SSH at all -- bin/health-check has set -euo pipefail at the top, and one function, dns_query() (Section 2, AdGuard DNS rewrites), had an unguarded pipeline:
dns_query() {
    local resolver="$1" name="$2"
    dig +short +time=3 +tries=1 "@${resolver}" "${name}" 2>/dev/null \
        | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -1
}
+tries=1 +time=3 is a single DNS query with a 3-second window -- tight by design (this runs 7 times per health-check invocation, and slow DNS checks make a "60-second triage" tool not actually 60 seconds). If dig returns nothing in that window (a real transient stall -- plausible right after a burst of other network activity from the same host, though not conclusively reproduced from that specific cause), grep finds no matching line and exits 1. With pipefail active, the pipeline's exit status becomes 1 (the last non-zero exit among the three commands, even though head -1 itself succeeds). None of dns_query()'s 7 call sites (WILDCARD_RESULT, WILDCARD_RESULT2, HS_RESULT, HS_RESULT2, AG_RESULT, AG2_RESULT, AG2_SELF) guarded the assignment with || true -- so under set -e, a failing $(dns_query ...) assignment killed the entire script on the spot, before it ever reached the final PASS/WARN/FAIL tally or printed the summary line. The script's own exit code (1) then propagated up as run_health_check()'s hc_rc. Every *other* command substitution in this file already guards against exactly this (see caddy_curl()'s own inline comment, which documents an earlier, related incident and fixes it with || true + ${code:-000}-style string fallback) -- dns_query() was simply missed when that pattern was established.

Reproduction

dns_query_old() {
    local resolver="$1" name="$2"
    dig +short +time=1 +tries=1 "@${resolver}" "${name}" 2>/dev/null \
        | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -1
}

192.0.2.1 = TEST-NET-1 (RFC 5737), guaranteed unreachable

RESULT=$(dns_query_old "192.0.2.1" "example.com") echo "got here?!" # never printed -- set -e kills the script first
Confirmed the unguarded version dies silently (exit 1, no further output) and the || true-guarded version survives, returns an empty string, and lets the caller's existing [[ -z "$RESULT" ]] branch correctly report it as a real FAIL -- which is what should have been happening for a genuine DNS non-answer all along.

Fix

Added || true to dns_query()'s own pipeline (one fix point, covers all 7 call sites) and to the same-shape latent risk in CADDY_ERR's grep pipeline (Section 3, only reachable on an already-failed caddy validate, lower exposure but identical mechanism). A transient no-answer is supposed to be *data* the existing empty-string checks already handle correctly -- not a reason for the whole triage script to die before it can report anything.

Lesson

A set -e/pipefail bash script needs || true (or equivalent) on *every* command substitution whose failure is a normal, expected outcome of the check it's performing -- not just the ones that happened to get noticed. grep finding no match is exactly this shape and is easy to miss because it "usually" succeeds; audit for it by grepping the script itself for $(...) assignments and checking each one has a guard, the way this file already inconsistently demonstrated (3 of 4 similar patterns were guarded, one wasn't).
HA Companion app has no internal/external URL auto-switching (single URL field), and Home Assistant's own Headscale IP doesn't work at all -- only the Caddy-proxied FQDN does low
Setting homeassistant: internal_url/external_url in configuration.yaml had no effect on which URL the iOS Companion app actually used • No 'Home network SSID' or equivalent internal-network-detection setting exists anywhere in the app's Settings -> Companion App screens • App configured with HA's own Headscale IP directly (100.64.0.110:8123) never worked, with or without Tailscale connected -- confirmed unreachable (HTTP 000 / no connection) from a completely different, working tailnet host (developer-env), ruling out anything phone-specific
homeassistant tailscale headscale ios companion-app dns caddy internal-url external-url   last seen: 2026-08-24

Symptoms

  • Setting homeassistant: internal_url/external_url in configuration.yaml had no effect on which URL the iOS Companion app actually used
  • No 'Home network SSID' or equivalent internal-network-detection setting exists anywhere in the app's Settings -> Companion App screens
  • App configured with HA's own Headscale IP directly (100.64.0.110:8123) never worked, with or without Tailscale connected -- confirmed unreachable (HTTP 000 / no connection) from a completely different, working tailnet host (developer-env), ruling out anything phone-specific
  • App works instantly once pointed at the FQDN (https://homeassistant.compellinglylowbrow.org) instead -- confirmed HTTP 200 from the same test host
See also: iphone-tailscale-disconnected-leaks-headscale-traffic-to-wan

Symptom

Follow-up from known-fixes/iphone-tailscale-disconnected-leaks-headscale-traffic-to-wan.md: as part of reducing the HA Companion app's dependency on Tailscale being up while on the home network, homeassistant: internal_url / external_url were added to configuration.yaml (previously both null). The expectation, based on how the HA web frontend has historically worked, was that the Companion app would auto-detect being on the home network and prefer the LAN URL. That never happened, and chasing why revealed two separate, unrelated things going on.

Finding 1: this app version has no internal/external auto-switching

Multiple UI locations were guessed from memory across this investigation (Settings -> System -> Network in the HA web UI, `Settings -> Companion App -> General -> Home network SSID` in the iOS app) and neither existed as described. Confirmed directly on MOS's screen: the iOS Companion app has exactly one editable server URL field, no SSID/network-detection setting anywhere in its Settings. Whatever single URL is configured there is what the app always uses -- setting internal_url/external_url server-side has no effect on this app's behavior. This isn't a setting that was missed; the capability isn't present in the installed app version. The internal_url/external_url YAML addition wasn't wasted -- other consumers (notifications, share links, other integrations) can still use it -- but it doesn't drive the Companion app's connection target on its own.

Finding 2: Home Assistant's own Headscale IP doesn't work, at all

Independent of the above: MOS tried configuring the app with HA's own Headscale IP directly (100.64.0.110:8123, no Caddy involved) with Tailscale connected. It didn't work. This looked at first like it might be a config regression from the YAML change, but testing the same address from a completely different tailnet host (developer-env) settled it:
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 6 http://100.64.0.110:8123/

HTTP 000 -- no connection at all, from anywhere on the tailnet

curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 6 -k https://homeassistant.compellinglylowbrow.org/

HTTP 200 -- works instantly

100.64.0.110:8123 has never been a working path, independent of the phone, independent of configuration.yaml. HAOS's Tailscale integration/add-on registers the node and gives it a Headscale IP, but nothing proxies HA Core's own port 8123 onto that tailscale interface -- Core runs in its own container network namespace (ha core info shows ip_address: 172.30.32.1, not a tailscale address), separate from whatever network namespace the Tailscale integration lives in. This is unlike the SSH/admin-access pattern used elsewhere in this homelab, where a host's own Headscale IP genuinely is the direct path (e.g. ssh nastynas over 100.64.0.113) -- for Home Assistant's *web UI* specifically, the only working remote path is the same one every other service in this homelab uses: *.compellinglylowbrow.org through Caddy's reverse proxy (100.64.0.4), never the service's own Headscale IP.

Fix

Point the app at the FQDN: https://homeassistant.compellinglylowbrow.org. This works both on the home network and remotely, as long as Tailscale is connected when remote -- the same dependency every other *.compellinglylowbrow.org service already has (see known-fixes/iphone-tailscale-disconnected-leaks-headscale-traffic-to-wan.md for what happens when Tailscale itself is down). There is currently no way to make this app prefer the LAN path automatically while home; the only options are living with the Tailscale dependency (chosen here) or manually flipping the app's URL field depending on location (not worth the hassle for this benefit).

Prevention

- **Don't guess a specific app or web UI's current menu layout from memory across multiple attempts.** This investigation burned several exchanges on wrong UI paths (HA's own web Network settings, the Companion app's non-existent SSID field) before just asking what was actually on screen. Frontend layouts for actively-developed apps (Home Assistant Core, its Companion apps) drift version to version -- verify against the live version or ask directly rather than repeat the same kind of guess a second or third time. - **A per-service Headscale IP is not automatically a working direct path to that service's actual application port.** It's reliable for SSH/admin access to a host, and for cases where a service explicitly binds to it, but not to be assumed for an application's own web server unless verified -- especially through a HAOS-style integration/add-on/container boundary, where a Tailscale node's presence on the tailnet doesn't imply anything about what's reachable on it. Test with curl/nc from another tailnet host before configuring a client to depend on it.
Two Home Assistant integrations registering an HTTP view at the same path -- one silently wins, no error anywhere, the other is simply unreachable low
A newly-installed/configured HA custom integration (via HACS) appears healthy in Settings -> Devices & Services -- entry exists, enabled, no error state -- but requests to its documented endpoint don't reach its code • The endpoint responds (even authenticates correctly), but with behavior/tool set/serverInfo belonging to a *different*, earlier-registered integration instead • No error in the new integration's own startup, no exception, no warning -- ha core logs / home-assistant.log show nothing (log access itself may also be limited depending on how the host is reached)
homeassistant mcp hacs custom-component http view-registration route-collision   last seen: 2026-08-25

Symptoms

  • A newly-installed/configured HA custom integration (via HACS) appears healthy in Settings -> Devices & Services -- entry exists, enabled, no error state -- but requests to its documented endpoint don't reach its code
  • The endpoint responds (even authenticates correctly), but with behavior/tool set/serverInfo belonging to a *different*, earlier-registered integration instead
  • No error in the new integration's own startup, no exception, no warning -- ha core logs / home-assistant.log show nothing (log access itself may also be limited depending on how the host is reached)

Symptom

Installed ganhammar/hass-mcp-server (HACS custom component) alongside HA's existing official mcp_server integration (already live since 2026-06-03, what Claude Desktop was already using). The new integration's config entry showed enabled with no errors, and its own log line even claimed "MCP Server initialized at /api/mcp" -- but a live probe against /api/mcp kept returning the *official* integration's behavior (tool names like HassTurnOn/GetLiveContext, serverInfo.name: "home-assistant") instead of the new one's (create_automation/create_scene, serverInfo.name: "home-assistant-mcp-server").

Root cause

Both integrations call hass.http.register_view() for the identical URL path, /api/mcp. Confirmed directly in ganhammar/hass-mcp-server's __init__.py -- a code comment there notes *"HA has no public register_view reverse — see #37"*, meaning Home Assistant provides no way to un-register an HTTP view once claimed. Whichever integration registers a given path first (here, the official one, live since June) keeps serving it -- a later integration's registration call for the same path either silently fails or is silently shadowed, with **no exception, no log entry visible via SSH, and no indication anywhere in the UI** that anything is wrong. The new integration's own "initialized at /api/mcp" log message is genuinely misleading here -- it logs its intent to serve that path, not confirmation that it actually won the registration.

Diagnosis

Don't trust the integration's own log line, its README, or general documentation about "how this should work" -- none of these reflect what's actually being served when two integrations collide like this. Test the live endpoint directly and inspect something implementation-specific in the response:
# initialize call -- compare serverInfo.name against what each

integration's own source code names itself as

curl -s -k -X POST https://<ha-host>/api/mcp \ -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer $TOKEN" \ -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0.1"}}}'

tools/list -- compare the actual tool names against each integration's

documented/source-code tool set

curl -s -k -X POST https://<ha-host>/api/mcp \ -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer $TOKEN" \ -d '{"jsonrpc":"2.0","method":"tools/list","id":2}'
If unsure which of two similar-looking integrations is actually registered first, grep the losing integration's own __init__.py/source in /config/custom_components// for its register_view calls and any comment about registration order or conflicts -- the answer is often already acknowledged there, as it was here.

Fix

Disable the integration that registered first (here: HA's official mcp_server integration, Settings -> Devices & Services -> the entry -> Disable), then restart Core. The later integration's register_view call then succeeds against a clear path. Bonus finding: if both integrations share the same URL *and* the same auth mechanism (here: HA long-lived access tokens, valid against either implementation since both just validate against HA's normal user auth), disabling the first one requires **no client-side reconfiguration at all** -- any existing client (Claude Desktop, in this case) pointed at the shared URL just starts talking to the newly-unblocked integration transparently, with zero visible change on the client side.

Prevention

- **Before installing a second Home Assistant integration that claims to provide "the same kind of thing" as one you already have running, check whether they'd claim the same HTTP path** -- there's no HA-level protection against this, and the failure mode (silent, no error, looks like it's just not doing what it claims) is much harder to diagnose than a clean startup failure would be. - A live behavioral probe against the actual endpoint beats trusting any single source (the integration's own log message, its README, generic web documentation about how it "should" work) -- confirmed repeatedly this session on unrelated fronts too (see dont-guess-live-ui-paths-from-memory in Claude's own session memory) that live ground truth is the only reliable tiebreaker once two plausible sources disagree.
homelab-switch: bin/health-check always reports the web UI down even though it loads fine in a browser -- known single-connection embedded httpd limitation low
bin/health-check FAILs 'homelab-switch (192.168.42.35:80) — no response (backend down or wrong IP)' • curl to http://192.168.42.35/ from developer-env or from the Caddy LXC times out after the full --max-time window (connection never completes, not a fast refuse/reset) • curl through Caddy's reverse proxy (https://homelab-switch.compellinglylowbrow.org/) gets a 502 after Caddy's own backend dial timeout
network switch homelab-switch health-check uptime-kuma omada false-positive   last seen: 2026-08-25

Symptoms

  • bin/health-check FAILs 'homelab-switch (192.168.42.35:80) — no response (backend down or wrong IP)'
  • curl to http://192.168.42.35/ from developer-env or from the Caddy LXC times out after the full --max-time window (connection never completes, not a fast refuse/reset)
  • curl through Caddy's reverse proxy (https://homelab-switch.compellinglylowbrow.org/) gets a 502 after Caddy's own backend dial timeout
  • ping to 192.168.42.35 succeeds fine throughout -- only the web UI (TCP:80) is affected
  • MOS's own browser loads the switch's web UI without issue at the same time the automated check is failing
See also: homelab-switch-lost-management-ip-after-power-cycle, homelab-switch-vlan30-trunk-only-outage

Symptom

bin/health-check's backend-reachability section FAILs on homelab-switch (TP-Link Omada ES210X-M2, 192.168.42.35:80) essentially every run, with "no response". Confirmed live 2026-08-25: 6 consecutive curl attempts from developer-env, and a separate attempt from the Caddy LXC itself, all timed out completely rather than getting refused or erroring fast -- and going through Caddy's own reverse proxy (homelab-switch.compellinglylowbrow.org) returned a 502 after Caddy's backend dial gave up. ping to the same IP succeeded the entire time. MOS confirmed the web UI loaded fine in a browser at the same moment the check was reporting it down.

Root cause

Not investigated further than "this device's job (L2 forwarding) is unaffected and pings fine, but its embedded web UI is not reliably reachable from a cold, unauthenticated curl". The most likely explanation, consistent with known behavior of this class of TP-Link "Easy Smart" switch, is a lightweight embedded httpd that only accepts one session/connection at a time -- a second connection attempt (like this check's) just doesn't get accepted rather than being actively refused, which matches the observed "hangs for the full timeout" behavior exactly. This device has no SSH/API surface at all (inventory/hosts-config.yaml's collect.config_path: ~), so this can't be confirmed server-side; it's inferred from symptoms only. Whatever the exact mechanism, the practical point is: **this check only ever tests the web GUI's reachability, never the switch's actual job.** A down web UI here says nothing about whether the switch itself is healthy, and unlike every other backend in this check, "unreachable right now" does not reliably mean "down" for this specific device.

Fix

Not a fix for the switch (nothing to fix -- it's forwarding fine) -- a fix for the check's classification. bin/health-check's backend-reachability loop now has a small WARN_ONLY_BACKENDS allowlist; a "no response" result for a listed backend is downgraded from FAIL to WARN (still visible, but no longer trips exit 1 / "Action required" for something that isn't actually an outage). homelab-switch is the first (and so far only) entry. If this ever needs a *real* investigation (e.g. the browser stops loading it too, or ping starts failing), start with known-fixes/homelab-switch-lost-management-ip-after-power-cycle.md -- this switch has prior documented history of losing its management interface entirely after a power cycle, which is a genuinely different (and more serious) failure mode than this one.

Prevention

None needed beyond the classification fix above -- this is inherent device behavior, not a regression to prevent. If a *second* backend in this fleet turns out to have the same "known-unreliable web UI, real service is fine" pattern, add it to the same WARN_ONLY_BACKENDS list rather than special-casing it separately.
Migrating existing Wi-Fi IoT devices onto VLAN 20 via UniFi's per-client Virtual Network Override -- no second SSID needed, but four separate gotchas along the way low
A Wi-Fi client with Virtual Network Override enabled and a Fixed IP set reconnects, but lands back on its old network/IP rather than the new VLAN, then disconnects a short time later • A device never even attempts to reconnect again after one failed override attempt, despite repeated power cycles -- last_seen in ace.user stops updating entirely • A device associates briefly (a few seconds) and disconnects with no DHCP request logged at all, unlike a clean migration which shows a full DHCPNAK-old-IP / DHCPDISCOVER / DHCPOFFER-new-IP / DHCPACK sequence
network firewall vlan ucg-fiber unifi virtual-network-override iot home-assistant enphase_envoy chargepoint dhcp mongo   last seen: 2026-08-26

Symptoms

  • A Wi-Fi client with Virtual Network Override enabled and a Fixed IP set reconnects, but lands back on its old network/IP rather than the new VLAN, then disconnects a short time later
  • A device never even attempts to reconnect again after one failed override attempt, despite repeated power cycles -- last_seen in ace.user stops updating entirely
  • A device associates briefly (a few seconds) and disconnects with no DHCP request logged at all, unlike a clean migration which shows a full DHCPNAK-old-IP / DHCPDISCOVER / DHCPOFFER-new-IP / DHCPACK sequence
  • A Home Assistant integration for a device that just changed VLAN/IP goes to SETUP_ERROR or stays unavailable even though the new firewall rule is confirmed correct and the device is confirmed reachable by ping
See also: unifi-zone-rule-creation-gotchas, trusted-vlan-return-traffic-toggle-missing, interim-trusted-vlan-wifi-devices-need-per-service-servers-rules, raspi4-followed-wifi-ssid-onto-trusted-vlan

Context

docs/vlan-gateway-migration-plan.md originally assumed IoT-candidate Wi-Fi devices would need to join a dedicated IoT_GDTRFB SSID to land on VLAN 20. Turns out UniFi supports a per-client override instead: the device stays on the same SSID/password (GDTRFB) and gets pinned to a different VLAN by MAC. Confirmed live in ace.user -- every client record already carries virtual_network_override_enabled / virtual_network_override_id fields (paired with use_fixedip / fixed_ip), just unset until you use them. In the UniFi Network app this is the Virtual Network Override checkbox on a client's detail page. It comes with an UI warning about the VLAN needing to be tagged on all upstream switch ports -- doesn't apply to this homelab's wireless devices specifically, since the U7 Pro AP plugs directly into the gateway (ucg-fiber port 3, confirmed via port_overrides/ port_table), not through homelab-switch, and that port already carries forward: "all" (every VLAN tagged). If a future AP or wired IoT device *does* sit behind the Omada switch, check that switch's per-port VLAN tagging before assuming the warning is moot again.

Gotcha 1: reconnecting too fast races the override's own provisioning

Symptom: device reconnects, DHCP grants it its *old* IP on its *old* VLAN bridge (e.g. DHCPACK(br10) 192.168.12.184), then ~30-90 seconds later it gets disconnected with no action from you. Cause: saving the override in the UI doesn't apply instantly to the AP's client-isolation service. If the device reconnects (e.g. via a plain power cycle) before that finishes, it associates under the *old* rules, and the isolation service then retroactively enforces the new VLAN mid-session, killing the connection it doesn't recognize anymore. Diagnosis, on ucg-fiber:
journalctl --since '15 minutes ago' | grep -i '<device-mac>'
Look for the sequence: a dnsmasq-dhcp ACK on the *old* bridge, followed some time later by `ubios-udapi-server[...]: svc-client-isolation: [] new CFG: `. If the isolation line comes *after* the DHCP ACK, that's the race. Fix: just retry once the isolation line has already appeared -- it's a one-time provisioning delay, not a persistent problem. The second attempt typically lands cleanly (confirmed with envoy: a DHCPDISCOVER / DHCPOFFER(br20, new IP) / DHCPREQUEST / DHCPACK sequence with no bounce, once retried after the isolation config had already landed). MAC formatting gotcha found while grepping for this: some log producers on this gateway drop a leading zero in a MAC octet (e.g. 00:24:b1:0b:9b:f1 appears as 00:24:b1:b:9b:f1 in svc-client-isolation lines specifically, not in dnsmasq-dhcp lines). Grep on the last few octets or try both forms rather than assuming a single canonical format across log sources on this box.

Gotcha 2: editing an existing rule's IP without its zone reproduces the known zone-pair trap

If you're rebuilding an interim Servers -> Trusted firewall rule (see known-fixes/interim-trusted-vlan-wifi-devices-need-per-service-servers-rules.md) into Servers -> IoT by editing the existing rule rather than creating a fresh one, it's easy to update the destination IP and miss the destination zone dropdown -- a second occurrence of Trap 1 in known-fixes/unifi-zone-rule-creation-gotchas.md. Confirmed live: the rule's firewall_policy document had the right new IP but destination.zone_id still resolved to Trusted, so it sat in UBIOS_LAN_CUSTOM1_USER (Servers->Trusted) with an ipset containing the IoT-subnet IP -- inert, since traffic to that IP never physically traverses that chain. Verify both fields independently after any such edit, same as the original trap's prevention advice.

Gotcha 3: Home Assistant does not know a device's IP changed

Fixing the firewall rule is necessary but not sufficient if the device backs a local-polling HA integration (e.g. enphase_envoy). The integration's config entry stores the host IP from initial setup and keeps retrying *that* address indefinitely -- reloading the config entry just retries the same stored host, it doesn't re-discover anything. Confirmed via get_error_log: `ConnectionTimeoutError: ... to host https:///info` persisting well after the firewall rule was correct and the new IP was pinging fine from the gateway. Fix: Settings -> Devices & Services -> find the integration -> use its Reconfigure flow (not just Reload) to enter the new IP. Confirmed working for enphase_envoy: config entry went from SETUP_ERROR to LOADED immediately after, with entity states populating on the next poll. Not every integration needs this at all -- check first whether the integration is even local-IP-based before assuming a rule and a reconfigure are both required. chargepoint's HA integration authenticates via a cloud session (confirmed via its own log line: `Username used for discovery ... does not match session, using value from session`, and its title being an email address, not a device name) -- moving the charger's own VLAN has no effect on HA's connection to it at all, and no `Servers -> IoT` rule was needed for it.

Gotcha 4: some appliances need their own app's reconnect flow, not just a power cycle

Two different failure shapes seen, both needing manufacturer-app intervention rather than more power cycles: - Bosch dishwasher (Home Connect): after one failed override attempt (Gotcha 1's race), the appliance simply stopped trying to reconnect at all -- last_seen in ace.user froze completely, multiple power cycles produced zero new log lines. Only resolved by using the Home Connect app's own Wi-Fi reconnect flow, which re-entered the (unchanged) SSID password as part of a forced re-pair. Immediately after, the device did a full clean DHCP sequence and landed on IoT correctly, override having already been safely provisioned in the meantime. - ChargePoint EV charger: repeated power cycles produced a brief ~5-second association with no DHCP request logged at all (not even a request for the old IP) -- distinct from the dishwasher's "doesn't try at all" and from Gotcha 1's "gets a lease then kicked." Suspected to need the ChargePoint app's own setup/pairing flow to actually complete a WiFi handshake, but unconfirmed as of 2026-08-25 -- blocked by an unrelated ChargePoint app login failure on MOS's phone, itself outside this homelab's control (cloud account auth on ChargePoint's side). Lesson: if a device doesn't reconnect cleanly within one or two retries after the override is confirmed provisioned server-side, stop retrying blind power cycles and check whether the device's own companion app has a WiFi/network settings screen -- that's the more reliable path for consumer IoT/appliance hardware, and probably necessary in general for anything with a "Home Connect"/"connect to WiFi" style onboarding flow rather than a bare embedded WiFi radio.

Results as of 2026-08-25

| Device | New IP | Status | |---|---|---| | envoy | 192.168.22.2 | Done -- gateway + enphase_envoy HA integration both verified live | | bosch-dishwasher | 192.168.22.4 | Done -- gateway verified live (no HA integration for this device) | | charge point | 192.168.22.3 (override saved, not yet connected) | Blocked on ChargePoint app login; no firewall rule needed regardless (cloud integration) | Remaining IoT candidates not yet started: WiiM_Ultra-0934, Living-Room-TV, RokuStreamingStick, BROTHER-2370DW. Management VLAN (50) migration is still queued separately -- see [[iot-vlan-device-migration-paused]].

Update 2026-08-26

Two more migrated clean, both first-attempt, no Gotcha 1 race hit (isolation config landed before the reconnect both times): | Device | New IP | Status | |---|---|---| | Living-Room-TV | 192.168.22.5 | Done -- no HA integration, no firewall rule needed at all | | WiiM_Ultra-0934 | 192.168.22.7 | Done -- see below for the firewall-rule cleanup that went with it | charge point's block got root-caused, not just deferred: the charger's Bluetooth radio (used for initial WiFi association/handoff) is dead hardware, unrelated to the VLAN/network work entirely -- it was never going to reconnect regardless of app-login state. ChargePoint is sending a replacement unit; the existing override/fixed-IP (192.168.22.3) should still apply whenever the replacement gets provisioned. WiiM's firewall rules also needed cleanup as part of this migration, since it carried three now-dead interim rules from known-fixes/interim-trusted-vlan-wifi-devices-need-per-service-servers-rules.md (short_ids 19/20/22 -- Servers → Trusted WiiM API/UPnP/HTTP, all built for the WiiM HA integration MOS has since dropped). Deleted all three. The fourth interim rule (21, Trusted → Servers WiiM LMS, port 3483 -- WiiM → lyrionmusicserver) is real music-playback traffic, not HA, so it was edited rather than deleted: source changed from 192.168.12.82/Trusted to 192.168.22.7/IoT, renamed to IoT → Servers WiiM LMS. Verified live on ucg-fiber post-edit, not just via the UI's save confirmation (per this repo's standing distrust of that alone) -- both the per-policy ipsets (UBIOS_policy_src_ip_21192.168.22.7, UBIOS_policy_dst_ip_21192.168.42.55, UBIOS_policy_dst_port_213483) and both iptables directions (UBIOS_CUSTOM2_LAN_USER forward ACCEPT, UBIOS_LAN_CUSTOM2_USER reverse ACCEPT with RELATED,ESTABLISHED -- the return-traffic toggle survived the edit correctly this time) checked out on live iptables-save/ ipset list output. Note for next time: on this firmware, per-rule state lives in iptables-save-visible legacy chains/ipsets named by the rule's numeric short_id (UBIOS_policy_*_ip_, UBIOS_CUSTOM_LAN_USER / UBIOS_LAN_CUSTOM_USER) -- nft list ruleset comes back empty on this box (legacy iptables, not nftables), which looks like a dead end if you reach for it first. Remaining not started: RokuStreamingStick, BROTHER-2370DW -- picking those up in a later session. Brother will need its own interim rule (18, Servers → Trusted Brother Printer, port 631) rebuilt as Servers → IoT the same way WiiM's was.
Custom LMS plugins must use the Plugins:: namespace, not Slim::Plugin:: -- wrong one fails with a misleading @INC error low
New custom plugin fails to load with: Can't locate Slim/Plugin/<Name>/Plugin.pm in @INC (you may need to install the Slim::Plugin::<Name>::Plugin module) • install.xml and Plugin.pm both look correct, files are in a directory that IS scanned, yet the plugin never appears in My Music
lyrionmusicserver plugin-development custom-plugin browselibrary   last seen: 2026-08-16

Symptoms

  • New custom plugin fails to load with: Can't locate Slim/Plugin/<Name>/Plugin.pm in @INC (you may need to install the Slim::Plugin::<Name>::Plugin module)
  • install.xml and Plugin.pm both look correct, files are in a directory that IS scanned, yet the plugin never appears in My Music
See also: lyrionmusicserver-materialskin-musicfolder-hidden

Symptom

Building a new custom plugin (RecentFolders -- see lms-plugins/README.md and INFRASTRUCTURE.md's lyrionmusicserver section) to add a "Recent Folders" node to My Music. First attempt: package named Slim::Plugin::RecentFolders::Plugin (matching the naming convention every *stock* plugin under /usr/share/perl5/Slim/Plugin/*/Plugin.pm uses), install.xml's tag set to match. Server restart logged:
Slim::bootstrap::tryModuleLoad (271) Warning: Module [Slim::Plugin::RecentFolders::Plugin] failed to load:
Can't locate Slim/Plugin/RecentFolders/Plugin.pm in @INC (you may need to install the Slim::Plugin::RecentFolders::Plugin module)
Slim::Utils::PluginManager::load (325) Error: Couldn't load Slim::Plugin::RecentFolders::Plugin
This reads like a missing-file/wrong-directory problem. It isn't -- the directory (/usr/share/squeezeboxserver/Plugins/RecentFolders/, confirmed scanned -- see below) and the file were both already correct.

Cause

Two separate plugin namespaces coexist in LMS, and only one applies to custom/manually-installed plugins: - Slim::Plugin::::Plugin -- reserved for the plugins shipped with the server itself, physically at /usr/share/perl5/Slim/Plugin//Plugin.pm. This resolves via a plain bareword require Slim::Plugin::::Plugin because /usr/share/perl5 is already a standard system Perl @INC entry -- the package name maps directly onto the file path under that root. - Plugins::::Plugin (plural, no Slim:: prefix) -- used by every custom or extension-installed plugin, whether manually dropped into /usr/share/squeezeboxserver/Plugins// or downloaded via the in-app Extension Installer into /InstalledPlugins/Plugins//. Confirmed by inspecting a real installed plugin (MusicArtistInfo): package Plugins::MusicArtistInfo::Plugin; -- not Slim::Plugin::.... Slim::Utils::PluginManager::load (/usr/share/perl5/Slim/Utils/PluginManager.pm) still does a plain bareword Slim::bootstrap::tryModuleLoad($module) using whatever string install.xml's tag gives it -- it does not unshift @INC the plugin's own basedir (it only ever adds a lib/ subdirectory under basedir, for bundled third-party CPAN deps). So the bareword require only works at all because the *parent* of every scanned custom-plugin directory (/usr/share/squeezeboxserver/Plugins, /InstalledPlugins/Plugins) is effectively already on the resolution path for the Plugins:: top-level namespace, matching the literal directory name "Plugins". Get the namespace wrong (Slim::Plugin:: instead of Plugins::) and the bareword require looks for a path under /usr/share/perl5/Slim/Plugin/... -- which doesn't exist for a custom plugin -- hence the misleading @INC error. A second, harmless red herring hit along the way: /var/lib/squeezeboxserver/Plugins and /usr/share/squeezeboxserver/Plugins are the same physical location (the former is a symlink to the latter) -- confirmed via Slim::Utils::OS::Debian.pm's dirsFor('Plugins') override, which returns /usr/share/perl5/Slim/Plugin and /usr/share/squeezeboxserver/Plugins (plus the generic-Unix-class defaults). Either path works for dropping a plugin; the actual bug was the namespace, not the directory.

Fix

Package declaration and install.xml's tag both use Plugins::::Plugin, e.g. package Plugins::RecentFolders::Plugin;. use base qw(Slim::Plugin::Base); (the real, distinct base-class module) is unaffected -- only *your own* plugin's package name needs the Plugins:: prefix. After changing it, also clear the plugin manifest cache (rm /plugin-data.yaml, i.e. /var/lib/squeezeboxserver/cache/plugin-data.yaml) before restarting, so PluginManager re-reads install.xml rather than trusting a stale cached manifest.

Verification

Confirmed end-to-end via the LMS JSON-RPC CLI directly (curl against http://127.0.0.1:9000/jsonrpc.js, slim.request / browselibrary items ... mode:recentfolders): the new node returned all 4,563 folders sorted newest-first by mtime (cross-checked against stat on the two lead entries), and drilling into a folder (item_id:0) handed off cleanly to the existing Slim::Menu::BrowseLibrary::_bmf track listing, identical to Music Folder browsing. Re-queried the existing mode:bmf (Music Folder) afterward to confirm it was untouched and still alphabetical -- no regression from adding the new node.
Material Skin's Music Folder browse mode was explicitly disabled in its own settings, unrelated to any server-wide LMS config low
No 'Music Folder' entry anywhere in Material Skin's browse/My Music menu, even though it's a standard LMS feature • Tag-based search/browse gives poor results for a library organized by folder-name convention rather than ID3/Vorbis tags • lyrtui shows Music Folder browsing fine, but the Material Skin web UI doesn't
lyrionmusicserver material-skin lyrtui browse-music-folder prefs   last seen: 2026-08-15

Symptoms

  • No 'Music Folder' entry anywhere in Material Skin's browse/My Music menu, even though it's a standard LMS feature
  • Tag-based search/browse gives poor results for a library organized by folder-name convention rather than ID3/Vorbis tags
  • lyrtui shows Music Folder browsing fine, but the Material Skin web UI doesn't
See also: lyrionmusicserver-autorescan-canautorescan-disabled

Symptom

MOS's library (known-fixes/lyrionmusicserver-autorescan-canautorescan-disabled.md has the background) is 4,566 show folders sitting flat directly under /mnt/nuc-ssd, mostly untagged (taper-archive convention: folder name and filename carry the real information, not ID3/Vorbis tags). Tag-based search and the standard Artists/Albums/New Music views work poorly for this — untagged tracks collapse into one shared No Album bucket, and the global search engine does ranked/OR token matching rather than literal substring matching (see conversation-level discussion; not yet its own known-fix entry). The right tool for this library is filesystem-based Music Folder browsing — a standard LMS feature (Slim::Menu::BrowseLibrary's myMusicMusicFolder node, BROWSE_MUSIC_FOLDER) — but it was nowhere in the Material Skin web UI's menu.

Cause

Not a server-wide LMS setting. Slim::Menu::BrowseLibrary.pm registers myMusicMusicFolder unconditionally (gated only on isEnabledNode and on audio dirs being configured, both true here) — the node exists and is generally reachable by any client. Material Skin, however, maintains its own plugin-level pref that can hide specific browse modes from its UI regardless of server-side availability: /var/lib/squeezeboxserver/prefs/plugin/material-skin.prefs, a defaults key holding a JSON blob (not a plain YAML field — a JSON string embedded in one YAML scalar), containing:
"disabledBrowseModes":["myMusicArtistsComposers","myMusicArtistsConductors",
  "myMusicArtistsJazzComposers","myMusicRecentlyChangeAlbums","myMusicTopTracks",
  "myMusicFlopTracks","myMusicMusicFolder","myMusicFileSystem"]
myMusicMusicFolder was in that list. Origin unknown — either a deliberate choice at some earlier point (plausible: a browse mode list dominated by niche classical-music entries like composers/conductors suggests someone was pruning an over-long menu and caught Music Folder in the sweep) or a Material Skin default for this install. Not worth chasing further; the fix is the same either way. lyrtui was never affected — it's a separate client reading the server's generic home-menu tree directly, with no concept of Material Skin's plugin-specific disabledBrowseModes pref.

Fix

Edit the defaults JSON blob to remove "myMusicMusicFolder" from disabledBrowseModes, leaving every other entry (including the unrelated myMusicFileSystem, a raw-filesystem mode, left disabled on purpose — Music Folder is the one scoped to the actual configured audio dirs) exactly as-is. Since it's JSON-inside-YAML on one long line, safest approach: fetch the file, edit locally with an exact string match on the one array element (confirms a single-value diff, not a wholesale rewrite — see CLAUDE.md's read-back-after-write practice), validate the extracted JSON parses (python3 -c "import json; json.load(...)"), back up the original (material-skin.prefs.bak-preBMF), then push it back and restart lyrionmusicserver (prefs are read at startup; a live file swap while the server is running won't take effect, and worse, the running process may overwrite your edit with its own in-memory copy on its next save).

Verification

Confirmed server-side: JSON diff showed exactly one array element removed, nothing else changed; service restarted clean; no Material Skin plugin errors in server.log after restart. **Not yet confirmed in the actual browser UI** — that requires MOS to check Material Skin's Browse/My Music menu directly. Also worth checking whether Material Skin's list views expose a filter/search box while inside Music Folder browsing specifically (expected, consistent with its other list views, but not confirmed from its docs — general LMS knowledge, not this specific mode). lyrtui's side needs no fix — confirmed via its README (hjelev/lyrtui) that Music Folder browsing is a built-in mode and / filters whatever list is currently on screen, live, by typed text (Esc/Backspace to clear). For a flat, taper-archive-style library like this one, "Music Folder → root listing → / filter" is a literal substring match against real folder names — the closest thing to the exact/ordered date-substring search behavior originally asked about, without depending on tags or LMS's ranked full-text search engine at all.
lyrtui (LMS terminal client) fails to connect or play — two distinct causes found setting it up: a missing ACL grant on a new consumer host, and a misspelled config key silently falling back to broken auto-discovery low
curl or lyrtui to lyrionmusicserver's Headscale IP on port 9000 hangs/times out from a host that can already SSH (22) to the same LXC fine • lyrtui --info or the interactive TUI can't reach the server at all, even though config.toml's host/port look correct at a glance • A player that IS actually online and connected in LMS (confirmed via lyrtui --info from a host that CAN reach it) does nothing when told to play from a different, misconfigured lyrtui instance
lyrionmusicserver headscale acl lyrtui squeezelite toml config developer-env   last seen: 2026-08-15

Symptoms

  • curl or lyrtui to lyrionmusicserver's Headscale IP on port 9000 hangs/times out from a host that can already SSH (22) to the same LXC fine
  • lyrtui --info or the interactive TUI can't reach the server at all, even though config.toml's host/port look correct at a glance
  • A player that IS actually online and connected in LMS (confirmed via lyrtui --info from a host that CAN reach it) does nothing when told to play from a different, misconfigured lyrtui instance
See also: onboard-offboard-container-never-touched-acl-hujson

Symptom

Setting up lyrtui (third-party Rust TUI client for Lyrion Music Server) as a new consumer of an existing LXC produced two separate, sequential failures that both look like "it just doesn't connect" from the outside — worth telling apart since the fixes are unrelated.

Cause 1 — ACL grants a port to specific hosts, not the whole LXC

A host already having SSH (port 22) access to lyrionmusicserver via ACL rule 4 does not imply it can reach the control API on port 9000 — Headscale ACL rules are per-port as well as per-host. developer-env could already SSH into the LXC (for collect-homelab) but had no rule granting port 9000 — only iphone/macbook did (rule 8, added for LyrPlay — see docs/friend-guest-lms-lyrplay-access.md). Same symptom family as a host missing from hosts{} entirely (see onboard-offboard-container-never-touched-acl-hujson.md): a plain hang/timeout, not a fast refuse — implicit-deny never distinguishes "host isn't declared" from "host is declared but this specific rule doesn't cover it." Diagnosis: curl -v --max-time 6 http://:/ from the host that can't connect — a clean Connection timed out (not "connection refused") after the full timeout window is the signature; grep headscale/acl.hujson for the target host's name across every rule's src, not just whichever rule seems most relevant, since a host can be correctly declared in hosts{} and have some ACL access while still lacking the one specific rule a new use case needs. Fix: extend the existing rule that already grants the right port for the right reason (here, rule 8 — same reasoning as the iphone/macbook grant, just a different kind of client) rather than forking a near-duplicate rule. Validate with headscale policy check, back up the live acl.hujson before overwriting, deploy, restart, then re-test with the same curl command — it should return a real HTTP response immediately instead of hanging.

Cause 2 — a misspelled TOML key silently falls back to a default that breaks connectivity

lyrtui's config.toml has an auto_discover field (default true, does a UDP broadcast search for LMS servers). A config file with the field misspelled as aut_discover (missing the "o") doesn't error — the TOML parser just doesn't recognize the key, so it's treated as absent and auto_discover reverts to its built-in default of true, silently overriding the explicitly-set host/port values in the same file. UDP broadcast discovery doesn't cross a Headscale/WireGuard tunnel the way a direct connection does, so the app effectively has no way to find the server at all — but nothing about this looks like a config error from the user's side; the file parses fine, has no complaints, and the correctly-spelled fields are still right there in the same file looking correct. Diagnosis: lyrtui --info prints its *loaded* config back out (not just what's in the file) — compare its auto-discover: yes/no line against what you actually set. A mismatch between the file's intent and the loaded value points at a key-name typo rather than a network/server problem. In this case the server side was independently confirmed completely healthy throughout (same lyrtui --info run from a correctly configured host showed the target player live, connected, with a real IP) — worth ruling out server-side issues this way before assuming the client config is fine and looking elsewhere. Fix: correct the key name (sed -i '' 's/aut_discover= false/auto_discover = false/' ~/.config/lyrtui/config.toml on macOS — note BSD sed -i needs the explicit, even if empty, backup-extension argument, unlike GNU sed -i on Linux). Re-run --info to confirm auto-discover: no and the target server/player now show correctly before going back into the interactive TUI.

General lesson

Both failures looked identical from the outside ("it just doesn't play") but had nothing in common — one was network/ACL, one was client-config parsing. lyrtui --info (non-interactive, prints both the loaded config and live server/player state in one shot) was the right first diagnostic step for either: it separates "can this host even reach the server" from "is this host's config actually saying what I think it says" without needing to drive the full interactive TUI to find out.
run-maintenance-window and gen-runbook's Group 1 .sh restore monitoring via different constructs (Python finally vs bash trap EXIT) -- intentional, not drift low
Grepping run-maintenance-window for 'trap' or the .sh runbook for 'finally' finds nothing -- looks like only one of the two scripts guards its maintenance-mode restore against a failure/abort • The two 'silence monitoring for the duration' implementations don't look like copies of each other, even though they do the same job
maintenance-mode uptime-kuma run-maintenance-window gen-runbook architecture trap finally   last seen: 2026-08-25

Symptoms

  • Grepping run-maintenance-window for 'trap' or the .sh runbook for 'finally' finds nothing -- looks like only one of the two scripts guards its maintenance-mode restore against a failure/abort
  • The two 'silence monitoring for the duration' implementations don't look like copies of each other, even though they do the same job

Symptom

run-maintenance-window (added 2026-08-25) and gen-runbook's generated Group 1 .sh runbook (added the same day) both wrap their work in bin/maintenance-mode on at the start and bin/maintenance-mode off at the end, guaranteed to run even if the work in between fails or is aborted. But the actual guarantee mechanism is different in each: - run-maintenance-window (Python): bin/maintenance-mode off runs in a finally block around the node-processing chain. - gen-runbook's .sh (bash, generated by Python string-building): bin/maintenance-mode off runs in a function registered via trap _maintenance_off EXIT. Looking for one pattern in the other file finds nothing, which can read as "only one of these is actually safe against a halted run."

This is not a bug

Both mechanisms give the identical guarantee in their own runtime: fire on normal completion, fire on an error exit (a Python exception / a bash exit 1 under set -euo pipefail), and fire on Ctrl-C (Python raises KeyboardInterrupt, which finally catches same as any other exception; bash's trap ... EXIT fires when SIGINT terminates the shell). Neither is weaker than the other -- they're each the canonical "always run this cleanup" construct for their language, and there is no single construct that is simultaneously idiomatic Python control flow *and* idiomatic generated-bash control flow.

Why not unify them anyway

Considered: a shared bin/with-maintenance-silence "" -- wrapper script that owns the trap/on/off/fallback-warning logic once, with both consumers becoming plain subprocesses of it. This would genuinely remove the small duplicated bash block gen-runbook currently generates. Not done, on a cost/benefit basis: - run-maintenance-window is invoked directly today, and that exact invocation is documented in multiple places -- the script's own docstring, the ssh watchdog "cd ~/homelab && bin/run-maintenance-window" convention (INFRASTRUCTURE.md's "Proxmox Node Maintenance Windows" section), and this repo's operational muscle memory. Routing it through a wrapper means the *command you actually type* changes, not just an internal. - The two current implementations already behave identically from the outside (silence on start, restore on any exit path) -- unifying them buys code-sharing, not new correctness. Revisit if a third consumer needs this same "silence monitoring for the duration of a script, restore on any exit path" pattern (e.g. if run-fleet-update ever grows it) -- that's the point where a shared wrapper starts paying for itself (rule of three), not before.

Prevention

None needed -- this file exists so a future session (or a future code-review) that notices the asymmetry checks here first instead of "fixing" it by porting one mechanism into the other language, which isn't possible cleanly, or building the wrapper prematurely.
PBS offsite sync-job fails with tcp connect deadline elapsed during a transient wildwood tailscaled path renegotiation low
ntfy: 'nastynas offsite sync: FAILED' (or proxmox-nuc/wildwood offsite sync variants), exit 255 • sync.log: TASK ERROR: client error (Connect): error connecting to https://<peer>:8007/ - tcp connect error: deadline has elapsed • manually re-running the sync-job (or SSH/ping to the peer) a few minutes later works fine with no changes
pbs sync-job nastynas wildwood tailscale magicsock transient offsite-backup retry   last seen: 2026-08-21

Symptoms

  • ntfy: 'nastynas offsite sync: FAILED' (or proxmox-nuc/wildwood offsite sync variants), exit 255
  • sync.log: TASK ERROR: client error (Connect): error connecting to https://<peer>:8007/ - tcp connect error: deadline has elapsed
  • manually re-running the sync-job (or SSH/ping to the peer) a few minutes later works fine with no changes
  • peer's own journalctl around the failure timestamp shows tailscaled endpoint/NetInfo/portmap churn and briefly falls back to a DERP relay before re-stabilizing to a direct connection
See also: rsync-wildwood-exit-255-ssh-auth, nastynas-wildwood-derp-relay, nastynas-wildwood-bulk-transfer-stalls, pbs-offsite-sync-cron-path-manager-not-found, tailscaled-magicsock-network-down-stuck

Cause

A brief, self-resolving WAN/NAT path change on wildwood's Tailscale client happened to land in the same ~10-second window as nastynas-self-offsite-sync.sh's 04:00 cron-triggered PBS sync-job connecting to wildwood:8007. PBS's client hit a hard connect timeout before Tailscale's path re-established, and the job died with exit 255 — `TASK ERROR: client error (Connect): error connecting to https://100.64.0.3:8007/ - tcp connect error: deadline has elapsed`. Not the rsync-wildwood-exit-255-ssh-auth.md pattern — that's a missing authorized_keys entry in a different (older, rsync-based) script. This exit 255 is PBS's own generic sync-job failure code, coincidental with that entry's. Confirmed via wildwood's own logs for the same ~10-minute window:
03:59:58 wildwood tailscaled[...]: control: NetInfo: NetInfo{... portmap=U ...}
03:59:58 wildwood tailscaled[...]: portmapper: saw UPnP type WANIPConnection1 at ...
03:59:58 wildwood tailscaled[...]: magicsock: endpoints changed: 73.93.163.170:54267 (stun), 192.168.0.20:41641 (local)
03:59:59 wildwood tailscaled[...]: control: NetInfo: NetInfo{... portmap=active-U ...}
03:59:59 wildwood tailscaled[...]: magicsock: endpoints changed: 73.93.163.170:55387 (portmap), ...
04:07:40 wildwood tailscaled[...]: magicsock: 1 active derp conns: derp-2=...
Postfix mail-queue delivery attempts on wildwood in the same window also logged "Network is unreachable"/"Connection timed out" against Google's mail servers — consistent with a real, brief WAN-level blip on wildwood's residential connection, not a homelab config issue. By the time it was checked live (~65 min later), ssh wildwood, wildwood:8007, and tailscale status (nastynas → wildwood) were all clean, with a direct (non-DERP) connection at ~13ms.

Diagnosis

ssh nastynas 'tail -50 /var/log/nastynas-self-offsite-sync/sync.log'

Look for: TASK ERROR: client error (Connect): ... tcp connect error: deadline has elapsed

Confirm current reachability from the actual sync source (not developer-env --

developer-env may not have an ACL path to wildwood:8007 at all, which is a

red herring, not evidence of anything wrong):

ssh nastynas "tailscale status | grep -i wildwood" # want 'active; direct', not a derp-N relay ssh nastynas "timeout 8 bash -c 'cat < /dev/null > /dev/tcp/100.64.0.3/8007' && echo OPEN || echo TIMEOUT"

Correlate against wildwood's own tailscaled log for the failure window:

ssh wildwood "journalctl -u proxmox-backup --since '<HH:MM-5min>' --until '<HH:MM+15min>' --no-pager" ssh wildwood "journalctl --since '<HH:MM-5min>' --until '<HH:MM+15min>' --no-pager | grep -iE 'tailscaled|magicsock|derp|NetInfo'"

Resolution

No config or ACL change needed — connectivity self-healed within minutes, before any intervention. Two things were still worth doing: 1. Closed the same-day gap: manually re-ran the failed job — ssh nastynas '/usr/sbin/proxmox-backup-manager sync-job run nastynas-self-to-wildwood'TASK OK, today's snapshot pushed. 2. Added retry logic, 2026-08-21, to all three sibling offsite-sync scripts (nastynas-self-offsite-sync.sh, proxmox-nuc-offsite-sync.sh, wildwood-self-offsite-sync.sh — same copy-pasted family, same exposure, per the "check every sibling" lesson from pbs-offsite-sync-cron-path-manager-not-found.md) so a future occurrence self-heals without a human noticing overnight: up to **4 attempts, 180s apart** (≈9 extra minutes worst case — comfortably inside the ~8-10 minute instability window observed here, and still well clear of the 04:30 proxmox-nuc-offsite-sync.sh cron slot even in the worst case). The failure ntfy now also includes the exact one-line command to manually re-run the whole wrapper script (not just the bare sync-job run), so a genuine multi-attempt failure can be retried by hand immediately from the notification alone: ssh '/root/.sh'. Success ntfy notes (succeeded on attempt N/4) when a retry was needed, so a pattern of frequent retries (vs. a one-off) is visible in the ntfy history without having to grep sync.log.

Prevention / what NOT to do

- Don't chase this as a network/ACL/credential bug on the homelab side — the peer's own tailscaled log for the exact failure window is the fastest way to tell "real transient WAN blip" apart from "something we broke." A one-off, self-resolving connect timeout with corroborating magicsock churn on the peer is not actionable beyond retry logic. - Don't assume a ping/tailscale status check from developer-env proves or disproves anything about this failure — developer-env is not the sync source and may have no ACL path to the peer's PBS port at all. Always reproduce reachability checks from the actual host that runs the cron job (nastynas or wildwood, per script). - If this starts recurring frequently (not a one-off), that's a different, more serious problem than this entry covers — escalate to known-fixes/tailscaled-magicsock-network-down-stuck.md's territory (a genuinely stuck tailscaled, needing the self-heal script or a manual restart) rather than assuming retries will keep covering for it.
12 separate ntfy messages in one 3-minute window -- consolidated update-advisor/maintenance-window-check/gen-runbook into two once-daily tier digests low
A single collect-homelab run (6h cron) fires many individual ntfy messages in quick succession -- per-service CAUTION/SECURITY pings from update-advisor, per-node 'maintenance window due' from maintenance-window-check, and a separate 'Runbook ready' from gen-runbook, all within the same few minutes • The same pending item gets re-notified independently by more than one tool, or gets a fresh notification every 6h even though nothing about it changed
collect-homelab ntfy notification digest update-advisor maintenance-window-check gen-runbook run-fleet-update daily-fleet-digest   last seen: 2026-08-14

Symptoms

  • A single collect-homelab run (6h cron) fires many individual ntfy messages in quick succession -- per-service CAUTION/SECURITY pings from update-advisor, per-node 'maintenance window due' from maintenance-window-check, and a separate 'Runbook ready' from gen-runbook, all within the same few minutes
  • The same pending item gets re-notified independently by more than one tool, or gets a fresh notification every 6h even though nothing about it changed
See also: clone-behind-notification-persisted-added-auto-sync

Symptom

Pulled the actual ntfy message log (GET /homelab-alerts/json?since=24h) for an arbitrary 3-minute window (2026-08-14, 12:01-12:04) to check MOS's report of notification overload. Found 12 separate messages from one single collect-homelab run: | source | count | example | |---|---|---| | collect-homelab | 2 | watchdog/raspi4 clone-behind | | update-advisor | 7 | 5x per-service CAUTION (bentopdf-os, grafana-os, seeder-daemon-os, vaultwarden-os, watchdog-os) + 2x SECURITY (nastynas-pve, proxmox-nuc-pve) | | maintenance-window-check | 2 | "nastynas: maintenance window due", "proxmox-nuc: maintenance window due" | | gen-runbook | 1 | "Runbook ready: adguard2, headscale" | All three of the non-collect-homelab sources already run sequentially *inside* collect-homelab itself, every 6h (update-advisor --execute, then maintenance-window-check, then gen-runbook) -- see bin/collect-homelab around its --execute/gen-runbook invocation block. Each was independently deciding to fire its own ntfy the moment it found something notify-worthy, with no coordination between them.

Fix -- two ntfy per day instead of up to 4x/day per tool

MOS's explicit call: split by tier, once per day per tier, not once per 6h collect-homelab run. - Tier 1 = Group 1 LXCs (caddy/adguard/adguard2/headscale) + the 3 proxmox_nodes -- everything bin/run-fleet-update already excludes as "handled by their own tools" (excluded_names()), i.e. always-human, never auto-executed. Points at bin/gen-runbook (LXCs) and bin/run-maintenance-window (nodes) -- kept as two separate commands deliberately, not merged into one: they're canary-ordered / typed-REBOOT gated for good reason and collapsing them would erase that safety property. Only sent when something's actually pending in either lane -- a quiet "all clear" here would just be more noise on a tier that changes rarely. - Tier 2/3 = everything else, i.e. exactly what bin/run-fleet-update handles. Points at that one command. Sent daily regardless (keeps the existing "all clear" heartbeat from before this change). Mechanics: update-advisor, maintenance-window-check, and gen-runbook no longer send any ntfy of their own -- they still run 4x/day via collect-homelab (state needs to stay fresh through the day), but now only *persist* what they found: - update-advisor -- unchanged: collected/update-notes/digest.txt (already covered every service, Group 1 included; the per-item notify_if_needed()/should_notify()/send_ntfy() machinery was removed entirely, ~120 lines) - maintenance-window-check -- new: collected/update-notes/maintenance-window-state.json, overwritten every run with the full per-node assessment (assessment_body() kept as a pure text-builder, reused by the digest instead of a removed send_ntfy()) - gen-runbook -- new: only writes a fresh timestamped runbook when the pending Group 1 set's content-hash actually changed since the last one (collected/update-notes/group1-runbook.hash) -- previously it regenerated and re-notified on *every* 6h run regardless, so a single unresolved Group 1 item produced 4 duplicate runbook files a day bin/daily-fleet-digest (already the once-daily consolidator, 06:30 cron) now reads all of the above once a day and sends exactly the two messages: it imports run-fleet-update's own parse_digest()/excluded_names() to split digest.txt's items by tier (no reimplementation), reads the maintenance-window JSON, and globs collected/runbooks/*-group1.sh for the freshest runbook path.

What did NOT change

- bin/run-update's own "[OK] Updated: X" immediate confirmation when something actually applies -- that's a real-time apply confirmation, not pre-apply nagging, never part of this consolidation. - bin/weekly-maintenance-notify's separate "can't wait for Sunday" SECURITY escalation for proxmox nodes -- distinct cadence/purpose, explicitly not superseded (see its own module docstring). - collect-homelab's own clone-staleness / repo-drift ntfy -- unrelated category (infra sync health, not pending updates); see the companion entry clone-behind-notification-persisted-added-auto-sync.md for that fix.

Trade-off, stated explicitly

Before this change, a brand-new SECURITY item could reach MOS within minutes of the collect-homelab run that discovered it. After: it waits for the next daily digest slot (up to ~18h in the worst case; the old per-item throttle was already ~daily in practice via NOTIFY_REMINDER_HOURS, so the real change is mainly to the *first* sighting, not the reminder cadence). MOS accepted this explicitly ("once per day per Tier works perfectly") when this was proposed -- noted here so a future session doesn't "fix" it back toward more frequent per-item nagging without checking whether that trade-off is still acceptable.
onboard-container fails if VMID passed as argument low
scan-containers failed: No such file or directory: '/home/mos/<VMID>/bin/scan-containers' • onboard-container exits rc=2 immediately
onboarding onboard-container cli   last seen: 2026-06-28

Symptoms

  • scan-containers failed: No such file or directory: '/home/mos/<VMID>/bin/scan-containers'
  • onboard-container exits rc=2 immediately

Symptom

Running onboard-container 108 (or any VMID) fails immediately:
✗ scan-containers failed (rc=2)
  FileNotFoundError: No such file or directory: '/home/mos/108/bin/scan-containers'

Root Cause

onboard-container accepts an optional positional argument as the homelab directory path (homelab_dir). Passing a VMID like 108 causes it to treat 108 as the homelab directory, so it looks for 108/bin/scan-containers instead of ~/projects/homelab/bin/scan-containers. The script does not accept a VMID argument — scan-containers auto-discovers what needs onboarding from Proxmox.

Fix

Run without arguments, from any directory:
python3 ~/projects/homelab/bin/onboard-container
Or via the runtee wrapper (also without a VMID):
runtee python3 ~/projects/homelab/bin/onboard-container
Note: runtee onboard-container (using the ~/bin/ synced copy) also fails because that copy computes homelab_dir relative to its own path (~/bin/), not the repo root. Always invoke via the full repo path.
watchdog-os / raspi4-os update_cmd having no sudo prefix LOOKS like a bug over a direct SSH test, but isn't -- the real dispatch path (update-apt-host) already handles it low
Running a <host>-os service's update_cmd by hand over SSH as its non-root ssh_user fails with 'E: Could not open lock file /var/lib/apt/lists/lock - open (13: Permission denied)' • Tempted to conclude a Group 2 auto_update:true OS-package pseudo-service has been silently failing every real invocation because its update_cmd has no sudo prefix
update-advisor run-update update-apt-host sudo ssh_user watchdog raspi4 false-alarm misdiagnosis   last seen: 2026-08-12

Symptoms

  • Running a <host>-os service's update_cmd by hand over SSH as its non-root ssh_user fails with 'E: Could not open lock file /var/lib/apt/lists/lock - open (13: Permission denied)'
  • Tempted to conclude a Group 2 auto_update:true OS-package pseudo-service has been silently failing every real invocation because its update_cmd has no sudo prefix
See also: os-update-checker-stale-notes-on-resolve

What this looks like

watchdog-os/raspi4-os update_cmd in hosts-config.yaml is plain apt-get update && apt-get -y dist-upgrade, no sudo, while both hosts' ssh_user is non-root (watchdog, mos). Testing that command directly:
ssh watchdog@192.168.42.229 "apt-get update"

E: Could not open lock file /var/lib/apt/lists/lock - open (13: Permission denied)

This looks exactly like a real bug -- a Group 2 auto_update: true service that could never have actually applied anything since the OS-package lane went live. It isn't one. This exact false conclusion was reached and briefly "fixed" (adding sudo -n to both hosts' update_cmd) during the Phase 1/2 daily-automation build session on 2026-08-12, then caught and reverted during the same session's /done review.

Why the direct-SSH test is misleading

run-update/update-advisor never SSH a -os service's update_cmd directly. run-update's dispatch_update() classifies update_cmd first -- anything containing dist-upgrade (every -os pseudo-service) routes to bin/update-apt-host, not a bare SSH call. update-apt-host already sudo-wraps correctly for a non-root ssh_user, by design, since the same 2026-08-08 session that built this whole lane (hosts-config.yaml deliberately keeps update_cmd itself plain -- see update-apt-host's own comment and TROUBLESHOOTING.md's "Full-package OS-update lane build session" entry, item 3, both dated 2026-08-08):
if ssh_user != "root":
    noninteractive_cmd = f"sudo env DEBIAN_FRONTEND=noninteractive bash -c {shlex.quote(update_cmd)}"
Testing update_cmd by SSHing and running it directly bypasses this wrapping entirely -- the permission error it produces is real, but it's a property of the test, not of the actual execution path.

How to actually verify this class of pseudo-service

Don't SSH and run update_cmd by hand. Call the real dispatcher directly with a harmless probe:
python3 bin/update-apt-host <ssh_ip> test-probe "apt-get update" "echo 0" <ssh_user>

{"ok": true, ... "error": ""}

Confirmed live 2026-08-12 for both watchdog (192.168.42.229) and by the same code path applying to raspi4 -- ok: true, no error, via the exact sudo-wrapping shown above.

Lesson

When a service's automation goes through a classify-then-dispatch layer (run-update's dispatch_update() / update-advisor's classify_update_cmd()), testing the raw update_cmd string directly tests a path the system doesn't actually take. Verify against the real dispatcher (or trace the classification logic to confirm which dispatcher a given update_cmd shape routes to) before concluding a hosts-config.yaml field itself is broken -- especially when, as here, an adjacent comment or TROUBLESHOOTING.md/known-fixes/ entry already documents that exact field's sudo handling as deliberately living one layer up.
os-update-checker never cleared a resolved host's stale <host>-os.txt -- digest.txt kept claiming it was still pending low
digest.txt / update-advisor still lists a service as SECURITY or pending after its packages have genuinely already been applied and verified • apt list --upgradable on the host shows nothing pending, but collected/update-notes/<host>-os.txt on developer-env still shows an old pending-package count • A service you just ran bin/run-update or bin/run-fleet-update against still shows up in the next digest.txt as if nothing happened
os-update-checker update-advisor digest.txt stale-state run-fleet-update silent-failure   last seen: 2026-08-12

Symptoms

  • digest.txt / update-advisor still lists a service as SECURITY or pending after its packages have genuinely already been applied and verified
  • apt list --upgradable on the host shows nothing pending, but collected/update-notes/<host>-os.txt on developer-env still shows an old pending-package count
  • A service you just ran bin/run-update or bin/run-fleet-update against still shows up in the next digest.txt as if nothing happened
See also: os-package-update-cmd-sudo-misdiagnosis

Symptom

immich-os and lyrionmusicserver-os both showed as SECURITY-pending in digest.txt immediately after bin/run-fleet-update had already applied their Postfix security patch and verified it (confirmed live via dpkg -l postfix on both hosts: 3.10.13-0+deb13u1, the target version). digest.txt still said 2-pending → 2-pending / 1-pending → 1-pending.

Root cause

bin/os-update-checker's per-host loop only ever calls write_notes() (the one and only place that writes -os.txt) when it finds pending packages. The "nothing pending" branch just logged and continued -- it never deleted an existing, now-stale -os.txt (or its -summary.txt/-impact.txt/.hash siblings). No script anywhere in this pipeline ever called .unlink() on these files. A host that went from N-pending to 0-pending kept its old notes file exactly as it was, and update-advisor's assessment loop (which just globs every .txt in collected/update-notes/) had no way to know it was stale. This mostly self-corrected within the normal 6-hourly collect-homelab cadence, but only as an *accidental* side effect: changelog-fetcher (the counterpart for tracked-app version updates, not OS packages) runs earlier in collect-homelab and has its own correct cleanup_stale_notes() that diffs all notes_dir .txt files against its own current "behind" set and deletes anything not in it -- which includes -os.txt files, since they were never in changelog-fetcher's own tracked set. So every cycle, changelog-fetcher blindly wiped os-update-checker's notes files too, and os-update-checker/pve-update-checker (which run after it in the same cycle) rewrote fresh ones for whatever was genuinely still pending. This worked in the steady 6h cadence but relied on an unrelated script's side effect, not on os-update-checker correctly managing its own file lifecycle -- and it meant anything applied *between* cycles (exactly what bin/run-fleet-update does) stayed stale until the next cycle, up to 6h later.

Fix

os-update-checker's "nothing pending" branch now explicitly deletes -os.txt, -summary.txt, -impact.txt, and .hash if they exist, logging what it cleared. This makes a resolved host read as resolved on its very next check, independent of changelog-fetcher's unrelated cleanup pass. bin/run-fleet-update also does its own scoped refresh after applying anything: os-update-checker --host for each applied -os service, then one update-advisor --execute pass, so digest.txt is accurate by the time its wrap-up ntfy fires rather than waiting for the next collect-homelab cycle.

Prevention / lesson

A notes-writer script needs to manage its *own* file's full lifecycle (write when pending, clear when resolved) rather than relying on a different script's unrelated cleanup pass to incidentally cover it -- that only works for as long as both scripts keep running in the same relative order on the same cadence, and breaks the moment anything (like a new orchestrator applying updates directly) changes state between cycles. Found building bin/run-fleet-update, the first tool to apply updates outside update-advisor's own normal 6-hourly --execute path -- see os-package-update-cmd-sudo-misdiagnosis for a related bug found the same day in the same pipeline.
Getting PBS TLS fingerprint low
need PBS fingerprint for pvesm or remote create
pbs tls fingerprint   last seen: 2026-06-24

Symptoms

  • need PBS fingerprint for pvesm or remote create

Note

--output-format json is not supported by proxmox-backup-manager cert info. Use openssl directly:
openssl x509 -in /etc/proxmox-backup/proxy.pem -noout -fingerprint -sha256

Output: SHA256 Fingerprint=AA:BB:CC:...

Strip the "SHA256 Fingerprint=" prefix when passing to pvesm/remote create

uu-digest apt-get update FAILED: download.proxmox.com 'no longer has a Release file' -- transient mirror glitch, not the enterprise-401 issue low
ntfy: '<host>: apt-get update FAILED', priority 5, from the 06:30 uu-digest.sh cron • Error text: 'Err:N http://download.proxmox.com/debian/pve trixie Release' / 'E: The repository ... no longer has a Release file' • Fires on multiple hosts at the exact same cron timestamp (shared upstream dependency, not per-host drift)
apt pve pbs proxmox-nuc nastynas mirror transient uu-digest   last seen: 2026-08-12

Symptoms

  • ntfy: '<host>: apt-get update FAILED', priority 5, from the 06:30 uu-digest.sh cron
  • Error text: 'Err:N http://download.proxmox.com/debian/pve trixie Release' / 'E: The repository ... no longer has a Release file'
  • Fires on multiple hosts at the exact same cron timestamp (shared upstream dependency, not per-host drift)
  • Manually re-running apt-get update minutes/hours later succeeds cleanly, no config change needed
See also: pbs-enterprise-repo-401-blocks-apt-update

Cause

download.proxmox.com (the no-subscription PVE/PBS repo, a normal legit source, not the enterprise repo) briefly served a broken/missing Release file — almost certainly a mirror mid-sync or CDN blip on Proxmox's end, not anything wrong with our hosts. apt-get update treats this as a hard failure for the whole run (same exit-100 behavior as the enterprise-401 case), which is why bin/uu-digest.sh's health check alerted. **This is a different failure signature from [[pbs-enterprise-repo-401-blocks-apt-update]]** despite firing from the same alert path and the alert body's own text pointing at that doc as a first guess: - Enterprise-401: 401 Unauthorized / is not signed, from enterprise.proxmox.com, caused by a missing Enabled: false line — persistent until fixed, single-host. - This case: no longer has a Release file, from download.proxmox.com (the repo that's supposed to be enabled), no config defect — transient, hit multiple hosts simultaneously at the same cron tick. Confirmed 2026-08-12: both proxmox-nuc and nastynas alerted within the same second (06:30:03, their shared uu-digest.sh cron time), both had already-correct Enabled: false on every *enterprise*.sources file (no drift), and both cleared on a manual apt-get update re-run minutes later with zero changes made — confirming an upstream blip, not local config.

Diagnosis

ssh <host> "apt-get update"   # re-run by hand; if it's now clean, this was transient
ssh <host> "grep -L 'Enabled: false' /etc/apt/sources.list.d/*enterprise*.sources || echo none-missing"

^ rule out the enterprise-401 pattern -- if this is clean too, don't chase that fix

If multiple hosts alerted at the same cron timestamp, that's a strong signal it's shared-upstream, not per-host — don't spend time diffing configs between the affected hosts before checking that first.

Resolution

None needed — self-resolved. No local state was ever wrong; apt-get update succeeded on manual retest on both hosts with no changes made. Unattended-upgrades will run normally on the next cycle since the underlying apt-get update now succeeds.

Follow-up

If this recurs (same no longer has a Release file text against download.proxmox.com specifically, as opposed to enterprise.proxmox.com), it's still almost certainly transient on Proxmox's mirror infrastructure — no action needed beyond confirming a manual retry clears it. Only worth escalating if it starts persisting for hours/days, which would suggest something host-local after all.
watchdog/snapshot.py reported uptime_kuma: http_error on developer-env — UPTIME_KUMA_API_KEY was never added to developer-env's .env low
watchdog/snapshot.py's uptime_kuma.status is 'http_error' with note 'Uptime Kuma unreachable -- not yet installed or watchdog is down', even though Uptime Kuma is confirmed up and healthy • curl -s -o /dev/null -w '%{http_code}' http://192.168.42.229:3001/metrics returns 401 from developer-env • grep -c UPTIME_KUMA_API_KEY ~/projects/homelab/proxmox-inventory/.env returns 0 on developer-env
snapshot.py uptime-kuma api-key env developer-env watchdog ci-cd-testing   last seen: 2026-07-14

Symptoms

  • watchdog/snapshot.py's uptime_kuma.status is 'http_error' with note 'Uptime Kuma unreachable -- not yet installed or watchdog is down', even though Uptime Kuma is confirmed up and healthy
  • curl -s -o /dev/null -w '%{http_code}' http://192.168.42.229:3001/metrics returns 401 from developer-env
  • grep -c UPTIME_KUMA_API_KEY ~/projects/homelab/proxmox-inventory/.env returns 0 on developer-env

Cause

watchdog/snapshot.py's collect_uptime_kuma() polls Uptime Kuma's /metrics endpoint with HTTP Basic auth (empty username, UPTIME_KUMA_API_KEY as password) if that env var is set — otherwise it sends the request with no auth header at all. Uptime Kuma requires auth on /metrics, so an unauthenticated request correctly gets a 401, which http_get() classifies as "http_error". This is not a bug in snapshot.py's logic — the code path is correct — it's a missing credential. watchdog/daemon.py (which runs on the watchdog host itself, where the key *is* set) uses the identical auth pattern and works fine. But snapshot.py is also invoked from developer-env directly (e.g. during CI/CD pipeline testing of bin/run-update's post-deploy snapshot trigger), and developer-env's own proxmox-inventory/.env never had UPTIME_KUMA_API_KEY added to it — only the watchdog host's copy of the repo did. Both scripts' load_env() correctly derive homelab_dir from Path(__file__).resolve().parent.parent (not cwd), so this wasn't a path-resolution bug — the key genuinely wasn't present in this host's .env file. Caught live 2026-07-14 while manually testing watchdog/snapshot.py standalone from developer-env as part of unrelated CI/CD verification work — the SECURITY-adjacent-looking snapshot output ("all probes nominal" triage summary sitting next to an http_error uptime_kuma block) prompted a closer look rather than being waved off as expected noise.

Fix

Copied the existing key from the watchdog host's own .env (same key watchdog/daemon.py already uses successfully) into developer-env's:
ssh watchdog "grep UPTIME_KUMA_API_KEY ~/homelab/proxmox-inventory/.env"
echo 'UPTIME_KUMA_API_KEY=<value-from-watchdog>' >> ~/projects/homelab/proxmox-inventory/.env
Deliberately reused the same key rather than minting a second one in the Uptime Kuma UI — avoids Uptime Kuma accumulating orphaned API keys with no clear owner.

Verify

cd ~/projects/homelab
python3 watchdog/snapshot.py --trigger "uptime-kuma-auth-verify" --output /tmp/test.json --pretty
python3 -c "import json; print(json.load(open('/tmp/test.json'))['uptime_kuma']['status'])"
Before fix: http_error. After fix: ok.

Follow-up

No code change needed — snapshot.py's behavior (send unauthenticated if the key isn't present, rather than failing loudly) is arguably too quiet about a missing credential, since the resulting http_error status looks identical to Uptime Kuma actually being down. Worth considering whether collect_uptime_kuma() should distinguish "no API key configured" from "got a real HTTP error" in its status string, so a future missing- credential case doesn't require manually diffing against a known-good host's .env to diagnose.
ssh seeder-daemon breaks sudo / systemctl PATH low
sudo: command not found after SSH • systemctl: command not found after SSH
ssh seeder-daemon path   last seen: 2026-06-03

Symptoms

  • sudo: command not found after SSH
  • systemctl: command not found after SSH

Cause

The SSH alias uses RemoteCommand which drops a restricted PATH.

Fix

ssh -o RemoteCommand=none seeder-daemon
Same applies to ssh-copy-id:
ssh-copy-id -i ~/.ssh/id_rsa.pub -o RemoteCommand=none seeder-daemon
How to actually trunk multiple VLANs onto one of the UCG-Fiber's own built-in LAN ports (no named 'All' profile exists on this device type) low
Looking for a 'Switch Port Profile' dropdown with a built-in 'All' option (the mechanism documented for dedicated UniFi switches) on one of the UCG-Fiber's own LAN ports -- not present • The only dropdown initially found under a port's 'Core Settings' offered a single-select list of individual networks (None/Default(1)/Trusted(10)/IoT(20)/Management(50)/Guest(60)) with no 'All' or multi-select option • Settings -> Profiles -> Switch Ports also has no pre-existing 'All' profile to select -- only a blank 'create new profile' form
ucg-fiber unifi vlan trunk port-profile u7-pro ap   last seen:

Symptoms

  • Looking for a 'Switch Port Profile' dropdown with a built-in 'All' option (the mechanism documented for dedicated UniFi switches) on one of the UCG-Fiber's own LAN ports -- not present
  • The only dropdown initially found under a port's 'Core Settings' offered a single-select list of individual networks (None/Default(1)/Trusted(10)/IoT(20)/Management(50)/Guest(60)) with no 'All' or multi-select option
  • Settings -> Profiles -> Switch Ports also has no pre-existing 'All' profile to select -- only a blank 'create new profile' form
See also: homelab-switch-vlan30-trunk-only-outage

Context

Needed to trunk multiple VLANs (10 Trusted, 20 IoT, 60 Guest) onto the built-in gateway LAN port the U7 Pro AP is plugged into (port 3 / eth2), so its VLAN-tagged SSIDs would actually have tagged frames to send. Docs and community writeups describing UniFi VLAN trunking are written for dedicated UniFi switches, where the mechanism is: build a named Switch Port Profile (Settings -> Profiles -> Switch Ports) with a Native Network + Tagged Networks, including a built-in All profile that tags every configured network automatically -- then assign that profile to a port from a "Switch Port Profile" dropdown. The UCG-Fiber's own built-in LAN ports don't expose that same UI. There's no pre-existing All profile anywhere to select, and no separate "Switch Port Profile" dropdown distinct from the port's own inline settings.

The actual mechanism, confirmed live 2026-08-23

A gateway port's own config screen has three relevant controls together, not a profile-picker: 1. Port Mode: Access vs Tagged. Must be Tagged for a port that needs to carry more than one VLAN. 2. Native Network: single-select, the untagged/native VLAN for this port (e.g. Default (1) to keep the AP's own management traffic on Servers, unchanged). 3. Tagged VLAN Management: Allow All / Block All / Custom. - Allow All is the functional equivalent of a dedicated switch's built-in All profile -- it automatically tags every VLAN that exists on the gateway, no manual list needed. - Custom lets you hand-pick exactly which VLANs get tagged (used here: Trusted/IoT/Guest, deliberately excluding Management since that VLAN has no reason to ever reach a WiFi AP's uplink). No named profile object is created or needed -- this is all inline per-port state, distinct from the Settings -> Profiles -> Switch Ports mechanism that dedicated switches use.

Verification note

The raw switch-chip VLAN table (swconfig dev switch0 show, confirmed live) showed all four built-in LAN ports as tagged members of every VLAN by default, before this change was ever made -- this is normal UniFi gateway behavior (every VLAN you create is auto-trunked to every port at the hardware level), not something this port config controls. What Port Mode / Native Network / Tagged VLAN Management actually govern is a software-level allow-list on top of that hardware trunk, filtering which of the already-trunked VLANs are actually permitted through a given port. This means checking the hardware VLAN table cannot verify whether a Custom/Allow-All change took effect -- the only real verification is testing with an actual VLAN-tagged client (which is what Session 2 Step 2 did next, for IoT and Guest).

Prevention

- Don't assume a dedicated-switch UniFi doc/community writeup describing "Switch Port Profiles" and a built-in All profile applies verbatim to a gateway's own built-in LAN ports -- confirm which UI you're actually looking at first. - If hunting for a named "All" profile costs more than a couple minutes on a gateway port specifically, stop and look for Port Mode / `Native Network / Tagged VLAN Management` instead.
UniFi 'Enhanced IoT' WLAN setting blocked an iPhone from completing WPA handshake -- correct passphrase, connection just hangs low
iPhone could see the IoT_GDTRFB SSID and attempt to join it, but the connection never completed -- no error, just never logged in • Passphrase confirmed correct against the value actually stored server-side (ace.wlanconf's x_passphrase, read directly via mongo on the gateway) • AP itself confirmed healthy and actively informing (last_seen within seconds, config provisioned minutes earlier) -- not a stuck-provisioning problem
unifi wifi iot vlan u7-pro wpa enhanced-iot iphone   last seen:

Symptoms

  • iPhone could see the IoT_GDTRFB SSID and attempt to join it, but the connection never completed -- no error, just never logged in
  • Passphrase confirmed correct against the value actually stored server-side (ace.wlanconf's x_passphrase, read directly via mongo on the gateway)
  • AP itself confirmed healthy and actively informing (last_seen within seconds, config provisioned minutes earlier) -- not a stuck-provisioning problem
See also: guest-ssid-passphrase-not-persisting

Root cause

IoT_GDTRFB's WLAN config had "enhanced_iot": true set -- UniFi's setting that tunes 802.11 timing/data-rate behavior specifically for genuine low-power IoT hardware (thermostats, smart plugs, etc). An iPhone is not that kind of client, and the altered timing/rate parameters this setting introduces are a known category of compatibility problem for regular smartphones -- the symptom is exactly this: visible, attempts to associate, hangs indefinitely during the WPA handshake rather than failing fast the way a wrong passphrase does.

Fix

Turned off "Enhanced IoT" in the SSID's advanced settings. iPhone connected immediately afterward and received a normal 192.168.22.x lease.

Prevention

- Leave "Enhanced IoT" off for verification/testing with a phone or laptop -- only turn it on once real IoT hardware (TV, WiiM, etc, per docs/vlan-gateway-migration-plan.md's device assignment table) is actually the thing joining, and test again afterward if it's turned on, since it may reintroduce this exact symptom for any non-IoT client that needs to join later (e.g. for troubleshooting). - A WiFi client that's visible and attempting to join but never completing auth, with a *confirmed-correct* passphrase, is a good signal to check SSID-level compatibility toggles like this one before suspecting the credential, the AP's health, or the underlying VLAN/firewall config.
update-advisor's generic loop was independently re-assessing developer-env-os.txt, already covered by check-dev-upgrades' own dedicated pipeline low
update-advisor gives developer-env-os a SECURITY verdict with installed/latest both 'unknown' • collected/update-notes/developer-env-os-summary.txt exists alongside developer-env-os.txt, and the two disagree in tone/verdict scale from what bin/check-dev-upgrades itself would report
update-advisor check-dev-upgrades developer-env verdict-taxonomy redundant-assessment   last seen: 2026-07-14

Symptoms

  • update-advisor gives developer-env-os a SECURITY verdict with installed/latest both 'unknown'
  • collected/update-notes/developer-env-os-summary.txt exists alongside developer-env-os.txt, and the two disagree in tone/verdict scale from what bin/check-dev-upgrades itself would report

Cause

bin/check-dev-upgrades is a separate systemd-timer script (installed by bin/deploy-os-updates) that runs its own dedicated Claude assessment of developer-env's pending apt upgrades, writing to collected/update-notes/developer-env-os.txt. Its SYSTEM_PROMPT is narrow and purpose-built — "will this disrupt VS Code Remote SSH / Python dev workflows" — and its verdict scale deliberately caps at HOLD (no SECURITY category, since it isn't CVE-scanning). update-advisor's own file-discovery loop treats *any* .txt file in collected/update-notes/ (excluding -summary/-impact/-digest suffixes) as a service changelog worth its own independent assessment, using its own much broader SYSTEM_PROMPT (which does have a SECURITY verdict and actively scans for CVE-like language). It picked up developer-env-os.txt and re-assessed it a second time with that prompt, producing a differently-scaled verdict on the same content. A SECURITY verdict this way was never wrong exactly (the anti-fabrication guardrail correctly reported no CVE ID was actually present), but it was redundant and confusing: two different verdict systems disagreeing about the same input, with a header format (# packages: N) that never matched update-advisor's own parse_versions_from_notes() expectation (installed=X/latest=Y) — confirming the file was never meant to flow through this path at all. developer-env-os also isn't a hosts-config.yaml service, so it could never execute via update-advisor's --execute path regardless.

Fix

Added an EXCLUDED_FROM_ASSESSMENT = {"developer-env-os"} set and filtered it out of the notes_files glob in update-advisor's run():
notes_files = sorted(
    p for p in notes_dir.glob("*.txt")
    if not p.stem.endswith(("-summary", "-impact", "-digest", "digest"))
    and p.stem not in EXCLUDED_FROM_ASSESSMENT
)
check-dev-upgrades continues to run its own dedicated assessment on its own daily schedule, unaffected.

Verify

runtee python3 bin/update-advisor --force
developer-env-os no longer appears in the "N service(s) to consider" count or in digest.txt — only services with a real hosts-config.yaml entry (or at least the expected installed=/latest= header format) get assessed.

Follow-up

If another dedicated-pipeline script starts writing its own file into collected/update-notes/ in the future, add its filename stem to EXCLUDED_FROM_ASSESSMENT at the same time, rather than waiting for a confusing double-verdict to surface it.
uu-digest.sh silence on one node vs another looks like a missing deployment, but usually just means fewer eligible packages low
One PVE node (e.g. nastynas) never sends 'security patches applied' ntfy notifications while others (proxmox-nuc, wildwood) do • Can't remember whether unattended-upgrades / uu-digest.sh was ever extended to a given Proxmox node • history.log has very few unattended-upgrade entries on one node compared to a sibling node with the same config
unattended-upgrades uu-digest ntfy apt nastynas wildwood proxmox-nuc false-alarm   last seen: 2026-07-22

Symptoms

  • One PVE node (e.g. nastynas) never sends 'security patches applied' ntfy notifications while others (proxmox-nuc, wildwood) do
  • Can't remember whether unattended-upgrades / uu-digest.sh was ever extended to a given Proxmox node
  • history.log has very few unattended-upgrade entries on one node compared to a sibling node with the same config
See also: pbs-enterprise-repo-401-blocks-apt-update

RETIRED 2026-08-12: bin/uu-digest.sh itself is gone — cron entry and deployed copy removed from all three nodes, source removed from the repo. Superseded by bin/daily-fleet-digest (cron'd on developer-env), which carries the same live apt-get update health-check idea across all 21 tracked hosts instead of just these 3, plus everything else the fleet digest covers. This entry stays as historical record of the diagnostic pattern (silence-is-not-failure, distinguishing "nothing pending" from "the health check itself is broken") — the same reasoning applies to daily-fleet-digest's equivalent check now.

Symptom

Only proxmox-nuc appears to send "security patches applied" ntfy notifications (the ones produced by bin/uu-digest.sh, title format `: security patches applied`). nastynas and wildwood appear silent, which looks identical to "unattended-upgrades was never actually deployed there" — easy to worry about, especially given the real, previously-confirmed nastynas incident (pbs-enterprise-repo-401-blocks-apt-update.md) where a broken apt-get update silently killed unattended-upgrades for weeks with zero visible symptom.

Cause

uu-digest.sh only posts to ntfy when unattended-upgrades actually installed something overnight (see its own header: "Digest: posts ONE ntfy summary per day if and only if packages were actually upgraded ... Silent otherwise"). A night with nothing eligible to install produces zero log output and zero notification — completely indistinguishable from "not deployed" or "silently broken" by looking at the ntfy stream alone. Confirmed 2026-07-22: unattended-upgrades, uu-digest.sh, and its `30 6 * * *` cron entry are installed identically (same file, same size, same Jul 6 18:03 timestamp) on nastynas and wildwood. Both hosts' apt-daily.timer / apt-daily-upgrade.timer are enabled, active, and firing on a normal schedule. apt-get update is clean on both (rules out the pbs-enterprise-401 failure mode recurring). The only real difference: wildwood's unattended-upgrades.log shows it actually found and installed packages twice in a 7-day window (once installing libtiff6); nastynas's log shows `"No packages found that can be upgraded unattended and no pending auto-removals"` on every single day in the same window. nastynas is just a quieter box with fewer Debian-Security-origin packages landing during that stretch — not a broken or missing deployment.

Diagnosis

Run this before assuming a node is missing its unattended-upgrades setup:
# 1. Confirm the package + cron + script are actually installed
for h in <host1> <host2>; do
  echo "=== $h ==="
  ssh $h "dpkg -l unattended-upgrades 2>/dev/null | grep '^ii'; crontab -l 2>/dev/null | grep uu-digest; ls -la /usr/local/bin/uu-digest.sh 2>/dev/null"
done

2. Rule out a silent apt-get update failure (the pbs-enterprise-401 class of bug)

for h in <host1> <host2>; do echo "=== $h ===" ssh $h "apt-get update 2>&1 | grep -E '^(Err:|E:)' || echo 'apt-get update: clean'" done

3. Confirm the periodic timers are enabled/active and firing on schedule

for h in <host1> <host2>; do echo "=== $h ===" ssh $h "systemctl is-enabled apt-daily.timer apt-daily-upgrade.timer" ssh $h "systemctl list-timers apt-daily.timer apt-daily-upgrade.timer --no-pager" done

4. Confirm the service itself is actually running and completing cleanly

(not just that the timer fires — the timer firing doesn't guarantee

the service found/installed anything)

for h in <host1> <host2>; do echo "=== $h ===" ssh $h "journalctl -u apt-daily-upgrade.service --since '7 days ago' --no-pager | grep -E 'Starting|Finished|Failed|error'" done

5. The decisive check — the actual unattended-upgrades log, not the ntfy

digest. This shows exactly what candidates it evaluated and why it

did or didn't act, per run:

for h in <host1> <host2>; do echo "=== $h ===" ssh $h "tail -40 /var/log/unattended-upgrades/unattended-upgrades.log" done

6. Config parity check — confirm both hosts are evaluating the same

Origins-Pattern / blacklist (rules out a silent config drift causing

one host to reject everything):

diff <(ssh <host1> cat /etc/apt/apt.conf.d/50unattended-upgrades) \ <(ssh <host2> cat /etc/apt/apt.conf.d/50unattended-upgrades)
If step 6 comes back empty and step 5 shows "No packages found that can be upgraded unattended" repeated day after day (not an error, not a gap in entries), the deployment is healthy — the node genuinely just has nothing to report. Nothing to fix. If step 2 shows an Err:/E: line, treat it as a live recurrence of pbs-enterprise-repo-401-blocks-apt-update (or a new apt-source problem) and go there first — a broken apt-get update is the one failure mode that produces this same silence *and* is an actual problem.

Prevention / follow-up

uu-digest.sh's existing health-check half already covers the failure mode that matters (a failing apt-get update, which is what actually bit nastynas once before) with an always-alert-on-failure design. There's currently no equivalent "timer fired N days in a row with zero installs across the whole fleet" signal, since that's expected/normal behavior most of the time and would be noisy to alert on. If this comes up again, the fast path is the six-step diagnosis above, in order — steps 1-3 rule out "not deployed" or "timer disabled", step 4 rules out the service crashing, and steps 5-6 give the actual answer (quiet box vs. real problem) in under a minute of SSH output.
clone-sync 'cannot fast-forward' on watchdog was an uncommitted local edit, not a real divergence -- plus watchdog's deploy key turned out to be read-only low
clone-sync's ntfy alert 'cannot fast-forward on watchdog' fires repeatedly (hourly, since clone-sync.sh exits 1 and cron re-runs it every hour without self-healing) • git rev-list --left-right --count HEAD...origin/main on watchdog shows the LEFT side at 0 (e.g. '0 3') -- purely behind, not the '2 11'-style true divergence seen in the prior incident • git status on watchdog shows a modified tracked file, not local-only commits
collect-homelab git drift watchdog process ntfy alert-text deploy-key read-only run-maintenance-window   last seen: 2026-08-20

Symptoms

  • clone-sync's ntfy alert 'cannot fast-forward on watchdog' fires repeatedly (hourly, since clone-sync.sh exits 1 and cron re-runs it every hour without self-healing)
  • git rev-list --left-right --count HEAD...origin/main on watchdog shows the LEFT side at 0 (e.g. '0 3') -- purely behind, not the '2 11'-style true divergence seen in the prior incident
  • git status on watchdog shows a modified tracked file, not local-only commits
  • git diff origin/main -- <that file> on watchdog is empty / the file is byte-identical to origin's committed version
  • attempting to commit and push the fix directly from watchdog's own clone fails: 'ERROR: The key you are authenticating with has been marked as read only.'
See also: clone-staleness-alert-wrong-service-and-watchdog-local-merge-divergence, git-checkout-discards-preexisting-uncommitted-drift, host-repo-clone-uncommitted-drift, watchdog-raspi4-clone-path-not-projects-homelab

Two different root causes can produce the identical ntfy alert

clone-sync.sh's "cannot fast-forward" alert fires on *any* `git pull --ff-only` failure and always says the same thing ("local commits exist that were never pushed"). That's accurate for the 2026-08-12 incident (clone-staleness-alert-wrong-service-and-watchdog-local-merge-divergence.md — a real orphaned local merge commit). It is not accurate for this one: on 2026-08-20, git rev-list --left-right --count HEAD...origin/main showed 0 3 (zero local-only commits, three behind) — a plain, non-diverged clone. --ff-only still refused, because the working tree had an *uncommitted modification* to bin/check-backup-freshness, and the incoming commit (fd86ea4, the same day's backup-freshness rewrite) touched that exact file. Git won't fast-forward over a change that would silently overwrite uncommitted local edits, even a trivial one. **Always check rev-list --left-right (or just git status) before assuming the alert's own "diverged, needs manual reconciliation" framing applies** — a nonzero left count is a real divergence; an all-zero left count with a dirty working tree is a much simpler problem.

Diagnosis: was the local edit unique work, or a stray duplicate?

git diff origin/main -- bin/check-backup-freshness was empty — the uncommitted content on watchdog was byte-identical to what fd86ea4 had already put on origin/main. Root cause: the backup-freshness rewrite had at some point been hand-edited directly on watchdog's own clone (SSH in, edit in place) before/while the *real* commit was made and pushed from developer-env — leaving a redundant, never-git added copy of the same change sitting in watchdog's working tree. Fix: since the diff was empty, discarding was risk-free —
ssh watchdog
cd ~/homelab
git restore bin/check-backup-freshness   # confirmed identical to origin first
git pull --ff-only origin main -q
Fast-forwarded cleanly to origin/main, no service restart needed (the pulled commits didn't touch watchdog/). If the diff had shown anything *not* already present on origin/main, this would need the same manual-reconciliation treatment as the 2026-08-12 incident instead.

Related, separately-discovered gap: 10 snapshot files stranded, uncommittable

While diagnosing, git status also showed 10 untracked collected/snapshots/*-post-update-*.json files dated 2026-08-11, 2026-08-15, and 2026-08-19. First read as a process violation ("run-update was run directly on watchdog by hand") — that read was wrong, corrected same day once traced further. The actual cause: bin/run-maintenance-window (which walks the proxmox_nodes canary chain — macbookpro-pve → nastynas → wildwood → proxmox-nuc — driving run-update per node) is *documented* to run from watchdog specifically whenever proxmox-nuc is in the chain, per its own usage block and INFRASTRUCTURE.md's "Proxmox Node Maintenance Windows" note: developer-env is itself VM 118, hosted on proxmox-nuc, so an orchestrator running there would go dark the moment proxmox-nuc reboots, with no way to observe or recover. The three batches match this exactly — the 8/11 05:31 run falls inside the proxmox_nodes `execute_window: "05:00-07:30"` and touches all four canary-chain hosts (macbookpro-pve still active then); the 8/15 and 8/19 runs cover the three still-active nodes after macbookpro-pve's 2026-08-13 shutdown. So the snapshots themselves are an expected, correct byproduct of run-update's trigger_post_deploy_snapshot() writing to /collected/snapshots/ relative to wherever it's invoked from (watchdog's clone, in this case) — not evidence of anyone working somewhere they shouldn't have. The real gap is narrower: nothing ever commits or pushes those snapshots back afterward. clone-sync.sh only pulls, and (see below) watchdog's deploy key can't push at all even if something tried — so every legitimate run-maintenance-window run against proxmox-nuc leaves its snapshot files stranded until someone notices and recovers them by hand, as happened here. Recovered same session: committed on watchdog, but the push failed — see below. Fetched the exact commit object from watchdog into developer-env's clone (`git fetch watchdog:/home/watchdog/homelab main:refs/heads/tmp`) and fast-forward-pushed *that* from developer-env, so watchdog's local HEAD already matched origin/main afterward with no further changes needed on watchdog's side. Closed same day (2026-08-20): bin/collect-homelab gained sync_watchdog_snapshots(), run every 6h right after collect_git_state. It lists watchdog's untracked collected/snapshots/*.json via `git status --porcelain, scps each down to developer-env's own collected/ snapshots/, verifies the copy with sha256sum` on both sides, and only then deletes the source on watchdog — leaving it in place (to retry next run) on any scp or checksum failure. The recovered files then ride collect-homelab's existing git add inventory/ collected/ + commit + push, no new git logic needed. This was chosen over the other two options considered (a write-capable deploy key on watchdog; `run-maintenance- window` scp-ing its own output out) specifically because it needed **zero new credentials and no change to watchdog's read-only posture** — the guardrail that made the check-backup-freshness mistake above recoverable in the first place stays fully intact; developer-env remains the only clone that can ever write to origin/main. Deleting the source after verified recovery also prevents the exact "untracked file would be overwritten by merge" collision that a later clone-sync.sh pull would otherwise hit once the recovered copy reached origin/main at the same path.

Discovery: watchdog's deploy key is read-only

Attempting git push origin main directly from watchdog's own clone failed outright:
ERROR: The key you are authenticating with has been marked as read only.
fatal: Could not read from remote repository.
This wasn't previously documented anywhere in this repo. It means watchdog's deploy key was already, deliberately or not, scoped read-only — clone-sync.sh's pull-only design (see its own header comment) is enforced at the credential level too, not just by convention. Worth knowing before assuming any push-from-watchdog approach is available as a fallback in future incidents: it isn't, and won't be unless the key is deliberately re-scoped.

Prevention

Only the bin/check-backup-freshness hand-edit was actually a mistake — that's the one real "don't do this" here: never SSH onto watchdog to edit a tracked file in its clone in place, since anything landed there has no path back to origin/main (the deploy key can't push it) and will eventually collide with the same change arriving properly via developer-env. CLAUDE.md's Developer Environment section states this. bin/run-maintenance-window running from watchdog is *not* part of that prevention rule — it's correct, documented, necessary behavior, and stays that way. What actually needs fixing is the missing return path for collected/snapshots/ content it produces there — see "Still open" above.
watchdog's (and raspi4's) homelab clone lives at ~/homelab, not developer-env's ~/projects/homelab -- a naive ssh+cd fails with 'No such file or directory' low
ssh watchdog "cd ~/projects/homelab && <command>" fails with 'bash: line 1: cd: /home/watchdog/projects/homelab: No such file or directory' • same failure shape ssh'ing to raspi4 with the developer-env path • the host is definitely up and the repo clone definitely exists -- just not at that path
watchdog raspi4 ssh git clone path developer-env run-maintenance-window   last seen: 2026-08-15

Symptoms

  • ssh watchdog "cd ~/projects/homelab && <command>" fails with 'bash: line 1: cd: /home/watchdog/projects/homelab: No such file or directory'
  • same failure shape ssh'ing to raspi4 with the developer-env path
  • the host is definitely up and the repo clone definitely exists -- just not at that path
See also: collect-homelab-clone-staleness-and-update-log-split

Symptom

Composing an ad-hoc ssh watchdog "cd ~/projects/homelab && bin/