Skip to content

Let a deployment run its own rendezvous, and give the lab three NATs - #58

Merged
op-q merged 13 commits into
mainfrom
feat/netlab
Sep 10, 2026
Merged

op-q merged 13 commits into
mainfrom
feat/netlab

Conversation

@op-q

@op-q op-q commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Two halves of one problem: the direct path's rendezvous was compiled in, so a network with egress filtering could not use the direct path at all, and the lab could not test it hermetically.

The feature

DROP_RENDEZVOUS_RELAY and DROP_RENDEZVOUS_BOOTSTRAP point the iroh relay and the DHT bootstrap somewhere else. Environment variables rather than flags: rendezvous is a property of the network a machine is on, set once and never changed between two transfers.

A malformed value is an error, never a silent default. An operator who meant to keep rendezvous inside their network and quietly got the public DHT has lost exactly what they configured, invisibly.

Recorded as decisions.md entry 15, with what an operator takes on in security.md.

The lab

netlab/rendezvous/ runs an iroh relay and a three-node mainline testnet in one process, deliberately outside the workspace — iroh-relay's server feature pulls in hyper, rustls and an ACME client, none of which belongs in a binary shipped for four targets.

Three findings changed the lab rather than the code:

  • Phase 4's assertions tested nothing. Carrier::Direct is reported as soon as rendezvous and connect succeed; nothing consults whether a hole was punched. A punch is now measured by counting bytes on the rendezvous link, and NAT mapping behaviour by comparing source ports seen by two destinations.
  • The lab died where it should have skipped. user_namespaces_available() read two sysctls that both say yes on Ubuntu 24.04+, where the refusal comes from apparmor_restrict_unprivileged_userns. conftest acts on that with execvp, so a wrong yes left no process alive to report anything. The probe now attempts a namespace instead of predicting one.
  • Outbound UDP is not blocked, only port 53 is. Two DHT routers answered, and the reply carried this host's public address.

Known failure

First run 2026-09-10: 7 passed, 1 failed. test_a_full_cone_nat_is_punched_through reproduces three runs of three.

It is not the direct path in general — topology 1 completes with no api process anywhere, and topology 3 completes over the rendezvous relay. The NAT measures as endpoint-independent (37848 to two destinations) and nothing fell back to a relay. The sender reports a QUIC authentication failed; the receiver times out.

Landed as a known failure with the evidence in the plan. Next step is packet capture, not another assertion.

Verification

171 Rust tests, fmt, clippy --workspace --all-targets, check-secrets.sh — all pass.

🤖 Generated with Claude Code

op-q and others added 7 commits August 31, 2026 15:49
Drop's Rust tests cover almost everything in-process, and nothing that needs
a topology. Loopback has no NAT and no round-trip time, so hole punching,
fallback and the acknowledgement window are all unexercised — and the
endpoint-drop bug fixed on the transport branch was invisible to every
loopback test for exactly that reason.

The plan proposes `netlab/`: real binaries in Linux network namespaces,
asserting on arrival, carrier, fallback and throughput. It reimplements no
part of the protocol, which is decisions.md entry 11's rule applied to a
second non-Rust consumer.

Two findings from probing the machine shape it, and both contradict the
brief it was written from:

Root is not needed. This process has no capabilities at all and the full
lab still works, because an unprivileged user namespace grants CAP_NET_ADMIN
inside itself — verified with veth, netem and an iptables NAT rule. So the
entry condition is "can a namespace be obtained", not "is CAP_NET_ADMIN
held", and it has three answers rather than two.

The direct path cannot run hermetically as the code stands. It reaches the
public internet in three independent places, and one cannot be routed
around: `publishable` refuses every address a lab may use, including the
documentation ranges, because entry 14 strips private addresses from
published records on purpose. Entry 14 states the consequence itself — two
peers on one LAN can no longer find each other through the DHT — and a netns
lab is a LAN.

So the relay topologies come first; they need no source change and carry the
throughput claim. The direct topologies wait on a decision about production
surface, written up as open question 1 rather than settled inside a test
directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Which path a transfer took is reported as prose on stderr — "Path    relay
(encrypted; the relay cannot read it)", "Falling back to the relay." A
harness matching on those is matching on sentences written to be reworded,
and the first sympathetic rewrite breaks it silently.

Add one stable line beside the prose, never instead of it:

    drop-status: path=relay fallback=rendezvous

`path` names the carrier that actually moved bytes; `fallback` says why it
was not the other one, distinguishing a direct path that could not be set up
from a receiver that looked and correctly found the sender absent. Only the
receiver can tell those apart, so only it reports `no-record`.

Behind `--status` and `DROP_STATUS`, off by default, because the prose is
the product and an unasked-for key=value line is noise. The variable only
turns it on, so a harness exports it once and spawns many.

Not a `--json` mode. A JSON object invites a reporting schema to grow inside
it and then the schema is a compatibility surface; what anything needs to
know is which carrier and why.

The end-to-end test spawns the real binary rather than calling into the
library like the rest of transfer.rs. An in-process assertion would pin the
string while leaving the flag parsing, the option plumbing and the choice of
stream unchecked — which is precisely what a lab spawning `drop` depends on.
A second test asserts the line is absent by default, since the first alone
would pass just as well if it were unconditional. 156 tests, up from 153.

Also corrects commands.md, which still said the direct path was unreachable
from the binary. It has been reachable since --transport landed; what a
local run actually fails to cover is the topology, not the code path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds netlab/: the real drop and api binaries, running in Linux network
namespaces, across a router and two segments. Loopback has no router and no
second segment, so nothing in the Rust suite had moved a Drop transfer across
one.

It needs no privilege. An unprivileged user namespace grants CAP_NET_ADMIN
inside itself, so the pytest session re-executes into `unshare -Urnm` and
carries on; a kernel that refuses skips every test with a message naming what
was tried. Root is case one of three, not the requirement.

No part of the Drop protocol is implemented here. The lab starts binaries and
inspects a checksum, an exit code, and the machine-readable line `drop
--status` prints — matching that rather than the prose, which is written to be
reworded.

The phase's stated gate was that the UDP-blocked test should fail when the
iptables rule is removed. It cannot, and finding out why is the most useful
thing this produced: with no route to the internet the direct path cannot be
set up whether or not UDP is forwarded, so the topology cannot attribute the
fallback to the block. That is the plan's own "passes while proving less than
it looks like" risk, met on the first topology. The test now claims what it
shows — the fallback fires, completes across a routed network, and is
reported — and the README says what it does not.

Two negative controls that do discriminate replace the gate: with the router
not forwarding, the ends cannot reach each other at all; with no relay
running, a relayed transfer fails. So the router is shown to be carrying the
transfer and the relay to be relaying it.

Two things cost real time and are written down so they are not rediscovered.
pytest replaces fds 1 and 2 with capture buffers before any hook runs, and a
process that execs inherits them — so the re-executed session wrote its whole
output into a buffer nothing survived to read, and the run passed silently in
zero seconds. And inside a user namespace this process is uid 0 without
owning anything, so `ip netns` needs a tmpfs over its directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phases 2 and 3 of the network lab plan: throughput against the
`window / RTT` claim in protocol.md, and delivery across a lossy path.
Both were the next unblocked phases, and both needed a correction before
they measured anything.

The round trip that binds the sender's window is four traversals, not
two. protocol.md line 86 has the receiver acknowledge bytes and the relay
merely forward them on, so the loop is sender to relay to receiver for
the chunk and back again for its acknowledgement. Delaying by half, as
the phase was written, would have doubled the ceiling and let every
measured rate sit comfortably under it while checking nothing.

Three more things would each have produced a passing lane that proved
less than it looked like:

- `measure_rtt` read 15-20% high, because resolving an unresolved
  neighbour costs its own round trip across the same delayed link and
  ping averages that first reply in. The lane divides by that number.
- A single transfer's wall clock is mostly handshake at these round
  trips - 2.7s, 5.5s and 9.5s of setup - and setup grows with RTT, so
  dividing bytes by seconds would have reported the handshake as
  throughput and still looked inversely proportional to RTT, which is
  the very shape being checked. Rates are a slope across two payload
  sizes, which cancels it.
- Debug binaries move 6 MiB/s against 600 MiB/s optimised, below every
  ceiling under test, so the window could never have been the binding
  constraint. The lab builds --release.

For loss, a hung transfer was surfacing as `LabError`, the exception
meaning the lab itself broke - while a transfer that never finishes and
never errors is exactly the bug the phase hunts. It is now a
`Transfer.timed_out` outcome, the same conflation Phase 1 fixed on a
different path. And at zero RTT a 1% drop rate completes as fast as no
loss at all, so timing cannot show the impairment exists; the qdisc is
read back and asserted instead, with observed loss recorded rather than
gated, since a run that happens to lose nothing must not fail a build.

Every measured rate lands at 41-53% of its ceiling, consistently. The
window is enforced and does scale with the round trip; what costs the
other half is not established, so it is recorded as unexplained and the
ceiling assertion is one-sided to keep the gap from becoming the thing
under test. The 400-800ms ratio is the noisy point and its margin is
written down, so a future failure there is read as drift rather than as
a discovery.

The README now says where pytest comes from. It claimed `pytest netlab/`
was the whole invocation, and the virtualenv it needs is gitignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cy7f61694MXmv7LYGK1i7n
Two halves of one problem. The direct path's rendezvous — the relay that
makes an endpoint reachable and the DHT that carries its address — was
compiled in, so a network with egress filtering could not use the direct
path at all, and the lab could not test it hermetically.

DROP_RENDEZVOUS_RELAY and DROP_RENDEZVOUS_BOOTSTRAP point both somewhere
else. Environment variables rather than flags: rendezvous is a property of
the network a machine is on, set once and never changed between two
transfers, and a flag would charge every reader of --help for something
almost nobody sets. A malformed value is an error and never a silent
default — an operator who meant to keep rendezvous inside their network
and quietly got the public DHT has lost exactly what they configured,
invisibly. Recorded as decisions.md entry 15, with what an operator takes
on in security.md.

netlab/rendezvous/ runs an iroh relay and a three-node mainline testnet in
one process. Deliberately outside the workspace: iroh-relay's server
feature pulls in hyper, rustls and an ACME client, and none of that
belongs in a binary shipped for four targets.

Three findings changed the lab rather than the code:

- Phase 4's assertions tested nothing. Carrier::Direct is reported as soon
  as rendezvous and connect succeed, and nothing consults whether a hole
  was punched — exactly as direct.rs's own doc says. A punch is now
  measured by counting bytes on the rendezvous link, and the NAT's mapping
  behaviour by comparing source ports seen by two destinations, rather
  than inferred from an iptables rule.
- The lab died where it should have skipped. user_namespaces_available()
  read two sysctls that both say yes on Ubuntu 24.04+, where the refusal
  actually comes from apparmor_restrict_unprivileged_userns; conftest acts
  on that with execvp, so a wrong yes left no process alive to report
  anything. The probe now attempts a namespace instead of predicting one.
- Outbound UDP is not blocked here, only port 53 is. Two DHT routers
  answered, and the reply carried this host's public address — which turns
  the objection to reaching the public DHT from an argument into a
  measurement.

Suite run 2026-09-10: 7 passed, 1 failed. The failure is
test_a_full_cone_nat_is_punched_through, which reproduces three runs of
three while plain LAN and symmetric NAT both pass, so the QUIC path and
rendezvous work and only the punch does not. Landed as a known failure and
recorded as an open Phase 4 item rather than held back; see the plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hosted relay is closed. `DEFAULT_SERVER` is deleted rather than
repointed, because a default naming a host that no longer answers is worse
than no default at all: it turns "you have not configured a relay" into a
TLS failure against somebody else's DNS, at a hostname this project no
longer controls.

Entry 8 is the reason it cannot simply be corrected in place. It argued for
a stable API hostname precisely because the value is compiled in — so every
binary already installed keeps reaching for it until its owner installs a
new one. What entry 8 wanted, it can no longer have; the honest response is
to stop shipping a guess. Recorded as decisions.md entry 16, which
supersedes entry 8 rather than rewriting it.

What this changes for a person:

- `--server` and `DROP_SERVER` have no default. Unset, and empty, both mean
  no relay.
- `--transport relay` without one is an error, raised before the payload is
  read, because being told the relay is missing is worth nothing after a
  wait to compress a directory.
- `auto` without one is peer-to-peer that says so rather than falling back
  to nowhere. `may_fall_back` now takes the configured relay so it can tell
  *no relay configured* from *`--transport p2p` forbids falling back* —
  they were one branch before, and a person who never asked for `p2p`
  should not be told their transport forbids a fallback they did not
  choose.

It also costs the one thing the relay was still for: a browser cannot speak
QUIC and can only meet a CLI at a relay, so browser transfers now need an
operator to run one. Stated in `--help` rather than discovered.

The help's OPTIONS block is regrouped while it is being edited. It listed
`-h` and `-V`, which work alone, beside `-s` and `-t`, which do not, with
no way to tell them apart — so `drop -s` answered "unknown command `-s`"
about an option that exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`drop`, `drop send` and `drop recv`, typed on their own in a terminal, open
an interface: a chooser, a file browser, a code field, a destination picker,
and a few checkboxes. Everything else is unchanged, which is the point — the
flags are the program-facing surface and every harness that drives them keeps
working untouched.

The activation rule is deliberately conservative, and stricter than "no
arguments on a terminal":

- All three streams must be terminals, **stdout included**, though the
  interface never writes there. The transfer code goes to stdout so it
  survives a pipe, so a redirected stdout is somebody collecting that code
  and they should get it rather than a screen.
- `DROP_STATUS` counts even with a bare command line. A harness exports it
  once and every `drop` it spawns inherits it.
- **Any** flag means the command, not a curated list of flags that imply
  non-interactive use — that list would be wrong the first time somebody
  added a flag.

Terminal lifecycle lands before any screen is drawn, because the worst
failure here is not a bad screen but a shell that no longer echoes. `send`
answers a signal with `std::process::exit(130)` and no unwinding, so the
spool file can be deleted by hand; no destructor runs on that path, so
`restore()` lives over an atomic and the handler calls it — before deleting
the spool file, since that one cannot be recovered from by hand. `recv` had
no termination handler at all and now has one. Ctrl-C also had to become an
ordinary key: in raw mode the driver stops turning it into SIGINT.

Three things the first person to use it found, each a design error rather
than a discoverability one:

- `enter` opened a folder while `s` selected one, so the gesture everybody
  makes first did the wrong thing. `enter` now picks whatever is
  highlighted, `→` opens, and choosing the folder you are standing in is the
  first row of the listing rather than a key to know about.
- That was only safe once `esc` meant back. It meant "give up entirely", so
  one mistaken keystroke was unrecoverable. The flow is now a loop whose
  next screen is whichever answer is still missing.
- The code field took any non-empty string and let `TransferCode::parse`
  reject it two screens later, from a program that had already exited. It
  is checked where it is typed, by the same parser.

The sender's announcement says who the code is for and that `drop recv` on
its own now works.

ratatui and crossterm, both pure Rust, which is the bar `flate2` is already
held to for four prebuilt targets. Release binary 26,988,848 → 27,482,824
bytes, +1.8%.

Progress still prints below the closed interface; putting it on screen, and
closing both sides on cancel, is phase 3 of the plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@op-q
op-q merged commit 07a0cde into main Sep 10, 2026
6 checks passed
@op-q
op-q deleted the feat/netlab branch September 10, 2026 14:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant