fae3816bbffbb2060413221946db4862fed4d898
38
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
7cea9afa37 |
Proof-of-concept mesh agent library over iroh
Working library with real iroh connections, not an interface sketch: - persistent device identity in state.sqlite, stable across restarts - deterministic network space derived from name + secret via HKDF-SHA256, with frozen labels and unambiguous length-prefixed encoding - replaceable discovery returning unverified candidates only; static bootstrap, in-memory test backend and a composite - real iroh connections plus an explicit mutual membership proof: HMAC-SHA256 over a role-separated transcript bound to the TLS exporter, the network id and both endpoint identities - small versioned control protocol: handshake, announcement, ping/pong - multiple networks per agent with enforced isolation - automatic reconnect with bounded backoff and jitter - mandatory state vs disposable cache, with a real directory ownership lock - status snapshots, event stream and honest diagnostics 47 integration and unit tests cover the required scenarios offline on loopback. Snapshots, revocations and WireGuard are designed for and documented, not implemented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |