Files
branderson 7cba3932c2
tests / runner-env (pull_request_target) Successful in 39s
tests / tests (pull_request_target) Successful in 1m2s
Bound the LAN over IPv6, and over the range a tailnet uses
The node firewall's DROPs named IPv4 ranges only, so the control this
project exists to provide did not apply to IPv6 at all. That is open by
default on every network including ones with no IPv6 in them: every Linux
host brings up an fe80:: address on every interface whether or not anything
routes v6, and a daemon bound to :: answers on it. Neighbours were reachable
across the segment with all four DROPs in force, `ip -4 route` showed
nothing unusual, and `dejima-devbox-audit reach` reported PASS.

Dropped whole rather than enumerated, because IPv6 has no RFC 1918. A v6
network is numbered out of a globally routable delegated prefix, so no
equivalent range list can be written here - fc00::/7 catches ULAs most
networks do not use and misses the prefix a neighbour is actually on. On the
node rather than in the guest, for the same reason as every other rule here:
a sysctl is a setting root on the guest puts back.

Also drops 100.64.0.0/10. A tailnet is numbered there and is none of the
three RFC 1918 ranges, so a devbox on one reached every machine on the
overlay.

The audit gets a matching check: it asks ff02::1 who is on the link and
counts the replies that are not its own. No positive control, because both
reasons for v6 silence are the outcome wanted - unlike the v4 case, where a
working firewall and a broken network look identical. It does report
INDETERMINATE when the probe cannot run at all, which is why bootstrap now
installs iputils-ping; without it the check would have passed vacuously,
which is the one thing this program is built not to do.

Docs: the threat model gains a "Not bounded" list collecting the negative
space in one place, a section on the two address families, two new entries
under "Rejected, with reasons", and a quarterly review cadence with the
conditions to check rather than re-argue.

Tested with `bats test/` (114 passing) and by exercising the probe against
recorded ping output, including its own reply and DUP! lines.
2026-08-25 14:40:23 -07:00

7.2 KiB

Reference

Detail that would clutter the README.

The three verdicts

dejima-devbox-audit reach probes the default gateway on 443, 80 and 22, and reports one of three things.

Verdict Means Exit
PASS Every probe timed out, and a positive control succeeded 0
FAIL Something answered: a handshake, or a refusal 1
INDETERMINATE Every probe timed out and the positive controls also failed 1

Why three and not two. A timeout means "no answer came back", which is the firewall working or the network being broken. Reading it as a pass is a false negative, and false negatives are the wrong direction here: the operator stops looking. So silence alone is never enough.

Why a refusal is a failure. A closed port still proves the packet reached a live host and something sent an answer back. Silently dropped is the only state that counts as contained.

Why not 53. The resolver exception may legitimately permit it, so a rule working exactly as designed would read as a failure.

Why the gateway. Its address is already in the routing table, so naming it writes down nothing an attacker on this box could not read anyway. It is guaranteed to be alive, which an arbitrary address is not. And it sits inside the ranges the firewall drops, so with the rules applied it must be unreachable as a destination while traffic through it is untouched.

The strongest case is free when your resolver is your gateway, which is common on home networks: one host answers on 53 and times out on 443, and no routing failure can produce that.

A known limit. bash's /dev/tcp cannot distinguish ECONNREFUSED from EHOSTUNREACH, so a rule that REJECTs rather than DROPs looks like a live host refusing and produces FAIL. That is the safe direction to be wrong in, and it is why the template specifies DROP.

The IPv6 check

dejima-devbox-audit reach runs a second check on the other address family and reports on it separately. Anything but PASS there fails the whole run, even when the gateway probe passed.

Verdict Means Exit
FAIL A global or unique-local IPv6 address is configured here, or something that was not this VM answered ff02::1 1
PASS Neither, and the probe ran 0
INDETERMINATE The probe could not run: no ping, or no interface to ask on 1

The three states are not the three above. There, INDETERMINATE is silence this program cannot interpret, because a working firewall and a broken network look identical from inside the VM. That ambiguity does not exist here. Both reasons a segment stays silent over IPv6 - dropped at the node, or never configured in the guest - are the outcome wanted, and no service a devbox needs lives on this family for the silence to be hiding one. So a silent segment is a plain PASS and there is no positive control, because there is nothing to disambiguate.

INDETERMINATE here means the question was never asked. ping comes from iputils-ping, which dejima-devbox-bootstrap installs and a host set up before it did will not have. Nobody answering because nobody was asked is not containment, and the one thing this program will not do is call it a pass.

Why an address is enough on its own. A global or unique-local address got here by SLAAC or DHCPv6, so an advertisement reached this guest and a reply left it. That is the node not dropping v6, evidenced rather than inferred. Link-local is excluded because the kernel brings one up regardless and its presence says nothing.

Why ff02::1. It is the all-nodes link-local multicast address and every IPv6 host answers it - and every Linux host is an IPv6 host, on a network that routes no v6 at all. So it finds neighbours a v4 reading says are not there. Our own replies are excluded; counting them would fail every contained VM. Like the gateway, it is not a target worth writing down: it is the same address on every network there has ever been.

Why this is not the gateway probe with a -6. Where the rule is working there is no IPv6 default route to probe, so the check has to work without one. It takes its interface from the v4 default route instead.

The positive controls

Either one is sufficient:

  • the forge's name resolves, which exercises the resolver exception;
  • the forge answers on 443, which exercises layer 4 and the forge exception.

Requiring both would turn an unrelated outage into a report that gets ignored.

The three addressing modes

Mode Boundary cost Maintenance
Static in the guest None. Six DROPs plus the named exceptions, and no DHCP rules The address is asserted in two places
Reserved at the router Needs both halves of the DHCP pair One source of truth
Plain DHCP Same DHCP pair None, but the address moves

Static is the recommended default here, on the strength of being the only mode that needs no DHCP rules at all.

Plain DHCP is more workable here than in the sister project, because the inbound rules are scoped to client subnets rather than to this VM's own address, so a changed lease breaks nothing in the rule set.

What the audit reports about addressing

dejima-devbox-audit radius names the mode and, on DHCP, checks the lease.

Static is decided before any lease file is consulted, deliberately: a host pinned by dejima-devbox-static-ip keeps the leases from whatever it was doing before, and letting those decide would tell every pinned host it needs rules it does not need. A setup matching neither is reported as unknown rather than assumed to be the friendlier answer.

The lease check exists for the worst failure in this design. A DHCP guest behind these rules renews through a path the firewall may be dropping. Nothing breaks at boot, because the first lease was obtained before the rules mattered. The VM stops renewing and falls off the network up to a full lease time later, looking nothing like a firewall rule.

It is detected without keeping any state between runs. The obvious design records the expiry each run and watches whether it advances, which means this program starts writing files. It does not need to: dhclient rewrites the lease file on every successful renewal, so the file's own mtime against the renewal time it records answers the question in one pass. Past T1 with an mtime older than T1 means dhclient asked and got nothing back, which is exactly the shape of a dropped reply, and it is visible hours before the lease expires.

Configuration

/etc/dejima-devbox/config, one key:

forge_host=forge.example.invalid

That value must match the destination of the forge rules on the hypervisor. They are entered in two places and are silent when they disagree: the audit reports INDETERMINATE, which is correct and unhelpful.

Prefer a name over an address. Both work, but getent hosts on an address is a reverse lookup, and a forge without a PTR record fails it, so the resolution control quietly stops contributing.

There is no list of what this VM must not reach, and there should never be one. Such a list is a map of valuable targets kept on the machine this project assumes will be owned, describing topology not otherwise obtainable from here without loud scanning.