Commit Graph
42 Commits
Author SHA1 Message Date
Aleksandr Bogomiakov 90ef5cefec Retire offline node claims with equal network-wide cleanup rights
Offline devices reserved names and IPv4 addresses indefinitely, leaving stale
members in the device history. Give every authenticated network participant
the same right to retire another device's observed signed claim without an
owner, quorum, permanent identity ban or secret change.

Add a separately signed retirement containing the target's original signed
claim as proof. Verify both signatures and network scopes, retain the strongest
proved cutoff per target with deterministic ties, and suppress claim versions
at or below it in membership, hostname ownership and IPv4 allocation. Keep the
old signed records and range agreement, and retain markers after a newer claim
returns so old replicas cannot resurrect obsolete reservations. A returning
device signs a newer claim, allocates against current holders and resets its
previous hostname priority rather than taking a reused name with an old time.

Commit local retirement and the requester's author counter atomically before
announcing it; receivers persist proofs before applying them. Bound history to
1024 targets by default, reject additional targets without evicting protection,
and treat corrupt or over-limit mandatory storage as an explicit error. Repeat
bounded marker batches with a per-session rotating cursor so queue pressure
cannot indefinitely starve later markers. Exclude retired claims and cached
hints from history-derived discovery without preventing a fresh authenticated
device from returning.

Expose Agent::forget_node through the shared local control API. CLI command
tsng network forget-node accepts exact network names, full ids or unique id
prefixes, and exact node hostnames, ids, prefixes or overlay IPv4 addresses.
Ambiguous selections change nothing. Recheck the observed version and refuse
self, online nodes, the selected exit node and absent or changed claims.
Add Forget with an identity confirmation to offline tray device rows. Widen
Show devices to 1440x640 with an 800x400 minimum to fit its contents.

Control ALPN/version advances to tsunagi/ctrl/7 and local IPC to 21; upgrade
all peers and restart the agent with matching CLI/tray clients. Data ALPN 5,
network identity derivation, handshake encoding and existing record signing
bytes remain unchanged. SQLite schema 9 adds retirement storage while retaining
identity, secrets, settings and signed claims. Document behavior and limits.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets: 519 passed, 2 existing ignored
  tests; inherited TSUNAGI_CONTROL_SOCKET and TSUNAGI_CONTROL_GROUP were unset
- git diff --check and the built CLI forget-node help

Tests use real iroh authentication and SQLite. Cover restart, stale-replica
recovery, equal rights and network isolation, hostname reuse and return, twelve
marker batches through a two-message queue, malformed signatures and proofs,
invented future cutoffs, deterministic merge, capacity, migration and injected
counter-write rollback, selectors and cleanup through two real CLI processes.
Earlier iterations caught a private test API, a collapsible tray conditional
and a crash-based CLI test waiting for QUIC's idle timeout; corrected before
the final successful suite. Manual tray rendering, macOS/Windows, privileged
TUNs and public-network traversal were not run. The requested RC tag is
v0.1.0-rc.31; no push was requested.
2026-10-12 18:04:29 +01:00
Aleksandr Bogomiakov d941cdac7a Change network protocol selections at runtime
Protocol selection previously belonged to tsng up and therefore to the
service command line. A blocked or degraded carrier could only be removed
by editing the service and restarting the whole agent. Make the ordered
selection a local per-network setting instead.

Add Agent::set_protocols and a local control request shared by CLI and tray.
Keep tsng protocols without arguments as the protocol catalog; naming a
network shows its selection and supplying a comma-separated list replaces
it. Accept exact names, full ids and unique prefixes using shared selector
rules; ambiguous selectors and invalid lists change nothing. An explicit
none selects control-only operation. Offline edits take the directory lock
and apply on the next start; live edits never write behind the daemon.

Register every built-in plugin and transport in the CLI. Treat up --protocol
as ordered initial defaults for networks without an override, allowing a
protocol absent from the unit to be enabled later. Retain the targeting of
unqualified startup options and allow explicitly scoped options to configure
initially disabled protocols. Persist NULL for inherited preferences and an
empty selection distinctly in SQLite schema 8; migration preserves existing
identities, secrets, signed records and counters.

Serialize changes with network lifecycle operations and commit preferences
before applying or announcing them. Retire only the affected network's data
links and E2E channels, cancel pending opens, and reject obsolete completion
results by attempt id. Clear peer data channels when authenticated protocol
capabilities change while retaining control sessions and topology. Replay
allocations and peer identities after asynchronously prepared plugins become
ready. A control-only start defers creation of the agent's single interface
until data is enabled; plugins receive a sink that can use it later. Automatic
carrier fallback continues to preserve E2E state; explicit policy changes
renegotiate it. Identity derivation and peer wire formats are unchanged.

Add a collapsible protocols row to each tray network tile, with a catalog
provided by the daemon, descriptions, immediately applied checkboxes and
preference-order buttons. Keep editing disabled until the worker refreshes
status so a second click cannot overwrite an edit using a stale snapshot.
Local IPC wire version is 20 for the new request and report fields; upgrade
and restart agent, CLI and tray together. Crate release versions remain unchanged; the requested RC tag is v0.1.0-rc.30.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets: 509 passed, 2 existing ignored
  tests, with inherited TSUNAGI_CONTROL_SOCKET and TSUNAGI_CONTROL_GROUP removed

Tests use real iroh membership, SQLite, UDP, AWG and WireGuard encryption,
QUIC carriers and memory TUNs. Cover either endpoint-id role, full-size packet
delivery after switching, an unaffected second active network, saved selection
after restart, empty/invalid selections, first enablement without restart,
rapid changes racing old opens, live and offline CLI changes, startup-absent
protocols and preservation of signed state through migration. Earlier compile
and focused-test iterations caught initializer mistakes, a short test secret,
asynchronous plugin allocation readiness, and an inherited system socket
path; corrected before the successful final checks. Plain cargo was unavailable
in the host shell, so validation used the Nix toolchain. Manual tray interaction,
macOS/Windows, privileged host TUNs and public-network traversal were not run.
2026-10-12 16:19:18 +01:00
Aleksandr Bogomiakov e701986a39 Add ephemeral pairwise Amnezia transport and runtime fallback
Plain WG and QUIC can be blocked or severely degraded while the iroh control
connection still works. Add an awg carrier with one independent UDP socket,
WireGuard engine and randomized masking profile per network/peer attempt.
The smaller endpoint id negotiates fresh outer keys, PSK, header protection,
header ranges, padding and signature templates through private authenticated
control messages. Restart or fallback reentry generates a new profile;
ordinary rekeying preserves the current profile. No pair parameters enter
signed state, capabilities, persistent storage or public message events.
Private payloads and serialized control buffers redact and zeroize themselves.

Adapt the bounded packet masking engine from RunaRoo/AmneziaWG-RS at commit
51579a7d6e8882d1d8d6d00822f09e18a9423b73, retaining its Apache license and
provenance. Declare WTFPL AND Apache-2.0 in package metadata to cover both
new glue and the adapted source. Reuse boringtun instead of upstream's Node/global executors and
interface machinery. This is Tsunagi AWG wire version 1, with standard WG
headers restored before authentication; external Amnezia server compatibility
is not implemented. DNS/SIP signature generators vary fields instead of
replaying captures. Each attempt closes independently, bounds allocation,
queues, candidates and fragment assemblies, and supports the full 64 KiB
carrier contract using UDP packets at most 1200 bytes. Yield between fragment
batches so large messages do not starve local socket readers.

Keep carrier encryption separate from end-to-end tunnels so multihop relays
still forward opaque ciphertext. The system owns the sole interface, signed
addresses and admission. Plugins receive neither network secrets nor paths.
Expose a bounded opaque control sender at the transport boundary; core control
messages remain outside protocol crates. Optional STUN on the pair's actual
socket adds IPv4 public candidates in CLI direct/relay mode, with a finite
query deadline and an off/alternate-server option. Local mode remains offline.
Symmetric NAT, IPv6-only paths and arbitrary UDP blocking can still require
fallback; no AWG-over-QUIC or hidden AWG relay is introduced.

Default carrier order is wg, awg, wg-quic, tcp-tls. Advance the fallback cycle
after setup failures and sustained runtime loss, even after earlier cooldowns
expire. Authenticated delivery probes traverse the actual encrypted tunnel,
fit the interface MTU and never enter the TUN. Retire after a 20-second warmup
and at least 60% loss over at least 15 completed recent observations. Preserve
logical tunnels and control sessions while explicitly closing physical links,
including TCP reader/writer tasks. Retain the newest private probe settings
across independently queued control negotiation and inbound installation.
CLI/tray reports keep end-to-end protocol and actual carrier separate.

Control ALPN/version becomes tsunagi/ctrl/6 for the new private message;
upgrade all members together. Data ALPN/envelopes remain version 5. Network
identity derivation, transcript encoding, device identities, signed records,
saved network configuration and release versions are unchanged.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets, with the inherited
  TSUNAGI_CONTROL_SOCKET override removed: 504 passed, 2 pre-existing ignored

Tests use real iroh membership, SQLite, UDP sockets and WireGuard encryption
with memory TUNs. Cover three independent pairs, full-size IP and carrier
frames, restart freshness/socket release, 80% carrier datagram loss, fallback
to real iroh, fresh retries with working delivery checks, unequal small MTUs,
malformed negotiations/profiles, role and resource bounds, probe forgery and
STUN parsing. Earlier compile/lint iterations and focused tests caught RNG
API/initializer mistakes, a retry assertion racing path installation, and a
large-fragment burst overflowing loopback buffers; corrected before these
successful checks. Normalized upstream license line endings for diff checks.
Public NAT/STUN connectivity, actual DPI effectiveness, WAN performance,
per-pair memory measurement, macOS/Windows, privileged TUN operations and
manual tray interaction were not exercised.
2026-10-11 13:52:10 +01:00
Aleksandr Bogomiakov ed28e86748 Derive default network ranges and switch overlapping networks
Every first network previously proposed 10.13.37.0/24. Independently created
networks therefore collided when a device joined more than one of them.
Use the existing stable NetworkId-derived /24 in 10/8 for every new network,
independent of activation order or peer availability. Legacy and explicit
signed ranges outrank the derived default; remaining range disagreements
still use author-id ordering. The derivation, identity labels, handshake and
signed record encoding stay unchanged. There is no creator, timestamp or vote.

Reserve ranges before starting network actors and serialise start, stop and
switching through the agent lifecycle lock. Refuse equal or nested overlaps
without changing a network's subnet locally. A late authenticated range that
collides suspends only its network before updating plugins or the interface.
Coalesced watch notifications let the agent clean up outside the network actor
and reconsider suspended networks when ranges change.

An explicit switch stops every blocker before activating the chosen network.
Suspended networks retain their startup request and resume whenever their
ranges become free, including switching from a wide range to a narrow one.
A manual stop cancels that request even for an already suspended network.
Persist preference order and enabling together in an SQLite transaction so
the user's selection survives restart; no database schema migration is needed.

CLI status and network listing show blocker names, ids, ranges and the start
command. Network start switches explicitly and reports suspended networks;
joining an overlapping network reports it as configured but inactive. The
tray adds conflict details, an amber icon, Switch to this network and Keep
stopped actions. Local IPC wire version is 19 for the extended reports, so
restart the agent and tray together. Release versions and peer wire formats
are unchanged. Update the user documentation and compatibility tests.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets with TSUNAGI_CONTROL_SOCKET
  removed from the test environment: 486 passed, 2 pre-existing ignored tests
- final focused range_conflicts integration suite after making discovery
  fixtures deterministic: 6 passed

Earlier validation attempts caught outdated diagnostic assertions, a new
test helper scope error, and the inherited system control socket override.
Those were corrected before the successful runs. Plain cargo was unavailable
in the host shell, so validation used the Nix development toolchain. macOS,
Windows, privileged host TUN operations and manual tray interaction were not
exercised.
2026-10-11 11:58:27 +01:00
Aleksandr BogomiakovandClaude 97377e3703 Relay across any mix of links, one online answer for every client, first claim owns a hostname
A tunnel no longer depends on the kind of link under it. The relay hub keeps
one forwarding table per network instead of one per protocol, the envelope
names the end-to-end protocol, and a neighbour reached over several kinds of
transport is sent to over the best open one. A member in the middle needs no
protocol in common with either end. Which protocol a pair runs is settled by
the lower endpoint id's announced order, so both ends agree without asking.
A direct link that comes up later is shorter than the path through others, so
traffic moves to it with the same logical link.

The agent now answers "is it online" once: a live control session or any data
path. Members and peers report how they are reached (direct over a named
transport, relayed through a named member, or no data path yet), and `tsng
status` and the tray both read that instead of a session list and a WireGuard
handshake respectively.

A hostname belongs to whoever claimed it first. A claim carries the time its
author first claimed the name, signed with the record, and replicas order by
(time, id) so every member names the same holder. A later claimant keeps
working by address, is not found by the name, and is told so in its status,
`tsng status`, the tray and a HostnameTaken event.

Wire change: control and data ALPN 5, control PROTOCOL_VERSION 5, envelope
version 2, signing domain v3, state schema 7, local control protocol 18.
Upgrade every member together.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-05 11:39:33 +01:00
Aleksandr BogomiakovandClaude c184ef0914 Rewrite README as a short project front page; add a Nix flake
The README is now a landing page: the idea (a mesh VPN with no server, joined
with a network name and a secret), install and platform tables, exit nodes,
and a short build section. Implementation detail stays in docs/.

The flake builds tsng and the tray from source and ships a NixOS module
(services.tsunagi). Dist install notes now show the real `join -n -s` syntax,
and two links that pointed into the old README are redirected.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 17:47:44 +01:00
UltradesuandClaude Sonnet 5.5 7c176e0b74 Exit nodes on Windows: use a member as exit node via destination bypass routes
Choosing an exit node on Windows did nothing: the using side was refused,
because Windows has no per-user routing and the agent's own traffic would loop
into the tunnel it carries.

Now the overlay interface gets 0.0.0.0/1 and 128.0.0.0/1 routes (and ::/1,
8000::/1 as an IPv6 blackhole), and every underlay address the agent talks to
directly gets a /32 route through the physical default gateway, marked with
route metric 7373 so stale ones are found and removed. Bypass routes are
written before the halves and removed after them; a failed step removes what
was written. All routes are in the active store.

The addresses come from the runtime: each network runtime hands the interface
the verified direct IPv4 paths of its control connections and the relay hosts
in use (Interface::set_underlay, refreshed every dial round); relay names are
resolved when the rules are applied. ExitHostRules::needs_bypass (true only on
Windows) gates it, so Linux and macOS plans never carry or re-apply on path
changes. ExitHostPlan gains `bypass` and `bypass_hosts`; networks leaving
forget their entry.

Limits: a path that appears after the last apply goes through the tunnel until
the next apply; data-plane-only addresses are not collected separately (the
data links ride the same iroh paths). Nothing was run on Windows.

Checks: cargo fmt --check, cargo clippy --locked --workspace --all-targets
-D warnings and cargo test --locked --workspace --all-targets passed on macOS;
scripts and ordering are unit-tested with an injected runner and resolver, and
Interface::set_underlay with the mock host.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 13:43:01 +01:00
UltradesuandClaude Sonnet 5.5 637915600e Exit nodes on Windows: offer through WinNAT; using is refused
Windows reported exit nodes as unsupported. The offering side now creates one
WinNAT object per offered range (New-NetNat, named tsunagi-exit:<if>:<range>),
prunes this interface's stale ones, reads (never writes) IPv4 forwarding of
the overlay interface, and removes everything on clear. All values are
single-quoted PowerShell literals.

The using side is deliberately not implemented: Windows has no per-user or
per-process routing, so the agent's own QUIC/relay traffic cannot be kept out
of a default route through the overlay and would loop. It reports that reason
and installs no route. The CLI/tray forwarding hint and the kernel-forwarding
warning name the Windows command; docs/exit-node.md has a Windows section.

Checks: cargo fmt --check, cargo clippy --locked --workspace --all-targets
-D warnings and cargo test --locked --workspace --all-targets passed on macOS.
The script builders and plan logic are unit-tested with an injected runner on
every platform; nothing was run against a real Windows host or cross-compiled
(no Windows target installed).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 12:33:22 +01:00
UltradesuandClaude Sonnet 5.5 47c5d38d0c Exit nodes on macOS: pf anchor for offering, routes plus route-to for using
The exit-node host rules existed only for Linux (iptables + policy routing);
macOS reported "only supported on Linux yet".

Offering: a pf anchor com.apple/tsunagi-exit-<if> (evaluated by the stock
pf.conf) holds a nat rule per offered range and a pass-in rule. pf is enabled
if needed and the pfctl -E reference is released when the rules are removed.
net.inet.ip.forwarding is only read, never written, as on Linux.

Using: macOS has no uidrange policy routing and the agent is root, so the
overlay gets 0.0.0.0/1 and 128.0.0.0/1 routes and a pf route-to rule (matched
by the agent's uid) sends the agent's own QUIC/relay traffic back out the
physical interface, translated to its address. The rules are loaded before the
routes, so a failed load never sends traffic into a tunnel it cannot exempt
the agent from. IPv6 is blocked with rejecting ::/1 and 8000::/1 routes.
ExitHostPlan gains `overlay` (every network range) for the route-to exemption;
Linux ignores it.

Known limits: the egress is the default route's interface at apply time; other
root processes also bypass the tunnel. Forwarding hints in the CLI and tray
name the macOS sysctl. The rule text and command order are unit-tested with a
scripted host; nothing was run against a live pf/route (needs root), so the
route-to + nat second-pass behaviour is unverified.

Checks: cargo fmt --check, cargo clippy --locked --workspace --all-targets
-D warnings and cargo test --locked --workspace --all-targets, run on macOS
only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 12:29:40 +01:00
Aleksandr BogomiakovandClaude 9966b50407 Rename the CLI package to tsunagi and its executable to tsng
The command line package is now `tsunagi` and builds the `tsng` executable,
which the packages, the unit, the installers and the workflows install and
run. The core library package becomes `tsunagi-core`, keeping the library name
`tsunagi`, so no code changes `use tsunagi::...`. Directories follow
(crates/tsunagi-core, crates/tsunagi). Every command shown to a person is now
`tsng ...`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 02:14:43 +01:00
Aleksandr BogomiakovandClaude bb38562b0a Exit nodes: block IPv6 while one is in use; exit icon on the network tile
IPv6 has no way through an exit node, so the same policy rules get an
unreachable default for IPv6 instead of leaking it. The tray shows a green
icon in the tile corner while an exit node is in use, with a one-line
confirmation to stop it; confirmations are shortened.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 02:09:14 +01:00
Aleksandr BogomiakovandClaude b2431d7eb1 Exit nodes: offer one per network, send all traffic through a member
A member can offer itself as an exit node per network (off by default); it is
announced over the control channel (ctrl/4) once its iptables masquerade and
forward rules are in place, with a warning when kernel forwarding is off. A
device picks one member as its exit node via policy routing, with the agent's
own uid exempt from the tunnel; if the exit node disappears the rules stay
and traffic is dropped, loudly reported. CLI: `network exit-node`,
`exit-node`; tray: tile checkbox, warnings and a per-device icon with
confirmation. Linux only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 01:14:51 +01:00
Aleksandr Bogomiakov 533921059a Install broadcast route and firewall allowance on the host while broadcast is on
LAN discovery (CS 1.6 and similar) only worked after two manual steps on every
host: route 255.255.255.255 through the TUN, and allow inbound traffic on it.
Without the route the game's limited broadcast leaves via the physical adapter
and never reaches the overlay; without the firewall allowance the server's
unicast reply (from a different address than the one the request went to, so
never matched by conntrack) is dropped before the game's socket. Verified by
hand on Linux: with both in place the server answered A2S_INFO over the overlay.

The system level (not a plugin) now installs exactly those two things for the
interface it owns, and removes them when broadcast is turned off, the network
is left or stopped, or the agent exits.

Design:
- New boundary overlay::hostrules (BroadcastHostRules: idempotent apply/clear,
  with Linux, Windows, Mock and Unsupported implementations). TunFactory gains
  host_rules(); ManagedTunFactory::with_host_rules wires it in the CLI.
- Interface::sync_broadcast_rules reconciles the host against the routing
  table (RoutingTable::broadcast_host_target) and is called after address sync,
  on live `network broadcast on/off`, on network deactivation and by remove().
  Failure is contained: the outcome is kept and logged, never fatal.
- One interface carries several networks but the host has one 255.255.255.255
  route, so the source is the lowest NetworkId among broadcast-enabled networks
  with an address (documented in docs/broadcast.md).
- Linux: the route is added on the existing netlink thread (preferred source =
  overlay address, replace semantics). The firewall rule is `iptables -I INPUT
  -i <if> -p udp -s <overlay range>` tagged with a comment `tsunagi:<if>`;
  it is UDP-only and range-limited on purpose so TCP services stay unexposed.
  iptables is located in fixed system directories, never via PATH; stale tagged
  rules (e.g. from a crash) are replaced. A missing iptables leaves the route in
  place and is reported. rp_filter is not touched.
- Windows: PowerShell (absolute path) New-NetRoute metric 1 and a tagged
  inbound UDP Defender rule scoped to the interface alias and overlay range.
- status shows a `broadcast rules` row (ok, or which half failed and why).
  IPC control protocol bumped 14 -> 15 because OverlayReport gained a field.
- AGENTS.md records the narrow exception to "never touch routing/firewall".

Compatibility: restart the agent after upgrading (control protocol 15). No wire
or schema change.

Checks run (Linux only): cargo fmt --check, cargo clippy --locked --workspace
--all-targets -D warnings, cargo test --locked --workspace --all-targets, all
passing. Tests use a mock host and a scripted iptables; no OS setting is
changed. NOT done: the Windows code was never compiled for or run on Windows
(cross-compiling failed for lack of a C toolchain), only its script building is
unit-tested; the real netlink route and iptables calls were not exercised
against a live kernel in the suite; CS 1.6 was not launched from this change.
2026-10-01 15:03:43 +01:00
ab db9015b26c Allow network commands to select stored names
Leaving a configured network required copying its opaque id even when its
name identified exactly one saved network. Resolve exact, case-sensitive
names from the existing local state alongside full ids and unique id prefixes.
Use the shared resolver for leave, stop, start, secret and broadcast so the
same selector has the same meaning throughout the CLI. IPC and storage still
operate on the resolved id; release and offline-removal behavior are unchanged.

Refuse ambiguous names and name/id-prefix collisions before any mutation,
listing matching names and full ids without secrets. Full ids take precedence
to provide a definitive escape from ambiguity. Preserve copied id ellipses
without stripping literal ellipses from network names.

Update help and examples. Make administrator-friendly, predictable CLI behavior
a core requirement in AGENTS.md, including actionable errors and no guessing
which resource a destructive command should affect.

Validation on Windows: cargo fmt --all -- --check and workspace/all-target
Clippy with -D warnings passed. Release workspace/all-target tests: 318 passed,
including real-process online/offline leave-by-name, preservation of other
networks, ambiguous/unknown selectors, and consistent name selection across
commands. Two existing Windows SQLite wipe failures were explicitly skipped
(a_wipe_removes_everything_and_the_next_start_is_a_stranger and
wiping_twice_is_as_ordinary_as_wiping_once). The public-DHT smoke test and
forwarding benchmark remain ignored by default. Tests use isolated state;
the user's running agent and configured networks were not changed.
2026-09-22 19:00:08 +03:00
ab c724981bfd Added per-network LAN broadcast relay
LAN game discovery previously dropped IPv4 broadcasts at TUN ingress. Carry
limited and subnet-directed UDP broadcasts to authenticated, opted-in members
of the source network, including destinations reached through mesh relays.
Preserve the original IP/UDP bytes and deliver received broadcasts only to the
local TUN; never reflood them or expose another pair's plaintext at transit.

Build immutable recipient snapshots on address and participation changes. The
origin sends one ordinary end-to-end encrypted copy per recipient; the existing
fast, bounded-hop transport router remains unchanged. Validate UDP framing,
source ownership and destination admission without game-specific port rules.
Keep network domains isolated and refuse implicit gateways to physical LANs.
The separate broadcast policy/domain layer is the extension point for future
authorized subnet exports; physical capture, bridging and LAN deduplication
are deliberately not implemented yet.

Persist default-on participation independently for each local network. Add
join --no-broadcast/--broadcast and network broadcast <id> [on|off], including
live updates and authenticated announcements. Joining without a flag preserves
the saved choice. Opt-out stops local origination and delivery, while opaque
unicast transit for other members keeps working.

Migrate SQLite schema 3 to 4 without replacing identities or signed state.
Use control ALPN 3 and local IPC protocol 14 for the new announcement/request
shapes; update peers and restart running agents together. The data ALPN 4
envelope remains unchanged. No release version bump, tag or push is included.

Document agent-owned commits in AGENTS.md: short English subjects, explanatory
bodies, scoped staging, honest validation, and repository-local fallback author
AB <ab@hexor.cy> only when an effective name/email is missing. Release actions
remain the user's responsibility.

Validation on Windows: cargo fmt --all -- --check; cargo check --locked
--workspace --all-targets; cargo clippy --locked --workspace --all-targets --
-D warnings; release workspace/all-target tests: 313 passed. The two existing
SQLite wipe failures (a_wipe_removes_everything_and_the_next_start_is_a_stranger
and wiping_twice_is_as_ordinary_as_wiping_once) were explicitly skipped; the
public-DHT smoke test and forwarding benchmark remain ignored by default.
New coverage exercises real iroh/WireGuard multihop fanout, single delivery,
runtime opt-out, unicast replies, domain isolation, malformed input and schema
migration. TUNs are in-memory; actual games and OS adapter selection were not
tested.
2026-09-22 18:33:52 +03:00
ab b4f3e57c8d Implement fast userspace multihop mesh routing
Replace the one-intermediate-peer relay with protocol-scoped connectivity
graphs and precomputed shortest-path/ECMP forwarding snapshots. Independent
transport readers forward opaque transit frames without a plugin or TUN
round trip. Carry source, destination, a bounded hop limit and stable flow
tags; preserve end-to-end WireGuard links across topology changes.

Classify IP flows before encryption and preserve their tags through the
WireGuard pending queue. Add offline four-agent path-change coverage, loop
and isolation tests, and an opt-in release forwarding microbenchmark. Bump
control/data ALPNs while preserving persistent network identities and state.

Also include the pending Windows Mainline idle-timeout fix and its regression
test, using a reproducible vendored dependency patch.

Validation: fmt, workspace Clippy with warnings denied, and 307 release tests
passed. Two pre-existing Windows SQLite wipe failures were excluded; public
DHT and the manual benchmark remain ignored by default. The forwarding
microbenchmark measured 103 ns (64 B) and 202 ns (1280 B) per transit packet,
excluding encryption and socket I/O.
2026-09-22 18:08:26 +03:00
ab 9698f21d55 Added DHT peer resolver, fixed MTU 2026-09-22 15:44:33 +03:00
tsunagiandClaude Opus 5 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>
2026-09-22 01:59:50 +01:00
tsunagiandClaude Opus 5 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>
2026-09-22 01:32:43 +01:00
tsunagiandClaude Opus 5 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>
2026-09-22 00:30:03 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 23:32:23 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 23:19:58 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 23:03:42 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 21:54:05 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 20:41:19 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 19:57:27 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 18:05:42 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 14:46:39 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 14:33:19 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 14:04:44 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 13:53:42 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 13:43:01 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 13:11:09 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 13:00:35 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 12:41:57 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 12:36:16 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 12:23:37 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 12:17:01 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 12:08:48 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 11:55:20 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 11:07:31 +01:00
tsunagiandClaude Opus 5 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>
2026-09-21 00:10:07 +01:00