3581feb9b98cd78a19ea15602e13b00e3e5447cc
13
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9b240672e6 |
Put every network's address on the interface, and split up from join
Two networks on one agent, and only one of them worked. The interface plan is exhaustive by contract — it is what the interface should carry and nothing else — but the request that built it held a single address, so the provisioner was told about the first network and took the second one's address off, or never put it on. On the host that is a network whose address the operating system has never heard of: the tunnel is up, the status says all is well, and nothing routes. The request now carries every address, which is also what takes one off when a network is left or stopped. The other half is the command line. `up --network X --secret Y` and `network join` were two ways to do the same thing, and the one on `up` could only be undone by restarting — which is how a network somebody left came back, and how an invite line told the other side to start their agent with a network baked into it. So they are one thing now, split the way the system is: **`up` runs the agent** — the device's one process, serving whatever it has joined, answering `status`, taking instructions — and **`join` decides what it belongs to**, at any time, while it runs. `join` is at the top level because it is what gets typed; `network join` is the same command for anyone who likes the long form. Every line that told somebody to type the old form is gone with it: the invite after making a network, the lock error from a second `up`, the empty-network hint in `id`, the README walkthrough and the WireGuard document. The invite now prints the `join` line for the other machine and, separately, the `up --peer` line for an agent that is not running yet — two commands, because they really are two, and no amount of wording makes starting an agent the same thing as joining a network. Joining says how the network stood before: new, already here, or stopped and now running again. That last one matters — joining is an instruction to run it, so it undoes a stop, and a pause that ends without a word is a pause nobody can rely on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4759e47e31 |
Remove tun-setup and the attach path it served
The managed interface supersedes both. They go together because apart they are useless: attaching needs an interface somebody prepared, and tun-setup existed only to say how to prepare one. This also corrects what the last commit's README claimed. It said the manual route was needed on macOS and Windows; it was not, and could not be. The recipe printed Linux `ip` commands, and a persistent TUN that a second process can attach to is a Linux concept — macOS creates a utun by opening a control socket and there is nothing to hand over. So those platforms were never served by this path, and their honest state is that a real interface waits on a provisioner, with --no-tun meanwhile. Gone with it: the interface-existence check, the /proc/net/if_inet6 address inspection and its DAD flag decoding, and the --interface flag, which had one mode left. Kept: the check that the allocated IPv4 address is really on a local interface. The agent now assigns that address itself, so the check is no longer telling a user what to run — it verifies the outcome instead of trusting it, which is worth keeping precisely because the assumptions around Linux address behaviour have been wrong here more than once. Its message says which interface should have had the address rather than a command to run. Boxing Up(UpArgs) is fallout: TunSetupArgs had been masking how much larger that variant is than its siblings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b23e832a73 |
Manage the overlay interface instead of asking for it
The agent printed a list of `ip` commands and asked a human to run them. That is fragile in the way hand-held setup always is: a persistent TUN does not survive a reboot, a changed address allocation needs another manual round, and a run that died leaves a half-configured interface the next run trips over. On Linux the agent now creates the interface, sets the MTU, brings it up and assigns both overlay addresses itself, over netlink in process. No `ip` is invoked, so nothing this path does can be influenced by PATH, a shell, or anything a remote peer said. Cleanup stops being an action. The interface is tied to an open file descriptor and is deliberately not persistent, so the kernel removes it when the agent goes — cleanly, by panic, by SIGKILL or by power loss alike. That also retires `keep_addr_on_down` and `nodad`, which existed only because an interface nobody held open lost carrier. Anything still left behind is repaired rather than tripped over: an abandoned TUN is replaced along with its stale addresses. Two cases refuse instead of guessing — a link that is not a TUN, because a name collision is no reason to destroy somebody's bridge, and a TUN another process holds open, because that is a working overlay belonging to someone else. CAP_NET_ADMIN is kept out of the effective set except around the calls that use it. Two facts shape how: capabilities are per thread, and netlink checks the credentials of whichever thread calls sendmsg, which with an async client is the connection task rather than the caller. So netlink runs on one dedicated thread with a current-thread runtime where nothing is polled outside a block_on, and opening the TUN descriptor is synchronous with no await between the guard and its release. The decision of what to change is a pure function, tested on every platform; only the execution is behind the provisioner trait. macOS and Windows get an implementation that refuses with an explanation and falls back to attaching to a prepared interface, plus a mock host the tests drive the whole plugin lifecycle against. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
944d98389f |
Print the allocated IPv4 address in tun-setup
The overlay IPv4 address is not derived from the keys: it is allocated at run time and signed, so on a fresh state directory there is nothing for tun-setup to print. Once the agent has run, the claim is in state.sqlite, and reading it back takes no directory lock, so tun-setup can show the `ip address add` line while the agent is running. Records are verified on the way out; the database is not a trust boundary. The line needs no keep_addr_on_down and no nodad, unlike its IPv6 counterpart: Linux keeps IPv4 addresses on an interface that has lost carrier, and IPv4 has no duplicate address detection to stall. Also fix a race in the four-agent test. A peer counts as connected once its session authenticates, which can precede the announcement carrying its hostname, so reading the hostnames straight away occasionally saw only two. It now waits for them like every other success condition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8b333455f1 |
Report an allocated IPv4 address that is not on the host
IPv6 works end to end between two machines; IPv4 silently did not, and the agent said nothing useful about why. Allocation moved the address from something derivable before startup to something agreed at run time, so an interface configured by an earlier `tun-setup` carries a different address than the one allocated. The kernel then sends packets with that stale source and every peer drops them as not belonging to us — correct behaviour, invisible cause. Meanwhile pings to our own allocated address fall into the tunnel and land in the "nobody owns this" counter. The agent now checks whether its allocated address is assigned anywhere on the host — by binding a UDP socket to it, which needs no privileges and no platform code — and reports the exact `ip address add` command until it is, mentioning that another address of the range has to go. `tun-setup` no longer prints a derived IPv4 address, because that number is now wrong by construction. It says the agent will print the real one. The unroutable counter keeps one destination as a sample, in status output too. A bare count says something is wrong; the address says what. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
84c06c6cac |
Allocate IPv4 addresses and keep them, as signed state
Derived IPv4 addresses could not survive anything: they changed with the range, and there was no way for a member to come back to the one it had. Addresses are now allocated and recorded as signed facts, which is the first slice of the model in docs/sync-model.md. src/state/ holds one record per author per network, carrying that author's complete current statement, signed with its persistent device key over a length-prefixed canonical encoding. Merging follows the model's rules: a higher version wins, an older one never rolls back a newer, duplicates are idempotent, absence from a snapshot is not deletion, and a same-version conflict is resolved identically on every replica and reported rather than letting replicas diverge. Records are persisted in state.sqlite, with the record and the author's version counter committed in one transaction before anything is announced, and distributed as a State control message that is merged into what the receiver already holds. No vote, deliberately, despite the request. A majority is not a trust root here — anyone with the secret can mint identities — and a quorum would stall with one peer online and diverge across a partition. Signatures plus a deterministic merge converge without either failure mode: two members claiming one address at once are resolved by the lower endpoint id, and the loser allocates again with a higher version. The range moved from the plugin to the agent, defaults to 10.13.37.0/24, and is now agreed rather than configured per member: a joining agent adopts what the network already uses, so --ipv4-range only matters for whoever starts it. The announcement went back to identity only (version 3) since the range travels in signed records now. A release tombstone exists and merges correctly, but nothing emits one yet. 116 tests. The headline ones: an address survives restarting both agents, three members get three distinct addresses, and a member started with a different range adopts the one in use. Confirmed by hand with two CLI agents restarted end to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ce64264027 |
Make the IPv4 overlay opt-in and detect a range mismatch
100.64.0.0/10 was a bad default: it is exactly Tailscale's range, and carrier-grade NAT's. There is no IPv4 range that is free on every host, so there is now no default at all — IPv4 is off until --ipv4-range names one. IPv6 is unaffected and still works out of the box, because a ULA derived from the network id collides with essentially nothing. The more serious problem this exposed: the range is an input to the address derivation, and each agent derives every peer's address itself. Two members configured with different ranges would therefore derive different addresses for each other and IPv4 would silently misroute. So the range now travels in the announcement — not as a request and never trusted, only so the mismatch is seen. A peer whose range disagrees gets no IPv4 address here, keeps working over IPv6, and the reason is reported with both ranges named. The announcement format goes to version 2. postcard is not self-describing, so an older peer cannot read it; the version check already catches that and now says which side needs updating. The (Ipv4Addr, u8) tuple that had spread across six modules is now an Ipv4Range with validation, Display and FromStr, so a bad --ipv4-range is refused with a reason instead of being accepted and misbehaving later. It is also rejected when passed without --wireguard rather than ignored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cfab38824d |
Make the overlay dual stack
Every member now also derives an IPv4 address, from the same inputs as its IPv6 one, into 100.64.0.0/10 by default. The range is configurable and IPv4 can be turned off with --no-ipv4. IPv4 is honestly weaker than IPv6 here and the code says so. A 64 bit interface identifier makes an IPv6 collision impossible in practice; IPv4 has nothing like that room, and in a /10 with 50 members two will derive the same address about 0.03% of the time. A mesh with no coordinator cannot allocate around that, so a collision is detected and resolved instead: the member whose public key sorts lower keeps the address, a rule every member computes identically and therefore agrees on. The other keeps IPv6 and is flagged in the status. IPv6 always works; IPv4 almost always works and degrades predictably. Routing and address-ownership enforcement now cover both families: a packet goes to the peer that owns its destination, and a decrypted packet is dropped unless its source is an address derived for the peer that sent it, IPv4 included. Six new tests, among them a real IPv4 packet crossing a tunnel next to an IPv6 one, a spoofed IPv4 source being dropped, and an IPv6-only overlay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d2e336f2f9 |
Raise the overlay MTU to 1280: below that Linux disables IPv6
The setup recipe failed with a missing sysctl directory and "RTNETLINK answers: Invalid argument". The cause was the default MTU of 1100. IPv6 requires a minimum MTU of 1280 (RFC 8200) and Linux enforces it by tearing IPv6 down on any interface below it: the per-device /proc/sys/net/ipv6/conf entries disappear and an address can no longer be assigned. Evidence on the test host: every interface at 1280 or above has an IPv6 conf directory, every interface below it (1230, 1100) has none. So the overlay MTU is now 1280, which is also the floor. A smaller value is refused when the plugin opens, naming the reason, rather than surfacing as an obscure netlink error after the user has already run four commands. That leaves no slack against the other constraint: a packet needs mtu + 32 bytes of transport datagram, so 1312. A direct QUIC path offers roughly 1380 and fits; a relayed path may not, so the plugin now reports the exact numbers when a link cannot carry a full-size packet, instead of only counting silent drops. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
38beb762d8 |
Fix the TUN setup recipe: the overlay address was being flushed
The setup this tool printed did not work, and the agent then correctly refused to start. A persistent TUN interface has no carrier until a process attaches to it, and Linux flushes IPv6 addresses from an interface that loses carrier unless net.ipv6.conf.<dev>.keep_addr_on_down is set, which it is not by default. So `ip -6 address add` on a freshly created interface silently lost the address before the agent ever ran. The recipe now brings the link up first, sets keep_addr_on_down, and adds the address with `nodad` — without which duplicate address detection can never finish on an interface with no carrier and the address stays tentative and unusable. The agent's own retry loop made this worse: it attached, failed the address check, dropped the device and toggled the carrier, which flushed the address again. The check now runs before attaching to an existing interface, so looking is not destructive. Failures are self-diagnosing now: the check parses the IFA_F_* flags, tells tentative and DAD-failed apart from missing, and lists the addresses the interface actually has. Four new tests, including one that reads this host's real /proc/net/if_inet6 and one that pins the ordering of the setup commands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1dd7507bf4 |
Let the agent run unprivileged against a prepared TUN interface
Creating a network interface needs CAP_NET_ADMIN, but that is a one-time setup step rather than something the agent must hold for its whole life. SystemTunFactory now attaches to an interface that already exists and only creates one when it does not. A persistent interface created by root and owned by the user therefore lets the agent run with no privileges and no capabilities at all. When attaching, nothing is reconfigured, since doing so would need exactly the privileges we are avoiding. New `tsunagi tun-setup` prints the three commands to run once as root, resolving the derived interface name and overlay address for the network. This also fixes a real gap: the overlay address was passed to the factory and thrown away, so an interface the agent created had no address and could never have received anything. The `tun` crate sets addresses through an IPv4-only ioctl and cannot assign an IPv6 one at all, so the agent now verifies the address is present via /proc/net/if_inet6 and refuses with the exact command to run instead of coming up broken. Doing it in-process would mean speaking netlink, which is not implemented and is recorded as such. Not verified on this machine: no sudo is available here, so the privileged setup and the attach path were not executed end to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
21be7e9b44 |
Separate control and data logically, move WireGuard into userspace, add a CLI
Corrects the architecture on two points raised in review, while the project is still small enough to change cheaply. 1. Control and data are separated *logically*, not physically. The old reading — "nothing but control may ride on iroh" — threw away iroh's whole value and would have forced the data plane to reimplement STUN, ICE and a relay. Now both planes ride on iroh with different ALPNs and different connections, so the data plane inherits hole punching and relay fallback, while proto/ still knows nothing about packets and dataplane/ knows nothing about the control protocol. New boundary: PacketTransport / PacketLink, an authenticated unreliable datagram channel per (network, peer, protocol). tsunagi/data/1 runs the same membership handshake, then DataOpen/DataOpenAck, then QUIC datagrams. Only the smaller endpoint id dials, so exactly one link exists per pair. A plugin is handed links and never learns reachability, so the WireGuard announcement shrank to a public key: there is no address left to lie about. 2. WireGuard now runs in userspace, on boringtun's protocol state machine. No kernel module, no wg tool, no ip shell-out, no loopback proxy: the wgtool, backend and bridge modules are gone. Only creating a TUN device needs privileges, and that sits behind TunFactory, so the entire data plane — handshake, encryption, routing, address ownership — is tested with none. Address ownership is enforced rather than believed: outbound packets go to the owner of the destination address, inbound packets are dropped unless their source is the address derived for the peer that sent them. 3. A `tsunagi` binary: secret, doctor, id, up. It owns the runtime, the logging subscriber and Ctrl-C, which the library still refuses to. Also fixes a reference cycle where IrohTransport held Arc<Inner>, which kept the databases open and the directory lock held after shutdown; two storage tests caught it once the cycle existed. 81 tests pass offline with no privileges, including real IPv6 packets crossing a real WireGuard tunnel over real iroh connections. Verified by hand: two CLI processes forming a mesh both on loopback and via n0 discovery using only an endpoint id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ea7aaa2b69 |
Implement the WireGuard data plane plugin
The first IP plugin, built on the data plane boundary the core already had. Plugin: - one X25519 key per network in the plugin's own wireguard.sqlite, separate from the iroh identity and from the network secret; a damaged store is an error, never a silently regenerated identity - deterministic IPv6 ULA overlay: every member derives the same /64 from the network id and its own /128 from its WireGuard public key, so no coordinator allocates addresses - AllowedIPs are derived locally, never taken from a peer's announcement, so a member cannot claim another member's overlay address; a mismatched claim is rejected - bounded, versioned, validated announcement carried as the existing opaque capability payload, which the core still never parses - each agent builds its own full-mesh configuration (N-1 peers) and reconciles on every change and on a timer, repairing drift - WireguardBackend abstraction: RecordingBackend in memory, and WgToolBackend driving real wg/ip on Linux, split into a pure planner plus parsers and a thin executor so everything interesting is testable without root Core, three generic additions the plugin needed: - IpPlugin::on_network_activated, so per-network state is ready before peers - PluginContext for re-announcements and error reports from plugin tasks, with errors counted by the owning network runtime - IpPlugin::shutdown, awaited with a grace period, so system objects go away 94 tests pass offline with no privileges: 35 new WireGuard unit tests and 12 integration tests over real iroh connections. The real wg/ip backend needs root and is behind --ignored in tests/wireguard_system.rs; it was not run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |