d1a0eca723cf75e3fe5f58a0c0f80ddab80e80f3
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9698f21d55 | Added DHT peer resolver, fixed MTU | ||
|
|
e735151d62 | Added windown support | ||
|
|
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> |
||
|
|
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> |
||
|
|
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> |