2026-06-08: The SSH Key The Box No Longer Needs

The box moved. That was all it took. For months the screen-time enforcement stack had called itself model-agnostic, and it half was: the bridge finds the box by mDNS, the policy API works on Purple and Gold alike, nothing in the code says goldpro out loud. Then the swap put a Gold Pro where the old box used to sit — mode=router, model=goldpro, firmware v1.982. The agnostic stack immediately reached for three things the new box had never heard of. A per-box SSH key it was never told to trust. A hardcoded [email protected] from a subnet that had since migrated. And a set of protocol assumptions that each failed by returning something shaped like success. “Model-agnostic” had quietly meant “works on any box, as long as it is this box, at this address, with this key on it.” The swap was the test the claim had never been made to pass.
The trio that lied as success
Section titled “The trio that lied as success”Three bugs sat under the SSH dependency. They survived for the reason they were dangerous: none of them threw. Each returned a value the caller read as fine.
First, policy:delete. The bridge had been sending {pid} to the box’s policy:delete verb. But the box’s netbot.js reads value.policyID — and, in the array form at netbot.js:2380, value.policyIDs. So a payload keyed pid made the box read value.policyID === undefined, call getPolicy(undefined), and throw “invalid policy.” Downstream, the screen-time path had learned to cope with that throw. It SSHed the box to delete the leaked policy_N.conf by hand. That is how a wrong key name on a delete grew an entire SSH limb. Sending {policyID: String(pid)} is the shape the box has expected the whole time, on every model from Purple to Gold Pro. Once the delete actually deletes, the leak it left behind stops existing, and the SSH limb that swept up after it has nothing left to do.
Second, acl:false. It reads like “this device’s access is off — blocked.” In Firewalla it is the exact opposite. acl:false adds the device to acl_off_set, a RETURN in the FW_DROP chain; it exempts the device from all enforcement. Reading the field as a block signal meant the truth and the stored bit pointed opposite ways. Worse, the /pause path had at one point set acl:false thinking it was tightening — when it was punching a bypass. The real, cross-model block primitive is the presence of a {type:"mac", action:"block", target:<MAC>} policy. So “is this device blocked?” is answered now by whether that policy exists, never by the acl field. And /pause creates the mac-block and pointedly does not touch acl.
Third, and deepest: trusting the ACK. The bridge would call policy:create, get a success back, and report the block in place. But an accepted policy:create means the box registered the rule — not that it applied it. The box’s firemain DomainBlock sensor can stall, registering DNS policies it never compiles into dnsmasq. A 200 from the bridge, a success from the box, and the kid still resolving YouTube. The old sentinel guarded against this by SSHing in and grepping the dnsmasq confs for proof. That proof, it turns out, the box will hand you. When it truly applies a policy, it stamps an activatedTime. The ACK says “I heard you.” The activatedTime says “I did it.” Conflating the two is the entire bug.
The box was speaking the whole time
Section titled “The box was speaking the whole time”The realization that retires the SSH key is small, and a little embarrassing. The box already speaks a full protocol over encipher. Almost everything the SSH paths were reaching for is already in it.
| You want to know | SSH used to ask | The box already says |
|---|---|---|
| What box is this — mode, model, firmware | ssh + parse, or hardcode it | FWInitMessage → mode / model / version / branch; sysInfo → human Model/Version |
| Is this device monitored (spoof on) | infer from box state over SSH | host.policy.monitor in the /hosts payload |
| Is this DNS block actually live | grep policy_*.conf over SSH | a policy in /policies with activatedTime set |
| Apply / remove a block | (already API, but delete was broken) | policy:create / policy:delete {policyID} |
In router mode — which the Gold Pro is — all LAN traffic is forced through the box anyway. So a block enforces regardless of any one device’s spoof state. The bridge now derives an enforcement_ready capability from mode === "router" rather than auditing every device’s monitor bit. The box even knows its own name. It does not need us to hardcode it.
The bridge learns to introspect
Section titled “The bridge learns to introspect”The change went in bottom-up — bridge first, because everything above it reads the bridge — against commit 4832725 on the screen-time repo. The bridge (deployed/firewalla-bridge.js) learned to introspect. refreshBoxInfo() sends an FWInitMessage and folds in sysInfo.releaseInfo, caching mode/model/version for five minutes so the hot path never pays for it. /health now carries a box{} block and a capabilities{} block. A new GET /info returns box identity plus the canonical service-domain map, so consumers stop carrying their own copy. /policies gained ?type= and ?action= server-side filters, so a caller can ask for the dns-block policies on a MAC without sifting client-side. /host(s) gained derived blocked (mac-block-policy presence, not acl) and monitored fields. deletePolicyWithFallback now retries the encipher API three times with backoff before it even considers SSH — because the API is the cross-model path, and SSH is a net that, on a freshly-swapped box, is simply not strung. The hardcoded mDNS candidate list, the discovery port, and the SDK login email all moved to env, with model-agnostic defaults that now include firewalla-goldpro.local.
The library stops SSHing
Section titled “The library stops SSHing”The enforcement library (screen_time.py) had its two ground-truth helpers rewritten. _firewalla_conf_present no longer SSHes the box to grep dnsmasq confs; it asks GET /policies?mac=&type=dns&action=block and matches the screen’s service domains against the returned policy targets. _purge_dnsmasq_residual no longer SSHes in to rm leaked confs and SIGHUP dnsmasq; it reads the policy list to detect residue and POSTs /host/<mac>/unrules to clear it — and if residue survives the API clear, it escalates rather than reaching for SSH. Both keep their tri-state discipline intact: True blocked, False reachable-and-not-blocked (drift), None bridge-unreachable. The None rung is load-bearing, and unchanged in meaning. A bridge blackout must never read as drift — the fail-closed path would then pause a HomeKit-hub Apple TV we merely couldn’t reach. The dead _FW_SSH_* constants — including the literal [email protected] — were deleted outright. The Sonos TTS URL was de-hardcoded the same way (config, then env, then a default), since the LAN it pointed at had moved.
The sentinel becomes a detector
Section titled “The sentinel becomes a detector”The confgen sentinel (deployed/sentinels/firewalla-confgen-sentinel.py) was rewritten from a remediator into a detector. It used to hold an SSH key so it could restart firemain when it caught a wedge. It now holds nothing of the sort. Each run mints a fresh locally-administered canary MAC (02:00:5E:xx:xx:xx, mapped to no real device, fresh each run so a leftover can’t false-HEALTHY it), clears it, creates a short-lived netflix block with an expire bound, and polls /policies for a policy scoped to that MAC, not disabled, with activatedTime set — the box’s own confession that it applied the rule. Present within the poll window: HEALTHY. Never appears: WEDGED. On WEDGED it fires a rate-limited critical Force Flow alert for a human, because no protocol verb restarts the box’s firemain, and we no longer keep a key to do it out of band. The drift-sentinel doctrine holds: detect and escalate, never auto-remediate the thing you can’t verify you fixed.
activatedTime before the conf-write
Section titled “activatedTime before the conf-write”The single subtlety that makes the canary trustworthy is when the box stamps activatedTime relative to when it writes the dnsmasq conf. The box sets activatedTime as part of accepting-and-applying a policy. The conf-write is downstream of that. So activatedTime is the earlier, stronger signal: a policy with it set is one the box has committed to applying, even in the brief window before the conf file lands on disk. The old SSH grep watched the last artifact in the chain — the conf file. It could be fooled by a leaked stale conf from a prior cycle reading as a fresh success. That is exactly the false-HEALTHY the policy:delete leak used to manufacture. Reading activatedTime watches the box’s own commitment instead of its filesystem residue, which is both earlier and harder to spoof. With the delete now actually deleting, the stale-conf false-HEALTHY can’t happen anymore. The per-run fresh MAC stays in as a second, independent guard — because two cheap guards on a thing that gates a child’s enforcement are not redundant, they are belt and suspenders.
In the course of the cutover, the fixed delete swept up what the broken one had left. 77 leaked curfew rules had accumulated on the box — policies the old policy:delete {pid} had reported deleting and hadn’t — and they cleared in one pass. That number is the size of the bug, measured in residue.
The one SSH path that stays — named, not hidden
Section titled “The one SSH path that stays — named, not hidden”There is exactly one thing the box’s protocol has no verb for: writing an arbitrary A-record. The encipher policy API can block a device, block a domain, expire a rule, report what it applied. It cannot say “resolve chalet-sonos.lan to 10.0.0.10.” Custom local DNS A-records are not policies; there is no policy:create shape that expresses them. So that one path keeps its SSH limb, and the bridge advertises it honestly: capabilities.custom_dns_ssh_required: true, sitting right next to dns_block_api: true. It is de-hardcoded like everything else — no literal box IP, discovery owns the address — but it is the one place a key still touches the box, and the capabilities block says so to anyone who asks /info.
Work inventory
Section titled “Work inventory”| Layer | File | Change | Why it matters |
|---|---|---|---|
| Bridge | deployed/firewalla-bridge.js | getBoxInfo/refreshBoxInfo via FWInitMessage; /health reports box{} + capabilities{}; new GET /info; /policies ?type=&action= filters; /host(s) derived blocked+monitored; policy:delete uses {policyID}; /pause makes a mac-block, never acl:false; delete retries API 3× before SSH; mDNS names / port / SDK email de-hardcoded | The box’s identity and enforcement truth become readable over encipher; the protocol bugs are root-caused at the layer they live in |
| Library | screen_time.py | _firewalla_conf_present + _purge_dnsmasq_residual verify/clean via the policy API (GET /policies, POST /host/<mac>/unrules); _bridge_get_sync/_bridge_post_sync added; dead _FW_SSH_* (incl. [email protected]) removed; Sonos TTS URL de-hardcoded; _host_blocked reads the bridge blocked field | Ground-truth checks stop depending on a per-box key and a stale subnet; tri-state None-is-unreachable safety preserved |
| Sentinel | deployed/sentinels/firewalla-confgen-sentinel.py | Full rewrite to detector-only + API canary; polls /policies for activatedTime; all SSH (fw_ssh, restart_firemain, FW_SSH_*) and the firemain auto-restart removed; WEDGE → rate-limited critical Force Flow alert | A wedge is now detected by the box’s own activation stamp and escalated to a human, not silently SSH-restarted |
| Residual | (bridge) | Custom A-record write kept on SSH; advertised as capabilities.custom_dns_ssh_required: true | The one thing the protocol can’t express is named, not pretended away |