Inbound reachability
A subnet join, a pairing ceremony, and every peer dial toward this machine
arrive as inbound UDP (QUIC, plus mDNS on the LAN). If something drops
that traffic, the node still looks healthy from the inside — the daemon runs,
the endpoints list, outbound sends work — and joins toward it simply never
arrive. That silent shape has a name in this project’s history: the
NO_SEED_HOLDER dead-end.
The daemon therefore checks its own inbound reachability at every startup and
records the verdict. What it then says depends on who is reading.
spt subnet status and the coming-online banner speak only when there is
something to warn about: a node that was positively verified and a node whose
probe could not answer both print nothing at all. That silence is deliberate,
not an oversight — a view that announces “inbound is fine” on every healthy run
is one an operator has stopped reading by the run where it is not. A program
cannot work from silence the way a person can, so the machine surface says the
verdict in every state instead; see
reading the verdict from a program.
Three layers can block the traffic, and they are not equally fixable from inside the machine.
Windows: the firewall rule is checked against the running binary
The installer registers a program-scoped inbound-UDP allow rule when it is run elevated. Program-scoped means the rule admits one executable path, and that is the detail that matters:
- A rule can exist and still block you, if the binary that binds the socket has moved, been reinstalled elsewhere, or is a development build running from a different directory.
- So the check is not “does a rule with that name exist” — it is does the rule admit the binary that is actually running the daemon. A rule naming some other build fails the check and says so, naming both paths.
When the check fails and the daemon is running elevated, it repairs the rule onto its own path. Unelevated, it reports the exact command instead and changes nothing. If no daemon is resident, the check reports that it cannot say — it never guesses which binary would have bound the socket.
Re-checking at every daemon start is deliberate. An unelevated install prints a warning and continues, so the rule may simply never have landed; nothing would have re-examined it otherwise.
Linux: the host firewall is verified and reported, never modified
On Linux the daemon detects an active host firewall (ufw, nftables,
firewalld), works out whether inbound UDP to the port it actually bound is
permitted, and renders the command that would permit it. It does not write
firewall rules.
The reason is that spt’s QUIC endpoint currently binds an ephemeral UDP port, so a rule written for today’s port would be stale after the next restart while still reading as “fixed”. A durable rule needs a pinned port, which is a separate change with its own security implications; until then, verify-and-tell is the honest behaviour.
If a firewall manager is running — ufw or firewalld — the check reports
that manager and its commands, never nftables. nftables is the backend those
tools write into, so on a managed host its rules are the manager’s: editing
them directly would be overwritten, and it is not the language you administer
that box in. nftables is named only when no manager is active.
Two answers are worth distinguishing:
| What you see | What it means |
|---|---|
| “… does not admit inbound UDP to the port this daemon bound” | An active firewall was read and it does not permit the port. Run the printed command. |
| “… could not read its ruleset (it needs root) … unverified” | A firewall is active but the daemon is unprivileged and could not inspect it. Not known blocked, not known open — run the printed check. |
Silence means the check had nothing it could positively determine. It is not a guarantee of reachability; see the next section for why nothing local could be.
Cloud providers: inbound UDP is your requirement to satisfy
A provider-level firewall — a cloud vendor’s security group or network firewall, e.g. a DigitalOcean droplet’s — sits outside the machine. No installer and no local probe can see it or change it, by design.
If you run a node on cloud infrastructure, inbound UDP must be allowed to it at the provider. This is a hosting requirement, not something spt can arrange for you. Unsatisfied, it produces exactly the symptom above: joins and pairings toward the node time out with nothing to see locally, while the node itself reports healthy.
When a node is unreachable and the local checks are silent, the provider firewall is the first thing to check.
Reading the verdict from a program
spt subnet status --json carries the verdict as an inbound object — in
every state, and never omitted. The --nodes form of the same command
carries it on the same terms, because a field that appears and disappears with
an unrelated flag is one no consumer can depend on.
{
"daemon_running": true,
"inbound": { "verdict": "path_mismatch", "rule_path": "…", "running_path": "…", "fix": "…" }
}
The state is the verdict key, and it spells itself the way the daemon’s own
record does:
verdict | what it means | also carries |
|---|---|---|
ok | inbound UDP was positively verified to reach the binder | — |
missing | no firewall rule covers the binder at all | fix |
path_mismatch | a rule exists but names a different binary than the one holding the socket — green by name, blocked in fact | rule_path, running_path, fix |
blocked | an active host firewall was read and does not permit the bound port | firewall, fix |
unverified | a firewall is active but its ruleset could not be read — neither blocked nor fine | firewall, check |
unknown | cannot say: the probe failed, the platform has no firewall story, the record is stale, or no daemon is running | — |
path_mismatch carries running_path, not binder_path, and it is worth
saying why the name is what it is. The daemon’s on-disk record wraps the
verdict in an envelope that carries a binder_path of its own; while the
variant used the same name, the two serialized to a duplicate key, the record
failed to parse, and the verdict fell back to unknown (releases#172). So
until that fix, path_mismatch could not reach this field at all — a consumer
reading binder_path here was reading a shape the daemon had never once
emitted. running_path means exactly what the old name did: the image the
daemon that holds the socket is actually running.
Two properties are worth relying on:
unknown is a real answer, not a missing one. With no daemon running the
read resolves no live binder and answers unknown on its own — there is no
special case for it, and no state where the field is simply absent. So a
consumer never has to tell “verified fine” from “cannot say” from “this build
did not emit the field” by looking at what is not there.
The fix and check commands are built by the process that knows the real
binary path and bound port — render them verbatim rather than reconstructing
them. A command rebuilt from the outside drifts from the thing the daemon
actually checked, which is the same class of failure as path_mismatch itself.
This is the deliberate asymmetry with the human views above, and neither side should be “harmonised” into the other: silence is right for a person reading a status view, and useless to a program reading a field.