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.
192 lines
9.7 KiB
Markdown
192 lines
9.7 KiB
Markdown
# Architecture
|
|
|
|
Scope and non-scope are in [../README.md](../README.md). Rules for changing the
|
|
code are in [../AGENTS.md](../AGENTS.md). The wire format is in
|
|
[protocol.md](protocol.md).
|
|
|
|
## Two independent planes
|
|
|
|
**Control plane.** iroh provides connections between agents and carries control
|
|
messages. On top of it, this crate's protocol handles membership
|
|
authentication, participant announcements, capability exchange and — later —
|
|
state synchronisation and delivery of IP-plugin data.
|
|
|
|
**Data plane.** Separate plugins create IP connectivity. WireGuard is the first
|
|
one and is implemented in userspace — see [wireguard.md](wireguard.md). Plugin
|
|
keys, configuration and lifecycle are separate from iroh identity and from the
|
|
network secret. The core moves an opaque, bounded payload and never parses it.
|
|
|
|
**The transport in between.** Plugins do not open connections. They are handed
|
|
a `PacketLink` — an authenticated, unreliable datagram channel to one peer for
|
|
one protocol — and never learn how it is carried.
|
|
|
|
The separation between the two planes is **logical, not physical**. The control plane uses
|
|
iroh; the data plane can use iroh on its own ALPN and connection, or independent
|
|
UDP/TLS carriers. That is deliberate:
|
|
iroh's whole value is hole punching a direct path between peers behind NAT,
|
|
with a relay as fallback, and a data plane that refused to use it would have to
|
|
reimplement all of it. What the separation buys is that `proto` knows nothing
|
|
about packets and `dataplane` knows nothing about the control protocol, so
|
|
either can be replaced on its own.
|
|
|
|
A plugin talks to the core through three narrow hooks — `on_network_activated`,
|
|
a `PluginContext` for re-announcements and error reports, and a bounded
|
|
`shutdown` — so the core never learns anything protocol-specific.
|
|
|
|
Private pair negotiation uses a bounded transport sender supplied by the core.
|
|
Protocol crates do not encode or parse control messages and never receive the
|
|
network secret. No pair profiles enter signed state.
|
|
|
|
A data plane failure never stops the daemon: the control plane keeps running
|
|
and the agent stays manageable.
|
|
|
|
## Userspace routing
|
|
|
|
The routing engine builds shortest paths over opaque peer identifiers, with no
|
|
WireGuard or iroh dependency. The relay adapter binds those next hops to
|
|
`PacketLink` handles and publishes an immutable table per network and protocol.
|
|
One reader per raw transport forwards transit without entering the plugin or
|
|
TUN. End-to-end plugin links survive physical link changes. The existing
|
|
authenticated control mesh supplies first-hand topology; the data router does
|
|
not tunnel control sessions. See [routing.md](routing.md).
|
|
|
|
## Module responsibilities
|
|
|
|
| component | responsibility |
|
|
|---|---|
|
|
| `identity` | persistent device identity; network space parameters; derived identifiers and keys |
|
|
| `discovery` | obtaining and publishing address hints — not authentication, not state transfer |
|
|
| `net` | iroh connections, addresses, paths, statistics, connection events |
|
|
| `proto` | message format, handshake, membership proof, protocol limits |
|
|
| `agent` | agent and per-network lifecycle, reconnect, in-process message routing |
|
|
| `storage` | mandatory state and the separately recoverable cache |
|
|
| `state` | signed records that outlive a session, merged between replicas |
|
|
| `dataplane::transport` | authenticated datagram links to peers; where reachability lives |
|
|
| `dataplane::routing` | transport-independent graph, shortest paths and opaque flow identifiers |
|
|
| `dataplane::relay` | immutable forwarding snapshots and transport-to-transport transit |
|
|
| `dataplane` | the contract an IP protocol implements |
|
|
| `overlay::broadcast` | domain-scoped IPv4 UDP discovery fanout and receive admission |
|
|
| `overlay` | the one interface an agent owns: provisioning, the TUN, whose packet is whose |
|
|
| `dns` | the DNS view of a network, and telling the system resolver about it |
|
|
|
|
And in crates of their own:
|
|
|
|
| crate | responsibility |
|
|
|---|---|
|
|
| `tsunagi-awg` | ephemeral pairwise Amnezia masking, private negotiation and independently encrypted UDP carriers; reuses the end-to-end WireGuard engine |
|
|
| `tsunagi-wg-quic` | the `wg-quic` protocol: its keys, its announcement, its tunnels |
|
|
| `tsunagi` | the command line agent, built as the `tsng` executable: the only place that owns a runtime, a logger and signals |
|
|
|
|
The core library is the package `tsunagi-core`; its library target is still called `tsunagi`, so code says `use tsunagi::…`.
|
|
|
|
A protocol is a separate crate so the boundary is the compiler's to enforce,
|
|
and so it can carry its own version. That version is not what peers compare:
|
|
they compare the *wire* version, which moves only when the bytes do, so two
|
|
peers on different releases keep working.
|
|
|
|
Abstractions exist only where something is really substituted or really needs
|
|
isolating for tests: `NetworkDiscovery`, `IpPlugin`, `PacketTransport` /
|
|
`PacketLink` (so a protocol's carrier can change), `TunFactory` (which is what
|
|
lets the whole data plane be tested without privileges), `InterfaceProvisioner`
|
|
and `DnsPublisher` (which are where the platforms differ). Everything else is a
|
|
concrete type.
|
|
|
|
## Runtime shape
|
|
|
|
```text
|
|
Agent one persistent identity, one iroh endpoint,
|
|
├── EndpointAdapter (two ALPNs) one state directory, N networks
|
|
├── Storage (state.sqlite + cache.sqlite + ownership lock)
|
|
├── IrohTransport ── data plane links, weak ref back to the agent
|
|
├── accept loop task ── routes by ALPN; weak ref, exits when the agent drops
|
|
├── plugin request loop ── re-announcements and plugin error reports
|
|
└── NetworkRuntime per NetworkId
|
|
├── discovery + dial loop (bounded concurrency, backoff with jitter)
|
|
├── Session per peer (control)
|
|
│ ├── reader task ── frames in -> SessionEvent
|
|
│ └── writer task ── encoded frames out
|
|
└── PacketLink per (peer, plugin protocol), handed to the plugin
|
|
```
|
|
|
|
Every strong reference from a background task back to the agent is a `Weak`.
|
|
A cycle there would keep the databases open and the directory lock held
|
|
forever after shutdown.
|
|
|
|
The library starts no runtime, installs no logging subscriber, handles no
|
|
signals, never forks and never calls `process::exit`. Startup
|
|
(`Agent::spawn`) and shutdown (`Agent::shutdown`) are explicit, background
|
|
tasks are joined on shutdown, and there is no global mutable state — several
|
|
independent agents run in one test process.
|
|
|
|
`Agent::spawn` returns as soon as the local agent is ready. It never waits for
|
|
other participants or for a relay.
|
|
|
|
### Concurrency decisions
|
|
|
|
- The network runtime is a single task owning its own state, driven by a
|
|
command channel plus event channels. No shared locks on the hot path.
|
|
- A session is two tasks, because a partially consumed stream read cannot be
|
|
cancelled safely. The writer's frame write *is* cancellable, so shutdown
|
|
never waits on a peer that stopped reading.
|
|
- Outbound queues are bounded. A full queue fails that send instead of stalling
|
|
every other peer in the network.
|
|
- Envelopes are encoded in the runtime, not in the writer, so the exact number
|
|
of control bytes queued is known and reported rather than guessed.
|
|
- Simultaneous mutual dials are resolved by a deterministic rule both sides
|
|
compute identically: keep the session whose initiator has the smaller
|
|
endpoint id.
|
|
|
|
### Storage
|
|
|
|
Two physically separate SQLite files with their own schema versions and
|
|
migrations, no ORM. All access runs on the blocking pool; no database lock is
|
|
held across a network `await`. Files are owner-only where the platform supports
|
|
it. One state directory belongs to one live agent, enforced by an advisory OS
|
|
file lock — an existence check is not enough, because a crashed process must not
|
|
block a restart and two agents starting at once must not both win. A clean
|
|
shutdown releases the lock immediately.
|
|
|
|
### Observability
|
|
|
|
`AgentStatus` separates the three levels deliberately:
|
|
|
|
- **endpoint** — bound sockets, observed addresses, cache health;
|
|
- **logical network** — dial attempts and failures, handshake failures,
|
|
sessions established, disconnects, control message and byte counts, protocol
|
|
violations, plugin errors;
|
|
- **connection** — verified paths, selected path, direct/relay, RTT, QUIC
|
|
counters.
|
|
|
|
Values that cannot honestly be attributed to one network stay at the endpoint
|
|
level. A value iroh does not report is `None`.
|
|
|
|
An iroh address is an address for iroh. It is not assumed usable by any other
|
|
protocol; a future WireGuard plugin gathers its own reachability data and ships
|
|
it through the control plane as an opaque announcement.
|
|
|
|
### Relays
|
|
|
|
Standard iroh behaviour, including relay fallback, is allowed for the control
|
|
plane via `TransportPolicy::N0Defaults`. Public relays are fine for development
|
|
and carry no availability guarantee. The default is `LocalOnly`, and the test
|
|
suite never depends on a relay.
|
|
|
|
## Multiple networks
|
|
|
|
Every session, message, task and status carries an explicit `NetworkId`. Being
|
|
authenticated in network A grants nothing in network B, even over a shared
|
|
physical connection; each network gets its own sessions. Deactivating one
|
|
network does not close the agent or disturb the others.
|
|
|
|
Local deactivation is distinct from a future signed revocation of membership or
|
|
of a name. Nothing here is an irreversible global flag.
|
|
|
|
## Planned, not built
|
|
|
|
Signed per-author state, snapshots, revocations and merge rules are described
|
|
in [sync-model.md](sync-model.md). The module boundaries above are shaped so
|
|
that adding them does not require rewriting the core. The data plane locking
|
|
policy is likewise deliberately left coarse for now; the conflict domain is
|
|
described separately so it can later be narrowed to a network, peer, name or
|
|
plugin without touching the core.
|