docs(adr): read device joins from the DHCP server, not the eero cloud (#98) - #271
Merged
Merged
Conversation
…#98) #98 asks for a Home Assistant integration with the eero API so device joins stop being accidents, and it is right about where the events belong: Alloy on 1514, Loki, a rule in security.rules.yaml. It is the source that was assumed rather than chosen, and the assumption is what blocked the issue behind ADR-0008's unbought mini PC. Checked first, which changed the answer. There is no local eero API. The integration everyone means — schmittx/home-assistant-eero, a HACS component, not core — polls https://api-user.e2ro.com every 120 seconds and cannot log in with an Amazon-linked account. So a join on this wire would go to Amazon and come back over the WAN, which means it is unavailable exactly when the WAN is. Meanwhile morpheus already knows. The eeros are bridged — every phone and camera takes its lease from Kea on the firewall — and the lease log is 1,200 lines a day, one pfSense checkbox from the listener that already carries filterlog. Measured on the firewall on 2026-09-04. A lease is not a join: valid-lifetime is 7200s, so DHCP4_LEASE_ALLOC is mostly renewal (4,732 in four days from 57 MACs). The first line from a MAC on a segment is the event, and LogQL expresses that with no state of its own — the last 10 minutes `unless` the 7 days before it. `mac` is a parser label, never an ingest one, for ADR-0003's reason; the address is deliberately not in the `sum by`, because a device renewing onto a new address would then read as a stranger. There is no allowlist of household MACs anywhere, which is the half security.md's publication policy would not have allowed. Three rules: first lease on Hicks in seven days (warning, measured at about one every three days, and all five in the sample were worth a look), the same on Winterfell (critical, zero in 13 days), and DhcpLeaseLogsStopped at two hours against a measured worst gap of 975 seconds — because the first two fail silently, and unticking the DHCP class alone is a fault FirewallLogsStopped cannot see. Verified against a real Loki, not just parsed: seeded with eight days of renewals, the committed rules fire for a new MAC, for a MAC known on another segment, and for one absent nine days, and stay silent for the device renewing throughout. absent_over_time returns 1 for a stream that never existed, which is why the DHCP class has to be ticked before this deploys. Leaves are dropped rather than deferred: 9 releases against 4,732 allocations, and a departure is not a security event. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
There are a few concrete doc/alert-text inconsistencies/clarity issues (including an outdated count and a broken alert description sentence) that should be corrected before merge.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR implements ADR-0019 by adding Loki alert rules that treat “device joins” as first-seen DHCP lease activity from Kea on morpheus (instead of polling the eero cloud), and updates documentation to reflect the new detection approach and rule counts.
Changes:
- Add a new
dhcpLoki rule group with two “unknown device” alerts (VLAN 50/99) plus a “lease logs stopped” watchdog. - Document the operational prerequisite (enable pfSense “DHCP Events” remote logging before deploying rules) and expected cold-start behavior.
- Update repository documentation and summaries to reflect the increased log-rule count and the new threat-model coverage.
File summaries
| File | Description |
|---|---|
| stacks/observability/loki/rules/security.rules.yaml | Adds DHCP-based security/monitoring Loki alerts for first-seen MACs on VLAN 50/99 and missing DHCP logs. |
| README.md | Updates top-level and layout summaries to reflect the new Loki rule count. |
| docs/security.md | Extends threat model + documents DHCP-based join detection and its limitations. |
| docs/runbooks/ship-firewall-logs.md | Updates runbook with DHCP Events enablement, validation queries, and rollback guidance. |
| docs/roadmap.md | Updates #98 roadmap entry to reflect ADR-0019’s approach and removes the HA dependency. |
| docs/observability.md | Documents pfSense log ingestion (including Kea DHCP leases) and updates total rule counts. |
| docs/adr/0019-read-device-joins-from-the-dhcp-server.md | Adds the ADR capturing the rationale and consequences of DHCP-based join detection. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…se menu Three points from review on #271, all correct. - security.md's threat-model row counted 48 where the control it describes — losing visibility of a failure, "30 days of metrics and logs" — is now all 64 rules. check_docs.py accepts either reading, which is why it did not catch it. - UnknownDeviceOnManagementSegment's description read "the segment ADR-0002 says compromise of is total". It is on-call text; it now parses. - FirewallLogsStopped has been sending people to Services → System Logs → Settings since it was written. System Logs are under Status, which is what both runbooks say. Pre-existing, one line, fixed here rather than left to disagree with the DHCP rule next to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gerrrt
added a commit
that referenced
this pull request
Sep 4, 2026
main is currently failing its own numbering guard: #98's "read device joins from the DHCP server" and #101's "run the lab stack in a guest" both merged as ADR-0019, twenty-three minutes apart, and check_adr_numbers reports the pair on any tree containing both. That blocks every open pull request, not this one. check_docs.py states the rule in the failure text — "renumber the one that landed second" — so this is arithmetic rather than a judgement call. Landing order on main's first-parent line, which is when a document became referenceable rather than when it was written: da0a071 06:33 #271 device joins stays ADR-0019 c7071c0 06:40 #270 lab stack becomes ADR-0020 (this branch, unmerged) becomes ADR-0021 The converge ADR yields to both, because merged work should not renumber to accommodate an open branch. Its number has now moved twice in one day, which is the cost of three ADRs being written against the same tip. Also fixes two link-text/target mismatches this branch introduced earlier: the 0019->0020 pass used a blanket `ADR-0019]` -> `ADR-0020]` substitution scoped to files mentioning converge, and roadmap.md and observability.md mention both, so two device-joins links were relabelled while still pointing at the right file. check_docs.py compares a filename to its own H1 and cannot see that, so both are now checked directly: every [ADR-NNNN](...NNNN-*.md) in the tree agrees with its target, and every ADR link resolves. The README conflict was main reflowing the runbook list against this branch adding `converge` to it; kept both. The adr/ count goes to 21. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #98 — with the landing site the issue chose and a different source, and
no longer blocked behind ADR-0008's mini PC.
#98 is right that device joins belong in the log pipeline rather than in a Home
Assistant notification. What it assumed rather than chose is where the events
come from, and that assumption is the whole of the dependency: Home Assistant
sits on ADR-0008's sensitive tier, on hardware nobody has bought.
What the checks found
There is no local eero API. eero is app-and-cloud managed; the units serve
no management interface on the LAN. The integration everyone means is
schmittx/home-assistant-eero— HACS, not Home Assistant core — and read on 2026-09-04 its API base is
https://api-user.e2ro.com, its default poll interval is 120 seconds, and itsREADME says it cannot log in with an Amazon-linked eero account at all.
So the proposal in full: a join that happens on this wire is reported to
Amazon, held there, and fetched back over the WAN up to two minutes later — a
security signal that is unavailable exactly when the WAN is.
morpheusalready knows. Measured on the firewall, 2026-09-04:kea-dhcp4DHCP4_LEASE_ALLOCkea-dhcp4is excluded from the System Events selector, so the DHCP content class has to be tickedThe eeros are bridged, so every wireless client in the house takes its lease
from Kea. The event is one checkbox from the listener that already carries
filterlog, on a path that never leaves the house.
The rules
A lease is not a join — with a two-hour lifetime,
DHCP4_LEASE_ALLOCisoverwhelmingly renewal. The event is the first line from a MAC on a segment,
which LogQL expresses with no state of its own: the last 10 minutes
unlessthe 7 days before it.
dhcpd.logUnknownDeviceOnTrustedSegmentUnknownDeviceOnManagementSegmentDhcpLeaseLogsStoppedTwo design points worth the review:
macis a parser label, never an ingest label — ADR-0003's cardinalityconstraint, the same call as
src/dstin the filterlog rules beside it.And the address is deliberately not in the
sum by: a known devicerenewing onto a different address would otherwise have no match on the
right-hand side and would fire as a stranger.
is whatever Loki has seen in seven days. A rule that enumerated them would
have published exactly what
security.mdsays this repository withholds.Verified, not just parsed
check_loki_rules.shproves the LogQL parses. The semantics were checkedseparately against a throwaway Loki 3.7.7 seeded with eight days of synthetic
renewals: the committed rules fire for a brand-new MAC, for a MAC long known on
another segment, and for one absent nine days — and stay silent for the device
that renewed throughout.
absent_over_timereturns 1 for a stream that neverexisted, which is why the ordering below is not optional.
Before this merges
Tick DHCP Events in Status → System Logs → Settings on
morpheus, theunreliable-save way
ship-firewall-logs.mddocuments. Merged ahead of thecheckbox,
DhcpLeaseLogsStoppedfires truthfully and permanently — the sameordering ADR-0018 needed for the host override.
Then expect one alert per device for the first week — about twenty on Hicks
and three on Winterfell — while the seven-day baseline fills. That is an
inventory check, not noise to silence.
Also
allocations, and a departure is not a security event.
observability.mdandsecurity.mdcountsupdated,
check_docs.pyagrees.security.mdgains the threat-model row and the three limits — leases notassociations, segment not radio, private MAC rotation as the noise floor.
🤖 Generated with Claude Code