9698f21d558daad62ac23417584114e887533c8e
52
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9698f21d55 | Added DHT peer resolver, fixed MTU | ||
|
|
72eaf9a228 | Fixed win dns | ||
|
|
e735151d62 | Added windown support | ||
|
|
458051f47d |
Warn about a zone name only when it collides with something
"`.pidar` is not a delegated top-level domain today, but it could become one" fired on every private name anybody has ever picked, which is every network without a dot in its name. A warning that is always there is a warning about nothing, and it teaches people to skim past the ones that mean something — there were two of them in one report, side by side with the real checks. What is left is the collision that exists: a name that really is a public top-level domain, which does make every public name under it unreachable from that host, and `.local`, which belongs to multicast DNS. A name that shadows nothing is said nothing about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
83d3445b7e |
Meet everybody, not just the one you were told about
A device pointed at one member talked to that member and nobody else. The only candidates an agent had were the ones on its command line and whatever the cache remembered, so the network was a star around whichever peer happened to be typed — while the signed state sitting in front of it listed every other member by name. Two sources fix that, and both produce candidates rather than facts. Every author of a signed record is somebody to try. Those records reach us through anybody, so a member is known to exist, and by id, long before it is ever spoken to; an id with no address is still dialable where the endpoint's own discovery can resolve one. And members tell each other where they have seen the others. A `ControlMessage::Peers` carries each member with the addresses the sender observes for it — including the sender's own, which is the one thing nobody else can pass on — in the same `ip:`/`relay:` spelling the cache already uses, so one decoder serves both and neither can drift. An agent that only ever accepts has no candidates of its own and is exactly the one everybody was pointed at, so the addresses come from the live sessions as well as the candidate list. None of it authenticates anything. An introduction is not a vouching: the handshake decides membership as before, and a candidate from a member is tried exactly like one from a bootstrap entry or the cache. It is also deliberately the shape a distributed hash table lookup would return, so that becomes another source beside these rather than a redesign. With the mesh pairwise, the relay is what it was meant to be: the way to the one peer that cannot be reached directly, not the way the network is held together. Covered by three agents where two are told only about the first: each ends up with both of the others, and the one nobody mentioned arrives as an introduction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3581feb9b9 |
Reach a peer through one that can reach both
Two members of a mesh could both reach a third and not each other, and that pair was simply lost to one another: a packet for a peer with no data link was counted undeliverable and dropped. Now it goes through a member that has both. What travels is not routes. Each agent says only which peers *it* has a live link with — first-hand, over the control plane, one hop, never a claim about somebody else's reachability — and everybody computes their own way through from that. The choice is local and deterministic (the lowest endpoint id among the peers that have a link to the destination), so there is nothing to agree, nothing to elect, and two agents may well route each direction differently. It is soft state: repeated while it holds, expired when it stops, so a relay that disappears stops being chosen without anybody revoking anything. The one in the middle carries bytes it cannot read. A datagram is wrapped with the peer it is for, and unwrapped on the other side into the link for the peer it came *from* — which matters, because a packet attributed to the carrier would be dropped as coming from an address the carrier does not hold. The tunnel stays end to end, and the relayed datagram goes link in, link out: it never reaches the middle's interface, so no routing, forwarding or firewall setting of that host is involved. One hop, so a loop cannot form without counting anything. A protocol is handed one link per peer that now outlives the paths under it. A direct link that dies, a hop that changes, a direct link that comes back: none of it tears down a tunnel any more, and the size a protocol may use does not change with the path. Where there was never a direct link at all, the link exists anyway as long as a hop does, so a peer reachable only through somebody still gets a tunnel. The data ALPN is `tsunagi/data/2`: every datagram now carries a tag saying whether it is direct, for somebody else, or from somebody else. The local control protocol is 13, for the relay counters — what this device carried for others is their traffic on its uplink, and that should not be invisible. `status` says `via <peer>` on a path through somebody. Fairness between the peers a relay carries for is deliberately not here yet: the queues are bounded and the counters are what a limit would be built on. Tested with fake links for the mechanics, and end to end with three real agents — two that cannot reach each other directly, a real WireGuard packet crossing through the middle. The one arrangement a single host cannot produce by itself is a pair that cannot see each other, so that is a `testing`-only switch on the agent config and exists in no release build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3044e592bd |
Say when there is nobody to contact
Two freshly wiped devices joined the same network, each got an address, and nothing happened. The report said "none known yet; nobody else has joined", which reads as patience — as if somebody were expected and had not arrived. What had actually happened is that neither agent had anywhere to look: this project publishes nothing about who is in which network, so a first meeting needs one of them to be told the other's endpoint id, and the hints an agent remembers from earlier sessions had gone with the wipe. Candidates are now in the report, and having none of them with nobody connected is said plainly, with both ways out of it and this device's own id ready to hand to the other side. Having some and no sessions yet is the ordinary state of a network coming up, and stays ungraded. The local control protocol is 12. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
1b2f050c05 |
Start the agent without deciding anything yet
`up` demanded a network, so there was no way to run the agent in one terminal and decide what it belongs to in another — which is the shape of the thing now that networks are joined, stopped and left while it runs. `--network` is optional. Without it the agent starts with whatever it is already configured for and waits; the banner counts the networks instead of naming one, and points at `tsunagi network join`. A secret with no network is refused rather than ignored, because it says nothing on its own. The periodic summary, which had one network by construction, now falls back to the first running one — `tsunagi status` is the whole picture and this line was never more than a glance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
637e2f74e4 |
Tell being away from giving up
Leaving was the only way out of a network, and it is the irreversible one: it publishes a release and then removes the configuration, the secret, the network's signed records, its cached hints and the protocol key it used. What somebody usually wants before a reboot, a trip or an experiment is the other thing — stop serving it and keep everything. `tsunagi network stop <id>` closes that network's sessions, takes its address off the interface and keeps it from starting again. Nothing is announced, deliberately: to the others this device is away, which is an ordinary condition they already handle, and the address and name it holds stay reserved for it. `tsunagi network start <id>` resumes it where it left off. Both are remembered, so a restart does what the last instruction said rather than what the last command line happened to say. Except when the command line says otherwise: `up --network X` starts X whatever its stored state, because a command naming a network is an instruction to run it. The banner now says which of the three happened — `new`, `already here`, or `was stopped; this command starts it` — since silently, that is a stop that comes back from the dead with nothing to explain it. The listing tells the three states apart too: running with its address, stopped and kept, or configured and waiting for an agent to start. Each row says what to type to move it, because "stop" and "leave" are a pair that has to be easy to tell apart before the irreversible one is typed. The local control protocol is 11. Covered end to end against a running agent: stopping leaves it configured and says so, stopping twice is the state asked for rather than an error, starting brings it back, and the secret afterwards is the one from before — so it is the same network and not a lookalike. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
415b6a6667 |
Make a network on the spot, and say which one you just started
Two reports of the same shape: a network was left and came back after a restart, and `network join` asked for a secret it could have invented. The first was not a bug in leaving. The secret on the start command line derives the network id, so a command line carrying the secret of a network you have just left recreates it on the next start — which is right, it says to join that network, but nothing on screen said so. `up` now marks the network `· new` or `· already here`, and warns in full when another configured network answers to the same name. A name is a label; the id is the identity, and the secret is what decides which of them this is. Said at the moment it happens it is obvious; discovered later in a status report it is a mystery, which is exactly how it went. The second was an omission: `up` had learned to invent a secret and `network join` had not, so the quickest possible thing — a network with somebody for as long as it is needed, then gone — still needed a secret generated first. Both now resolve a bare name the same way: the one network of that name this device already has, or a fresh random secret when there is none. It is printed in full, with the single line the other person can paste as it stands, endpoint id included, because a secret nobody can read is a network nobody can join. The id is printed in full by both answers now. The shortened form belongs in a report, where it is read; this one gets copied into the next command. Covered end to end against a running agent: joining with no secret prints a secret and a pasteable command with a peer in it, and joining a name this device already has resumes that network instead of making another that merely looks the same. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0f97d60854 |
Serve a zone per network, and make a network in one command
Twice now a report has read "dns not serving" and been taken for a broken resolver. It was accurate both times: the agent had been started without `--dns`. That is the flag's fault, not the reader's — a resolver that disappears because one word was not retyped is worse than none, since the names simply stop working. So the setting belongs to the device now: `--dns` turns it on and it stays on, `tsunagi dns off` turns it off, and `tsunagi dns on` turns it on for an agent that is already running, without restarting it. `tsunagi dns` says what it is doing, or what it will do at the next start when nothing is running. One agent has one identity and as many networks as it likes, so one DNS service serves them all: each network is a zone named after it, and joining or leaving one changes what resolves with no restart. A question carries a name and not the network it belongs to, so the suffix decides and nothing is shared between zones — a member of one network is not a name in another. `--dns-zone` is gone with that: there is no single zone to name any more, and a network name may contain dots, so `--network lab.internal` is how you get `music.lab.internal`. It listens on loopback only, where it always could have. Binding the overlay address put the zones in front of the whole mesh, and with several networks on one agent that would have answered one network's questions about another's names. That made a gap plain: a second network on an agent had no addresses at all, because the configured range belongs to whichever network took it first, so its members had nothing to allocate from and no names to answer with. A second network now uses the range **derived from its own network id** — every member derives the same one from something they all already have, so it is an agreement rather than a local invention. It is held back for a moment first, because a network that already exists has a range of its own and a joiner should adopt it rather than argue; that wait is what keeps "the first member settles it" true. And a network needs no ceremony to start. `tsunagi up --network lab` with no secret resolves the obvious way: the one network of that name this device already has, or — when there is none — a fresh random secret, printed in full with the single line to send the others. That is the ad-hoc case, one person makes a network and passes the command round, and it was previously two steps with a flag people could not find. The secret is printed only when the agent invented it, because then there is nowhere else to read it from; one that was supplied is not echoed. Two networks of one name and no secret is the one case with no answer, and it says so rather than choosing. Releasing now also stops this agent claiming again. The periodic check would otherwise publish a fresh claim in the moment between the goodbye and the teardown, turning a release into a hello nobody asked for. Exercised with the real binary: a zone per network as a second one is joined into a running agent, the resolver switched on and off while it runs, and an ad-hoc network printing its secret and the line to share. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
44a799faee |
Split the command line along the line the system draws
`id` had grown into the place where everything was shown and changed, including the secret of every network this device had joined — and it printed them all in its ordinary overview, which is a poor default for output that gets pasted into chats and issue trackers. Now that networks have a command of their own, the boundary is the one the system already has: `id` is this **device**, `network` is what it **belongs to**. A device outlives every network it is in and a network outlives any device in it, so a command that mixed them had to be read twice. `id` keeps the key, the name and the directories, and says how many networks there are without naming their secrets. `network secret` prints one, or all of them, and only when asked. Nothing else ever does. `network join` is the part that was missing entirely. One state directory is one identity and one live agent, so a second `tsunagi up` on it is refused — and until now that refusal was the end of the road: a network could be left while the agent ran but never added. It goes over the control socket, takes effect at once, and is idempotent, saying which of "joined" and "already there" happened. With no agent running it is written to the configuration and starts with the next `up`, and says so rather than implying it is live. The secret travels over an owner-only socket to the agent that stores it anyway, and `Request` has a hand-written `Debug` that redacts it, because a derived one would put it in any log line that printed a request. The lock error from a second `up` now answers the question behind it: add the network to the running agent with one command, or run a genuinely separate agent — a second identity, with its own directories, interface and range — with the other. That is the shape of the thing: one agent per identity, many networks on it, one interface; a second agent is isolated, not a second view of the first. AGENTS.md carries that as a boundary now, since it is the kind of thing a change could quietly break. The local control protocol is 9. Exercised against a running agent: a second `up` refused with both routes named, a network joined into the live agent and answering for status at once, the same one again reported as already there, a same-name network with a different secret joined with the warning, and `id` showing three networks and no secrets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
41604225ba |
Let a device leave a network, and start over
Joining was one command and leaving was nothing at all: a network went into `state.sqlite` on the first `up` and stayed there, so a mistyped secret left a second network beside the working one with no way to remove it but editing the database by hand. `tsunagi network` lists what this device belongs to. `tsunagi network leave <id>` publishes a signed release first — while the agent is running and its sessions are up — and only then deactivates the network and removes it. The order is the whole point: signed state has no expiry, so the tombstone is the only thing that ever frees the address and the name for the others, and after the network is gone there is nothing left here to sign one with. Peers pass it on, so a member that was away hears it from them rather than from an agent that has already left. With no agent running nothing can sign or send, and the command says so instead of quietly succeeding: `--offline` drops the network locally and says plainly that the others keep the old claim. The outcome always distinguishes "published to nobody" from "not published at all", because they leave the network in different states. A network is named by its id, and a unique prefix will do. The name is refused on purpose: two networks can share one — that is exactly the situation this command exists for — and picking between them for the user is how the wrong one gets left. The author's version counter deliberately survives. Rejoining the same network with the same key must continue above the release, or every replica that holds the release would treat the new claim as stale and the returning member would be invisible for good. The protocol key does not survive: rejoining is joining, not resuming, and coming back with a key the network was told to let go claims an identity nobody holds any more. Plugins learn about it through a new `on_network_forgotten`, which is about what outlives a session rather than what a deactivation tears down. A released member also drops out of the roster `status` prints. The tombstone stays in the record set — a replica that never heard of it would otherwise reinstate the old claim — but listing an author that gave everything up as a member made leaving look like a peer that had broken. `tsunagi wipe` is the other half: it empties both directories, so the device identity, every network, every signed record and everything a protocol kept beside them go at once and the next start is a stranger. It refuses while an agent holds the directory, and refuses a directory with no `state.sqlite` in it, so a mistyped `--state-dir` cannot take somebody's documents with it. Without `--yes` it only prints what it would remove and what membership would be lost. It is not a goodbye and says so: leaving the networks first is what frees their addresses. The local control protocol is 8 — the socket carries a `Leave` request now, since only the running agent can publish the release. Exercised end to end against real agents: leaving by prefix released the address to a connected peer, leaving by name was refused, `--offline` was refused until asked for explicitly, wipe was refused while the agent ran, and the directory afterwards had no identity in it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fae3816bbf |
Answer DNS over both families, and only for our own interface
Questions now arrive over IPv4 or IPv6, whichever the resolver uses. The server opens one listener per family — the overlay address or `127.0.0.1`, and `[::1]` — and all of them are published to systemd-resolved in one call, which is what that call requires: sending them one at a time leaves only the last. A family that cannot be bound, IPv6 switched off in the kernel for instance, no longer stops the other from answering. The answers stay IPv4, because that is what the overlay is. A listening address is disposable, unlike an address a member holds in signed state, so serving one family over the overlay and the other over loopback costs nothing and loses nothing. `--no-tun` was also configuring the host. An in-memory interface has a name and an MTU and nothing else, but everything downstream read that name as a host interface: the resolver setting was pushed onto whatever else on the host happened to be called `tsun0` — which, with two agents on one machine, is another agent's live interface. A factory now says whether what it creates is on the host, and the resolver setting goes only to an interface the agent created. For the same reason the complaint that "the allocated address is not on any interface" no longer fires under `--no-tun`, where there was never going to be one; it had people looking for something that had removed their address. The status line says `tsun0 (in memory, --no-tun)` rather than printing an address beside a name the operating system does not have. That distinction also corrected a test that used the in-memory interface as a stand-in for an unconfigured host interface. They are not the same case, so the test now uses a factory that claims the host and puts nothing there — a provisioner that reported a success it did not achieve — and a second test covers `--no-tun` being an arrangement rather than a fault. An in-memory device now reports end of stream when it is destroyed. It never did, so the packet loop reading it could not end, and since shutdown became bounded that cost every `--no-tun` agent the full five-second grace before the loop was aborted instead of finishing. And `status` says when there is no local resolver at all. Its absence is the answer to "why does this name not resolve?", and leaving the section out made a report with DNS switched off look exactly like one where it was running. The local control protocol is 7: `DnsReport` carries a list of listening addresses and `OverlayReport` says whether the interface is on the host. Verified against the running systemd-resolved: it takes `127.0.0.1:5354 [::1]:5354` on one link in a single call, and forward and reverse questions for a peer's name are answered identically over both transports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0b3915d52b |
Bound every wait that could last for ever
Nine orphaned test processes were found still running from the day before, three of them spinning on a core each for twenty hours. The code they ran is several changes old and the mesh test passes twenty times over now, so the wedge itself is gone — but nothing in the way it was waited on was bounded, which is why a wedge lasted a day instead of failing a run. The harness enforced its deadline only between probes. A probe that never returned — one call into a wedged runtime, which is exactly what a status request is — waited for ever inside the deadline it was supposed to obey. The probe is now bounded too, so the same wedge fails the test in thirty seconds. Shutdown claimed to be bounded and was not. The plugins had a grace period; the network runtimes, the accept loop, the plugin request loop and the endpoint close did not, and a peer that stops reading is enough to hold any of them open. Each now gets a grace period and is aborted after it. The overlay packet loop was not stopped at all: it ends when the device reports end of stream, which a live interface never does, so it outlived the interface it was reading. And a plugin's grace period abandoned the future without stopping the task behind it, so the helper is public and `wg-quic` uses it on its own runtime. The local control socket was unbounded in both directions. A wedged agent left `tsunagi status` hanging with nothing on screen and no way out but Ctrl-C; it now says the agent did not answer, after five seconds, and falls back to the state store as it already did for a socket that refuses a connection. On the serving side, a connection that sends no request no longer holds a task open. Tests cover the mechanism — a task that stops on its own is not aborted, one that ignores the grace is cut off and drops what it held — and both sides of the change in behaviour: a probe that never answers fails its deadline, and a silent agent is reported rather than waited out. Also: the binary opts out of rustdoc, since it shares a name with the library and `cargo doc` cannot put both in one directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4c84cc9e4b |
Tell two networks of the same name apart
A status report with two sections both headed "network LAB" reads as one network that is somehow working and empty at once. It was two networks: the same name with different secrets, which is two different networks that share nothing, because a network's identity is its name *and* its secret. Three fixes for the one confusion. The heading now carries the network id, so the sections are plainly different things. A name is a label the user chose; the id is the identity. Joining a name that is already configured with another secret says so, at the moment it happens, because that is almost always a mistyped secret and until now it silently produced an empty network sitting beside a working one. `status` flags it too, for the case where it already happened. And the second network's emptiness now says why. It had no address because the only configured range was already taken by the first — one agent has one interface, so an address belongs to one network — and "nobody else has joined" pointed at the wrong thing entirely. It now names the range it cannot have, the reason, and the flag that gives it one of its own. Nothing was wrong with the connectivity: the working network's tunnel was up and its ping was answering throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
990b9f2e0f |
Describe the layers as they now stand
The module table still had the plugin inside the core and no mention of the overlay or the DNS view, and the stale path in the testing notes pointed at a directory that had moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
142fdf995c |
Make the protocol a crate of its own
tsunagi-wg-quic. The line between a protocol and the system level is now drawn by the compiler: nothing in it can reach into tsunagi beyond what tsunagi makes public, and it carries its own version — which is not the version peers compare. Two things the compiler found the moment the boundary was real. The key store was reaching into the core's `pub(crate)` file-permission helpers; those are a legitimate service of the system level, because a protocol keeping keys on disk has the same obligation the agent does, so they are public now with that said. And the test harness was about to be copied into a second crate, which is how two copies start to drift; it is a `testing` feature of the core instead, which is also what anybody writing a protocol would need. The bridges put up while things were moving are gone: the error conversion between the two levels, and the re-exports of the system level's types from the protocol crate. Imports now say which level they come from, which is the point. One deliberate deviation, stated rather than hidden. The authenticated transport stayed in the core. Moving it would have meant handing a protocol the network's keys so it could prove membership itself, and a plugin that can authenticate on the control plane is a worse trade than a module boundary is worth. So the core proves who is at the other end and the protocol owns what is said over it — the same separation, without the secret crossing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ff7e235414 |
Split the command line by level
`up` now says which level each setting belongs to, and `--help` shows the two sections. System: how the agent reaches peers, the one interface it owns, the address range, the resolver. Transport: which protocols carry packets and what they take. `--wireguard` is gone. `--protocol` takes a list and defaults to `wg-quic`, which is what the protocol is now called — WireGuard's cryptography in QUIC datagrams, so the name says what is on the wire rather than what the implementation borrows. `--protocol none` runs the control plane alone. Protocol settings moved to `-o key=value`, or `-o protocol:key=value` when several are selected. Each protocol declares its own settings and their help, so `tsunagi protocols` can list them without the agent knowing anything about any protocol, and a setting nobody takes is refused rather than dropped — a dropped setting looks exactly like one that did not work. What the user asked for is checked before anything that could fail on its own, so a misspelled protocol is not buried under a privilege error. `--wg-prefix` and `--wg-mtu` became `--interface` and `--mtu`: they were never the protocol's, and the interface they describe belongs to the agent. `--transport` became `--reach`, because "transport" now means the protocol level and using the word for iroh's path policy as well would be a collision of meaning rather than a shortage of words. The plugin gave up the last things that were not its own: the interface name it carried in its own state, and the check that this agent's address is really on an interface. Both are the agent's, and the check is now the agent's too, still said once per address rather than every round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7c1be332e3 |
Agree a protocol by name and wire version
A peer's announcement already carried a protocol and a version; only the name was being checked. Now both are, and a peer offering a protocol at a version this build does not speak simply has no data plane — the control plane keeps working, messages and signed state still flow, and the difference is reported once rather than on every announcement. The version compared is the *wire* version, not the software version, and the trait says so: a plugin crate has its own version and it is nobody else's business. Two peers on different releases work together for as long as the bytes between them have not changed, and nothing in the negotiation may be derived from anything that moves with a release. A test pins that agreement turns on the name and version alone, with a peer whose announcement carries a payload this build has never seen. `PeerStatus` gained the protocols agreed with each peer, so an empty list is visible as what it is: a session that is up, carrying control traffic, with no protocol in common. The forging test plugin was announcing version 1 while claiming to speak the current one, so its payload was being set aside for the wrong reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9415866193 |
Take the interface off the protocol and give it to the agent
One agent, one interface. The plugin no longer creates one, no longer holds a TUN factory, no longer keeps a routing table and no longer decides who owns an address. What is left of it is the protocol: a WireGuard key per network, a tunnel per peer, encryption on the way out and decryption on the way in. The packet path is now explicit about where each decision lives. Out: the agent's interface reads a packet, the routing table says whose destination it is, and each protocol is asked in turn whether it can carry it there. In: the protocol decrypts and hands the packet up with the peer it came from attached, and the agent checks that peer is entitled to the source address before writing it out. A protocol proves who; only the system level knows what they may say. Two things found by making it work. `carry` sent the packet unencrypted at first. The encryption had lived in the interface loop that moved to the core, so taking that out quietly removed it — the receiving end rejected plaintext as a bad WireGuard datagram and the counters said nothing at all. Encryption belongs with the protocol and is now there, with packets dropped for having no session yet counted apart, because a handful while a tunnel comes up is normal and a number that keeps climbing is not. An agent could impose a range it could not itself route. With one interface two networks need different ranges, and "the lowest author's range wins" would have carried one agent's colliding default to everybody. The configured range is now reserved when a network is activated — on the serialised path, so the answer does not depend on which task ran first — and an agent that cannot have it proposes nothing and adopts whatever the network settles on. Leaving a network takes its address off the interface and leaves the interface; the interface goes when the agent does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c852de2c78 |
Move packets across the interface at the system level
The loop that reads the interface, decides whose packet it is and hands it to whatever can carry it — and the other direction, where a packet a protocol decrypted is checked and written out. Neither direction knows which protocol is involved. The interface asks for a packet to reach a peer and is told whether that was possible; `PacketCarrier` is the whole of what it knows about protocols. That is what lets several be live at once without any of them owning the interface they are carrying traffic for. The source check stays here rather than in a protocol, and the split is the point: a protocol proves *who* sent a packet, and only this level knows what that member is entitled to say, because entitlement is the signed claim. Four kinds of drop are counted apart, because they want different answers: a destination nobody holds (with the first such address kept, so the number can be acted on), a destination that is known but has no live protocol, multicast the operating system emitted anyway, and a packet that could not be read. Rolled into one counter they would say only that something is wrong. Not wired in yet: the WireGuard plugin still owns its own interface, and that is the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a80a014f20 |
Decide at the system level whose packet this is
The routing table, which is what makes one interface able to serve several protocols. A packet coming off it could belong to any network the agent is in and to any protocol currently carrying traffic; the answer comes from signed state, which no protocol owns, so this is the right level for it. Two questions, deliberately not the same one. Outbound: whose is this destination — and a packet for nobody is dropped rather than flooded, because a tunnel is not a broadcast domain. Inbound: this peer decrypted a packet claiming this source, is that address actually its — checked against the signed claim and never against anything the peer said. A packet addressed to this agent itself routes nowhere, rather than to whichever peer happens to be listed. One interface means an address belongs to one network, so two networks whose ranges overlap are refused with the reason and the fix. Guessing between them would hand somebody's traffic to a stranger. Nested ranges count as overlapping, which is the case the obvious comparison misses. Not wired in yet: the interface loop that uses it comes next. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
41d5f554c7 |
Describe the layers the way they now are
The module documentation still said addresses were derived from a WireGuard key and that the plugin owned the interface. Both are now wrong, and rustdoc caught it as broken links to modules that had moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
745bbaea06 |
Drop IPv6 from the overlay
The overlay address was derived from a WireGuard key, which makes it the protocol's address — and the whole point of the interface belonging to the system level is that every protocol carries traffic for the *same* addresses. A derived-per-protocol address cannot be that. So the overlay is IPv4 only: allocated at the system level, signed by the member that holds it, and the same address whichever protocol happens to be moving the packets. The derivation, its ULA prefix and its constants are gone, along with the collision rule that existed only because a derived IPv4 address has too little room to be unique — an allocated one is unique by construction. A real loss came with it and is restored explicitly. The announcement was bound to its network only as a side effect of checking the derived address, so removing that check removed the binding. It now carries the network id and rejects a mismatch. Strictly redundant, because a capability arrives on a session that already proved membership, and kept because losing a property silently is the wrong way to lose one. An unlock falls out: the MTU floor of 1280 existed because Linux tears IPv6 down below it. Without IPv6 the floor is 576, what every IPv4 host must be able to reassemble, so a relayed path with small datagrams can be matched rather than warned about. The default stays 1280. The test that forged an overlay address now forges a network id, which is what is left to lie about. One flaky assertion fixed while passing: it waited for "the interface has some address", which is briefly true of the leftover it was meant to see replaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5bee148497 |
Move the overlay interface to the system level
One agent, one interface, owned by the agent rather than by a protocol. That is what makes several protocols able to be live at once: a packet leaving the interface is routed to whichever peer owns its destination, over whichever protocol has a link to that peer, and neither protocol has to hold the address because the agent holds it. This commit moves the pieces without changing behaviour: provisioning, the TUN itself, IP header parsing and interface naming are now crates/tsunagi/src/overlay, and the WireGuard module re-exports them while its callers are moved over. They were never WireGuard-specific — netlink, capabilities and `ip tuntap` have nothing to do with the protocol running on top. They also get their own error type. An interface that cannot be created is not a plugin failing, and now that the two belong to different levels they should not share a word for it. Routing, addressing and the reduced plugin contract come next. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
60e6b263d1 |
Split the system level and the command line into a workspace
First step of separating the layers. The library and the binary are now crates/tsunagi and crates/tsunagi-cli, which means the plugin crate to come can be told apart from the core by the compiler rather than by discipline. Falls out of it immediately: the CLI's dependencies stop being features of the library. clap, anstream and tracing-subscriber were optional dependencies behind a `cli` feature that every library user had to remember to turn off; now they belong to the crate that uses them, and the library defaults to no features at all. The one test that drives the binary moved beside it — a library cannot depend on a binary built from a crate that depends on the library — and was rewritten against the public API instead of the test harness. AGENTS.md said to prefer one crate. It now says the system level and its plugins are separate crates, for the reason above, and that everything else stays one crate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ca8759c023 |
Say the resolver refusal once, and hand over the fix
Running it for real turned up the predicted polkit refusal — InteractiveAuthorizationRequired — and two things wrong with how the agent handled it. It logged the same line every two seconds. A condition that persists is worth saying once, so it is now repeated only when the message changes. It also retried at that pace. A refusal will not lift until somebody grants permission, so retrying it as often as everything else is noise: refusals now back off to five minutes, other failures to fifteen seconds, and either resets the moment it succeeds or the desired setting changes. The more useful part: the agent prints the polkit rule that grants it, ready to paste, naming the user it is running as. polkit decides by user and not by capability, so this genuinely cannot be arranged from inside the process — which makes "write a polkit rule" the user's work, and handing them the rule rather than describing it is the difference between a minute and an afternoon. It grants the four actions the agent calls and nothing else; a test pins both halves of that, and that the JavaScript stays within what duktape implements. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
75b37fdda3 |
Resolve overlay members by name
`--dns` serves a zone for the network's members, built from signed state, so a member that is switched off still resolves — its claim outlived the session. IPv4 only, as agreed: the IPv6 overlay address derives from a key that travels in live announcements, so it cannot be answered for an absent member, and answering for some and not others depending on who is online is worse than not answering. On Linux the agent tells systemd-resolved where to ask, over D-Bus. SetLinkDNSEx carries a port, which is why the server needs neither port 53 nor CAP_NET_BIND_SERVICE; the suffix goes in as a routing domain and the link's default route is cleared, so this never becomes the resolver for anything else. The setting is keyed to the overlay interface, which goes with the agent, so it cleans itself up. That step needs permission CAP_NET_ADMIN does not give — resolved asks polkit, and polkit decides by user, not by capability — so it is reported as its own kind of failure with its own remedy. The server runs regardless and status prints the exact dig line: the automatic part is what is missing, not the feature. The zone name is the user's to choose. One shadowing a real public domain is reported and then used, because that is a decision; the warning knows the IANA list, says something different about `.local` where the clash is with mDNS, and stays quiet for names reserved for private use. Two bugs found by running it, both in the supervisor and neither reachable from a unit test, so tests/dns_service.rs drives the real binary. It bound to the allocated overlay address without checking that address was on an interface — with --no-tun it never is — and left the feature silently dead; it now tries the overlay first and falls back to loopback. And it compared the address it got against the address it wanted, which never matched when the preferred one could not be bound, so it tore the listener down every two seconds; it now compares what it tried. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
240e471c88 |
Answer DNS questions about the overlay
The zone and the server, without any of the operating system yet. `dns::zone` decides what the answer is and knows nothing about packets or sockets, so the parts worth getting right are testable on their own: which names exist, that a neighbouring name like `evillab` is not inside `lab`, and the difference between a name that is absent and one that exists with nothing of the type asked for. Getting that last one wrong would teach a resolver to stop asking for the A record it could have had. Names come from signed state, which is the point: a member that is switched off still resolves, because its claim outlived the session. Only IPv4 is served. The IPv6 overlay address derives from a WireGuard key that travels in live announcements and is not in signed state, so it cannot be answered for an absent member, and answering for some members and not others depending on who happens to be online is worse than not answering. `dns::server` puts that on the wire with simple-dns, which is already in the tree through iroh — a packet codec rather than a server framework, which is the right size for answering A records from memory. respond() goes from bytes to bytes so everything done to a packet is tested without a socket. It is authoritative for one zone and refuses everything else: no recursion, no forwarding, no cache, so pointing a resolver here can never make it a path to the outside. A message that is not a question gets no reply at all, rather than making this a reflector for anyone who can spoof a source address, and ANY is answered as an address question rather than by dumping the zone. Answers too large for the client's UDP limit are truncated so a resolver retries over TCP instead of waiting; TCP reads are length-checked before allocating, timed out, and bounded in number. The zone is shared rather than copied in, so a member joining is one write instead of a rebind that would drop questions in flight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
43e8ac8159 |
Make identity something you can look at and change
`id` now shows what this device is — the key it signs with, the name it
answers to, the secret of every network it has joined — and changes all
of it. One shape throughout: name a thing to see it, name it with a value
to change it. `secret` folded in as `id secret generate`, and the path
flags became global so they work either side of a subcommand.
There is no separate signing certificate to show: the endpoint key is
what signs records, and the report says so rather than leaving it to be
guessed.
Secrets appear in `id`, which is where you go to ask for one, and stay
out of `status`, logs, `Debug` and anything sent to a peer.
The hostname is now a signed claim, which is what makes changing it a
revocation. Records are one per author, so a new version replaces the
whole claim and no replica can keep the old name standing. RecordBody
generalised to Claim { address, range, hostname } + Release for that,
with the signing domain bumped; a name is bounded and canonicalised, and
a non-canonical one is rejected rather than repaired, because a repaired
version is not what its author signed. Two members claiming one name
resolve it like an address: lowest id wins, computed identically
everywhere. A member with only a name now has a record too, so an
IPv6-only network finally has a durable roster and an absent member can
be named rather than shown as a bare id.
Replacing the signing key is allowed and does not break the store. The
outgoing key signs a release for every network first, so the address and
name it held are freed rather than reserved forever to a key nobody has
— nothing can sign for a retired author, and by design no authority
could overrule one. Identity and releases commit together: a crash
between them would leave the old key gone and unable to sign what it
owed. It refuses while an agent holds the directory, rather than failing
on the lock with a message that says nothing about what to do.
The version counter is keyed by author as well as network, so a
replacement key starts its own sequence. The migration drops records
written under the previous signing domain instead of carrying rows that
every read must reject and that look exactly like corruption.
The hostname defaults to the machine's own name. Also fixed a
pre-existing flaky test: 40 random authors in a /24 collide by the
birthday problem often enough that its threshold failed about one run in
six, so the authors are fixed now and it tests a property rather than a
coin flip.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
776eedc669 |
Report members, not symptoms
A peer going away showed up as three unrelated yellow rows: no peers authenticated, N dial failures, one packet to an address nobody owns. Each was true and none of them said the actual thing, which is that a member we know about is offline. Worse, they are cumulative, so after the peer came back the report still looked broken. Two changes behind that. Members are now a list, joined from the three sources that each know part of the answer: signed state says who belongs and keeps saying it while they are away, the session list says who is here, the overlay says whose tunnel is up. Online first, then away, this agent left out because the device section already covers it. An absent member is stated rather than flagged — in a mesh of laptops being away is the ordinary condition — and its failed dials are attributed to it instead of floating free as a network-wide number. Counters are history and no longer grade anything. Grading them is what kept the report red long after the cause had gone. The one exception is context-sensitive rather than cumulative: handshake failures with nobody connected is the signature of a mismatched secret, so that is called out. NetworkStatus grows a member roster from the signed records, and the overlay report carries the endpoint id so a tunnel can be matched to its session. Note what the roster cannot do: a member is in signed state only once it has claimed something, which today means an IPv4 address, so an IPv6-only network still has no durable roster. Hostnames are not persisted either, so an absent member is named by its id. The control socket gained a version word. Adding these fields changed how postcard parses the bytes, and without it a client one build ahead of its agent reported "Found an Option discriminant that wasn't 0 or 1". The check names a mismatch for whichever side is newer; an older agent reading a newer request just drops the connection, so the CLI offers that as a possibility rather than asserting it. It also now separates an agent that is absent, which is an ordinary answer, from one that is there and will not answer, which is a fault. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8a6fb39689 |
Fold doctor into status, and stop id taking the lock
`status` asked a running agent and failed without one; `doctor` checked
the host and ignored the agent. Between them they answered one question
in two halves. They are now one command: device, agent, networks, host
capability, local addresses, all graded and aligned the same way.
`id` was worse than either. It spawned a whole agent to print three
facts, which took the directory lock and so failed with "owned by
another running agent instance" exactly when the answer was most wanted.
The lock exists to keep one writer over the mandatory state; reading who
this device is needs no such thing.
So both commands now prefer the running agent, which is live and
authoritative, and fall back to the state store, which takes no lock.
StateStore gains device_identity(), which reads and never writes:
load_or_create had the side effect of deciding an identity as a
consequence of asking about one.
Presentation moved out of the library. StatusReport::render is gone and
the CLI renders the structured data, so there is one renderer rather than
two that would drift. Health gains an Info level for rows that are facts
rather than checks — an endpoint id is neither good nor bad, and a column
of green next to plain data teaches the eye to ignore the column. A
report with no checks in it now ends without a summary instead of
claiming that everything checked out.
PathAddr and TransportKind grew Display impls; both were reaching the
user through {:?}, which is how "Direct via Ip(88.198.17.44:49792)" got
printed. The transport grading compares without regard to case, because
the agent answering can be a different build from the client asking and
this field's spelling has now changed once.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3e83e33313 |
Grade and colour what doctor reports
Findings now carry a level: ok, warn or FAIL. The distinction between the middle two is the part worth getting right — warn is something the agent runs without and that the user can fix from the line printed beneath it, FAIL is something it cannot work around. A diagnostic that grades those the wrong way round is worse than an ungraded one, so each check states which it is. The clearest case is storage: the same failure on the state directory is FAIL and on the cache directory is warn, because one is mandatory and the other is disposable. That asymmetry is central to the design and the report now shows it. The control plane check also became real — it binds a UDP socket rather than asserting that it could. Colour is redundant by construction. Every row carries its grade as a word, so the report reads identically when the escapes are gone: piped to a file, on a dumb terminal, under NO_COLOR, or to someone who cannot distinguish the colours. anstream decides whether they survive, which also gets virtual terminal processing right on Windows; it and anstyle were already in the tree through clap. What is reported and how it looks are separated, so the rendering is tested without a terminal: that a plain render contains no escapes, that a styled one says the same thing once they are stripped, that columns line up across sections whose labels differ in length, and that the summary names the worst thing found. 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> |
||
|
|
be459e5bd0 |
Add tsunagi status over a local control socket
There was no way to ask a running agent what it was doing; the only status came from the periodic print of the `up` process itself. The new ipc module is an adapter over the public API: nothing in the agent core knows it exists, so a Windows named pipe or an authenticated loopback socket can be added beside it. It is also a different interface from the peer-to-peer protocol — between processes on one machine, authorised by filesystem permissions rather than the network secret. The socket is 0600 inside an owner-only directory, the wire format is length-prefixed postcard with the same bounds the network protocol uses, and the report types are their own stable format rather than the crate's internals. The socket path is derived from the state directory into XDG_RUNTIME_DIR when there is one. A Unix socket address is limited to about 100 bytes, and a deeply nested state directory overflows it — which is exactly what happened on the first attempt. Two presentation fixes while here. Multicast is counted separately from unroutable traffic, because Linux emits multicast on every IPv6 interface and it was showing up as "packets for unknown addresses" on a healthy agent. And WireGuard protocol errors are no longer added into the dropped counter: a few are normal while both ends start a handshake at once, and a working tunnel was reporting "dropped 3" with no traffic at all. 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> |
||
|
|
0f8a4eb485 |
Issue no privileged ioctl when attaching to a prepared interface
The tun crate runs configure() by default (ensure_root_privileges is true), which is harmless today because it only acts on fields that were set, and the attach path sets none. Saying so explicitly documents the intent and keeps it true if the crate changes. Also records why packet information stays off: `ip tuntap add ... mode tun` defaults to no packet information too, so the TUNSETIFF flags match when attaching and reads and writes stay raw IP packets. 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> |
||
|
|
fae62892e0 |
Rename the --transport n0 value to relay and say what n0 means
"n0" is Number 0, the company behind iroh, and the name leaked from iroh's own preset into this project's user interface, where it explains nothing. The value is now --transport relay, which says what it does; n0 stays as an accepted alias. Also spells out, in the CLI help, the README, the threat model and the TransportPolicy docs, whose infrastructure is involved: address records are published to and resolved from dns.iroh.link, and the fallback relays are Number 0's, in the US, EU and Asia-Pacific. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5cc92d7067 |
Make joining a network idempotent and shut down cleanly on every path
Running `tsunagi up` twice with the same arguments failed with "network ... is already active", and then dropped the iroh endpoint without closing it. A configured network is activated automatically at startup, so the second run found it already up. `join_network` is declarative — "be a member of this network" — so joining one that is already active now succeeds and changes nothing. `activate_network` stays strict for callers that specifically want to know whether an inactive network was started. The CLI now closes the agent on the error path too, and handles SIGTERM as well as Ctrl-C, so a service manager stopping the agent gets the same clean shutdown an interactive user does. Also documents the two lookups people conflate: resolving one endpoint's address is iroh's public pkarr/DNS service and works today, which is why `--peer <endpoint-id>` needs no address; finding who is in a network is this project's `NetworkDiscovery` and is still static bootstrap only. Notes in the README and the threat model that `n0` and `direct` publish this endpoint's addresses to a public third-party service. 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> |