# 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](#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.

<!-- [doc->REQ-INBOUND-WINDOWS-RULE-BINDER-PATH-MATCH] -->
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

<!-- [doc->REQ-INBOUND-LINUX-HOST-FIREWALL-VERIFY] -->
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

<!-- [doc->REQ-INBOUND-PROVIDER-FIREWALL-DOCS] -->
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

<!-- [doc->REQ-INBOUND-VERDICT-SURFACED] -->

`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.

```json
{
  "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 | — |

<!-- [doc->REQ-INBOUND-RECORD-WIRE-FIDELITY] -->

**`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.
