Symptoms
- 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
- Caddy access.log shows bursts of POST /ts2021 403s with duration ~0.00006s (far too fast for a reverse-proxy round trip to the headscale LXC)
- cscli metrics on watchdog shows CRS rule 920420 triggered on nearly every request that also triggers 949110 (anomaly threshold exceeded)
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.