Compare commits

..
69 Commits
Author SHA1 Message Date
UltradesuandClaude Opus 4.8 eac54f8c79 Revert the GitHub workflow guard to server_url
Release / tsunagi-linux-aarch64 (push) Canceled after 15s
Release / tsunagi-linux-x86_64 (push) Canceled after 0s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
Misdiagnosed the earlier report: Gitea was correctly running its own
.gitea/workflows copy, not this .github one, so the server_url guard was fine.
The switch to a GITHUB_RELEASE repo variable then made the workflow skip on
GitHub itself, since that variable is not set there.

Put both job guards back to `github.server_url == 'https://github.com'`, which
runs on GitHub with no manual setup and skips on Gitea (its .gitea copy is the
one that runs). No repository variable is needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 12:53:26 +01:00
UltradesuandClaude Opus 4.8 a8dfd7017b Gate the GitHub release workflow on a repo variable, not server_url
Release / tsunagi-linux-aarch64 (push) Canceled after 35s
Release / tsunagi-linux-x86_64 (push) Canceled after 0s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
The github.server_url guard did not keep this workflow from running on Gitea:
Gitea reports github.server_url as https://github.com, so the condition was
true there too and the jobs ran (and would fail on @v4/gh).

Switch both job guards to `vars.GITHUB_RELEASE == 'true'`, a repository variable
set only in the GitHub repo's settings. It is deterministic: absent on Gitea, so
the jobs skip there and the .gitea workflow is the one that runs; present on
GitHub once configured, so it runs there. The header documents how to set it
(Settings → Secrets and variables → Actions → Variables → GITHUB_RELEASE=true).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 12:50:04 +01:00
UltradesuandClaude Opus 4.8 99f4cf0220 Add a GitHub release workflow alongside the Gitea one
Release / tsunagi-linux-aarch64 (push) Canceled after 36s
Release / tsunagi-linux-x86_64 (push) Canceled after 0s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
Gitea and GitHub need different release plumbing, so each gets its own file: the
Gitea workflow stays in .gitea/workflows, and this GitHub one lives in
.github/workflows. The build half is identical (static musl Linux via
cargo-zigbuild, native macOS aarch64 and Windows x86_64); only publishing
differs — GitHub uses the v4 artifact backend and `gh release create`/`upload`,
which the Gitea file cannot use.

Because Gitea reads both .gitea/workflows and .github/workflows, every job here
is guarded with `if: github.server_url == 'https://github.com'`, so on Gitea
this file's jobs are skipped (no v4/gh failures) and the .gitea file is the one
that runs. GitHub never reads .gitea, so there it runs only this one.

Publish is re-run safe: it creates the release on the first run and uploads with
--clobber onto an existing release afterwards; a pre-release suffix on the tag
marks it prerelease.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 12:47:19 +01:00
UltradesuandClaude Opus 4.8 69263b2dd9 Move the release workflow to .gitea/workflows
Release / tsunagi-linux-aarch64 (push) Successful in 3m39s
Release / tsunagi-linux-x86_64 (push) Successful in 3m27s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
The release pipeline is Gitea-specific (v3 artifacts, Gitea release API), so it
must not run on github.com, where it would fail. GitHub only reads
.github/workflows, while Gitea reads both, so moving the file to
.gitea/workflows keeps it running on Gitea and invisible to GitHub. A separate
GitHub-flavoured release workflow can be added under .github later.

The portable .github/workflows/ci.yml (fmt/clippy/test) is left in place; it
works on both and is not what this change is about.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 12:13:45 +01:00
UltradesuandClaude Opus 4.8 20444013c0 Split release into build artifacts + a publish job (Gitea)
The direct-upload-from-each-build approach failed ("Upload the archive to the
release" exit 1) and left no artifacts to inspect. Restructured into the
familiar shape: build jobs now leave their archive as a run artifact, and a
single publish job — gated on all targets succeeding — collects them and fills
the release.

- Build jobs use actions/upload-artifact@v3 (Gitea has no v4 backend; v3 is
  what shows under the run), so the archive is visible and handed on.
- publish (needs: build) downloads everything with download-artifact@v3 and
  uploads each archive to the Gitea release with curl, once, with no matrix
  race. It prints the API base and the HTTP status/body of every call, so a
  failure (e.g. a read-only Actions token → 403, or a bad API URL) is now
  visible in the log instead of a bare exit 1.
- Release API base prefers GITHUB_API_URL (Gitea sets it to its /api/v1 root)
  and falls back to GITHUB_SERVER_URL/api/v1. Asset uploads run in the main
  shell via process substitution so a failed upload fails the job.

Build, package, musl/zig setup and the target matrix are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 12:12:53 +01:00
UltradesuandClaude Opus 4.8 2196f6635b Quiet the cosmetic zig linker_messages warning on the musl build
The musl release build now links cleanly through cargo-zigbuild, but rustc's
new linker_messages lint echoed zig's "ignoring deprecated linker optimization
setting '1'" note — zig's linker declining the -O1 GNU-ld flag Rust passes. It
is cosmetic and the binary is correct, so allow that one lint, scoped to the
zig build step only. macOS and Windows keep full linker diagnostics, and a real
link error still fails the build.

No behaviour change to the produced binaries; takes effect on the next tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 12:06:04 +01:00
UltradesuandClaude Opus 4.8 6438982d45 Upload release assets via the Gitea API instead of artifacts + gh
Release / tsunagi-linux-aarch64 (push) Failing after 2m57s
Release / tsunagi-linux-x86_64 (push) Failing after 2m53s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
The release ran on Gitea Actions, where it failed at actions/upload-artifact@v4:
the v4 artifact backend (and the GitHub REST API that `gh release create` uses)
are not available on Gitea/GHES, so both the artifact handoff and the publish
job were dead ends there.

Reworked so each build job uploads its archive straight to the release through
Gitea's native API (`/api/v1/repos/<repo>/releases`) with curl: create the
release for the tag if absent (races between matrix jobs are resolved by reading
the id back by tag), then POST the archive as an asset. The separate publish
job, download-artifact and `gh` are gone. The id is parsed with grep, so the
runner needs only curl and coreutils — no jq, no third-party release action.
Uses the Gitea-injected GITHUB_TOKEN, GITHUB_SERVER_URL and GITHUB_REPOSITORY;
a tag with a pre-release suffix (e.g. -rc.2) is marked prerelease.

This targets Gitea by design; it no longer runs as-is on github.com (different
API), which is fine — the GitHub build is being retired. Build and package
steps, the musl/zig setup and the target matrix are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 12:04:20 +01:00
UltradesuandClaude Opus 4.8 21b619388c Pin Zig to a real release so the musl release build fetches reliably
Release / tsunagi-linux-aarch64 (push) Failing after 3m49s
Release / tsunagi-linux-x86_64 (push) Failing after 3m26s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
The release run failed at "Install zig": mlugg/setup-zig, left unpinned, resolved
to the newest version it knew of — an unreleased 0.16.0 whose tarball 404s on
ziglang.org — and the community mirrors returned 502/503 around it, so the step
errored out before cargo-zigbuild ever ran.

Pin the action to Zig 0.14.1, an archived release with a permanent official
download URL. The official source then succeeds even when the mirrors are flaky,
and the version stays compatible with cargo-zigbuild. Also moves the action to
@v2.

Only the Linux musl jobs use zig; macOS and Windows are unaffected. Not run on a
real tag yet in this session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 11:54:29 +01:00
UltradesuandClaude Opus 4.8 d1ab993dd2 Add a tagged-release CI workflow and Linux/Windows packaging
Release / tsunagi-linux-aarch64 (push) Failing after 3m12s
Release / tsunagi-linux-x86_64 (push) Failing after 2m21s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
Pushing a `v*` tag now builds the agent for every supported target and attaches
the archives to a GitHub release. Modelled on the furumi release workflow (a
build matrix, then a publish job that runs `gh release create`), adapted to this
project's two differences: it needs system integration (capabilities/polkit on
Linux, elevation+wintun on Windows, a root daemon on macOS), and its users run
varied distros, not just Ubuntu.

Linux is built as a fully **static musl** binary with cargo-zigbuild, so one
artifact runs on Arch, Alpine, NixOS and old-glibc systems alike, and can be
repackaged into whatever a distro prefers later. musl has no prebuilt
aws-lc-sys bindings, so the Linux jobs install cmake + libclang (bindgen) and
zig (the cross C compiler zigbuild drives). macOS (aarch64) and Windows (x86_64)
build natively; Linux covers x86_64 and aarch64.

Targets: x86_64/aarch64 linux-musl, aarch64 macOS, x86_64 Windows. Each archive
carries the binary, the top-level README, and the platform's install notes plus
integration files:
- dist/linux/: a static-friendly README, a systemd unit (runs as root, so no
  capability grant or polkit rule is needed; a commented non-root variant is
  included), and an install.sh that drops the binary and the unit or prints the
  setcap/polkit alternative.
- dist/windows/: install notes covering wintun.dll and Run-as-administrator.
- dist/macos/: the existing LaunchDaemon plist and notes, now also shipped in
  the archive.

Not exercised yet: the workflow only runs on a real tag push, and the
aws-lc-sys-under-musl build has not been run locally in this session (no zig
toolchain here). YAML and install.sh syntax are validated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 11:48:19 +01:00
UltradesuandClaude Opus 4.8 da7ce1dca1 Drop link-local addresses from the status "not announced" list
A host with several interfaces has a link-local (fe80::/10, and 169.254/16 on
IPv4) on each, and none of them is ever a peer candidate. Listing them under
"not announced" padded the section with one noise line per interface — most of
the list on a machine with several VPN utuns. They are now filtered out;
loopback and real unannounced addresses are unaffected.

Checks: cargo fmt --all --check and cargo clippy -p tsunagi-cli --all-targets
-D warnings pass; verified by eye against a live `tsunagi status`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 11:38:21 +01:00
UltradesuandClaude Opus 4.8 4d2f8a54ec Compact the status address list to one line per address
`tsunagi status` printed every local address as two lines: the address, then
an indented note holding the interface (and, for candidates, a priority rank).
On a host with many interfaces — common on macOS, where each VPN adds a utun
and every interface carries a link-local — that made the "local addresses"
section very long and hard to scan.

Each address is now a single line: the announce status stays the label
(announced / not announced / loopback) and the address and its interface share
the detail, as `<addr> · <iface>`. The per-row note and the candidate-priority
rank are dropped; ordering still reflects candidate priority. No other section
changes, and nothing but the presentation does.

Checks: cargo fmt --all --check, cargo clippy --locked --workspace
--all-targets -D warnings, and the tsunagi-cli tests all pass on macOS;
verified by eye against a live `tsunagi status`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 11:26:13 +01:00
UltradesuandClaude Opus 4.8 c3a6201ee2 Bring the macOS stack up: utun interface, resolver files, system paths
macOS was TUN-less: the agent ran only with `--no-tun`, so tunnels formed but
nothing reached the operating system. The provisioner, host rules and DNS
publisher had Linux and Windows implementations and fell through to the
unsupported/erroring paths on macOS. This adds the macOS half so `tsunagi up`
carries real traffic there. The shared decision logic (plan_changes,
rules_summary, Published) is untouched — only the per-platform mechanics are new.

What it does on macOS (all root-only; macOS has no per-capability grant):
- Interface: a `utun` device opened over the in-kernel utun control socket via
  the existing `tun` dependency (no kext, no Network Extension). The kernel
  assigns the `utunN` name, so a name derived from the network id is not a legal
  utun name; `open_tun` leaves the name unset on macOS and reads the real one
  back, and `Interface::start` adopts `device.name()` as the name everything
  downstream configures (host rules, reconfigure). A utun is torn down with the
  process, so a crash never leaks one. Addresses, MTU, up and the overlay subnet
  route are applied with `/sbin/ifconfig` and `/sbin/route` (absolute paths, args
  built only from locally-derived values), the same role netlink plays on Linux
  and netsh on Windows. Observation is `netdev` (getifaddrs).
- DNS: a resolver-dir publisher writes `/etc/resolver/<network>` files pointing
  the system resolver at the overlay DNS server for each zone. The files carry a
  `port`, so the server needs no privileged socket, and a marker line, so the
  publisher only ever replaces or removes files it wrote and never clobbers a
  user's own. Removed on revert.
- Broadcast host rules: the `255.255.255.255` route is installed with `route`.
  The inbound-UDP firewall allowance (a pf anchor) is deferred and reported as
  the incomplete half, so `status` says what to allow by hand; the route still
  goes in. Failure stays contained, exactly as on the other platforms.
- Privilege: probe is optimistic (like Windows); the honest answer comes from
  the utun open, whose permission error now says to run with sudo or as a daemon.

Deployment:
- Default paths on macOS are fixed system locations rather than per-user Library
  dirs, so they do not depend on whose shell ran sudo: state `/var/db/tsunagi`,
  cache `/var/db/tsunagi/cache` (StoragePaths::user_default), control socket
  `/var/run/tsunagi/agent.sock` (default_control_socket_path, mirroring the
  Windows %ProgramData% branch). The local control socket already uses the
  shared `unix` adapter, so no IPC changes were needed — macOS is Unix.
- dist/macos/ carries a root LaunchDaemon plist and install notes; README's
  deployment section now documents the macOS path instead of "not implemented".

Decisions (agreed up front): utun auto-assign with the real name adopted;
ifconfig/route rather than raw PF_ROUTE/ioctl (which would need unsafe);
/etc/resolver files rather than scutil; route-now/firewall-deferred; root via
sudo or a LaunchDaemon rather than Network Extension (which would require a
signed sandboxed app bundle, not a Rust CLI).

Checks: on macOS (Darwin 27), `cargo fmt --all --check`, `cargo clippy --locked
--workspace --all-targets -D warnings`, and `cargo test --locked --workspace
--all-targets` all pass (0 failures); the new macos-gated unit tests run under
the standard workspace command via feature unification. Not yet exercised live:
actual utun creation, ifconfig/route application and /etc/resolver writes need a
root run, which has not been done in this session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 11:15:59 +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
Aleksandr Bogomiakov d2778f0b5a Prevent WireGuard timer loop failure spam and fix handshake broadcast
When a WireGuard tunnel handshake timed out or a rekey failed,
Inner::on_tunnel_failed closed the transport link and reported the failure
to the agent core. However:
1. The peer remained in WireguardDevice::inner.peers. Once boringtun
   declared ConnectionExpired (after 90s), self.tunn.is_expired() remained
   permanently true on all subsequent 250ms timer ticks. drive_timers
   repeatedly detected failed = true every 250ms, flooding logs with
   'WARN WireGuard tunnel failed due to rekey timeout, triggering fallback'
   and sending repeated protocol failure events to the core 4 times/sec.
2. In WireguardLink::send, is_handshake_init incorrectly expected an
   envelope header offset (msg[74] == 1) when checking for initiation
   packets, which failed on raw WireGuard UDP packets. As a result,
   handshake initiations were not broadcast to all candidate endpoints.
3. In WireguardTransport::accept_loop, packet source matching contained
   dead envelope destination matching inspecting ephemeral key bytes
   against local endpoint IDs.

Fixes:
- In crates/tsunagi-wireguard/src/device.rs:
  - Add failed: bool to FlowTunnel to latch failure state. Once a failure
    is reported, update_timers returns (TunnResult::Done, false) on all
    subsequent ticks until a new handshake is started or completed.
  - In Inner::on_tunnel_failed, remove the failed peer from inner.peers so
    drive_timers ceases ticking on the dead tunnel.
- In crates/tsunagi-wireguard/src/plugin.rs:
  - In Worker::sync, retain only active unclosed links in state.links.
- In crates/tsunagi-wireguard/src/transport.rs:
  - Check is_handshake_init directly as '!msg.is_empty() && msg[0] == 1',
    ensuring handshake initiations are broadcast to all candidate addresses.
  - Clean up matched_peer to look up links and peer candidate addresses directly.
- Tests:
  - Update test_flow_tunnel_rekey_timeout_triggers_failure to assert failure
    latching and verify no repeated failure reports on subsequent ticks.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed.
- cargo build --release passed.
2026-10-01 03:18:32 +01:00
Aleksandr Bogomiakov 8cfece9479 Filter noisy internal warnings from boringtun and iroh net_report
The CLI's default tracing filter previously allowed WARN level logs from
boringtun and iroh, producing two confusing and noisy log messages during
routine operation:
1. 'WARN HANDSHAKE(REJECT_AFTER_TIME - KEEPALIVE_TIMEOUT - REKEY_TIMEOUT (on receive))':
   emitted by boringtun's internal timer tick when an endpoint primarily
   receives data for 165 seconds (180s - 10s - 5s). In boringtun, this is a
   routine scheduled rekey timer rather than an error or failure, but was
   unhelpfully logged as warn! upstream.
2. 'WARN probe timed out':
   emitted by iroh's net_report module during background HTTP captive portal
   detection when running in isolated subnets or VMs without direct internet
   access. This probe has no impact on mesh connectivity.

Update the default logging filter to:
  'info,iroh=warn,iroh::net_report=error,quinn=warn,rustls=warn,boringtun=error,mainline=off'

Real WireGuard handshake and rekey failures continue to be detected and
reported cleanly by Tsunagi's own data plane diagnostics and automatic
fallback handlers at INFO and WARN levels.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed.
- cargo build --release passed.
2026-10-01 03:06:20 +01:00
Aleksandr Bogomiakov 3930dff2dc Detect WireGuard tunnel rekey failure and trigger automatic fallback
Previously, because UDP is connectionless, when a pure WireGuard tunnel
encountered handshake or rekey failures (e.g. boringtun logging
`WARN HANDSHAKE(REKEY_TIMEOUT)` and `CONNECTION_EXPIRED`), the
`WireguardLink` never closed. It remained active in `agent.links` and
as a direct hop in `RelayHub.raw`. Consequently, outgoing packets
continued to be blackholed over the dead direct path, preventing
`RelayHub` from using working multihop relay routes and preventing
the agent from falling back to alternative protocols (`tcp-tls` or
`wg-quic`).

Key changes:
- In `crates/tsunagi-wireguard/src/device.rs`:
  - In `FlowTunnel`, track active handshake and rekey attempts via
    `handshake_started_at`. If an active handshake attempt remains
    unanswered beyond `HANDSHAKE_TIMEOUT` (15s) or boringtun reports
    `ConnectionExpired`, declare the tunnel dead.
  - Reset timeout timers upon successful handshake reception in
    `decapsulate`.
  - In `drive_timers`, when tunnel failure is detected, trigger
    `inner.on_tunnel_failed(peer)`, which closes the direct transport
    link and notifies the agent via `PluginContext::report_peer_protocol_failure`.
- In `crates/tsunagi-wireguard/src/transport.rs`:
  - Add `WireguardLink::close` to transition `is_closed` to true and
    notify `closed_notify`.
  - In `WireguardLink::recv`, select on `closed_notify` so readers
    unblock and return `None` immediately on close.
  - Add `WireguardTransport::close_link` to close active links by
    network and peer.
- In `crates/tsunagi/src/dataplane/mod.rs`:
  - Add `PluginRequest::ProtocolFailure` and public helper
    `PluginContext::report_peer_protocol_failure`.
- In `crates/tsunagi/src/agent/mod.rs` and `crates/tsunagi/src/agent/network.rs`:
  - Add `NetCommand::ProtocolFailure` to runtime command handling.
  - On protocol failure: remove dead direct link from `self.links`,
    clear it from `RelayHub.raw` (so `RelayHub` immediately routes
    over available multihop relays), record failure on `wg` for that
    peer with exponential backoff cooldown, emit `DataLinkDown`, and
    immediately call `ensure_links()` to dial the next mutually supported
    protocol (`tcp-tls` or `wg-quic`).
- Tests:
  - Add `test_flow_tunnel_rekey_timeout_triggers_failure` and
    `test_flow_tunnel_reset_on_handshake` unit tests in `device.rs`.
  - Add `test_wireguard_link_close_and_transport_close_link` in
    `transport_tests.rs`.
  - Add `test_protocol_dial_state_cooldown_and_fallback_selection` in
    `network.rs`.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (350+ tests passed).
- Built release binary: target/release/tsunagi.
2026-10-01 03:02:09 +01:00
Aleksandr Bogomiakov 35acc4d231 Fix WireGuard relay preemption with UDP probing, allow VM bridges, and categorize status addresses
When remote peers communicate across networks with mixed connectivity (e.g.
a remote Linux node, a local Linux host, and a Windows VM guest under KVM):
- Previously, `WireguardTransport` immediately created and installed
  unverified direct links into `RelayHub` in `notify_peer_capability` and
  `open()`. When a remote peer had only unroutable private addresses
  (e.g. 192.168.122.151 on a KVM NAT bridge), this dead direct link
  preempted the working 2-hop relay, causing packet drops, failed handshakes,
  and REKEY_TIMEOUT errors.
- Host-guest VM bridges (`virbr*`) were previously filtered out, preventing
  the VM guest from discovering the host's bridge IP (192.168.122.1) and
  forcing it to attempt cross-interface connections to host Wi-Fi/LAN.
- Concurrent activation of multiple networks sharing one `tsun0` interface
  raced in `sync_addresses`, resulting in netlink `File exists (os error 17)`
  and `The object already exists` on Windows.

Key fixes:
- In `crates/tsunagi-wireguard/src/transport.rs`:
  - Implement bidirectional UDP probing with `TSUNAGI_WG_PROBE_V1` PING/PONG.
  - In `open()`, probe candidate endpoints with retransmission up to 1200ms.
    If unreachable, return `TransportError::Unreachable`, preventing dead
    links from preempting valid relay routes in `RelayHub`.
  - In `notify_peer_capability`, send hole-punching probes without prematurely
    installing unverified links.
  - In `accept_loop`, reply to PING probes with PONG and install confirmed
    inbound links targeting the verified source address.
  - In `WireguardLink::send`, inspect byte 74 of enveloped packets to detect
    handshake initiations, ensuring confirmed links route data packets only
    to the active target.
- In `crates/tsunagi-wireguard/src/plugin.rs`:
  - Permit VM bridges (`virbr*`) as candidate interfaces (priority 10).
  - Add `prioritize_local_subnet` to prioritize candidates sharing a local
    IPv4 subnet with the receiver (e.g. 192.168.122.0/24).
- In `crates/tsunagi/src/overlay/interface.rs` & provisioners:
  - Add async mutex `sync_lock` to serialize concurrent address syncs across networks.
  - Handle netlink EEXIST (os error 17) and Windows already-exists errors
    idempotently as success.
- In `crates/tsunagi-cli/src/main.rs`:
  - Split `local addresses` in `tsunagi status` into `announced` (candidate
    addresses in priority order with interface and rank), `not announced`
    (container bridges/internal addresses with interface name), and `loopback`.
- Tests:
  - Add `test_wireguard_transport_unreachable_probe_times_out` and
    `test_matches_subnet` unit tests.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (350+ tests passed).
2026-10-01 02:38:13 +01:00
Aleksandr Bogomiakov c791102ad9 Fix overlay and virtual interface candidate leakage and WireGuard rekeying
On Windows (Wintun), iface.name in netdev is an adapter GUID, while
iface.friendly_name is 'tsun0'. Checking only iface.name failed to
identify and exclude Wintun overlay interfaces. Consequently, Windows
nodes announced their own overlay IP addresses (e.g. 10.13.37.26) as
candidate endpoints. Remote peers latched onto this overlay IP as the
WireGuard target, routing encrypted packets into tsun0 (recursive loop),
resulting in failed handshakes and subsequent HANDSHAKE(REKEY_TIMEOUT)
and CONNECTION_EXPIRED.

Similarly, Docker bridges (br-*, docker0) and virtual bridges were
announced and alphabetically sorted before physical LAN or Tailscale
interfaces, causing peers to attempt WireGuard handshakes with internal
Docker bridge addresses (e.g. 172.21.0.1).

Key changes:
- In crates/tsunagi-wireguard and crates/tsunagi-tcp-tls:
  - Robustly identify overlay interfaces by inspecting iface.name,
    iface.friendly_name, and iface.description for 'tsun', 'tsunagi',
    and 'wintun'.
  - Filter out container and virtual bridge interfaces (docker, br-,
    veth, virbr, cni, flannel, cali, kube, dummy, lxc, podman, vethernet,
    vboxnet, vmnet).
  - Prioritize candidates by physical interface score: interfaces with
    a default gateway (physical LAN/WAN) and Tailscale (100.64.0.0/10)
    score highest and sort to candidate #0.
  - In on_peer_capability, filter out any announced address falling inside
    any active overlay IPv4 range (defense in depth on receiver).
- In crates/tsunagi-wireguard/src/transport.rs:
  - In WireguardLink::send, broadcast Handshake Initiation packets (type 1)
    to all candidate addresses so initial handshakes and rekeys probe all
    candidate paths and recover if an endpoint roamed or NAT expired.
  - In accept_loop, prioritize exact socket address matches before falling
    back to candidate IP matches for NAT port roaming.
- In crates/tsunagi/src/net.rs:
  - Update iroh AddrFilter to inspect friendly_name and description,
    preventing Wintun overlay IPs from leaking into iroh discovery endpoints.
- Tests:
  - Add unit tests for candidate interface filtering, IP validation, and
    priority scoring in tsunagi-wireguard and tsunagi-tcp-tls.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (350+ tests passed).
2026-10-01 02:15:06 +01:00
Aleksandr Bogomiakov f80841da63 Fix WireGuard multi-network demux, candidate discovery, and status reporting
When a peer belongs to multiple overlay networks served by one agent (e.g.
a VM participating in both 'hexor' and 'ultradesu'), packets received on
the shared WireGuard UDP socket could not be differentiated by source IP/port
alone. Previously, `WireguardTransport::accept_loop` picked the first
matching link found in iteration order, starving other networks for that peer
and causing rekey timeouts (`WARN HANDSHAKE(REKEY_TIMEOUT)`,
`ERROR CONNECTION_EXPIRED(REKEY_ATTEMPT_TIME)`) and packet loss on the
starved network.

Additionally:
- When a peer had multiple candidate endpoints (such as LAN, VM bridge, and
  Tailscale CGNAT addresses), `WireguardLink` sent handshake initiations
  only to `addrs[0]`. If the first address was unreachable from the remote
  side (e.g. host's Tailscale IP unreachable from inside a local VM), the
  handshake never completed.
- In `build_report`, peers known to the `wg-quic` plugin were added with
  empty tunnels even if an active `wg` (pure WireGuard) tunnel existed,
  causing `tsunagi status` to falsely report 'no WireGuard handshake yet'
  for connected and pingable peers.
- `local_ip_candidates` previously included IPv6 addresses which caused
  OS error 22 (EINVAL) when passed to the IPv4-bound UDP socket.

Key fixes:
- In `crates/tsunagi-wireguard/src/transport.rs`:
  - Store `peer_networks` as `HashSet<NetworkId>` per peer.
  - In `WireguardLink`, maintain a list of candidate addresses and a
    `confirmed` flag. Before a handshake response confirms the active path,
    transmit datagrams (handshake initiations) to all candidate addresses.
    Upon receiving an inbound datagram, lock onto the confirmed address.
  - In `accept_loop`, deliver inbound datagrams to all active links
    associated with the sender's endpoint ID across all networks. Boringtun's
    cryptographic index and AEAD verification naturally demultiplexes and
    accepts the packet in the correct network while discarding it in others.
  - Filter candidate addresses to IPv4 to match the bound socket family.
- In `crates/tsunagi-wireguard/src/plugin.rs`:
  - Only collect IPv4 interfaces in `local_ip_candidates`.
- In `crates/tsunagi-cli/src/main.rs`:
  - Refactor `build_report` to evaluate all protocols per peer and prioritize
    whichever protocol has an active tunnel (`wg` > `tcp-tls` > `wg-quic`).
- In `crates/tsunagi-wireguard/tests/plugin_tests.rs`:
  - Add `test_wireguard_two_networks_same_peers` verifying simultaneous
    bidirectional packet transmission between two peers sharing two networks
    over a single `WireguardTransport`.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (350+ tests passed).
2026-10-01 01:38:44 +01:00
Aleksandr Bogomiakov 7070a16104 Add pure WireGuard direct UDP data plane plugin and transport
Peers communicating on uncensored networks can benefit from raw WireGuard
performance over direct UDP without QUIC framing or double-encryption
overhead. This provides Tailscale-like direct tunnel performance while
preserving Tsunagi's decentralized mesh architecture.

Key changes:
- Create `tsunagi-wireguard` crate implementing the `IpPlugin` and
  `PacketTransport` contracts:
  - `WG_PROTOCOL = "wg"` with wire version 1.
  - Bounded `WgAnnouncement` carrying WireGuard public keys, listening UDP
    ports, and candidate IP addresses (filtering out overlay interfaces).
  - `WgKeyStore` managing persistent per-network WireGuard X25519 keys
    in SQLite with zeroization on drop.
  - `WireguardTransport` providing direct UDP packet transmission, candidate
    address tracking, inbound link delivery, and roaming support.
  - `WireguardDevice` running userspace WireGuard encryption via `boringtun`,
    timer maintenance, and AllowedIPs source enforcement.
- Integrate `wg` into `tsunagi-cli`:
  - Set default protocols to `"wg,tcp-tls,wg-quic"` so all protocols are
    enabled by default in order of throughput and performance.
  - Support `"wireguard"` as a convenience alias for `"wg"` in `--protocol`.
  - Bind `PureWgTransport` and launch the inbound datagram accept loop on `up`.
  - Pass `pure_wg` plugin to `AgentControl` and update `build_report` so
    active WireGuard tunnels and overlay metrics are reported in `tsunagi status`.
  - Clean up WireGuard keys on network removal in `forget_protocol_state`.
- Comprehensive test coverage in `tsunagi-wireguard`:
  - `announcement_tests`: round-trip serialization and verification of malformed,
    mismatched network, zero port, or self-claiming announcements.
  - `transport_tests`: loopback UDP link delivery between transports.
  - `plugin_tests`: end-to-end two-peer handshake and bidirectional IP packet
    transit over direct UDP.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed.
2026-10-01 01:24:25 +01:00
Aleksandr Bogomiakov 46575d0899 Prioritize stored agreed range over default range in reserve_range
When an agent restarted with multiple networks (e.g. hexor and ultradesu),
Agent::reserve_range() previously attempted to reserve the default configured
range (10.13.37.0/24) for whichever network happened to be listed first in
storage, even if that network had already agreed upon a different range
(such as 10.79.175.0/24). As a result, the first network seized 10.13.37.0/24
in the route table, causing the second network (which genuinely used 10.13.37.0/24)
to trigger a spurious RouteError::Overlap warning at startup until the first
network asynchronously loaded its signed records and released the reservation.

Key changes:
- In crates/tsunagi/src/agent/mod.rs, make reserve_range asynchronous and
  inspect stored signed records first using StateSet::agreed_range().
- If an agreed range already exists in storage, reserve that specific range
  in routes immediately instead of preemptively claiming the default
  overlay_ipv4_range.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets --jobs 2 -- --test-threads=2 passed (349 tests passed).
2026-10-01 00:36:24 +01:00
Aleksandr Bogomiakov a89e65475d Implement Debug manually for PipeSecurityAttributes on Windows
The windows_sys::Win32::Security::SECURITY_ATTRIBUTES struct is a raw C FFI
struct that does not implement std::fmt::Debug. Deriving Debug on
PipeSecurityAttributes failed Windows compilation with E0277.

Key changes:
- In crates/tsunagi/src/ipc/windows.rs, remove #[derive(Debug)] on
  PipeSecurityAttributes and provide an explicit std::fmt::Debug
  implementation formatting nLength, lpSecurityDescriptor, and
  bInheritHandle.

Validation on Linux:
- Verified type check against Windows target via rustc.
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets --jobs 2 -- --test-threads=2 passed (349 tests passed).
2026-10-01 00:31:21 +01:00
Aleksandr Bogomiakov 2ed447ae86 Filter overlay interface from candidates and align status member columns
Peers previously advertised overlay interface (tsun0) addresses as physical
candidate endpoints for both iroh discovery and tcp-tls announcements. When an
intermediate tunnel was active between two peers (e.g., between a host and a VM),
iroh probes to the remote peer's overlay address were routed through the existing
overlay, causing iroh to select the overlay IP itself as a 'direct' endpoint.
This caused recursive tunneling (QUIC over WireGuard over TLS) and confusing
status output showing peers connecting to their own overlay IP addresses.
Additionally, in tsunagi status, peer rows were displayed with unaligned
columns, mixing control plane status and data plane metrics in a way that was
difficult to scan.

Key improvements:
- In crates/tsunagi/src/net.rs, add an AddrFilter to the iroh EndpointBuilder
  to dynamically filter out any local addresses assigned to tsun* overlay
  interfaces before publishing to address discovery services (DHT/pkarr/STUN).
- In crates/tsunagi-tcp-tls/src/plugin.rs, exclude tsun* interfaces from
  local_ip_candidates() so overlay IPs are never advertised as physical
  TCP endpoints.
- In crates/tsunagi-cli/src/main.rs, exclude tsun* interfaces from
  netwatch_addresses() so the local addresses section reflects only actual
  physical/host network interfaces.
- In crates/tsunagi-cli/src/main.rs, update member_row() to format member
  details with aligned columnar layout: the overlay IP is placed first and
  padded, followed by control plane transport and latency, and line 2 pads
  tunnel metrics so that the data link endpoint (via ...) aligns vertically.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets --jobs 2 -- --test-threads=2 passed (349 tests passed).
2026-10-01 00:24:53 +01:00
Aleksandr Bogomiakov ac5a1596ef Allow unelevated Windows processes to access control socket via SDDL and shared path
When running the tsunagi agent as an elevated Administrator or Windows Service,
unprivileged user-space processes (such as a system tray application, user-level
CLI commands, or status monitors) could not interact with the control socket.
This was due to two constraints on Windows:
1. The default control socket path was derived from the per-user %APPDATA%,
   making an administrator's socket invisible or inaccessible to a non-elevated
   standard user profile.
2. Even if pointed to the same named pipe, Windows Mandatory Integrity Control (MIC)
   and default ACLs prevent a Medium-Integrity (unelevated) process from opening
   a High-Integrity (elevated) pipe server for write access unless explicitly allowed.

Key changes:
- In crates/tsunagi/src/ipc/windows.rs, implement PipeSecurityAttributes using
  Win32 ConvertStringSecurityDescriptorToSecurityDescriptorW with SDDL:
  "D:(A;;GRGW;;;WD)(A;;GA;;;BA)(A;;GA;;;SY)S:(ML;;NW;;;ME)". This grants generic
  read/write access to Everyone (WD), retains full control for Administrators (BA)
  and LocalSystem (SY), and sets the Mandatory Label SACL to Medium Integrity (ME)
  with No-Write-Up (NW).
- In crates/tsunagi/src/ipc/mod.rs, introduce default_control_socket_path() which
  points to %ProgramData%\tsunagi\agent.sock on Windows (and default per-user
  state directory on Unix).
- In crates/tsunagi-cli/src/main.rs, support TSUNAGI_CONTROL_SOCKET environment
  variable across all commands, and use default_control_socket_path() on Windows
  when running against the default state directory. Custom state directories (--state-dir)
  or explicit sockets (--control-socket) continue to take precedence, allowing
  multi-daemon setups to remain strictly isolated.
- In workspace Cargo.toml, set unsafe_code = "deny" so the workspace forbids unsafe
  code while permitting explicit, documented unsafe Win32 API calls in windows.rs.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets --jobs 2 -- --test-threads=2 passed (349 tests passed).
2026-10-01 00:13:44 +01:00
Aleksandr Bogomiakov 192ab25bbd Support RUST_LOG, enable UPnP portmapper and add NAT traversal logging
Previously, `tsunagi-cli` configured clap with a fixed default_value and
`env = "TSUNAGI_LOG"`, which caused `RUST_LOG` to be completely ignored.
In addition, `iroh` was built with `default-features = false` and lacked the
`"portmapper"` feature, which caused it to compile a stub client and disable
UPnP-IGD, PCP, and NAT-PMP port mapping. Furthermore, there was no visibility
into STUN address discovery, UPnP port mapping status, or QUIC transmission path
switching (hole punch vs relay).

Key improvements:
- Enable the `"portmapper"` feature on `iroh` in workspace Cargo.toml, pulling in
  `portmapper` and `igd-next` to actively request port mappings on local routers.
- In `crates/tsunagi-cli/src/main.rs`, support `RUST_LOG` with fallback precedence
  (`--log` > `TSUNAGI_LOG` > `RUST_LOG` > default filter). When debug or trace
  filtering is active, automatically enable `.with_target(true)` so users can see
  which subsystem emitted the log (e.g., portmapper, iroh::magicsock, tsunagi::net).
- In `crates/tsunagi/src/net.rs`, monitor `endpoint.watch_addr()` and
  `endpoint.home_relay_status()`. Emit `DEBUG` logs for local socket binding,
  discovered STUN reflexive/UPnP addresses, and relay connections. If no direct
  addresses are discovered after probe grace, emit a concise `WARN`.
- In `crates/tsunagi/src/agent/session.rs`, monitor connection `path_events()`
  to log when transmission paths are opened, closed, or selected (switching between
  relay and direct hole-punched paths), with bounded task winding down on shutdown.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (349 tests passed).
- Live verification with `RUST_LOG=debug` confirmed SSDP broadcast to 239.255.255.250:1900,
  portmapper logs, STUN QAD discovery, and clean relay shutdown.
2026-09-30 23:08:23 +01:00
Aleksandr Bogomiakov c0a76f1942 Enable all available protocols by default with tcp-tls priority
By default, the agent now enables all available data plane protocols
starting with `tcp-tls,wg-quic`, removing the need to manually pass `--protocol`
to enable HTTPS-camouflage transport. The `--protocol` option remains available
to explicitly restrict or select specific protocols (or reorder their priority).

Furthermore, in `crates/tsunagi-tcp-tls`, if default port 443 is unavailable
(e.g., due to permission constraints or address in use), the transport now
attempts fallback port 8443, and if 8443 is also unavailable, gracefully falls
back to an ephemeral port rather than failing agent startup.

Key changes:
- In `crates/tsunagi-cli/src/main.rs`, update `UpArgs::protocols` default_value
  to `"tcp-tls,wg-quic"`.
- In `crates/tsunagi-cli/src/main.rs`, reorder `PROTOCOLS` registry with `tcp-tls`
  first, followed by `wg-quic`.
- In `crates/tsunagi-tcp-tls/src/transport.rs`, add multi-tier fallback
  (443 -> 8443 -> ephemeral) for unconstrained port bindings.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (349 tests passed).
- cargo build --release passed.
2026-09-30 22:56:08 +01:00
Aleksandr Bogomiakov 510116afb9 Add automatic protocol fallback with backoff and silence DHT bootstrap error
When multiple data plane protocols (such as tcp-tls and wg-quic) are configured,
the agent previously attempted only the first mutually supported protocol in
served order. If that protocol failed to connect or dropped (due to NAT, firewall,
or handshake failure), ensure_links retried the exact same failed protocol
on every tick without ever falling back to alternative supported protocols.
Additionally, tcp-tls mistakenly included 127.0.0.1 in peer candidate lists,
causing peers to connect to their own local listener and fail certificate
verification (NotValidForName). Finally, BitTorrent DHT bootstrap failures
in the third-party mainline crate were logged as noisy ERRORs despite overlay
connectivity operating normally.

Key improvements:
- In crates/tsunagi/src/agent/network.rs, implement ProtocolDialState with
  exponential backoff (3s, 6s, 12s, 24s, 48s, capped at 60s).
- In ensure_links, select the highest-priority mutually supported protocol
  that is not currently on backoff cooldown. If a protocol fails, immediately
  record the failure and fall back to alternative mutually supported protocols
  without waiting for the periodic ticker.
- In crates/tsunagi-tcp-tls, remove localhost loopback from local_ip_candidates
  and capability announcements, and prevent open() from connecting to our own
  listening port.
- In crates/tsunagi-cli/src/main.rs, set mainline=off in the default tracing
  filter so benign BitTorrent DHT routing table bootstrap errors do not alarm users.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (349 tests passed).
- cargo build --release passed.
2026-09-30 22:51:08 +01:00
Aleksandr Bogomiakov e24548b3b0 Fix Windows address allocation check and add custom SNI for tcp-tls
When running on Windows with Wintun, assigning an overlay IPv4 address
via netsh triggers Duplicate Address Detection (DAD). During this tentative
window, binding a UDP socket to the address returns WSAEADDRNOTAVAIL
(os error 10049), causing the agent to prematurely log a false-positive
PluginError ('allocated ... but the address is not on any interface').

Furthermore, Russian TSPU / DPI filters block unrecognized SNIs and
non-standard ALPNs on TCP port 443 with TCP RST injection (os error 10054).
The default 'tsunagi.local' SNI and 'tsunagi-tls/1' ALPN were trivially
fingerprinted.

Key improvements:
- In crates/tsunagi/src/overlay/tun.rs, enhance address_is_local on Windows
  to inspect active OS interfaces via netdev::get_interfaces() if UDP
  bind fails.
- In crates/tsunagi/src/agent/network.rs, introduce an asynchronous retry
  loop in publish_allocations on Windows (up to 1.5s in 150ms intervals)
  to gracefully allow DAD and IP helper updates to complete before
  reporting missing addresses.
- In crates/tsunagi-tcp-tls, add support for custom SNI domain via
  protocol options (-o sni=<domain>), defaulting to 'cloudflare.com'
  instead of 'tsunagi.local'.
- Switch TLS ALPN in tsunagi-tcp-tls from 'tsunagi-tls/1' to 'http/1.1'
  to camouflage handshake traffic as standard HTTPS web traffic.
- Update tests to verify custom and default SNI behavior.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (348 tests passed).
- cargo build --release passed.
2026-09-30 20:38:16 +01:00
Aleksandr Bogomiakov a4e341cd66 Report peer tunnel protocols accurately and eliminate overlay shutdown timeout
The status output previously reported all overlay peers under WireGuard
diagnostics: when a non-WireGuard protocol (like tcp-tls) was actively
carrying traffic, the report still complained that there was 'no WireGuard
handshake yet; the tunnel cannot carry traffic' and marked peer health as
degraded (warn). Furthermore, during agent shutdown or interface removal,
the overlay packet loop waited on device.recv() for the full 5-second
TASK_GRACE period, producing a spurious warning: 'task did not wind down in
time; aborting it task="overlay packet loop"'.

Key improvements:
- Add 'protocol' field to OverlayPeerReport in tsunagi::ipc.
- Track per-peer packet metrics (tx, rx, dropped) and uptime in
  GenericTunnelPlugin via TunnelPeerReport.
- Update build_report to accurately inspect and populate active tunnel
  protocols (tcp-tls, wg-quic) and their real traffic counters.
- Format member rows in CLI status with the actual protocol name, uptime,
  and real traffic metrics; mark health as Good when an active tunnel exists.
- In Interface::remove(), abort the overlay packet loop task immediately
  before destroying the device to remove the 5s hang and spurious timeout warning.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (348 tests passed).
- cargo build --release passed.
2026-09-30 17:31:17 +01:00
Aleksandr Bogomiakov 41990c9934 Refactor agent logging and report active tcp-tls tunnels
Previous agent logging suffered from noisy repetitive '--- status ---'
dumps to stdout every 15 seconds, intermingled with raw println! event lines
and lacking structured log levels. Furthermore, the status report only
inspected WireguardPlugin tunnels; when tcp-tls took precedence and established
a link, the status falsely showed wg-quic as '0/1 tunnels established, waiting
for a data link' without mentioning the active tcp-tls link.

Key changes:
- Adopt compact, clean tracing output: initialize tracing_subscriber with
  .with_target(false).compact() and default filter
  'info,iroh=warn,quinn=warn,rustls=warn,boringtun=warn,mainline=warn'
  to keep standard agent events at INFO while silencing noisy internals.
- Route agent events through structured tracing (log_event):
  emit INFO for peer connect/disconnect, data link up/down, and network
  activation; WARN for rejections and plugin errors; DEBUG for dials.
- Disable periodic status dump by default (set status_interval default to 0),
  and re-route periodic ticks to tracing::debug! (log_status_summary).
- Add active_peers() to GenericTunnelPlugin and update build_report to
  include active tcp-tls tunnels in OverlayReport alongside WireGuard peers.
- Display enabled protocol priority on agent startup.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (347 tests passed).
2026-09-30 17:16:02 +01:00
Aleksandr Bogomiakov 254a25ebd6 Display supported protocols in status report host section
The `host` section in `tsunagi status` previously hardcoded
`implementation: userspace WireGuard (boringtun); no kernel module needed`
and omitted information about data plane protocols supported by the build.

Changes:
- Add `protocols` row to `host_section` listing supported protocols
  and their wire versions (`wg-quic (wire v4), tcp-tls (wire v1)`) with a note
  directing to `tsunagi protocols` for detailed options.
- Update `implementation` row to reflect the multi-protocol userspace
  data plane (`userspace overlay (boringtun WireGuard, TCP TLS 1.3); no kernel module needed`).

Validation on Linux:
- `cargo fmt --all -- --check` passed.
- `cargo clippy --locked --workspace --all-targets -- -D warnings` passed.
- `cargo test --locked --workspace --all-targets` passed.
2026-09-30 17:00:39 +01:00
Aleksandr Bogomiakov 5495cb5b34 Support CAP_NET_BIND_SERVICE raising for privileged TCP TLS ports
When file capabilities are granted with `sudo setcap ...+p`, capabilities
are placed into the thread's permitted set rather than its effective set.
While TUN interface provisioning in `tsunagi` properly acquired and dropped
CAP_NET_ADMIN using `caps::raise`, `tsunagi-tcp-tls` attempted to bind port 443
directly without activating CAP_NET_BIND_SERVICE, resulting in EACCES
(os error 13 Permission denied).

Fixes and improvements:
- Add `caps` dependency to `tsunagi-tcp-tls` on Linux.
- Implement `NetBindServiceGuard` to temporarily raise CAP_NET_BIND_SERVICE
  into the effective set for the synchronous `TcpListener::bind` invocation
  and immediately drop it afterwards.
- Clarify diagnostic warnings to distinguish between default port 443 fallback
  and explicitly requested port failure.
- Update all privilege diagnostics and error hints across the codebase
  to uniformly recommend granting both capabilities:
  `sudo setcap cap_net_admin,cap_net_bind_service+p <path>`.

Validation on Linux:
- `cargo fmt --all -- --check` passed.
- `cargo clippy --locked --workspace --all-targets -- -D warnings` passed.
- `cargo test --locked --workspace --all-targets` passed (all workspace unit,
  integration, and protocol tests passed).
2026-09-30 16:53:33 +01:00
Aleksandr Bogomiakov fac3bd7bb9 Add tcp-tls transport and tunnel protocol disguised as HTTPS
Implement the `tcp-tls` protocol crate and generic tunnel codec abstractions
alongside `wg-quic` to circumvent deep packet inspection (DPI/TSPU), UDP
blocking, and QUIC throttling. Traffic uses standard TCP on port 443 with
TLS 1.3 framing, looking identical to regular HTTPS traffic to network
observers.

Architectural highlights:
- TunnelCodec & GenericTunnelPlugin: Provide an extensible plugin abstraction
  in `crates/tsunagi/src/dataplane/tunnel.rs` that encapsulates background task
  lifecycles, per-peer channels, and sink dispatch, so new transport plugins
  only define cryptographic transformations and handshake payloads.
- MultiPacketTransport & Protocol Priority Selection: Support multiple
  simultaneous transports in `AgentConfig` and dynamically dispatch `open()`
  calls by protocol identifier. In `ensure_links`, choose the highest-priority
  mutually supported protocol per peer to avoid duplicate links.
- TLS 1.3 & Certificate Pinning: Generate self-signed Ed25519 X.509 certificates
  derived from the persistent `DeviceIdentity` secret. Clients pin peer
  certificates using `PinnedEndpointVerifier` against `iroh::EndpointId`.
- End-to-end Encryption: `PeerCipher` uses X25519 static-static ECDH, HKDF-SHA256,
  and ChaCha20-Poly1305 with incrementing 64-bit nonces. Relayed traffic across
  intermediate nodes remains confidential and tamper-proof.
- Privilege handling: Default to TCP port 443 with fallback to 8443 on
  `PermissionDenied`, providing an actionable message recommending:
  `sudo setcap cap_net_admin,cap_net_bind_service+p <path>`.

Validation on Linux:
- `cargo fmt --all -- --check` passed.
- `cargo clippy --locked --workspace --all-targets -- -D warnings` passed.
- `cargo test --locked --workspace --all-targets` passed (all workspace unit,
  integration, CLI, and new `tsunagi-tcp-tls` tests passed).
2026-09-30 16:41:51 +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 d1a0eca723 Fixec join CLI 2026-09-22 16:32:39 +03:00
ab 686c74a3bd Enabled DNS by default 2026-09-22 16:08:06 +03:00
ab 9698f21d55 Added DHT peer resolver, fixed MTU 2026-09-22 15:44:33 +03:00
ab 72eaf9a228 Fixed win dns 2026-09-22 13:32:37 +03:00
ab e735151d62 Added windown support 2026-09-22 12:34:19 +03:00
tsunagiandClaude Opus 5 458051f47d Warn about a zone name only when it collides with something
"`.pidar` is not a delegated top-level domain today, but it could become
one" fired on every private name anybody has ever picked, which is every
network without a dot in its name. A warning that is always there is a
warning about nothing, and it teaches people to skim past the ones that
mean something — there were two of them in one report, side by side with
the real checks.

What is left is the collision that exists: a name that really is a public
top-level domain, which does make every public name under it unreachable
from that host, and `.local`, which belongs to multicast DNS. A name that
shadows nothing is said nothing about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:09:17 +01: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 3044e592bd Say when there is nobody to contact
Two freshly wiped devices joined the same network, each got an address,
and nothing happened. The report said "none known yet; nobody else has
joined", which reads as patience — as if somebody were expected and had
not arrived. What had actually happened is that neither agent had anywhere
to look: this project publishes nothing about who is in which network, so
a first meeting needs one of them to be told the other's endpoint id, and
the hints an agent remembers from earlier sessions had gone with the wipe.

Candidates are now in the report, and having none of them with nobody
connected is said plainly, with both ways out of it and this device's own
id ready to hand to the other side. Having some and no sessions yet is the
ordinary state of a network coming up, and stays ungraded.

The local control protocol is 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:51:39 +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 1b2f050c05 Start the agent without deciding anything yet
`up` demanded a network, so there was no way to run the agent in one
terminal and decide what it belongs to in another — which is the shape of
the thing now that networks are joined, stopped and left while it runs.

`--network` is optional. Without it the agent starts with whatever it is
already configured for and waits; the banner counts the networks instead of
naming one, and points at `tsunagi network join`. A secret with no network
is refused rather than ignored, because it says nothing on its own.

The periodic summary, which had one network by construction, now falls back
to the first running one — `tsunagi status` is the whole picture and this
line was never more than a glance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:36:36 +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 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>
2026-09-21 22:16:57 +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 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>
2026-09-21 21:21:55 +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 4c84cc9e4b Tell two networks of the same name apart
A status report with two sections both headed "network LAB" reads as one
network that is somehow working and empty at once. It was two networks:
the same name with different secrets, which is two different networks
that share nothing, because a network's identity is its name *and* its
secret.

Three fixes for the one confusion.

The heading now carries the network id, so the sections are plainly
different things. A name is a label the user chose; the id is the
identity.

Joining a name that is already configured with another secret says so, at
the moment it happens, because that is almost always a mistyped secret
and until now it silently produced an empty network sitting beside a
working one. `status` flags it too, for the case where it already
happened.

And the second network's emptiness now says why. It had no address
because the only configured range was already taken by the first — one
agent has one interface, so an address belongs to one network — and
"nobody else has joined" pointed at the wrong thing entirely. It now
names the range it cannot have, the reason, and the flag that gives it
one of its own.

Nothing was wrong with the connectivity: the working network's tunnel was
up and its ping was answering throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:24:11 +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 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>
2026-09-21 19:55:30 +01:00
tsunagiandClaude Opus 5 ff7e235414 Split the command line by level
`up` now says which level each setting belongs to, and `--help` shows the
two sections. System: how the agent reaches peers, the one interface it
owns, the address range, the resolver. Transport: which protocols carry
packets and what they take.

`--wireguard` is gone. `--protocol` takes a list and defaults to
`wg-quic`, which is what the protocol is now called — WireGuard's
cryptography in QUIC datagrams, so the name says what is on the wire
rather than what the implementation borrows. `--protocol none` runs the
control plane alone.

Protocol settings moved to `-o key=value`, or `-o protocol:key=value`
when several are selected. Each protocol declares its own settings and
their help, so `tsunagi protocols` can list them without the agent
knowing anything about any protocol, and a setting nobody takes is
refused rather than dropped — a dropped setting looks exactly like one
that did not work. What the user asked for is checked before anything
that could fail on its own, so a misspelled protocol is not buried under
a privilege error.

`--wg-prefix` and `--wg-mtu` became `--interface` and `--mtu`: they were
never the protocol's, and the interface they describe belongs to the
agent. `--transport` became `--reach`, because "transport" now means the
protocol level and using the word for iroh's path policy as well would
be a collision of meaning rather than a shortage of words.

The plugin gave up the last things that were not its own: the interface
name it carried in its own state, and the check that this agent's
address is really on an interface. Both are the agent's, and the check
is now the agent's too, still said once per address rather than every
round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:44:21 +01:00
tsunagiandClaude Opus 5 7c1be332e3 Agree a protocol by name and wire version
A peer's announcement already carried a protocol and a version; only the
name was being checked. Now both are, and a peer offering a protocol at a
version this build does not speak simply has no data plane — the control
plane keeps working, messages and signed state still flow, and the
difference is reported once rather than on every announcement.

The version compared is the *wire* version, not the software version, and
the trait says so: a plugin crate has its own version and it is nobody
else's business. Two peers on different releases work together for as
long as the bytes between them have not changed, and nothing in the
negotiation may be derived from anything that moves with a release. A
test pins that agreement turns on the name and version alone, with a
peer whose announcement carries a payload this build has never seen.

`PeerStatus` gained the protocols agreed with each peer, so an empty list
is visible as what it is: a session that is up, carrying control traffic,
with no protocol in common.

The forging test plugin was announcing version 1 while claiming to speak
the current one, so its payload was being set aside for the wrong reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:34:38 +01:00
tsunagiandClaude Opus 5 9415866193 Take the interface off the protocol and give it to the agent
One agent, one interface. The plugin no longer creates one, no longer
holds a TUN factory, no longer keeps a routing table and no longer
decides who owns an address. What is left of it is the protocol: a
WireGuard key per network, a tunnel per peer, encryption on the way out
and decryption on the way in.

The packet path is now explicit about where each decision lives. Out: the
agent's interface reads a packet, the routing table says whose
destination it is, and each protocol is asked in turn whether it can
carry it there. In: the protocol decrypts and hands the packet up with
the peer it came from attached, and the agent checks that peer is
entitled to the source address before writing it out. A protocol proves
who; only the system level knows what they may say.

Two things found by making it work.

`carry` sent the packet unencrypted at first. The encryption had lived in
the interface loop that moved to the core, so taking that out quietly
removed it — the receiving end rejected plaintext as a bad WireGuard
datagram and the counters said nothing at all. Encryption belongs with
the protocol and is now there, with packets dropped for having no session
yet counted apart, because a handful while a tunnel comes up is normal
and a number that keeps climbing is not.

An agent could impose a range it could not itself route. With one
interface two networks need different ranges, and "the lowest author's
range wins" would have carried one agent's colliding default to
everybody. The configured range is now reserved when a network is
activated — on the serialised path, so the answer does not depend on
which task ran first — and an agent that cannot have it proposes nothing
and adopts whatever the network settles on.

Leaving a network takes its address off the interface and leaves the
interface; the interface goes when the agent does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:20:30 +01:00
tsunagiandClaude Opus 5 c852de2c78 Move packets across the interface at the system level
The loop that reads the interface, decides whose packet it is and hands
it to whatever can carry it — and the other direction, where a packet a
protocol decrypted is checked and written out.

Neither direction knows which protocol is involved. The interface asks
for a packet to reach a peer and is told whether that was possible;
`PacketCarrier` is the whole of what it knows about protocols. That is
what lets several be live at once without any of them owning the
interface they are carrying traffic for.

The source check stays here rather than in a protocol, and the split is
the point: a protocol proves *who* sent a packet, and only this level
knows what that member is entitled to say, because entitlement is the
signed claim.

Four kinds of drop are counted apart, because they want different
answers: a destination nobody holds (with the first such address kept, so
the number can be acted on), a destination that is known but has no live
protocol, multicast the operating system emitted anyway, and a packet
that could not be read. Rolled into one counter they would say only that
something is wrong.

Not wired in yet: the WireGuard plugin still owns its own interface, and
that is the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:54:52 +01:00
tsunagiandClaude Opus 5 a80a014f20 Decide at the system level whose packet this is
The routing table, which is what makes one interface able to serve
several protocols. A packet coming off it could belong to any network the
agent is in and to any protocol currently carrying traffic; the answer
comes from signed state, which no protocol owns, so this is the right
level for it.

Two questions, deliberately not the same one. Outbound: whose is this
destination — and a packet for nobody is dropped rather than flooded,
because a tunnel is not a broadcast domain. Inbound: this peer decrypted
a packet claiming this source, is that address actually its — checked
against the signed claim and never against anything the peer said.

A packet addressed to this agent itself routes nowhere, rather than to
whichever peer happens to be listed.

One interface means an address belongs to one network, so two networks
whose ranges overlap are refused with the reason and the fix. Guessing
between them would hand somebody's traffic to a stranger. Nested ranges
count as overlapping, which is the case the obvious comparison misses.

Not wired in yet: the interface loop that uses it comes next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:51:24 +01:00
tsunagiandClaude Opus 5 41d5f554c7 Describe the layers the way they now are
The module documentation still said addresses were derived from a
WireGuard key and that the plugin owned the interface. Both are now
wrong, and rustdoc caught it as broken links to modules that had moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:42:14 +01:00
tsunagiandClaude Opus 5 745bbaea06 Drop IPv6 from the overlay
The overlay address was derived from a WireGuard key, which makes it the
protocol's address — and the whole point of the interface belonging to
the system level is that every protocol carries traffic for the *same*
addresses. A derived-per-protocol address cannot be that.

So the overlay is IPv4 only: allocated at the system level, signed by the
member that holds it, and the same address whichever protocol happens to
be moving the packets. The derivation, its ULA prefix and its constants
are gone, along with the collision rule that existed only because a
derived IPv4 address has too little room to be unique — an allocated one
is unique by construction.

A real loss came with it and is restored explicitly. The announcement was
bound to its network only as a side effect of checking the derived
address, so removing that check removed the binding. It now carries the
network id and rejects a mismatch. Strictly redundant, because a
capability arrives on a session that already proved membership, and kept
because losing a property silently is the wrong way to lose one.

An unlock falls out: the MTU floor of 1280 existed because Linux tears
IPv6 down below it. Without IPv6 the floor is 576, what every IPv4 host
must be able to reassemble, so a relayed path with small datagrams can be
matched rather than warned about. The default stays 1280.

The test that forged an overlay address now forges a network id, which is
what is left to lie about. One flaky assertion fixed while passing: it
waited for "the interface has some address", which is briefly true of the
leftover it was meant to see replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:40:49 +01:00
tsunagiandClaude Opus 5 5bee148497 Move the overlay interface to the system level
One agent, one interface, owned by the agent rather than by a protocol.
That is what makes several protocols able to be live at once: a packet
leaving the interface is routed to whichever peer owns its destination,
over whichever protocol has a link to that peer, and neither protocol
has to hold the address because the agent holds it.

This commit moves the pieces without changing behaviour: provisioning,
the TUN itself, IP header parsing and interface naming are now
crates/tsunagi/src/overlay, and the WireGuard module re-exports them
while its callers are moved over. They were never WireGuard-specific —
netlink, capabilities and `ip tuntap` have nothing to do with the
protocol running on top.

They also get their own error type. An interface that cannot be created
is not a plugin failing, and now that the two belong to different levels
they should not share a word for it.

Routing, addressing and the reduced plugin contract come next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:09:57 +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 ca8759c023 Say the resolver refusal once, and hand over the fix
Running it for real turned up the predicted polkit refusal —
InteractiveAuthorizationRequired — and two things wrong with how the
agent handled it.

It logged the same line every two seconds. A condition that persists is
worth saying once, so it is now repeated only when the message changes.

It also retried at that pace. A refusal will not lift until somebody
grants permission, so retrying it as often as everything else is noise:
refusals now back off to five minutes, other failures to fifteen
seconds, and either resets the moment it succeeds or the desired setting
changes.

The more useful part: the agent prints the polkit rule that grants it,
ready to paste, naming the user it is running as. polkit decides by user
and not by capability, so this genuinely cannot be arranged from inside
the process — which makes "write a polkit rule" the user's work, and
handing them the rule rather than describing it is the difference
between a minute and an afternoon. It grants the four actions the agent
calls and nothing else; a test pins both halves of that, and that the
JavaScript stays within what duktape implements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:11:37 +01:00
tsunagiandClaude Opus 5 75b37fdda3 Resolve overlay members by name
`--dns` serves a zone for the network's members, built from signed state,
so a member that is switched off still resolves — its claim outlived the
session. IPv4 only, as agreed: the IPv6 overlay address derives from a
key that travels in live announcements, so it cannot be answered for an
absent member, and answering for some and not others depending on who is
online is worse than not answering.

On Linux the agent tells systemd-resolved where to ask, over D-Bus.
SetLinkDNSEx carries a port, which is why the server needs neither port
53 nor CAP_NET_BIND_SERVICE; the suffix goes in as a routing domain and
the link's default route is cleared, so this never becomes the resolver
for anything else. The setting is keyed to the overlay interface, which
goes with the agent, so it cleans itself up.

That step needs permission CAP_NET_ADMIN does not give — resolved asks
polkit, and polkit decides by user, not by capability — so it is
reported as its own kind of failure with its own remedy. The server runs
regardless and status prints the exact dig line: the automatic part is
what is missing, not the feature.

The zone name is the user's to choose. One shadowing a real public
domain is reported and then used, because that is a decision; the
warning knows the IANA list, says something different about `.local`
where the clash is with mDNS, and stays quiet for names reserved for
private use.

Two bugs found by running it, both in the supervisor and neither
reachable from a unit test, so tests/dns_service.rs drives the real
binary. It bound to the allocated overlay address without checking that
address was on an interface — with --no-tun it never is — and left the
feature silently dead; it now tries the overlay first and falls back to
loopback. And it compared the address it got against the address it
wanted, which never matched when the preferred one could not be bound,
so it tore the listener down every two seconds; it now compares what it
tried.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:05:24 +01:00
tsunagiandClaude Opus 5 240e471c88 Answer DNS questions about the overlay
The zone and the server, without any of the operating system yet.

`dns::zone` decides what the answer is and knows nothing about packets
or sockets, so the parts worth getting right are testable on their own:
which names exist, that a neighbouring name like `evillab` is not inside
`lab`, and the difference between a name that is absent and one that
exists with nothing of the type asked for. Getting that last one wrong
would teach a resolver to stop asking for the A record it could have
had.

Names come from signed state, which is the point: a member that is
switched off still resolves, because its claim outlived the session.
Only IPv4 is served. The IPv6 overlay address derives from a WireGuard
key that travels in live announcements and is not in signed state, so it
cannot be answered for an absent member, and answering for some members
and not others depending on who happens to be online is worse than not
answering.

`dns::server` puts that on the wire with simple-dns, which is already in
the tree through iroh — a packet codec rather than a server framework,
which is the right size for answering A records from memory. respond()
goes from bytes to bytes so everything done to a packet is tested
without a socket.

It is authoritative for one zone and refuses everything else: no
recursion, no forwarding, no cache, so pointing a resolver here can
never make it a path to the outside. A message that is not a question
gets no reply at all, rather than making this a reflector for anyone who
can spoof a source address, and ANY is answered as an address question
rather than by dumping the zone. Answers too large for the client's UDP
limit are truncated so a resolver retries over TCP instead of waiting;
TCP reads are length-checked before allocating, timed out, and bounded
in number.

The zone is shared rather than copied in, so a member joining is one
write instead of a rebind that would drop questions in flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 16:50:55 +01:00
175 changed files with 41664 additions and 5859 deletions

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
name: Release
# Build the agent for every supported target on a version tag, leave each build
# as a run artifact, and then — only after every target has built — attach the
# archives to the release. Linux is a fully static musl binary (built with
# cargo-zigbuild) so one artifact runs on any distro and can be repackaged
# however a distro prefers.
#
# Targeted at Gitea Actions: artifacts use @v3 (the v4 backend is not available
# on Gitea/GHES), and the release is filled through Gitea's own API with curl
# rather than `gh`, which speaks only to github.com.
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
CARGO_NET_RETRY: "10"
jobs:
build:
name: ${{ matrix.asset }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
asset: tsunagi-linux-x86_64
archive: tar.gz
zig: true
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
asset: tsunagi-linux-aarch64
archive: tar.gz
zig: true
- os: macos-latest
target: aarch64-apple-darwin
asset: tsunagi-macos-aarch64
archive: tar.gz
zig: false
- os: windows-latest
target: x86_64-pc-windows-msvc
asset: tsunagi-windows-x86_64
archive: zip
zig: false
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
# aws-lc-sys (iroh's rustls backend) and the bundled sqlite are C, and the
# musl targets have no prebuilt bindings, so bindgen needs libclang and the
# C build needs cmake. zig is the cross C compiler cargo-zigbuild drives.
- name: Install musl build tooling
if: matrix.zig
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang libclang-dev
- name: Install zig
if: matrix.zig
uses: mlugg/setup-zig@v2
with:
# Pin a real, archived Zig release. Left unpinned the action resolves
# to the newest version it knows of (a yet-unreleased 0.16.0-dev),
# whose tarball 404s on ziglang.org while the community mirrors flake.
version: 0.14.1
- name: Install cargo-zigbuild
if: matrix.zig
uses: taiki-e/install-action@v2
with:
tool: cargo-zigbuild
- name: Build
shell: bash
run: |
set -euo pipefail
if [[ "${{ matrix.zig }}" == "true" ]]; then
# zig's linker ignores the `-O1` GNU-ld optimization flag Rust passes
# and says so on stderr, which rustc now surfaces as a linker_messages
# warning. It is cosmetic — the binary is correct — so quiet just that
# one lint to keep the musl build log clean. A real link error still
# fails the build.
export RUSTFLAGS="${RUSTFLAGS:-} -A linker_messages"
cargo zigbuild --release --locked --target "${{ matrix.target }}" --bin tsunagi
else
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
fi
- name: Package
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
stage="dist/pkg/${{ matrix.asset }}"
mkdir -p "$stage"
bin="tsunagi"
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
# The top-level README, plus the per-OS install notes and integration
# files, so the archive carries what the platform needs to set up.
cp README.md "$stage/"
case "${{ runner.os }}" in
Linux)
cp dist/linux/tsunagi.service dist/linux/install.sh "$stage/"
cp dist/linux/README.md "$stage/INSTALL.md"
;;
macOS)
cp dist/macos/cy.hexor.tsunagi.plist "$stage/"
cp dist/macos/README.md "$stage/INSTALL.md"
;;
Windows)
cp dist/windows/README.md "$stage/INSTALL.md"
;;
esac
archive="${{ matrix.asset }}-${version}.${{ matrix.archive }}"
if [[ "${{ matrix.archive }}" == "zip" ]]; then
(cd dist/pkg && 7z a "../../${archive}" "${{ matrix.asset }}")
else
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
fi
# @v3 on purpose: Gitea does not support the v4 artifact backend. This is
# what makes the archive show up under the run and hands it to `publish`.
- name: Upload the archive as a run artifact
uses: actions/upload-artifact@v3
with:
name: ${{ matrix.asset }}
path: ${{ matrix.asset }}-*.*
if-no-files-found: error
publish:
name: Publish release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download every build artifact
uses: actions/download-artifact@v3
with:
path: artifacts
- name: Show what was collected
run: find artifacts -type f
- name: Create the release and upload the archives
shell: bash
env:
# Gitea injects this with access to the current repository. If the
# release calls below return 403, the repo/runner is configured to
# hand Actions a read-only token — grant it write access to releases.
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -u
tag="${GITHUB_REF_NAME}"
# Gitea sets GITHUB_API_URL to its own API root (…/api/v1); fall back
# to building it from the server URL if it is absent.
base="${GITHUB_API_URL:-${GITHUB_SERVER_URL}/api/v1}"
api="${base}/repos/${GITHUB_REPOSITORY}"
auth="Authorization: token ${TOKEN}"
echo "release API: $api"
prerelease=false
case "$tag" in *-*) prerelease=true ;; esac
# The release id for this tag, if it already exists.
code=$(curl -sS -o resp.json -w '%{http_code}' -H "$auth" "$api/releases/tags/$tag" || true)
echo "get release by tag: HTTP $code"
id=$(grep -o '"id":[0-9]*' resp.json 2>/dev/null | head -n1 | cut -d: -f2 || true)
if [ -z "$id" ]; then
code=$(curl -sS -o resp.json -w '%{http_code}' -X POST -H "$auth" \
-H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$tag\",\"name\":\"tsunagi $tag\",\"prerelease\":$prerelease}" \
"$api/releases" || true)
echo "create release: HTTP $code"
id=$(grep -o '"id":[0-9]*' resp.json 2>/dev/null | head -n1 | cut -d: -f2 || true)
if [ -z "$id" ]; then
echo "could not create the release; response was:" >&2
cat resp.json >&2 || true
exit 1
fi
fi
echo "release id: $id"
# download-artifact@v3 nests each artifact in a directory named after
# its upload, with the single archive inside. The read loop runs in
# this shell (process substitution, not a pipe) so a failed upload
# actually fails the job.
rc=0
while IFS= read -r -d '' f; do
name=$(basename "$f")
code=$(curl -sS -o up.json -w '%{http_code}' -X POST -H "$auth" \
-F "attachment=@${f}" \
"$api/releases/$id/assets?name=${name}" || true)
echo "upload $name: HTTP $code"
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
cat up.json >&2 || true
rc=1
fi
done < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' \) -print0)
exit "$rc"
+187
View File
@@ -0,0 +1,187 @@
name: Release
# GitHub version of the release pipeline. The build half mirrors the Gitea one
# in .gitea/workflows/release.yml (static musl Linux via cargo-zigbuild, native
# macOS and Windows); only the publish half differs: GitHub needs the v4
# artifact backend and fills the release with `gh`.
#
# Every job is gated on `github.server_url == 'https://github.com'`, so this file
# runs only on GitHub. Gitea runs its own copy in .gitea/workflows; should a
# Gitea instance also read .github/workflows, the guard skips these jobs there
# so they never fail on the @v4 artifact backend or `gh`.
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
CARGO_NET_RETRY: "10"
jobs:
build:
name: ${{ matrix.asset }}
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
asset: tsunagi-linux-x86_64
archive: tar.gz
zig: true
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
asset: tsunagi-linux-aarch64
archive: tar.gz
zig: true
- os: macos-latest
target: aarch64-apple-darwin
asset: tsunagi-macos-aarch64
archive: tar.gz
zig: false
- os: windows-latest
target: x86_64-pc-windows-msvc
asset: tsunagi-windows-x86_64
archive: zip
zig: false
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
# aws-lc-sys (iroh's rustls backend) and the bundled sqlite are C, and the
# musl targets have no prebuilt bindings, so bindgen needs libclang and the
# C build needs cmake. zig is the cross C compiler cargo-zigbuild drives.
- name: Install musl build tooling
if: matrix.zig
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang libclang-dev
- name: Install zig
if: matrix.zig
uses: mlugg/setup-zig@v2
with:
# Pin a real, archived Zig release. Left unpinned the action resolves
# to the newest version it knows of (a yet-unreleased 0.16.0-dev),
# whose tarball 404s on ziglang.org while the community mirrors flake.
version: 0.14.1
- name: Install cargo-zigbuild
if: matrix.zig
uses: taiki-e/install-action@v2
with:
tool: cargo-zigbuild
- name: Build
shell: bash
run: |
set -euo pipefail
if [[ "${{ matrix.zig }}" == "true" ]]; then
# zig's linker ignores the `-O1` GNU-ld optimization flag Rust passes
# and says so on stderr, which rustc now surfaces as a linker_messages
# warning. It is cosmetic — the binary is correct — so quiet just that
# one lint to keep the musl build log clean. A real link error still
# fails the build.
export RUSTFLAGS="${RUSTFLAGS:-} -A linker_messages"
cargo zigbuild --release --locked --target "${{ matrix.target }}" --bin tsunagi
else
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
fi
- name: Package
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
stage="dist/pkg/${{ matrix.asset }}"
mkdir -p "$stage"
bin="tsunagi"
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
# The top-level README, plus the per-OS install notes and integration
# files, so the archive carries what the platform needs to set up.
cp README.md "$stage/"
case "${{ runner.os }}" in
Linux)
cp dist/linux/tsunagi.service dist/linux/install.sh "$stage/"
cp dist/linux/README.md "$stage/INSTALL.md"
;;
macOS)
cp dist/macos/cy.hexor.tsunagi.plist "$stage/"
cp dist/macos/README.md "$stage/INSTALL.md"
;;
Windows)
cp dist/windows/README.md "$stage/INSTALL.md"
;;
esac
archive="${{ matrix.asset }}-${version}.${{ matrix.archive }}"
if [[ "${{ matrix.archive }}" == "zip" ]]; then
(cd dist/pkg && 7z a "../../${archive}" "${{ matrix.asset }}")
else
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
fi
- name: Upload the archive as a run artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
path: ${{ matrix.asset }}-*.*
if-no-files-found: error
publish:
name: Publish release
needs: build
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download every build artifact
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Show what was collected
run: find artifacts -type f
- name: Create the release and upload the archives
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# download-artifact@v4 nests each artifact under its own directory, so
# the archives are artifacts/<asset>/<file>.
mapfile -t files < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' \))
printf 'asset: %s\n' "${files[@]}"
prerelease=()
case "$TAG" in *-*) prerelease=(--prerelease) ;; esac
# Create the release on the first run; on a re-run, add/replace assets
# on the release that is already there.
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release upload "$TAG" "${files[@]}" --repo "$GITHUB_REPOSITORY" --clobber
else
gh release create "$TAG" "${files[@]}" \
--repo "$GITHUB_REPOSITORY" \
--title "tsunagi $TAG" \
--generate-notes \
--verify-tag \
"${prerelease[@]}"
fi
+103 -24
View File
@@ -13,23 +13,67 @@ messages. Scope and non-scope are in [README.md](README.md).
There is no central server, no owner, no registration and no majority vote.
Design accordingly: a majority is not a root of trust.
## User experience
User convenience is a core requirement. The CLI should feel logical and
predictable to an experienced administrator: use consistent selectors and
flags, sensible defaults, and actionable errors. Let users refer to configured
networks by their exact name as well as by id or a unique id prefix; do not
make them copy an opaque identifier when the stored name identifies one network.
When a selector is ambiguous, list the matching names and ids and change
nothing. Never guess which resource a destructive command should affect.
Keep help text and examples in sync with behavior, and preserve existing
scripts when improving the interactive workflow.
## Architectural boundaries
Keep these separate. Crossing them is the main thing to review for.
- **Control plane vs data plane.** The separation is **logical, not physical**.
The control protocol in `src/proto/` knows nothing about packets, and
`src/dataplane/` knows nothing about the control protocol; either can be
replaced on its own. Both may ride on iroh — refusing to would throw away
iroh's NAT traversal and force the data plane to reimplement it. They use
different ALPNs and different connections, so a busy or broken data plane
cannot disturb control traffic.
The control protocol in `crates/tsunagi/src/proto/` knows nothing about
packets, and a protocol crate knows nothing about the control protocol;
either can be replaced on its own. Both may ride on iroh — refusing to
would throw away iroh's NAT traversal and force the data plane to
reimplement it. They use different ALPNs and different connections, so a
busy or broken data plane cannot disturb control traffic.
- **One agent, one interface.** It belongs to the system level, along with
the addresses on it and the decision of whose packet is whose. Several
protocols may be carrying traffic at once and none of them owns the thing
they carry it for. A protocol is handed a routed packet and hands back a
decrypted one; it never creates an interface and never picks an address.
- **One state directory, one identity, one live agent — many networks.** A
network is added to the agent that is already running, never by starting a
second one on the same directory. A second agent is a second identity and
is isolated: its own directories, its own interface, its own runtime. Do
not add anything that lets two agents share a directory, and do not make a
network's lifetime depend on the process that happened to start it.
- **A protocol is a separate crate with its own version.** The version peers
compare is the *wire* version, never the software version: two peers on
different releases work together for as long as the bytes between them
have not changed. Nothing negotiated may be derived from anything that
moves with a release.
- **A protocol never sees the network secret.** It proves who is at the other
end of a tunnel; proving membership of a network stays in the core, which
is why the authenticated transport does too.
- **A relay carries bytes, never packets.** A datagram passed on for two
other peers goes link in, link out: it is never written to the middle's
interface or decrypted there. Multihop routing has a bounded hop limit;
equal-cost next hops are chosen per flow. Reachability is first-hand and
volatile: each authenticated member advertises its own protocol-specific
links. Build routing tables on topology changes, never per packet. Transit
must not acquire a routing mutex or wait for a protocol/TUN reader.
- **Broadcast fanout belongs at local IP ingress.** Participation is local to
each network, enabled by default, persisted and advertised to authenticated
peers. Resolve one source domain and target only its willing members. Remote
delivery never triggers another fanout. Physical LAN exports must extend
explicit ingress and source/destination admission policies; never bypass
ownership checks or add broadcast flooding to encrypted transit.
- **Plugins never learn reachability.** An `IpPlugin` is handed a `PacketLink`
per peer and moves datagrams over it. Addresses, hole punching and relays
belong to `src/dataplane/transport/`. A plugin announcement says *who*, never
belong to `crates/tsunagi/src/dataplane/transport/`. A plugin announcement says *who*, never
*where*.
- **The core never parses a plugin payload.** See `src/dataplane/mod.rs`. Only
`src/dataplane/wireguard/announcement.rs` interprets WireGuard payloads, and
- **The core never parses a plugin payload.** See `crates/tsunagi/src/dataplane/mod.rs`. Only
`crates/tsunagi-wg-quic/src/announcement.rs` interprets `wg-quic` payloads, and
only after bounding every field. A data plane failure must never stop the
control plane.
- **Derived, not claimed.** A peer's overlay address is derived from its public
@@ -37,7 +81,7 @@ Keep these separate. Crossing them is the main thing to review for.
inbound packets are dropped unless their source is the address derived for
the peer that sent them. Never trust an address a peer announces.
- **Signed state is the only durable agreement.** A fact that must survive a
participant being away goes in `src/state/` as a record signed by its
participant being away goes in `crates/tsunagi/src/state/` as a record signed by its
author, never in a session. Merging is deterministic, an older version never
rolls back a newer one, and absence from a snapshot is not deletion. Never
add a vote or a quorum: a majority is not a trust root here, and it would
@@ -46,6 +90,12 @@ Keep these separate. Crossing them is the main thing to review for.
transaction, **before** the record is announced.
- **Plugins own their system objects.** A plugin creates and removes its own
interface and nothing else. Never touch routing, DNS or firewall settings.
The one exception is the system level, not a plugin: while a network's
broadcast is on, `overlay::hostrules` may install exactly a
`255.255.255.255/32` route and an inbound UDP firewall allowance (overlay range
only) for the interface the agent owns. Both are tagged `tsunagi:<interface>`,
reversible, removed on broadcast off, leave and shutdown, and computed from
local state, never from a remote announcement.
- **Device identity vs network identity.** The iroh endpoint id is the device's
public key. `NetworkId` is derived from name + secret only. Never conflate
them, and never let one change the other.
@@ -65,7 +115,7 @@ Keep these separate. Crossing them is the main thing to review for.
normal path *and* the important failures. See [docs/testing.md](docs/testing.md).
Do not add tests for getters or to move a coverage number.
2. **Never change `IDENTITY_SCHEME`, the derivation labels, or the transcript
encoding** in `src/identity/network.rs` and `src/proto/handshake.rs` without
encoding** in `crates/tsunagi/src/identity/network.rs` and `crates/tsunagi/src/proto/handshake.rs` without
treating it as an incompatible protocol change. Bumping the crate version or
the control protocol version must not change an existing `NetworkId`.
3. **Secrets never leak.** Not into logs, not into `Debug`, not into status
@@ -76,7 +126,7 @@ Keep these separate. Crossing them is the main thing to review for.
the library; tests opt out explicitly at the top of each file.
5. **Bounds before allocation.** Frame lengths are checked against
`Limits::max_frame_len` before a buffer is allocated. Every string, list and
queue has a limit in `src/config.rs`.
queue has a limit in `crates/tsunagi/src/config.rs`.
6. **Failure is contained.** A bad signature, wrong secret, malformed packet or
unknown version rejects one message or one session. It never stops another
network and never stops the agent. There is no irreversible global error
@@ -93,25 +143,35 @@ Keep these separate. Crossing them is the main thing to review for.
up shutdown. Wind tasks down with a grace period and then abort.
10. **Nothing from a remote announcement becomes a shell command, a filesystem
path or an OS setting.** The agent never touches interfaces or OS settings
it did not create.
it did not create (the tagged broadcast host rules above are for its own
interface).
## Where things live
| path | responsibility |
|---------------------|----------------|
| `src/identity/` | device identity; deterministic network space identity and derived keys |
| `src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
| `src/discovery.rs` | candidate sources; test and static backends |
| `src/proto/` | framing, message formats, membership handshake |
| `src/net.rs` | iroh endpoint adapter and observability snapshots |
| `src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
| `src/state/` | signed records that outlive a session, their merge rules and address allocation |
| `src/dataplane/` | the plugin contract, the packet transport, and the WireGuard plugin |
| `src/bin/tsunagi.rs`| the command line agent; the only place that owns a runtime, a logger and signals |
| `crates/tsunagi/src/identity/` | device identity; deterministic network space identity and derived keys |
| `crates/tsunagi/src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
| `crates/tsunagi/src/discovery.rs` | candidate sources; test and static backends |
| `crates/tsunagi/src/proto/` | framing, message formats, membership handshake |
| `crates/tsunagi/src/net.rs` | iroh endpoint adapter and observability snapshots |
| `crates/tsunagi/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
| `crates/tsunagi/src/state/` | signed records that outlive a session, their merge rules and address allocation |
| `crates/tsunagi/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks, broadcast host rules |
| `crates/tsunagi/src/dataplane/` | the protocol contract and the authenticated packet transport |
| `crates/tsunagi/src/dns/` | the DNS view of a network: zone, server, resolver publication |
| `crates/tsunagi-wg-quic/` | the `wg-quic` protocol: its keys, its announcement, its tunnels |
| `crates/tsunagi-cli/` | the command line agent; the only place that owns a runtime, a logger and signals |
| `tests/` | integration tests; `tests/common/` is the shared harness |
Add abstractions only at real substitution or testing boundaries. Do not add a
trait per struct. Prefer one crate with clear modules over many small crates.
trait per struct.
**The system level and its plugins are separate crates**, so that boundary is
checked by the compiler and not by discipline: a plugin can reach only what
`tsunagi` makes public, and carries its own version. Everything else stays
one crate with clear modules — do not split further without a reason of that
kind.
## Testing rules
@@ -133,7 +193,26 @@ trait per struct. Prefer one crate with clear modules over many small crates.
- Running several library instances in one process is not a test of several
system processes; do not describe it as one.
## Before you open a change
## Commit every completed change
Agents must create a Git commit before finishing any task that changes this
repository. Do not leave the user to make the commit. Inspect recent commit
messages and follow their style: a short English subject stating the change,
then a useful body explaining the problem, final behavior, architectural
decisions, compatibility/migration effects, and checks actually performed.
Write enough context for a future maintainer or LLM to understand why the code
exists. Record failed or skipped checks honestly; never claim unrun tests.
Before committing, inspect the diff and stage only this task's changes. Preserve
unrelated user changes and never commit secrets or generated build artifacts.
If Git has no effective author name or email, set only the missing repository-
local values to `AB` and `ab@hexor.cy`. Do not override an existing identity or
change global Git configuration. Report the resulting commit hash.
Version bumps, release tags, and pushing commits/tags belong to the user unless
they explicitly ask the agent to do them. Read-only tasks need no empty commit.
## Required validation
```bash
cargo fmt --all -- --check
Generated
+825 -18
View File
File diff suppressed because it is too large. Load diff
+30 -70
View File
@@ -1,80 +1,40 @@
[package]
name = "tsunagi"
version = "0.1.0"
# The system level and its plugins are separate crates, so the boundary
# between them is checked by the compiler rather than by discipline: a plugin
# can reach only what the core makes public, and carries its own version.
[workspace]
resolver = "3"
members = ["crates/*"]
exclude = ["vendor/mainline"]
# Mainline 8.0.0 treats Windows UDP read timeouts as socket failures.
# Keep the small receive-loop correction reproducible until an upstream fix.
[patch.crates-io]
mainline = { path = "vendor/mainline" }
[workspace.package]
edition = "2024"
rust-version = "1.91"
license = "MIT OR Apache-2.0"
description = "Proof-of-concept library for small private mesh networks: persistent agent identity, deterministic network spaces, iroh-based control plane."
repository = "https://github.com/tsunagi-net/tsunagi"
readme = "README.md"
keywords = ["mesh", "p2p", "iroh", "networking"]
categories = ["network-programming"]
[features]
default = ["cli"]
# The `tsunagi` command line binary. Library users can opt out.
cli = ["dep:clap", "dep:anstream", "dep:anstyle", "dep:tracing-subscriber", "tokio/signal", "tun-device"]
# A real TUN device, so the WireGuard plugin can carry actual IP traffic.
# Needs CAP_NET_ADMIN at run time; without it the plugin still runs and its
# in-memory device can be used for tests.
tun-device = ["dep:tun", "dep:rtnetlink", "dep:caps", "dep:futures-util"]
[[bin]]
name = "tsunagi"
path = "src/bin/tsunagi.rs"
required-features = ["cli"]
[dependencies]
clap = { version = "4.5", features = ["derive", "env"], optional = true }
# Already in the tree through clap. `anstream` strips the escapes when stdout
# is not a terminal and turns on virtual terminal processing on Windows, so
# colour is never written where it would show up as rubbish.
anstream = { version = "1.0", optional = true }
anstyle = { version = "1.0", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
iroh = { version = "1.2", default-features = false, features = ["tls-ring"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
rusqlite = { version = "0.40", features = ["bundled"] }
hkdf = "0.13"
hmac = "0.13"
sha2 = "0.11"
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
rand = "0.10"
serde = { version = "1.0", features = ["derive"] }
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
data-encoding = "2.11"
hex = "0.4"
thiserror = "2.0"
tracing = "0.1"
fs4 = { version = "1.1", features = ["sync"] }
directories = "6.0"
# The real system hostname, without a libc call of our own: this crate is
# forbidden `unsafe` and will not make one.
gethostname = "1.1"
netwatch = "0.19.3"
bytes = "1.12.1"
boringtun = { version = "0.7.1", default-features = false }
tun = { version = "0.8", features = ["async"], optional = true }
# Linux-only interface provisioning. `rtnetlink` configures the interface in
# process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN
# out of the effective set except during the moments it is used.
[target.'cfg(target_os = "linux")'.dependencies]
rtnetlink = { version = "0.23", optional = true }
caps = { version = "0.5", optional = true }
futures-util = { version = "0.3", default-features = false, optional = true }
[dev-dependencies]
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
tempfile = "3.24"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[lints.rust]
[workspace.lints.rust]
missing_docs = "warn"
unsafe_code = "forbid"
unsafe_code = "deny"
[lints.clippy]
[workspace.lints.clippy]
unwrap_used = "warn"
expect_used = "warn"
panic = "warn"
[workspace.dependencies]
iroh = { version = "1.2", default-features = false, features = ["tls-ring", "portmapper"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
serde = { version = "1.0", features = ["derive"] }
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
bytes = "1.12.1"
thiserror = "2.0"
tracing = "0.1"
hex = "0.4"
data-encoding = "2.11"
tempfile = "3.24"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+401 -53
View File
@@ -24,6 +24,7 @@ A working library with **real iroh connections** and integration tests:
- several independent networks at once in one agent;
- deterministic network identity derived from name + secret;
- candidates supplied by a replaceable discovery component;
- automatic first contact through the public Mainline DHT (BEP44);
- real iroh connections plus an explicit mutual proof of network membership;
- a small versioned control protocol: handshake, hostname/capability
announcement, ping/pong;
@@ -39,7 +40,7 @@ A working library with **real iroh connections** and integration tests:
### What it deliberately does **not** do
Not implemented, and not pretended to be: Mainline DHT, DNS, routing through
Not implemented, and not pretended to be: DNS, routing through
intermediate participants, a full CRDT, dynamically loaded plugins, a system
service, a complete CLI, or a local control socket. Snapshot synchronisation
and signed revocations are designed for but not implemented — see
@@ -58,7 +59,7 @@ system's and the user's responsibility, not this library's.
- Rust 1.91 or newer (iroh 1.2 requires it), edition 2024. Pinned dependencies
in `Cargo.lock`.
- No internet, no DHT, no public relay, no administrator rights and no changes
- No internet, no public DHT, no public relay, no administrator rights and no changes
to OS network settings are needed to build or test.
- WireGuard runs in userspace (boringtun): **no kernel module and no `wg`
tool**. Only creating a real network interface needs `CAP_NET_ADMIN`, and
@@ -70,29 +71,67 @@ On the first machine:
```bash
cargo build --release
./target/release/tsunagi id secret generate # prints tsn1...; share it privately
./target/release/tsunagi status # this device, the agent, and this host
./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard
./target/release/tsunagi network # the networks this device belongs to
```
It prints its endpoint id and then waits. On the second machine, pass that id:
Two commands do the work, and they are separate on purpose: `up` runs the
agent — one per device, for as long as you want it — and `join` decides
what it belongs to, at any time, while it runs.
```bash
./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard \
--peer <endpoint-id-from-the-first-machine>
# Terminal one: the agent. It prints its endpoint id and then serves.
./target/release/tsunagi up
# Terminal two: make a network. With no --secret it invents one and prints
# it, along with the line to send the other machine.
./target/release/tsunagi join --network lab
```
On the second machine, start its agent and join the same network with the
secret that was printed:
```bash
./target/release/tsunagi up
./target/release/tsunagi join --network lab --secret tsn1...
```
**Name and secret are enough for the first meeting.** Mainline DHT discovery
is enabled by default. Each active network publishes this device's endpoint
and searches until the first authenticated connection. While connected it
only republishes; after 60 seconds without any authenticated connection it
searches again. Publication is approximately every five minutes, and records
older than 15 minutes are ignored. DHT storage nodes may retain them longer.
Use `--no-dht` to disable DHT, or `--peer <endpoint-id>` for optional manual
bootstrap. `--reach local` disables public DHT regardless of the DHT flag.
Endpoint records are public, signed, and contain no network secret. Discovery
is not authentication: every connection must still prove network membership.
Details and failure behavior are in [docs/mainline-dht.md](docs/mainline-dht.md).
**One introduction is enough for the whole network.** Members tell each
other about the members they know, and every author of a signed record is
somebody to try, so a device pointed at one member ends up talking to all
of them rather than to the one that happened to be on its command line.
What travels is a candidate — an address somebody has seen — and it is
authenticated by the handshake like any other; being introduced grants
nothing. It is deliberately the same shape a lookup in a distributed hash
table would return, so that is a source to add beside this one rather than
a redesign.
An agent with nobody to contact says so in `status` rather than sitting
there looking patient.
Within a few seconds both print something like:
```text
+ peer b47c958462 connected over Direct rtt=Some(4.5ms)
+ data link to b47c958462 for wireguard: Direct via Ip(…), datagram 1382
+ peer b47c958462 connected over direct rtt=Some(4.5ms)
+ data link to b47c958462 for wg-quic: direct via 192.0.2.7:41234, datagram 1382
--- status ---
control: 1 peer(s), 0 dial failure(s), 0 handshake failure(s)
wireguard: tsunkkcp43lmdje on fd15:1d9e:fa21:f201:…/64 mtu 1280, 1/1 tunnel(s) established
4jO4kx9Z fd15:1d9e:fa21:f201:… handshake 3s ago tx=0 rx=0 dropped=0 path=Direct via Ip(…)
wg-quic: tsun0 on 10.13.37.69/24 mtu 1280, 1/1 tunnel(s) established
4jO4kx9Z 10.13.37.237 handshake 3s ago tx=0 rx=0 dropped=0 path=direct via 192.0.2.7:41234
```
`1/1 tunnel(s) established` means a real WireGuard handshake completed.
@@ -126,18 +165,21 @@ ping 100.65.243.53
`tx` and `rx` in the status should start moving.
IPv6 works out of the box: each member's address is derived from the network
id and collides with essentially nothing.
**IPv4 addresses are allocated and then remembered.** The default range is
**Addresses are allocated and then remembered.** The default range is
`10.13.37.0/24`; the first member to join settles it and later members adopt
what they find, so `--ipv4-range` only matters for whoever starts the network:
```bash
tsunagi up --network lab --secret "$SECRET" --wireguard --ipv4-range 10.44.0.0/16
tsunagi up --network lab --secret "$SECRET" --wireguard --ipv4-range none # IPv6 only
tsunagi up --ipv4-range 10.44.0.0/16
tsunagi up --ipv4-range none # no data plane at all
```
One agent has one interface, so two of its networks cannot both use that
range. The second takes the range **derived from its own network id**: not
picked locally — every member derives the same one from something they all
already have — so it is an agreement rather than a guess, and a device in
several networks gets an address in each.
An address is claimed with a record signed by that member's persistent device
key, stored, and merged between every replica. A member that disappears for a
month comes back to the same address, because the claim outlived the session.
@@ -149,6 +191,123 @@ Because the address is allocated at run time rather than derived, it is not
known until the agent has started and agreed with its peers. The agent then
assigns it to the interface itself.
## Levels
The command line is split the way the design is. A bare `tsunagi up` needs
only a network name and a secret; everything else sits under the level it
belongs to, which `--help` shows as two sections:
* **System** — what the agent itself does: how it reaches peers (`--reach`),
the one overlay interface it owns (`--interface`, `--mtu`, `--no-tun`), the
address range (`--ipv4-range`), and the local resolver (`--no-dns` to disable).
* **Transport** — which protocols carry packets (`--protocol`, a list) and
their own settings (`-o key=value`, or `-o protocol:key=value`).
```bash
tsunagi protocols # what this build can carry packets with
```
```text
wg-quic (wire version 4)
what WireGuard's cryptography carried in iroh's QUIC datagrams
-o keepalive=SECONDS keeps a tunnel and its link warm through a NAT
-o mtu=BYTES largest packet a tunnel will carry, at least 576
```
Each protocol declares its own settings, so the agent can list them without
knowing anything about the protocol, and a setting no selected protocol takes
is refused rather than ignored. `--protocol none` runs the control plane by
itself.
A pair of peers uses a protocol they both have **at the same wire version**.
That is not the software version: two peers on different builds carry traffic
for each other for as long as the bytes between them have not changed. A peer
with nothing in common keeps its control plane — messages and signed state
still flow — and simply has no data plane, which `tsunagi status` shows as a
session with no agreed protocol.
## Names
DNS is enabled by default and serves a local zone for **every network this device is in**,
each named after the network, so members can be reached by name instead of
by address:
```bash
tsunagi up # DNS is already enabled
dig @127.0.0.1 -p 5354 music.lab
```
An explicit choice is remembered across restarts. `--no-dns` or `tsunagi dns off`
disables it; `--dns` or `tsunagi dns on` enables it again. Existing saved opt-outs
remain respected. The `dns on` and `dns off` commands also take effect immediately
on a running agent. `--dns` and `--no-dns` cannot be combined.
The zone of a network is its name — `--dns-zone` is gone, because with
several networks there is no single zone to name. A network name may contain
dots, so `--network lab.internal` is how you get `music.lab.internal`.
Names come from signed state, which is the point: **a member that is
switched off still resolves**, because its claim outlived the session.
The answers are IPv4 addresses, because that is what the overlay is. The
*questions* are taken on `127.0.0.1` and `[::1]`, over UDP and TCP, so a
resolver reaches it over whichever family it uses; both are published to the
system resolver together. Loopback and nothing else: the zones are a view
for the host running the agent, and binding an overlay address would put
them in front of the whole mesh — an agent in two networks would then answer
one network's questions about the other's names.
The zone is the network name, and it is yours to choose. A name that
shadows a real public domain is reported and then used: a network called
`ru` warns that every public `.ru` name becomes unreachable from this host,
and then does it. Anything that collides with nothing — which is most names
— is said nothing about, because a warning that fires on every private name
anybody picks is how people learn to ignore warnings.
On Linux the agent tells systemd-resolved to send questions for that suffix
here, over D-Bus, scoped to the overlay interface and as a *routing* domain
so it never becomes the resolver for anything else. resolved drops the whole
setting when the interface goes, and the interface goes with the agent.
Only an interface the agent created, though. Under `--no-tun` there is no
host interface at all, and the agent says so and serves the zone on
loopback rather than configuring whatever else on the host happens to share
the name.
That last step needs permission that `CAP_NET_ADMIN` does not give:
systemd-resolved asks polkit, and polkit decides by **user**, not by
capability, so there is no way for the agent to arrange it from inside. On
a desktop the refusal reads `Interactive authentication required`.
Running as a system service is enough. Otherwise the agent prints the rule
that grants it — the four actions it calls and nothing else — ready to
paste:
```bash
sudo tee /etc/polkit-1/rules.d/50-tsunagi-resolved.rules > /dev/null <<'RULE'
polkit.addRule(function(action, subject) {
var allowed = [
"org.freedesktop.resolve1.set-dns-servers",
"org.freedesktop.resolve1.set-domains",
"org.freedesktop.resolve1.set-default-route",
"org.freedesktop.resolve1.revert"
];
if (allowed.indexOf(action.id) >= 0 && subject.user == "YOUR-USER") {
return polkit.Result.YES;
}
});
RULE
```
**Without it the server still runs** — `tsunagi status` prints where it is
listening and the exact `dig` line — so the automatic part is missing, not
the feature. The refusal is said once rather than on every pass, and retried
slowly, because nothing but a person will change it.
The server is authoritative for its zone and nothing else. No recursion, no
forwarding, no cache: pointing a resolver at it can never make it a route to
the outside.
## Privileges
On Linux the agent **manages its own overlay interface**. It creates the TUN
@@ -184,10 +343,14 @@ sayable — it remembers who belongs while they are gone, so the report can say
dial-failure counter to imply it. Counters are history and are never graded:
a peer that left and came back should not leave the report looking broken.
`id` is the other half: it shows what this device is — its signing key, the
name it answers to, and the secret of every network it has joined — and
changes those. Every item takes the same shape, so there is nothing to
remember: name it to see it, name it with a value to change it.
The other two commands split along the line the system itself draws. `id` is
this **device**: the key it signs with and the name it answers to. `network`
is what it **belongs to**: which networks, their secrets, joining and
leaving. 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.
Every item takes the same shape, so there is nothing to remember: name it to
see it, name it with a value to change it.
```
tsunagi id everything about this device
@@ -195,12 +358,66 @@ tsunagi id hostname the name it answers to
tsunagi id hostname mango change it
tsunagi id key the key it signs with
tsunagi id key rotate replace that key
tsunagi id secret the secret of each joined network
tsunagi id secret generate a fresh secret for a network that does not exist yet
tsunagi network the networks this device belongs to
tsunagi network join -n lab -s tsn1… join one; adds it to a running agent
tsunagi network join -n lab resume one this device has, or make it on the spot
tsunagi network stop lab stop serving it, keeping everything
tsunagi network start lab serve it again, from where it left off
tsunagi network leave lab give up the address and name, then forget it
tsunagi network secret the secret of each joined network
tsunagi network secret lab just that one, for copying
tsunagi network secret generate a fresh secret for a network that does not exist yet
tsunagi dns whether the local resolver is serving, and what
tsunagi dns on start it, now and after every restart
tsunagi dns off stop it, now and after every restart
```
Secrets appear in `id`, which is where you go to ask for one, and never in
`status`, in a log, in a `Debug` rendering or in anything sent to a peer.
**A network without a secret makes one.** `tsunagi join --network lab`
resolves a bare name in the obvious way: if this device is already in
exactly one network called `lab`, that one — so the name alone resumes what
you have; if it is in none, a fresh random secret, printed in full with the
line to send the other machine:
```
joined `lab` (k2on43wadbi5x267vp6z3ogkm7nbjedfdoxyauxhtxhwqrprylba)
secret tsn1u7c…
Run this on the other machine:
tsunagi join --network lab --secret tsn1u7c…
Its agent has to be running. If it is not:
tsunagi up --peer 91e83a6e2b7a…
```
Two networks of one name is a thing that happens — a mistyped secret makes
one — so joining says whether the network was already here, and warns when
another configured network answers to the same name. A name is a label and
the id is the identity.
That is the ad-hoc case: one person makes a network and sends the command
round. The secret is printed *only* when the agent invented it — there is
nowhere else to read it from — and never when it was supplied, because then
it is already yours. Two networks of one name and no secret is the one case
with no answer, and it says so instead of choosing.
A secret is printed by `network secret` and nowhere else — not by `id`, not
by `status`, not in a log, a `Debug` rendering or anything sent to a peer.
Asking for it is deliberate, because these reports get pasted into chats.
**`up` takes no network at all** if you would rather decide later: it
brings up the agent and whatever it is already configured for, and waits.
That is the shape of a daemon in one terminal and `tsunagi network join`
in another.
`network join` is also the answer to a question `up` cannot: a state
directory belongs to one live agent, so a second `tsunagi up` cannot add a
network to the one already running. This adds it over the control socket and
it starts at once. With no agent running it is written to the configuration
and starts with the next `up`.
The name is part of the signed state, so changing it revokes the previous
one: there is one record per author, a new version replaces the whole claim,
@@ -247,13 +464,12 @@ its whole life has carrier for its whole life.
### The MTU is 1280
That is the minimum IPv6 requires (RFC 8200), and Linux enforces it by
disabling IPv6 outright on an interface below it — the per-device
`/proc/sys/net/ipv6` entries vanish and adding an address fails with
`Invalid argument`. A smaller MTU cannot work at all, so the agent refuses one
rather than letting it fail later. See
[docs/wireguard.md](docs/wireguard.md#mtu) for the ceiling that pushes back
from the other side.
The default interface MTU stays at 1280 on direct paths, relays and paths
inside another VPN. Large encrypted packets are split into smaller QUIC
datagrams and reassembled before reaching WireGuard. SSH and full-size TCP
segments therefore do not require a manual MTU override or MSS adjustment.
The original IP packet, including its DF bit, is preserved. See
[docs/wireguard.md](docs/wireguard.md#mtu) for limits and compatibility.
### Summary
@@ -262,14 +478,36 @@ from the other side.
| `setcap cap_net_admin+p` | ordinary user, one capability | recommended: nothing to prepare, nothing left behind. Lost on every rebuild or copy of the binary. |
| systemd service | `User=`, `AmbientCapabilities=CAP_NET_ADMIN` | the same, for an installed service |
| `sudo tsunagi up` | root | everything works, nothing is isolated |
| macOS root LaunchDaemon | root | the native macOS deployment; see below |
| `--no-tun` | ordinary user, no capabilities | tunnels run and handshake, traffic never reaches the OS |
**Not implemented yet.** macOS and Windows have no provisioner: both need
real platform work — `utun` and `SystemConfiguration` on one, the IP Helper
API and a Wintun adapter on the other. There the agent says so and `--no-tun`
is the way to run it; the control plane and the tunnels are unaffected. The
decision logic that says *what* to change is shared and tested on every
platform, so only the execution is left to write.
On Windows, put `wintun.dll` beside the executable and start `tsunagi up`
from PowerShell or Command Prompt opened with **Run as administrator**.
Run commands controlling that agent (`join`, `status`, `dns`, `network`)
as the same Windows user with the same elevation. If Windows denies access
to the agent's control pipe, the command reports a permission error instead
of claiming the agent is absent or trying to edit its locked state.
Creating a TUN without sufficient privileges also explains how to restart
the agent with the required permissions.
On macOS the agent creates a `utun` interface, configures it with `ifconfig`
and `route`, and points the system resolver at the overlay by writing
`/etc/resolver/<network>` files (which carry a port, so the DNS server needs no
privileged socket). All of this needs **root**: macOS has no per-capability
grant like Linux, and a `utun` plus `/etc/resolver` are root-only. The kernel
assigns the interface its `utunN` name, so the name derived from the network id
is only a request and the real one is adopted once the interface is up.
Run it as root — `sudo tsunagi up` for a quick start, or install it as a root
**LaunchDaemon** for the native always-on deployment. As a daemon the agent runs
unprivileged commands (`join`, `status`, `dns`, `network`) over its control
socket like any other platform; the socket lives at `/var/run/tsunagi/agent.sock`
and the stores at `/var/db/tsunagi`, both fixed system paths so they do not
depend on whose shell started `sudo`. A ready-to-edit plist and install notes
are in [dist/macos/](dist/macos/). The broadcast route is installed; the
inbound-UDP firewall allowance (a `pf` anchor) is not yet, so `status` reports
that half as incomplete and names what to allow by hand. The decision logic that
says *what* to change is shared and tested on every platform.
## Checks
@@ -287,7 +525,6 @@ tests:
```bash
cargo run --example two_agents # control plane only
cargo run --example wireguard_mesh # a WireGuard overlay carrying a real packet
```
Both run with no privileges and change nothing on the host.
@@ -340,31 +577,85 @@ async fn main() -> Result<()> {
reaches the internet by accident. Opt into `DirectOnly` or `N0Defaults`
explicitly.
### Through somebody in the middle
Tsunagi routes the overlay in userspace. A direct data link always wins;
otherwise the shortest available path can pass through several members.
Each authenticated member advertises only its own live, protocol-specific
transport links. The control plane collects these into a graph and replaces
the routing table when connectivity changes; announcements expire after
90 seconds without refresh.
Transit goes transport → router → transport, without entering WireGuard or
the intermediate host's TUN. The payload stays encrypted between the original
endpoints. TUN connects only the local OS to Tsunagi; the kernel needs only
the overlay route through that interface, with no IP forwarding configuration.
A 16-hop limit bounds loops while topology updates converge. Equal shortest
paths use a stable flow hash derived before encryption, so packets of a TCP
or UDP flow keep their path while topology is unchanged. Physical link changes
do not replace end-to-end WireGuard tunnels or change the 1280-byte default MTU.
`status` reports the hop count and first next hop for relayed paths.
The hot path reads an immutable routing snapshot and sends directly to a cached
transport handle. It takes no routing mutex, walks no graph and does not parse
the encrypted payload. See [routing.md](docs/routing.md) for the architecture,
limits and reproducible forwarding microbenchmark.
This wire format requires all members to upgrade together (control ALPN 3,
data ALPN 4); saved identities, network names, secrets and addresses survive.
### LAN game discovery
IPv4 UDP broadcast relay is enabled by default for each network. The IP router
sends one encrypted copy to each participating peer, including through multihop
paths. It supports `255.255.255.255` and the overlay subnet's broadcast address;
received broadcasts never trigger another fanout.
```sh
tsunagi join -n games --no-broadcast
tsunagi join -n games --broadcast
tsunagi network broadcast games off
tsunagi network broadcast games on
```
The choice persists across restarts and a plain `join`. It can be changed while
the agent runs; `status` shows it for each network. While broadcast is on, the
agent routes `255.255.255.255` through the Tsunagi interface and allows inbound
UDP from the overlay range on it (Linux and Windows, needs the same privilege as
the interface), and removes both when it is turned off or the agent stops. Games
bound to a physical address still bypass it. Physical LAN capture/subnet sharing
is not implemented.
See [broadcast.md](docs/broadcast.md) for domain isolation and future LAN gateways.
## How peers find each other
Two different lookups are involved, and only one of them is this project's:
**1. Resolving one endpoint's address — iroh's, and it works today.**
With `--transport relay` or `--transport direct`, iroh publishes a signed
With `--reach relay` or `--reach direct`, iroh publishes a signed
record of this endpoint's addresses, keyed by its endpoint id, to the public
service run by Number 0 — "n0", the company behind iroh — at `dns.iroh.link`,
over pkarr and DNS, and resolves other endpoints the same way. That is why `--peer <endpoint-id>` works with no address attached:
iroh looks it up. None of that code is ours.
**2. Finding who is in a network — ours, and it is still manual.**
`NetworkDiscovery` maps a secret-derived `DiscoveryKey` to a set of *candidate*
members. Two backends exist: `StaticBootstrap` (what `--peer` feeds) and an
in-memory one for tests. The planned Mainline DHT backend, which would let
members find each other from the network secret alone, is **not implemented**.
So today you bootstrap by passing one peer's id; after that the mesh is
whatever those agents reach.
**2. Finding who is in a network — Mainline DHT by default.**
`NetworkDiscovery` supplies unverified candidates. The Mainline backend uses
a signing key derived from the network name and secret to publish signed
BEP44 records containing each writer's endpoint ID and addresses. Its 16
slots provide starting candidates, not a complete membership list or a limit
on network size. After the first authenticated connection, introductions and
signed state reveal the other members. `StaticBootstrap` (`--peer`) remains
available, and tests use either a local Mainline Testnet or in-memory discovery.
What this means in practice:
- With `relay` or `direct`, **your endpoint id and IP addresses are published
to a public third-party service** (Number 0's, unless you change it). They are not secret, and the network secret is
never published, but an observer of that service learns that your endpoint
exists and where it is. `--transport local` publishes nothing.
exists and where it is. `--reach local` publishes nothing.
- With DHT enabled, endpoint records are also published to the public Mainline
DHT. They are signed, not encrypted; membership still requires the secret.
- A relay, when one is needed, sees the volume and timing of your traffic — not
its contents. The default relays are Number 0's, in the US, EU and
Asia-Pacific.
@@ -380,15 +671,72 @@ paths; tests always use temporary directories.
| `state.sqlite` | device identity, network configuration, hostname | clear error, never reset |
| `cache.sqlite` | address hints and other recoverable data | discarded and recreated |
The WireGuard plugin keeps its own keys in its own `wireguard.sqlite`, wherever
its configuration points, because plugin keys are neither the iroh identity nor
the network secret.
The `wg-quic` protocol keeps its own keys in its own store under `wg-quic/`,
because a protocol's keys are neither the iroh identity nor the network
secret.
The command line agent puts everything under the platform's per-user
directories by default; `--state-dir` and `--cache-dir` override them.
One state directory belongs to one live agent, enforced with a real OS file
lock rather than an existence check.
lock rather than an existence check. That directory *is* the identity: one
agent, one device key, one interface, and as many networks as you like on it
— `tsunagi network join` adds them to the agent that is already running,
which is why a second `tsunagi up` on the same directory is refused rather
than made to work. A second agent on the same host is a second identity, and
needs everything of its own: its own state and cache directories, its own
interface name and its own overlay range. The library owns no globals, so
several of them run side by side in one process as readily as in one host.
### Leaving, and starting over
Membership outlives a session, so it also has to be possible to end it.
```bash
tsunagi network # what this device belongs to
tsunagi network leave lab # give up the address and the name, then forget it
tsunagi wipe --yes # remove everything and be a stranger again
```
**Stopping is not leaving.** `stop` closes this network's sessions, takes
its address off the interface and keeps it from starting again, and that is
all: the configuration, the secret, the signed state and the protocol key
stay exactly as they are, nothing is announced, and to the others this
device is simply away — an ordinary condition they already handle, with its
address and name still reserved for it. `start` picks it up where it left
off. A network named on the `up` command line is started by that command
whatever its stored state, and the start-up banner says so rather than
letting a `stop` quietly come back.
`leave` is the other one, and it removes, locally: the configuration and
the secret, this network's signed records, its cached address hints and the
protocol key it used there. What it keeps is this author's version counter
for that network — a rejoin has to continue above the release, or every
replica would treat the new claim as stale. Everything about *other*
networks, and the device identity itself, is untouched.
`leave` publishes a signed `Release` **first**, while the agent is running and
its sessions are up, so the address and the name are freed for the others
instead of staying reserved to a member that has gone. They pass the tombstone
on, so a member that was away hears it from them. With no agent running,
nothing can sign or send it: the command says so and refuses, and `--offline`
drops the network locally while leaving the others holding the old claim. The
protocol key for that network goes too — rejoining is joining, not resuming.
`leave`, `stop`, `start`, `secret` and `broadcast` accept an exact stored network
name, its full id, or a unique id prefix (including a copied report ellipsis).
Names are case-sensitive. If several networks share a name, or a name matches
another network's id prefix, the command lists matching names and full ids and
changes nothing. Use a full id to choose explicitly; a full id always takes
precedence over a name. No secret is needed to select a configured network.
`wipe` removes both directories' contents: the device identity, every network,
every signed record and everything a protocol kept beside them. It refuses
while an agent is running, 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 says what it would remove. It is not a goodbye: nobody
is told, because after it there is no key left to sign anything with. Leave the
networks first if the addresses should be freed.
## Documentation
+42
View File
@@ -0,0 +1,42 @@
[package]
name = "tsunagi-cli"
version = "0.1.0"
description = "The tsunagi command line agent."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[[bin]]
name = "tsunagi"
path = "src/main.rs"
# The binary and the library share a name, which `cargo doc` cannot map to
# two directories. The command line is documented by `--help`, not rustdoc,
# so the library keeps the name and this opts out.
doc = false
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0", features = ["tun-device", "dns-publish"] }
tsunagi-wireguard = { path = "../tsunagi-wireguard", version = "0.1.0" }
tsunagi-wg-quic = { path = "../tsunagi-wg-quic", version = "0.1.0" }
tsunagi-tcp-tls = { path = "../tsunagi-tcp-tls", version = "0.1.0" }
iroh.workspace = true
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "signal"] }
tracing.workspace = true
tracing-subscriber.workspace = true
clap = { version = "4.5", features = ["derive", "env"] }
# Already in the tree through clap. `anstream` strips the escapes when stdout
# is not a terminal and turns on virtual terminal processing on Windows, so
# colour is never written where it would show up as rubbish.
anstream = "1.0"
anstyle = "1.0"
# Local interface addresses, for the diagnostics in `status`.
netwatch = "0.19.3"
[dev-dependencies]
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
tempfile.workspace = true
simple-dns = "0.12"
[lints]
workspace = true
File diff suppressed because it is too large. Load diff
+347
View File
@@ -0,0 +1,347 @@
//! The DNS service as the binary actually runs it.
//!
//! The zone and the server have their own tests. What this covers is the
//! wiring between them and the agent, which is where the interesting
//! mistakes live: choosing an address to listen on, and deciding when to
//! rebuild the listener. Both were wrong once, and neither was reachable
//! from a unit test, so this runs the real binary.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::{SocketAddr, UdpSocket};
use std::time::{Duration, Instant};
use simple_dns::{Name, Packet, QCLASS, RCODE, TYPE, rdata::RData};
use tempfile::TempDir;
// A port per test, high enough to need no privileges and fixed so the query
// knows where to look. Distinct because these tests run in parallel and each
// starts its own agent.
const PORT_BINDS: u16 = 15361;
const PORT_REBIND: u16 = 15362;
const PORT_REFUSE: u16 = 15363;
const PORT_TWO_ZONES: u16 = 15364;
const PORT_SWITCH: u16 = 15365;
const PORT_FLAGS: u16 = 15366;
/// Asks, and returns the raw reply. Raw because a parsed packet borrows
/// from the bytes it came out of.
fn query(server: SocketAddr, name: &str, qtype: TYPE) -> Option<Vec<u8>> {
let mut packet = Packet::new_query(0x2468);
packet.questions.push(simple_dns::Question::new(
Name::new(name).unwrap(),
qtype.into(),
QCLASS::CLASS(simple_dns::CLASS::IN),
false,
));
let bytes = packet.build_bytes_vec().unwrap();
let socket = UdpSocket::bind("127.0.0.1:0").ok()?;
socket
.set_read_timeout(Some(Duration::from_millis(500)))
.ok()?;
socket.send_to(&bytes, server).ok()?;
let mut buffer = vec![0u8; 4096];
let read = socket.recv(&mut buffer).ok()?;
buffer.truncate(read);
Packet::parse(&buffer).ok()?;
Some(buffer)
}
/// Whether a reply carries at least one answer record.
fn has_answer(reply: &[u8]) -> bool {
Packet::parse(reply).is_ok_and(|packet| !packet.answers.is_empty())
}
/// Blocks until the server answers, or gives up.
fn wait_for_answer(server: SocketAddr, name: &str) -> Vec<u8> {
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some(reply) = query(server, name, TYPE::A)
&& has_answer(&reply)
{
return reply;
}
assert!(Instant::now() < deadline, "the dns server never answered");
std::thread::sleep(Duration::from_millis(200));
}
}
/// The agent, running as a real process with its DNS service on.
struct Running {
child: std::process::Child,
dir: TempDir,
}
impl Running {
fn wait_ready(&mut self) {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let socket = tsunagi::ipc::control_socket_path(&self.dir.path().join("state"));
let deadline = Instant::now() + Duration::from_secs(20);
while !runtime.block_on(tsunagi::ipc::is_serving(&socket)) {
assert!(
self.child.try_wait().unwrap().is_none(),
"the agent exited before becoming ready"
);
assert!(
Instant::now() < deadline,
"the control socket never became ready"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn restart(&mut self, dns_flag: Option<&str>) {
self.child.kill().unwrap();
self.child.wait().unwrap();
self.child = spawn_child(&self.dir, None, dns_flag);
self.wait_ready();
}
/// Runs another `tsunagi` command against this agent's directory.
fn run(&self, args: &[&str]) -> std::process::Output {
std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.args(args)
.arg("--state-dir")
.arg(self.dir.path().join("state"))
.arg("--cache-dir")
.arg(self.dir.path().join("cache"))
.output()
.expect("the agent binary runs")
}
}
impl Drop for Running {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// Starts an agent in one network, whose name is therefore the zone.
///
/// There is no separate zone setting: an agent serves a zone per network,
/// named after it, so the network name is the zone name.
fn start(zone: &str, port: u16) -> Running {
let agent = start_without_dns_at(zone, Some(port));
let joined = agent.run(&[
"join",
"--network",
zone,
"--secret",
"a-secret-for-the-dns-test",
]);
assert!(
joined.status.success(),
"{}",
String::from_utf8_lossy(&joined.stderr)
);
agent
}
/// Starts an agent in one network with the resolver off, to switch on later.
fn start_without_dns(network: &str) -> Running {
let agent = start_without_dns_at(network, None);
let joined = agent.run(&[
"join",
"--network",
network,
"--secret",
"a-secret-for-the-dns-test",
]);
assert!(
joined.status.success(),
"{}",
String::from_utf8_lossy(&joined.stderr)
);
agent
}
/// The agent alone: `up` runs it, and what it belongs to is decided after.
fn start_without_dns_at(_network: &str, dns_port: Option<u16>) -> Running {
let dir = TempDir::new().unwrap();
let child = spawn_child(&dir, dns_port, dns_port.is_none().then_some("--no-dns"));
let mut agent = Running { child, dir };
agent.wait_ready();
agent
}
fn spawn_child(
dir: &TempDir,
dns_port: Option<u16>,
dns_flag: Option<&str>,
) -> std::process::Child {
let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"));
command
.arg("up")
.arg("--state-dir")
.arg(dir.path().join("state"))
.arg("--cache-dir")
.arg(dir.path().join("cache"))
// No real interface and no internet: this is about the wiring.
.args(["--reach", "local", "--no-tun"]);
if let Some(port) = dns_port {
command.args(["--dns-port", &port.to_string()]);
}
if let Some(flag) = dns_flag {
command.arg(flag);
}
command
.args(["--log", "error", "--status-interval", "0"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("the agent binary starts")
}
#[test]
fn the_resolver_is_on_by_default_even_without_an_overlay_interface() {
// The promise is that the port is served whatever else fails: with
// `--no-tun` there is no interface to attach a resolver setting to,
// and the zone is answered on loopback all the same. Getting this
// wrong left the feature silently dead.
let _agent = start("lab.internal", PORT_BINDS);
let server: SocketAddr = format!("127.0.0.1:{PORT_BINDS}").parse().unwrap();
let reply = wait_for_answer(server, &format!("{}.lab.internal", hostname()));
let answer = Packet::parse(&reply).unwrap();
assert_eq!(answer.rcode(), RCODE::NoError);
match &answer.answers[0].rdata {
RData::A(_) => {}
other => panic!("expected an A record, got {other:?}"),
}
}
#[test]
fn the_listener_is_not_rebuilt_on_every_pass() {
// The listener is bound once and kept, and the zones are swapped
// underneath it as networks and members come and go. Rebuilding it on
// the way past — which an earlier version did on every tick — shut the
// port for a moment each time.
let _agent = start("rebind.internal", PORT_REBIND);
let server: SocketAddr = format!("127.0.0.1:{PORT_REBIND}").parse().unwrap();
let name = format!("{}.rebind.internal", hostname());
wait_for_answer(server, &name);
// Long enough to cross several of the supervisor's passes.
for round in 0..6 {
std::thread::sleep(Duration::from_millis(900));
assert!(
query(server, &name, TYPE::A).is_some_and(|reply| has_answer(&reply)),
"the server stopped answering on round {round}"
);
}
}
#[test]
fn a_name_outside_the_zone_is_refused_and_never_forwarded() {
let _agent = start("refuse.internal", PORT_REFUSE);
let server: SocketAddr = format!("127.0.0.1:{PORT_REFUSE}").parse().unwrap();
wait_for_answer(server, &format!("{}.refuse.internal", hostname()));
let reply = query(server, "example.com", TYPE::A).expect("an answer");
let answer = Packet::parse(&reply).unwrap();
assert_eq!(answer.rcode(), RCODE::Refused);
assert!(answer.answers.is_empty());
}
fn hostname() -> String {
tsunagi::agent::system_hostname().unwrap_or_else(|| "unknown".into())
}
#[test]
fn every_network_gets_a_zone_of_its_own() {
// One agent, one identity, several networks — and a question carries a
// name, not the network it belongs to. Each network is a zone named
// after it, and joining one while the agent runs adds its zone without
// restarting anything.
let agent = start("first.internal", PORT_TWO_ZONES);
let server: SocketAddr = format!("127.0.0.1:{PORT_TWO_ZONES}").parse().unwrap();
let host = hostname();
wait_for_answer(server, &format!("{host}.first.internal"));
let joined = agent.run(&[
"join",
"--network",
"second.internal",
"--secret",
"another-secret-for-the-dns-test",
]);
assert!(
joined.status.success(),
"joining failed: {}",
String::from_utf8_lossy(&joined.stderr)
);
// The second network's zone answers too, and neither leaks into the
// other: a member of one is not a name in the other.
let reply = wait_for_answer(server, &format!("{host}.second.internal"));
assert_eq!(Packet::parse(&reply).unwrap().rcode(), RCODE::NoError);
let first = wait_for_answer(server, &format!("{host}.first.internal"));
assert_eq!(Packet::parse(&first).unwrap().rcode(), RCODE::NoError);
}
#[test]
fn the_resolver_can_be_switched_on_and_off_while_the_agent_runs() {
// An intentional --no-dns can be reversed at runtime without restarting.
let agent = start_without_dns("switch.internal");
let server: SocketAddr = format!("127.0.0.1:{PORT_SWITCH}").parse().unwrap();
let host = hostname();
assert!(
query(server, &format!("{host}.switch.internal"), TYPE::A).is_none(),
"nothing should be answering yet"
);
let on = agent.run(&["dns", "on", "--port", &PORT_SWITCH.to_string()]);
assert!(
on.status.success(),
"dns on failed: {}",
String::from_utf8_lossy(&on.stderr)
);
wait_for_answer(server, &format!("{host}.switch.internal"));
let off = agent.run(&["dns", "off"]);
assert!(off.status.success());
let deadline = Instant::now() + Duration::from_secs(10);
while query(server, &format!("{host}.switch.internal"), TYPE::A).is_some() {
assert!(
Instant::now() < deadline,
"it kept answering after `dns off`"
);
std::thread::sleep(Duration::from_millis(200));
}
}
#[test]
fn explicit_dns_choices_override_the_saved_setting_and_survive_restart() {
let mut agent = start("flags.internal", PORT_FLAGS);
let server = format!("127.0.0.1:{PORT_FLAGS}").parse().unwrap();
let name = format!("{}.flags.internal", hostname());
wait_for_answer(server, &name);
for flag in [Some("--no-dns"), None] {
agent.restart(flag);
let state = agent.run(&["dns"]);
assert!(state.status.success());
assert!(String::from_utf8_lossy(&state.stdout).contains("not serving"));
assert!(query(server, &name, TYPE::A).is_none());
}
agent.restart(Some("--dns"));
wait_for_answer(server, &name);
agent.restart(None);
wait_for_answer(server, &name);
}
#[test]
fn opposing_dns_flags_are_rejected() {
let result = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.args(["up", "--dns", "--no-dns"])
.output()
.unwrap();
assert!(!result.status.success());
assert!(String::from_utf8_lossy(&result.stderr).contains("cannot be used with"));
}
+56
View File
@@ -0,0 +1,56 @@
//! What the command line says about this device, against a running agent.
//!
//! These drive the real binary, which is why they live beside it rather
//! than with the library's own tests: the library cannot depend on a binary
//! built from a crate that depends on it.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tempfile::TempDir;
use tsunagi::Agent;
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
/// Asking who this device is must not need the directory lock.
///
/// The lock belongs to the one agent allowed to *write* the state. `id` only
/// reads, so making it take the lock would mean the question could never be
/// answered while an agent was running — which is exactly when you want to
/// ask it.
#[tokio::test]
async fn identity_can_be_read_while_an_agent_holds_the_directory() {
let dir = TempDir::new().unwrap();
// Hold the directory the way a running agent does.
let agent = Agent::spawn(
AgentConfig::new(StoragePaths::under(dir.path()))
.with_transport(TransportPolicy::LocalOnly)
.with_loopback_bind(),
)
.await
.unwrap();
let expected = agent.endpoint_id().to_string();
let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.arg("id")
// The same layout `StoragePaths::under` gives the agent above.
.arg("--state-dir")
.arg(dir.path().join("state"))
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.output()
.await
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"`id` failed while an agent was running:\n{stderr}"
);
assert!(
stdout.contains(&expected),
"expected {expected} in:\n{stdout}"
);
agent.shutdown().await;
}
+420
View File
@@ -0,0 +1,420 @@
//! Making and joining a network from the command line, against a real agent.
//!
//! The rules here are small and easy to get wrong in a way no unit test
//! notices: what a bare network name means, when a secret is invented, and
//! whether what is printed is enough for the other person to paste.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::time::Duration;
use tempfile::TempDir;
/// An agent running as a real process, with its own directories.
struct Running {
child: std::process::Child,
dir: TempDir,
}
impl Drop for Running {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Running {
fn run(&self, args: &[&str]) -> std::process::Output {
run_in(&self.dir, args)
}
}
fn run_in(dir: &TempDir, args: &[&str]) -> std::process::Output {
std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.args(args)
.arg("--state-dir")
.arg(dir.path().join("state"))
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.output()
.expect("the agent binary runs")
}
fn read_networks(dir: &TempDir) -> Vec<tsunagi::storage::StoredNetwork> {
tsunagi::storage::StateStore::open(dir.path().join("state/state.sqlite"))
.unwrap()
.list_networks()
.unwrap()
}
/// An agent with no network at all, the way a daemon is started before
/// anything has been decided.
fn start_bare(port: u16) -> Running {
let dir = TempDir::new().unwrap();
let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.arg("up")
.arg("--state-dir")
.arg(dir.path().join("state"))
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.args(["--reach", "local", "--no-tun", "--no-dns"])
.arg("--bind")
.arg(format!("127.0.0.1:{port}"))
.args(["--log", "error", "--status-interval", "0"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("the agent binary starts");
std::thread::sleep(Duration::from_secs(2));
Running { child, dir }
}
/// An agent already in one network, the way most of these start.
fn start(network: &str, port: u16) -> Running {
let agent = start_bare(port);
let joined = agent.run(&[
"join",
"--network",
network,
"--secret",
"a-secret-for-the-cli-test",
]);
assert!(
joined.status.success(),
"{}",
String::from_utf8_lossy(&joined.stderr)
);
agent
}
#[test]
fn joining_with_no_secret_makes_one_and_prints_what_to_send() {
// The hurry case: a network with somebody, for as long as it is needed,
// and then gone. Asking for a secret first is a step with no purpose,
// and a secret that is not printed is a network nobody else can join.
let agent = start("resident", 45071);
let joined = agent.run(&["network", "join", "--network", "spontaneous"]);
assert!(
joined.status.success(),
"joining failed: {}",
String::from_utf8_lossy(&joined.stderr)
);
let out = String::from_utf8_lossy(&joined.stdout);
assert!(out.contains("joined `spontaneous`"), "{out}");
// The secret in full, and one line the other side can paste as it is.
let secret = out
.lines()
.find_map(|line| line.trim().strip_prefix("secret "))
.expect("the invented secret is printed");
assert!(secret.starts_with("tsn1"), "{out}");
let command = out
.lines()
.find(|line| line.contains("tsunagi join --network spontaneous"))
.expect("a command to send");
assert!(command.contains(secret), "with the secret in it: {out}");
// And where to find this device, for an agent that is not up yet.
assert!(out.contains("--peer "), "somewhere to find us: {out}");
// Joining the same name again resumes it rather than making another
// network that merely looks the same.
let again = agent.run(&["network", "join", "--network", "spontaneous"]);
let out = String::from_utf8_lossy(&again.stdout);
assert!(again.status.success());
assert!(out.contains("already here"), "{out}");
assert!(!out.contains("secret tsn1"), "no second secret: {out}");
}
#[test]
fn a_bare_name_resumes_the_network_of_that_name_rather_than_inventing_one() {
// `join --network resident` with no secret is the same rule: this device
// has exactly one network of that name, so that is the one meant.
let agent = start("resident", 45072);
let listed = agent.run(&["network"]);
let before = String::from_utf8_lossy(&listed.stdout).to_string();
let id = before
.lines()
.find(|line| line.contains("resident"))
.and_then(|line| line.split_whitespace().nth(1))
.expect("the network is listed")
.to_string();
let joined = agent.run(&["network", "join", "--network", "resident"]);
assert!(joined.status.success());
let out = String::from_utf8_lossy(&joined.stdout);
assert!(out.contains(&id), "the same network, not a new one: {out}");
assert!(out.contains("already here"), "{out}");
}
#[test]
fn a_network_can_be_stopped_and_resumed_without_losing_anything() {
// Leaving gives everything up; stopping is being away. The difference
// is what somebody wants when they will be back.
let agent = start("resident", 45073);
let listed = agent.run(&["network"]);
let out = String::from_utf8_lossy(&listed.stdout).to_string();
let id = out
.lines()
.find(|line| line.contains("resident"))
.and_then(|line| line.split_whitespace().nth(1))
.expect("the network is listed")
.to_string();
let secret_before = agent.run(&["network", "secret", &id[..10]]);
let secret_before = String::from_utf8_lossy(&secret_before.stdout)
.trim()
.to_string();
assert!(secret_before.starts_with("tsn1"));
let stopped = agent.run(&["network", "stop", "resident"]);
assert!(
stopped.status.success(),
"{}",
String::from_utf8_lossy(&stopped.stderr)
);
assert!(
String::from_utf8_lossy(&stopped.stdout).contains("stopped `resident`"),
"{}",
String::from_utf8_lossy(&stopped.stdout)
);
// Still configured, and said to be stopped rather than missing.
let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string();
assert!(listed.contains(&id), "still configured: {listed}");
assert!(listed.contains("stopped"), "{listed}");
// Stopping what is stopped is the state asked for, not an error.
let again = agent.run(&["network", "stop", &id[..10]]);
assert!(again.status.success());
assert!(
String::from_utf8_lossy(&again.stdout).contains("already stopped"),
"{}",
String::from_utf8_lossy(&again.stdout)
);
let started = agent.run(&["network", "start", "resident"]);
assert!(started.status.success());
assert!(
String::from_utf8_lossy(&started.stdout).contains("started `resident`"),
"{}",
String::from_utf8_lossy(&started.stdout)
);
let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string();
assert!(listed.contains("running"), "{listed}");
// And nothing was given up on the way: the same network, same secret.
let secret_after = agent.run(&["network", "secret", "resident"]);
assert_eq!(
String::from_utf8_lossy(&secret_after.stdout).trim(),
secret_before,
"the secret is kept, so this is the same network"
);
}
#[test]
fn an_agent_starts_with_no_network_and_takes_one_later() {
// A daemon in one terminal and the deciding done in another: the
// agent is the identity, and which networks it is in is a separate
// question it can answer at any time.
let agent = start_bare(45074);
let listed = agent.run(&["network"]);
assert!(listed.status.success());
assert!(
String::from_utf8_lossy(&listed.stderr).contains("no network has been joined"),
"{}",
String::from_utf8_lossy(&listed.stderr)
);
let joined = agent.run(&["network", "join", "--network", "afterwards"]);
assert!(
joined.status.success(),
"{}",
String::from_utf8_lossy(&joined.stderr)
);
let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string();
assert!(listed.contains("afterwards"), "{listed}");
assert!(listed.contains("running"), "and it is live: {listed}");
}
#[test]
fn up_is_the_agent_and_takes_no_network_at_all() {
// The two commands are separate on purpose: `up` runs the device's
// agent, `join` decides what it is in. A network on `up` would be a
// second way to do the same thing, and the one that cannot be undone
// without a restart.
for argument in ["--network", "--secret"] {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.args(["up", argument, "whatever"])
.arg("--state-dir")
.arg(TempDir::new().unwrap().path().join("state"))
.output()
.expect("the agent binary runs");
assert!(
!out.status.success(),
"`up {argument}` should not be a thing"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("unexpected argument"),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn broadcast_choice_is_per_network_live_persistent_and_preserved_by_rejoin() {
let agent = start_bare(0);
for args in [
vec!["join", "-n", "broadcast-default"],
vec!["join", "-n", "broadcast-off", "--no-broadcast"],
] {
let result = agent.run(&args);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
}
let read = || {
tsunagi::storage::StateStore::open(agent.dir.path().join("state/state.sqlite"))
.unwrap()
.list_networks()
.unwrap()
};
let networks = read();
let enabled = networks
.iter()
.find(|n| n.name.as_str() == "broadcast-default")
.unwrap();
let disabled = networks
.iter()
.find(|n| n.name.as_str() == "broadcast-off")
.unwrap();
assert!(enabled.broadcast);
assert!(!disabled.broadcast);
assert!(agent.run(&["join", "-n", "broadcast-off"]).status.success());
assert!(
!read()
.iter()
.find(|n| n.name.as_str() == "broadcast-off")
.unwrap()
.broadcast
);
let id = disabled.network_id.to_string();
let change = agent.run(&["network", "broadcast", "broadcast-off", "on"]);
assert!(
change.status.success(),
"{}",
String::from_utf8_lossy(&change.stderr)
);
assert!(read().iter().all(|n| n.broadcast));
let change = agent.run(&["join", "-n", "broadcast-off", "--no-broadcast"]);
assert!(
change.status.success(),
"{}",
String::from_utf8_lossy(&change.stderr)
);
let status = agent.run(&["network", "broadcast", &id]);
assert!(String::from_utf8_lossy(&status.stdout).contains("broadcast off"));
assert!(
!agent
.run(&["join", "-n", "conflict", "--broadcast", "--no-broadcast"])
.status
.success()
);
}
#[test]
fn leaving_by_name_removes_only_that_network_from_a_running_agent() {
let agent = start("resident", 0);
assert!(agent.run(&["join", "-n", "games"]).status.success());
let resident = read_networks(&agent.dir)
.into_iter()
.find(|network| network.name.as_str() == "resident")
.unwrap();
let left = agent.run(&["network", "leave", "games"]);
assert!(
left.status.success(),
"{}",
String::from_utf8_lossy(&left.stderr)
);
assert!(String::from_utf8_lossy(&left.stdout).contains("left `games`"));
let remaining = read_networks(&agent.dir);
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].network_id, resident.network_id);
assert_eq!(
remaining[0].secret.encode().as_str(),
resident.secret.encode().as_str()
);
let status = agent.run(&["network"]);
assert!(status.status.success());
let out = String::from_utf8_lossy(&status.stdout);
assert!(out.contains("resident") && out.contains("running"), "{out}");
assert!(!out.contains("games"), "{out}");
}
#[test]
fn leaving_by_name_offline_keeps_the_explicit_offline_requirement() {
let dir = TempDir::new().unwrap();
for name in ["resident", "games"] {
assert!(run_in(&dir, &["join", "-n", name]).status.success());
}
let refused = run_in(&dir, &["network", "leave", "games"]);
assert!(!refused.status.success());
assert!(String::from_utf8_lossy(&refused.stderr).contains("--offline"));
assert_eq!(read_networks(&dir).len(), 2);
let left = run_in(&dir, &["network", "leave", "games", "--offline"]);
assert!(
left.status.success(),
"{}",
String::from_utf8_lossy(&left.stderr)
);
let remaining = read_networks(&dir);
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].name.as_str(), "resident");
}
#[test]
fn ambiguous_and_unknown_names_leave_every_network_untouched() {
let agent = start_bare(0);
for secret in [
"first-secret-for-the-cli-test",
"second-secret-for-the-cli-test",
] {
assert!(
agent
.run(&["join", "-n", "games", "-s", secret])
.status
.success()
);
}
let before = read_networks(&agent.dir);
assert_eq!(before.len(), 2);
for name in ["games", "missing-network"] {
let result = agent.run(&["network", "leave", name]);
assert!(!result.status.success());
let err = String::from_utf8_lossy(&result.stderr);
if name == "games" {
assert!(err.contains("matches 2 networks"), "{err}");
for network in &before {
assert!(err.contains(&network.network_id.to_string()), "{err}");
assert!(!err.contains(network.secret.encode().as_str()), "{err}");
}
} else {
assert!(err.contains("no configured network"), "{err}");
}
assert_eq!(read_networks(&agent.dir).len(), 2);
}
// The suggested full id still selects exactly one of the same-name networks.
let left = agent.run(&["network", "leave", &before[0].network_id.to_string()]);
assert!(
left.status.success(),
"{}",
String::from_utf8_lossy(&left.stderr)
);
let remaining = read_networks(&agent.dir);
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].network_id, before[1].network_id);
}
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "tsunagi-tcp-tls"
version = "0.1.0"
description = "TCP TLS 443 transport and E2EE tunnel protocol for tsunagi mesh networks."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0" }
iroh.workspace = true
tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macros"] }
rustls = "0.23"
tokio-rustls = "0.26"
rcgen = { version = "0.14", default-features = false, features = ["ring"] }
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
chacha20poly1305 = "0.10"
sha2 = "0.11"
hkdf = "0.13"
rand = "0.10"
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
bytes.workspace = true
serde.workspace = true
postcard.workspace = true
tracing.workspace = true
thiserror.workspace = true
hex.workspace = true
netdev = "0.46"
[target.'cfg(target_os = "linux")'.dependencies]
caps = "0.5"
[dev-dependencies]
tsunagi = { path = "../tsunagi", features = ["testing"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
[lints]
workspace = true
+147
View File
@@ -0,0 +1,147 @@
//! TLS certificate generation and verification.
//!
//! Generates a self-signed TLS certificate derived from the device's persistent
//! identity, and verifies remote certificates against the expected peer's
//! [`iroh::EndpointId`].
use std::sync::Arc;
use iroh::EndpointId;
use rustls::client::danger::{ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, Error as RustlsError, ServerConfig};
/// Verifies that the server certificate's public key matches the expected peer.
#[derive(Debug)]
pub struct PinnedEndpointVerifier {
expected: EndpointId,
}
impl PinnedEndpointVerifier {
/// Creates a verifier expecting `peer`.
pub fn new(peer: EndpointId) -> Self {
Self { expected: peer }
}
}
impl ServerCertVerifier for PinnedEndpointVerifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, RustlsError> {
// Parse the certificate's SPKI / public key
let cert_bytes = end_entity.as_ref();
let expected_bytes = self.expected.as_bytes();
// Check if the expected 32-byte Ed25519 public key is in the certificate
if cert_bytes
.windows(32)
.any(|window| window == expected_bytes)
{
Ok(ServerCertVerified::assertion())
} else {
Err(RustlsError::InvalidCertificate(
rustls::CertificateError::NotValidForName,
))
}
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, RustlsError> {
// We require TLS 1.3
Err(RustlsError::PeerIncompatible(
rustls::PeerIncompatible::NoSignatureSchemesInCommon,
))
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, RustlsError> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![rustls::SignatureScheme::ED25519]
}
}
/// Default SNI domain used to disguise TLS connections.
pub const DEFAULT_SNI: &str = "cloudflare.com";
/// Generates a self-signed certificate and private key for TLS 1.3 using
/// a given 32-byte Ed25519 secret key and specified SNI domain.
pub fn generate_self_signed_cert(
secret_key_bytes: &[u8; 32],
sni: &str,
) -> Result<
(
CertificateDer<'static>,
rustls::pki_types::PrivateKeyDer<'static>,
),
Box<dyn std::error::Error + Send + Sync>,
> {
// PKCS#8 prefix for v1 Ed25519 private keys:
// 30 2e 02 01 00 30 05 06 03 2b 65 70 04 22 04 20 <32 bytes>
let mut pkcs8 = Vec::with_capacity(48);
pkcs8.extend_from_slice(&[
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04,
0x20,
]);
pkcs8.extend_from_slice(secret_key_bytes);
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(pkcs8);
let key_pair = rcgen::KeyPair::from_pkcs8_der_and_sign_algo(&key_der, &rcgen::PKCS_ED25519)?;
let mut params = rcgen::CertificateParams::new(vec![sni.to_string()])?;
params
.distinguished_name
.push(rcgen::DnType::CommonName, sni);
let cert = params.self_signed(&key_pair)?;
let cert_der = CertificateDer::from(cert.der().to_vec());
let priv_key = rustls::pki_types::PrivateKeyDer::Pkcs8(key_der);
Ok((cert_der, priv_key))
}
/// Creates a `rustls::ServerConfig` for the node.
pub fn make_server_config(
cert: CertificateDer<'static>,
key: rustls::pki_types::PrivateKeyDer<'static>,
) -> Result<Arc<ServerConfig>, rustls::Error> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut config = ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()?
.with_no_client_auth()
.with_single_cert(vec![cert], key)?;
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(Arc::new(config))
}
/// Creates a `rustls::ClientConfig` expecting a specific peer's `EndpointId`.
pub fn make_client_config(expected_peer: EndpointId) -> Result<Arc<ClientConfig>, rustls::Error> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let verifier = Arc::new(PinnedEndpointVerifier::new(expected_peer));
let mut config = ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()?
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_no_client_auth();
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(Arc::new(config))
}
+104
View File
@@ -0,0 +1,104 @@
//! End-to-end packet encryption for `tcp-tls`.
//!
//! Uses X25519 Diffie-Hellman key exchange and ChaCha20-Poly1305 AEAD.
//! This ensures that even when traffic passes through intermediate relay nodes,
//! the intermediate nodes cannot inspect or tamper with the payload.
use std::sync::atomic::{AtomicU64, Ordering};
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
use hkdf::Hkdf;
use iroh::EndpointId;
use sha2::Sha256;
use tsunagi::dataplane::PluginError;
use x25519_dalek::{PublicKey, StaticSecret};
/// Length of the X25519 public key.
pub const KEY_LEN: usize = 32;
/// Domain separator for session key derivation.
const HKDF_SALT: &[u8] = b"tsunagi/e2ee/noise/v1";
/// A session cipher for communicating with one peer.
pub struct PeerCipher {
tx_cipher: ChaCha20Poly1305,
rx_cipher: ChaCha20Poly1305,
tx_nonce: AtomicU64,
}
impl PeerCipher {
/// Establishes a new cipher between `local_id` and `remote_id` given
/// our local secret and their public key.
pub fn new(
local_id: EndpointId,
remote_id: EndpointId,
local_secret: &StaticSecret,
remote_public: &PublicKey,
) -> Self {
let shared = local_secret.diffie_hellman(remote_public);
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), shared.as_bytes());
let mut key_a_to_b = [0u8; 32];
let mut key_b_to_a = [0u8; 32];
let _ = hk.expand(b"tsunagi-key-a-to-b", &mut key_a_to_b);
let _ = hk.expand(b"tsunagi-key-b-to-a", &mut key_b_to_a);
// Deterministic role: smaller EndpointId is party A.
let is_party_a = local_id.as_bytes() < remote_id.as_bytes();
let (tx_key, rx_key) = if is_party_a {
(key_a_to_b, key_b_to_a)
} else {
(key_b_to_a, key_a_to_b)
};
let tx_cipher = ChaCha20Poly1305::new(&tx_key.into());
let rx_cipher = ChaCha20Poly1305::new(&rx_key.into());
Self {
tx_cipher,
rx_cipher,
tx_nonce: AtomicU64::new(0),
}
}
/// Encrypts an outbound packet. Prefixes the ciphertext with the 8-byte nonce.
pub fn encrypt(&self, packet: &[u8]) -> Result<bytes::Bytes, PluginError> {
let counter = self.tx_nonce.fetch_add(1, Ordering::Relaxed);
let mut nonce_bytes = [0u8; 12];
nonce_bytes[4..12].copy_from_slice(&counter.to_be_bytes());
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = self
.tx_cipher
.encrypt(nonce, packet)
.map_err(|err| PluginError::Other(format!("encryption failed: {err}")))?;
let mut out = Vec::with_capacity(8 + ciphertext.len());
out.extend_from_slice(&counter.to_be_bytes());
out.extend_from_slice(&ciphertext);
Ok(bytes::Bytes::from(out))
}
/// Decrypts an inbound packet prefixed with the 8-byte nonce.
pub fn decrypt(&self, payload: &[u8]) -> Result<bytes::Bytes, PluginError> {
if payload.len() < 8 + 16 {
return Err(PluginError::Other("payload too short".into()));
}
let counter_bytes: [u8; 8] = match payload[..8].try_into() {
Ok(b) => b,
Err(_) => return Err(PluginError::Other("invalid nonce".into())),
};
let mut nonce_bytes = [0u8; 12];
nonce_bytes[4..12].copy_from_slice(&counter_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let plaintext = self
.rx_cipher
.decrypt(nonce, &payload[8..])
.map_err(|err| PluginError::Other(format!("decryption failed: {err}")))?;
Ok(bytes::Bytes::from(plaintext))
}
}
+11
View File
@@ -0,0 +1,11 @@
//! The `tcp-tls` protocol: TCP with TLS 1.3 on port 443 with Noise E2EE.
pub mod cert;
pub mod crypto;
pub mod plugin;
pub mod transport;
pub use plugin::{
TCP_TLS_PROTOCOL, TCP_TLS_VERSION, TcpTlsCodec, TcpTlsConfig, TcpTlsPlugin, local_ip_candidates,
};
pub use transport::TcpTlsTransport;
+530
View File
@@ -0,0 +1,530 @@
//! The `tcp-tls` IP plugin implementation.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use bytes::Bytes;
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use x25519_dalek::{PublicKey, StaticSecret};
use tsunagi::dataplane::tunnel::{GenericTunnelPlugin, TunnelCodec};
use tsunagi::dataplane::{PluginError, ProtocolOption};
use tsunagi::identity::NetworkId;
use crate::crypto::PeerCipher;
use crate::transport::TcpTlsTransport;
/// The wire protocol identifier for `tcp-tls`.
pub const TCP_TLS_PROTOCOL: &str = "tcp-tls";
/// Wire version for `tcp-tls`.
pub const TCP_TLS_VERSION: u16 = 1;
/// Configuration for the `tcp-tls` protocol plugin.
#[derive(Debug, Clone, Default)]
pub struct TcpTlsConfig {
/// Requested port to bind.
pub port: Option<u16>,
/// Custom SNI domain to disguise TLS connections.
pub sni: Option<String>,
}
impl TcpTlsConfig {
/// Creates a configuration with default settings.
pub fn new() -> Self {
Self::default()
}
/// Sets the TCP listen port.
pub fn with_port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
/// Sets the SNI domain used to disguise TLS connections.
pub fn with_sni(mut self, sni: impl Into<String>) -> Self {
self.sni = Some(sni.into());
self
}
}
/// Announcement payload exchanged between peers for `tcp-tls`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TcpTlsAnnouncement {
/// X25519 public key for E2EE noise encryption.
pub noise_public: [u8; 32],
/// TCP port the peer is listening on.
pub port: u16,
/// Candidate IP addresses where this peer can be reached on `port`.
#[serde(default)]
pub addrs: Vec<std::net::IpAddr>,
}
/// Discovers local network interface IPs to announce to peers.
pub fn local_ip_candidates() -> Vec<std::net::IpAddr> {
let mut candidates = Vec::new();
for iface in netdev::get_interfaces() {
if !is_candidate_interface(&iface) {
continue;
}
for ip in &iface.ipv4 {
let addr = ip.addr();
if is_candidate_ipv4(addr) {
let priority = ip_priority(&iface, std::net::IpAddr::V4(addr));
candidates.push((priority, std::net::IpAddr::V4(addr)));
}
}
for ip in &iface.ipv6 {
let addr = ip.addr();
if is_candidate_ip(std::net::IpAddr::V6(addr)) {
let priority = ip_priority(&iface, std::net::IpAddr::V6(addr));
candidates.push((priority, std::net::IpAddr::V6(addr)));
}
}
}
// Sort descending by priority so best interface IP is candidate #0
candidates.sort_by_key(|b| std::cmp::Reverse(b.0));
let mut seen = std::collections::HashSet::new();
let mut ips = Vec::new();
for (_, ip) in candidates {
if seen.insert(ip) {
ips.push(ip);
}
}
ips
}
pub(crate) fn is_candidate_interface(iface: &netdev::Interface) -> bool {
if !iface.is_up() || iface.is_loopback() {
return false;
}
let name = iface.name.to_lowercase();
let friendly = iface
.friendly_name
.as_deref()
.map(str::to_lowercase)
.unwrap_or_default();
let desc = iface
.description
.as_deref()
.map(str::to_lowercase)
.unwrap_or_default();
// Skip Tsunagi / Wintun overlay interfaces
if name.starts_with("tsun")
|| name.contains("tsunagi")
|| name.contains("wintun")
|| friendly.starts_with("tsun")
|| friendly.contains("tsunagi")
|| friendly.contains("wintun")
|| desc.contains("wintun")
|| desc.contains("tsunagi")
{
return false;
}
// Skip container, VM, and virtual bridge interfaces
if name.starts_with("docker")
|| name.starts_with("br-")
|| name.starts_with("veth")
|| name.starts_with("virbr")
|| name.starts_with("cni")
|| name.starts_with("flannel")
|| name.starts_with("cali")
|| name.starts_with("kube")
|| name.starts_with("dummy")
|| name.starts_with("lxc")
|| name.starts_with("podman")
|| friendly.contains("vethernet")
|| friendly.contains("vboxnet")
|| friendly.contains("vmnet")
|| desc.contains("virtualbox")
|| desc.contains("hyper-v")
|| desc.contains("vmware")
{
return false;
}
true
}
pub(crate) fn is_candidate_ipv4(addr: std::net::Ipv4Addr) -> bool {
if addr.is_loopback() || addr.is_unspecified() || addr.is_link_local() || addr.is_broadcast() {
return false;
}
if addr.is_documentation() {
return false;
}
true
}
pub(crate) fn is_candidate_ip(addr: std::net::IpAddr) -> bool {
match addr {
std::net::IpAddr::V4(v4) => is_candidate_ipv4(v4),
std::net::IpAddr::V6(v6) => {
if v6.is_loopback() || v6.is_unspecified() {
return false;
}
let segments = v6.segments();
// Filter link-local fe80::/10
if (segments[0] & 0xffc0) == 0xfe80 {
return false;
}
true
}
}
}
pub(crate) fn ip_priority(iface: &netdev::Interface, ip: std::net::IpAddr) -> u32 {
let has_gateway = iface.gateway.is_some();
let base = if has_gateway { 100 } else { 0 };
match ip {
std::net::IpAddr::V4(v4) => {
let octets = v4.octets();
// Tailscale: 100.64.0.0/10
let is_tailscale = octets[0] == 100 && (octets[1] & 0xc0) == 64;
let is_private = v4.is_private();
if !is_private && !is_tailscale {
base + 50
} else if has_gateway && is_private {
base + 40
} else if is_tailscale {
base + 30
} else {
base + 10
}
}
std::net::IpAddr::V6(v6) => {
let segments = v6.segments();
let is_global = (segments[0] & 0xe000) == 0x2000;
if is_global { base + 45 } else { base + 20 }
}
}
}
/// The codec implementing cryptographic transformation and announcements for `tcp-tls`.
pub struct TcpTlsCodec {
local_id: EndpointId,
static_secret: StaticSecret,
public_key: PublicKey,
transport: Arc<TcpTlsTransport>,
ciphers: RwLock<HashMap<(NetworkId, EndpointId), Arc<PeerCipher>>>,
}
impl std::fmt::Debug for TcpTlsCodec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TcpTlsCodec")
.field("local_id", &self.local_id.fmt_short().to_string())
.field("port", &self.transport.bound_port())
.finish()
}
}
impl TcpTlsCodec {
/// Supported options for `tcp-tls`.
pub const OPTIONS: &'static [ProtocolOption] = &[
ProtocolOption {
key: "port",
value: "PORT",
help: "TCP port to listen on for TLS 1.3 connections",
default: Some("443"),
},
ProtocolOption {
key: "sni",
value: "DOMAIN",
help: "Server Name Indication (SNI) domain to disguise TLS connections",
default: Some(crate::cert::DEFAULT_SNI),
},
];
/// Creates a new `TcpTlsCodec`.
pub fn new(local_id: EndpointId, transport: Arc<TcpTlsTransport>) -> Self {
let mut secret_bytes = [0u8; 32];
rand::fill(&mut secret_bytes);
let static_secret = StaticSecret::from(secret_bytes);
let public_key = PublicKey::from(&static_secret);
Self {
local_id,
static_secret,
public_key,
transport,
ciphers: RwLock::new(HashMap::new()),
}
}
/// Configures settings from parsed `-o` options.
pub fn configure(
mut config: TcpTlsConfig,
options: &[(String, String)],
) -> Result<TcpTlsConfig, String> {
for (key, value) in options {
match key.as_str() {
"port" => {
let port: u16 = value
.parse()
.map_err(|_| format!("invalid port `{value}`"))?;
config.port = Some(port);
}
"sni" => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("sni domain cannot be empty".into());
}
config.sni = Some(trimmed.to_string());
}
other => return Err(format!("unknown option `{other}` for tcp-tls")),
}
}
Ok(config)
}
}
impl TunnelCodec for TcpTlsCodec {
fn protocol_id(&self) -> &str {
TCP_TLS_PROTOCOL
}
fn protocol_version(&self) -> u16 {
TCP_TLS_VERSION
}
fn options(&self) -> &'static [ProtocolOption] {
Self::OPTIONS
}
fn local_capability_data(&self, _network: NetworkId) -> Result<Vec<u8>, PluginError> {
let announcement = TcpTlsAnnouncement {
noise_public: *self.public_key.as_bytes(),
port: self.transport.bound_port(),
addrs: local_ip_candidates(),
};
postcard::to_allocvec(&announcement)
.map_err(|err| PluginError::Other(format!("encode announcement failed: {err}")))
}
fn on_peer_capability_data(
&self,
network: NetworkId,
peer: EndpointId,
data: &[u8],
) -> Result<(), PluginError> {
let announcement: TcpTlsAnnouncement = postcard::from_bytes(data)
.map_err(|err| PluginError::Rejected(format!("invalid announcement: {err}")))?;
for ip in &announcement.addrs {
if is_candidate_ip(*ip) {
self.transport
.set_peer_addr(peer, std::net::SocketAddr::new(*ip, announcement.port));
}
}
let peer_noise_pubkey = PublicKey::from(announcement.noise_public);
let cipher = Arc::new(PeerCipher::new(
self.local_id,
peer,
&self.static_secret,
&peer_noise_pubkey,
));
let mut guard = match self.ciphers.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.insert((network, peer), cipher);
Ok(())
}
fn on_peer_down(&self, network: NetworkId, peer: EndpointId) {
let mut guard = match self.ciphers.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.remove(&(network, peer));
}
fn encrypt(
&self,
network: NetworkId,
peer: EndpointId,
packet: &[u8],
) -> Result<Bytes, PluginError> {
let cipher = {
let guard = match self.ciphers.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.get(&(network, peer)).cloned()
};
let Some(cipher) = cipher else {
return Err(PluginError::Unavailable("no cipher for peer".into()));
};
cipher.encrypt(packet)
}
fn decrypt(
&self,
network: NetworkId,
peer: EndpointId,
payload: &[u8],
) -> Result<Bytes, PluginError> {
let cipher = {
let guard = match self.ciphers.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.get(&(network, peer)).cloned()
};
let Some(cipher) = cipher else {
return Err(PluginError::Unavailable("no cipher for peer".into()));
};
cipher.decrypt(payload)
}
}
/// The complete `tcp-tls` plugin.
pub type TcpTlsPlugin = GenericTunnelPlugin<TcpTlsCodec>;
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
#[test]
fn test_is_candidate_interface_filters_overlay_and_bridges() {
let mut iface = netdev::Interface::dummy();
iface.flags = 1; // UP
// Standard Ethernet - should pass
iface.name = "eth0".to_string();
assert!(is_candidate_interface(&iface));
// Windows Ethernet with GUID name - should pass
iface.name = "{B6149D2C-1234-5678-9ABC-DEF012345678}".to_string();
iface.friendly_name = Some("Ethernet".to_string());
iface.description = Some("Intel(R) Ethernet Connection".to_string());
assert!(is_candidate_interface(&iface));
// Linux tsun0 overlay - must be filtered
iface.name = "tsun0".to_string();
iface.friendly_name = None;
iface.description = None;
assert!(!is_candidate_interface(&iface));
// Windows Wintun overlay (GUID name with tsun0 friendly name) - must be filtered!
iface.name = "{72D85D76-F1A5-42AC-BC49-2169527CDDDF}".to_string();
iface.friendly_name = Some("tsun0".to_string());
iface.description = Some("Wintun Userspace Tunnel".to_string());
assert!(!is_candidate_interface(&iface));
// Windows Wintun overlay with description containing Wintun - must be filtered!
iface.friendly_name = Some("Local Area Connection 2".to_string());
iface.description = Some("Wintun Userspace Tunnel".to_string());
assert!(!is_candidate_interface(&iface));
// Docker bridge - must be filtered
iface.name = "docker0".to_string();
iface.friendly_name = None;
iface.description = None;
assert!(!is_candidate_interface(&iface));
// Docker network bridge br-* - must be filtered
iface.name = "br-4f6d3a9b1c2e".to_string();
assert!(!is_candidate_interface(&iface));
// Virtual ethernet veth* - must be filtered
iface.name = "veth12345".to_string();
assert!(!is_candidate_interface(&iface));
// Libvirt NAT bridge virbr* - must be filtered
iface.name = "virbr0".to_string();
assert!(!is_candidate_interface(&iface));
// CNI bridge - must be filtered
iface.name = "cni0".to_string();
assert!(!is_candidate_interface(&iface));
// Hyper-V / WSL vEthernet switch on Windows - must be filtered
iface.name = "{A1B2C3D4-E5F6-7890-1234-567890ABCDEF}".to_string();
iface.friendly_name = Some("vEthernet (WSL)".to_string());
iface.description = Some("Hyper-V Virtual Ethernet Adapter".to_string());
assert!(!is_candidate_interface(&iface));
// Down interface - must be filtered
let mut down_iface = netdev::Interface::dummy();
down_iface.flags = 0; // Not UP
down_iface.name = "eth0".to_string();
assert!(!is_candidate_interface(&down_iface));
}
#[test]
fn test_is_candidate_ip() {
// Valid LAN IP
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
192, 168, 1, 117
))));
// Valid Tailscale IP
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
100, 77, 155, 120
))));
// Valid Public IP
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
1, 1, 1, 1
))));
// Valid IPv6 GUA
assert!(is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::new(
0x2a01, 0x4b00, 0xb8e3, 0x4e00, 0, 0, 0, 1
))));
// Loopback - must be rejected
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
127, 0, 0, 1
))));
assert!(!is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::LOCALHOST)));
// Unspecified - must be rejected
assert!(!is_candidate_ip(std::net::IpAddr::V4(
Ipv4Addr::UNSPECIFIED
)));
assert!(!is_candidate_ip(std::net::IpAddr::V6(
Ipv6Addr::UNSPECIFIED
)));
// Link-local - must be rejected
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
169, 254, 1, 1
))));
assert!(!is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::new(
0xfe80, 0, 0, 0, 0, 0x1ff, 0xfe00, 1
))));
// Broadcast - must be rejected
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::BROADCAST)));
}
#[test]
fn test_ip_priority_ordering() {
let mut iface_lan = netdev::Interface::dummy();
iface_lan.flags = 1;
iface_lan.gateway = Some(netdev::net::device::NetworkDevice::new());
let mut iface_vpn = netdev::Interface::dummy();
iface_vpn.flags = 1;
iface_vpn.name = "tailscale0".to_string();
let lan_ip = std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 1, 117));
let tailscale_ip = std::net::IpAddr::V4(Ipv4Addr::new(100, 77, 155, 120));
let p_lan = ip_priority(&iface_lan, lan_ip);
let p_ts = ip_priority(&iface_vpn, tailscale_ip);
// LAN IP with gateway should score higher than VPN IP without gateway
assert!(p_lan > p_ts);
}
}
+627
View File
@@ -0,0 +1,627 @@
//! TCP TLS 1.3 transport implementation for Tsunagi.
//!
//! Provides [`TcpTlsTransport`] implementing [`PacketTransport`]
//! and [`TcpTlsLink`] implementing [`PacketLink`].
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use bytes::Bytes;
use iroh::{EndpointId, Signature};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{Notify, mpsc};
use tokio_rustls::TlsConnector;
use tsunagi::BoxFuture;
use tsunagi::dataplane::transport::{
InboundLink, PacketLink, PacketTransport, SharedLink, TransportError,
};
use tsunagi::identity::{DeviceIdentity, NetworkId};
use crate::cert::{generate_self_signed_cert, make_client_config, make_server_config};
/// Default TCP port to bind and listen on.
pub const DEFAULT_PORT: u16 = 443;
/// Fallback port if 443 is unprivileged on Linux and denied.
pub const FALLBACK_PORT: u16 = 8443;
/// Maximum payload length for a single framed datagram over TLS.
pub const MAX_FRAME_SIZE: usize = 65535;
/// Initial handshake message on established TLS stream (128 bytes).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LinkHandshake {
/// The overlay network ID.
pub network: NetworkId,
/// The connecting client's endpoint ID.
pub client_id: EndpointId,
/// Ed25519 signature over `tsunagi-tcp-tls-link-v1` + network + server ID.
pub signature: [u8; 64],
}
impl LinkHandshake {
/// Total encoded length in bytes.
pub const LEN: usize = 32 + 32 + 64; // 128
/// Serializes to fixed-size byte array.
pub fn to_bytes(&self) -> [u8; Self::LEN] {
let mut buf = [0u8; Self::LEN];
buf[0..32].copy_from_slice(self.network.as_bytes());
buf[32..64].copy_from_slice(self.client_id.as_bytes());
buf[64..128].copy_from_slice(&self.signature);
buf
}
/// Deserializes from byte slice.
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
if bytes.len() != Self::LEN {
return None;
}
let network = NetworkId::from_bytes(bytes[0..32].try_into().ok()?);
let client_id = EndpointId::from_bytes(bytes[32..64].try_into().ok()?).ok()?;
let signature: [u8; 64] = bytes[64..128].try_into().ok()?;
Some(Self {
network,
client_id,
signature,
})
}
}
/// Acknowledgement message on established TLS stream (1 byte).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LinkAck {
/// Whether the link was accepted by the server.
pub accepted: bool,
}
impl LinkAck {
/// Total encoded length in bytes.
pub const LEN: usize = 1;
/// Serializes to fixed-size byte array.
pub fn to_bytes(&self) -> [u8; 1] {
[if self.accepted { 1 } else { 0 }]
}
/// Deserializes from byte slice.
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
if bytes.len() != 1 {
return None;
}
Some(Self {
accepted: bytes[0] == 1,
})
}
}
fn link_handshake_bytes(network: NetworkId, server_id: EndpointId) -> Vec<u8> {
let mut bytes = Vec::with_capacity(23 + 32 + 32);
bytes.extend_from_slice(b"tsunagi-tcp-tls-link-v1");
bytes.extend_from_slice(network.as_bytes());
bytes.extend_from_slice(server_id.as_bytes());
bytes
}
/// An authenticated datagram link carried over a TCP TLS 1.3 stream.
#[derive(Debug)]
pub struct TcpTlsLink {
network: NetworkId,
peer: EndpointId,
tx: mpsc::Sender<Bytes>,
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
closed_notify: Arc<Notify>,
is_closed: Arc<AtomicBool>,
remote_addr: String,
}
impl TcpTlsLink {
/// Creates a new `TcpTlsLink` from a stream that has already passed the TLS handshake.
pub fn new<S>(network: NetworkId, peer: EndpointId, stream: S, remote_addr: String) -> Self
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static,
{
let (mut reader, mut writer) = tokio::io::split(stream);
let (tx_out, mut rx_out) = mpsc::channel::<Bytes>(256);
let (tx_in, rx_in) = mpsc::channel::<Bytes>(256);
let is_closed = Arc::new(AtomicBool::new(false));
let closed_notify = Arc::new(Notify::new());
let closed_flag_w = Arc::clone(&is_closed);
let closed_notify_w = Arc::clone(&closed_notify);
tokio::spawn(async move {
while let Some(msg) = rx_out.recv().await {
if msg.len() > MAX_FRAME_SIZE {
continue;
}
let len = msg.len() as u16;
if writer.write_all(&len.to_be_bytes()).await.is_err() {
break;
}
if writer.write_all(&msg).await.is_err() {
break;
}
if writer.flush().await.is_err() {
break;
}
}
closed_flag_w.store(true, Ordering::Release);
closed_notify_w.notify_waiters();
});
let closed_flag_r = Arc::clone(&is_closed);
let closed_notify_r = Arc::clone(&closed_notify);
tokio::spawn(async move {
let mut len_buf = [0u8; 2];
loop {
if reader.read_exact(&mut len_buf).await.is_err() {
break;
}
let len = u16::from_be_bytes(len_buf) as usize;
if len > MAX_FRAME_SIZE {
break;
}
let mut buf = vec![0u8; len];
if reader.read_exact(&mut buf).await.is_err() {
break;
}
if tx_in.send(Bytes::from(buf)).await.is_err() {
break;
}
}
closed_flag_r.store(true, Ordering::Release);
closed_notify_r.notify_waiters();
});
Self {
network,
peer,
tx: tx_out,
rx: tokio::sync::Mutex::new(rx_in),
closed_notify,
is_closed,
remote_addr,
}
}
}
impl PacketLink for TcpTlsLink {
fn network(&self) -> NetworkId {
self.network
}
fn peer(&self) -> EndpointId {
self.peer
}
fn max_datagram_size(&self) -> usize {
MAX_FRAME_SIZE
}
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
if payload.len() > MAX_FRAME_SIZE {
return Err(TransportError::TooLarge {
size: payload.len(),
limit: MAX_FRAME_SIZE,
});
}
if self.is_closed.load(Ordering::Acquire) {
return Err(TransportError::Closed);
}
self.tx
.try_send(payload)
.map_err(|_| TransportError::Closed)
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move {
let mut guard = self.rx.lock().await;
guard.recv().await
})
}
fn closed(&self) -> BoxFuture<'_, ()> {
Box::pin(async move {
if self.is_closed.load(Ordering::Acquire) {
return;
}
self.closed_notify.notified().await;
})
}
fn is_closed(&self) -> bool {
self.is_closed.load(Ordering::Acquire)
}
fn path_description(&self) -> String {
format!("tcp-tls via {}", self.remote_addr)
}
}
/// The `tcp-tls` packet transport.
pub struct TcpTlsTransport {
identity: DeviceIdentity,
local_id: EndpointId,
listen_port: u16,
sni: String,
peer_addresses: std::sync::RwLock<HashMap<EndpointId, Vec<SocketAddr>>>,
server_config: Arc<rustls::ServerConfig>,
listener: Option<Arc<TcpListener>>,
}
impl std::fmt::Debug for TcpTlsTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TcpTlsTransport")
.field("local_id", &self.local_id.fmt_short().to_string())
.field("listen_port", &self.listen_port)
.field("sni", &self.sni)
.finish()
}
}
#[cfg(target_os = "linux")]
struct NetBindServiceGuard {
raised: bool,
}
#[cfg(target_os = "linux")]
impl NetBindServiceGuard {
fn acquire() -> Self {
let already = caps::has_cap(
None,
caps::CapSet::Effective,
caps::Capability::CAP_NET_BIND_SERVICE,
)
.unwrap_or(false);
if already {
return Self { raised: false };
}
let permitted = caps::has_cap(
None,
caps::CapSet::Permitted,
caps::Capability::CAP_NET_BIND_SERVICE,
)
.unwrap_or(false);
if permitted
&& caps::raise(
None,
caps::CapSet::Effective,
caps::Capability::CAP_NET_BIND_SERVICE,
)
.is_ok()
{
Self { raised: true }
} else {
Self { raised: false }
}
}
}
#[cfg(target_os = "linux")]
impl Drop for NetBindServiceGuard {
fn drop(&mut self) {
if self.raised {
let _ = caps::drop(
None,
caps::CapSet::Effective,
caps::Capability::CAP_NET_BIND_SERVICE,
);
}
}
}
impl TcpTlsTransport {
/// Binds a new TCP TLS transport using the device identity, configured port, and SNI domain.
pub async fn bind(
identity: &DeviceIdentity,
requested_port: Option<u16>,
requested_sni: Option<String>,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let sni = requested_sni
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::cert::DEFAULT_SNI.to_string());
let (cert, key) = generate_self_signed_cert(&identity.secret_bytes(), &sni)?;
let server_config = make_server_config(cert, key)?;
let target_port = requested_port.unwrap_or(DEFAULT_PORT);
let bind_addr = SocketAddr::from(([0, 0, 0, 0], target_port));
let bind_result = {
#[cfg(target_os = "linux")]
let _guard = if target_port < 1024 {
Some(NetBindServiceGuard::acquire())
} else {
None
};
std::net::TcpListener::bind(bind_addr)
};
let listener = match bind_result {
Ok(std_listener) => {
std_listener.set_nonblocking(true)?;
let l = TcpListener::from_std(std_listener)?;
tracing::info!(port = target_port, "TCP TLS listening");
Some(Arc::new(l))
}
Err(err) if requested_port.is_none() => {
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "tsunagi".into());
if err.kind() == std::io::ErrorKind::PermissionDenied {
tracing::warn!(
port = target_port,
%err,
"Cannot bind to default port {target_port} without privileges. \
To allow port 443 and TUN on Linux, grant capabilities once with:\n \
sudo setcap cap_net_admin,cap_net_bind_service+p {exe}\n\
Falling back to port {FALLBACK_PORT}."
);
} else {
tracing::warn!(
port = target_port,
%err,
"Cannot bind to default port {target_port}: {err}. \
Falling back to port {FALLBACK_PORT}."
);
}
let fallback_addr = SocketAddr::from(([0, 0, 0, 0], FALLBACK_PORT));
match TcpListener::bind(fallback_addr).await {
Ok(l) => {
tracing::info!(port = FALLBACK_PORT, "TCP TLS listening on fallback port");
Some(Arc::new(l))
}
Err(fallback_err) => {
tracing::warn!(
port = FALLBACK_PORT,
err = %fallback_err,
"Cannot bind to fallback port {FALLBACK_PORT}: {fallback_err}. \
Falling back to ephemeral port."
);
let ephemeral_addr = SocketAddr::from(([0, 0, 0, 0], 0));
let l = TcpListener::bind(ephemeral_addr).await?;
let bound = l.local_addr()?.port();
tracing::info!(port = bound, "TCP TLS listening on ephemeral port");
Some(Arc::new(l))
}
}
}
Err(err) => {
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "tsunagi".into());
tracing::warn!(
port = target_port,
%err,
"Cannot bind to requested port {target_port}: {err}. \
To allow port {target_port} and TUN on Linux, grant capabilities once with:\n \
sudo setcap cap_net_admin,cap_net_bind_service+p {exe}"
);
return Err(Box::new(err));
}
};
let bound_port = listener
.as_ref()
.and_then(|l| l.local_addr().ok())
.map(|a| a.port())
.unwrap_or(target_port);
Ok(Self {
identity: identity.clone(),
local_id: identity.endpoint_id(),
listen_port: bound_port,
sni,
peer_addresses: std::sync::RwLock::new(HashMap::new()),
server_config,
listener,
})
}
/// The port this transport is listening on.
pub fn bound_port(&self) -> u16 {
self.listen_port
}
/// The SNI domain this transport advertises during TLS handshake.
pub fn sni(&self) -> &str {
&self.sni
}
/// Records an observed TCP target address for a peer.
pub fn set_peer_addr(&self, peer: EndpointId, addr: SocketAddr) {
if let Ok(mut guard) = self.peer_addresses.write() {
let addrs = guard.entry(peer).or_default();
if !addrs.contains(&addr) {
addrs.push(addr);
}
}
}
/// Runs the accept loop for inbound TCP TLS connections.
pub async fn accept_loop<F>(&self, on_inbound: F)
where
F: Fn(InboundLink) + Send + Sync + Clone + 'static,
{
let Some(listener) = self.listener.as_ref().cloned() else {
return;
};
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::clone(&self.server_config));
let local_id = self.local_id;
while let Ok((socket, remote_addr)) = listener.accept().await {
let acceptor = acceptor.clone();
let on_inbound = on_inbound.clone();
tokio::spawn(async move {
let mut tls_stream = match acceptor.accept(socket).await {
Ok(s) => s,
Err(err) => {
tracing::debug!(%err, "inbound TLS handshake failed");
return;
}
};
let mut hello_buf = [0u8; LinkHandshake::LEN];
if tls_stream.read_exact(&mut hello_buf).await.is_err() {
return;
}
let Some(hello) = LinkHandshake::from_bytes(&hello_buf) else {
return;
};
let msg = link_handshake_bytes(hello.network, local_id);
let sig = Signature::from_bytes(&hello.signature);
if hello.client_id.verify(&msg, &sig).is_err() {
tracing::debug!("LinkHandshake signature verification failed");
return;
}
let ack = LinkAck { accepted: true };
let ack_bytes = ack.to_bytes();
if tls_stream.write_all(&ack_bytes).await.is_err() {
return;
}
if tls_stream.flush().await.is_err() {
return;
}
let link = TcpTlsLink::new(
hello.network,
hello.client_id,
tls_stream,
remote_addr.to_string(),
);
on_inbound(InboundLink {
network: hello.network,
peer: hello.client_id,
protocol: "tcp-tls".into(),
link: Arc::new(link),
});
});
}
}
}
impl PacketTransport for TcpTlsTransport {
fn name(&self) -> &str {
"tcp-tls"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn open<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
protocol: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
Box::pin(async move {
if protocol != "tcp-tls" {
return Err(TransportError::Declined(protocol.to_string()));
}
let target_addrs = {
let guard = self
.peer_addresses
.read()
.map_err(|_| TransportError::Other("lock poisoned".into()))?;
guard.get(&peer).cloned().unwrap_or_default()
};
if target_addrs.is_empty() {
return Err(TransportError::Unreachable(
"no known TCP address for peer".into(),
));
}
let mut connected_socket = None;
let mut connected_addr = None;
for addr in &target_addrs {
if addr.ip().is_loopback() && addr.port() == self.listen_port {
continue;
}
match tokio::time::timeout(Duration::from_secs(3), TcpStream::connect(*addr)).await
{
Ok(Ok(socket)) => {
connected_socket = Some(socket);
connected_addr = Some(*addr);
break;
}
_ => continue,
}
}
let (socket, addr) = match (connected_socket, connected_addr) {
(Some(s), Some(a)) => (s, a),
_ => {
return Err(TransportError::Unreachable(
"cannot connect to any TCP address for peer".into(),
));
}
};
let client_config =
make_client_config(peer).map_err(|err| TransportError::Other(err.to_string()))?;
let connector = TlsConnector::from(client_config);
let server_name = rustls::pki_types::ServerName::try_from(self.sni.as_str())
.map_err(|err| TransportError::Other(err.to_string()))?
.to_owned();
let mut tls_stream = connector
.connect(server_name, socket)
.await
.map_err(|err| TransportError::Other(format!("TLS handshake failed: {err}")))?;
// Send LinkHandshake (fixed 128 bytes)
let msg = link_handshake_bytes(network, peer);
let sig = self.identity.sign(&msg);
let hello = LinkHandshake {
network,
client_id: self.local_id,
signature: sig.to_bytes(),
};
let hello_bytes = hello.to_bytes();
tls_stream
.write_all(&hello_bytes)
.await
.map_err(|err| TransportError::Other(err.to_string()))?;
tls_stream
.flush()
.await
.map_err(|err| TransportError::Other(err.to_string()))?;
// Read LinkAck (fixed 1 byte)
let mut ack_buf = [0u8; LinkAck::LEN];
tls_stream
.read_exact(&mut ack_buf)
.await
.map_err(|err| TransportError::Other(err.to_string()))?;
let ack = LinkAck::from_bytes(&ack_buf)
.ok_or_else(|| TransportError::Other("invalid link ack".into()))?;
if !ack.accepted {
return Err(TransportError::Declined("link declined".into()));
}
let link = TcpTlsLink::new(network, peer, tls_stream, addr.to_string());
Ok(Arc::new(link) as SharedLink)
})
}
}
@@ -0,0 +1,53 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use rustls::client::danger::ServerCertVerifier;
use rustls::pki_types::{ServerName, UnixTime};
use tsunagi::identity::DeviceIdentity;
use tsunagi_tcp_tls::cert::{PinnedEndpointVerifier, generate_self_signed_cert};
#[test]
fn test_generate_and_pin_certificate() {
let identity = DeviceIdentity::generate();
let peer_id = identity.endpoint_id();
let (cert, _key) =
generate_self_signed_cert(&identity.secret_bytes(), tsunagi_tcp_tls::cert::DEFAULT_SNI)
.expect("cert generation should succeed");
let verifier = PinnedEndpointVerifier::new(peer_id);
let server_name = ServerName::try_from(tsunagi_tcp_tls::cert::DEFAULT_SNI).unwrap();
let verified = verifier.verify_server_cert(&cert, &[], &server_name, &[], UnixTime::now());
assert!(
verified.is_ok(),
"certificate matching peer endpoint id must verify"
);
let other_identity = DeviceIdentity::generate();
let wrong_verifier = PinnedEndpointVerifier::new(other_identity.endpoint_id());
let wrong_verified =
wrong_verifier.verify_server_cert(&cert, &[], &server_name, &[], UnixTime::now());
assert!(
wrong_verified.is_err(),
"certificate with wrong endpoint id must fail verification"
);
}
#[test]
fn test_make_client_and_server_config() {
let identity = DeviceIdentity::generate();
let peer_id = identity.endpoint_id();
let (cert, key) = generate_self_signed_cert(&identity.secret_bytes(), "custom.example.com")
.expect("cert generation should succeed");
let server_config = tsunagi_tcp_tls::cert::make_server_config(cert, key);
assert!(server_config.is_ok());
let client_config = tsunagi_tcp_tls::cert::make_client_config(peer_id);
assert!(client_config.is_ok());
}
@@ -0,0 +1,73 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tsunagi_tcp_tls::crypto::PeerCipher;
use x25519_dalek::{PublicKey, StaticSecret};
#[test]
fn test_peer_cipher_bidirectional_roundtrip() {
let peer_a_id = iroh::SecretKey::generate().public();
let peer_b_id = iroh::SecretKey::generate().public();
let secret_a = StaticSecret::from([10u8; 32]);
let public_a = PublicKey::from(&secret_a);
let secret_b = StaticSecret::from([20u8; 32]);
let public_b = PublicKey::from(&secret_b);
let cipher_a = PeerCipher::new(peer_a_id, peer_b_id, &secret_a, &public_b);
let cipher_b = PeerCipher::new(peer_b_id, peer_a_id, &secret_b, &public_a);
// A -> B
let msg_from_a = b"hello from node A to node B over Noise E2EE";
let encrypted_by_a = cipher_a
.encrypt(msg_from_a)
.expect("encryption should succeed");
let decrypted_by_b = cipher_b
.decrypt(&encrypted_by_a)
.expect("decryption should succeed");
assert_eq!(&decrypted_by_b[..], msg_from_a);
// B -> A
let msg_from_b = b"hello back from node B to node A";
let encrypted_by_b = cipher_b
.encrypt(msg_from_b)
.expect("encryption should succeed");
let decrypted_by_a = cipher_a
.decrypt(&encrypted_by_b)
.expect("decryption should succeed");
assert_eq!(&decrypted_by_a[..], msg_from_b);
// Sequence nonces increment
let msg2 = b"packet 2";
let enc2 = cipher_a.encrypt(msg2).expect("encryption should succeed");
assert_ne!(encrypted_by_a[..8], enc2[..8], "nonces should differ");
let dec2 = cipher_b.decrypt(&enc2).expect("decryption should succeed");
assert_eq!(&dec2[..], msg2);
}
#[test]
fn test_tampered_payload_rejected() {
let peer_a_id = iroh::SecretKey::generate().public();
let peer_b_id = iroh::SecretKey::generate().public();
let secret_a = StaticSecret::from([10u8; 32]);
let public_a = PublicKey::from(&secret_a);
let secret_b = StaticSecret::from([20u8; 32]);
let public_b = PublicKey::from(&secret_b);
let cipher_a = PeerCipher::new(peer_a_id, peer_b_id, &secret_a, &public_b);
let cipher_b = PeerCipher::new(peer_b_id, peer_a_id, &secret_b, &public_a);
let encrypted = cipher_a.encrypt(b"secret payload").expect("encrypt");
// Corrupt one byte of ciphertext
let len = encrypted.len();
let mut modified = encrypted.to_vec();
modified[len - 1] ^= 0xff;
assert!(
cipher_b.decrypt(&modified).is_err(),
"corrupted ciphertext must fail AEAD verification"
);
}
@@ -0,0 +1,149 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use tokio::sync::mpsc;
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
use tsunagi::dataplane::{IpPlugin, PacketSink, PluginContext};
use tsunagi::identity::{DeviceIdentity, NetworkId};
use tsunagi_tcp_tls::{TcpTlsCodec, TcpTlsPlugin, TcpTlsTransport};
struct TestSink {
delivered: mpsc::Sender<(NetworkId, iroh::EndpointId, Bytes)>,
}
impl PacketSink for TestSink {
fn deliver(
&self,
network: NetworkId,
from: iroh::EndpointId,
packet: Bytes,
) -> tsunagi::BoxFuture<'_, ()> {
let delivered = self.delivered.clone();
Box::pin(async move {
let _ = delivered.send((network, from, packet)).await;
})
}
}
#[tokio::test]
async fn test_tcp_tls_plugin_end_to_end() {
let identity_a = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([77u8; 32]);
// Transports
let transport_b = Arc::new(
TcpTlsTransport::bind(&identity_b, Some(0), None)
.await
.expect("bind B"),
);
let port_b = transport_b.bound_port();
let transport_a = Arc::new(
TcpTlsTransport::bind(&identity_a, Some(0), None)
.await
.expect("bind A"),
);
// Codecs and plugins
let codec_a = TcpTlsCodec::new(peer_a, Arc::clone(&transport_a));
let plugin_a = Arc::new(TcpTlsPlugin::new(codec_a));
let codec_b = TcpTlsCodec::new(peer_b, Arc::clone(&transport_b));
let plugin_b = Arc::new(TcpTlsPlugin::new(codec_b));
// Attach sinks
let (sink_a_tx, mut sink_a_rx) = mpsc::channel(10);
plugin_a.attach(PluginContext::with_sink(Arc::new(TestSink {
delivered: sink_a_tx,
})));
let (sink_b_tx, mut sink_b_rx) = mpsc::channel(10);
plugin_b.attach(PluginContext::with_sink(Arc::new(TestSink {
delivered: sink_b_tx,
})));
// Capability exchange
let cap_a = plugin_a
.local_capability(network)
.expect("cap A")
.expect("some cap A");
let cap_b = plugin_b
.local_capability(network)
.expect("cap B")
.expect("some cap B");
plugin_a
.on_peer_capability(network, peer_b, &cap_b)
.expect("A on cap B");
plugin_b
.on_peer_capability(network, peer_a, &cap_a)
.expect("B on cap A");
// Spawn accept loop on B
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone
.accept_loop(move |inbound| {
let _ = inbound_tx.try_send(inbound);
})
.await;
});
// Make sure A knows B's loopback address
transport_a.set_peer_addr(peer_b, format!("127.0.0.1:{port_b}").parse().unwrap());
// Connect link from A to B
let link_a = transport_a
.open(network, peer_b, "tcp-tls")
.await
.expect("open link from A");
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
.await
.expect("recv timeout")
.expect("recv inbound");
let link_b = inbound_b.link;
// Install links into plugins
plugin_a.on_peer_link(network, peer_b, link_a);
plugin_b.on_peer_link(network, peer_a, link_b);
// Carry packet from A to B
let ip_packet_a = Bytes::from_static(b"4500003c000100004011e0e8c0a80101c0a8010200000000");
let carried_a = plugin_a.carry(network, peer_b, ip_packet_a.clone());
assert!(carried_a, "plugin A should carry packet");
let (net_received, from_peer, delivered_pkt) =
tokio::time::timeout(Duration::from_secs(3), sink_b_rx.recv())
.await
.expect("timeout waiting for delivery on B")
.expect("packet on B");
assert_eq!(net_received, network);
assert_eq!(from_peer, peer_a);
assert_eq!(delivered_pkt, ip_packet_a);
// Carry reply packet from B to A
let ip_packet_b = Bytes::from_static(b"reply-packet-from-b");
let carried_b = plugin_b.carry(network, peer_a, ip_packet_b.clone());
assert!(carried_b, "plugin B should carry packet");
let (net_received, from_peer, delivered_pkt) =
tokio::time::timeout(Duration::from_secs(3), sink_a_rx.recv())
.await
.expect("timeout waiting for delivery on A")
.expect("packet on A");
assert_eq!(net_received, network);
assert_eq!(from_peer, peer_b);
assert_eq!(delivered_pkt, ip_packet_b);
}
@@ -0,0 +1,98 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use tokio::sync::mpsc;
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
use tsunagi::identity::{DeviceIdentity, NetworkId};
use tsunagi_tcp_tls::TcpTlsTransport;
#[tokio::test]
async fn test_tcp_tls_transport_loopback() {
let identity_a = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([42u8; 32]);
// Bind transport B on port 0 (ephemeral)
let transport_b = Arc::new(
TcpTlsTransport::bind(&identity_b, Some(0), Some("custom.example.com".to_string()))
.await
.expect("bind server should succeed"),
);
let port_b = transport_b.bound_port();
assert!(port_b > 0);
assert_eq!(transport_b.sni(), "custom.example.com");
// Bind transport A on port 0 (ephemeral)
let transport_a = Arc::new(
TcpTlsTransport::bind(&identity_a, Some(0), Some("custom.example.com".to_string()))
.await
.expect("bind client should succeed"),
);
assert_eq!(transport_a.sni(), "custom.example.com");
// Register B's address on transport A
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
transport_a.set_peer_addr(peer_b, addr_b);
// Spawn accept loop on B
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone
.accept_loop(move |inbound| {
let _ = inbound_tx.try_send(inbound);
})
.await;
});
// Open link from A to B
let link_a = tokio::time::timeout(
Duration::from_secs(5),
transport_a.open(network, peer_b, "tcp-tls"),
)
.await
.expect("open should not timeout")
.expect("open should succeed");
// Receive inbound link on B
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
.await
.expect("accept should not timeout")
.expect("inbound link should arrive");
assert_eq!(inbound_b.network, network);
assert_eq!(inbound_b.peer, peer_a);
assert_eq!(inbound_b.protocol, "tcp-tls");
let link_b = inbound_b.link;
// Send from A to B
let data_a_to_b = Bytes::from_static(b"packet from A to B");
link_a.send(data_a_to_b.clone()).expect("send A->B");
let received_on_b = tokio::time::timeout(Duration::from_secs(2), link_b.recv())
.await
.expect("recv B timeout")
.expect("recv B option");
assert_eq!(received_on_b, data_a_to_b);
// Send from B to A
let data_b_to_a = Bytes::from_static(b"reply from B to A");
link_b.send(data_b_to_a.clone()).expect("send B->A");
let received_on_a = tokio::time::timeout(Duration::from_secs(2), link_a.recv())
.await
.expect("recv A timeout")
.expect("recv A option");
assert_eq!(received_on_a, data_b_to_a);
// Path descriptions
assert!(link_a.path_description().contains("tcp-tls"));
assert!(link_b.path_description().contains("tcp-tls"));
}
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "tsunagi-wg-quic"
# Its own version, and deliberately separate from the wire version it
# negotiates: a release here does not stop it talking to a peer on an older
# build, because what peers compare is `ANNOUNCEMENT_VERSION`.
version = "0.1.0"
description = "The wg-quic protocol for tsunagi: WireGuard's cryptography carried in iroh's QUIC datagrams."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0" }
iroh.workspace = true
tokio.workspace = true
serde.workspace = true
postcard.workspace = true
bytes.workspace = true
thiserror.workspace = true
tracing.workspace = true
data-encoding.workspace = true
hex.workspace = true
rusqlite = { version = "0.40", features = ["bundled"] }
sha2 = "0.11"
hkdf = "0.13"
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
rand = "0.10"
boringtun = { version = "0.7.1", default-features = false }
[dev-dependencies]
tsunagi = { path = "../tsunagi", features = ["testing"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
[lints]
workspace = true
@@ -1,45 +1,49 @@
//! What a WireGuard peer tells the network about itself.
//!
//! This is the opaque payload the control plane carries in a
//! [`crate::dataplane::PluginCapability`]. The agent core never parses it —
//! [`tsunagi::dataplane::PluginCapability`]. The agent core never parses it —
//! only this module does, and only after bounding every field.
//!
//! The announcement is deliberately tiny: a participant says **who it is**,
//! not **where it is**. Reachability is the data plane transport's job, and
//! the transport already solves it — see
//! [`crate::dataplane::transport`]. A plugin that also tried to advertise
//! [`tsunagi::dataplane::transport`]. A plugin that also tried to advertise
//! addresses would be reimplementing NAT traversal badly.
use std::net::Ipv6Addr;
use serde::{Deserialize, Serialize};
use crate::dataplane::PluginError;
use crate::identity::NetworkId;
use tsunagi::dataplane::PluginError;
use tsunagi::identity::NetworkId;
use super::keys::WgPublicKey;
use super::overlay::overlay_address;
use crate::keys::WgPublicKey;
/// Version of the announcement format.
///
/// Version 3 dropped the IPv4 range again: overlay addressing moved to the
/// signed records in [`crate::state`], which carry the range and survive a
/// signed records in [`tsunagi::state`], which carry the range and survive a
/// participant being away. postcard is not self-describing, so an older peer
/// cannot read a newer announcement; the mismatch is reported, not misparsed.
pub const ANNOUNCEMENT_VERSION: u16 = 3;
pub const ANNOUNCEMENT_VERSION: u16 = 4;
/// What one participant advertises for the WireGuard data plane.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WgAnnouncement {
/// Announcement format version.
pub version: u16,
/// The peer's WireGuard public key. Its overlay address is derived from it.
pub public_key: [u8; 32],
/// The overlay address the peer believes it has.
/// The peer's WireGuard public key.
///
/// Carried for diagnostics and cross-checking only. Addresses are always
/// derived locally, never taken from this field.
pub overlay_address: Ipv6Addr,
/// The whole announcement, now that addresses belong to the system
/// level: this says *who* is at the other end of a tunnel, and nothing
/// about where.
pub public_key: [u8; 32],
/// The network this key is for.
///
/// Strictly redundant — a capability arrives on a session that already
/// proved membership of one network — and kept anyway, because the
/// binding used to be a side effect of checking a derived address and
/// losing it silently when that check went would be the wrong way to
/// lose it.
pub network: [u8; 32],
}
/// A peer announcement that has been validated against a specific network.
@@ -47,8 +51,6 @@ pub struct WgAnnouncement {
pub struct ValidatedAnnouncement {
/// The peer's WireGuard public key.
pub public_key: WgPublicKey,
/// The overlay address derived locally for this key. Authoritative.
pub overlay_address: Ipv6Addr,
}
impl WgAnnouncement {
@@ -57,7 +59,7 @@ impl WgAnnouncement {
Self {
version: ANNOUNCEMENT_VERSION,
public_key: *public_key.as_bytes(),
overlay_address: overlay_address(network, public_key),
network: *network.as_bytes(),
}
}
@@ -105,20 +107,13 @@ impl WgAnnouncement {
"peer announced this agent's own WireGuard key".into(),
));
}
// AllowedIPs are derived, never trusted. A mismatch means the peer is
// confused or lying, and either way its own claim is discarded.
let derived = overlay_address(network, &public_key);
if self.overlay_address != derived {
if self.network != *network.as_bytes() {
return Err(PluginError::Rejected(
"announced overlay address does not match the one derived from the peer's key"
.into(),
"announcement is for a different network".into(),
));
}
Ok(ValidatedAnnouncement {
public_key,
overlay_address: derived,
})
Ok(ValidatedAnnouncement { public_key })
}
}
@@ -127,7 +122,7 @@ mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
use super::super::keys::WgSecretKey;
@@ -149,7 +144,6 @@ mod tests {
let validated = WgAnnouncement::decode_and_validate(&payload, id, &local).unwrap();
assert_eq!(validated.public_key, peer);
assert_eq!(validated.overlay_address, overlay_address(id, &peer));
}
#[test]
@@ -167,20 +161,27 @@ mod tests {
}
#[test]
fn allowed_ips_are_derived_not_taken_from_the_peer() {
fn there_is_nothing_address_like_to_forge() {
// Addresses belong to the system level, are allocated there and are
// signed by the member that holds one. A protocol announcement
// carries no address at all, so this is not a thing a peer can lie
// about here — and a peer sending traffic from an address it does
// not hold is rejected by the agreed address, not by anything it
// said in this message.
let id = network("no-hijack");
let victim = WgSecretKey::generate().public();
let attacker = WgSecretKey::generate().public();
let peer = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
// An attacker claims the victim's overlay address with its own key.
let mut forged = WgAnnouncement::new(id, &attacker);
forged.overlay_address = overlay_address(id, &victim);
let announcement = WgAnnouncement::new(id, &peer);
let validated =
WgAnnouncement::decode_and_validate(&announcement.encode().unwrap(), id, &local)
.unwrap();
assert_eq!(validated.public_key, peer);
let result = WgAnnouncement::decode_and_validate(&forged.encode().unwrap(), id, &local);
assert!(
matches!(result, Err(PluginError::Rejected(ref reason)) if reason.contains("does not match")),
"claiming another member's overlay address must be rejected: {result:?}"
// The validated form has one field, and it is an identity.
assert_eq!(
std::mem::size_of_val(&validated),
std::mem::size_of::<WgPublicKey>()
);
}
@@ -236,7 +237,7 @@ mod tests {
let peer = WgSecretKey::generate().public();
let payload = WgAnnouncement::new(id, &peer).encode().unwrap();
assert!(
payload.len() < crate::config::Limits::default().max_capability_data_len,
payload.len() < tsunagi::config::Limits::default().max_capability_data_len,
"announcement is {} bytes",
payload.len()
);
@@ -18,16 +18,18 @@
//! ourselves, and we do:
//!
//! * outbound, a packet is routed to the peer that **owns** its destination
//! address, where ownership is the derivation in [`super::overlay`];
//! address, where ownership is the signed claim the system level agreed;
//! * inbound, a decrypted packet is dropped unless its **source** is exactly
//! the address derived for the peer whose tunnel decrypted it.
//! the address that peer holds.
//!
//! So a participant cannot receive traffic addressed to someone else, and
//! cannot forge traffic that appears to come from someone else, no matter
//! what it announced.
//! cannot forge traffic that appears to come from someone else. Neither
//! check consults anything the peer said here: an address is claimed at the
//! system level and signed by its holder, and that is what is compared
//! against.
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::collections::{HashMap, VecDeque};
use std::net::Ipv4Addr;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
@@ -37,15 +39,12 @@ use bytes::Bytes;
use iroh::EndpointId;
use tokio::task::JoinHandle;
use crate::dataplane::PluginError;
use crate::dataplane::transport::{SharedLink, TransportError};
use crate::identity::NetworkId;
use tsunagi::dataplane::routing::{FlowId, flow::ip_flow};
use tsunagi::dataplane::transport::{SharedLink, TransportError};
use tsunagi::dataplane::{PacketSink, PluginError};
use tsunagi::identity::NetworkId;
use super::keys::{WgPublicKey, WgSecretKey};
use super::overlay::overlay_address;
use super::packet::IpHeader;
use super::tun::TunDevice;
use crate::state::Ipv4Range;
use crate::keys::{WgPublicKey, WgSecretKey};
/// How often WireGuard's own timers are driven.
///
@@ -56,6 +55,66 @@ const TIMER_INTERVAL: Duration = Duration::from_millis(250);
/// Scratch space for one encapsulate or decapsulate call.
const SCRATCH: usize = 4096;
/// Mirrors boringtun 0.7's bounded pending-packet FIFO with opaque flow tags.
/// All three operations share the same mutex as the cryptographic state.
struct FlowTunnel {
tunn: Tunn,
queued: VecDeque<FlowId>,
}
fn is_data(result: &TunnResult<'_>) -> bool {
matches!(result, TunnResult::WriteToNetwork(bytes) if bytes.starts_with(&[4, 0, 0, 0]))
}
impl FlowTunnel {
fn encapsulate<'a>(
&mut self,
packet: &[u8],
scratch: &'a mut [u8],
flow: FlowId,
) -> (TunnResult<'a>, FlowId) {
let result = self.tunn.encapsulate(packet, scratch);
if is_data(&result) {
return (result, flow);
}
// MAX_QUEUE_DEPTH in boringtun 0.7: the newest packet is dropped at 256.
if self.queued.len() < 256 {
self.queued.push_back(flow);
}
(result, 0)
}
fn decapsulate<'a>(
&mut self,
packet: &[u8],
scratch: &'a mut [u8],
) -> (TunnResult<'a>, FlowId) {
let result = self.tunn.decapsulate(None, packet, scratch);
let mut flow = 0;
if packet.is_empty() {
if is_data(&result) {
flow = self.queued.pop_front().unwrap_or(0);
} else if let Some(pending) = self.queued.pop_front() {
// Without a session, draining pops the oldest IP packet and
// encapsulate queues it again at the back.
self.queued.push_back(pending);
}
}
(result, flow)
}
fn update_timers<'a>(&mut self, scratch: &'a mut [u8]) -> TunnResult<'a> {
let result = self.tunn.update_timers(scratch);
if matches!(
result,
TunnResult::Err(boringtun::noise::errors::WireGuardError::ConnectionExpired)
) {
self.queued.clear();
}
result
}
}
/// Counters for one peer's tunnel.
#[derive(Debug, Default)]
struct PeerCounters {
@@ -65,6 +124,8 @@ struct PeerCounters {
rx_bytes: AtomicU64,
dropped_wrong_source: AtomicU64,
dropped_oversize: AtomicU64,
/// Packets there was no session to encrypt with yet.
dropped_no_session: AtomicU64,
protocol_errors: AtomicU64,
}
@@ -85,6 +146,11 @@ pub struct PeerStats {
pub dropped_wrong_source: u64,
/// Packets dropped because they did not fit in one link datagram.
pub dropped_oversize: u64,
/// Packets dropped because there was no session to encrypt with yet.
///
/// A handful while a tunnel comes up is normal; a number that keeps
/// climbing means the handshake is not completing.
pub dropped_no_session: u64,
/// WireGuard protocol errors, including packets that failed to decrypt.
pub protocol_errors: u64,
}
@@ -109,11 +175,13 @@ impl PeerHealth {
struct Peer {
endpoint_id: EndpointId,
public_key: WgPublicKey,
overlay: Ipv6Addr,
/// The IPv4 address this peer owns, when the overlay is dual stack and
/// nobody else derived the same one.
/// The overlay address this peer holds, as the control plane agreed it.
///
/// Not derived here and not taken from the peer: the system level
/// allocates it, the peer signs the claim, and every protocol carries
/// traffic for the same address.
overlay_v4: Mutex<Option<Ipv4Addr>>,
tunn: Mutex<Tunn>,
tunn: Mutex<FlowTunnel>,
link: SharedLink,
counters: Arc<PeerCounters>,
task: Mutex<Option<JoinHandle<()>>>,
@@ -124,7 +192,6 @@ impl std::fmt::Debug for Peer {
f.debug_struct("Peer")
.field("peer", &self.endpoint_id.fmt_short().to_string())
.field("public_key", &self.public_key)
.field("overlay", &self.overlay)
.finish()
}
}
@@ -148,6 +215,7 @@ impl Peer {
rx_bytes: self.counters.rx_bytes.load(Ordering::Relaxed),
dropped_wrong_source: self.counters.dropped_wrong_source.load(Ordering::Relaxed),
dropped_oversize: self.counters.dropped_oversize.load(Ordering::Relaxed),
dropped_no_session: self.counters.dropped_no_session.load(Ordering::Relaxed),
protocol_errors: self.counters.protocol_errors.load(Ordering::Relaxed),
}
}
@@ -158,7 +226,7 @@ impl Peer {
Err(poisoned) => poisoned.into_inner(),
};
PeerHealth {
since_handshake: guard.time_since_last_handshake(),
since_handshake: guard.tunn.time_since_last_handshake(),
}
}
}
@@ -170,15 +238,8 @@ pub struct PeerSummary {
pub endpoint_id: EndpointId,
/// The peer's WireGuard public key.
pub public_key: WgPublicKey,
/// The overlay address this agent derived for it.
pub overlay_address: Ipv6Addr,
/// Its IPv4 overlay address, when the overlay is dual stack.
///
/// `None` with `ipv4_conflict` set means another member derived the same
/// address and won it; that peer is still fully reachable over IPv6.
/// The overlay address it holds, once the control plane has agreed one.
pub overlay_address_v4: Option<Ipv4Addr>,
/// Whether this peer lost an IPv4 address to a derivation collision.
pub ipv4_conflict: bool,
/// Whether the tunnel has handshaken.
pub health: PeerHealth,
/// Traffic counters.
@@ -192,30 +253,25 @@ pub struct PeerSummary {
struct Inner {
network: NetworkId,
private_key: WgSecretKey,
tun: Arc<dyn TunDevice>,
/// The IPv4 overlay range, when the overlay is dual stack.
ipv4_range: Option<Ipv4Range>,
peers: RwLock<HashMap<WgPublicKey, Arc<Peer>>>,
/// Both families, so one lookup routes any packet.
routes: RwLock<HashMap<IpAddr, WgPublicKey>>,
/// Where decrypted packets go.
///
/// The interface belongs to the system level, so this hands a packet up
/// rather than writing it out: only that level knows which addresses the
/// sending member is entitled to use.
sink: Arc<dyn PacketSink>,
next_index: AtomicU32,
unroutable: AtomicU64,
/// One destination nobody owned, kept so the counter can be acted on.
unroutable_sample: Mutex<Option<IpAddr>>,
multicast: AtomicU64,
ipv4_conflicts: AtomicU64,
}
impl std::fmt::Debug for Inner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Inner")
.field("network", &self.network.fmt_short())
.field("tun", &self.tun.name())
.finish()
}
}
/// A userspace WireGuard interface for one network.
/// The userspace WireGuard tunnels of one network.
#[derive(Debug)]
pub struct WireguardDevice {
inner: Arc<Inner>,
@@ -223,44 +279,75 @@ pub struct WireguardDevice {
}
impl WireguardDevice {
/// Starts a device on top of `tun`.
pub fn start(
network: NetworkId,
private_key: WgSecretKey,
tun: Arc<dyn TunDevice>,
ipv4_range: Option<Ipv4Range>,
) -> Self {
/// Starts the tunnels for one network.
///
/// No interface is involved: packets arrive through [`Self::carry`] and
/// leave through the sink. Which address belongs to whom, and therefore
/// where a packet should go, is decided above this.
pub fn start(network: NetworkId, private_key: WgSecretKey, sink: Arc<dyn PacketSink>) -> Self {
let inner = Arc::new(Inner {
network,
private_key,
tun,
ipv4_range,
peers: RwLock::new(HashMap::new()),
routes: RwLock::new(HashMap::new()),
sink,
next_index: AtomicU32::new(1),
unroutable: AtomicU64::new(0),
unroutable_sample: Mutex::new(None),
multicast: AtomicU64::new(0),
ipv4_conflicts: AtomicU64::new(0),
});
let reader = tokio::spawn(read_from_os(Arc::clone(&inner)));
let timers = tokio::spawn(drive_timers(Arc::clone(&inner)));
Self {
inner,
tasks: vec![reader, timers],
tasks: vec![timers],
}
}
/// The interface name in use.
pub fn interface(&self) -> &str {
self.inner.tun.name()
}
/// Encrypts a packet and sends it to a peer.
///
/// `false` when there is no tunnel for that peer, which is a state the
/// caller reports rather than an error: a peer whose link has not come
/// up yet is normal.
pub fn carry(&self, peer: iroh::EndpointId, packet: &[u8]) -> bool {
let found = read_lock(&self.inner.peers)
.values()
.find(|candidate| candidate.endpoint_id == peer)
.map(Arc::clone);
let Some(peer) = found else {
return false;
};
/// The interface MTU.
pub fn mtu(&self) -> u32 {
self.inner.tun.mtu()
let mut scratch = [0u8; SCRATCH];
let flow = ip_flow(packet);
// The encryption is the whole of what this protocol contributes, so
// it happens here rather than anywhere the packet passes through.
// The lock is released before the send: a slow link must not hold up
// the tunnel's timers.
let result = {
let mut tunn = match peer.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let (result, selected_flow) = tunn.encapsulate(packet, &mut scratch, flow);
match result {
TunnResult::WriteToNetwork(out) => Some((out.len(), selected_flow)),
// No session yet, so nothing to send. Counted as dropped
// rather than reported: the handshake is in flight and the
// next packet will go.
_ => None,
}
};
let Some((len, selected_flow)) = result else {
peer.counters
.dropped_no_session
.fetch_add(1, Ordering::Relaxed);
return false;
};
send_to_peer_flow(&peer, &scratch[..len], selected_flow);
peer.counters.tx_packets.fetch_add(1, Ordering::Relaxed);
peer.counters
.tx_bytes
.fetch_add(packet.len() as u64, Ordering::Relaxed);
true
}
/// Adds or replaces a peer and starts its tunnel.
@@ -292,14 +379,14 @@ impl WireguardDevice {
None,
);
let overlay = overlay_address(self.inner.network, &public_key);
let overlay_v4 = self.claim_ipv4(&public_key, overlay_v4);
let peer = Arc::new(Peer {
endpoint_id,
public_key,
overlay,
overlay_v4: Mutex::new(overlay_v4),
tunn: Mutex::new(tunn),
tunn: Mutex::new(FlowTunnel {
tunn,
queued: VecDeque::new(),
}),
link,
counters: Arc::new(PeerCounters::default()),
task: Mutex::new(None),
@@ -311,10 +398,6 @@ impl WireguardDevice {
}
write_lock(&self.inner.peers).insert(public_key, Arc::clone(&peer));
write_lock(&self.inner.routes).insert(IpAddr::V6(overlay), public_key);
if let Some(v4) = overlay_v4 {
write_lock(&self.inner.routes).insert(IpAddr::V4(v4), public_key);
}
// Start the handshake now instead of waiting for the next timer tick,
// so the tunnel is usable as soon as the link exists.
@@ -322,61 +405,11 @@ impl WireguardDevice {
Ok(())
}
/// Decides which IPv4 address a new peer gets, if any.
///
/// IPv4 has far too little room for a derived address to be collision
/// free. When two members derive the same one, the member whose public
/// key sorts lower keeps it — a rule every member computes identically,
/// so they all agree on the outcome without talking about it. The other
/// member simply has no IPv4 address; it is still fully reachable over
/// IPv6, which never collides.
fn claim_ipv4(&self, public_key: &WgPublicKey, wanted: Option<Ipv4Addr>) -> Option<Ipv4Addr> {
let wanted = wanted?;
let holder = read_lock(&self.inner.routes)
.get(&IpAddr::V4(wanted))
.copied();
let Some(holder) = holder else {
return Some(wanted);
};
if holder == *public_key {
return Some(wanted);
}
self.inner.ipv4_conflicts.fetch_add(1, Ordering::Relaxed);
if holder.as_bytes() <= public_key.as_bytes() {
// The peer already holding it wins.
return None;
}
// The newcomer wins; take the address away from the other peer.
if let Some(loser) = read_lock(&self.inner.peers).get(&holder).cloned() {
let mut slot = match loser.overlay_v4.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
*slot = None;
}
Some(wanted)
}
/// Removes a peer and stops its tunnel.
pub fn remove_peer(&self, public_key: &WgPublicKey) {
if let Some(peer) = write_lock(&self.inner.peers).remove(public_key) {
let mut routes = write_lock(&self.inner.routes);
routes.remove(&IpAddr::V6(peer.overlay));
let v4 = match peer.overlay_v4.lock() {
Ok(guard) => *guard,
Err(poisoned) => *poisoned.into_inner(),
};
if let Some(v4) = v4 {
routes.remove(&IpAddr::V4(v4));
}
}
}
/// How many IPv4 derivation collisions have been resolved.
pub fn ipv4_conflicts(&self) -> u64 {
self.inner.ipv4_conflicts.load(Ordering::Relaxed)
// Dropping it stops the task and closes the link; there is no route
// to withdraw, because routes are not kept here.
write_lock(&self.inner.peers).remove(public_key);
}
/// Removes every peer whose key is not in `keep`.
@@ -408,9 +441,7 @@ impl WireguardDevice {
PeerSummary {
endpoint_id: peer.endpoint_id,
public_key: peer.public_key,
overlay_address: peer.overlay,
overlay_address_v4: overlay_v4,
ipv4_conflict: overlay_v4.is_none() && self.inner.ipv4_range.is_some(),
health: peer.health(),
stats: peer.stats(),
path: peer.link.path_description(),
@@ -421,34 +452,6 @@ impl WireguardDevice {
peers.sort_by_key(|peer| peer.public_key);
peers
}
/// Unicast packets the operating system sent to an address no peer owns.
///
/// A non-zero value means something tried to reach a host that is not in
/// the overlay.
pub fn unroutable_packets(&self) -> u64 {
self.inner.unroutable.load(Ordering::Relaxed)
}
/// One destination that nobody owned, if there was one.
///
/// A bare count says something is wrong but not what; the address usually
/// says it outright.
pub fn unroutable_sample(&self) -> Option<IpAddr> {
match self.inner.unroutable_sample.lock() {
Ok(guard) => *guard,
Err(poisoned) => *poisoned.into_inner(),
}
}
/// Multicast packets dropped.
///
/// Expected and harmless: Linux emits multicast listener and router
/// solicitation traffic on any IPv6 interface, and this overlay is
/// unicast only. Counted separately so it does not look like a fault.
pub fn multicast_packets(&self) -> u64 {
self.inner.multicast.load(Ordering::Relaxed)
}
}
impl Drop for WireguardDevice {
@@ -475,9 +478,7 @@ fn write_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
/// Asks boringtun for a handshake initiation and sends it.
///
/// Encapsulating an empty packet is how the protocol state machine is told
/// "there is something to say"; with no session yet it answers with the
/// handshake initiation.
/// This does not enqueue an empty IP packet in boringtun's pending queue.
fn kick_handshake(peer: &Peer) {
let mut scratch = vec![0u8; SCRATCH];
let len = {
@@ -485,7 +486,7 @@ fn kick_handshake(peer: &Peer) {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
match tunn.encapsulate(&[], &mut scratch) {
match tunn.tunn.format_handshake_initiation(&mut scratch, false) {
TunnResult::WriteToNetwork(out) => Some(out.len()),
_ => None,
}
@@ -497,7 +498,11 @@ fn kick_handshake(peer: &Peer) {
/// Sends whatever boringtun produced, without holding the tunnel lock.
fn send_to_peer(peer: &Peer, payload: &[u8]) {
match peer.link.send(Bytes::copy_from_slice(payload)) {
send_to_peer_flow(peer, payload, 0);
}
fn send_to_peer_flow(peer: &Peer, payload: &[u8], flow: FlowId) {
match peer.link.send_flow(Bytes::copy_from_slice(payload), flow) {
Ok(()) => {}
Err(TransportError::TooLarge { .. }) => {
peer.counters
@@ -511,84 +516,13 @@ fn send_to_peer(peer: &Peer, payload: &[u8]) {
}
}
/// Operating system -> peer.
async fn read_from_os(inner: Arc<Inner>) {
loop {
let Some(packet) = inner.tun.recv().await else {
return;
};
// Route by destination: only the peer that owns that overlay address
// may receive it. Both families go through the same table.
let Some(destination) = IpHeader::parse(&packet).map(|header| header.destination()) else {
inner.unroutable.fetch_add(1, Ordering::Relaxed);
continue;
};
// The kernel emits multicast on every IPv6 interface. The overlay is
// unicast only, so this is dropped, but it is not a fault.
if destination.is_multicast() {
inner.multicast.fetch_add(1, Ordering::Relaxed);
continue;
}
let target = read_lock(&inner.routes).get(&destination).copied();
let Some(target) = target else {
note_unroutable(&inner, destination);
continue;
};
let peer = read_lock(&inner.peers).get(&target).cloned();
let Some(peer) = peer else {
note_unroutable(&inner, destination);
continue;
};
let mut scratch = vec![0u8; SCRATCH];
let outcome = {
let mut tunn = match peer.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
match tunn.encapsulate(&packet, &mut scratch) {
TunnResult::WriteToNetwork(out) => Some(out.len()),
TunnResult::Done => None,
TunnResult::Err(err) => {
tracing::trace!(?err, "wireguard encapsulation failed");
peer.counters
.protocol_errors
.fetch_add(1, Ordering::Relaxed);
None
}
_ => None,
}
};
if let Some(len) = outcome {
send_to_peer(&peer, &scratch[..len]);
peer.counters.tx_packets.fetch_add(1, Ordering::Relaxed);
peer.counters
.tx_bytes
.fetch_add(packet.len() as u64, Ordering::Relaxed);
}
}
}
/// Counts a packet nobody owned the destination of, keeping one example.
fn note_unroutable(inner: &Inner, destination: IpAddr) {
inner.unroutable.fetch_add(1, Ordering::Relaxed);
let mut sample = match inner.unroutable_sample.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
*sample = Some(destination);
}
/// Peer -> operating system.
async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
let mut scratch = vec![0u8; SCRATCH];
loop {
let Some(datagram) = peer.link.recv().await else {
return;
};
let mut scratch = vec![0u8; SCRATCH];
// boringtun may need several passes: a handshake reply first, then
// any packets that were queued while the session was coming up.
let mut input: Option<&[u8]> = Some(&datagram);
@@ -598,14 +532,14 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
match tunn.decapsulate(None, input.unwrap_or(&[]), &mut scratch) {
TunnResult::WriteToNetwork(out) => Outcome::ToNetwork(out.len()),
TunnResult::WriteToTunnelV6(out, source) => {
Outcome::ToTunnel(out.len(), IpAddr::V6(source))
}
TunnResult::WriteToTunnelV4(out, source) => {
Outcome::ToTunnel(out.len(), IpAddr::V4(source))
}
let (result, flow) = tunn.decapsulate(input.unwrap_or(&[]), &mut scratch);
match result {
TunnResult::WriteToNetwork(out) => Outcome::ToNetwork(out.len(), flow),
// The source boringtun reports is not consulted here:
// whether the peer may use it is checked where the
// claims live.
TunnResult::WriteToTunnelV6(out, _) => Outcome::ToTunnel(out.len()),
TunnResult::WriteToTunnelV4(out, _) => Outcome::ToTunnel(out.len()),
TunnResult::Done => Outcome::Done,
TunnResult::Err(err) => {
tracing::trace!(?err, "wireguard decapsulation failed");
@@ -615,38 +549,26 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
};
match outcome {
Outcome::ToNetwork(len) => {
send_to_peer(&peer, &scratch[..len]);
Outcome::ToNetwork(len, flow) => {
send_to_peer_flow(&peer, &scratch[..len], flow);
// Keep draining with an empty datagram, as boringtun asks.
input = None;
continue;
}
Outcome::ToTunnel(len, source) => {
Outcome::ToTunnel(len) => {
// Handed up, not written out. This end has proved *who*
// sent the packet; whether that member may use the source
// address it chose is a question about a signed claim,
// and only the system level holds those.
let payload = Bytes::copy_from_slice(&scratch[..len]);
// Enforce address ownership: a peer may only send from an
// address derived for its own key, in either family.
let owned = match source {
IpAddr::V6(addr) => addr == peer.overlay,
IpAddr::V4(addr) => {
let held = match peer.overlay_v4.lock() {
Ok(guard) => *guard,
Err(poisoned) => *poisoned.into_inner(),
};
held == Some(addr)
}
};
if !owned {
peer.counters
.dropped_wrong_source
.fetch_add(1, Ordering::Relaxed);
break;
}
if inner.tun.send(payload).await.is_ok() {
peer.counters.rx_packets.fetch_add(1, Ordering::Relaxed);
peer.counters
.rx_bytes
.fetch_add(len as u64, Ordering::Relaxed);
}
inner
.sink
.deliver(inner.network, peer.endpoint_id, payload)
.await;
peer.counters.rx_packets.fetch_add(1, Ordering::Relaxed);
peer.counters
.rx_bytes
.fetch_add(len as u64, Ordering::Relaxed);
break;
}
Outcome::Failed => {
@@ -662,8 +584,8 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
}
enum Outcome {
ToNetwork(usize),
ToTunnel(usize, IpAddr),
ToNetwork(usize, FlowId),
ToTunnel(usize),
Done,
Failed,
}
@@ -697,3 +619,73 @@ async fn drive_timers(inner: Arc<Inner>) {
}
}
}
#[cfg(test)]
mod flow_tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn tunnel(ours: &WgSecretKey, theirs: &WgSecretKey) -> FlowTunnel {
FlowTunnel {
tunn: Tunn::new(
ours.to_static_secret(),
theirs.public().into_x25519(),
None,
None,
1,
None,
),
queued: VecDeque::new(),
}
}
fn wire(result: TunnResult<'_>) -> Vec<u8> {
match result {
TunnResult::WriteToNetwork(bytes) => bytes.to_vec(),
_ => panic!("expected WireGuard frame"),
}
}
#[test]
fn flow_tags_survive_pending_queue_overflow_handshake_and_encryption() {
let ak = WgSecretKey::from_bytes(&[1; 32]);
let bk = WgSecretKey::from_bytes(&[2; 32]);
let mut a = tunnel(&ak, &bk);
let mut b = tunnel(&bk, &ak);
let mut scratch = [0u8; SCRATCH];
let mut packet = [0u8; 40];
packet[0] = 0x45;
packet[2..4].copy_from_slice(&40u16.to_be_bytes());
packet[9] = 6;
packet[12..20].copy_from_slice(&[10, 0, 0, 1, 10, 0, 0, 2]);
let (result, flow) = a.encapsulate(&packet, &mut scratch, 100);
assert_eq!(
flow, 0,
"handshake does not masquerade as application traffic"
);
let hello = wire(result);
for flow in 101..400 {
let _ = a.encapsulate(&packet, &mut scratch, flow);
}
assert_eq!(a.queued.len(), 256);
let response = wire(b.decapsulate(&hello, &mut scratch).0);
let keepalive = wire(a.decapsulate(&response, &mut scratch).0);
let _ = b.decapsulate(&keepalive, &mut scratch);
for expected in 100..356 {
let (result, flow) = a.decapsulate(&[], &mut scratch);
assert_eq!(flow, expected);
let ciphertext = wire(result);
match b.decapsulate(&ciphertext, &mut scratch).0 {
TunnResult::WriteToTunnelV4(bytes, _) => assert_eq!(bytes, packet),
_ => panic!("queued IP packet must decrypt"),
}
}
assert!(a.queued.is_empty());
assert!(matches!(
a.decapsulate(&[], &mut scratch).0,
TunnResult::Done
));
let (result, flow) = a.encapsulate(&packet, &mut scratch, 12345);
assert!(is_data(&result));
assert_eq!(flow, 12345);
}
}
@@ -11,7 +11,7 @@ use boringtun::x25519;
use data_encoding::BASE64;
use zeroize::{Zeroize, Zeroizing};
use crate::dataplane::PluginError;
use tsunagi::dataplane::PluginError;
/// Length of a raw WireGuard key, in bytes.
pub const KEY_LEN: usize = 32;
+50
View File
@@ -0,0 +1,50 @@
//! The `wg-quic` protocol: WireGuard's cryptography in iroh's QUIC datagrams.
//!
//! A crate of its own, so the line between a protocol and the system level
//! is drawn by the compiler rather than by discipline: nothing here can
//! reach into `tsunagi` beyond what it makes public. It also carries its own
//! version, which is **not** the version peers compare — that is
//! [`ANNOUNCEMENT_VERSION`], and it moves only when the bytes on the wire
//! do, so two peers on different releases keep working.
//!
//! That it uses iroh is a convenience, not a requirement of the design: the
//! system level already has an iroh endpoint that crosses NAT, and plain
//! WireGuard is blocked on some networks where this is not.
//!
//! Where the line falls:
//!
//! * **It knows nothing about reachability.** It is handed a
//! [`PacketLink`](tsunagi::dataplane::transport::PacketLink) per peer and
//! moves datagrams over it. Hole punching and relaying are the transport's.
//! * **It knows nothing about addresses, and owns no interface.** One agent
//! has one interface at the system level, and every protocol carries
//! traffic for the same addresses on it. A packet arrives here already
//! routed and leaves here already decrypted, to be checked against the
//! signed claim by the level that holds those.
//! * **Its announcement says who, not where.** A public key and the network
//! it is for, so there is nothing about reachability to lie about.
//! * **The core never parses that announcement.** It moves a bounded opaque
//! blob; only [`announcement`] reads it.
//! * **Its keys are its own.** One per network, in its own store, unrelated
//! to the iroh device key and to the network secret — which it never sees.
//! * **WireGuard's own crypto is untouched.** The handshake and encryption
//! run end to end between the two ends of a tunnel, via [`boringtun`]'s
//! state machine in this process: no kernel module, no `wg` tool, the same
//! code on every platform.
//!
//! See `docs/wireguard.md` for the full picture.
pub mod announcement;
pub mod device;
pub mod keys;
pub mod plugin;
pub mod store;
pub use announcement::{ANNOUNCEMENT_VERSION, ValidatedAnnouncement, WgAnnouncement};
pub use device::{PeerHealth, PeerStats, PeerSummary, WireguardDevice};
pub use keys::{WgPublicKey, WgSecretKey};
pub use plugin::{
DEFAULT_MTU, MIN_MTU, NetworkOverview, PeerOverview, WIREGUARD_OVERHEAD, WIREGUARD_PROTOCOL,
WireguardConfig, WireguardPlugin,
};
pub use store::WgKeyStore;
@@ -1,4 +1,4 @@
//! The WireGuard IP plugin.
//! The WireGuard protocol plugin.
//!
//! Each agent builds its own view of the overlay from the set of participants
//! the control plane agreed on. For a full mesh of `N` members that is `N - 1`
@@ -8,24 +8,24 @@
//! # What this plugin does and does not know
//!
//! * It does **not** know where a peer is. It is handed a
//! [`PacketLink`](crate::dataplane::transport::PacketLink) per peer and runs
//! [`PacketLink`](tsunagi::dataplane::transport::PacketLink) per peer and runs
//! a WireGuard tunnel over it. Reachability, hole punching and relaying are
//! the transport's problem.
//! * It does **not** know which addresses anybody holds, and owns no
//! interface. One agent has one interface, at the system level, and every
//! protocol carries traffic for the same addresses on it. A packet arrives
//! here already routed and leaves here already decrypted.
//! * It owns one WireGuard key per network, in its own store, unrelated to the
//! iroh device key and to the network secret.
//! * It owns one packet interface per network, named deterministically.
//! * It never touches an interface it did not create, and never changes
//! routing, DNS or firewall settings beyond its own device.
//!
//! WireGuard runs in userspace via [`boringtun`], so there is no kernel module
//! and no `wg` tool to depend on. The only privileged step is creating the
//! packet interface, and even that is behind [`TunFactory`] so the whole data
//! plane can run unprivileged in tests.
//! and no `wg` tool to depend on, and nothing this plugin does needs
//! privileges: creating the interface is somebody else's job now.
//!
//! A failure here is reported and retried. It never stops the control plane.
use std::collections::{BTreeSet, HashMap};
use std::net::{IpAddr, Ipv4Addr};
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
@@ -34,43 +34,46 @@ use iroh::EndpointId;
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;
use crate::BoxFuture;
use crate::dataplane::transport::SharedLink;
use crate::dataplane::{IpPlugin, PluginCapability, PluginContext, PluginError};
use crate::identity::NetworkId;
use tsunagi::BoxFuture;
use tsunagi::dataplane::PacketSink;
use tsunagi::dataplane::transport::SharedLink;
use tsunagi::dataplane::{IpPlugin, PluginCapability, PluginContext, PluginError};
use tsunagi::identity::NetworkId;
use super::announcement::{ValidatedAnnouncement, WgAnnouncement};
use super::config::{DEFAULT_INTERFACE_PREFIX, interface_name};
use super::device::{PeerSummary, WireguardDevice};
use super::keys::{WgPublicKey, WgSecretKey};
use super::overlay::{OVERLAY_PREFIX_LEN, overlay_address, overlay_prefix};
use super::store::WgKeyStore;
use super::tun::{TunFactory, TunRequest};
use crate::state::Ipv4Range;
use crate::announcement::{ValidatedAnnouncement, WgAnnouncement};
use crate::device::{PeerSummary, WireguardDevice};
use crate::keys::{WgPublicKey, WgSecretKey};
use crate::store::WgKeyStore;
use tsunagi::state::Ipv4Range;
use tsunagi::task::{TASK_GRACE, wind_down};
/// The protocol identifier this plugin announces.
pub const WIREGUARD_PROTOCOL: &str = "wireguard";
/// Smallest interface MTU IPv6 permits, from RFC 8200.
/// The protocol id of this plugin.
///
/// This is not advice, it is a hard limit. Linux tears IPv6 down entirely on
/// an interface whose MTU is below it — the per-device `/proc/sys/net/ipv6`
/// entries disappear and `ip -6 address add` fails with `Invalid argument` —
/// so the overlay address could never be assigned. Anything smaller is
/// rejected up front instead of failing obscurely later.
pub const MIN_MTU: u32 = 1280;
/// `wg-quic`, because that is what it is: WireGuard's cryptography carried
/// in QUIC datagrams. The name is on the wire, so it is a protocol name and
/// not a description of the implementation.
pub const WIREGUARD_PROTOCOL: &str = "wg-quic";
/// Smallest interface MTU the overlay accepts.
///
/// 576 bytes is what IPv4 guarantees every host can reassemble (RFC 1122),
/// so nothing below it is worth offering. The current overlay is IPv4.
/// Lowering this is not needed to accommodate a narrow QUIC path: the default
/// transport fragments opaque payloads below the plugin.
pub const MIN_MTU: u32 = 576;
/// Default interface MTU.
///
/// Equal to [`MIN_MTU`], because the overlay is IPv6 and there is no room
/// below it.
pub const DEFAULT_MTU: u32 = MIN_MTU;
/// Kept stable across path changes. The default transport splits encrypted
/// packets when the current QUIC path cannot carry them in one datagram.
pub const DEFAULT_MTU: u32 = 1280;
/// Bytes WireGuard adds to a packet: type and reserved, receiver index,
/// counter and the Poly1305 tag.
///
/// A link therefore has to carry `mtu + WIREGUARD_OVERHEAD` bytes in one
/// datagram for a full-size packet to get through.
/// A link must carry `mtu + WIREGUARD_OVERHEAD` logical payload bytes.
/// Transport fragmentation is independent of WireGuard and the inner IP flags.
pub const WIREGUARD_OVERHEAD: u32 = 32;
/// Configuration of the WireGuard plugin.
@@ -78,14 +81,13 @@ pub const WIREGUARD_OVERHEAD: u32 = 32;
pub struct WireguardConfig {
/// Directory for the plugin's own key store. Separate from agent state.
pub state_dir: PathBuf,
/// Prefix of the interface names this plugin creates.
///
/// Two agents on one host in the same network need different prefixes,
/// because the rest of the name is derived from the network id.
pub interface_prefix: String,
/// WireGuard keepalive, which keeps tunnels and their links warm.
pub keepalive: Option<u16>,
/// Interface MTU. See [`DEFAULT_MTU`].
/// The largest packet a tunnel will carry.
///
/// Not the interface MTU, which belongs to the agent: this is what this
/// protocol is configured to carry. The transport may split ciphertext
/// into smaller datagrams without changing the original IP packet.
pub mtu: u32,
/// How long to coalesce changes before reconciling.
pub reconcile_debounce: Duration,
@@ -99,7 +101,6 @@ impl WireguardConfig {
pub fn new(state_dir: impl Into<PathBuf>) -> Self {
Self {
state_dir: state_dir.into(),
interface_prefix: DEFAULT_INTERFACE_PREFIX.to_string(),
keepalive: Some(25),
mtu: DEFAULT_MTU,
reconcile_debounce: Duration::from_millis(200),
@@ -107,12 +108,6 @@ impl WireguardConfig {
}
}
/// Sets the interface name prefix.
pub fn with_interface_prefix(mut self, prefix: impl Into<String>) -> Self {
self.interface_prefix = prefix.into();
self
}
/// Sets the interface MTU.
///
/// Validated when the plugin is opened; see [`MIN_MTU`].
@@ -139,30 +134,16 @@ impl WireguardConfig {
pub struct NetworkOverview {
/// The network.
pub network: NetworkId,
/// Packet interface this plugin created for it.
pub interface: String,
/// Interface MTU.
/// The largest packet a tunnel in this network will carry.
pub mtu: u32,
/// This agent's WireGuard public key in this network.
pub public_key: WgPublicKey,
/// This agent's overlay address.
pub overlay_address: IpAddr,
/// The overlay subnet every member shares.
pub overlay_prefix: IpAddr,
/// Prefix length of the overlay subnet.
pub overlay_prefix_len: u8,
/// This agent's IPv4 overlay address, when the overlay is dual stack.
/// This agent's overlay address, once the network has agreed one.
pub overlay_address_v4: Option<Ipv4Addr>,
/// The IPv4 overlay range in use.
pub ipv4_range: Option<Ipv4Range>,
/// Peers this agent knows about.
pub peers: Vec<PeerOverview>,
/// Unicast packets the operating system sent to an address no peer owns.
pub unroutable_packets: u64,
/// Multicast packets dropped. Expected, not a fault.
pub multicast_packets: u64,
/// One destination nobody owned, if there was one.
pub unroutable_sample: Option<IpAddr>,
}
impl NetworkOverview {
@@ -179,9 +160,7 @@ pub struct PeerOverview {
pub endpoint_id: EndpointId,
/// The peer's WireGuard public key.
pub public_key: WgPublicKey,
/// The overlay address derived for it locally.
pub overlay_address: IpAddr,
/// Its IPv4 overlay address, once a tunnel exists and it won the address.
/// The overlay address the network agreed it holds.
pub overlay_address_v4: Option<Ipv4Addr>,
/// Whether a data plane link to it exists.
pub has_link: bool,
@@ -201,7 +180,6 @@ impl PeerOverview {
#[derive(Debug)]
struct NetworkState {
key: WgSecretKey,
interface: String,
device: Option<Arc<WireguardDevice>>,
announcements: HashMap<EndpointId, ValidatedAnnouncement>,
links: HashMap<EndpointId, SharedLink>,
@@ -209,14 +187,6 @@ struct NetworkState {
allocations: HashMap<EndpointId, Ipv4Addr>,
/// The range those allocations came from.
ipv4_range: Option<Ipv4Range>,
/// The address last reported as missing, so it is said once, not forever.
reported_missing_v4: Option<Ipv4Addr>,
/// What was last applied to the host interface.
///
/// The overlay IPv4 address is allocated at run time and can change while
/// the agent runs, so the interface has to be brought back in line
/// without being recreated — recreating it would drop every tunnel.
applied: Option<TunRequest>,
}
#[derive(Debug, Default)]
@@ -241,16 +211,16 @@ struct Worker {
config: WireguardConfig,
/// This agent's endpoint id, learned when the plugin is attached.
local_id: OnceLock<EndpointId>,
tun_factory: Arc<dyn TunFactory>,
store: WgKeyStore,
shared: Mutex<Shared>,
context: OnceLock<PluginContext>,
/// Where decrypted packets go, once the agent has attached one.
sink: OnceLock<Arc<dyn PacketSink>>,
}
impl std::fmt::Debug for Worker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Worker")
.field("tun", &self.tun_factory.name())
.field("store", &self.store.path())
.finish()
}
@@ -265,22 +235,65 @@ pub struct WireguardPlugin {
}
impl WireguardPlugin {
/// The settings this protocol accepts.
pub const OPTIONS: &'static [tsunagi::dataplane::ProtocolOption] = &[
tsunagi::dataplane::ProtocolOption {
key: "keepalive",
value: "SECONDS",
help: "keeps a tunnel and its link warm through a NAT; 0 turns it off",
default: Some("25"),
},
tsunagi::dataplane::ProtocolOption {
key: "mtu",
value: "BYTES",
help: "largest packet a tunnel will carry, at least 576",
default: Some("1280"),
},
];
/// Applies `key=value` settings to a configuration.
///
/// An unknown key is refused rather than ignored: a setting that was
/// silently dropped looks exactly like one that did not work.
pub fn configure(
mut config: WireguardConfig,
options: &[(String, String)],
) -> Result<WireguardConfig, PluginError> {
for (key, value) in options {
match key.as_str() {
"keepalive" => {
let seconds: u16 = value.parse().map_err(|_| {
PluginError::Other(format!("keepalive={value} is not a number of seconds"))
})?;
config.keepalive = (seconds > 0).then_some(seconds);
}
"mtu" => {
let mtu: u32 = value.parse().map_err(|_| {
PluginError::Other(format!("mtu={value} is not a number of bytes"))
})?;
config = config.with_mtu(mtu);
}
other => {
let known: Vec<&str> = Self::OPTIONS.iter().map(|spec| spec.key).collect();
return Err(PluginError::Other(format!(
"`{other}` is not a setting of {WIREGUARD_PROTOCOL}; it takes {}",
known.join(", ")
)));
}
}
}
Ok(config)
}
/// Opens the plugin's key store and starts its reconciliation task.
///
/// Must be called from inside a tokio runtime; the plugin starts no
/// runtime of its own.
pub async fn open(
config: WireguardConfig,
tun_factory: Arc<dyn TunFactory>,
) -> Result<Arc<Self>, PluginError> {
// Validate the prefix once, here, rather than failing per network.
interface_name(&config.interface_prefix, NetworkId::from_bytes([0u8; 32]))?;
pub async fn open(config: WireguardConfig) -> Result<Arc<Self>, PluginError> {
if config.mtu < MIN_MTU {
return Err(PluginError::Other(format!(
"an MTU of {} is below the {MIN_MTU} bytes IPv6 requires (RFC 8200). \
Linux disables IPv6 on an interface below that, so the overlay address \
could never be assigned.",
"an MTU of {} is below the {MIN_MTU} bytes every IPv4 host must be able \
to reassemble (RFC 1122)",
config.mtu
)));
}
@@ -293,10 +306,10 @@ impl WireguardPlugin {
let worker = Arc::new(Worker {
config,
local_id: OnceLock::new(),
tun_factory,
store,
shared: Mutex::new(Shared::default()),
context: OnceLock::new(),
sink: OnceLock::new(),
});
let (commands, receiver) = mpsc::channel(64);
@@ -332,7 +345,6 @@ impl WireguardPlugin {
.map(|(endpoint_id, announcement)| PeerOverview {
endpoint_id: *endpoint_id,
public_key: announcement.public_key,
overlay_address: IpAddr::V6(announcement.overlay_address),
overlay_address_v4: state.allocations.get(endpoint_id).copied(),
has_link: state.links.contains_key(endpoint_id),
tunnel: tunnels.get(&announcement.public_key).cloned(),
@@ -342,29 +354,11 @@ impl WireguardPlugin {
Some(NetworkOverview {
network,
interface: state.interface.clone(),
mtu: self.worker.config.mtu,
public_key: state.key.public(),
overlay_address: IpAddr::V6(overlay_address(network, &state.key.public())),
overlay_prefix: IpAddr::V6(overlay_prefix(network)),
overlay_prefix_len: OVERLAY_PREFIX_LEN,
overlay_address_v4: state.allocations.get(&self.worker.local_id()).copied(),
ipv4_range: state.ipv4_range,
peers,
unroutable_packets: state
.device
.as_ref()
.map(|device| device.unroutable_packets())
.unwrap_or(0),
multicast_packets: state
.device
.as_ref()
.map(|device| device.multicast_packets())
.unwrap_or(0),
unroutable_sample: state
.device
.as_ref()
.and_then(|device| device.unroutable_sample()),
})
}
@@ -433,7 +427,6 @@ impl Worker {
return Ok(false);
}
let name = interface_name(&self.config.interface_prefix, network)?;
let worker = Arc::clone(self);
let key = tokio::task::spawn_blocking(move || worker.store.load_or_create(network))
.await
@@ -443,14 +436,11 @@ impl Worker {
let mut shared = self.lock_shared();
shared.networks.entry(network).or_insert(NetworkState {
key,
interface: name,
device: None,
announcements: HashMap::new(),
links: HashMap::new(),
allocations: HashMap::new(),
ipv4_range: None,
reported_missing_v4: None,
applied: None,
});
}
@@ -463,79 +453,35 @@ impl Worker {
Ok(true)
}
/// What the host interface for a network should look like.
fn desired_request(&self, state: &NetworkState, network: NetworkId) -> TunRequest {
let own_range = state.ipv4_range;
TunRequest {
name: state.interface.clone(),
address: overlay_address(network, &state.key.public()),
prefix_len: OVERLAY_PREFIX_LEN,
address_v4: state.allocations.get(&self.local_id()).copied(),
prefix_len_v4: own_range.map_or(0, |range| range.prefix_len),
mtu: self.config.mtu,
}
}
/// Creates the packet interface and starts the WireGuard device.
/// Starts this network's tunnels.
///
/// No interface is created: one agent has one, it belongs to the system
/// level, and decrypted packets are handed there rather than written
/// out.
async fn ensure_device(&self, network: NetworkId) -> Result<(), PluginError> {
let (request, key, own_range) = {
let key = {
let shared = self.lock_shared();
match shared.networks.get(&network) {
Some(state) if state.device.is_none() => (
self.desired_request(state, network),
state.key.clone(),
state.ipv4_range,
),
Some(state) if state.device.is_none() => state.key.clone(),
_ => return Ok(()),
}
};
let applied = request.clone();
let tun = self.tun_factory.create(request).await?;
let device = Arc::new(WireguardDevice::start(network, key, tun, own_range));
let sink = match self.sink.get() {
Some(sink) => Arc::clone(sink),
// Not attached to an agent: the protocol still runs, and its
// packets have nowhere to go.
None => Arc::new(tsunagi::dataplane::DiscardPackets) as Arc<dyn PacketSink>,
};
let device = Arc::new(WireguardDevice::start(network, key, sink));
let mut shared = self.lock_shared();
if let Some(state) = shared.networks.get_mut(&network) {
state.interface = device.interface().to_string();
state.device = Some(device);
state.applied = Some(applied);
}
Ok(())
}
/// Brings a live interface back in line after the overlay changed its
/// mind about this agent's address.
async fn ensure_addresses(&self, network: NetworkId) {
let wanted = {
let shared = self.lock_shared();
match shared.networks.get(&network) {
Some(state) if state.device.is_some() => {
let wanted = self.desired_request(state, network);
if state.applied.as_ref() == Some(&wanted) {
return;
}
wanted
}
_ => return,
}
};
match self.tun_factory.reconfigure(wanted.clone()).await {
Ok(()) => {
let mut shared = self.lock_shared();
if let Some(state) = shared.networks.get_mut(&network) {
state.applied = Some(wanted);
}
}
Err(err) => {
// Not fatal: the tunnels keep running on the addresses that
// are there, and the next reconciliation tries again.
tracing::warn!(%err, "cannot update the overlay interface addresses");
self.report(network, err);
}
}
}
/// Brings the running tunnels in line with what is known.
///
/// A peer gets a tunnel once both halves have arrived: its announcement,
@@ -550,10 +496,6 @@ impl Worker {
};
let allocations = state.allocations.clone();
let own_v4 = state.allocations.get(&self.local_id()).copied();
let interface = state.device.as_ref().map(|_| state.interface.clone());
let range = state.ipv4_range;
let state_reported = state.reported_missing_v4;
let mut wanted: Vec<WgPublicKey> = Vec::new();
let mut too_small: Vec<(usize, usize)> = Vec::new();
for (endpoint_id, announcement) in &state.announcements {
@@ -594,53 +536,15 @@ impl Worker {
}
device.retain_peers(&wanted);
// The agent assigns this address itself, so finding it absent means
// the assignment did not take — something outside removed it, or the
// provisioner reported a success it did not achieve. Left unsaid it
// looks like a broken network: the kernel would send packets with the
// wrong source address and every peer would drop them. So it is
// checked rather than assumed, because the assumption is exactly the
// kind that has been wrong here before.
let missing_v4 = match (own_v4, interface.as_deref(), range) {
(Some(address), Some(interface), Some(range))
if !super::tun::address_is_local(IpAddr::V4(address)) =>
{
let already = state_reported == Some(address);
if let Some(state) = shared.networks.get_mut(&network) {
state.reported_missing_v4 = Some(address);
}
(!already).then_some((address, interface.to_string(), range))
}
_ => {
if let Some(state) = shared.networks.get_mut(&network) {
state.reported_missing_v4 = None;
}
None
}
};
drop(shared);
if let Some((address, interface, range)) = missing_v4 {
self.report(
network,
format!(
"this agent was allocated {address}/{} but the address is not on any \
interface, so IPv4 cannot work: packets would leave with the wrong \
source and every peer would drop them. It should have been assigned \
to `{interface}` automatically; check whether something else removed \
it.",
range.prefix_len
),
);
}
for (available, needed) in too_small {
self.report(
network,
format!(
"this path carries only {available} byte datagrams but a {} byte MTU needs \
{needed}; packets larger than {} bytes will be dropped. Lower the MTU only \
if you can stay at or above {MIN_MTU}, which IPv6 requires.",
{needed}; packets larger than {} bytes will be dropped. The floor is \
{MIN_MTU} bytes, what every IPv4 host must be able to reassemble.",
self.config.mtu,
available.saturating_sub(WIREGUARD_OVERHEAD as usize)
),
@@ -655,11 +559,10 @@ impl Worker {
// kernel to remove an interface this agent created; the explicit
// destroy makes that immediate and definite rather than dependent on
// the last reader letting go.
let removed = self.lock_shared().networks.remove(&network);
if let Some(state) = removed {
drop(state.device);
self.tun_factory.destroy(&state.interface).await;
}
// Dropping the state drops the tunnels, which stops their tasks and
// closes their links. There is no interface to remove: the agent owns
// it, and it outlives any one network.
self.lock_shared().networks.remove(&network);
}
fn known_networks(&self) -> Vec<NetworkId> {
@@ -725,7 +628,6 @@ async fn run(worker: Arc<Worker>, mut commands: mpsc::Receiver<Command>) {
}, if wait_until.is_some() => {
deadline = None;
for network in std::mem::take(&mut pending) {
worker.ensure_addresses(network).await;
worker.sync(network);
}
}
@@ -736,7 +638,6 @@ async fn run(worker: Arc<Worker>, mut commands: mpsc::Receiver<Command>) {
if let Err(err) = worker.ensure_device(network).await {
tracing::debug!(%err, "packet interface still unavailable");
}
worker.ensure_addresses(network).await;
worker.sync(network);
}
}
@@ -749,10 +650,19 @@ impl IpPlugin for WireguardPlugin {
WIREGUARD_PROTOCOL
}
fn protocol_version(&self) -> u16 {
crate::announcement::ANNOUNCEMENT_VERSION
}
fn options(&self) -> &'static [tsunagi::dataplane::ProtocolOption] {
Self::OPTIONS
}
fn attach(&self, context: PluginContext) {
if let Some(local) = context.local_endpoint_id() {
let _ = self.worker.local_id.set(local);
}
let _ = self.worker.sink.set(context.packet_sink());
let _ = self.worker.context.set(context);
}
@@ -777,7 +687,7 @@ impl IpPlugin for WireguardPlugin {
let announcement = WgAnnouncement::new(network, &state.key.public());
Ok(Some(PluginCapability {
protocol: WIREGUARD_PROTOCOL.to_string(),
version: super::announcement::ANNOUNCEMENT_VERSION,
version: crate::announcement::ANNOUNCEMENT_VERSION,
enabled: true,
data: announcement.encode()?,
}))
@@ -845,6 +755,15 @@ impl IpPlugin for WireguardPlugin {
}
}
fn carry(&self, network: NetworkId, peer: EndpointId, packet: bytes::Bytes) -> bool {
let shared = self.worker.lock_shared();
shared
.networks
.get(&network)
.and_then(|state| state.device.as_ref())
.is_some_and(|device| device.carry(peer, &packet))
}
fn on_peer_link(&self, network: NetworkId, peer: EndpointId, link: SharedLink) {
self.nudge(Command::Link {
network,
@@ -873,15 +792,28 @@ impl IpPlugin for WireguardPlugin {
self.nudge(Command::Teardown(network));
}
fn on_network_forgotten(&self, network: NetworkId) {
// The key is this protocol's identity in that network and nothing
// else's. Keeping it after leaving would keep a secret for a
// network this agent is no longer in — and hand back the same
// overlay address on a rejoin that everyone else has moved past.
if let Err(err) = self.worker.store.forget(network) {
tracing::warn!(%err, "cannot remove the WireGuard key of a network we left");
}
}
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
Box::pin(async move {
let (reply_tx, reply_rx) = oneshot::channel();
if self.commands.send(Command::Stop(reply_tx)).await.is_ok() {
let _ = reply_rx.await;
// Bounded here as well as by the agent's grace: that grace
// abandons this future, which would leave the runtime task
// running behind it.
let _ = tokio::time::timeout(TASK_GRACE, reply_rx).await;
}
let task = self.task.lock().ok().and_then(|mut guard| guard.take());
if let Some(task) = task {
let _ = task.await;
wind_down(task, TASK_GRACE, "wg-quic runtime").await;
}
})
}
@@ -17,10 +17,10 @@ use std::sync::Mutex;
use rusqlite::{Connection, OptionalExtension, params};
use crate::dataplane::PluginError;
use crate::identity::NetworkId;
use tsunagi::dataplane::PluginError;
use tsunagi::identity::NetworkId;
use super::keys::{KEY_LEN, WgSecretKey};
use crate::keys::{KEY_LEN, WgSecretKey};
/// Schema version written by this build.
pub const SCHEMA_VERSION: i64 = 1;
@@ -37,14 +37,14 @@ impl WgKeyStore {
pub fn open(path: impl AsRef<Path>) -> Result<Self, PluginError> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
crate::storage::create_dir(parent)
tsunagi::storage::create_dir(parent)
.map_err(|err| PluginError::Other(format!("cannot create {parent:?}: {err}")))?;
}
let existed = path.exists();
let conn = Connection::open(&path).map_err(|err| {
PluginError::Other(format!("cannot open the WireGuard key store: {err}"))
})?;
crate::storage::restrict_path_permissions(&path)
tsunagi::storage::restrict_path_permissions(&path)
.map_err(|err| PluginError::Other(format!("cannot secure the key store: {err}")))?;
conn.busy_timeout(std::time::Duration::from_secs(5))
@@ -169,7 +169,7 @@ mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
fn network(name: &str) -> NetworkId {
NetworkKeys::derive(
@@ -11,29 +11,27 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
use std::time::Duration;
use common::{config_with, network, wait_until};
use tempfile::TempDir;
use tsunagi::dataplane::IpPlugin;
use tsunagi::dataplane::wireguard::{
Cidr, InterfaceState, LinkKind, ManagedTunFactory, MockHost, MockProvisioner, WireguardConfig,
WireguardPlugin,
};
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::NetworkId;
use tsunagi::overlay::{
Cidr, InterfaceState, LinkKind, ManagedTunFactory, MockHost, MockProvisioner,
};
use tsunagi::state::DEFAULT_IPV4_RANGE;
use tsunagi::testing::{config_with, network, wait_until};
use tsunagi::{Agent, NetworkStatus};
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
/// An agent whose overlay interface is applied to a pretend host.
struct HostedAgent {
_dir: TempDir,
agent: Agent,
plugin: Arc<WireguardPlugin>,
_plugin: Arc<WireguardPlugin>,
host: MockHost,
}
@@ -43,12 +41,12 @@ impl HostedAgent {
let provisioner = Arc::new(MockProvisioner::new(host.clone()));
let factory = Arc::new(ManagedTunFactory::new(provisioner));
let config = WireguardConfig::new(dir.path().join("wireguard"))
.with_interface_prefix(tag)
.with_reconcile(Duration::from_millis(20), Duration::from_millis(100));
let plugin = WireguardPlugin::open(config, factory).await.unwrap();
let plugin = WireguardPlugin::open(config).await.unwrap();
let agent = Agent::spawn(
config_with(dir.path(), discovery)
.with_overlay_ipv4_range(Some(DEFAULT_IPV4_RANGE))
.with_interface(factory, tag, 1280)
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
)
.await
@@ -56,17 +54,18 @@ impl HostedAgent {
Self {
_dir: dir,
agent,
plugin,
_plugin: plugin,
host,
}
}
/// The interface name the plugin settled on for a network.
async fn interface(&self, network: NetworkId) -> String {
wait_until("the plugin named its interface", || async {
self.plugin
.overview(network)
.map(|view| view.interface)
/// The one interface this agent owns. Not per network.
async fn interface(&self, _network: NetworkId) -> String {
wait_until("the agent named its interface", || async {
self.agent
.overlay()
.map(|overlay| overlay.interface)
.filter(|name| !name.is_empty())
})
.await
@@ -94,11 +93,12 @@ fn v4(state: &InterfaceState) -> Vec<Ipv4Addr> {
.collect()
}
fn has_v6(state: &InterfaceState) -> bool {
state
.addresses
.iter()
.any(|cidr| matches!(cidr.addr, IpAddr::V6(_)))
/// Whether the overlay address has been put on the interface yet.
///
/// It is allocated and signed at the system level, so it arrives on a later
/// reconciliation than the interface itself rather than with it.
fn addressed(state: &InterfaceState) -> bool {
!state.addresses.is_empty()
}
#[tokio::test]
@@ -111,18 +111,13 @@ async fn an_agent_creates_and_configures_its_own_overlay_interface() {
let interface = agent.interface(network_id).await;
let state = agent
.wait_for_host("the interface to be created", &interface, |state| {
state.filter(|state| !state.addresses.is_empty())
})
.wait_for_host("the interface to be created", &interface, |state| state)
.await;
assert_eq!(state.kind, LinkKind::Tun);
assert!(state.up, "the agent brought the link up itself");
assert_eq!(state.mtu, 1280);
assert!(has_v6(&state), "the derived overlay address is assigned");
// The IPv4 address is allocated at run time, so it arrives on a later
// reconciliation than the interface itself.
let addresses = agent
.wait_for_host("the allocated IPv4 address", &interface, |state| {
state.map(|state| v4(&state)).filter(|v4| !v4.is_empty())
@@ -160,9 +155,14 @@ async fn an_interface_left_by_a_crashed_run_is_replaced_rather_than_tripped_over
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
assert_eq!(agent.interface(network_id).await, interface);
// Waited on directly rather than on "it has some address": the address
// this agent was allocated arrives a reconciliation after the interface
// does, and in between the leftover is still the only one there.
let state = agent
.wait_for_host("the interface to be rebuilt", &interface, |state| {
state.filter(|state| state.attached && has_v6(state))
.wait_for_host("the stale address to be replaced", &interface, |state| {
state.filter(|state| {
state.attached && addressed(state) && !state.addresses.contains(&stale)
})
})
.await;
assert!(
@@ -216,7 +216,10 @@ async fn an_interface_belonging_to_something_else_is_left_alone() {
}
#[tokio::test]
async fn leaving_a_network_removes_the_interface_from_the_host() {
async fn leaving_a_network_takes_its_address_off_the_interface_but_not_the_interface() {
// The interface belongs to the agent, so it outlives any one network:
// another may still be using it. What a network takes with it is its own
// address.
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("provision-cleanup");
let agent = HostedAgent::spawn(&discovery, "tsunx", MockHost::new()).await;
@@ -224,21 +227,25 @@ async fn leaving_a_network_removes_the_interface_from_the_host() {
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
let interface = agent.interface(network_id).await;
agent
.wait_for_host("the interface to exist", &interface, |state| state)
.wait_for_host("the address to be assigned", &interface, |state| {
state.filter(addressed)
})
.await;
agent.agent.deactivate_network(network_id).await.unwrap();
agent
.wait_for_host("the interface to be removed", &interface, |state| {
state.is_none().then_some(())
let state = agent
.wait_for_host("the address to be withdrawn", &interface, |state| {
state.filter(|state| !addressed(state))
})
.await;
assert_eq!(state.kind, LinkKind::Tun, "the interface is still there");
// And it goes when the agent does.
agent.agent.shutdown().await;
assert!(
agent.host.names().is_empty(),
"nothing is left behind: {:?}",
agent.host.names()
);
agent.agent.shutdown().await;
}
@@ -6,22 +6,21 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::sync::Arc;
use std::time::Duration;
use common::{config_with, network, wait_for_peers, wait_until};
use tempfile::TempDir;
use tsunagi::dataplane::IpPlugin;
use tsunagi::dataplane::wireguard::{MemoryTunFactory, WireguardConfig, WireguardPlugin};
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::ipc::unix::{ControlSocket, request_status};
use tsunagi::ipc::{ControlSocket, request_status};
use tsunagi::ipc::{StatusReport, control_socket_path};
use tsunagi::overlay::MemoryTunFactory;
use tsunagi::testing::{config_with, network, wait_for_peers, wait_until};
use tsunagi::{Agent, BoxFuture};
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
/// Builds the report the way the binary does, from the agent plus the plugin.
fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::unix::ReportSource> {
fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::ReportSource> {
Arc::new(move || -> BoxFuture<'static, StatusReport> {
let agent = agent.clone();
let plugin = Arc::clone(&plugin);
@@ -46,17 +45,19 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::u
.collect(),
overlay: plugin.overview(net.network_id).map(|view| {
tsunagi::ipc::OverlayReport {
interface: view.interface.clone(),
mtu: view.mtu,
address: view.overlay_address.to_string(),
prefix: view.overlay_prefix.to_string(),
prefix_len: view.overlay_prefix_len,
// The interface belongs to the agent now.
interface: agent
.overlay()
.map_or_else(String::new, |overlay| overlay.interface),
mtu: agent.overlay().map_or(0, |overlay| overlay.mtu),
address: view.overlay_address_v4.map(|a| a.to_string()),
prefix_len: view.ipv4_range.map_or(0, |range| range.prefix_len),
peers: view
.peers
.iter()
.map(|peer| tsunagi::ipc::OverlayPeerReport {
public_key: peer.public_key.to_string(),
address: peer.overlay_address.to_string(),
address: peer.overlay_address_v4.map(|a| a.to_string()),
handshake_secs_ago: peer
.tunnel
.as_ref()
@@ -81,6 +82,7 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::u
.collect(),
cache_healthy: status.cache_healthy,
networks,
dns: None,
}
})
})
@@ -95,14 +97,14 @@ async fn a_client_sees_the_agent_and_its_overlay() {
let tuns = MemoryTunFactory::new();
let plugin = WireguardPlugin::open(
WireguardConfig::new(dir_a.path().join("wg"))
.with_interface_prefix("tca")
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
Arc::new(tuns),
)
.await
.unwrap();
let agent = Agent::spawn(
config_with(dir_a.path(), &discovery).with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
config_with(dir_a.path(), &discovery)
.with_interface(Arc::new(tuns), "tca0", 1280)
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
)
.await
.unwrap();
@@ -110,14 +112,14 @@ async fn a_client_sees_the_agent_and_its_overlay() {
let dir_b = TempDir::new().unwrap();
let plugin_b = WireguardPlugin::open(
WireguardConfig::new(dir_b.path().join("wg"))
.with_interface_prefix("tcb")
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
Arc::new(MemoryTunFactory::new()),
)
.await
.unwrap();
let agent_b = Agent::spawn(
config_with(dir_b.path(), &discovery).with_plugin(plugin_b.clone() as Arc<dyn IpPlugin>),
config_with(dir_b.path(), &discovery)
.with_interface(Arc::new(MemoryTunFactory::new()), "tcb0", 1280)
.with_plugin(plugin_b.clone() as Arc<dyn IpPlugin>),
)
.await
.unwrap();
@@ -167,7 +169,7 @@ async fn a_client_sees_the_agent_and_its_overlay() {
"the tunnel is up: {:?}",
overlay.peers
);
assert!(!overlay.address.is_empty());
assert!(overlay.address.is_some());
control.shutdown().await;
assert!(!socket_path.exists(), "the socket is removed on shutdown");
@@ -184,7 +186,7 @@ async fn a_leftover_socket_file_is_replaced_but_a_live_one_is_not() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("agent.sock");
let empty: Arc<dyn tsunagi::ipc::unix::ReportSource> =
let empty: Arc<dyn tsunagi::ipc::ReportSource> =
Arc::new(|| -> BoxFuture<'static, StatusReport> {
Box::pin(async { StatusReport::default() })
});
@@ -232,45 +234,172 @@ fn the_socket_path_is_derived_and_short_enough() {
);
}
/// Asking who this device is must not need the directory lock.
///
/// The lock belongs to the one agent allowed to *write* the state. `id` only
/// reads, so making it take the lock would mean the question could never be
/// answered while an agent was running — which is exactly when you want to
/// ask it.
#[cfg(feature = "cli")]
#[tokio::test]
async fn identity_can_be_read_while_an_agent_holds_the_directory() {
let dir = TempDir::new().unwrap();
let discovery = SharedMemoryDiscovery::new();
/// A source that can also leave, the way the binary's one does.
#[derive(Debug)]
struct Control(Agent);
// Hold the directory the way a running agent does.
impl tsunagi::ipc::ReportSource for Control {
fn report(&self) -> BoxFuture<'_, StatusReport> {
Box::pin(async move { StatusReport::default() })
}
fn join(
&self,
name: String,
secret: String,
_broadcast: Option<bool>,
) -> BoxFuture<'_, Result<tsunagi::ipc::JoinedReport, String>> {
Box::pin(async move {
let name = tsunagi::identity::NetworkName::new(&name).map_err(|e| e.to_string())?;
let secret =
tsunagi::identity::NetworkSecret::decode(&secret).map_err(|e| e.to_string())?;
let already = self
.0
.list_networks()
.await
.map_err(|err| err.to_string())?
.iter()
.any(|other| other.name == name);
let network_id = self
.0
.join_network(&name, &secret)
.await
.map_err(|err| err.to_string())?;
Ok(tsunagi::ipc::JoinedReport {
name: name.as_str().to_string(),
network_id: network_id.to_string(),
already_configured: already,
name_shared_with: None,
})
})
}
fn leave(&self, network_id: String) -> BoxFuture<'_, Result<tsunagi::ipc::LeftReport, String>> {
Box::pin(async move {
let wanted: tsunagi::NetworkId = network_id.parse().map_err(|_| "not an id")?;
let name = self
.0
.list_networks()
.await
.map_err(|err| err.to_string())?
.into_iter()
.find(|network| network.network_id == wanted)
.map(|network| network.name.as_str().to_string())
.ok_or("not a network this agent is in")?;
let outcome = self
.0
.leave_network(wanted)
.await
.map_err(|err| err.to_string())?;
Ok(tsunagi::ipc::LeftReport {
name,
announced: outcome.announced,
peers_told: outcome.peers_told as u32,
})
})
}
}
#[tokio::test]
async fn a_client_can_leave_a_network_through_the_running_agent() {
// The release can only be published by the agent that is running, and
// only while its sessions are up, so leaving goes over this socket
// rather than being done behind its back in the state store.
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("control-leave");
let dir = TempDir::new().unwrap();
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
let expected = agent.endpoint_id().to_string();
let other = TempDir::new().unwrap();
let peer = Agent::spawn(config_with(other.path(), &discovery))
.await
.unwrap();
let network_id = agent.join_network(&name, &secret).await.unwrap();
peer.join_network(&name, &secret).await.unwrap();
wait_for_peers(&agent, network_id, 1).await;
let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.arg("id")
// The same layout `StoragePaths::under` gives the agent above.
.arg("--state-dir")
.arg(dir.path().join("state"))
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.output()
let socket_path = dir.path().join("control.sock");
let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone())))
.await
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"`id` failed while an agent was running:\n{stderr}"
);
assert!(
stdout.contains(&expected),
"expected {expected} in:\n{stdout}"
);
let report = tsunagi::ipc::leave_network(&socket_path, &network_id.to_string())
.await
.unwrap();
assert_eq!(report.name, name.as_str());
assert!(report.announced);
assert_eq!(report.peers_told, 1);
assert!(agent.list_networks().await.unwrap().is_empty());
// Asking again names the state it is in rather than failing obscurely.
let err = tsunagi::ipc::leave_network(&socket_path, &network_id.to_string())
.await
.unwrap_err();
assert!(err.to_string().contains("not a network"), "{err}");
control.shutdown().await;
agent.shutdown().await;
peer.shutdown().await;
}
#[tokio::test]
async fn a_client_can_add_a_network_to_a_running_agent() {
// One agent per identity, and it may be in several networks at once —
// but the state directory belongs to that one live agent, so a second
// `up` cannot add a network to it. Without this there was no way at
// all: you could leave a network while running, but not join one.
let discovery = SharedMemoryDiscovery::new();
let (first, first_secret) = network("control-join-one");
let (second, second_secret) = network("control-join-two");
let dir = TempDir::new().unwrap();
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
agent.join_network(&first, &first_secret).await.unwrap();
let socket_path = dir.path().join("control.sock");
let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone())))
.await
.unwrap();
let report = tsunagi::ipc::join_network(
&socket_path,
second.as_str(),
second_secret.encode().as_str(),
)
.await
.unwrap();
assert_eq!(report.name, second.as_str());
assert!(!report.already_configured);
// Running, not merely written down: it is in the agent's own list and
// answering for status straight away, on the same identity.
let joined: tsunagi::NetworkId = report.network_id.parse().unwrap();
assert!(agent.is_active(joined).await);
assert_eq!(agent.list_networks().await.unwrap().len(), 2);
assert!(agent.network_status(joined).await.is_ok());
// Joining the same one again is not an error, and says which it was.
let again = tsunagi::ipc::join_network(
&socket_path,
second.as_str(),
second_secret.encode().as_str(),
)
.await
.unwrap();
assert!(again.already_configured);
assert_eq!(again.network_id, report.network_id);
// A secret that is not one is refused rather than stored.
let err = tsunagi::ipc::join_network(&socket_path, "rubbish", "not-a-secret")
.await
.unwrap_err();
assert!(!err.to_string().is_empty());
assert_eq!(agent.list_networks().await.unwrap().len(), 2);
control.shutdown().await;
agent.shutdown().await;
}
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "tsunagi-wireguard"
version = "0.1.0"
description = "Pure WireGuard direct UDP transport and protocol plugin for tsunagi mesh networks."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0" }
iroh.workspace = true
tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macros"] }
serde.workspace = true
postcard.workspace = true
bytes.workspace = true
thiserror.workspace = true
tracing.workspace = true
data-encoding.workspace = true
hex.workspace = true
rusqlite = { version = "0.40", features = ["bundled"] }
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
rand = "0.10"
boringtun = { version = "0.7.1", default-features = false }
netdev = "0.46"
[dev-dependencies]
tsunagi = { path = "../tsunagi", features = ["testing"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
[lints]
workspace = true
@@ -0,0 +1,142 @@
//! What a pure WireGuard peer tells the network about itself.
//!
//! Exchanged via [`tsunagi::dataplane::PluginCapability`]. The announcement
//! carries the WireGuard public key, the listening UDP port, and candidate IP addresses
//! for direct UDP packet transport.
use serde::{Deserialize, Serialize};
use tsunagi::dataplane::PluginError;
use tsunagi::identity::NetworkId;
use crate::keys::WgPublicKey;
/// The wire protocol identifier for pure WireGuard.
pub const WG_PROTOCOL: &str = "wg";
/// Wire version of the `wg` announcement format.
pub const ANNOUNCEMENT_VERSION: u16 = 1;
/// Wire version peers compare during capability exchange.
///
/// This is the same as [`ANNOUNCEMENT_VERSION`]; keeping a separate name
/// makes the intent explicit in the CLI protocol registry.
pub const WG_WIRE_VERSION: u16 = ANNOUNCEMENT_VERSION;
/// Maximum number of IP candidates allowed in an announcement.
pub const MAX_CANDIDATE_ADDRS: usize = 32;
/// What one participant advertises for the pure WireGuard UDP data plane.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WgAnnouncement {
/// Announcement format version.
pub version: u16,
/// The peer's WireGuard public key.
pub public_key: [u8; 32],
/// The network this key is for.
pub network: [u8; 32],
/// The UDP port the peer is listening on for WireGuard packets.
pub port: u16,
/// Candidate IP addresses where this peer can be reached on `port`.
#[serde(default)]
pub addrs: Vec<std::net::IpAddr>,
}
/// A peer announcement that has been validated against a specific network.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidatedAnnouncement {
/// The peer's WireGuard public key.
pub public_key: WgPublicKey,
/// The UDP port the peer listens on.
pub port: u16,
/// Validated candidate IP addresses.
pub addrs: Vec<std::net::IpAddr>,
}
impl WgAnnouncement {
/// Builds this agent's announcement.
pub fn new(
network: NetworkId,
public_key: &WgPublicKey,
port: u16,
addrs: Vec<std::net::IpAddr>,
) -> Self {
Self {
version: ANNOUNCEMENT_VERSION,
public_key: *public_key.as_bytes(),
network: *network.as_bytes(),
port,
addrs,
}
}
/// Encodes the announcement into the opaque capability payload.
pub fn encode(&self) -> Result<Vec<u8>, PluginError> {
postcard::to_allocvec(self)
.map_err(|err| PluginError::Other(format!("cannot encode announcement: {err}")))
}
/// Decodes and validates a payload received from a peer.
pub fn decode_and_validate(
payload: &[u8],
network: NetworkId,
local_key: &WgPublicKey,
) -> Result<ValidatedAnnouncement, PluginError> {
let announcement: Self = postcard::from_bytes(payload)
.map_err(|_| PluginError::Rejected("malformed WireGuard announcement".into()))?;
announcement.validate(network, local_key)
}
fn validate(
self,
network: NetworkId,
local_key: &WgPublicKey,
) -> Result<ValidatedAnnouncement, PluginError> {
if self.version != ANNOUNCEMENT_VERSION {
return Err(PluginError::Rejected(format!(
"peer speaks WireGuard announcement version {} but this build speaks \
{ANNOUNCEMENT_VERSION}; one of the two needs updating",
self.version
)));
}
let public_key = WgPublicKey::from_bytes(self.public_key);
if public_key.is_zero() {
return Err(PluginError::Rejected(
"WireGuard public key is all zeroes".into(),
));
}
if &public_key == local_key {
return Err(PluginError::Rejected(
"peer announced this agent's own WireGuard key".into(),
));
}
if self.network != *network.as_bytes() {
return Err(PluginError::Rejected(
"announcement is for a different network".into(),
));
}
if self.port == 0 {
return Err(PluginError::Rejected("WireGuard port cannot be 0".into()));
}
if self.addrs.len() > MAX_CANDIDATE_ADDRS {
return Err(PluginError::Rejected(format!(
"too many candidate addresses: {} > {MAX_CANDIDATE_ADDRS}",
self.addrs.len()
)));
}
let mut valid_addrs = Vec::new();
for addr in self.addrs {
if !addr.is_unspecified() && !valid_addrs.contains(&addr) {
valid_addrs.push(addr);
}
}
Ok(ValidatedAnnouncement {
public_key,
port: self.port,
addrs: valid_addrs,
})
}
}
+686
View File
@@ -0,0 +1,686 @@
//! Userspace WireGuard device implementation over direct UDP links.
//!
//! Uses [`boringtun::noise::Tunn`] state machine to encrypt and decrypt
//! standard WireGuard protocol packets directly over a [`SharedLink`].
use std::collections::{HashMap, VecDeque};
use std::net::Ipv4Addr;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
use boringtun::noise::{Tunn, TunnResult};
use bytes::Bytes;
use iroh::EndpointId;
use tokio::task::JoinHandle;
use tsunagi::dataplane::routing::{FlowId, flow::ip_flow};
use tsunagi::dataplane::transport::{SharedLink, TransportError};
use tsunagi::dataplane::{PacketSink, PluginContext, PluginError};
use tsunagi::identity::NetworkId;
use crate::keys::{WgPublicKey, WgSecretKey};
use crate::transport::WireguardTransport;
const TIMER_INTERVAL: Duration = Duration::from_millis(250);
const SCRATCH: usize = 4096;
/// Maximum consecutive rekey timeout retransmissions before declaring a tunnel dead.
pub const MAX_REKEY_TIMEOUTS: u32 = 3;
/// Overall maximum duration trying to establish or rekey a handshake before declaring it dead.
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15);
struct FlowTunnel {
tunn: Tunn,
queued: VecDeque<FlowId>,
handshake_started_at: Option<std::time::Instant>,
rekey_timeouts: u32,
failed: bool,
}
fn is_data(result: &TunnResult<'_>) -> bool {
matches!(result, TunnResult::WriteToNetwork(bytes) if bytes.starts_with(&[4, 0, 0, 0]))
}
impl FlowTunnel {
fn encapsulate<'a>(
&mut self,
packet: &[u8],
scratch: &'a mut [u8],
flow: FlowId,
) -> (TunnResult<'a>, FlowId) {
let result = self.tunn.encapsulate(packet, scratch);
if is_data(&result) {
return (result, flow);
}
if matches!(result, TunnResult::WriteToNetwork(ref b) if !b.is_empty() && b[0] == 1)
&& self.handshake_started_at.is_none()
{
self.handshake_started_at = Some(std::time::Instant::now());
self.rekey_timeouts = 0;
self.failed = false;
}
if self.queued.len() < 256 {
self.queued.push_back(flow);
}
(result, 0)
}
fn decapsulate<'a>(
&mut self,
packet: &[u8],
scratch: &'a mut [u8],
) -> (TunnResult<'a>, FlowId) {
let result = self.tunn.decapsulate(None, packet, scratch);
if self.tunn.time_since_last_handshake().is_some() {
self.handshake_started_at = None;
self.rekey_timeouts = 0;
self.failed = false;
}
let mut flow = 0;
if packet.is_empty() {
if is_data(&result) {
flow = self.queued.pop_front().unwrap_or(0);
} else if let Some(pending) = self.queued.pop_front() {
self.queued.push_back(pending);
}
}
(result, flow)
}
fn update_timers<'a>(&mut self, scratch: &'a mut [u8]) -> (TunnResult<'a>, bool) {
if self.failed {
return (TunnResult::Done, false);
}
let result = self.tunn.update_timers(scratch);
if matches!(
result,
TunnResult::Err(boringtun::noise::errors::WireGuardError::ConnectionExpired)
) || self.tunn.is_expired()
{
self.failed = true;
self.queued.clear();
self.handshake_started_at = None;
self.rekey_timeouts = 0;
return (result, true);
}
if let TunnResult::WriteToNetwork(ref bytes) = result
&& !bytes.is_empty()
&& bytes[0] == 1
&& self.handshake_started_at.is_none()
{
self.handshake_started_at = Some(std::time::Instant::now());
}
if let Some(started) = self.handshake_started_at
&& started.elapsed() >= HANDSHAKE_TIMEOUT
{
self.failed = true;
self.queued.clear();
self.handshake_started_at = None;
self.rekey_timeouts = 0;
return (result, true);
}
(result, false)
}
}
#[derive(Debug, Default)]
struct PeerCounters {
tx_packets: AtomicU64,
tx_bytes: AtomicU64,
rx_packets: AtomicU64,
rx_bytes: AtomicU64,
dropped_wrong_source: AtomicU64,
dropped_oversize: AtomicU64,
dropped_no_session: AtomicU64,
protocol_errors: AtomicU64,
}
/// A snapshot of one peer's tunnel counters.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PeerStats {
/// Plaintext packets encrypted and sent to this peer.
pub tx_packets: u64,
/// Plaintext bytes encrypted and sent to this peer.
pub tx_bytes: u64,
/// Plaintext packets decrypted from this peer and given to the OS.
pub rx_packets: u64,
/// Plaintext bytes decrypted from this peer and given to the OS.
pub rx_bytes: u64,
/// Packets dropped because their source was not this peer's address.
pub dropped_wrong_source: u64,
/// Packets dropped because they did not fit in one link datagram.
pub dropped_oversize: u64,
/// Packets dropped because there was no session to encrypt with yet.
pub dropped_no_session: u64,
/// WireGuard protocol errors, including packets that failed to decrypt.
pub protocol_errors: u64,
}
/// Whether a peer's tunnel has completed a handshake.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PeerHealth {
/// Time since the last successful WireGuard handshake.
pub since_handshake: Option<Duration>,
}
impl PeerHealth {
/// Whether the tunnel has ever completed a handshake.
pub fn is_up(&self) -> bool {
self.since_handshake.is_some()
}
}
struct Peer {
endpoint_id: EndpointId,
public_key: WgPublicKey,
overlay_v4: Mutex<Option<Ipv4Addr>>,
tunn: Mutex<FlowTunnel>,
link: SharedLink,
counters: Arc<PeerCounters>,
task: Mutex<Option<JoinHandle<()>>>,
}
impl std::fmt::Debug for Peer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Peer")
.field("peer", &self.endpoint_id.fmt_short().to_string())
.field("public_key", &self.public_key)
.finish()
}
}
impl Drop for Peer {
fn drop(&mut self) {
if let Ok(mut guard) = self.task.lock()
&& let Some(task) = guard.take()
{
task.abort();
}
}
}
impl Peer {
fn stats(&self) -> PeerStats {
PeerStats {
tx_packets: self.counters.tx_packets.load(Ordering::Relaxed),
tx_bytes: self.counters.tx_bytes.load(Ordering::Relaxed),
rx_packets: self.counters.rx_packets.load(Ordering::Relaxed),
rx_bytes: self.counters.rx_bytes.load(Ordering::Relaxed),
dropped_wrong_source: self.counters.dropped_wrong_source.load(Ordering::Relaxed),
dropped_oversize: self.counters.dropped_oversize.load(Ordering::Relaxed),
dropped_no_session: self.counters.dropped_no_session.load(Ordering::Relaxed),
protocol_errors: self.counters.protocol_errors.load(Ordering::Relaxed),
}
}
fn health(&self) -> PeerHealth {
let guard = match self.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
PeerHealth {
since_handshake: guard.tunn.time_since_last_handshake(),
}
}
}
/// What a peer's tunnel looks like from outside.
#[derive(Debug, Clone)]
pub struct PeerSummary {
/// The peer's control plane identity.
pub endpoint_id: EndpointId,
/// The peer's WireGuard public key.
pub public_key: WgPublicKey,
/// The overlay address it holds, once agreed.
pub overlay_address_v4: Option<Ipv4Addr>,
/// Whether the tunnel has handshaken.
pub health: PeerHealth,
/// Traffic counters.
pub stats: PeerStats,
/// What the transport reports about the path carrying this tunnel.
pub path: String,
/// Largest datagram the link accepts.
pub max_datagram: usize,
}
struct Inner {
network: NetworkId,
private_key: WgSecretKey,
peers: RwLock<HashMap<WgPublicKey, Arc<Peer>>>,
sink: Arc<dyn PacketSink>,
transport: Arc<WireguardTransport>,
context: PluginContext,
next_index: AtomicU32,
}
impl Inner {
fn on_tunnel_failed(&self, peer: EndpointId, public_key: WgPublicKey) {
self.transport.close_link(self.network, peer);
write_lock(&self.peers).remove(&public_key);
self.context.report_peer_protocol_failure(
self.network,
peer,
crate::announcement::WG_PROTOCOL,
"WireGuard rekey timeout",
);
}
}
impl std::fmt::Debug for Inner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Inner")
.field("network", &self.network.fmt_short())
.finish()
}
}
/// The userspace WireGuard tunnels of one network.
#[derive(Debug)]
pub struct WireguardDevice {
inner: Arc<Inner>,
tasks: Vec<JoinHandle<()>>,
}
impl WireguardDevice {
/// Starts the WireGuard tunnels for one network.
pub fn start(
network: NetworkId,
private_key: WgSecretKey,
sink: Arc<dyn PacketSink>,
transport: Arc<WireguardTransport>,
context: PluginContext,
) -> Self {
let inner = Arc::new(Inner {
network,
private_key,
peers: RwLock::new(HashMap::new()),
sink,
transport,
context,
next_index: AtomicU32::new(1),
});
let timers = tokio::spawn(drive_timers(Arc::clone(&inner)));
Self {
inner,
tasks: vec![timers],
}
}
/// Encrypts an IP packet and sends it to a peer over its direct link.
pub fn carry(&self, peer: EndpointId, packet: &[u8]) -> bool {
let found = read_lock(&self.inner.peers)
.values()
.find(|candidate| candidate.endpoint_id == peer)
.cloned();
let Some(peer) = found else {
return false;
};
let mut scratch = [0u8; SCRATCH];
let flow = ip_flow(packet);
let result = {
let mut tunn = match peer.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let (result, selected_flow) = tunn.encapsulate(packet, &mut scratch, flow);
match result {
TunnResult::WriteToNetwork(out) => Some((out.len(), selected_flow)),
_ => None,
}
};
let Some((len, selected_flow)) = result else {
peer.counters
.dropped_no_session
.fetch_add(1, Ordering::Relaxed);
return false;
};
send_to_peer_flow(&peer, &scratch[..len], selected_flow);
peer.counters.tx_packets.fetch_add(1, Ordering::Relaxed);
peer.counters
.tx_bytes
.fetch_add(packet.len() as u64, Ordering::Relaxed);
true
}
/// Adds or replaces a peer and starts its tunnel.
pub fn add_peer(
&self,
endpoint_id: EndpointId,
public_key: WgPublicKey,
overlay_v4: Option<Ipv4Addr>,
link: SharedLink,
keepalive: Option<u16>,
) -> Result<(), PluginError> {
if public_key == self.inner.private_key.public() {
return Err(PluginError::Rejected(
"refusing to add ourselves as a WireGuard peer".into(),
));
}
let index = self.inner.next_index.fetch_add(1, Ordering::Relaxed);
let tunn = Tunn::new(
self.inner.private_key.to_static_secret(),
public_key.into_x25519(),
None,
keepalive,
index,
None,
);
let peer = Arc::new(Peer {
endpoint_id,
public_key,
overlay_v4: Mutex::new(overlay_v4),
tunn: Mutex::new(FlowTunnel {
tunn,
queued: VecDeque::new(),
handshake_started_at: None,
rekey_timeouts: 0,
failed: false,
}),
link,
counters: Arc::new(PeerCounters::default()),
task: Mutex::new(None),
});
let task = tokio::spawn(read_from_link(Arc::clone(&self.inner), Arc::clone(&peer)));
if let Ok(mut guard) = peer.task.lock() {
*guard = Some(task);
}
write_lock(&self.inner.peers).insert(public_key, Arc::clone(&peer));
kick_handshake(&peer);
Ok(())
}
/// Removes a peer and stops its tunnel.
pub fn remove_peer(&self, public_key: &WgPublicKey) {
write_lock(&self.inner.peers).remove(public_key);
}
/// Removes every peer whose key is not in `keep`.
pub fn retain_peers(&self, keep: &[WgPublicKey]) {
let stale: Vec<WgPublicKey> = read_lock(&self.inner.peers)
.keys()
.filter(|key| !keep.contains(key))
.copied()
.collect();
for key in stale {
self.remove_peer(&key);
}
}
/// Whether a peer's tunnel exists.
pub fn has_peer(&self, public_key: &WgPublicKey) -> bool {
read_lock(&self.inner.peers).contains_key(public_key)
}
/// A snapshot of every peer.
pub fn peers(&self) -> Vec<PeerSummary> {
let mut peers: Vec<PeerSummary> = read_lock(&self.inner.peers)
.values()
.map(|peer| {
let overlay_v4 = match peer.overlay_v4.lock() {
Ok(guard) => *guard,
Err(poisoned) => *poisoned.into_inner(),
};
PeerSummary {
endpoint_id: peer.endpoint_id,
public_key: peer.public_key,
overlay_address_v4: overlay_v4,
health: peer.health(),
stats: peer.stats(),
path: peer.link.path_description(),
max_datagram: peer.link.max_datagram_size(),
}
})
.collect();
peers.sort_by_key(|peer| peer.public_key);
peers
}
}
impl Drop for WireguardDevice {
fn drop(&mut self) {
for task in &self.tasks {
task.abort();
}
}
}
fn read_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
match lock.read() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn write_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
match lock.write() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn kick_handshake(peer: &Peer) {
let mut scratch = vec![0u8; SCRATCH];
let len = {
let mut tunn = match peer.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
match tunn.tunn.format_handshake_initiation(&mut scratch, false) {
TunnResult::WriteToNetwork(out) => {
tunn.handshake_started_at = Some(std::time::Instant::now());
tunn.rekey_timeouts = 0;
tunn.failed = false;
Some(out.len())
}
_ => None,
}
};
if let Some(len) = len {
send_to_peer(peer, &scratch[..len]);
}
}
fn send_to_peer(peer: &Peer, payload: &[u8]) {
send_to_peer_flow(peer, payload, 0);
}
fn send_to_peer_flow(peer: &Peer, payload: &[u8], flow: FlowId) {
match peer.link.send_flow(Bytes::copy_from_slice(payload), flow) {
Ok(()) => {}
Err(TransportError::TooLarge { .. }) => {
peer.counters
.dropped_oversize
.fetch_add(1, Ordering::Relaxed);
}
Err(TransportError::Closed) => {}
Err(err) => {
tracing::trace!(%err, "dropping a WireGuard packet the link refused");
}
}
}
async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
let mut scratch = vec![0u8; SCRATCH];
loop {
let Some(datagram) = peer.link.recv().await else {
return;
};
let mut input: Option<&[u8]> = Some(&datagram);
loop {
let outcome = {
let mut tunn = match peer.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let (result, flow) = tunn.decapsulate(input.unwrap_or(&[]), &mut scratch);
match result {
TunnResult::WriteToNetwork(out) => Outcome::ToNetwork(out.len(), flow),
TunnResult::WriteToTunnelV6(out, _) => Outcome::ToTunnel(out.len()),
TunnResult::WriteToTunnelV4(out, _) => Outcome::ToTunnel(out.len()),
TunnResult::Done => Outcome::Done,
TunnResult::Err(err) => {
tracing::trace!(?err, "WireGuard decapsulation failed");
Outcome::Failed
}
}
};
match outcome {
Outcome::ToNetwork(len, flow) => {
send_to_peer_flow(&peer, &scratch[..len], flow);
input = None;
continue;
}
Outcome::ToTunnel(len) => {
let payload = Bytes::copy_from_slice(&scratch[..len]);
inner
.sink
.deliver(inner.network, peer.endpoint_id, payload)
.await;
peer.counters.rx_packets.fetch_add(1, Ordering::Relaxed);
peer.counters
.rx_bytes
.fetch_add(len as u64, Ordering::Relaxed);
break;
}
Outcome::Failed => {
peer.counters
.protocol_errors
.fetch_add(1, Ordering::Relaxed);
break;
}
Outcome::Done => break,
}
}
}
}
enum Outcome {
ToNetwork(usize, FlowId),
ToTunnel(usize),
Done,
Failed,
}
async fn drive_timers(inner: Arc<Inner>) {
let mut ticker = tokio::time::interval(TIMER_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let peers: Vec<Arc<Peer>> = read_lock(&inner.peers).values().cloned().collect();
for peer in peers {
let mut scratch = vec![0u8; SCRATCH];
let (len, failed) = {
let mut tunn = match peer.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let (result, failed) = tunn.update_timers(&mut scratch);
let len = match result {
TunnResult::WriteToNetwork(out) => Some(out.len()),
TunnResult::Err(err) => {
tracing::trace!(?err, "WireGuard timer error");
None
}
_ => None,
};
(len, failed)
};
if let Some(len) = len {
send_to_peer(&peer, &scratch[..len]);
}
if failed {
tracing::warn!(
peer = %peer.endpoint_id.fmt_short(),
"WireGuard tunnel failed due to rekey timeout, triggering fallback"
);
inner.on_tunnel_failed(peer.endpoint_id, peer.public_key);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_flow_tunnel_rekey_timeout_triggers_failure() {
let my_secret = WgSecretKey::generate();
let their_secret = WgSecretKey::generate();
let their_public = their_secret.public();
let tunn = Tunn::new(
my_secret.to_static_secret(),
their_public.into_x25519(),
None,
None,
1,
None,
);
let mut tunnel = FlowTunnel {
tunn,
queued: VecDeque::new(),
handshake_started_at: Some(std::time::Instant::now() - Duration::from_secs(16)),
rekey_timeouts: 0,
failed: false,
};
let mut scratch = vec![0u8; SCRATCH];
// When handshake timeout exceeds HANDSHAKE_TIMEOUT (15s), update_timers returns true (failed)
let (_, failed) = tunnel.update_timers(&mut scratch);
assert!(failed, "handshake timeout should report failure");
assert!(tunnel.handshake_started_at.is_none());
assert!(tunnel.failed, "tunnel should be marked as failed");
// Subsequent timer tick must not report failure again
let (_, failed_again) = tunnel.update_timers(&mut scratch);
assert!(
!failed_again,
"subsequent timer tick should not re-trigger failure"
);
}
#[test]
fn test_flow_tunnel_reset_on_handshake() {
let my_secret = WgSecretKey::generate();
let their_secret = WgSecretKey::generate();
let their_public = their_secret.public();
let tunn = Tunn::new(
my_secret.to_static_secret(),
their_public.into_x25519(),
None,
None,
1,
None,
);
let mut tunnel = FlowTunnel {
tunn,
queued: VecDeque::new(),
handshake_started_at: Some(std::time::Instant::now()),
rekey_timeouts: 2,
failed: true,
};
let mut scratch = vec![0u8; SCRATCH];
let _ = tunnel.decapsulate(&[], &mut scratch);
// decapsulate without handshake response doesn't establish session
assert_eq!(tunnel.rekey_timeouts, 2);
}
}
+160
View File
@@ -0,0 +1,160 @@
//! WireGuard key material for pure WireGuard transport.
//!
//! Keys belong to this protocol plugin alone. They are not derived from
//! the iroh device key and not derived from the network secret, so compromising
//! or rotating one does not affect the others.
//!
//! Keys are X25519, encoded the way WireGuard encodes them: standard base64
//! with padding, 44 characters.
use boringtun::x25519;
use data_encoding::BASE64;
use zeroize::{Zeroize, Zeroizing};
use tsunagi::dataplane::PluginError;
/// Length of a raw WireGuard key in bytes.
pub const KEY_LEN: usize = 32;
/// Length of the base64 text form of a key.
pub const KEY_TEXT_LEN: usize = 44;
/// Applies the X25519 clamping WireGuard applies to private keys.
fn clamp(bytes: &mut [u8; KEY_LEN]) {
bytes[0] &= 248;
bytes[31] &= 127;
bytes[31] |= 64;
}
/// A WireGuard public key.
///
/// Public, safe to log, and the identity a peer is known by inside the overlay.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WgPublicKey([u8; KEY_LEN]);
impl WgPublicKey {
/// Wraps raw key bytes.
pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
Self(bytes)
}
/// The raw key bytes.
pub fn as_bytes(&self) -> &[u8; KEY_LEN] {
&self.0
}
/// The key in the form the WireGuard implementation expects.
pub(crate) fn into_x25519(self) -> x25519::PublicKey {
x25519::PublicKey::from(self.0)
}
/// Whether this is the all-zero key, which is never a valid peer.
pub fn is_zero(&self) -> bool {
self.0 == [0u8; KEY_LEN]
}
/// The base64 form WireGuard uses.
pub fn encode(&self) -> String {
BASE64.encode(&self.0)
}
/// Parses the base64 form WireGuard uses.
pub fn decode(text: &str) -> Result<Self, PluginError> {
if text.len() != KEY_TEXT_LEN {
return Err(PluginError::Rejected(format!(
"a WireGuard key is {KEY_TEXT_LEN} base64 characters, got {}",
text.len()
)));
}
let raw = BASE64
.decode(text.as_bytes())
.map_err(|_| PluginError::Rejected("key is not valid base64".into()))?;
let bytes = <[u8; KEY_LEN]>::try_from(raw.as_slice())
.map_err(|_| PluginError::Rejected("key is not 32 bytes".into()))?;
Ok(Self(bytes))
}
/// A short prefix for logs and diagnostics.
pub fn fmt_short(&self) -> String {
self.encode().chars().take(8).collect()
}
}
impl std::fmt::Display for WgPublicKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.encode())
}
}
impl std::fmt::Debug for WgPublicKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "WgPublicKey({})", self.fmt_short())
}
}
/// A WireGuard private key.
///
/// Clamped on creation so it matches what `wg genkey` produces. Zeroized on
/// drop, and prints as `[redacted]` in Debug so it cannot leak into logs.
#[derive(Clone)]
pub struct WgSecretKey {
bytes: Zeroizing<[u8; KEY_LEN]>,
public: WgPublicKey,
}
impl WgSecretKey {
/// Generates a fresh clamped random private key.
pub fn generate() -> Self {
let mut bytes = [0u8; KEY_LEN];
rand::fill(&mut bytes);
clamp(&mut bytes);
let secret = x25519::StaticSecret::from(bytes);
let public = WgPublicKey(*x25519::PublicKey::from(&secret).as_bytes());
Self {
bytes: Zeroizing::new(bytes),
public,
}
}
/// Wraps existing key bytes, clamping them to a valid X25519 private key.
pub fn from_bytes(raw: &[u8; KEY_LEN]) -> Self {
let mut bytes = *raw;
clamp(&mut bytes);
let secret = x25519::StaticSecret::from(bytes);
let public = WgPublicKey(*x25519::PublicKey::from(&secret).as_bytes());
Self {
bytes: Zeroizing::new(bytes),
public,
}
}
/// The matching public key.
pub fn public(&self) -> WgPublicKey {
self.public
}
/// The key in the form the WireGuard implementation expects.
pub(crate) fn to_static_secret(&self) -> x25519::StaticSecret {
x25519::StaticSecret::from(*self.bytes)
}
/// The raw key bytes.
pub fn as_bytes(&self) -> &[u8; KEY_LEN] {
&self.bytes
}
}
impl Drop for WgSecretKey {
fn drop(&mut self) {
self.bytes.zeroize();
}
}
impl std::fmt::Debug for WgSecretKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WgSecretKey")
.field("bytes", &"[redacted]")
.field("public", &self.public)
.finish()
}
}
+28
View File
@@ -0,0 +1,28 @@
//! The pure WireGuard protocol plugin for Tsunagi: WireGuard cryptography over direct UDP.
//!
//! Deliberately a separate crate from the core, ensuring clear boundaries:
//! - Direct UDP datagram transport without QUIC encapsulation or double-encryption.
//! - Standard WireGuard cryptography via `boringtun`.
//! - Peer discovery and key exchange managed through the Tsunagi control plane.
#![deny(unsafe_code)]
#![warn(missing_docs)]
pub mod announcement;
pub mod device;
pub mod keys;
pub mod plugin;
pub mod store;
pub mod transport;
pub use announcement::{
ANNOUNCEMENT_VERSION, ValidatedAnnouncement, WG_PROTOCOL, WG_WIRE_VERSION, WgAnnouncement,
};
pub use device::{PeerHealth, PeerStats, PeerSummary, WireguardDevice};
pub use keys::{WgPublicKey, WgSecretKey};
pub use plugin::{
DEFAULT_MTU, MIN_MTU, NetworkOverview, PeerOverview, WIREGUARD_OVERHEAD, WireguardConfig,
WireguardPlugin, local_ip_candidates,
};
pub use store::WgKeyStore;
pub use transport::{DEFAULT_PORT, MAX_UDP_DATAGRAM_SIZE, WireguardLink, WireguardTransport};
File diff suppressed because it is too large. Load diff
+160
View File
@@ -0,0 +1,160 @@
//! The plugin's own key store.
//!
//! Deliberately a separate SQLite file from the agent's `state.sqlite`: plugin
//! keys are not the iroh identity and not the network secret, and their
//! lifecycle is the plugin's business alone.
//!
//! One key per network, so a participant presents a different WireGuard
//! identity in each network it belongs to.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use rusqlite::{Connection, OptionalExtension, params};
use tsunagi::dataplane::PluginError;
use tsunagi::identity::NetworkId;
use crate::keys::{KEY_LEN, WgSecretKey};
/// Schema version written by this build.
pub const SCHEMA_VERSION: i64 = 1;
/// Per-network WireGuard private keys.
#[derive(Debug)]
pub struct WgKeyStore {
conn: Mutex<Connection>,
path: PathBuf,
}
impl WgKeyStore {
/// Opens, creating the file and its directory if needed.
pub fn open(path: impl AsRef<Path>) -> Result<Self, PluginError> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
tsunagi::storage::create_dir(parent)
.map_err(|err| PluginError::Other(format!("cannot create {parent:?}: {err}")))?;
}
let existed = path.exists();
let conn = Connection::open(&path).map_err(|err| {
PluginError::Other(format!("cannot open the WireGuard key store: {err}"))
})?;
tsunagi::storage::restrict_path_permissions(&path)
.map_err(|err| PluginError::Other(format!("cannot secure the key store: {err}")))?;
conn.busy_timeout(std::time::Duration::from_secs(5))
.and_then(|()| conn.pragma_update(None, "journal_mode", "WAL"))
.and_then(|()| conn.pragma_update(None, "synchronous", "NORMAL"))
.map_err(|err| PluginError::Other(format!("cannot configure the key store: {err}")))?;
if existed {
let integrity: String = conn
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
.map_err(|err| {
PluginError::Other(format!("WireGuard key store is unusable: {err}"))
})?;
if integrity != "ok" {
return Err(PluginError::Other(format!(
"WireGuard key store at {} is corrupt and will not be recreated: {integrity}",
path.display()
)));
}
}
let found: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.map_err(|err| PluginError::Other(format!("cannot read the schema version: {err}")))?;
if found > SCHEMA_VERSION {
return Err(PluginError::Other(format!(
"WireGuard key store schema {found} is newer than {SCHEMA_VERSION}"
)));
}
if found < SCHEMA_VERSION {
conn.execute_batch(
"BEGIN;
CREATE TABLE IF NOT EXISTS network_keys (
network_id BLOB PRIMARY KEY,
secret BLOB NOT NULL,
created_at INTEGER NOT NULL
);
PRAGMA user_version = 1;
COMMIT;",
)
.map_err(|err| PluginError::Other(format!("cannot create the schema: {err}")))?;
}
Ok(Self {
conn: Mutex::new(conn),
path,
})
}
/// Path of the underlying file.
pub fn path(&self) -> &Path {
&self.path
}
/// Loads the stored key for `network`, or generates, stores and returns a fresh one.
pub fn load_or_create(&self, network: NetworkId) -> Result<WgSecretKey, PluginError> {
let guard = self
.conn
.lock()
.map_err(|_| PluginError::Other("key store lock is poisoned".into()))?;
let existing: Option<Vec<u8>> = guard
.query_row(
"SELECT secret FROM network_keys WHERE network_id = ?1",
params![network.as_bytes().as_slice()],
|row| row.get(0),
)
.optional()
.map_err(|err| PluginError::Other(format!("cannot read the key store: {err}")))?;
if let Some(secret) = existing {
let bytes = <[u8; KEY_LEN]>::try_from(secret.as_slice()).map_err(|_| {
PluginError::Other(format!(
"stored WireGuard key for {} is {} bytes, expected {KEY_LEN}",
network.fmt_short(),
secret.len()
))
})?;
return Ok(WgSecretKey::from_bytes(&bytes));
}
let fresh = WgSecretKey::generate();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|err| PluginError::Other(format!("system clock is broken: {err}")))?
.as_secs() as i64;
guard
.execute(
"INSERT INTO network_keys (network_id, secret, created_at) VALUES (?1, ?2, ?3)",
params![
network.as_bytes().as_slice(),
fresh.as_bytes().as_slice(),
now
],
)
.map_err(|err| PluginError::Other(format!("cannot write the key store: {err}")))?;
Ok(fresh)
}
/// Removes the stored key for `network`.
pub fn forget(&self, network: NetworkId) -> Result<(), PluginError> {
let guard = self
.conn
.lock()
.map_err(|_| PluginError::Other("key store lock is poisoned".into()))?;
guard
.execute(
"DELETE FROM network_keys WHERE network_id = ?1",
params![network.as_bytes().as_slice()],
)
.map_err(|err| {
PluginError::Other(format!("cannot delete from the key store: {err}"))
})?;
Ok(())
}
}
+796
View File
@@ -0,0 +1,796 @@
//! Pure WireGuard UDP packet transport and link implementation.
//!
//! Provides [`WireguardTransport`] implementing [`PacketTransport`]
//! and [`WireguardLink`] implementing [`PacketLink`].
use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use bytes::Bytes;
use iroh::EndpointId;
use tokio::net::UdpSocket;
use tokio::sync::{Notify, mpsc, oneshot};
use tsunagi::BoxFuture;
use tsunagi::dataplane::transport::{
InboundLink, PacketLink, PacketTransport, SharedLink, TransportError,
};
use tsunagi::identity::{DeviceIdentity, NetworkId};
/// Default UDP port to bind and listen on for pure WireGuard.
pub const DEFAULT_PORT: u16 = 51820;
/// Maximum payload length for a single framed datagram over UDP.
pub const MAX_UDP_DATAGRAM_SIZE: usize = 65507;
const PROBE_MAGIC: &[u8; 19] = b"TSUNAGI_WG_PROBE_V1";
const PROBE_TYPE_PING: u8 = 1;
const PROBE_TYPE_PONG: u8 = 2;
const PROBE_LEN: usize = 19 + 1 + 32 + 32 + 32 + 8; // 124 bytes
fn encode_probe(
msg_type: u8,
sender: EndpointId,
receiver: EndpointId,
network: NetworkId,
cookie: u64,
) -> Vec<u8> {
let mut buf = Vec::with_capacity(PROBE_LEN);
buf.extend_from_slice(PROBE_MAGIC);
buf.push(msg_type);
buf.extend_from_slice(sender.as_bytes());
buf.extend_from_slice(receiver.as_bytes());
buf.extend_from_slice(network.as_bytes());
buf.extend_from_slice(&cookie.to_be_bytes());
buf
}
struct DecodedProbe {
msg_type: u8,
sender: EndpointId,
receiver: EndpointId,
network: NetworkId,
cookie: u64,
}
fn decode_probe(bytes: &[u8]) -> Option<DecodedProbe> {
if bytes.len() < PROBE_LEN || &bytes[..19] != PROBE_MAGIC {
return None;
}
let msg_type = bytes[19];
if msg_type != PROBE_TYPE_PING && msg_type != PROBE_TYPE_PONG {
return None;
}
let sender = EndpointId::from_bytes(&bytes[20..52].try_into().ok()?).ok()?;
let receiver = EndpointId::from_bytes(&bytes[52..84].try_into().ok()?).ok()?;
let network = NetworkId::from_bytes(bytes[84..116].try_into().ok()?);
let cookie = u64::from_be_bytes(bytes[116..124].try_into().ok()?);
Some(DecodedProbe {
msg_type,
sender,
receiver,
network,
cookie,
})
}
/// An authenticated datagram link carried over direct UDP.
#[derive(Debug)]
pub struct WireguardLink {
network: NetworkId,
peer: EndpointId,
target_addr: Arc<RwLock<SocketAddr>>,
candidates: Arc<RwLock<Vec<SocketAddr>>>,
confirmed: Arc<AtomicBool>,
tx: mpsc::Sender<Bytes>,
inbound_tx: mpsc::Sender<Bytes>,
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
closed_notify: Arc<Notify>,
is_closed: Arc<AtomicBool>,
}
impl WireguardLink {
/// Creates a new `WireguardLink` targeting `target_addr` over `socket`.
pub fn new(
network: NetworkId,
peer: EndpointId,
target_addr: SocketAddr,
candidates: Vec<SocketAddr>,
socket: Arc<UdpSocket>,
) -> Self {
let (tx_out, mut rx_out) = mpsc::channel::<Bytes>(1024);
let (tx_in, rx_in) = mpsc::channel::<Bytes>(1024);
let is_closed = Arc::new(AtomicBool::new(false));
let confirmed = Arc::new(AtomicBool::new(false));
let closed_notify = Arc::new(Notify::new());
let target_holder = Arc::new(RwLock::new(target_addr));
let candidates_holder = Arc::new(RwLock::new(candidates));
let socket_w = Arc::clone(&socket);
let target_r = Arc::clone(&target_holder);
let candidates_r = Arc::clone(&candidates_holder);
let confirmed_r = Arc::clone(&confirmed);
let is_closed_w = Arc::clone(&is_closed);
let closed_notify_w = Arc::clone(&closed_notify);
tokio::spawn(async move {
while let Some(msg) = rx_out.recv().await {
if msg.len() > MAX_UDP_DATAGRAM_SIZE {
continue;
}
// In WireGuard protocol, type 1 is Handshake Initiation (148 bytes).
let is_handshake_init = !msg.is_empty() && msg[0] == 1;
if confirmed_r.load(Ordering::Acquire) && !is_handshake_init {
let target = match target_r.read() {
Ok(g) => *g,
Err(p) => *p.into_inner(),
};
if let Err(err) = socket_w.send_to(&msg, target).await {
tracing::trace!(%err, %target, "failed to send WireGuard UDP datagram");
}
} else {
let targets = match candidates_r.read() {
Ok(g) => g.clone(),
Err(p) => p.into_inner().clone(),
};
if targets.is_empty() {
let target = match target_r.read() {
Ok(g) => *g,
Err(p) => *p.into_inner(),
};
let _ = socket_w.send_to(&msg, target).await;
} else {
for target in targets {
let _ = socket_w.send_to(&msg, target).await;
}
}
}
}
is_closed_w.store(true, Ordering::Release);
closed_notify_w.notify_waiters();
});
Self {
network,
peer,
target_addr: target_holder,
candidates: candidates_holder,
confirmed,
tx: tx_out,
inbound_tx: tx_in,
rx: tokio::sync::Mutex::new(rx_in),
closed_notify,
is_closed,
}
}
/// Delivers an inbound packet received from UDP into this link's receive queue.
pub fn deliver_inbound(&self, payload: Bytes) -> bool {
if self.is_closed.load(Ordering::Acquire) {
return false;
}
self.confirmed.store(true, Ordering::Release);
self.inbound_tx.try_send(payload).is_ok()
}
/// Marks this link as confirmed by a probe response or verified packet.
pub fn confirm(&self) {
self.confirmed.store(true, Ordering::Release);
}
/// Whether this link has had verified two-way direct connectivity.
pub fn is_confirmed(&self) -> bool {
self.confirmed.load(Ordering::Acquire)
}
/// Updates candidate addresses for this peer.
pub fn update_candidates(&self, addrs: impl IntoIterator<Item = SocketAddr>) {
if let Ok(mut guard) = self.candidates.write() {
let new_candidates: Vec<SocketAddr> = addrs
.into_iter()
.filter(|a| !a.ip().is_unspecified())
.collect();
if !new_candidates.is_empty() {
*guard = new_candidates;
}
}
}
/// Updates the target socket address (roaming / NAT hole punch update).
pub fn update_target(&self, new_target: SocketAddr) {
if new_target.ip().is_unspecified() {
return;
}
self.confirmed.store(true, Ordering::Release);
if let Ok(mut guard) = self.target_addr.write()
&& *guard != new_target
{
tracing::debug!(peer = %self.peer.fmt_short(), old = %*guard, new = %new_target, "WireGuard endpoint roamed");
*guard = new_target;
}
}
/// Explicitly closes this link, notifying any pending readers or waiters.
pub fn close(&self) {
if !self.is_closed.swap(true, Ordering::AcqRel) {
self.closed_notify.notify_waiters();
}
}
/// Returns the current target address.
pub fn target_addr(&self) -> SocketAddr {
match self.target_addr.read() {
Ok(g) => *g,
Err(p) => *p.into_inner(),
}
}
}
impl PacketLink for WireguardLink {
fn network(&self) -> NetworkId {
self.network
}
fn peer(&self) -> EndpointId {
self.peer
}
fn max_datagram_size(&self) -> usize {
MAX_UDP_DATAGRAM_SIZE
}
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
if payload.len() > MAX_UDP_DATAGRAM_SIZE {
return Err(TransportError::TooLarge {
size: payload.len(),
limit: MAX_UDP_DATAGRAM_SIZE,
});
}
if self.is_closed.load(Ordering::Acquire) {
return Err(TransportError::Closed);
}
self.tx
.try_send(payload)
.map_err(|_| TransportError::Closed)
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move {
if self.is_closed.load(Ordering::Acquire) {
return None;
}
tokio::select! {
biased;
_ = self.closed_notify.notified() => None,
msg = async {
let mut guard = self.rx.lock().await;
guard.recv().await
} => msg,
}
})
}
fn closed(&self) -> BoxFuture<'_, ()> {
Box::pin(async move {
if self.is_closed.load(Ordering::Acquire) {
return;
}
self.closed_notify.notified().await;
})
}
fn is_closed(&self) -> bool {
self.is_closed.load(Ordering::Acquire)
}
fn path_description(&self) -> String {
format!("wireguard via {}", self.target_addr())
}
}
type InboundCallback = Box<dyn Fn(InboundLink) + Send + Sync + 'static>;
type PendingProbes = Arc<Mutex<HashMap<(NetworkId, EndpointId, u64), oneshot::Sender<SocketAddr>>>>;
/// Direct UDP WireGuard packet transport.
pub struct WireguardTransport {
local_id: EndpointId,
listen_port: u16,
socket: Arc<UdpSocket>,
peer_addresses: RwLock<HashMap<EndpointId, Vec<SocketAddr>>>,
peer_networks: RwLock<HashMap<EndpointId, HashSet<NetworkId>>>,
links: RwLock<HashMap<(NetworkId, EndpointId), Arc<WireguardLink>>>,
inbound_cb: Arc<Mutex<Option<InboundCallback>>>,
pending_probes: PendingProbes,
}
impl std::fmt::Debug for WireguardTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WireguardTransport")
.field("local_id", &self.local_id.fmt_short().to_string())
.field("listen_port", &self.listen_port)
.finish()
}
}
impl WireguardTransport {
/// Binds a UDP socket for pure WireGuard transport.
///
/// Attempts `requested_port` (default 51820). If that fails, falls back to an ephemeral port.
pub async fn bind(
identity: &DeviceIdentity,
requested_port: Option<u16>,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let target_port = requested_port.unwrap_or(DEFAULT_PORT);
let bind_addr = SocketAddr::from(([0, 0, 0, 0], target_port));
let socket = match UdpSocket::bind(bind_addr).await {
Ok(s) => s,
Err(err) if target_port > 0 => {
tracing::warn!(%err, port = target_port, "failed to bind WireGuard UDP port, falling back to ephemeral port");
UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], 0))).await?
}
Err(err) => return Err(Box::new(err)),
};
let bound_port = socket.local_addr()?.port();
tracing::info!(port = bound_port, "WireGuard UDP transport bound");
Ok(Self {
local_id: identity.endpoint_id(),
listen_port: bound_port,
socket: Arc::new(socket),
peer_addresses: RwLock::new(HashMap::new()),
peer_networks: RwLock::new(HashMap::new()),
links: RwLock::new(HashMap::new()),
inbound_cb: Arc::new(Mutex::new(None)),
pending_probes: Arc::new(Mutex::new(HashMap::new())),
})
}
/// The local port this transport is listening on.
pub fn bound_port(&self) -> u16 {
self.listen_port
}
/// Associates a peer with a network ID.
pub fn set_peer_network(&self, peer: EndpointId, network: NetworkId) {
if let Ok(mut guard) = self.peer_networks.write() {
guard.entry(peer).or_default().insert(network);
}
}
/// Records an observed UDP target address for a peer.
pub fn set_peer_addr(&self, peer: EndpointId, addr: SocketAddr) {
if !addr.is_ipv4() || addr.ip().is_unspecified() {
return;
}
if let Ok(mut guard) = self.peer_addresses.write() {
let addrs = guard.entry(peer).or_default();
addrs.retain(|a| *a != addr);
addrs.insert(0, addr);
}
}
/// Records multiple observed UDP target addresses for a peer.
pub fn set_peer_addrs(&self, peer: EndpointId, addrs: impl IntoIterator<Item = SocketAddr>) {
if let Ok(mut guard) = self.peer_addresses.write() {
let entry = guard.entry(peer).or_default();
for addr in addrs {
if addr.is_ipv4() && !addr.ip().is_unspecified() && !entry.contains(&addr) {
entry.push(addr);
}
}
}
}
/// Returns candidate socket addresses known for a peer.
pub fn peer_addrs(&self, peer: EndpointId) -> Vec<SocketAddr> {
let guard = match self.peer_addresses.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.get(&peer).cloned().unwrap_or_default()
}
/// Explicitly closes any active direct link for the given network and peer.
pub fn close_link(&self, network: NetworkId, peer: EndpointId) {
let link = {
let guard = match self.links.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.get(&(network, peer)).cloned()
};
if let Some(link) = link {
link.close();
}
}
/// Notifies the transport that a peer's capability was announced.
///
/// When `we_accept` is true, sends a hole-punch probe to candidate addresses
/// to open stateful NAT firewalls, but does NOT create an unverified direct link
/// in the hub before packets actually flow.
pub fn notify_peer_capability(
&self,
network: NetworkId,
peer: EndpointId,
addrs: &[SocketAddr],
we_accept: bool,
) {
self.set_peer_network(peer, network);
let ipv4_addrs: Vec<SocketAddr> = addrs.iter().copied().filter(|a| a.is_ipv4()).collect();
self.set_peer_addrs(peer, ipv4_addrs.iter().copied());
// Update candidate addresses on any existing link for this peer
if let Ok(guard) = self.links.read()
&& let Some(link) = guard.get(&(network, peer))
{
link.update_candidates(ipv4_addrs.iter().copied());
}
// Send hole punching probe if we accept and have candidate addresses.
if we_accept && !ipv4_addrs.is_empty() {
let socket = Arc::clone(&self.socket);
let local_id = self.local_id;
tokio::spawn(async move {
let cookie: u64 = rand::random();
let probe = encode_probe(PROBE_TYPE_PING, local_id, peer, network, cookie);
for addr in ipv4_addrs {
let _ = socket.send_to(&probe, addr).await;
}
});
}
}
/// Runs the receive loop for incoming UDP datagrams.
pub async fn accept_loop<F>(&self, on_inbound: F)
where
F: Fn(InboundLink) + Send + Sync + 'static,
{
{
let mut guard = match self.inbound_cb.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
*guard = Some(Box::new(on_inbound));
}
let socket = Arc::clone(&self.socket);
let mut buf = [0u8; MAX_UDP_DATAGRAM_SIZE];
while let Ok((len, src_addr)) = socket.recv_from(&mut buf).await {
if src_addr.ip().is_unspecified() {
continue;
}
// 1. Direct UDP probe packet handling
if len >= PROBE_LEN
&& &buf[..19] == PROBE_MAGIC
&& let Some(probe) = decode_probe(&buf[..len])
{
if probe.receiver != self.local_id {
continue;
}
if probe.msg_type == PROBE_TYPE_PING {
let is_known = {
let guard = match self.peer_networks.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard
.get(&probe.sender)
.is_some_and(|nets| nets.contains(&probe.network))
};
if is_known {
let pong = encode_probe(
PROBE_TYPE_PONG,
self.local_id,
probe.sender,
probe.network,
probe.cookie,
);
let _ = socket.send_to(&pong, src_addr).await;
self.set_peer_addr(probe.sender, src_addr);
let link = {
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(existing) = guard.get(&(probe.network, probe.sender))
&& !existing.is_closed()
{
existing.update_target(src_addr);
existing.confirm();
None
} else {
let candidates = self.peer_addrs(probe.sender);
let link = Arc::new(WireguardLink::new(
probe.network,
probe.sender,
src_addr,
candidates,
Arc::clone(&self.socket),
));
link.confirm();
guard.insert((probe.network, probe.sender), Arc::clone(&link));
Some(link)
}
};
if let Some(link) = link {
let cb_guard = match self.inbound_cb.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(ref cb) = *cb_guard {
cb(InboundLink {
network: probe.network,
peer: probe.sender,
protocol: crate::announcement::WG_PROTOCOL.to_string(),
link,
});
}
}
}
continue;
} else if probe.msg_type == PROBE_TYPE_PONG {
self.set_peer_addr(probe.sender, src_addr);
let waiter = {
let mut probes = match self.pending_probes.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
probes.remove(&(probe.network, probe.sender, probe.cookie))
};
if let Some(tx) = waiter {
let _ = tx.send(src_addr);
}
continue;
}
}
// 2. Data / handshake packet handling
let datagram = Bytes::copy_from_slice(&buf[..len]);
// Identify which peer this packet comes from
let matched_peer = {
let links_guard = match self.links.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
links_guard
.values()
.find(|link| link.target_addr() == src_addr)
.map(|link| link.peer())
.or_else(|| {
let addrs_guard = match self.peer_addresses.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
addrs_guard
.iter()
.find(|(_, addrs)| addrs.contains(&src_addr))
.map(|(peer, _)| *peer)
.or_else(|| {
links_guard
.values()
.find(|link| link.target_addr().ip() == src_addr.ip())
.map(|link| link.peer())
.or_else(|| {
addrs_guard
.iter()
.find(|(_, addrs)| {
addrs.iter().any(|a| a.ip() == src_addr.ip())
})
.map(|(peer, _)| *peer)
})
})
})
};
let Some(peer) = matched_peer else {
continue;
};
self.set_peer_addr(peer, src_addr);
// Deliver to ALL active links for this peer (across all networks this peer shares with us)
let peer_links: Vec<Arc<WireguardLink>> = {
let guard = match self.links.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard
.values()
.filter(|l| l.peer() == peer && !l.is_closed())
.cloned()
.collect()
};
if !peer_links.is_empty() {
for link in peer_links {
link.update_target(src_addr);
link.deliver_inbound(datagram.clone());
}
continue;
}
// If no active link exists for this peer yet, create inbound links for all networks known for this peer
let networks: Vec<NetworkId> = {
let guard = match self.peer_networks.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard
.get(&peer)
.map(|s| s.iter().copied().collect())
.unwrap_or_default()
};
for network in networks {
let link = {
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(existing) = guard.get(&(network, peer))
&& !existing.is_closed()
{
existing.update_target(src_addr);
existing.deliver_inbound(datagram.clone());
continue;
}
let candidates = self.peer_addrs(peer);
let link = Arc::new(WireguardLink::new(
network,
peer,
src_addr,
candidates,
Arc::clone(&self.socket),
));
link.update_target(src_addr);
link.deliver_inbound(datagram.clone());
guard.insert((network, peer), Arc::clone(&link));
link
};
let cb_guard = match self.inbound_cb.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(ref cb) = *cb_guard {
cb(InboundLink {
network,
peer,
protocol: crate::announcement::WG_PROTOCOL.to_string(),
link,
});
}
}
}
}
}
impl PacketTransport for WireguardTransport {
fn name(&self) -> &str {
"wireguard"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn open<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
_protocol: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
Box::pin(async move {
let mut addrs = self.peer_addrs(peer);
if addrs.is_empty() {
return Err(TransportError::Unreachable(format!(
"no candidate UDP address known for peer {}",
peer.fmt_short()
)));
}
crate::plugin::prioritize_local_subnet(&mut addrs);
// If an existing confirmed link is alive, reuse it
{
let guard = match self.links.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(existing) = guard.get(&(network, peer))
&& !existing.is_closed()
&& existing.is_confirmed()
{
existing.update_candidates(addrs);
return Ok(Arc::clone(existing) as SharedLink);
}
}
// Probe candidate addresses with PING
let cookie: u64 = rand::random();
let (tx, rx) = oneshot::channel::<SocketAddr>();
{
let mut probes = match self.pending_probes.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
probes.insert((network, peer, cookie), tx);
}
let probe_ping = encode_probe(PROBE_TYPE_PING, self.local_id, peer, network, cookie);
let socket = Arc::clone(&self.socket);
let candidates = addrs.clone();
let probe_task = tokio::spawn(async move {
for i in 0..3 {
for target in &candidates {
let _ = socket.send_to(&probe_ping, *target).await;
}
if i < 2 {
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
}
}
});
let confirmed_addr =
match tokio::time::timeout(std::time::Duration::from_millis(1200), rx).await {
Ok(Ok(addr)) => {
probe_task.abort();
addr
}
_ => {
probe_task.abort();
let mut probes = match self.pending_probes.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
probes.remove(&(network, peer, cookie));
return Err(TransportError::Unreachable(format!(
"direct UDP probe to peer {} timed out",
peer.fmt_short()
)));
}
};
let link = {
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(existing) = guard.get(&(network, peer))
&& !existing.is_closed()
{
existing.update_target(confirmed_addr);
existing.update_candidates(addrs);
existing.confirm();
return Ok(Arc::clone(existing) as SharedLink);
}
let link = Arc::new(WireguardLink::new(
network,
peer,
confirmed_addr,
addrs,
Arc::clone(&self.socket),
));
link.confirm();
guard.insert((network, peer), Arc::clone(&link));
link
};
Ok(link as SharedLink)
})
}
}
@@ -0,0 +1,86 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::IpAddr;
use tsunagi::identity::NetworkId;
use tsunagi_wireguard::{ANNOUNCEMENT_VERSION, WgAnnouncement, WgPublicKey, WgSecretKey};
#[test]
fn announcement_round_trip() {
let network = NetworkId::from_bytes([42u8; 32]);
let key = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
let addrs: Vec<IpAddr> = vec![
"192.168.1.100".parse().unwrap(),
"10.0.0.5".parse().unwrap(),
];
let announcement = WgAnnouncement::new(network, &key, 51820, addrs.clone());
let encoded = announcement.encode().expect("encode");
let validated = WgAnnouncement::decode_and_validate(&encoded, network, &local).expect("decode");
assert_eq!(validated.public_key, key);
assert_eq!(validated.port, 51820);
assert_eq!(validated.addrs, addrs);
}
#[test]
fn announcement_rejects_zero_key() {
let network = NetworkId::from_bytes([42u8; 32]);
let local = WgSecretKey::generate().public();
let zero = WgPublicKey::from_bytes([0u8; 32]);
let announcement = WgAnnouncement::new(network, &zero, 51820, vec![]);
let encoded = announcement.encode().unwrap();
let err = WgAnnouncement::decode_and_validate(&encoded, network, &local).unwrap_err();
assert!(err.to_string().contains("all zeroes"));
}
#[test]
fn announcement_rejects_own_key() {
let network = NetworkId::from_bytes([42u8; 32]);
let local = WgSecretKey::generate().public();
let announcement = WgAnnouncement::new(network, &local, 51820, vec![]);
let encoded = announcement.encode().unwrap();
let err = WgAnnouncement::decode_and_validate(&encoded, network, &local).unwrap_err();
assert!(err.to_string().contains("own WireGuard key"));
}
#[test]
fn announcement_rejects_wrong_network() {
let network_a = NetworkId::from_bytes([1u8; 32]);
let network_b = NetworkId::from_bytes([2u8; 32]);
let key = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
let announcement = WgAnnouncement::new(network_a, &key, 51820, vec![]);
let encoded = announcement.encode().unwrap();
let err = WgAnnouncement::decode_and_validate(&encoded, network_b, &local).unwrap_err();
assert!(err.to_string().contains("different network"));
}
#[test]
fn announcement_rejects_zero_port() {
let network = NetworkId::from_bytes([42u8; 32]);
let key = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
let announcement = WgAnnouncement {
version: ANNOUNCEMENT_VERSION,
public_key: *key.as_bytes(),
network: *network.as_bytes(),
port: 0,
addrs: vec![],
};
let encoded = announcement.encode().unwrap();
let err = WgAnnouncement::decode_and_validate(&encoded, network, &local).unwrap_err();
assert!(err.to_string().contains("cannot be 0"));
}
@@ -0,0 +1,378 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use tokio::sync::mpsc;
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
use tsunagi::dataplane::{IpPlugin, PacketSink, PluginContext};
use tsunagi::identity::{DeviceIdentity, NetworkId};
use tsunagi_wireguard::{WireguardConfig, WireguardPlugin, WireguardTransport};
struct TestSink {
delivered: mpsc::Sender<(NetworkId, iroh::EndpointId, Bytes)>,
}
impl PacketSink for TestSink {
fn deliver(
&self,
network: NetworkId,
from: iroh::EndpointId,
packet: Bytes,
) -> tsunagi::BoxFuture<'_, ()> {
let delivered = self.delivered.clone();
Box::pin(async move {
let _ = delivered.send((network, from, packet)).await;
})
}
}
fn sample_ipv4_packet() -> Bytes {
let mut packet = vec![0u8; 40];
packet[0] = 0x45; // IPv4, IHL 5
packet[2..4].copy_from_slice(&40u16.to_be_bytes()); // total len 40
packet[8] = 64; // TTL
packet[9] = 17; // UDP
packet[12..16].copy_from_slice(&[10, 0, 0, 1]); // src 10.0.0.1
packet[16..20].copy_from_slice(&[10, 0, 0, 2]); // dst 10.0.0.2
Bytes::from(packet)
}
#[tokio::test]
async fn test_wireguard_plugin_end_to_end() {
let _ = tracing_subscriber::fmt()
.with_env_filter("trace")
.with_test_writer()
.try_init();
let identity_a = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([77u8; 32]);
let tmp_a = tempfile::tempdir().expect("tmp A");
let tmp_b = tempfile::tempdir().expect("tmp B");
// Transports
let transport_b = Arc::new(
WireguardTransport::bind(&identity_b, Some(0))
.await
.expect("bind B"),
);
let port_b = transport_b.bound_port();
let transport_a = Arc::new(
WireguardTransport::bind(&identity_a, Some(0))
.await
.expect("bind A"),
);
let port_a = transport_a.bound_port();
// Plugins
let cfg_a = WireguardConfig::new(tmp_a.path());
let plugin_a = WireguardPlugin::open(cfg_a, Arc::clone(&transport_a))
.await
.expect("open plugin A");
let cfg_b = WireguardConfig::new(tmp_b.path());
let plugin_b = WireguardPlugin::open(cfg_b, Arc::clone(&transport_b))
.await
.expect("open plugin B");
// Attach sinks
let (sink_a_tx, mut sink_a_rx) = mpsc::channel(10);
plugin_a.attach(PluginContext::with_sink(Arc::new(TestSink {
delivered: sink_a_tx,
})));
let (sink_b_tx, mut sink_b_rx) = mpsc::channel(10);
plugin_b.attach(PluginContext::with_sink(Arc::new(TestSink {
delivered: sink_b_tx,
})));
// Capability exchange
plugin_a.on_network_activated(network);
plugin_b.on_network_activated(network);
let cap_a = loop {
if let Some(cap) = plugin_a.local_capability(network).expect("cap A") {
break cap;
}
tokio::time::sleep(Duration::from_millis(50)).await;
};
let cap_b = loop {
if let Some(cap) = plugin_b.local_capability(network).expect("cap B") {
break cap;
}
tokio::time::sleep(Duration::from_millis(50)).await;
};
plugin_a
.on_peer_capability(network, peer_b, &cap_b)
.expect("A on cap B");
plugin_b
.on_peer_capability(network, peer_a, &cap_a)
.expect("B on cap A");
// Register loopback address candidates
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
transport_a.set_peer_addr(peer_b, addr_b);
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
transport_b.set_peer_addr(peer_a, addr_a);
// Spawn accept loop on A
let transport_a_clone = Arc::clone(&transport_a);
tokio::spawn(async move {
transport_a_clone.accept_loop(|_| {}).await;
});
// Spawn accept loop on B
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone
.accept_loop(move |inbound| {
let _ = inbound_tx.try_send(inbound);
})
.await;
});
// Open link from A to B
let link_a = tokio::time::timeout(
Duration::from_secs(5),
transport_a.open(network, peer_b, "wg"),
)
.await
.expect("open should not timeout")
.expect("open should succeed");
plugin_a.on_peer_link(network, peer_b, Arc::clone(&link_a));
// Wait for inbound link on B and hand to plugin B
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
.await
.expect("accept should not timeout")
.expect("inbound link should arrive");
plugin_b.on_peer_link(network, peer_a, inbound_b.link);
// Wait briefly for WireGuard handshake to complete
let mut up = false;
for _ in 0..50 {
if let Some(overview_a) = plugin_a.overview(network)
&& overview_a.peers.iter().any(|p| p.is_up())
{
up = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(up, "WireGuard handshake should complete between peers");
// Send IP packet from A to B
let packet_a_to_b = sample_ipv4_packet();
let sent = plugin_a.carry(network, peer_b, packet_a_to_b.clone());
assert!(sent, "plugin A carry should succeed");
let received_b = tokio::time::timeout(Duration::from_secs(5), sink_b_rx.recv())
.await
.expect("recv B timeout")
.expect("recv B option");
assert_eq!(received_b.0, network);
assert_eq!(received_b.1, peer_a);
assert_eq!(received_b.2, packet_a_to_b);
// Send IP packet from B to A
let packet_b_to_a = sample_ipv4_packet();
let sent_reply = plugin_b.carry(network, peer_a, packet_b_to_a.clone());
assert!(sent_reply, "plugin B carry should succeed");
let received_a = tokio::time::timeout(Duration::from_secs(5), sink_a_rx.recv())
.await
.expect("recv A timeout")
.expect("recv A option");
assert_eq!(received_a.0, network);
assert_eq!(received_a.1, peer_b);
assert_eq!(received_a.2, packet_b_to_a);
}
#[tokio::test]
async fn test_wireguard_two_networks_same_peers() {
let identity_a = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let net1 = NetworkId::from_bytes([11u8; 32]);
let net2 = NetworkId::from_bytes([22u8; 32]);
let tmp_a = tempfile::tempdir().expect("tmp A");
let tmp_b = tempfile::tempdir().expect("tmp B");
let transport_b = Arc::new(
WireguardTransport::bind(&identity_b, Some(0))
.await
.expect("bind B"),
);
let port_b = transport_b.bound_port();
let transport_a = Arc::new(
WireguardTransport::bind(&identity_a, Some(0))
.await
.expect("bind A"),
);
let port_a = transport_a.bound_port();
let plugin_a =
WireguardPlugin::open(WireguardConfig::new(tmp_a.path()), Arc::clone(&transport_a))
.await
.expect("open plugin A");
let plugin_b =
WireguardPlugin::open(WireguardConfig::new(tmp_b.path()), Arc::clone(&transport_b))
.await
.expect("open plugin B");
let (sink_a_tx, mut sink_a_rx) = mpsc::channel(20);
plugin_a.attach(PluginContext::with_sink(Arc::new(TestSink {
delivered: sink_a_tx,
})));
let (sink_b_tx, mut sink_b_rx) = mpsc::channel(20);
plugin_b.attach(PluginContext::with_sink(Arc::new(TestSink {
delivered: sink_b_tx,
})));
// Activate both networks
for net in [net1, net2] {
plugin_a.on_network_activated(net);
plugin_b.on_network_activated(net);
}
// Exchange capabilities for both networks
for net in [net1, net2] {
let cap_a = loop {
if let Some(cap) = plugin_a.local_capability(net).expect("cap A") {
break cap;
}
tokio::time::sleep(Duration::from_millis(20)).await;
};
let cap_b = loop {
if let Some(cap) = plugin_b.local_capability(net).expect("cap B") {
break cap;
}
tokio::time::sleep(Duration::from_millis(20)).await;
};
plugin_a
.on_peer_capability(net, peer_b, &cap_b)
.expect("A on cap B");
plugin_b
.on_peer_capability(net, peer_a, &cap_a)
.expect("B on cap A");
}
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
transport_a.set_peer_addr(peer_b, addr_b);
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
transport_b.set_peer_addr(peer_a, addr_a);
// Accept loops
let transport_a_clone = Arc::clone(&transport_a);
tokio::spawn(async move {
transport_a_clone.accept_loop(|_| {}).await;
});
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(10);
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone
.accept_loop(move |inbound| {
let _ = inbound_tx.try_send(inbound);
})
.await;
});
// Open links for both networks from A to B
for net in [net1, net2] {
let link_a = transport_a
.open(net, peer_b, "wg")
.await
.expect("open A link");
plugin_a.on_peer_link(net, peer_b, link_a);
}
// Deliver inbound links to B
for _ in 0..2 {
let inbound = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
.await
.expect("inbound timeout")
.expect("inbound option");
plugin_b.on_peer_link(inbound.network, peer_a, inbound.link);
}
// Wait for both handshakes to complete on both networks
for net in [net1, net2] {
let mut up = false;
for _ in 0..50 {
if let Some(overview) = plugin_a.overview(net)
&& overview.peers.iter().any(|p| p.is_up())
{
up = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(up, "handshake on {net:?} should complete");
}
// Send on net1
let pkt1 = sample_ipv4_packet();
assert!(plugin_a.carry(net1, peer_b, pkt1.clone()));
let recv1 = tokio::time::timeout(Duration::from_secs(5), sink_b_rx.recv())
.await
.expect("recv1 timeout")
.expect("recv1 option");
assert_eq!(recv1.0, net1);
assert_eq!(recv1.2, pkt1);
// Send on net2 from A to B
let mut pkt2_bytes = sample_ipv4_packet().to_vec();
pkt2_bytes[15] = 99; // different byte
let pkt2 = Bytes::from(pkt2_bytes);
assert!(plugin_a.carry(net2, peer_b, pkt2.clone()));
let recv2 = tokio::time::timeout(Duration::from_secs(5), sink_b_rx.recv())
.await
.expect("recv2 timeout")
.expect("recv2 option");
assert_eq!(recv2.0, net2);
assert_eq!(recv2.2, pkt2);
// Send reply on net1 from B to A
assert!(plugin_b.carry(net1, peer_a, pkt1.clone()));
let reply1 = tokio::time::timeout(Duration::from_secs(5), sink_a_rx.recv())
.await
.expect("reply1 timeout")
.expect("reply1 option");
assert_eq!(reply1.0, net1);
assert_eq!(reply1.2, pkt1);
// Send reply on net2 from B to A
assert!(plugin_b.carry(net2, peer_a, pkt2.clone()));
let reply2 = tokio::time::timeout(Duration::from_secs(5), sink_a_rx.recv())
.await
.expect("reply2 timeout")
.expect("reply2 option");
assert_eq!(reply2.0, net2);
assert_eq!(reply2.2, pkt2);
}
@@ -0,0 +1,220 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use tokio::sync::mpsc;
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
use tsunagi::identity::{DeviceIdentity, NetworkId};
use tsunagi_wireguard::WireguardTransport;
#[tokio::test]
async fn test_wireguard_transport_loopback() {
let identity_a = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([42u8; 32]);
// Bind transport B on port 0 (ephemeral)
let transport_b = Arc::new(
WireguardTransport::bind(&identity_b, Some(0))
.await
.expect("bind server should succeed"),
);
let port_b = transport_b.bound_port();
assert!(port_b > 0);
// Bind transport A on port 0 (ephemeral)
let transport_a = Arc::new(
WireguardTransport::bind(&identity_a, Some(0))
.await
.expect("bind client should succeed"),
);
let port_a = transport_a.bound_port();
assert!(port_a > 0);
// Register B's address and network on transport A
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
transport_a.set_peer_addr(peer_b, addr_b);
transport_a.set_peer_network(peer_b, network);
// Register A's address and network on transport B
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
transport_b.set_peer_addr(peer_a, addr_a);
transport_b.set_peer_network(peer_a, network);
// Spawn accept loop on A
let transport_a_clone = Arc::clone(&transport_a);
tokio::spawn(async move {
transport_a_clone.accept_loop(|_| {}).await;
});
// Spawn accept loop on B
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone
.accept_loop(move |inbound| {
let _ = inbound_tx.try_send(inbound);
})
.await;
});
// Open link from A to B
let link_a = tokio::time::timeout(
Duration::from_secs(5),
transport_a.open(network, peer_b, "wg"),
)
.await
.expect("open should not timeout")
.expect("open should succeed");
// Send from A to B to trigger inbound link discovery on B
let data_a_to_b = Bytes::from_static(b"wireguard packet from A to B");
link_a.send(data_a_to_b.clone()).expect("send A->B");
// Receive inbound link on B
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
.await
.expect("accept should not timeout")
.expect("inbound link should arrive");
assert_eq!(inbound_b.network, network);
assert_eq!(inbound_b.peer, peer_a);
assert_eq!(inbound_b.protocol, "wg");
let link_b = inbound_b.link;
// Check packet received on B
let received_on_b = tokio::time::timeout(Duration::from_secs(2), link_b.recv())
.await
.expect("recv B timeout")
.expect("recv B option");
assert_eq!(received_on_b, data_a_to_b);
// Send from B to A
let data_b_to_a = Bytes::from_static(b"wireguard packet from B to A");
link_b.send(data_b_to_a.clone()).expect("send B->A");
let received_on_a = tokio::time::timeout(Duration::from_secs(2), link_a.recv())
.await
.expect("recv A timeout")
.expect("recv A option");
assert_eq!(received_on_a, data_b_to_a);
// Path descriptions
assert!(link_a.path_description().contains("wireguard via"));
assert!(link_b.path_description().contains("wireguard via"));
}
#[tokio::test]
async fn test_wireguard_transport_unreachable_probe_times_out() {
let identity_a = DeviceIdentity::generate();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([99u8; 32]);
let transport_a = Arc::new(
WireguardTransport::bind(&identity_a, Some(0))
.await
.expect("bind client should succeed"),
);
// Register a non-responsive address for peer B (TEST-NET-1 documentation IP which drops packets)
let unreachable_addr: SocketAddr = "192.0.2.1:51820".parse().unwrap();
transport_a.set_peer_addr(peer_b, unreachable_addr);
transport_a.set_peer_network(peer_b, network);
// Spawn accept loop on A
let transport_a_clone = Arc::clone(&transport_a);
tokio::spawn(async move {
transport_a_clone.accept_loop(|_| {}).await;
});
// Opening to unreachable candidate must fail with Unreachable rather than returning a dead direct link
let result = transport_a.open(network, peer_b, "wg").await;
match result {
Err(tsunagi::dataplane::transport::TransportError::Unreachable(msg)) => {
assert!(msg.contains("timed out"));
}
other => panic!("expected Unreachable error, got {other:?}"),
}
}
#[tokio::test]
async fn test_wireguard_link_close_and_transport_close_link() {
let identity_a = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([55u8; 32]);
let transport_b = Arc::new(
WireguardTransport::bind(&identity_b, Some(0))
.await
.unwrap(),
);
let port_b = transport_b.bound_port();
let transport_a = Arc::new(
WireguardTransport::bind(&identity_a, Some(0))
.await
.unwrap(),
);
let port_a = transport_a.bound_port();
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
transport_a.set_peer_addr(peer_b, addr_b);
transport_a.set_peer_network(peer_b, network);
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
transport_b.set_peer_addr(peer_a, addr_a);
transport_b.set_peer_network(peer_a, network);
let transport_a_clone = Arc::clone(&transport_a);
tokio::spawn(async move {
transport_a_clone.accept_loop(|_| {}).await;
});
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone.accept_loop(|_| {}).await;
});
let link_a = transport_a
.open(network, peer_b, "wg")
.await
.expect("open link A");
assert!(!link_a.is_closed());
// Spawn a reader task waiting on recv()
let link_a_clone = Arc::clone(&link_a);
let recv_task = tokio::spawn(async move { link_a_clone.recv().await });
// Explicitly closing via transport.close_link()
transport_a.close_link(network, peer_b);
assert!(link_a.is_closed());
// Wait on closed() notification
tokio::time::timeout(Duration::from_secs(1), link_a.closed())
.await
.expect("link.closed() should resolve promptly");
// recv() must have returned None
let recv_res = tokio::time::timeout(Duration::from_secs(1), recv_task)
.await
.expect("recv task should complete")
.expect("task join");
assert_eq!(recv_res, None);
// Send after close must return Closed error
let send_res = link_a.send(Bytes::from_static(b"after close"));
assert!(matches!(
send_res,
Err(tsunagi::dataplane::transport::TransportError::Closed)
));
}
+110
View File
@@ -0,0 +1,110 @@
[package]
name = "tsunagi"
version = "0.1.0"
description = "Small private mesh networks: persistent agent identity, deterministic network spaces, an iroh control plane, signed state and a local DNS view."
readme = "../../README.md"
keywords = ["mesh", "p2p", "iroh", "networking"]
categories = ["network-programming"]
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[features]
default = []
# A real TUN device, so a plugin can carry actual IP traffic. On Linux it
# needs CAP_NET_ADMIN; on Windows the process must run elevated and
# `wintun.dll` must be reachable. Without either the in-memory device serves
# tests. The platform-specific dependencies are declared per target, so
# enabling this only pulls what the target it is built for actually uses.
tun-device = [
"dep:tun",
"dep:rtnetlink",
"dep:caps",
"dep:futures-util",
]
# Telling the operating system where to send its DNS questions. systemd-resolved
# on Linux, the Name Resolution Policy Table on Windows; the zone and the
# server work without it either way.
dns-publish = ["dep:zbus"]
# The test harness in `testing`, for this crate's own tests and for a
# protocol crate's. Not on by default: it is only of use to a test.
testing = ["dep:tempfile", "dep:tracing-subscriber"]
[dependencies]
arc-swap = "1.9"
iroh.workspace = true
# `net` for the local control interface: a Unix socket on Unix, a named pipe
# on Windows. Both live behind the same `net` feature.
tokio = { workspace = true, features = ["net"] }
tokio-stream = "0.1"
serde.workspace = true
postcard.workspace = true
bytes.workspace = true
thiserror.workspace = true
tracing.workspace = true
hex.workspace = true
data-encoding.workspace = true
rusqlite = { version = "0.40", features = ["bundled"] }
hkdf = "0.13"
hmac = "0.13"
sha2 = "0.11"
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
rand = "0.10"
# Already in the tree through iroh. A packet codec, not a DNS server: the
# zone logic is ours and a full server framework would be a large dependency
# for answering A records from memory.
simple-dns = "0.12"
# The IANA top-level domain list, compiled in: one function, no
# dependencies, no network. Used only to warn that a zone name shadows a
# real public domain, never to refuse one.
tld = "2.40"
fs4 = { version = "1.1", features = ["sync"] }
directories = "6.0"
# The real system hostname, without a libc call of our own: this crate is
# forbidden `unsafe` and will not make one.
gethostname = "1.1"
netwatch = "0.19.3"
netdev = "0.46"
mainline = "8.0"
futures-lite = "2.6"
tun = { version = "0.8", features = ["async"], optional = true }
# Only for the `testing` harness, which a protocol crate's tests use too.
tempfile = { version = "3.24", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
# Linux-only interface provisioning. `rtnetlink` configures the interface in
# process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN
# out of the effective set except during the moments it is used.
[target.'cfg(target_os = "linux")'.dependencies]
rtnetlink = { version = "0.23", optional = true }
# Pure Rust D-Bus, no libdbus to link against. `tokio` rather than the
# default reactor, because the agent brings its own.
zbus = { version = "5.19", default-features = false, features = ["tokio"], optional = true }
caps = { version = "0.5", optional = true }
futures-util = { version = "0.3", default-features = false, optional = true }
# Windows-only interface provisioning. The Wintun adapter itself is created
# and driven through the `tun` dependency, whose safe wrapper is the only way
# in — loading `wintun.dll` is an `unsafe` call this crate forbids. `netdev`
# (already in the tree through `netwatch`) reads the interface table through a
# safe wrapper, which is how the provisioner observes an adapter by name
# before deciding what to change. Creating an adapter needs the process to be
# elevated and `wintun.dll` to be reachable at run time.
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Foundation",
] }
[dev-dependencies]
# Its own tests use the harness it publishes.
tsunagi = { path = ".", features = ["testing"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process", "test-util"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
[lints]
workspace = true
File renamed without changes.
@@ -27,7 +27,7 @@ mod status;
pub use events::Event;
pub use status::{
AgentStatus, CandidateStatus, MemberStatus, NetworkMetrics, NetworkState, NetworkStatus,
PeerStatus,
OverlayStatus, PeerStatus,
};
use std::collections::HashMap;
@@ -37,23 +37,29 @@ use iroh::{EndpointAddr, EndpointId};
use tokio::sync::{RwLock, broadcast, mpsc, oneshot};
use tokio::task::JoinHandle;
use crate::BoxFuture;
use crate::config::{AgentConfig, Limits};
use crate::dataplane::transport::PacketTransport;
use crate::dataplane::transport::iroh_link::{IrohTransport, TransportContext};
use crate::dataplane::{PluginContext, PluginRequest};
use crate::dataplane::{PacketSink, PluginContext, PluginRequest};
use crate::error::{Error, Result};
use crate::identity::{DeviceIdentity, NetworkId, NetworkKeys, NetworkName, NetworkSecret};
use crate::net::EndpointAdapter;
use crate::overlay::PacketCarrier;
use crate::proto::handshake;
use crate::proto::message::ControlMessage;
use crate::state::Ipv4Range;
use crate::storage::{CacheOutcome, Storage};
use crate::task::{TASK_GRACE, wind_down};
use network::{InboundSession, NetCommand, NetworkHandle, RuntimeParams};
use shutdown::Shutdown;
/// Summary of a configured network, whether or not it is running.
#[derive(Debug, Clone)]
pub struct ConfiguredNetwork {
/// Saved local broadcast participation.
pub broadcast: bool,
/// Public network identifier.
pub network_id: NetworkId,
/// Network name.
@@ -87,6 +93,10 @@ struct Inner {
accept_task: std::sync::Mutex<Option<JoinHandle<()>>>,
plugin_task: std::sync::Mutex<Option<JoinHandle<()>>>,
transport: std::sync::OnceLock<Arc<dyn PacketTransport>>,
/// Who holds which overlay address, across every network.
routes: Arc<crate::overlay::RoutingTable>,
/// The one interface, when the agent was given a way to make one.
interface: std::sync::OnceLock<Arc<crate::overlay::Interface>>,
}
impl Inner {
@@ -107,11 +117,69 @@ impl Inner {
}
}
/// Routes an outbound packet to whichever protocol can carry it.
///
/// Holds a weak reference: the interface belongs to the agent, and a strong
/// one here would keep the agent alive for as long as its own interface.
struct Carrier(Weak<Inner>);
impl std::fmt::Debug for Carrier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Carrier")
}
}
impl std::fmt::Debug for Sink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Sink")
}
}
impl PacketCarrier for Carrier {
fn carry(&self, route: crate::overlay::Route, packet: bytes::Bytes) -> bool {
let Some(inner) = self.0.upgrade() else {
return false;
};
// Offered to each protocol in turn. With one configured this is a
// single call; the shape is what allows several to be live at once,
// each carrying the peers it has a link to.
inner
.config
.plugins
.iter()
.any(|plugin| plugin.carry(route.network, route.peer, packet.clone()))
}
}
/// Writes a packet a protocol decrypted to the interface.
struct Sink(Weak<Inner>);
impl PacketSink for Sink {
fn deliver<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
packet: bytes::Bytes,
) -> BoxFuture<'a, ()> {
Box::pin(async move {
let Some(inner) = self.0.upgrade() else {
return;
};
let Some(interface) = inner.interface.get() else {
return;
};
// The rejection is already counted on the interface; there is
// nothing useful to do with it here.
let _ = interface.deliver(network, peer, packet).await;
})
}
}
/// Answers the data plane transport's questions about the agent.
///
/// Holds a weak reference on purpose: the transport lives inside the agent, so
/// a strong one would be a cycle and the agent — with its open databases and
/// its directory lock — would never be released.
/// Holds a weak reference for the same reason as [`Carrier`]: the transport
/// lives inside the agent, so a strong one would be a cycle and the agent —
/// with its open databases and its directory lock — would never be released.
#[derive(Debug)]
struct TransportCtx(Weak<Inner>);
@@ -178,17 +246,54 @@ impl Agent {
accept_task: std::sync::Mutex::new(None),
plugin_task: std::sync::Mutex::new(None),
transport: std::sync::OnceLock::new(),
routes: Arc::new(crate::overlay::RoutingTable::new()),
interface: std::sync::OnceLock::new(),
config,
});
// One interface for the agent, if it was given a way to make one.
// Failing to is reported and not fatal: the control plane works, and
// so do the protocols, they just have nowhere to put packets.
if let Some(factory) = inner.config.tun_factory.clone() {
let carrier = Arc::new(Carrier(Arc::downgrade(&inner))) as Arc<dyn PacketCarrier>;
match crate::overlay::Interface::start(
factory,
inner.config.interface_name.clone(),
inner.config.interface_mtu,
Arc::clone(&inner.routes),
carrier,
)
.await
{
Ok(interface) => {
let _ = inner.interface.set(Arc::new(interface));
}
Err(err) => {
let _ = inner.events.send(Event::PluginError {
network: NetworkId::from_bytes([0u8; 32]),
protocol: "overlay".into(),
reason: err.to_string(),
});
}
}
}
// The data plane rides on iroh too, which is where it gets hole
// punching and relay fallback from. It is a separate ALPN and a
// separate connection, so the two planes stay independent.
let transport: Arc<dyn PacketTransport> = Arc::new(IrohTransport::new(
let iroh_transport: Arc<dyn PacketTransport> = Arc::new(IrohTransport::new(
inner.adapter.clone(),
Arc::clone(&inner.limits),
Arc::new(TransportCtx(Arc::downgrade(&inner))) as Arc<dyn TransportContext>,
));
let transport: Arc<dyn PacketTransport> = if inner.config.custom_transports.is_empty() {
iroh_transport
} else {
Arc::new(crate::dataplane::MultiPacketTransport::new(
iroh_transport,
inner.config.custom_transports.clone(),
))
};
let _ = inner.transport.set(transport);
if let CacheOutcome::Reset(reason) = inner.storage.cache_outcome().clone() {
@@ -205,7 +310,11 @@ impl Agent {
// bound; overflow drops the request rather than stalling the plugin.
if !inner.config.plugins.is_empty() {
let (plugin_tx, plugin_rx) = mpsc::channel(64);
let context = PluginContext::new(plugin_tx, inner.identity.endpoint_id());
let sink = inner
.interface
.get()
.map(|_| Arc::new(Sink(Arc::downgrade(&inner))) as Arc<dyn PacketSink>);
let context = PluginContext::new(plugin_tx, inner.identity.endpoint_id(), sink);
for plugin in &inner.config.plugins {
plugin.attach(context.clone());
}
@@ -245,6 +354,137 @@ impl Agent {
self.inner.adapter.loopback_addr()
}
/// Delivers an inbound data plane link to the matching network runtime.
pub async fn install_inbound_link(
&self,
inbound: crate::dataplane::transport::InboundLink,
) -> Result<()> {
let network = inbound.network;
let sender = {
let networks = self.inner.networks.read().await;
networks.get(&network).map(|handle| handle.commands.clone())
};
let Some(sender) = sender else {
return Err(Error::NetworkNotActive(network));
};
sender
.send(NetCommand::InboundLink(Box::new(inbound)))
.await
.map_err(|_| Error::NetworkNotActive(network))?;
Ok(())
}
/// Reserves the configured overlay range for a network, if it can.
///
/// Done here, where activations are serialised, rather than inside the
/// runtime: whether a range is free depends on what the other networks
/// took, and deciding that in a task would make the answer depend on
/// which task ran first.
///
/// `None` means this agent will not propose a range for that network and
/// waits to adopt whatever it settles on. One agent has one interface, so
/// proposing a range it could not route would be worse than having none:
/// the lowest author's range wins, and the collision would spread.
async fn reserve_range(&self, network: NetworkId) -> RangePlan {
let reserve = |wanted: Ipv4Range| {
let reservation = crate::overlay::NetworkRoutes {
broadcast: Default::default(),
range: Some(wanted),
local: None,
peers: Vec::new(),
};
self.inner.routes.set_network(network, reservation).is_ok()
};
// If this network already has signed records in storage, check if its
// members have already agreed on a range. If so, preserve and reserve
// THAT range rather than preemptively seizing the default configured range.
let stored_records = self
.inner
.storage
.signed_records(network)
.await
.unwrap_or_default();
let mut state_set = crate::state::StateSet::new();
state_set.merge_all(network, stored_records);
if let Some(agreed) = state_set.agreed_range() {
if reserve(agreed) {
return RangePlan {
propose: if Some(agreed) == self.inner.config.overlay_ipv4_range {
Some(agreed)
} else {
None
},
..RangePlan::default()
};
} else {
tracing::warn!(
%network,
range = %agreed,
"agreed range for this network overlaps another network already active on this interface"
);
return RangePlan {
conflict: Some(agreed),
..RangePlan::default()
};
}
}
let Some(configured) = self.inner.config.overlay_ipv4_range else {
return RangePlan::default();
};
// The configured range first, so a network a device has always had
// keeps the addresses it has always had.
if reserve(configured) {
return RangePlan {
propose: Some(configured),
..RangePlan::default()
};
}
// A second network on the same agent cannot have it — one agent,
// one interface — so it falls back to the range derived from its
// own id, which every one of its members derives identically
// without being told.
let derived = crate::state::derived_ipv4_range(network);
if reserve(derived) {
tracing::info!(
%network,
range = %derived,
"another network here holds the configured range; this one uses the \
range derived from its id unless its members settled another"
);
return RangePlan {
fallback: Some(derived),
conflict: Some(configured),
..RangePlan::default()
};
}
tracing::info!(%network, "not proposing a range for this network");
RangePlan {
conflict: Some(configured),
..RangePlan::default()
}
}
/// The overlay interface this agent owns, when it has one.
///
/// One agent, one interface, so this is not asked per network: a packet
/// on it may belong to any of them.
pub fn overlay(&self) -> Option<OverlayStatus> {
let interface = self.inner.interface.get()?;
Some(OverlayStatus {
interface: interface.name().to_string(),
on_host: interface.on_host(),
mtu: interface.mtu(),
addresses: interface.wanted_addresses(),
counters: interface.counters(),
broadcast_rules: interface.broadcast_rules(),
})
}
/// The hostname announced to peers.
pub fn hostname(&self) -> String {
self.inner.read_hostname()
@@ -315,13 +555,47 @@ impl Agent {
&self,
name: &NetworkName,
secret: &NetworkSecret,
) -> Result<NetworkId> {
self.join_network_with_broadcast(name, secret, None).await
}
/// Joins with an explicit broadcast choice; absent preserves saved policy.
pub async fn join_network_with_broadcast(
&self,
name: &NetworkName,
secret: &NetworkSecret,
broadcast: Option<bool>,
) -> Result<NetworkId> {
let keys = NetworkKeys::derive(name, secret);
let network_id = keys.network_id();
// A network's identity is its name *and* its secret, so the same
// name with a different secret is a different network — and one that
// looks identical in anything that shows a name. Almost always a
// mistyped secret, so it is said out loud rather than left to be
// discovered as an empty network sitting beside a working one.
if let Ok(configured) = self.inner.storage.list_networks().await {
for other in configured {
if other.name == *name && other.network_id != network_id {
tracing::warn!(
"`{name}` is already configured with a different secret, as {}. \
Joining with this one adds a second network under the same name; \
they share nothing. Check the secret, or use \
`tsunagi network secret` \
to see which is which.",
other.network_id
);
}
}
}
self.inner
.storage
.upsert_network(network_id, name.clone(), secret.clone(), true)
.await?;
if let Some(enabled) = broadcast {
self.set_broadcast(network_id, enabled).await?;
}
match self.activate_with_keys(keys).await {
// Already a member of exactly this network space: nothing to do.
Ok(()) | Err(Error::NetworkAlreadyActive(_)) => Ok(network_id),
@@ -329,6 +603,21 @@ impl Agent {
}
}
/// Changes a network's local broadcast policy now and after restart.
pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
self.inner
.storage
.set_broadcast(network_id, enabled)
.await?;
if self.is_active(network_id).await {
let (reply, receive) = oneshot::channel();
self.command(network_id, NetCommand::SetBroadcast { enabled, reply })
.await?;
receive.await.map_err(|_| Error::Stopped)?;
}
Ok(())
}
/// Activates a configured network that is currently inactive.
///
/// Fails with [`Error::NetworkAlreadyActive`] if it is already running.
@@ -357,20 +646,52 @@ impl Agent {
return Err(Error::NetworkAlreadyActive(network_id));
}
let range = self.reserve_range(network_id).await;
let mut backends = Vec::new();
if let Some(discovery) = &self.inner.config.discovery {
backends.push(discovery.clone());
}
if let Some(dht) = &self.inner.config.dht {
backends.push(dht.for_network(&keys));
}
let discovery = if backends.is_empty() {
None
} else {
Some(
Arc::new(crate::discovery::CompositeDiscovery::new(backends))
as Arc<dyn crate::discovery::NetworkDiscovery>,
)
};
let broadcast = self
.inner
.storage
.list_networks()
.await?
.into_iter()
.find(|stored| stored.network_id == network_id)
.is_none_or(|stored| stored.broadcast);
let handle = network::spawn(RuntimeParams {
broadcast,
keys,
adapter: self.inner.adapter.clone(),
storage: self.inner.storage.clone(),
events: self.inner.events.clone(),
limits: Arc::clone(&self.inner.limits),
reconnect: self.inner.config.reconnect.clone(),
discovery: self.inner.config.discovery.clone(),
discovery,
discovery_interval: self.inner.config.discovery_interval,
discovery_policy: self.inner.config.discovery_policy.clone(),
plugins: self.inner.config.plugins.clone(),
routes: Arc::clone(&self.inner.routes),
interface: self.inner.interface.get().cloned(),
hostname: self.inner.read_hostname(),
transport: self.inner.transport.get().cloned(),
device_secret: self.inner.identity.signing_key(),
ipv4_range: self.inner.config.overlay_ipv4_range,
#[cfg(feature = "testing")]
unreachable_data_peers: Arc::clone(&self.inner.config.unreachable_data_peers),
ipv4_range: range.propose,
ipv4_fallback: range.fallback,
range_conflict: range.conflict,
});
networks.insert(network_id, handle);
drop(networks);
@@ -401,17 +722,77 @@ impl Agent {
for plugin in &self.inner.config.plugins {
plugin.on_network_deactivated(network_id);
}
// The network's addresses come off the interface; the interface
// itself stays, because the agent owns it and other networks may
// still be using it.
self.inner.routes.remove_network(network_id);
if let Some(interface) = self.inner.interface.get()
&& let Err(err) = interface.sync_addresses().await
{
tracing::warn!(%err, "cannot update the overlay addresses");
}
if let Some(interface) = self.inner.interface.get() {
interface.sync_broadcast_rules().await;
}
self.inner.storage.set_auto_start(network_id, false).await?;
Ok(())
}
/// Deactivates a network if it is running and removes it from the state
/// store together with its cached hints.
///
/// Local and silent: nobody is told. [`Agent::leave_network`] is the one
/// that says goodbye first, and is what a person means by leaving.
pub async fn forget_network(&self, network_id: NetworkId) -> Result<()> {
if self.is_active(network_id).await {
self.deactivate_network(network_id).await?;
}
self.inner.storage.remove_network(network_id).await
self.inner.storage.remove_network(network_id).await?;
// Whatever a protocol kept for this network goes with it. The
// agent cannot know what that is, only that there is no longer any
// reason to hold it.
for plugin in &self.inner.config.plugins {
plugin.on_network_forgotten(network_id);
}
Ok(())
}
/// Leaves a network: gives up what was claimed, then forgets it.
///
/// The order matters and cannot be improved on. A signed `Release` goes
/// out first, while there are still sessions to carry it, so the address
/// and name this agent held are freed for somebody else rather than
/// staying reserved to a member that has gone. Only then is the network
/// deactivated and removed.
///
/// Reaching every member is not on offer and never could be: a member
/// that is away hears the tombstone from the ones that were here, the
/// same way it hears everything else. Leaving with nobody connected
/// tells nobody, and says so in the outcome rather than pretending.
///
/// The author's version counter is deliberately **kept**. Rejoining the
/// same network with the same device key must continue from a higher
/// version than the release, or every replica would treat the new claim
/// as stale and ignore it.
pub async fn leave_network(&self, network_id: NetworkId) -> Result<LeaveOutcome> {
let mut outcome = LeaveOutcome::default();
if self.is_active(network_id).await {
let (reply_tx, reply_rx) = oneshot::channel();
self.command(network_id, NetCommand::Release { reply: reply_tx })
.await?;
if let Ok(peers) = reply_rx.await {
outcome.announced = true;
outcome.peers_told = peers;
}
// The tombstone is queued on each session, not yet written to
// the wire. A short pause is the difference between peers
// hearing it now and hearing it from somebody else much later.
if outcome.peers_told > 0 {
tokio::time::sleep(RELEASE_FLUSH).await;
}
}
self.forget_network(network_id).await?;
Ok(outcome)
}
/// Whether a network is currently running.
@@ -429,6 +810,7 @@ impl Agent {
.await?
.into_iter()
.map(|stored| ConfiguredNetwork {
broadcast: stored.broadcast,
network_id: stored.network_id,
name: stored.name,
auto_start: stored.auto_start,
@@ -503,16 +885,20 @@ impl Agent {
}
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
networks.push(NetworkStatus {
broadcast: stored.broadcast,
descriptor: keys.descriptor(),
name: stored.name,
network_id: stored.network_id,
state: NetworkState::Inactive,
peers: Vec::new(),
candidates: Vec::new(),
// An inactive network has no runtime to ask; the roster comes
// from one. Empty, not invented.
// An inactive network has no runtime to ask; the roster and
// the range come from one. Empty, not invented.
members: Vec::new(),
range: None,
range_conflict: None,
metrics: NetworkMetrics::default(),
relay: Default::default(),
});
}
@@ -537,7 +923,8 @@ impl Agent {
self.command(network_id, NetCommand::Reannounce).await
}
/// Asks one network to re-run discovery and re-evaluate dials right now.
/// Asks one network to re-evaluate known peers and dials right now.
/// External lookup still follows its connected/isolation policy.
///
/// Call this when the host's network environment changed. Platform wake-up
/// notifications can be wired to it later.
@@ -545,7 +932,7 @@ impl Agent {
self.command(network_id, NetCommand::Recheck).await
}
/// Asks every running network to re-run discovery right now.
/// Asks every running network to re-evaluate known peers right now.
pub async fn recheck(&self) {
let senders: Vec<mpsc::Sender<NetCommand>> = self
.inner
@@ -574,15 +961,35 @@ impl Agent {
handle.stop().await;
}
self.inner.adapter.close().await;
if let Some(dht) = &self.inner.config.dht {
dht.shutdown().await;
}
for handle in [&self.inner.accept_task, &self.inner.plugin_task] {
// iroh's own close waits for peers to acknowledge; a peer that has
// gone silent must not decide how long that takes.
if tokio::time::timeout(TASK_GRACE, self.inner.adapter.close())
.await
.is_err()
{
tracing::warn!("the endpoint did not close in time; abandoning it");
}
for (what, handle) in [
("inbound connections", &self.inner.accept_task),
("plugin requests", &self.inner.plugin_task),
] {
let task = handle.lock().ok().and_then(|mut guard| guard.take());
if let Some(task) = task {
let _ = task.await;
wind_down(task, TASK_GRACE, what).await;
}
}
// The interface goes with the agent that owns it. Before the
// plugins, so nothing is still trying to write to it.
if let Some(interface) = self.inner.interface.get() {
interface.remove().await;
}
// Plugins remove whatever system objects they created. A plugin that
// misbehaves here must not hold up the agent, so this is bounded.
for plugin in &self.inner.config.plugins {
@@ -613,6 +1020,41 @@ impl Agent {
}
}
/// What a network may propose as its overlay range, and what it cannot have.
#[derive(Debug, Clone, Copy, Default)]
struct RangePlan {
/// Proposed straight away: the configured range, when it is free here.
propose: Option<Ipv4Range>,
/// Proposed after a moment, when the configured range is another
/// network's: the range derived from this network's own id.
fallback: Option<Ipv4Range>,
/// The configured range, when this network cannot have it.
conflict: Option<Ipv4Range>,
}
/// How long a release is given to reach the sessions it was queued on.
///
/// Short: it is one small message on a connection that is already open, and
/// the alternative to waiting at all is tearing the sessions down underneath
/// it.
const RELEASE_FLUSH: std::time::Duration = std::time::Duration::from_millis(300);
/// What happened when an agent left a network.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LeaveOutcome {
/// Whether a signed release was published at all.
///
/// `false` when the network was not running: there was nothing to
/// publish it from, so this was a local removal only.
pub announced: bool,
/// How many connected peers it was sent to.
///
/// Zero with `announced` true means the tombstone is in this agent's
/// own state and nowhere else, and it is leaving with it — so nobody
/// will learn of it.
pub peers_told: usize,
}
/// How long each plugin gets to tear itself down during agent shutdown.
const PLUGIN_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
@@ -680,6 +1122,26 @@ async fn plugin_request_loop(weak: Weak<Inner>, mut requests: mpsc::Receiver<Plu
}
}
}
PluginRequest::ProtocolFailure {
network,
peer,
protocol,
reason,
} => {
let sender = {
let networks = inner.networks.read().await;
networks.get(&network).map(|handle| handle.commands.clone())
};
if let Some(sender) = sender {
let _ = sender
.send(NetCommand::ProtocolFailure {
peer,
protocol,
reason,
})
.await;
}
}
}
}
}
@@ -825,8 +1287,23 @@ async fn handle_inbound_data(inner: Arc<Inner>, conn: iroh::endpoint::Connection
conn.close(5u32.into(), b"data plane not ready");
return;
};
// Downcasting is avoided by keeping the accept side on the concrete type.
let Some(iroh_transport) = transport.as_ref().as_any().downcast_ref::<IrohTransport>() else {
let iroh_transport =
if let Some(iroh) = transport.as_ref().as_any().downcast_ref::<IrohTransport>() {
Some(iroh)
} else if let Some(multi) = transport
.as_ref()
.as_any()
.downcast_ref::<crate::dataplane::MultiPacketTransport>()
{
multi
.default_transport()
.as_ref()
.as_any()
.downcast_ref::<IrohTransport>()
} else {
None
};
let Some(iroh_transport) = iroh_transport else {
conn.close(5u32.into(), b"unsupported data transport");
return;
};
File diff suppressed because it is too large. Load diff
@@ -73,17 +73,19 @@ pub(crate) struct Session {
pub(crate) outbound: mpsc::Sender<Vec<u8>>,
pub(crate) hostname: Option<String>,
pub(crate) capabilities: Vec<PluginCapability>,
pub(crate) broadcast: bool,
pub(crate) messages_sent: u64,
pub(crate) messages_received: u64,
pub(crate) bytes_sent: u64,
pub(crate) bytes_received: u64,
reader: JoinHandle<()>,
writer: JoinHandle<()>,
path_monitor: JoinHandle<()>,
shutdown: Shutdown,
}
impl Session {
/// Signals both tasks to stop and waits for them, with a bounded grace
/// Signals all tasks to stop and waits for them, with a bounded grace
/// period.
///
/// A peer that stops reading must not be able to hold up shutdown, so the
@@ -93,6 +95,7 @@ impl Session {
conn,
reader,
writer,
path_monitor,
shutdown,
..
} = self;
@@ -102,23 +105,27 @@ impl Session {
let reader_abort = reader.abort_handle();
let writer_abort = writer.abort_handle();
let path_abort = path_monitor.abort_handle();
let joined = tokio::time::timeout(STOP_GRACE, async move {
let _ = reader.await;
let _ = writer.await;
let _ = path_monitor.await;
})
.await;
if joined.is_err() {
tracing::debug!("session tasks did not wind down in time; aborting them");
reader_abort.abort();
writer_abort.abort();
path_abort.abort();
}
}
/// Aborts both tasks without waiting. Used on replacement.
/// Aborts all tasks without waiting. Used on replacement.
pub(crate) fn abort(&self) {
self.shutdown.trigger();
self.reader.abort();
self.writer.abort();
self.path_monitor.abort();
}
}
@@ -154,6 +161,52 @@ pub(crate) fn spawn(
parent_shutdown.clone(),
));
let path_conn = conn.clone();
let path_shutdown = shutdown.clone();
let path_parent = parent_shutdown.clone();
let path_monitor = tokio::spawn(async move {
use tokio_stream::StreamExt;
let mut events = path_conn.path_events();
loop {
tokio::select! {
biased;
_ = path_shutdown.wait() => break,
_ = path_parent.wait() => break,
ev = events.next() => {
match ev {
Some(iroh::endpoint::PathEvent::Selected { remote_addr, local_addr, .. }) => {
tracing::debug!(
peer = %peer.fmt_short(),
remote = %remote_addr,
local = ?local_addr,
"Connection path selected (direct / relay / hole punch switch)"
);
}
Some(iroh::endpoint::PathEvent::Opened { remote_addr, .. }) => {
tracing::debug!(
peer = %peer.fmt_short(),
remote = %remote_addr,
"Network path opened to peer"
);
}
Some(iroh::endpoint::PathEvent::Closed { remote_addr, .. }) => {
tracing::debug!(
peer = %peer.fmt_short(),
remote = %remote_addr,
"Network path closed for peer"
);
}
Some(iroh::endpoint::PathEvent::Lagged { missed, .. }) => {
tracing::trace!(peer = %peer.fmt_short(), missed, "Path events lagged");
}
Some(_) => {}
None => break,
}
}
}
}
});
let reader = tokio::spawn(reader_task(
id,
network_id,
@@ -174,12 +227,14 @@ pub(crate) fn spawn(
outbound: outbound_tx,
hostname: None,
capabilities: Vec::new(),
broadcast: false,
messages_sent: 0,
messages_received: 0,
bytes_sent: 0,
bytes_received: 0,
reader,
writer,
path_monitor,
shutdown,
}
}
@@ -51,3 +51,25 @@ impl Shutdown {
// The sender is gone, which for our purposes means "stop".
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[tokio::test]
async fn waiting_resolves_once_triggered_and_stays_resolved() {
let shutdown = Shutdown::new();
assert!(!shutdown.is_triggered());
let waiter = shutdown.clone();
let waiting = tokio::spawn(async move { waiter.wait().await });
shutdown.trigger();
waiting.await.unwrap();
assert!(shutdown.is_triggered());
// Triggering twice is not an error, and a token cloned afterwards
// sees the trigger that already happened.
shutdown.trigger();
shutdown.clone().wait().await;
}
}
@@ -36,6 +36,29 @@ pub struct CandidateStatus {
pub consecutive_failures: u32,
}
/// The overlay interface, as the agent sees it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OverlayStatus {
/// The interface name the operating system gave.
pub interface: String,
/// Whether that interface exists on the host.
///
/// `false` with an in-memory device: the name is real to the agent and
/// to nothing else. Reported, because everything that would configure
/// the operating system for this interface must not when it is this.
pub on_host: bool,
/// Its MTU.
pub mtu: u32,
/// The addresses it should be carrying.
pub addresses: Vec<crate::overlay::Cidr>,
/// What has happened on it.
pub counters: crate::overlay::Counters,
/// The route and firewall allowance installed for LAN broadcast, when
/// some network has broadcast on and the interface is on the host.
/// `None` is "none installed", not "unknown".
pub broadcast_rules: Option<crate::overlay::BroadcastRulesReport>,
}
/// A member the signed state knows about, connected or not.
///
/// This is the durable roster: it comes from signed records, so a member that
@@ -62,6 +85,8 @@ pub struct MemberStatus {
/// Status of one authenticated session.
#[derive(Debug, Clone)]
pub struct PeerStatus {
/// This authenticated peer accepts network broadcasts.
pub broadcast: bool,
/// Authenticated endpoint id.
pub endpoint_id: EndpointId,
/// Which side this agent played in the handshake.
@@ -70,6 +95,12 @@ pub struct PeerStatus {
///
/// A mutable binding, not an identity.
pub hostname: Option<String>,
/// Protocols agreed with this peer: both sides have them at the same
/// version.
///
/// Empty means no data plane with that peer, which leaves the control
/// plane working — messages and state still flow.
pub protocols: Vec<String>,
/// Capabilities the peer announced. Payloads stay opaque.
pub capabilities: Vec<PluginCapability>,
/// How long the session has been up.
@@ -126,6 +157,8 @@ pub struct NetworkMetrics {
/// Status of one network.
#[derive(Debug, Clone)]
pub struct NetworkStatus {
/// Local broadcast participation in this network.
pub broadcast: bool,
/// Immutable deterministic description of the network space.
pub descriptor: NetworkDescriptor,
/// Network name, for convenience.
@@ -140,8 +173,19 @@ pub struct NetworkStatus {
pub candidates: Vec<CandidateStatus>,
/// Members the signed state knows about, whether connected or not.
pub members: Vec<MemberStatus>,
/// The overlay range this network uses, once it has one.
pub range: Option<crate::state::Ipv4Range>,
/// The range it could not have, because another network on this agent
/// already holds it.
///
/// One agent has one interface, so an address belongs to one network.
/// This network waits to adopt whatever its members settle on instead of
/// proposing something it could not route.
pub range_conflict: Option<crate::state::Ipv4Range>,
/// Per-network counters.
pub metrics: NetworkMetrics,
/// What has gone through a peer in the middle, in both directions.
pub relay: crate::dataplane::relay::RelayCounters,
}
impl NetworkStatus {
+183 -15
View File
@@ -11,12 +11,42 @@ use std::sync::Arc;
use std::time::Duration;
use crate::dataplane::SharedPlugin;
use crate::discovery::NetworkDiscovery;
use crate::error::{Error, Result};
use crate::discovery::{MainlineDiscovery, NetworkDiscovery};
use crate::error::Result;
/// Qualifier/organisation/application triple used for platform directories.
///
/// Unused on macOS, where the default paths are fixed system locations rather
/// than per-user directories derived from this.
#[cfg_attr(target_os = "macos", allow(dead_code))]
const APP_NAME: &str = "tsunagi";
/// Rendezvous slots per network; this bounds a discovery sample, not membership.
pub const DHT_SLOTS: u8 = 16;
/// BEP44 permits 1000 bencoded bytes; a 996-byte string has a four-byte prefix.
pub const DHT_MAX_VALUE: usize = 996;
/// Maximum direct addresses in one rendezvous record.
pub const DHT_MAX_ADDRS: usize = 8;
/// Maximum relay URL bytes in a rendezvous record.
pub const DHT_MAX_RELAY_LEN: usize = 512;
/// Application freshness, independent of storage nodes' own expiration.
pub const DHT_RECORD_TTL: Duration = Duration::from_secs(900);
/// Clock skew tolerated when reading a recently published record.
pub const DHT_CLOCK_SKEW: Duration = Duration::from_secs(120);
/// Maximum reassembled data-transport payload, independent of the path MTU.
pub const MAX_DATA_DATAGRAM: usize = 64 * 1024;
/// Maximum incomplete datagrams held by one authenticated data link.
pub const MAX_DATA_ASSEMBLIES: usize = 64;
/// Maximum allocated payload bytes for incomplete datagrams on one data link.
pub const MAX_DATA_REASSEMBLY_BYTES: usize = 256 * 1024;
/// Maximum fragments of one logical datagram, including after a path MTU change.
pub const MAX_DATA_FRAGMENTS: usize = 128;
/// Time allowed to assemble a datagram; loss never stalls later datagrams.
pub const DATA_REASSEMBLY_TTL: Duration = Duration::from_secs(5);
/// Completed or discarded packet IDs retained to ignore late duplicate fragments.
pub const DATA_RECENT_IDS: usize = 128;
/// Where the two stores live.
///
/// The mandatory state and the disposable cache are separate both logically and
@@ -48,17 +78,34 @@ impl StoragePaths {
}
}
/// The per-user platform directories.
/// The default platform directories.
///
/// A future system service can supply its own paths instead.
/// Per user on Linux and Windows. On macOS the agent runs as a root
/// LaunchDaemon (creating a utun interface needs root, and `$HOME` under
/// `sudo` points at the wrong place), so the stores live in the system
/// locations instead — `/var/db/tsunagi` for the mandatory state and a
/// disposable cache beside it. A caller that wants somewhere else passes
/// explicit paths to [`StoragePaths::new`] or [`StoragePaths::under`].
pub fn user_default() -> Result<Self> {
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
Error::Storage("no valid home directory for platform config paths".into())
})?;
Ok(Self {
state_dir: dirs.data_dir().to_path_buf(),
cache_dir: dirs.cache_dir().to_path_buf(),
})
#[cfg(target_os = "macos")]
{
Ok(Self {
state_dir: PathBuf::from("/var/db/tsunagi"),
cache_dir: PathBuf::from("/var/db/tsunagi/cache"),
})
}
#[cfg(not(target_os = "macos"))]
{
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
crate::error::Error::Storage(
"no valid home directory for platform config paths".into(),
)
})?;
Ok(Self {
state_dir: dirs.data_dir().to_path_buf(),
cache_dir: dirs.cache_dir().to_path_buf(),
})
}
}
/// Path of the mandatory state database.
@@ -137,6 +184,8 @@ pub struct Limits {
pub write_timeout: Duration,
/// Maximum simultaneous outbound dials per network.
pub max_concurrent_dials: usize,
/// Maximum unverified discovery candidates retained per network.
pub max_discovery_candidates: usize,
/// Maximum simultaneous authenticated sessions per network.
pub max_sessions_per_network: usize,
/// Maximum simultaneous inbound connections being handshaken.
@@ -163,6 +212,7 @@ impl Default for Limits {
dial_timeout: Duration::from_secs(10),
write_timeout: Duration::from_secs(30),
max_concurrent_dials: 8,
max_discovery_candidates: 16,
max_sessions_per_network: 64,
max_inbound_handshakes: 32,
session_send_queue: 64,
@@ -212,6 +262,33 @@ impl ReconnectPolicy {
}
}
/// Scheduling of candidate lookup and independent self-publication.
#[derive(Debug, Clone)]
pub struct DiscoveryPolicy {
/// Mean interval between successful self-publications (20% jitter).
pub publish_interval: Duration,
/// Initial delay between unsuccessful bootstrap lookups.
pub lookup_interval: Duration,
/// Maximum delay between bootstrap lookups.
pub max_lookup_interval: Duration,
/// Continuous isolation before a previously connected network searches again.
pub reconnect_delay: Duration,
/// Deadline for one backend operation.
pub request_timeout: Duration,
}
impl Default for DiscoveryPolicy {
fn default() -> Self {
Self {
publish_interval: Duration::from_secs(300),
lookup_interval: Duration::from_secs(5),
max_lookup_interval: Duration::from_secs(30),
reconnect_delay: Duration::from_secs(60),
request_timeout: Duration::from_secs(60),
}
}
}
/// Everything needed to start an [`crate::Agent`].
#[derive(Clone)]
pub struct AgentConfig {
@@ -223,13 +300,24 @@ pub struct AgentConfig {
pub bind_addrs: Vec<SocketAddr>,
/// How much external connectivity machinery the endpoint may use.
pub transport: TransportPolicy,
/// Real QUIC transport settings for constrained-path integration tests.
#[cfg(feature = "testing")]
pub test_quic_transport: Option<iroh::endpoint::QuicTransportConfig>,
/// Peers with no direct data path, for tests. See
/// [`AgentConfig::with_unreachable_data_peers`].
#[cfg(feature = "testing")]
pub unreachable_data_peers: Arc<std::sync::Mutex<std::collections::HashSet<iroh::EndpointId>>>,
/// Hostname announced to peers. `None` keeps whatever the state store holds,
/// falling back to the OS hostname and finally to a short endpoint id.
pub hostname: Option<String>,
/// Discovery backend. `None` disables discovery-driven dialling; static
/// bootstrap candidates still work.
/// Additional discovery backend, composed with Mainline when it is enabled.
pub discovery: Option<Arc<dyn NetworkDiscovery>>,
/// How often each active network re-runs discovery and re-evaluates dials.
/// Optional Mainline client, shared by this agent's networks. The CLI enables
/// it by default except in local-only mode; library callers opt in explicitly.
pub dht: Option<MainlineDiscovery>,
/// Lookup, publication and recovery scheduling.
pub discovery_policy: DiscoveryPolicy,
/// How often each active network maintains known peers and observes address changes.
pub discovery_interval: Duration,
/// Bounds applied to network input.
pub limits: Limits,
@@ -237,6 +325,20 @@ pub struct AgentConfig {
pub reconnect: ReconnectPolicy,
/// IP plugins whose capabilities are announced and dispatched.
pub plugins: Vec<SharedPlugin>,
/// Custom packet transports registered per protocol.
pub custom_transports:
std::collections::HashMap<String, Arc<dyn crate::dataplane::PacketTransport>>,
/// Where the overlay interface comes from, when the agent should have one.
///
/// One agent, one interface: it belongs here rather than to a protocol,
/// because every protocol carries traffic for the same addresses on it.
/// `None` means no interface — the protocols still run and their packets
/// are discarded.
pub tun_factory: Option<Arc<dyn crate::overlay::TunFactory>>,
/// The name to ask the operating system for.
pub interface_name: String,
/// The interface MTU.
pub interface_mtu: u32,
/// The IPv4 overlay range this agent proposes.
///
/// Addresses are allocated from it and recorded in signed state, so a
@@ -253,12 +355,24 @@ impl AgentConfig {
paths,
bind_addrs: Vec::new(),
transport: TransportPolicy::default(),
#[cfg(feature = "testing")]
test_quic_transport: None,
#[cfg(feature = "testing")]
unreachable_data_peers: Arc::new(std::sync::Mutex::new(
std::collections::HashSet::new(),
)),
hostname: None,
discovery: None,
dht: None,
discovery_policy: DiscoveryPolicy::default(),
discovery_interval: Duration::from_secs(5),
limits: Limits::default(),
reconnect: ReconnectPolicy::default(),
plugins: Vec::new(),
custom_transports: std::collections::HashMap::new(),
tun_factory: None,
interface_name: "tsun0".to_string(),
interface_mtu: 1280,
overlay_ipv4_range: Some(crate::state::DEFAULT_IPV4_RANGE),
}
}
@@ -278,6 +392,21 @@ impl AgentConfig {
self
}
/// Peers this agent will not open or accept a **direct** data link with.
///
/// Test-only, and behind the `testing` feature so a release build has
/// no such switch. The one arrangement that cannot be made on a single
/// host is two agents that both reach a third and not each other,
/// which is exactly the case a relay exists for.
#[cfg(feature = "testing")]
pub fn with_unreachable_data_peers(
mut self,
peers: Arc<std::sync::Mutex<std::collections::HashSet<iroh::EndpointId>>>,
) -> Self {
self.unreachable_data_peers = peers;
self
}
/// Sets the transport policy.
pub fn with_transport(mut self, transport: TransportPolicy) -> Self {
self.transport = transport;
@@ -290,7 +419,19 @@ impl AgentConfig {
self
}
/// Sets how often discovery runs.
/// Adds Mainline discovery alongside the configured candidate backend.
pub fn with_dht(mut self, dht: MainlineDiscovery) -> Self {
self.dht = Some(dht);
self
}
/// Sets lookup, publication and isolation recovery timing.
pub fn with_discovery_policy(mut self, policy: DiscoveryPolicy) -> Self {
self.discovery_policy = policy;
self
}
/// Sets the known-peer maintenance and endpoint-address observation interval.
pub fn with_discovery_interval(mut self, interval: Duration) -> Self {
self.discovery_interval = interval;
self
@@ -314,6 +455,29 @@ impl AgentConfig {
self
}
/// Registers a custom packet transport for a specific protocol.
pub fn with_custom_transport(
mut self,
protocol: impl Into<String>,
transport: Arc<dyn crate::dataplane::PacketTransport>,
) -> Self {
self.custom_transports.insert(protocol.into(), transport);
self
}
/// Gives the agent an overlay interface, from this factory.
pub fn with_interface(
mut self,
factory: Arc<dyn crate::overlay::TunFactory>,
name: impl Into<String>,
mtu: u32,
) -> Self {
self.tun_factory = Some(factory);
self.interface_name = name.into();
self.interface_mtu = mtu;
self
}
/// Replaces the limits.
pub fn with_limits(mut self, limits: Limits) -> Self {
self.limits = limits;
@@ -335,6 +499,8 @@ impl std::fmt::Debug for AgentConfig {
.field("transport", &self.transport)
.field("hostname", &self.hostname)
.field("discovery", &self.discovery.as_ref().map(|d| d.name()))
.field("dht", &self.dht.is_some())
.field("discovery_policy", &self.discovery_policy)
.field("discovery_interval", &self.discovery_interval)
.field("limits", &self.limits)
.field("reconnect", &self.reconnect)
@@ -349,3 +515,5 @@ impl std::fmt::Debug for AgentConfig {
.finish()
}
}
/// Maximum number of transport links a routed datagram may traverse.
pub const ROUTING_HOP_LIMIT: u8 = 16;
@@ -19,8 +19,10 @@
//! A data plane failure never stops the daemon: errors returned here are
//! recorded and surfaced, the control plane keeps running.
pub mod relay;
pub mod routing;
pub mod transport;
pub mod wireguard;
pub mod tunnel;
use std::sync::Arc;
@@ -30,7 +32,10 @@ use tokio::sync::mpsc;
use crate::BoxFuture;
use crate::identity::NetworkId;
pub use transport::{PacketLink, PacketTransport, SharedLink, TransportError};
pub use transport::{
MultiPacketTransport, PacketLink, PacketTransport, SharedLink, TransportError,
};
pub use tunnel::{GenericTunnelPlugin, TunnelCodec};
/// Maximum length of a plugin protocol identifier.
pub const MAX_PROTOCOL_ID_LEN: usize = 32;
@@ -42,7 +47,7 @@ pub const MAX_PROTOCOL_ID_LEN: usize = 32;
/// chooses to do so must validate it itself.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PluginCapability {
/// Protocol identifier, e.g. `wireguard`. Bounded by [`MAX_PROTOCOL_ID_LEN`].
/// Protocol identifier, e.g. `wg-quic`. Bounded by [`MAX_PROTOCOL_ID_LEN`].
pub protocol: String,
/// Version of the plugin's announcement format.
pub version: u16,
@@ -52,6 +57,51 @@ pub struct PluginCapability {
pub data: Vec<u8>,
}
/// Where a protocol hands the packets it has decrypted.
///
/// A protocol proves *who* sent a packet; it does not know what that member
/// is entitled to say, because entitlement is an address claim the system
/// level holds. So a decrypted packet goes here rather than straight to an
/// interface, and is checked on the way.
pub trait PacketSink: Send + Sync + 'static {
/// Hands over one packet, attributed to the peer whose tunnel decrypted
/// it.
fn deliver<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
packet: bytes::Bytes,
) -> BoxFuture<'a, ()>;
}
/// A sink that drops everything, for a protocol running without an interface.
#[derive(Debug, Clone, Copy, Default)]
pub struct DiscardPackets;
impl PacketSink for DiscardPackets {
fn deliver<'a>(
&'a self,
_network: NetworkId,
_peer: EndpointId,
_packet: bytes::Bytes,
) -> BoxFuture<'a, ()> {
Box::pin(async move {})
}
}
/// One setting a protocol accepts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProtocolOption {
/// The key, as written in `key=value`.
pub key: &'static str,
/// A word for the value, for the help line: `BYTES`, `NAME`.
pub value: &'static str,
/// What it does.
pub help: &'static str,
/// What happens when it is not given.
pub default: Option<&'static str>,
}
/// Errors a plugin may return. They are recorded, never fatal for the agent.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
@@ -69,7 +119,7 @@ pub enum PluginError {
/// A request a plugin makes of the agent that owns it.
#[derive(Debug)]
pub(crate) enum PluginRequest {
pub enum PluginRequest {
/// Re-send this agent's announcement to every peer of a network.
Reannounce(NetworkId),
/// Surface a plugin error on the agent's event stream.
@@ -81,6 +131,17 @@ pub(crate) enum PluginRequest {
/// Human readable reason, free of secrets.
reason: String,
},
/// A plugin reported that a data link or tunnel for a peer failed.
ProtocolFailure {
/// Network the failure occurred in.
network: NetworkId,
/// Peer whose protocol failed.
peer: EndpointId,
/// Protocol that failed.
protocol: String,
/// Human readable reason.
reason: String,
},
}
/// The agent-side handle a plugin is given when it is attached.
@@ -95,13 +156,32 @@ pub(crate) enum PluginRequest {
pub struct PluginContext {
sender: Option<mpsc::Sender<PluginRequest>>,
local: Option<EndpointId>,
/// Where decrypted packets go. Absent when nothing is carrying traffic,
/// in which case a protocol still runs and its packets are discarded.
sink: Option<Arc<dyn PacketSink>>,
}
impl PluginContext {
pub(crate) fn new(sender: mpsc::Sender<PluginRequest>, local: EndpointId) -> Self {
pub(crate) fn new(
sender: mpsc::Sender<PluginRequest>,
local: EndpointId,
sink: Option<Arc<dyn PacketSink>>,
) -> Self {
Self {
sender: Some(sender),
local: Some(local),
sink,
}
}
/// Where to hand a decrypted packet.
///
/// Always answers: with no interface to write to, the packets are
/// discarded, which is what `--no-tun` means and is not an error.
pub fn packet_sink(&self) -> Arc<dyn PacketSink> {
match &self.sink {
Some(sink) => Arc::clone(sink),
None => Arc::new(DiscardPackets),
}
}
@@ -110,6 +190,30 @@ impl PluginContext {
Self {
sender: None,
local: None,
sink: None,
}
}
/// A context configured with a custom packet sink.
pub fn with_sink(sink: Arc<dyn PacketSink>) -> Self {
Self {
sender: None,
local: None,
sink: Some(sink),
}
}
/// A context configured with a channel for plugin requests and custom sink, for tests.
#[cfg(feature = "testing")]
pub fn for_test(
sender: mpsc::Sender<PluginRequest>,
local: EndpointId,
sink: Option<Arc<dyn PacketSink>>,
) -> Self {
Self {
sender: Some(sender),
local: Some(local),
sink,
}
}
@@ -154,6 +258,22 @@ impl PluginContext {
reason: reason.into(),
});
}
/// Reports that data plane connectivity or a tunnel for a peer has failed.
pub fn report_peer_protocol_failure(
&self,
network: NetworkId,
peer: EndpointId,
protocol: impl Into<String>,
reason: impl Into<String>,
) {
self.send(PluginRequest::ProtocolFailure {
network,
peer,
protocol: protocol.into(),
reason: reason.into(),
});
}
}
impl std::fmt::Debug for PluginContext {
@@ -170,11 +290,34 @@ impl std::fmt::Debug for PluginContext {
/// Implementations must be cheap and non-blocking: the agent calls them from
/// its runtime tasks. Anything slow belongs in the plugin's own tasks.
pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
/// Stable protocol identifier, e.g. `wireguard`.
/// Stable protocol identifier, e.g. `wg-quic`.
///
/// Must be non-empty and at most [`MAX_PROTOCOL_ID_LEN`] bytes.
fn protocol_id(&self) -> &str;
/// The wire version of this protocol.
///
/// Two peers carry traffic for each other only when they have the same
/// protocol at the same version. There is no negotiating a middle
/// ground: a protocol either speaks the same words at both ends or it
/// does not, and pretending otherwise produces a link that fails later
/// and less clearly.
///
/// **Not the software version.** A plugin crate has its own version and
/// it is nobody else's business: two peers on different builds work
/// together for as long as the wire between them has not changed. This
/// number moves only when the bytes do, so it must never be derived from
/// `CARGO_PKG_VERSION` or anything else that moves with a release.
fn protocol_version(&self) -> u16;
/// The settings this protocol accepts, and what they mean.
///
/// Declared rather than documented elsewhere, so the agent can list them
/// without knowing anything about the protocol.
fn options(&self) -> &'static [ProtocolOption] {
&[]
}
/// Called once, when the agent starts, before any network is activated.
///
/// The plugin keeps the context to ask for re-announcements and to report
@@ -224,6 +367,20 @@ pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
let _ = (network, range, allocations);
}
/// Carries one packet to a peer, encrypting it however this protocol
/// does.
///
/// `false` when it cannot right now — no link, no tunnel, not this
/// protocol's peer — which the caller reports rather than treats as an
/// error. The packet came off the one interface the agent owns, and
/// which protocol takes it is settled by asking.
///
/// The default carries nothing, which is right for a plugin that only
/// announces something.
fn carry(&self, _network: NetworkId, _peer: EndpointId, _packet: bytes::Bytes) -> bool {
false
}
/// A data plane link to a peer is available for this plugin's protocol.
///
/// The plugin moves its packets over this link and never learns how the
@@ -243,6 +400,19 @@ pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
/// The plugin is expected to remove whatever it created for that network.
fn on_network_deactivated(&self, network: NetworkId);
/// Called when this agent has left a network for good.
///
/// Deactivation is temporary and keeps everything ready for next time;
/// this is the other one. Whatever the plugin holds *durably* for that
/// network — a key of its own, a file, a record — goes now, because the
/// agent is no longer a member and keeping it is keeping a secret for a
/// network it cannot rejoin without being told the secret again.
///
/// Always preceded by [`IpPlugin::on_network_deactivated`], so this is
/// only about what outlives a session. Defaulted to nothing, for a
/// plugin that stores nothing.
fn on_network_forgotten(&self, _network: NetworkId) {}
/// Called once when the agent shuts down.
///
/// The plugin removes the system objects it created and stops its tasks.
@@ -290,6 +460,10 @@ impl IpPlugin for TestCapabilityPlugin {
&self.protocol
}
fn protocol_version(&self) -> u16 {
1
}
fn local_capability(
&self,
_network: NetworkId,
+506
View File
@@ -0,0 +1,506 @@
//! Userspace forwarding. Topology updates publish immutable tables; the packet
//! path takes no routing mutex, walks no graph and never inspects plugin bytes.
//! Each transport reader forwards transit immediately, independently of the
//! destination protocol's receive task. Only local delivery enters its inbox.
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use arc_swap::ArcSwap;
use bytes::Bytes;
use iroh::EndpointId;
use tokio::sync::{Notify, mpsc, watch};
use tokio::task::JoinHandle;
use super::routing::{FlowId, PeerId, RoutingTable, envelope, mix64};
use super::transport::{PacketLink, SharedLink, TransportError};
use crate::BoxFuture;
use crate::config::{MAX_DATA_DATAGRAM, ROUTING_HOP_LIMIT};
use crate::identity::NetworkId;
/// Fixed routing header, subtracted from the logical transport MTU.
pub const RELAY_OVERHEAD: usize = envelope::HEADER;
const INBOX: usize = 256;
/// Forwarding counters, shared by all protocols of one network.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RelayCounters {
/// Datagrams originated here and sent through another member.
pub sent_via: u64,
/// Transit datagrams handed to the next transport link.
pub forwarded: u64,
/// Local datagrams received through another member.
pub received_via: u64,
/// Missing or failed next hop.
pub dropped_no_link: u64,
/// Malformed envelope or unknown member.
pub dropped_unknown: u64,
/// Exhausted hop limit, including loops during topology convergence.
pub dropped_hop_limit: u64,
/// Full local protocol inbox. Transit does not use this queue.
pub dropped_congested: u64,
}
#[derive(Debug, Default)]
struct Tally {
sent_via: AtomicU64,
forwarded: AtomicU64,
received_via: AtomicU64,
dropped_no_link: AtomicU64,
dropped_unknown: AtomicU64,
dropped_hop_limit: AtomicU64,
dropped_congested: AtomicU64,
}
impl Tally {
fn snapshot(&self) -> RelayCounters {
RelayCounters {
sent_via: self.sent_via.load(Ordering::Relaxed),
forwarded: self.forwarded.load(Ordering::Relaxed),
received_via: self.received_via.load(Ordering::Relaxed),
dropped_no_link: self.dropped_no_link.load(Ordering::Relaxed),
dropped_unknown: self.dropped_unknown.load(Ordering::Relaxed),
dropped_hop_limit: self.dropped_hop_limit.load(Ordering::Relaxed),
dropped_congested: self.dropped_congested.load(Ordering::Relaxed),
}
}
}
#[derive(Debug)]
struct NextHop {
peer: PeerId,
link: SharedLink,
}
#[derive(Debug)]
struct ForwardRoute {
hops: u8,
next: Box<[NextHop]>,
}
impl ForwardRoute {
fn select(&self, source: &PeerId, flow: FlowId) -> Option<&NextHop> {
if self.next.len() == 1 {
return self.next.first().filter(|hop| !hop.link.is_closed());
}
let seed = u64::from_le_bytes(source[..8].try_into().ok()?);
let start = mix64(flow ^ seed) as usize % self.next.len();
// Stable fallback if a link closed just before its table was replaced.
(0..self.next.len())
.map(|i| &self.next[(start + i) % self.next.len()])
.find(|hop| !hop.link.is_closed())
}
}
#[derive(Debug, Default)]
struct ForwardingTable {
routes: HashMap<PeerId, ForwardRoute>,
inboxes: HashMap<PeerId, mpsc::Sender<Bytes>>,
members: HashSet<PeerId>,
}
#[derive(Debug)]
struct Plane {
local: PeerId,
table: ArcSwap<ForwardingTable>,
tally: Arc<Tally>,
}
impl Plane {
/// Synchronous hot path: one snapshot, bounded header decode, table lookup,
/// and transport send. An exclusively owned buffer is modified in place.
fn receive(&self, incoming: PeerId, frame: Bytes) {
let Some(header) = envelope::decode(&frame) else {
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
return;
};
let table = self.table.load();
if !table.members.contains(&header.source) || header.source == self.local {
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
return;
}
if header.destination == self.local {
let Some(inbox) = table.inboxes.get(&header.source) else {
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
return;
};
if inbox.try_send(frame.slice(RELAY_OVERHEAD..)).is_err() {
self.tally.dropped_congested.fetch_add(1, Ordering::Relaxed);
} else if incoming != header.source {
self.tally.received_via.fetch_add(1, Ordering::Relaxed);
}
return;
}
if header.remaining <= 1 {
self.tally.dropped_hop_limit.fetch_add(1, Ordering::Relaxed);
return;
}
let hop = table
.routes
.get(&header.destination)
.and_then(|route| route.select(&header.source, header.flow));
let Some(hop) = hop else {
self.tally.dropped_no_link.fetch_add(1, Ordering::Relaxed);
return;
};
if hop.link.send(envelope::decrement(frame)).is_ok() {
self.tally.forwarded.fetch_add(1, Ordering::Relaxed);
} else {
self.tally.dropped_no_link.fetch_add(1, Ordering::Relaxed);
}
}
}
#[derive(Debug)]
struct Protocol {
plane: Arc<Plane>,
graph: HashMap<PeerId, Vec<PeerId>>,
members: HashSet<PeerId>,
raw: HashMap<PeerId, SharedLink>,
readers: HashMap<PeerId, JoinHandle<()>>,
links: HashMap<PeerId, Arc<PeerLink>>,
}
impl Protocol {
fn publish(&self) {
let mut graph = self.graph.clone();
graph.insert(
self.plane.local,
self.raw
.iter()
.filter(|(_, link)| !link.is_closed())
.map(|(peer, _)| *peer)
.collect(),
);
let routing = RoutingTable::build(self.plane.local, &graph, ROUTING_HOP_LIMIT);
let routes = routing
.iter()
.filter_map(|(peer, route)| {
let next: Box<[_]> = route
.next_hops
.iter()
.filter_map(|id| {
self.raw.get(id).map(|link| NextHop {
peer: *id,
link: link.clone(),
})
})
.collect();
(!next.is_empty()).then_some((
*peer,
ForwardRoute {
hops: route.hops,
next,
},
))
})
.collect();
let mut members = self.members.clone();
members.extend(self.raw.keys().copied());
self.plane.table.store(Arc::new(ForwardingTable {
routes,
members,
inboxes: self
.links
.iter()
.map(|(peer, link)| (*peer, link.inbox_tx.clone()))
.collect(),
}));
}
}
impl Drop for Protocol {
fn drop(&mut self) {
for task in self.readers.values() {
task.abort();
}
for link in self.links.values() {
link.close();
}
self.plane.table.store(Arc::default());
}
}
/// One network's transport links and independently published protocol tables.
#[derive(Debug)]
pub struct RelayHub {
network: NetworkId,
local: PeerId,
protocols: Mutex<HashMap<String, Protocol>>,
tally: Arc<Tally>,
changed: Arc<Notify>,
}
impl RelayHub {
/// Creates the router at this network's local endpoint.
pub fn new(network: NetworkId, local: EndpointId) -> Arc<Self> {
Arc::new(Self {
network,
local: *local.as_bytes(),
protocols: Mutex::default(),
tally: Arc::default(),
changed: Arc::default(),
})
}
fn protocols(&self) -> std::sync::MutexGuard<'_, HashMap<String, Protocol>> {
self.protocols
.lock()
.unwrap_or_else(|error| error.into_inner())
}
fn protocol<'a>(
&self,
protocols: &'a mut HashMap<String, Protocol>,
name: &str,
) -> &'a mut Protocol {
protocols
.entry(name.to_owned())
.or_insert_with(|| Protocol {
plane: Arc::new(Plane {
local: self.local,
table: ArcSwap::from_pointee(ForwardingTable::default()),
tally: self.tally.clone(),
}),
graph: HashMap::new(),
members: HashSet::new(),
raw: HashMap::new(),
readers: HashMap::new(),
links: HashMap::new(),
})
}
/// Wakes the control loop promptly when a transport reader ends.
pub async fn changed(&self) {
self.changed.notified().await;
}
/// Current counters.
pub fn counters(&self) -> RelayCounters {
self.tally.snapshot()
}
/// Publishes authenticated topology. Refreshing identical state is cheap.
pub fn set_topology(
&self,
protocol: &str,
graph: HashMap<PeerId, Vec<PeerId>>,
members: HashSet<PeerId>,
) {
let mut protocols = self.protocols();
let state = self.protocol(&mut protocols, protocol);
if state.graph != graph || state.members != members {
state.graph = graph;
state.members = members;
state.publish();
}
}
/// Whether the current table has a path to this destination.
pub fn reachable(&self, peer: EndpointId, protocol: &str) -> bool {
self.protocols().get(protocol).is_some_and(|state| {
state
.plane
.table
.load()
.routes
.contains_key(peer.as_bytes())
})
}
/// Stable logical link, preserved across topology changes.
pub fn link(&self, peer: EndpointId, protocol: &str) -> Arc<PeerLink> {
let mut protocols = self.protocols();
let state = self.protocol(&mut protocols, protocol);
if let Some(link) = state.links.get(peer.as_bytes()) {
return link.clone();
}
let (inbox_tx, inbox_rx) = mpsc::channel(INBOX);
let (closed, _) = watch::channel(false);
let link = Arc::new(PeerLink {
network: self.network,
peer,
plane: state.plane.clone(),
inbox_tx,
inbox_rx: tokio::sync::Mutex::new(inbox_rx),
closed,
});
state.links.insert(*peer.as_bytes(), link.clone());
state.publish();
link
}
/// Whether the protocol already owns its logical link.
pub fn has_link(&self, peer: EndpointId, protocol: &str) -> bool {
self.protocols()
.get(protocol)
.is_some_and(|state| state.links.contains_key(peer.as_bytes()))
}
/// Installs a raw transport link and starts its independent ingress reader.
pub fn set_direct(&self, peer: EndpointId, protocol: &str, raw: SharedLink) {
if raw.network() != self.network || raw.peer() != peer {
return;
}
let mut protocols = self.protocols();
let state = self.protocol(&mut protocols, protocol);
let id = *peer.as_bytes();
if let Some(old) = state.readers.remove(&id) {
old.abort();
}
state.raw.insert(id, raw.clone());
state.publish();
let plane = state.plane.clone();
let changed = self.changed.clone();
state.readers.insert(
id,
tokio::spawn(async move {
let mut batch = 0;
while let Some(frame) = raw.recv().await {
plane.receive(id, frame);
batch += 1;
if batch == 64 {
tokio::task::yield_now().await;
batch = 0;
}
}
changed.notify_one();
}),
);
}
/// Removes a physical link without tearing down end-to-end protocol state.
pub fn clear_direct(&self, peer: EndpointId, protocol: &str) {
if let Some(state) = self.protocols().get_mut(protocol) {
state.raw.remove(peer.as_bytes());
if let Some(task) = state.readers.remove(peer.as_bytes()) {
task.abort();
}
state.publish();
}
}
/// Revokes all links and topology involving a departed authenticated peer.
pub fn remove_peer(&self, peer: EndpointId) {
for state in self.protocols().values_mut() {
state.raw.remove(peer.as_bytes());
if let Some(task) = state.readers.remove(peer.as_bytes()) {
task.abort();
}
if let Some(link) = state.links.remove(peer.as_bytes()) {
link.close();
}
state.members.remove(peer.as_bytes());
state.graph.remove(peer.as_bytes());
for neighbors in state.graph.values_mut() {
neighbors.retain(|id| id != peer.as_bytes());
}
state.publish();
}
}
/// Stops every reader and releases all transport handles.
pub fn close(&self) {
self.protocols().clear();
}
}
/// A protocol's end-to-end channel, independent of its current next hop.
#[derive(Debug)]
pub struct PeerLink {
network: NetworkId,
peer: EndpointId,
plane: Arc<Plane>,
inbox_tx: mpsc::Sender<Bytes>,
inbox_rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
closed: watch::Sender<bool>,
}
impl PeerLink {
fn close(&self) {
self.closed.send_replace(true);
}
}
impl PacketLink for PeerLink {
fn network(&self) -> NetworkId {
self.network
}
fn peer(&self) -> EndpointId {
self.peer
}
fn max_datagram_size(&self) -> usize {
MAX_DATA_DATAGRAM - RELAY_OVERHEAD
}
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
self.send_flow(payload, 0)
}
fn send_flow(&self, payload: Bytes, flow: FlowId) -> Result<(), TransportError> {
if self.is_closed() {
return Err(TransportError::Closed);
}
if payload.len() > self.max_datagram_size() {
return Err(TransportError::TooLarge {
size: payload.len(),
limit: self.max_datagram_size(),
});
}
let table = self.plane.table.load();
let route = table
.routes
.get(self.peer.as_bytes())
.ok_or(TransportError::Closed)?;
let hop = route
.select(&self.plane.local, flow)
.ok_or(TransportError::Closed)?;
hop.link.send(envelope::encode(
self.plane.local,
*self.peer.as_bytes(),
flow,
&payload,
))?;
if route.hops > 1 {
self.plane.tally.sent_via.fetch_add(1, Ordering::Relaxed);
}
Ok(())
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move {
let mut closed = self.closed.subscribe();
if *closed.borrow_and_update() {
return None;
}
let mut inbox = self.inbox_rx.lock().await;
tokio::select! { biased; _ = closed.changed() => None, frame = inbox.recv() => frame }
})
}
fn closed(&self) -> BoxFuture<'_, ()> {
Box::pin(async move {
let mut closed = self.closed.subscribe();
if !*closed.borrow_and_update() {
let _ = closed.changed().await;
}
})
}
fn is_closed(&self) -> bool {
*self.closed.borrow()
}
fn path_description(&self) -> String {
let table = self.plane.table.load();
match table.routes.get(self.peer.as_bytes()) {
Some(route) if route.hops == 1 => route.next[0].link.path_description(),
Some(route) => format!(
"relay {} hops via {} ({} equal paths)",
route.hops,
hex::encode(route.next[0].peer),
route.next.len()
),
None => "unreachable".into(),
}
}
}
#[cfg(test)]
#[path = "relay_tests.rs"]
mod tests;
+287
View File
@@ -0,0 +1,287 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
use std::sync::atomic::AtomicBool;
fn network() -> NetworkId {
NetworkKeys::derive(
&NetworkName::new("routing-tests").unwrap(),
&NetworkSecret::from_bytes([3; 32]).unwrap(),
)
.network_id()
}
fn peer(seed: u8) -> EndpointId {
iroh::SecretKey::from_bytes(&[seed; 32]).public()
}
fn id(seed: u8) -> PeerId {
*peer(seed).as_bytes()
}
#[derive(Debug)]
struct Wire {
peer: EndpointId,
sent: Mutex<Vec<Bytes>>,
feed: mpsc::Sender<Bytes>,
inbound: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
dead: AtomicBool,
count: AtomicU64,
record: bool,
}
impl Wire {
fn new(seed: u8, record: bool) -> Arc<Self> {
let (feed, inbound) = mpsc::channel(32);
Arc::new(Self {
peer: peer(seed),
sent: Mutex::default(),
feed,
inbound: tokio::sync::Mutex::new(inbound),
dead: AtomicBool::new(false),
count: AtomicU64::new(0),
record,
})
}
fn take(&self) -> Vec<Bytes> {
std::mem::take(&mut self.sent.lock().unwrap())
}
}
impl PacketLink for Wire {
fn network(&self) -> NetworkId {
network()
}
fn peer(&self) -> EndpointId {
self.peer
}
fn max_datagram_size(&self) -> usize {
MAX_DATA_DATAGRAM
}
fn send(&self, frame: Bytes) -> Result<(), TransportError> {
if self.is_closed() {
return Err(TransportError::Closed);
}
self.count.fetch_add(1, Ordering::Relaxed);
if self.record {
self.sent.lock().unwrap().push(frame);
}
Ok(())
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move { self.inbound.lock().await.recv().await })
}
fn closed(&self) -> BoxFuture<'_, ()> {
Box::pin(std::future::pending())
}
fn is_closed(&self) -> bool {
self.dead.load(Ordering::Relaxed)
}
fn path_description(&self) -> String {
"test-direct".into()
}
}
fn topology(hub: &RelayHub, protocol: &str, edges: &[(u8, &[u8])], members: &[u8]) {
hub.set_topology(
protocol,
edges
.iter()
.map(|(a, bs)| (id(*a), bs.iter().map(|b| id(*b)).collect()))
.collect(),
members.iter().map(|p| id(*p)).collect(),
);
}
#[tokio::test]
async fn ecmp_is_per_flow_direct_wins_and_existing_tunnel_survives_changes() {
let hub = RelayHub::new(network(), peer(1));
let b = Wire::new(2, true);
let c = Wire::new(3, true);
hub.set_direct(peer(2), "ip", b.clone());
hub.set_direct(peer(3), "ip", c.clone());
topology(&hub, "ip", &[(2, &[4]), (3, &[4])], &[1, 2, 3, 4]);
let link = hub.link(peer(4), "ip");
let mut chosen = HashMap::new();
for _ in 0..8 {
for flow in 0..100 {
link.send_flow(Bytes::from_static(b"ciphertext"), flow)
.unwrap();
}
for (hop, wire) in [(2, &b), (3, &c)] {
for frame in wire.take() {
let header = envelope::decode(&frame).unwrap();
assert_eq!(header.source, id(1));
assert_eq!(header.destination, id(4));
if let Some(old) = chosen.insert(header.flow, hop) {
assert_eq!(old, hop);
}
}
}
}
assert_eq!(
chosen.values().copied().collect::<HashSet<_>>(),
HashSet::from([2, 3])
);
let direct = Wire::new(4, true);
hub.set_direct(peer(4), "ip", direct.clone());
link.send_flow(Bytes::from_static(b"direct"), 8).unwrap();
assert_eq!(direct.take().len(), 1);
assert!(b.take().is_empty() && c.take().is_empty());
hub.clear_direct(peer(4), "ip");
b.dead.store(true, Ordering::Relaxed);
for flow in 0..100 {
link.send_flow(Bytes::from_static(b"fallback"), flow)
.unwrap();
}
assert_eq!(c.take().len(), 100);
hub.clear_direct(peer(2), "ip");
topology(&hub, "ip", &[(3, &[])], &[1, 2, 3, 4]);
assert!(link.send(Bytes::new()).is_err());
assert!(!link.is_closed());
assert!(Arc::ptr_eq(&link, &hub.link(peer(4), "ip")));
hub.remove_peer(peer(4));
assert!(link.is_closed());
tokio::time::timeout(std::time::Duration::from_secs(1), link.closed())
.await
.unwrap();
assert!(link.recv().await.is_none());
hub.close();
}
#[tokio::test]
async fn transport_reader_forwards_without_any_plugin_reader_and_isolates_protocols() {
let hub = RelayHub::new(network(), peer(2));
let a = Wire::new(1, true);
let c = Wire::new(3, true);
hub.set_direct(peer(1), "ip", a.clone());
hub.set_direct(peer(3), "ip", c.clone());
topology(&hub, "ip", &[(3, &[4])], &[1, 2, 3, 4]);
let frame = envelope::encode(id(1), id(4), 91, b"end-to-end encrypted");
let pointer = frame.as_ptr();
a.feed.send(frame).await.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(1), async {
while hub.counters().forwarded == 0 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let sent = c.take();
assert_eq!(sent[0].as_ptr(), pointer);
assert_eq!(
envelope::decode(&sent[0]).unwrap().remaining,
ROUTING_HOP_LIMIT - 1
);
assert_eq!(&sent[0][RELAY_OVERHEAD..], b"end-to-end encrypted");
let isolated = hub.link(peer(4), "other-ip");
assert!(isolated.send(Bytes::new()).is_err());
assert_eq!(hub.counters().forwarded, 1);
hub.close();
}
#[tokio::test]
async fn local_delivery_is_bounded_and_malformed_or_unknown_sources_are_dropped() {
let hub = RelayHub::new(network(), peer(2));
let a = Wire::new(1, true);
hub.set_direct(peer(1), "ip", a);
let link = hub.link(peer(1), "ip");
let plane = link.plane.clone();
for _ in 0..INBOX + 1 {
plane.receive(id(1), envelope::encode(id(1), id(2), 0, b"ip"));
}
assert_eq!(hub.counters().dropped_congested, 1);
for _ in 0..INBOX {
assert_eq!(link.recv().await.unwrap(), Bytes::from_static(b"ip"));
}
plane.receive(id(1), Bytes::from_static(b"broken"));
plane.receive(id(1), envelope::encode(id(9), id(2), 0, b"unknown member"));
assert_eq!(hub.counters().dropped_unknown, 2);
plane.receive(
id(1),
envelope::encode(id(1), id(9), 0, b"unknown destination"),
);
assert_eq!(hub.counters().dropped_no_link, 1);
hub.close();
}
#[tokio::test]
async fn inconsistent_tables_cannot_loop_beyond_the_hop_limit() {
let a = RelayHub::new(network(), peer(1));
let b = RelayHub::new(network(), peer(2));
let ab = Wire::new(2, true);
let ba = Wire::new(1, true);
a.set_direct(peer(2), "ip", ab.clone());
b.set_direct(peer(1), "ip", ba.clone());
// Deliberately inconsistent snapshots while updates are in flight.
topology(&a, "ip", &[(2, &[4])], &[1, 2, 3, 4]);
topology(&b, "ip", &[(1, &[4])], &[1, 2, 3, 4]);
let ap = a.link(peer(4), "ip").plane.clone();
let bp = b.link(peer(4), "ip").plane.clone();
let mut frame = envelope::encode(id(3), id(4), 7, b"transit");
for turn in 0..ROUTING_HOP_LIMIT {
let (plane, incoming, wire) = if turn % 2 == 0 {
(&ap, id(2), &ab)
} else {
(&bp, id(1), &ba)
};
plane.receive(incoming, frame);
let sent = wire.take();
if turn == ROUTING_HOP_LIMIT - 1 {
assert!(sent.is_empty());
break;
}
assert_eq!(sent.len(), 1);
frame = sent.into_iter().next().unwrap();
}
assert_eq!(
a.counters().forwarded + b.counters().forwarded,
u64::from(ROUTING_HOP_LIMIT - 1)
);
assert_eq!(
a.counters().dropped_hop_limit + b.counters().dropped_hop_limit,
1
);
a.close();
b.close();
}
/// Measures the actual synchronous transit routine, including header checks,
/// snapshot lookup, ECMP, in-place TTL update and mock transport submission.
/// Packet construction, encryption and socket I/O are outside this measurement.
#[tokio::test]
#[ignore = "manual release-mode forwarding microbenchmark"]
async fn forwarding_benchmark() {
let hub = RelayHub::new(network(), peer(2));
let wire = Wire::new(3, false);
hub.set_direct(peer(3), "ip", wire.clone());
let alternate = Wire::new(5, false);
hub.set_direct(peer(5), "ip", alternate.clone());
topology(&hub, "ip", &[(3, &[4]), (5, &[4])], &[1, 2, 3, 4, 5]);
let plane = hub.link(peer(4), "ip").plane.clone();
let source = id(1);
let destination = id(4);
let incoming = id(1);
for size in [64, 1280] {
let mut elapsed = std::time::Duration::ZERO;
const COUNT: usize = 10000;
const BATCHES: usize = 100;
for _ in 0..BATCHES {
let frames: Vec<_> = (0..COUNT)
.map(|flow| envelope::encode(source, destination, flow as u64, &vec![42; size]))
.collect();
let start = std::time::Instant::now();
for frame in frames {
plane.receive(incoming, std::hint::black_box(frame));
}
elapsed += start.elapsed();
}
let count = (COUNT * BATCHES) as f64;
println!(
"transit {size} B: {:.1} ns/packet, {:.2} Mpps (1M packets, two equal paths)",
elapsed.as_nanos() as f64 / count,
count / elapsed.as_secs_f64() / 1e6
);
}
assert_eq!(
wire.count.load(Ordering::Relaxed) + alternate.count.load(Ordering::Relaxed),
2000000
);
hub.close();
}
@@ -0,0 +1,84 @@
//! Fixed header. Transit changes one byte in an owned receive buffer.
use super::{FlowId, PeerId};
use crate::config::{MAX_DATA_DATAGRAM, ROUTING_HOP_LIMIT};
use bytes::{BufMut, Bytes, BytesMut};
pub(crate) const HEADER: usize = 2 + 32 + 32 + 8;
const VERSION: u8 = 1;
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Header {
pub source: PeerId,
pub destination: PeerId,
pub remaining: u8,
pub flow: FlowId,
}
pub(crate) fn decode(bytes: &[u8]) -> Option<Header> {
if bytes.len() < HEADER
|| bytes.len() > MAX_DATA_DATAGRAM
|| bytes[0] != VERSION
|| bytes[1] == 0
|| bytes[1] > ROUTING_HOP_LIMIT
{
return None;
}
Some(Header {
source: bytes[2..34].try_into().ok()?,
destination: bytes[34..66].try_into().ok()?,
remaining: bytes[1],
flow: u64::from_be_bytes(bytes[66..74].try_into().ok()?),
})
}
pub(crate) fn encode(source: PeerId, destination: PeerId, flow: FlowId, payload: &[u8]) -> Bytes {
let mut bytes = BytesMut::with_capacity(HEADER + payload.len());
bytes.put_u8(VERSION);
bytes.put_u8(ROUTING_HOP_LIMIT);
bytes.extend_from_slice(&source);
bytes.extend_from_slice(&destination);
bytes.put_u64(flow);
bytes.extend_from_slice(payload);
bytes.freeze()
}
pub(crate) fn decrement(bytes: Bytes) -> Bytes {
let mut bytes = match bytes.try_into_mut() {
Ok(bytes) => bytes,
Err(bytes) => BytesMut::from(bytes.as_ref()),
};
// Called only after decode and after excluding remaining <= 1.
bytes[1] -= 1;
bytes.freeze()
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn transit_reuses_the_buffer_and_rejects_invalid_headers() {
let frame = encode([1; 32], [2; 32], 17, &[42; 1280]);
let address = frame.as_ptr();
let frame = decrement(frame);
assert_eq!(
frame.as_ptr(),
address,
"no payload copy for an owned buffer"
);
assert_eq!(decode(&frame).unwrap().remaining, ROUTING_HOP_LIMIT - 1);
assert_eq!(&frame[HEADER..], &[42; 1280]);
for length in 0..HEADER {
assert!(decode(&frame[..length]).is_none());
}
for ttl in [0, ROUTING_HOP_LIMIT + 1] {
let mut bad = frame.to_vec();
bad[1] = ttl;
assert!(decode(&bad).is_none());
}
let mut bad = frame.to_vec();
bad[0] = 99;
assert!(decode(&bad).is_none());
assert!(decode(&vec![1; MAX_DATA_DATAGRAM + 1]).is_none());
}
}
@@ -0,0 +1,125 @@
//! Optional IP flow classifier for IP plugins, called before encryption.
//! The router itself treats this identifier and the payload as opaque.
use super::FlowId;
fn hash(parts: &[&[u8]]) -> FlowId {
let mut value = 0xcbf29ce484222325u64;
for part in parts {
for byte in *part {
value = (value ^ u64::from(*byte)).wrapping_mul(0x100000001b3);
}
}
value
}
/// Stable directional IP flow hash, independent of TCP sequence numbers,
/// checksums and packet contents. Fragmented traffic uses addresses/protocol
/// instead of ports: even non-initial fragments and subsequent fragmented
/// datagrams stay together. This deliberately coalesces fragmented flows.
/// Malformed packets get a coarse hash; parsing is bounded and never allocates.
pub fn ip_flow(packet: &[u8]) -> FlowId {
match packet.first().map(|b| b >> 4) {
Some(4) if packet.len() >= 20 => {
let addresses = &packet[12..20];
let protocol = &packet[9..10];
let offset = usize::from(packet[0] & 15) * 4;
if u16::from_be_bytes([packet[6], packet[7]]) & 0x3fff != 0 {
return hash(&[addresses, protocol]);
}
if offset >= 20
&& matches!(packet[9], 6 | 17)
&& let Some(ports) = packet.get(offset..offset + 4)
{
return hash(&[addresses, protocol, ports]);
}
hash(&[addresses, protocol])
}
Some(6) if packet.len() >= 40 => {
let addresses = &packet[8..40];
let mut protocol = packet[6];
let mut offset = 40;
for _ in 0..8 {
match protocol {
44 => {
if let Some(fragment) = packet.get(offset..offset + 8) {
return hash(&[addresses, &fragment[..1]]);
}
break;
}
0 | 43 | 60 | 51 => {
let Some(header) = packet.get(offset..offset + 2) else {
break;
};
let size = if protocol == 51 {
(usize::from(header[1]) + 2) * 4
} else {
(usize::from(header[1]) + 1) * 8
};
protocol = header[0];
offset += size;
}
6 | 17 => {
if let Some(ports) = packet.get(offset..offset + 4) {
return hash(&[addresses, &[protocol], ports]);
}
break;
}
_ => break,
}
}
hash(&[addresses, &[protocol]])
}
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tcp_flow_ignores_packet_contents_but_distinguishes_ports() {
let mut packet = [0u8; 80];
packet[0] = 0x45;
packet[9] = 6;
packet[12..24].copy_from_slice(&[10, 0, 0, 1, 10, 0, 0, 2, 0, 22, 1, 2]);
let flow = ip_flow(&packet);
packet[4] = 123; // IP id changes for each unfragmented packet.
packet[24..].fill(47);
assert_eq!(flow, ip_flow(&packet));
packet[23] = 3;
assert_ne!(flow, ip_flow(&packet));
packet[6] = 0x20;
let fragment_flow = ip_flow(&packet);
packet[6] = 0;
packet[7] = 1;
packet[20..].fill(99);
assert_eq!(fragment_flow, ip_flow(&packet));
packet[4] = 97;
assert_eq!(fragment_flow, ip_flow(&packet));
}
#[test]
fn ipv6_extensions_and_fragments_are_bounded_and_stable() {
let mut packet = [0u8; 80];
packet[0] = 0x60;
packet[6] = 0;
packet[40] = 17;
packet[48..52].copy_from_slice(&[1, 2, 3, 4]);
let flow = ip_flow(&packet);
packet[52..].fill(5);
assert_eq!(flow, ip_flow(&packet));
packet[50] = 9;
assert_ne!(flow, ip_flow(&packet));
packet[6] = 44;
packet[40] = 6;
let flow = ip_flow(&packet);
packet[42] = 32;
packet[48..].fill(3);
assert_eq!(flow, ip_flow(&packet));
for len in 0..packet.len() {
let _ = ip_flow(&packet[..len]);
}
}
}
+131
View File
@@ -0,0 +1,131 @@
//! Transport-independent routing over opaque identities. Tables are built on
//! topology changes, never on the packet path. No WireGuard or iroh APIs here.
pub(crate) mod envelope;
pub mod flow;
use std::collections::{BTreeSet, HashMap, VecDeque};
/// Opaque authenticated identity; the transport adapter validates its keys.
pub type PeerId = [u8; 32];
/// Stable flow identifier, carried unchanged between transit routers.
pub type FlowId = u64;
/// A shortest route with all equal-cost first hops in stable order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Route {
/// Number of transport links to the destination.
pub hops: u8,
/// Equal-cost first hops. Direct destinations have one entry.
pub next_hops: Box<[PeerId]>,
}
/// Immutable shortest-path table, replaced when connectivity changes.
#[derive(Debug, Clone, Default)]
pub struct RoutingTable {
routes: HashMap<PeerId, Route>,
}
impl RoutingTable {
/// Computes shortest directed paths bounded by `hop_limit`.
/// The caller supplies only authenticated, fresh, compatible edges.
pub fn build(local: PeerId, graph: &HashMap<PeerId, Vec<PeerId>>, hop_limit: u8) -> Self {
let mut distances = HashMap::from([(local, 0u8)]);
let mut first_hops: HashMap<PeerId, BTreeSet<PeerId>> = HashMap::new();
let mut pending = VecDeque::from([local]);
while let Some(node) = pending.pop_front() {
let distance = distances[&node];
if distance >= hop_limit {
continue;
}
let inherited = first_hops.get(&node).cloned().unwrap_or_default();
for &next in graph.get(&node).into_iter().flatten() {
let next_distance = distance + 1;
match distances.get(&next) {
Some(&known) if known < next_distance => continue,
None => {
distances.insert(next, next_distance);
pending.push_back(next);
}
_ => {}
}
let hops = first_hops.entry(next).or_default();
if node == local {
hops.insert(next);
} else {
hops.extend(inherited.iter().copied());
}
}
}
Self {
routes: first_hops
.into_iter()
.map(|(peer, hops)| {
(
peer,
Route {
hops: distances[&peer],
next_hops: hops.into_iter().collect(),
},
)
})
.collect(),
}
}
/// Precomputed route to a destination.
pub fn get(&self, destination: &PeerId) -> Option<&Route> {
self.routes.get(destination)
}
/// All reachable destinations, excluding ourselves.
pub fn iter(&self) -> impl Iterator<Item = (&PeerId, &Route)> {
self.routes.iter()
}
}
/// Stable ECMP mixing, without random state or hashing the payload per hop.
pub(crate) fn mix64(mut value: u64) -> u64 {
value ^= value >> 30;
value = value.wrapping_mul(0xbf58476d1ce4e5b9);
value ^= value >> 27;
value = value.wrapping_mul(0x94d049bb133111eb);
value ^ (value >> 31)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn p(n: u8) -> PeerId {
[n; 32]
}
#[test]
fn shortest_paths_keep_equal_cost_hops_and_prefer_direct_links() {
let mut graph = HashMap::from([
(p(1), vec![p(3), p(2)]),
(p(2), vec![p(4)]),
(p(3), vec![p(4)]),
(p(4), vec![p(5)]),
(p(5), vec![p(1)]),
]);
let routes = RoutingTable::build(p(1), &graph, 16);
assert_eq!(routes.get(&p(5)).unwrap().hops, 3);
assert_eq!(&*routes.get(&p(5)).unwrap().next_hops, &[p(2), p(3)]);
assert!(routes.get(&p(1)).is_none());
assert!(routes.get(&p(9)).is_none());
graph.get_mut(&p(1)).unwrap().push(p(5));
assert_eq!(
RoutingTable::build(p(1), &graph, 16).get(&p(5)).unwrap(),
&Route {
hops: 1,
next_hops: Box::new([p(5)])
}
);
}
#[test]
fn topology_removal_and_hop_budget_remove_impossible_routes() {
let mut graph = HashMap::from([(p(1), vec![p(2)]), (p(2), vec![p(3)]), (p(3), vec![p(4)])]);
assert!(RoutingTable::build(p(1), &graph, 2).get(&p(4)).is_none());
assert!(RoutingTable::build(p(1), &graph, 3).get(&p(4)).is_some());
graph.remove(&p(2));
assert!(RoutingTable::build(p(1), &graph, 16).get(&p(4)).is_none());
}
}
@@ -0,0 +1,234 @@
//! Bounded fragmentation of opaque transport payloads, below peer relaying.
//!
//! Data ALPN v3: u64 packet ID, u32 total length, u32 byte offset (big endian),
//! followed by payload. Each QUIC connection owns its IDs and reassembly state.
//! There is no retransmission: one missing fragment loses one datagram only.
use std::collections::{HashMap, VecDeque};
use bytes::{BufMut, Bytes, BytesMut};
use tokio::time::Instant;
use crate::config::{
DATA_REASSEMBLY_TTL, DATA_RECENT_IDS, MAX_DATA_ASSEMBLIES, MAX_DATA_DATAGRAM,
MAX_DATA_FRAGMENTS, MAX_DATA_REASSEMBLY_BYTES,
};
pub(super) const HEADER: usize = 16;
pub(super) fn encode(id: u64, total: usize, offset: usize, payload: &[u8]) -> Bytes {
let mut frame = BytesMut::with_capacity(HEADER + payload.len());
frame.put_u64(id);
frame.put_u32(total as u32);
frame.put_u32(offset as u32);
frame.extend_from_slice(payload);
frame.freeze()
}
#[derive(Debug)]
struct Assembly {
started: Instant,
bytes: Vec<u8>,
ranges: Vec<(usize, usize)>,
received: usize,
}
#[derive(Debug, Default)]
pub(super) struct Reassembler {
packets: HashMap<u64, Assembly>,
buffered: usize,
recent: VecDeque<u64>,
}
impl Reassembler {
fn remember(&mut self, id: u64) {
if self.recent.len() == DATA_RECENT_IDS {
self.recent.pop_front();
}
self.recent.push_back(id);
}
fn remove(&mut self, id: u64) -> Option<Assembly> {
let packet = self.packets.remove(&id)?;
self.buffered -= packet.bytes.len();
self.remember(id);
Some(packet)
}
pub(super) fn expire(&mut self, now: Instant) {
let expired: Vec<_> = self
.packets
.iter()
.filter(|(_, p)| now.duration_since(p.started) >= DATA_REASSEMBLY_TTL)
.map(|(id, _)| *id)
.collect();
for id in expired {
self.remove(id);
}
}
pub(super) fn push(&mut self, frame: Bytes, now: Instant) -> Option<Bytes> {
self.expire(now);
let id = u64::from_be_bytes(frame.get(..8)?.try_into().ok()?);
let total = u32::from_be_bytes(frame.get(8..12)?.try_into().ok()?) as usize;
let offset = u32::from_be_bytes(frame.get(12..HEADER)?.try_into().ok()?) as usize;
let payload = frame.get(HEADER..)?;
let end = offset.checked_add(payload.len())?;
if total > MAX_DATA_DATAGRAM
|| end > total
|| (payload.is_empty() && total != 0)
|| self.recent.contains(&id)
{
return None;
}
if !self.packets.contains_key(&id) {
if offset == 0 && end == total {
self.remember(id);
return Some(frame.slice(HEADER..));
}
// The length is validated before allocating. Drop the oldest
// incomplete packet when the per-link byte or entry budget is full.
while self.packets.len() >= MAX_DATA_ASSEMBLIES
|| self.buffered + total > MAX_DATA_REASSEMBLY_BYTES
{
let oldest = self
.packets
.iter()
.min_by_key(|(_, p)| p.started)
.map(|(id, _)| *id)?;
self.remove(oldest);
}
self.packets.insert(
id,
Assembly {
started: now,
bytes: vec![0; total],
ranges: Vec::new(),
received: 0,
},
);
self.buffered += total;
}
let packet = self.packets.get_mut(&id)?;
if packet.bytes.len() != total {
self.remove(id);
return None;
}
for &(start, stop) in &packet.ranges {
if start == offset && stop == end && packet.bytes[offset..end] == *payload {
return None; // a duplicate does not reset the expiry time
}
if offset < stop && end > start {
self.remove(id); // conflicting or overlapping fragments
return None;
}
}
if packet.ranges.len() == MAX_DATA_FRAGMENTS {
self.remove(id);
return None;
}
packet.bytes[offset..end].copy_from_slice(payload);
packet.ranges.push((offset, end));
packet.received += payload.len();
if packet.received == total {
return self.remove(id).map(|p| Bytes::from(p.bytes));
}
None
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn reordered_duplicates_and_changing_fragment_sizes_reassemble_once() {
let mut rx = Reassembler::default();
let now = Instant::now();
let packet: Vec<u8> = (0..9000).map(|i| (i % 251) as u8).collect();
let mut parts = Vec::new();
let mut offset = 0;
for size in [1100, 700, 1100, 1100, 700, 1100, 1100, 1100, 1000] {
parts.push(encode(
7,
packet.len(),
offset,
&packet[offset..offset + size],
));
offset += size;
}
assert_eq!(offset, packet.len());
for part in parts[1..].iter().rev() {
assert!(rx.push(part.clone(), now).is_none());
assert!(rx.push(part.clone(), now).is_none());
}
assert_eq!(rx.push(parts[0].clone(), now).unwrap().as_ref(), packet);
assert!(rx.push(parts[0].clone(), now).is_none());
assert_eq!(rx.buffered, 0);
assert!(rx.packets.is_empty());
}
#[test]
fn a_missing_fragment_never_blocks_the_next_packet_and_expires() {
let mut rx = Reassembler::default();
let now = Instant::now();
assert!(rx.push(encode(1, 1280, 0, &[1; 800]), now).is_none());
assert_eq!(
rx.push(encode(2, 4, 0, b"next"), now).unwrap(),
&b"next"[..]
);
rx.expire(now + DATA_REASSEMBLY_TTL);
assert!(rx.packets.is_empty());
assert_eq!(rx.buffered, 0);
assert!(
rx.push(encode(1, 1280, 800, &[1; 480]), now + DATA_REASSEMBLY_TTL)
.is_none()
);
}
#[test]
fn malformed_conflicting_and_oversized_fragments_are_bounded() {
let now = Instant::now();
let mut rx = Reassembler::default();
for len in 0..HEADER {
assert!(rx.push(Bytes::from(vec![0xff; len]), now).is_none());
}
assert!(
rx.push(encode(1, MAX_DATA_DATAGRAM + 1, 0, b"x"), now)
.is_none()
);
assert!(rx.push(encode(2, 100, 100, b"x"), now).is_none());
assert!(rx.push(encode(3, 100, 0, b""), now).is_none());
assert_eq!(rx.buffered, 0);
for (id, bad) in [
(4, encode(4, 100, 0, b"different")),
(5, encode(5, 100, 3, b"overlap")),
(6, encode(6, 101, 9, b"changed total")),
] {
assert!(rx.push(encode(id, 100, 0, b"123456789"), now).is_none());
assert!(rx.push(bad, now).is_none());
assert_eq!(rx.buffered, 0);
}
for id in 100..400 {
assert!(
rx.push(encode(id, MAX_DATA_DATAGRAM, 0, b"x"), now)
.is_none()
);
assert!(rx.buffered <= MAX_DATA_REASSEMBLY_BYTES);
assert!(rx.packets.len() <= MAX_DATA_ASSEMBLIES);
assert!(rx.recent.len() <= DATA_RECENT_IDS);
}
}
#[test]
fn excessive_fragment_counts_are_discarded() {
let mut rx = Reassembler::default();
let now = Instant::now();
for offset in 0..=MAX_DATA_FRAGMENTS {
assert!(rx.push(encode(1, 1000, offset, b"x"), now).is_none());
}
assert!(rx.packets.is_empty());
assert_eq!(rx.buffered, 0);
}
}
@@ -20,13 +20,14 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use bytes::Bytes;
use iroh::EndpointId;
use iroh::endpoint::{Connection, RecvStream, SendStream};
use crate::BoxFuture;
use crate::config::Limits;
use crate::config::{DATA_REASSEMBLY_TTL, Limits, MAX_DATA_DATAGRAM, MAX_DATA_FRAGMENTS};
use crate::error::ProtocolError;
use crate::identity::{NetworkId, NetworkKeys};
use crate::net::EndpointAdapter;
@@ -36,6 +37,7 @@ use crate::proto::message::{
};
use crate::proto::{read_frame, write_frame};
use super::fragments::{self, Reassembler};
use super::{InboundLink, PacketLink, PacketTransport, SharedLink, TransportError};
/// What the iroh transport needs from the agent.
@@ -61,6 +63,8 @@ pub struct IrohLink {
peer: EndpointId,
conn: Connection,
max_datagram: usize,
next_packet: AtomicU64,
reassembly: tokio::sync::Mutex<Reassembler>,
// Kept alive so the peer sees the channel as open; the connection closes
// when the link is dropped.
_send: tokio::sync::Mutex<SendStream>,
@@ -76,14 +80,16 @@ impl IrohLink {
send: SendStream,
recv: RecvStream,
) -> Self {
let local_limit = conn.max_datagram_size().unwrap_or(0);
// Both ends must agree, so the smaller limit wins.
let max_datagram = local_limit.min(peer_limit);
// The negotiated limit is for reassembled payloads, never a snapshot
// of the initial path (which may still be a relay or another VPN).
let max_datagram = MAX_DATA_DATAGRAM.min(peer_limit);
Self {
network,
peer,
conn,
max_datagram,
next_packet: AtomicU64::new(0),
reassembly: tokio::sync::Mutex::new(Reassembler::default()),
_send: tokio::sync::Mutex::new(send),
_recv: tokio::sync::Mutex::new(recv),
}
@@ -110,17 +116,65 @@ impl PacketLink for IrohLink {
limit: self.max_datagram,
});
}
self.conn.send_datagram(payload).map_err(|err| {
use iroh::endpoint::SendDatagramError;
match err {
SendDatagramError::ConnectionLost(_) => TransportError::Closed,
other => TransportError::Other(other.to_string()),
let id = self.next_packet.fetch_add(1, Ordering::Relaxed);
let mut offset: usize = 0;
let mut attempts = 0;
// Re-read the current path's capacity for every fragment. A migration
// can shrink it even between two send_datagram calls.
for _ in 0..MAX_DATA_FRAGMENTS {
loop {
let capacity = self
.conn
.max_datagram_size()
.unwrap_or(0)
.checked_sub(fragments::HEADER)
.filter(|n| *n > 0)
.ok_or_else(|| {
TransportError::Other("QUIC path cannot carry data fragments".into())
})?;
let end = offset.saturating_add(capacity).min(payload.len());
let frame = fragments::encode(id, payload.len(), offset, &payload[offset..end]);
match self.conn.send_datagram(frame) {
Ok(()) => {
offset = end;
break;
}
Err(iroh::endpoint::SendDatagramError::TooLarge) if attempts < 3 => {
attempts += 1;
}
Err(iroh::endpoint::SendDatagramError::ConnectionLost(_)) => {
return Err(TransportError::Closed);
}
Err(err) => return Err(TransportError::Other(err.to_string())),
}
}
})
if offset == payload.len() {
return Ok(());
}
}
Err(TransportError::Other(
"QUIC path needs too many fragments".into(),
))
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move { self.conn.read_datagram().await.ok() })
Box::pin(async move {
let mut reassembly = self.reassembly.lock().await;
loop {
match tokio::time::timeout(DATA_REASSEMBLY_TTL, self.conn.read_datagram()).await {
Ok(Ok(frame)) => {
if let Some(packet) = reassembly.push(frame, tokio::time::Instant::now()) {
return Some(packet);
}
}
Ok(Err(_)) => {
*reassembly = Reassembler::default();
return None;
}
Err(_) => reassembly.expire(tokio::time::Instant::now()),
}
}
})
}
fn closed(&self) -> BoxFuture<'_, ()> {
@@ -208,10 +262,14 @@ impl IrohTransport {
}
let serves = self.lookup.serves(outcome.network_id, &open.protocol).await;
let max_datagram = conn.max_datagram_size().unwrap_or(0);
if open.max_datagram == 0 || conn.max_datagram_size().is_none() {
return Err(TransportError::Other(
"data channel has no datagram support".into(),
));
}
let ack = DataOpenAck {
accepted: serves,
max_datagram: max_datagram as u32,
max_datagram: MAX_DATA_DATAGRAM as u32,
};
write_frame(
&mut send,
@@ -226,7 +284,14 @@ impl IrohTransport {
return Err(TransportError::Declined(open.protocol));
}
let link = IrohLink::new(outcome.network_id, peer, conn, usize::MAX, send, recv);
let link = IrohLink::new(
outcome.network_id,
peer,
conn,
open.max_datagram as usize,
send,
recv,
);
Ok(InboundLink {
network: outcome.network_id,
peer,
@@ -289,6 +354,7 @@ impl PacketTransport for IrohTransport {
let open = DataOpen {
protocol: protocol.to_string(),
max_datagram: MAX_DATA_DATAGRAM as u32,
};
write_frame(
&mut send,
@@ -310,6 +376,12 @@ impl PacketTransport for IrohTransport {
return Err(TransportError::Declined(protocol.to_string()));
}
if ack.max_datagram == 0 || conn.max_datagram_size().is_none() {
return Err(TransportError::Other(
"data channel has no datagram support".into(),
));
}
let link = IrohLink::new(network, peer, conn, ack.max_datagram as usize, send, recv);
Ok(Arc::new(link) as SharedLink)
})
@@ -29,8 +29,11 @@
//! encrypted by the transport, and scoped to exactly one network, one peer and
//! one plugin protocol.
mod fragments;
pub mod iroh_link;
use std::sync::Arc;
use bytes::Bytes;
use iroh::EndpointId;
@@ -77,8 +80,8 @@ pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
/// The largest datagram this link can carry, in bytes.
///
/// A plugin must size its own packets to fit, because there is no
/// fragmentation here.
/// This is the logical payload limit. A transport can fragment underneath
/// it so path MTU changes do not force a plugin to resize the host interface.
fn max_datagram_size(&self) -> usize;
/// Sends one datagram.
@@ -87,6 +90,16 @@ pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
/// handed to the transport, nothing more.
fn send(&self, payload: Bytes) -> Result<(), TransportError>;
/// Sends a datagram with an opaque stable flow identifier. Plugins derive
/// this before encryption; transports that do not route can ignore it.
fn send_flow(
&self,
payload: Bytes,
_flow: super::routing::FlowId,
) -> Result<(), TransportError> {
self.send(payload)
}
/// Receives the next datagram, or `None` once the link is finished.
fn recv(&self) -> BoxFuture<'_, Option<Bytes>>;
@@ -144,3 +157,51 @@ pub trait PacketTransport: Send + Sync + std::fmt::Debug + 'static {
protocol: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>>;
}
/// A composite packet transport that dispatches requests by protocol id.
#[derive(Debug)]
pub struct MultiPacketTransport {
default: Arc<dyn PacketTransport>,
by_protocol: std::collections::HashMap<String, Arc<dyn PacketTransport>>,
}
impl MultiPacketTransport {
/// Creates a new composite transport with a default fallback transport.
pub fn new(
default: Arc<dyn PacketTransport>,
by_protocol: std::collections::HashMap<String, Arc<dyn PacketTransport>>,
) -> Self {
Self {
default,
by_protocol,
}
}
/// Access the default underlying transport.
pub fn default_transport(&self) -> &Arc<dyn PacketTransport> {
&self.default
}
}
impl PacketTransport for MultiPacketTransport {
fn name(&self) -> &str {
"multi"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn open<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
protocol: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
if let Some(transport) = self.by_protocol.get(protocol) {
transport.open(network, peer, protocol)
} else {
self.default.open(network, peer, protocol)
}
}
}
+364
View File
@@ -0,0 +1,364 @@
//! Generic harness for IP tunnel plugins.
//!
//! Implementing [`super::IpPlugin`] directly requires managing per-peer background
//! tasks, synchronization, packet queues, and graceful shutdown.
//!
//! [`TunnelCodec`] provides a simplified interface where a plugin author only
//! defines cryptographic transformation and handshake payloads.
//! [`GenericTunnelPlugin`] adapts any [`TunnelCodec`] into a full [`super::IpPlugin`].
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use bytes::Bytes;
use iroh::EndpointId;
use tokio::task::JoinHandle;
use super::{IpPlugin, PluginCapability, PluginContext, PluginError, ProtocolOption, SharedLink};
use crate::BoxFuture;
use crate::identity::NetworkId;
/// The simplified contract for an IP tunnel protocol implementation.
///
/// Implementors only handle key announcements, link lifecycle events,
/// and packet encrypt/decrypt. Concurrency and task management are handled
/// by [`GenericTunnelPlugin`].
pub trait TunnelCodec: Send + Sync + 'static {
/// Protocol identifier, e.g. `tcp-tls`.
fn protocol_id(&self) -> &str;
/// Protocol wire version.
fn protocol_version(&self) -> u16;
/// Options accepted by the protocol.
fn options(&self) -> &'static [ProtocolOption] {
&[]
}
/// Generates local capability announcement data for a network.
fn local_capability_data(&self, network: NetworkId) -> Result<Vec<u8>, PluginError>;
/// Handles a peer's announced capability payload.
fn on_peer_capability_data(
&self,
network: NetworkId,
peer: EndpointId,
data: &[u8],
) -> Result<(), PluginError>;
/// Called when an authenticated link to a peer is established.
fn on_peer_link_up(&self, network: NetworkId, peer: EndpointId, link: &SharedLink) {
let _ = (network, peer, link);
}
/// Called when a peer goes away.
fn on_peer_down(&self, network: NetworkId, peer: EndpointId) {
let _ = (network, peer);
}
/// Encrypts an outbound IP packet into a payload to send over the link.
fn encrypt(
&self,
network: NetworkId,
peer: EndpointId,
packet: &[u8],
) -> Result<Bytes, PluginError>;
/// Decrypts an inbound payload from the link into an IP packet.
fn decrypt(
&self,
network: NetworkId,
peer: EndpointId,
payload: &[u8],
) -> Result<Bytes, PluginError>;
/// Called during shutdown.
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
Box::pin(async {})
}
}
struct TunnelPeerState {
link: SharedLink,
tx_packets: AtomicU64,
rx_packets: AtomicU64,
dropped: AtomicU64,
established_at: std::time::Instant,
}
/// Statistics and status for one active peer in [`GenericTunnelPlugin`].
#[derive(Debug, Clone)]
pub struct TunnelPeerReport {
/// Peer endpoint identity.
pub peer: EndpointId,
/// Protocol identifier.
pub protocol: String,
/// Human-readable path description.
pub path: String,
/// Packets successfully encrypted and sent.
pub tx_packets: u64,
/// Packets successfully received and decrypted.
pub rx_packets: u64,
/// Packets dropped due to encryption/decryption/send failures.
pub dropped: u64,
/// Uptime in seconds since the data link was established.
pub uptime_secs: u64,
}
/// Generic wrapper that turns a [`TunnelCodec`] into a full [`IpPlugin`].
pub struct GenericTunnelPlugin<C: TunnelCodec> {
codec: Arc<C>,
context: Mutex<Option<PluginContext>>,
links: RwLock<HashMap<(NetworkId, EndpointId), Arc<TunnelPeerState>>>,
tasks: Mutex<HashMap<(NetworkId, EndpointId), JoinHandle<()>>>,
}
impl<C: TunnelCodec> GenericTunnelPlugin<C> {
/// Wraps `codec` into an [`IpPlugin`].
pub fn new(codec: C) -> Self {
Self {
codec: Arc::new(codec),
context: Mutex::new(None),
links: RwLock::new(HashMap::new()),
tasks: Mutex::new(HashMap::new()),
}
}
/// Returns a reference to the underlying codec.
pub fn codec(&self) -> &C {
&self.codec
}
/// Returns the active peers with established data links for a network.
pub fn active_peers(&self, network: NetworkId) -> Vec<TunnelPeerReport> {
let guard = match self.links.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard
.iter()
.filter(|((net, _), _)| *net == network)
.map(|((_, peer), state)| TunnelPeerReport {
peer: *peer,
protocol: self.codec.protocol_id().to_string(),
path: state.link.path_description(),
tx_packets: state.tx_packets.load(Ordering::Relaxed),
rx_packets: state.rx_packets.load(Ordering::Relaxed),
dropped: state.dropped.load(Ordering::Relaxed),
uptime_secs: state.established_at.elapsed().as_secs(),
})
.collect()
}
}
impl<C: TunnelCodec> std::fmt::Debug for GenericTunnelPlugin<C> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GenericTunnelPlugin")
.field("protocol_id", &self.codec.protocol_id())
.field("protocol_version", &self.codec.protocol_version())
.finish()
}
}
impl<C: TunnelCodec> IpPlugin for GenericTunnelPlugin<C> {
fn protocol_id(&self) -> &str {
self.codec.protocol_id()
}
fn protocol_version(&self) -> u16 {
self.codec.protocol_version()
}
fn options(&self) -> &'static [ProtocolOption] {
self.codec.options()
}
fn attach(&self, context: PluginContext) {
if let Ok(mut guard) = self.context.lock() {
*guard = Some(context);
}
}
fn local_capability(
&self,
network: NetworkId,
) -> Result<Option<PluginCapability>, PluginError> {
let data = self.codec.local_capability_data(network)?;
Ok(Some(PluginCapability {
protocol: self.codec.protocol_id().to_string(),
version: self.codec.protocol_version(),
enabled: true,
data,
}))
}
fn on_peer_capability(
&self,
network: NetworkId,
peer: EndpointId,
capability: &PluginCapability,
) -> Result<(), PluginError> {
if capability.protocol != self.codec.protocol_id() {
return Ok(());
}
self.codec
.on_peer_capability_data(network, peer, &capability.data)
}
fn carry(&self, network: NetworkId, peer: EndpointId, packet: bytes::Bytes) -> bool {
let state = {
let guard = match self.links.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.get(&(network, peer)).cloned()
};
let Some(state) = state else {
return false;
};
match self.codec.encrypt(network, peer, &packet) {
Ok(ciphertext) => {
if state.link.send(ciphertext).is_ok() {
state.tx_packets.fetch_add(1, Ordering::Relaxed);
true
} else {
state.dropped.fetch_add(1, Ordering::Relaxed);
false
}
}
Err(err) => {
state.dropped.fetch_add(1, Ordering::Relaxed);
tracing::debug!(%peer, %err, "tunnel encrypt failed");
false
}
}
}
fn on_peer_link(&self, network: NetworkId, peer: EndpointId, link: SharedLink) {
let state = Arc::new(TunnelPeerState {
link: Arc::clone(&link),
tx_packets: AtomicU64::new(0),
rx_packets: AtomicU64::new(0),
dropped: AtomicU64::new(0),
established_at: std::time::Instant::now(),
});
{
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.insert((network, peer), Arc::clone(&state));
}
self.codec.on_peer_link_up(network, peer, &link);
let codec = Arc::clone(&self.codec);
let sink = match self.context.lock() {
Ok(g) => g.as_ref().map(|ctx| ctx.packet_sink()),
Err(p) => p.into_inner().as_ref().map(|ctx| ctx.packet_sink()),
};
let rx_link = Arc::clone(&link);
let rx_state = Arc::clone(&state);
let handle = tokio::spawn(async move {
while let Some(payload) = rx_link.recv().await {
match codec.decrypt(network, peer, &payload) {
Ok(packet) => {
rx_state.rx_packets.fetch_add(1, Ordering::Relaxed);
if let Some(ref sink) = sink {
sink.deliver(network, peer, packet).await;
}
}
Err(err) => {
rx_state.dropped.fetch_add(1, Ordering::Relaxed);
tracing::debug!(%peer, %err, "tunnel decrypt failed");
}
}
}
});
let mut guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(old) = guard.insert((network, peer), handle) {
old.abort();
}
}
fn on_peer_gone(&self, network: NetworkId, peer: EndpointId) {
{
let mut guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(task) = guard.remove(&(network, peer)) {
task.abort();
}
}
{
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.remove(&(network, peer));
}
self.codec.on_peer_down(network, peer);
}
fn on_network_deactivated(&self, network: NetworkId) {
let keys_to_remove: Vec<(NetworkId, EndpointId)> = {
let guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard
.keys()
.filter(|(net, _)| *net == network)
.copied()
.collect()
};
{
let mut guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
for key in &keys_to_remove {
if let Some(task) = guard.remove(key) {
task.abort();
}
}
}
{
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.retain(|(net, _), _| *net != network);
}
}
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
Box::pin(async move {
let mut tasks = Vec::new();
{
let mut guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
for (_, task) in guard.drain() {
tasks.push(task);
}
}
for task in tasks {
task.abort();
}
self.codec.shutdown().await;
})
}
}
@@ -12,8 +12,7 @@
//!
//! * *Finding members of a network* — [`NetworkDiscovery::resolve`], keyed by
//! the secret-derived [`DiscoveryKey`]. That is what lives here, and today
//! it is [`StaticBootstrap`] plus a test backend; a DHT backend is future
//! work.
//! it includes [`StaticBootstrap`], [`MainlineDiscovery`] and a test backend.
//! * *Resolving the address of one iroh endpoint* — **iroh's job, not ours**.
//! With [`crate::config::TransportPolicy::N0Defaults`] or `DirectOnly`, iroh
//! publishes and resolves endpoint addresses through Number 0's public
@@ -23,14 +22,18 @@
//! No empty result ever proves a network is empty. It only means "nobody found
//! yet".
//!
//! Mainline DHT discovery is future work and is not implemented here.
//! Mainline rendezvous is opt-in for library callers; the command line enables it.
mod mainline;
pub(crate) mod worker;
pub use mainline::MainlineDiscovery;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use iroh::{EndpointAddr, EndpointId};
use crate::error::Result;
use crate::error::{Error, Result};
use crate::identity::DiscoveryKey;
pub use crate::BoxFuture;
@@ -44,6 +47,18 @@ pub enum CandidateSource {
Discovery,
/// An address hint restored from the disposable cache.
Cache,
/// An author of a signed record: somebody who belongs to this network,
/// learned from state that reached us through anybody.
///
/// It carries no address of its own — the endpoint's own discovery has
/// to resolve it — but knowing that a member exists is what turns a
/// star around whoever was named on the command line into a mesh.
Member,
/// Passed on by a member we are talking to.
///
/// A candidate like any other: an introduction is not a vouching, and
/// membership is still decided by the handshake.
Introduced,
}
/// An unverified candidate peer.
@@ -91,6 +106,23 @@ pub trait NetworkDiscovery: Send + Sync + std::fmt::Debug + 'static {
/// Returns the candidates currently known for `key`.
fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>>;
/// Delivers candidates as they arrive. The default adapts a batch backend;
/// remote backends can override this so the first dial need not wait for all lookups.
fn resolve_into<'a>(
&'a self,
key: DiscoveryKey,
candidates: tokio::sync::mpsc::Sender<Candidate>,
) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
for candidate in self.resolve(key).await? {
if candidates.send(candidate).await.is_err() {
break;
}
}
Ok(())
})
}
}
/// A statically configured list of bootstrap candidates.
@@ -261,13 +293,25 @@ impl NetworkDiscovery for CompositeDiscovery {
fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
let mut tasks = tokio::task::JoinSet::new();
for backend in &self.backends {
// One failing backend must not stop the others.
if let Err(err) = backend.publish(key, addr.clone()).await {
tracing::debug!(backend = backend.name(), %err, "publish failed");
let backend = backend.clone();
let addr = addr.clone();
tasks.spawn(async move { backend.publish(key, addr).await });
}
let mut failed = false;
while let Some(result) = tasks.join_next().await {
if !matches!(result, Ok(Ok(()))) {
failed = true;
}
}
Ok(())
if failed {
Err(Error::Discovery(
"a discovery publication failed; will retry".into(),
))
} else {
Ok(())
}
})
}
@@ -277,11 +321,12 @@ impl NetworkDiscovery for CompositeDiscovery {
endpoint: EndpointId,
) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
let mut tasks = tokio::task::JoinSet::new();
for backend in &self.backends {
if let Err(err) = backend.unpublish(key, endpoint).await {
tracing::debug!(backend = backend.name(), %err, "unpublish failed");
}
let backend = backend.clone();
tasks.spawn(async move { backend.unpublish(key, endpoint).await });
}
while tasks.join_next().await.is_some() {}
Ok(())
})
}
@@ -300,4 +345,25 @@ impl NetworkDiscovery for CompositeDiscovery {
Ok(out)
})
}
fn resolve_into<'a>(
&'a self,
key: DiscoveryKey,
candidates: tokio::sync::mpsc::Sender<Candidate>,
) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
let mut tasks = tokio::task::JoinSet::new();
for backend in &self.backends {
let backend = backend.clone();
let candidates = candidates.clone();
tasks.spawn(async move {
if let Err(err) = backend.resolve_into(key, candidates).await {
tracing::debug!(backend = backend.name(), %err, "resolve failed");
}
});
}
while tasks.join_next().await.is_some() {}
Ok(())
})
}
}
+516
View File
@@ -0,0 +1,516 @@
//! Mainline BEP44 rendezvous. Slots are a changing sample, not a membership list.
use ::mainline::{Dht, MutableItem, SigningKey, async_dht::AsyncDht};
use futures_lite::StreamExt;
use iroh::{EndpointAddr, EndpointId};
use sha2::{Digest, Sha256};
use std::collections::{HashSet, VecDeque};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::{Mutex as AsyncMutex, mpsc};
use tokio::task::JoinSet;
use tokio::time::timeout;
use zeroize::Zeroizing;
use super::{BoxFuture, Candidate, CandidateSource, NetworkDiscovery};
use crate::config::{
DHT_CLOCK_SKEW, DHT_MAX_ADDRS, DHT_MAX_RELAY_LEN, DHT_MAX_VALUE, DHT_RECORD_TTL, DHT_SLOTS,
};
use crate::error::{Error, Result};
use crate::identity::{DiscoveryKey, NetworkKeys};
const QUERY_TIMEOUT: Duration = Duration::from_secs(8);
const MAGIC: &[u8; 5] = b"TSND\x01";
/// One lazily started Mainline client, shared by all networks on one agent.
/// No socket is opened until an active network publishes or looks up candidates.
/// Agent shutdown closes this client and all its clones; a new agent needs a new client.
#[derive(Debug, Clone, Default)]
pub struct MainlineDiscovery {
client: Arc<AsyncMutex<ClientState>>,
allow_loopback: bool,
}
#[derive(Debug, Default)]
enum ClientState {
#[default]
Pending,
Ready(AsyncDht),
Stopped,
}
impl MainlineDiscovery {
/// Uses a caller-supplied Mainline node instead of public bootstrap defaults.
pub fn from_dht(dht: Dht) -> Self {
Self {
client: Arc::new(AsyncMutex::new(ClientState::Ready(dht.as_async()))),
allow_loopback: false,
}
}
/// Creates a loopback-only client for a local Mainline Testnet.
#[cfg(feature = "testing")]
pub fn local_testnet(bootstrap: &[String]) -> Result<Self> {
// Reject anything that could resolve or send outside loopback.
if bootstrap.is_empty()
|| bootstrap.iter().any(|s| {
s.parse::<SocketAddr>()
.map_or(true, |a| !a.ip().is_loopback())
})
{
return Err(Error::Discovery(
"test bootstrap must contain loopback sockets".into(),
));
}
let dht = Dht::builder()
.bootstrap(bootstrap)
.bind_address(Ipv4Addr::LOCALHOST)
.port(0)
.request_timeout(Duration::from_millis(200))
.build()
.map_err(|_| Error::Discovery("cannot bind local DHT".into()))?;
Ok(Self {
allow_loopback: true,
..Self::from_dht(dht)
})
}
/// Binds this client's rendezvous backend to one network's independent keys.
pub fn for_network(&self, keys: &NetworkKeys) -> Arc<dyn NetworkDiscovery> {
Arc::new(NetworkDht {
client: self.clone(),
key: keys.discovery_key(),
seed: Zeroizing::new(*keys.dht_write_key()),
observed: Mutex::new(VecDeque::new()),
})
}
async fn client(&self) -> Result<AsyncDht> {
let mut client = self.client.lock().await;
match &*client {
ClientState::Ready(dht) => return Ok(dht.clone()),
ClientState::Stopped => return Err(Error::Discovery("DHT client is stopped".into())),
ClientState::Pending => {}
}
// Construction may resolve bootstrap hostnames. Keep it off Tokio's
// executor; a construction failure is retried on the next operation.
let dht = tokio::task::spawn_blocking(|| Dht::builder().port(0).build())
.await
.map_err(|_| Error::Discovery("DHT startup task failed".into()))?
.map(Dht::as_async)
.map_err(|_| Error::Discovery("cannot start DHT client".into()))?;
*client = ClientState::Ready(dht.clone());
Ok(dht)
}
pub(crate) async fn shutdown(&self) {
*self.client.lock().await = ClientState::Stopped;
}
}
struct NetworkDht {
client: MainlineDiscovery,
key: DiscoveryKey,
seed: Zeroizing<[u8; 32]>,
// Records encountered while publishing. Only an explicit lookup consumes
// these hints: publication never causes dials on a connected network.
observed: Mutex<VecDeque<Vec<u8>>>,
}
impl std::fmt::Debug for NetworkDht {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("NetworkDht(<redacted>)")
}
}
fn salt(slot: u8) -> Vec<u8> {
let mut salt = b"tsunagi-rendezvous-v1".to_vec();
salt.push(slot);
salt
}
fn slots_for(id: EndpointId) -> [u8; 2] {
let hash = Sha256::digest(id.as_bytes());
let first = hash[0] % DHT_SLOTS;
[first, (first + 1 + hash[1] % (DHT_SLOTS - 1)) % DHT_SLOTS]
}
fn now() -> Result<u64> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.map_err(|_| Error::Discovery("system clock predates Unix epoch".into()))
}
impl NetworkDht {
async fn publish_slot(&self, dht: &AsyncDht, slot: u8, value: &[u8]) -> Result<()> {
let salt = salt(slot);
let signer = SigningKey::from_bytes(&self.seed);
let public = signer.verifying_key().to_bytes();
for attempt in 0..3 {
let recent = timeout(
QUERY_TIMEOUT,
dht.get_mutable_most_recent(&public, Some(&salt)),
)
.await
.map_err(|_| Error::Discovery("DHT read timed out".into()))?;
if let Some(item) = &recent
&& item.value().len() <= DHT_MAX_VALUE
&& let Ok(mut observed) = self.observed.lock()
{
if observed.len() == DHT_SLOTS as usize {
observed.pop_front();
}
observed.push_back(item.value().to_vec());
}
let seq = recent
.as_ref()
.map_or(0, MutableItem::seq)
.checked_add(1)
.ok_or_else(|| Error::Discovery("DHT sequence exhausted".into()))?;
let item = MutableItem::new(signer.clone(), value, seq, Some(&salt));
match timeout(
QUERY_TIMEOUT,
dht.put_mutable(item, recent.as_ref().map(MutableItem::seq)),
)
.await
{
Ok(Ok(outcome)) if outcome.stored_at > 0 => return Ok(()),
_ if attempt < 2 => {
// CAS is per storage node, not a global lock. Read again on
// conflicts; another writer's entry is an acceptable sample.
tokio::time::sleep(Duration::from_millis(rand::random_range(30..150))).await;
}
_ => break,
}
}
Err(Error::Discovery(
"DHT publication was not acknowledged".into(),
))
}
}
impl NetworkDiscovery for NetworkDht {
fn name(&self) -> &str {
"mainline"
}
fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
if key != self.key {
return Err(Error::Discovery("wrong rendezvous network".into()));
}
let value = encode_record(&addr, now()?, self.client.allow_loopback)?;
let dht = self.client.client().await?;
let [first, second] = slots_for(addr.id);
let (a, b) = tokio::join!(
self.publish_slot(&dht, first, &value),
self.publish_slot(&dht, second, &value)
);
a.and(b)
})
}
fn unpublish<'a>(&'a self, _: DiscoveryKey, _: EndpointId) -> BoxFuture<'a, Result<()>> {
// Stopping publication lets freshness expire. Deleting a shared slot
// could erase a different writer, and BEP44 has no delete operation.
Box::pin(async { Ok(()) })
}
fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
Box::pin(async move {
let (tx, mut rx) = mpsc::channel(DHT_SLOTS as usize);
let collect = async move {
let mut found = Vec::new();
while let Some(candidate) = rx.recv().await {
found.push(candidate);
}
found
};
let (result, found) = tokio::join!(self.resolve_into(key, tx), collect);
result?;
Ok(found)
})
}
fn resolve_into<'a>(
&'a self,
key: DiscoveryKey,
candidates: mpsc::Sender<Candidate>,
) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
if key != self.key {
return Err(Error::Discovery("wrong rendezvous network".into()));
}
let dht = self.client.client().await?;
let public = SigningKey::from_bytes(&self.seed)
.verifying_key()
.to_bytes();
let observed = self
.observed
.lock()
.map(|mut values| std::mem::take(&mut *values))
.unwrap_or_default();
let mut seen = HashSet::new();
for value in observed {
if let Some(addr) = decode_record(&value, now()?, self.client.allow_loopback)
&& seen.insert(addr.id)
&& candidates
.send(Candidate::new(addr, CandidateSource::Discovery))
.await
.is_err()
{
return Ok(());
}
}
let mut slots: Vec<_> = (0..DHT_SLOTS).collect();
for i in (1..slots.len()).rev() {
slots.swap(i, rand::random_range(0..=i));
}
let mut queries = JoinSet::new();
loop {
if seen.len() >= DHT_SLOTS as usize {
break;
}
while queries.len() < 4 {
let Some(slot) = slots.pop() else {
break;
};
let dht = dht.clone();
let allow_loopback = self.client.allow_loopback;
queries.spawn(async move {
timeout(QUERY_TIMEOUT, async move {
let salt = salt(slot);
let target = MutableItem::target_from_key(&public, Some(&salt));
let mut items = dht.get_mutable(&public, Some(&salt), None);
while let Some(item) = items.next().await {
// Mainline verifies the signature; also bind the
// returned item to the key and slot we requested.
if item.key() != &public || item.target() != &target {
continue;
}
if let Some(addr) =
decode_record(item.value(), now().ok()?, allow_loopback)
{
return Some(addr);
}
}
None
})
.await
.ok()
.flatten()
});
}
let Some(result) = queries.join_next().await else {
break;
};
if let Ok(Some(addr)) = result
&& seen.insert(addr.id)
&& candidates
.send(Candidate::new(addr, CandidateSource::Discovery))
.await
.is_err()
{
break;
}
}
Ok(())
})
}
}
fn usable(addr: &SocketAddr, allow_loopback: bool) -> bool {
addr.port() != 0
&& !addr.ip().is_unspecified()
&& !addr.ip().is_multicast()
&& (allow_loopback || !addr.ip().is_loopback())
&& match addr.ip() {
IpAddr::V4(ip) => !ip.is_broadcast() && !ip.is_link_local(),
IpAddr::V6(ip) => !ip.is_unicast_link_local(),
}
}
fn encode_record(addr: &EndpointAddr, timestamp: u64, allow_loopback: bool) -> Result<Vec<u8>> {
let ips: Vec<_> = addr
.ip_addrs()
.filter(|ip| usable(ip, allow_loopback))
.take(DHT_MAX_ADDRS)
.collect();
let relay = addr
.relay_urls()
.next()
.map(ToString::to_string)
.unwrap_or_default();
if relay.len() > DHT_MAX_RELAY_LEN || (ips.is_empty() && relay.is_empty()) {
return Err(Error::Discovery("no encodable endpoint address yet".into()));
}
let mut bytes = Vec::with_capacity(DHT_MAX_VALUE);
bytes.extend_from_slice(MAGIC);
bytes.extend_from_slice(&timestamp.to_be_bytes());
bytes.extend_from_slice(addr.id.as_bytes());
bytes.extend_from_slice(&(relay.len() as u16).to_be_bytes());
bytes.extend_from_slice(relay.as_bytes());
bytes.push(ips.len() as u8);
for ip in ips {
match ip.ip() {
IpAddr::V4(v) => {
bytes.push(4);
bytes.extend_from_slice(&v.octets());
}
IpAddr::V6(v) => {
bytes.push(6);
bytes.extend_from_slice(&v.octets());
}
}
bytes.extend_from_slice(&ip.port().to_be_bytes());
}
if bytes.len() > DHT_MAX_VALUE {
return Err(Error::Discovery("DHT record too large".into()));
}
Ok(bytes)
}
fn take<'a>(input: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
let (head, rest) = input.split_at_checked(n)?;
*input = rest;
Some(head)
}
fn decode_record(mut bytes: &[u8], now: u64, allow_loopback: bool) -> Option<EndpointAddr> {
if bytes.len() > DHT_MAX_VALUE || take(&mut bytes, MAGIC.len())? != MAGIC {
return None;
}
let timestamp = u64::from_be_bytes(take(&mut bytes, 8)?.try_into().ok()?);
if timestamp > now.saturating_add(DHT_CLOCK_SKEW.as_secs())
|| now.saturating_sub(timestamp) >= DHT_RECORD_TTL.as_secs()
{
return None;
}
let id = EndpointId::from_bytes(take(&mut bytes, 32)?.try_into().ok()?).ok()?;
let mut addr = EndpointAddr::new(id);
let len = u16::from_be_bytes(take(&mut bytes, 2)?.try_into().ok()?) as usize;
if len > DHT_MAX_RELAY_LEN {
return None;
}
let relay = std::str::from_utf8(take(&mut bytes, len)?).ok()?;
if !relay.is_empty() {
addr = addr.with_relay_url(relay.parse().ok()?);
}
let count = *take(&mut bytes, 1)?.first()? as usize;
if count > DHT_MAX_ADDRS {
return None;
}
for _ in 0..count {
let ip = match *take(&mut bytes, 1)?.first()? {
4 => IpAddr::V4(Ipv4Addr::from(
<[u8; 4]>::try_from(take(&mut bytes, 4)?).ok()?,
)),
6 => IpAddr::V6(Ipv6Addr::from(
<[u8; 16]>::try_from(take(&mut bytes, 16)?).ok()?,
)),
_ => return None,
};
let port = u16::from_be_bytes(take(&mut bytes, 2)?.try_into().ok()?);
let socket = SocketAddr::new(ip, port);
if usable(&socket, allow_loopback) {
addr = addr.with_ip_addr(socket);
}
}
(bytes.is_empty() && !addr.addrs.is_empty()).then_some(addr)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::testing::network;
fn address() -> EndpointAddr {
EndpointAddr::new(iroh::SecretKey::generate().public())
.with_ip_addr("127.0.0.1:12345".parse().unwrap())
}
#[test]
fn records_reject_expiration_future_dates_truncation_and_oversize() {
let addr = address();
let bytes = encode_record(&addr, 1_000, true).unwrap();
assert_eq!(decode_record(&bytes, 1_100, true).unwrap(), addr);
assert!(decode_record(&bytes, 1_901, true).is_none());
assert!(decode_record(&bytes, 100, true).is_none());
for n in 0..bytes.len() {
assert!(decode_record(&bytes[..n], 1_100, true).is_none());
}
assert!(decode_record(&vec![0; DHT_MAX_VALUE + 1], 1_100, true).is_none());
assert!(encode_record(&addr, 1_000, false).is_err());
assert!(decode_record(&bytes, 1_100, false).is_none());
}
#[tokio::test]
async fn a_stopped_custom_client_never_falls_back_to_public_bootstrap() {
let net = mainline::Testnet::builder(3).build().unwrap();
let client = MainlineDiscovery::local_testnet(&net.bootstrap).unwrap();
let (name, secret) = network("mainline-shutdown");
let keys = NetworkKeys::derive(&name, &secret);
let backend = client.for_network(&keys);
client.shutdown().await;
assert!(backend.resolve(keys.discovery_key()).await.is_err());
}
#[tokio::test]
async fn concurrent_writers_share_slots_and_other_secrets_find_nothing() {
let net = mainline::Testnet::builder(5).build().unwrap();
let client_a = MainlineDiscovery::local_testnet(&net.bootstrap).unwrap();
let client_b = MainlineDiscovery::local_testnet(&net.bootstrap).unwrap();
let (name, secret) = network("mainline-slots");
let keys = NetworkKeys::derive(&name, &secret);
let a = client_a.for_network(&keys);
let b = client_b.for_network(&keys);
let first = address();
let second = loop {
let next = address();
if slots_for(next.id) == slots_for(first.id) {
break next;
}
};
let key = keys.discovery_key();
let (ra, rb) = tokio::join!(
a.publish(key, first.clone()),
b.publish(key, second.clone())
);
assert!(ra.is_ok() || rb.is_ok());
let (found_a, found_b) = tokio::join!(a.resolve(key), b.resolve(key));
let found_a = found_a.unwrap();
let found_b = found_b.unwrap();
assert!(
found_a.iter().any(|c| c.addr == second) || found_b.iter().any(|c| c.addr == first),
"at least one writer must discover the other despite colliding in both slots"
);
assert!(
found_a
.iter()
.chain(&found_b)
.all(|c| c.addr == first || c.addr == second)
);
let other = NetworkKeys::derive(&name, &crate::identity::NetworkSecret::generate());
assert!(
client_a
.for_network(&other)
.resolve(other.discovery_key())
.await
.unwrap()
.is_empty()
);
// Restarting the publisher must read seq from DHT instead of starting at 1.
let restarted = client_a.for_network(&keys);
restarted.publish(key, first.clone()).await.unwrap();
assert!(
b.resolve(key)
.await
.unwrap()
.iter()
.any(|c| c.addr == first)
);
}
}
+251
View File
@@ -0,0 +1,251 @@
//! Per-network discovery tasks. Dropping the owner cancels all backend futures.
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tokio::task::JoinSet;
use tokio::time::{Instant, timeout};
use super::{Candidate, NetworkDiscovery};
use crate::config::DiscoveryPolicy;
use crate::identity::DiscoveryKey;
use crate::net::EndpointAdapter;
pub(crate) struct DiscoveryWorker {
connected: watch::Sender<Connectivity>,
tasks: JoinSet<()>,
backend: Arc<dyn NetworkDiscovery>,
key: DiscoveryKey,
endpoint: iroh::EndpointId,
}
#[derive(Clone, Copy)]
enum Connectivity {
Initial,
Connected,
Isolated(Instant),
}
impl DiscoveryWorker {
pub(crate) fn spawn(
backend: Arc<dyn NetworkDiscovery>,
key: DiscoveryKey,
adapter: EndpointAdapter,
policy: DiscoveryPolicy,
address_check: Duration,
candidates: mpsc::Sender<Candidate>,
) -> Self {
let (connected, receiver) = watch::channel(Connectivity::Initial);
let mut tasks = JoinSet::new();
tasks.spawn(publish_loop(
backend.clone(),
key,
adapter.clone(),
policy.clone(),
address_check,
));
tasks.spawn(lookup_loop(
backend.clone(),
key,
policy,
receiver,
candidates,
));
Self {
connected,
tasks,
backend,
key,
endpoint: adapter.endpoint_id(),
}
}
pub(crate) fn set_connected(&self, connected: bool) {
self.connected.send_if_modified(|current| {
if matches!(*current, Connectivity::Connected) == connected {
return false;
}
*current = if connected {
Connectivity::Connected
} else {
Connectivity::Isolated(Instant::now())
};
true
});
}
pub(crate) async fn stop(mut self) {
self.tasks.abort_all();
while self.tasks.join_next().await.is_some() {}
// BEP44 has no deletion. Memory/static backends can withdraw promptly;
// an unresponsive backend must not extend agent shutdown.
let _ = timeout(
Duration::from_millis(250),
self.backend.unpublish(self.key, self.endpoint),
)
.await;
}
}
fn jitter(delay: Duration) -> Duration {
delay
.mul_f64(0.8 + 0.4 * rand::random::<f64>())
.max(Duration::from_millis(10))
}
async fn publish_loop(
backend: Arc<dyn NetworkDiscovery>,
key: DiscoveryKey,
adapter: EndpointAdapter,
policy: DiscoveryPolicy,
address_check: Duration,
) {
let mut previous = None;
let mut due = Instant::now();
let mut ticker = tokio::time::interval(address_check.max(Duration::from_millis(10)));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let mut addr = adapter.addr();
addr.addrs.extend(adapter.loopback_addr().addrs);
if previous.as_ref() != Some(&addr) {
previous = Some(addr.clone());
due = Instant::now();
}
if Instant::now() < due {
continue;
}
let result = timeout(policy.request_timeout, backend.publish(key, addr.clone())).await;
match result {
Ok(Ok(())) => {
due = Instant::now() + jitter(policy.publish_interval);
}
result => {
tracing::debug!(?result, "discovery publication failed; will retry");
due = Instant::now() + jitter(policy.lookup_interval);
}
}
}
}
async fn lookup_loop(
backend: Arc<dyn NetworkDiscovery>,
key: DiscoveryKey,
policy: DiscoveryPolicy,
mut connected: watch::Receiver<Connectivity>,
candidates: mpsc::Sender<Candidate>,
) {
let mut last_isolation = None;
let mut due = Instant::now();
let mut delay = policy.lookup_interval;
loop {
let state = *connected.borrow_and_update();
if matches!(state, Connectivity::Connected) {
if connected.changed().await.is_err() {
break;
}
continue;
}
if let Connectivity::Isolated(since) = state
&& last_isolation != Some(since)
{
// Preserve the transition time even if a rapid reconnect/disconnect
// overwrote a watch value before this task had a chance to run.
due = since + policy.reconnect_delay;
delay = policy.lookup_interval;
last_isolation = Some(since);
}
tokio::select! {
biased;
changed = connected.changed() => {
if changed.is_err() { break; }
continue;
}
_ = tokio::time::sleep_until(due) => {}
}
tokio::select! {
biased;
changed = connected.changed() => {
if changed.is_err() { break; }
continue;
}
result = timeout(policy.request_timeout, backend.resolve_into(key, candidates.clone())) => {
if !matches!(result, Ok(Ok(()))) {
tracing::debug!(?result, "discovery lookup failed; will retry");
}
}
}
due = Instant::now() + jitter(delay);
delay = delay.saturating_mul(2).min(policy.max_lookup_interval);
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::Result;
use crate::discovery::BoxFuture;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug, Default)]
struct Reads(AtomicUsize);
impl NetworkDiscovery for Reads {
fn name(&self) -> &str {
"reads"
}
fn publish<'a>(
&'a self,
_: DiscoveryKey,
_: iroh::EndpointAddr,
) -> BoxFuture<'a, Result<()>> {
Box::pin(async { Ok(()) })
}
fn unpublish<'a>(
&'a self,
_: DiscoveryKey,
_: iroh::EndpointId,
) -> BoxFuture<'a, Result<()>> {
Box::pin(async { Ok(()) })
}
fn resolve<'a>(&'a self, _: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
self.0.fetch_add(1, Ordering::SeqCst);
Box::pin(async { Ok(Vec::new()) })
}
}
#[tokio::test(start_paused = true)]
async fn recovery_requires_a_full_minute_and_reconnection_restarts_that_minute() {
let reads = Arc::new(Reads::default());
let (tx, rx) = watch::channel(Connectivity::Connected);
let (candidates, _receiver) = mpsc::channel(16);
let mut tasks = JoinSet::new();
tasks.spawn(lookup_loop(
reads.clone(),
DiscoveryKey::from_bytes([0; 32]),
DiscoveryPolicy::default(),
rx,
candidates,
));
tokio::task::yield_now().await;
tx.send(Connectivity::Isolated(Instant::now())).unwrap();
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(59)).await;
tokio::task::yield_now().await;
assert_eq!(reads.0.load(Ordering::SeqCst), 0);
tx.send(Connectivity::Connected).unwrap();
tx.send(Connectivity::Isolated(Instant::now())).unwrap();
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(59)).await;
tokio::task::yield_now().await;
assert_eq!(reads.0.load(Ordering::SeqCst), 0);
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
assert_eq!(reads.0.load(Ordering::SeqCst), 1);
tx.send(Connectivity::Connected).unwrap();
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(600)).await;
tokio::task::yield_now().await;
assert_eq!(reads.0.load(Ordering::SeqCst), 1);
}
}
+84
View File
@@ -0,0 +1,84 @@
//! A DNS view of the overlay.
//!
//! The names and addresses of a network, served to the host that runs the
//! agent, so members can be reached by name. Answered from signed state, so a
//! member that is switched off still resolves.
//!
//! Three parts, kept apart on purpose:
//!
//! * [`zone`] decides what the answer is. Pure, and knows nothing about
//! packets or sockets.
//! * [`server`] puts that on the wire.
//! * `publish` tells the operating system where to send its questions,
//! which is the only part that differs between platforms.
pub mod publish;
pub mod server;
pub mod zone;
pub use publish::{DnsPublisher, PublishError, Published};
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
/// Where the server listens, one list per address family.
///
/// Loopback, and only loopback. The zones are a view *for the host running
/// the agent*: it is that host's resolver that is pointed at them. Binding
/// an overlay address instead would put them in front of the whole mesh —
/// and an agent in several networks would then answer one network's
/// questions about another's names, which is precisely what separate
/// networks are for.
///
/// Both families, and independently: a question arrives over whichever one
/// the resolver happens to use, and on a host with one of them switched off
/// the other must still answer. So neither list failing says anything about
/// the other, and a listener is opened from each.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListenPlan {
/// IPv4 candidates, best first.
pub v4: Vec<SocketAddr>,
/// IPv6 candidates, best first.
pub v6: Vec<SocketAddr>,
}
impl ListenPlan {
/// The two lists, so a caller can bind one listener from each.
pub fn families(&self) -> [&[SocketAddr]; 2] {
[&self.v4, &self.v6]
}
}
/// Builds the listen plan for one port.
pub fn listen_plan(port: u16) -> ListenPlan {
ListenPlan {
v4: vec![SocketAddr::from((Ipv4Addr::LOCALHOST, port))],
v6: vec![SocketAddr::from((Ipv6Addr::LOCALHOST, port))],
}
}
pub use server::{DnsServer, SharedZone};
pub use zone::{Answer, Query, Zone, ZoneError, ZoneName, Zones};
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn both_families_are_planned_for_and_neither_is_reachable_off_this_host() {
let plan = listen_plan(5354);
assert_eq!(
plan.v4,
vec!["127.0.0.1:5354".parse::<SocketAddr>().unwrap()]
);
assert_eq!(plan.v6, vec!["[::1]:5354".parse::<SocketAddr>().unwrap()]);
assert_eq!(plan.families().len(), 2);
// Nothing here is reachable from another member: the zones are a
// view for this host, and an agent in two networks must not answer
// one network's questions about the other's names.
for address in plan.families().concat() {
assert!(address.ip().is_loopback(), "{address} is not loopback");
}
}
}
+333
View File
@@ -0,0 +1,333 @@
//! Telling the macOS resolver to send some questions here, through
//! `/etc/resolver`.
//!
//! macOS reads one file per domain out of `/etc/resolver`: a file named after
//! a domain, containing the `nameserver` and `port` to send that domain's
//! questions to, makes the system resolver a *scoped* resolver for exactly
//! that suffix and nothing else (see `resolver(5)`). That is the same contract
//! the systemd-resolved and NRPT publishers arrange on the other platforms:
//! route these suffixes to this server, and claim nothing else.
//!
//! # Why a port, and so no privileged socket
//!
//! A resolver file carries a `port`, so the server does not have to sit on 53
//! and the agent needs nothing like `CAP_NET_BIND_SERVICE`. It can serve on a
//! high port and still be reached for its suffixes.
//!
//! # Tagged, reversible, and never clobbering
//!
//! Every file this publisher writes begins with a marker line, so it only ever
//! replaces or removes files it wrote itself: a pre-existing `/etc/resolver`
//! entry a user set up by hand is left untouched. The files are removed when
//! the resolver is turned off and on shutdown; a crash leaves them behind, and
//! the next apply for the same domains overwrites them.
//!
//! # Privilege
//!
//! Writing under `/etc/resolver` needs root. A permission error is reported as
//! a refusal — the kind of failure a person has to act on — rather than a
//! transient one, so the agent does not retry it at the ordinary pace.
use std::collections::BTreeSet;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::BoxFuture;
use super::{DnsPublisher, PublishError, Published};
/// The directory macOS reads scoped resolvers from.
const RESOLVER_DIR: &str = "/etc/resolver";
/// The first line of every file this publisher writes, so it recognises its
/// own and never touches a file a user created.
const MARKER: &str = "# managed by tsunagi — do not edit";
/// Writes and removes `/etc/resolver` files for the overlay zones.
#[derive(Debug)]
pub struct ResolverDirPublisher {
/// The resolver directory. A field rather than a constant so the tests can
/// point it at a temporary directory and never touch the real one.
dir: PathBuf,
/// The domain files written by the last apply, so a later apply can remove
/// the ones no longer wanted and `revert` can remove them all.
written: Mutex<BTreeSet<String>>,
}
impl Default for ResolverDirPublisher {
fn default() -> Self {
Self::new()
}
}
impl ResolverDirPublisher {
/// A publisher writing to the real `/etc/resolver`.
pub fn new() -> Self {
Self::in_dir(RESOLVER_DIR)
}
/// A publisher writing to a specific directory. For the tests.
fn in_dir(dir: impl Into<PathBuf>) -> Self {
Self {
dir: dir.into(),
written: Mutex::new(BTreeSet::new()),
}
}
fn apply_inner(&self, published: &Published) -> Result<(), PublishError> {
let desired = desired_files(published)?;
// The directory may not exist yet; creating it needs root.
std::fs::create_dir_all(&self.dir).map_err(|err| classify(&self.dir, err))?;
// Remove the files from a previous apply that are no longer wanted.
let previous = lock(&self.written).clone();
for name in previous.difference(&keys(&desired)) {
remove_if_ours(&self.dir.join(name));
}
for (name, body) in &desired {
let path = self.dir.join(name);
// Only ever overwrite a file that is already ours, or a name that
// is free. A resolver a user set up by hand is left in place and
// reported, never clobbered.
if path.exists() && !is_ours(&path) {
return Err(PublishError::Failed(format!(
"{} already exists and was not created by tsunagi; \
remove it or rename the network to publish its resolver",
path.display()
)));
}
std::fs::write(&path, body).map_err(|err| classify(&path, err))?;
}
*lock(&self.written) = keys(&desired);
Ok(())
}
fn revert_inner(&self) {
let written = std::mem::take(&mut *lock(&self.written));
for name in &written {
remove_if_ours(&self.dir.join(name));
}
}
}
impl DnsPublisher for ResolverDirPublisher {
fn name(&self) -> &str {
"resolver-dir"
}
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
// A handful of small file writes, short enough to run in place rather
// than hop to the blocking pool.
Box::pin(async move { self.apply_inner(published) })
}
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
Box::pin(async move {
self.revert_inner();
Ok(())
})
}
}
/// The resolver files a publication implies: a map of file name to contents.
fn desired_files(published: &Published) -> Result<Vec<(String, String)>, PublishError> {
if published.servers.is_empty() {
return Err(PublishError::Failed(
"no server address to point the resolver at".to_string(),
));
}
let body = file_contents(published);
let mut files = Vec::new();
for domain in &published.domains {
let name = resolver_file_name(domain)?;
files.push((name, body.clone()));
}
Ok(files)
}
/// The contents of one resolver file: the marker, every server, one port.
fn file_contents(published: &Published) -> String {
let mut out = String::from(MARKER);
out.push('\n');
for server in &published.servers {
let ip = match server.ip() {
IpAddr::V4(v4) => v4.to_string(),
IpAddr::V6(v6) => v6.to_string(),
};
out.push_str("nameserver ");
out.push_str(&ip);
out.push('\n');
}
// One port for the file; every server listens on the same one. The `port`
// directive is what lets the server stay off 53.
if let Some(first) = published.servers.first() {
out.push_str("port ");
out.push_str(&first.port().to_string());
out.push('\n');
}
out
}
/// The file name for a domain, rejecting anything that is not a plain label
/// path component: a resolver file name must never escape the directory.
fn resolver_file_name(domain: &str) -> Result<String, PublishError> {
let trimmed = domain.trim_end_matches('.');
if trimmed.is_empty()
|| trimmed.contains('/')
|| trimmed.contains('\\')
|| trimmed.contains(std::path::MAIN_SEPARATOR)
|| trimmed == "."
|| trimmed == ".."
|| trimmed.starts_with('.')
{
return Err(PublishError::Failed(format!(
"cannot make a resolver file name for the domain {domain:?}"
)));
}
Ok(trimmed.to_string())
}
/// Whether a file is one this publisher wrote, by its marker first line.
fn is_ours(path: &Path) -> bool {
match std::fs::read_to_string(path) {
Ok(text) => text.lines().next() == Some(MARKER),
Err(_) => false,
}
}
/// Removes a file, but only if it is one of ours. Best effort.
fn remove_if_ours(path: &Path) {
if is_ours(path)
&& let Err(err) = std::fs::remove_file(path)
&& err.kind() != std::io::ErrorKind::NotFound
{
tracing::debug!(path = %path.display(), %err, "cannot remove the resolver file");
}
}
/// The set of file names in a desired-files list.
fn keys(files: &[(String, String)]) -> BTreeSet<String> {
files.iter().map(|(name, _)| name.clone()).collect()
}
/// Turns a filesystem error into the right kind of publish error: a permission
/// problem is a refusal a person must act on, anything else is a plain failure.
fn classify(path: &Path, err: std::io::Error) -> PublishError {
if err.kind() == std::io::ErrorKind::PermissionDenied {
PublishError::Refused(format!(
"writing {} needs root; start the agent with `sudo` or as a root LaunchDaemon",
path.display()
))
} else {
PublishError::Failed(format!("cannot write {}: {err}", path.display()))
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::SocketAddr;
use super::*;
fn published(domains: &[&str]) -> Published {
Published {
interface: "utun5".into(),
servers: vec![
"10.13.37.1:5353".parse::<SocketAddr>().unwrap(),
"[fd00::1]:5353".parse::<SocketAddr>().unwrap(),
],
domains: domains.iter().map(|d| d.to_string()).collect(),
}
}
#[test]
fn a_file_lists_every_server_and_one_port_under_the_marker() {
let body = file_contents(&published(&["mynet."]));
let mut lines = body.lines();
assert_eq!(lines.next(), Some(MARKER));
assert_eq!(lines.next(), Some("nameserver 10.13.37.1"));
assert_eq!(lines.next(), Some("nameserver fd00::1"));
assert_eq!(lines.next(), Some("port 5353"));
assert_eq!(lines.next(), None);
}
#[test]
fn a_domain_becomes_a_plain_file_name_without_its_trailing_dot() {
assert_eq!(resolver_file_name("mynet.").unwrap(), "mynet");
assert_eq!(resolver_file_name("my.net").unwrap(), "my.net");
}
#[test]
fn a_domain_that_would_escape_the_directory_is_refused() {
assert!(resolver_file_name("../etc/passwd").is_err());
assert!(resolver_file_name("a/b").is_err());
assert!(resolver_file_name("").is_err());
assert!(resolver_file_name(".").is_err());
assert!(resolver_file_name(".hidden").is_err());
}
#[tokio::test]
async fn apply_writes_a_file_per_domain_and_revert_removes_them() {
let dir = tempfile::tempdir().unwrap();
let publisher = ResolverDirPublisher::in_dir(dir.path());
publisher
.apply(&published(&["mynet.", "other."]))
.await
.unwrap();
assert!(dir.path().join("mynet").is_file());
assert!(dir.path().join("other").is_file());
publisher.revert().await.unwrap();
assert!(!dir.path().join("mynet").exists());
assert!(!dir.path().join("other").exists());
}
#[tokio::test]
async fn re_applying_removes_a_domain_that_is_no_longer_wanted() {
let dir = tempfile::tempdir().unwrap();
let publisher = ResolverDirPublisher::in_dir(dir.path());
publisher.apply(&published(&["a.", "b."])).await.unwrap();
publisher.apply(&published(&["a."])).await.unwrap();
assert!(dir.path().join("a").is_file());
assert!(
!dir.path().join("b").exists(),
"the dropped domain's file is removed"
);
}
#[tokio::test]
async fn a_file_a_user_created_is_never_clobbered_or_removed() {
let dir = tempfile::tempdir().unwrap();
let foreign = dir.path().join("mynet");
std::fs::write(&foreign, "nameserver 9.9.9.9\n").unwrap();
let publisher = ResolverDirPublisher::in_dir(dir.path());
let err = publisher.apply(&published(&["mynet."])).await.unwrap_err();
assert!(matches!(err, PublishError::Failed(_)), "{err:?}");
// Untouched.
assert_eq!(
std::fs::read_to_string(&foreign).unwrap(),
"nameserver 9.9.9.9\n"
);
// And revert leaves a foreign file alone even if it is in our set.
publisher.revert().await.unwrap();
assert!(foreign.is_file());
}
}
+105
View File
@@ -0,0 +1,105 @@
//! A pretend resolver, so the wiring is tested without touching this host's.
use std::sync::{Arc, Mutex};
use crate::BoxFuture;
use super::{DnsPublisher, PublishError, Published};
/// Records what it was asked to do, and can be told to refuse.
#[derive(Debug, Clone, Default)]
pub struct MockPublisher {
applied: Arc<Mutex<Option<Published>>>,
failure: Option<PublishError>,
}
impl MockPublisher {
/// A publisher that accepts everything.
pub fn new() -> Self {
Self::default()
}
/// A publisher that refuses everything, with this reason.
pub fn failing(error: PublishError) -> Self {
Self {
applied: Arc::new(Mutex::new(None)),
failure: Some(error),
}
}
/// What is currently applied, if anything.
pub fn applied(&self) -> Option<Published> {
match self.applied.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
}
impl DnsPublisher for MockPublisher {
fn name(&self) -> &str {
"mock"
}
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
Box::pin(async move {
if let Some(failure) = &self.failure {
return Err(failure.clone());
}
match self.applied.lock() {
Ok(mut guard) => *guard = Some(published.clone()),
Err(poisoned) => *poisoned.into_inner() = Some(published.clone()),
}
Ok(())
})
}
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
Box::pin(async move {
match self.applied.lock() {
Ok(mut guard) => *guard = None,
Err(poisoned) => *poisoned.into_inner() = None,
}
Ok(())
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn published() -> Published {
Published {
interface: "tsundemo".into(),
servers: vec![
"10.13.37.69:5354".parse().unwrap(),
"[::1]:5354".parse().unwrap(),
],
domains: vec!["lab".into()],
}
}
#[tokio::test]
async fn applying_and_reverting_are_both_recorded() {
let publisher = MockPublisher::new();
assert!(publisher.applied().is_none());
publisher.apply(&published()).await.unwrap();
assert_eq!(publisher.applied(), Some(published()));
publisher.revert().await.unwrap();
assert!(publisher.applied().is_none());
// Reverting twice is not an error: shutdown must not fail here.
publisher.revert().await.unwrap();
}
#[tokio::test]
async fn a_refusal_leaves_nothing_applied() {
let publisher = MockPublisher::failing(PublishError::Refused("polkit said no".into()));
assert!(publisher.apply(&published()).await.is_err());
assert!(publisher.applied().is_none());
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Telling the operating system where to send its questions.
//!
//! The server answers whether or not this works. That is the whole reason it
//! is a separate thing: if the resolver cannot be configured — no
//! systemd-resolved, an unwilling polkit, a platform nobody has written this
//! for — the port is still up and the user can point something at it by
//! hand. A failure here is a degraded overlay, not a broken one.
//!
//! Only the *mechanics* differ between systems. What has to be arranged is
//! the same everywhere: send questions for these suffixes to this address,
//! through this interface, and do not make it the resolver for anything
//! else. That is [`Published`]; the rest is behind [`DnsPublisher`].
use std::net::SocketAddr;
use crate::BoxFuture;
mod mock;
pub use mock::MockPublisher;
#[cfg(all(feature = "dns-publish", target_os = "linux"))]
mod resolved;
#[cfg(all(feature = "dns-publish", target_os = "linux"))]
pub use resolved::ResolvedPublisher;
#[cfg(all(feature = "dns-publish", target_os = "windows"))]
mod windows;
#[cfg(all(feature = "dns-publish", target_os = "windows"))]
pub use windows::NrptPublisher;
#[cfg(all(feature = "dns-publish", target_os = "macos"))]
mod macos;
#[cfg(all(feature = "dns-publish", target_os = "macos"))]
pub use macos::ResolverDirPublisher;
mod unsupported;
pub use unsupported::UnsupportedPublisher;
/// What the operating system is asked to do.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Published {
/// The interface questions should be sent through.
///
/// The server listens on an overlay address, which is only reachable
/// over the overlay interface, so the two travel together. It must be an
/// interface this agent created: configuring one it did not is
/// configuring somebody else's.
pub interface: String,
/// Every address the server is listening on, best first.
///
/// One per address family, so a resolver reaches it over whichever it
/// uses. All of them are published together and none is preferred by
/// this: which one a resolver picks is its business.
pub servers: Vec<SocketAddr>,
/// The suffixes that belong to this server.
///
/// Routing suffixes only: they say *which questions* come here, never
/// that this is the resolver for anything else.
pub domains: Vec<String>,
}
/// Why the resolver could not be told.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PublishError {
/// There is nothing here that can be configured this way.
#[error("{0}")]
Unavailable(String),
/// Something is there, and it declined.
///
/// Separate from a plain failure because the answer is different: this
/// one is about who the agent is running as, not about whether the thing
/// works.
#[error("{0}")]
Refused(String),
/// It was there, it accepted the request, and it went wrong anyway.
#[error("{0}")]
Failed(String),
}
impl PublishError {
/// Whether waiting will fix it.
///
/// A refusal will not change on its own — somebody has to grant
/// permission — so retrying it at the pace of everything else is just
/// noise. Anything else might be a service still starting.
pub fn needs_a_human(&self) -> bool {
matches!(self, PublishError::Refused(_))
}
/// One line for a status table.
pub fn remedy(&self) -> Option<&'static str> {
match self {
PublishError::Refused(_) => Some(
"grant this user the `org.freedesktop.resolve1.set-*` actions in \
/etc/polkit-1/rules.d, or run the agent as a system service",
),
_ => None,
}
}
}
/// The polkit rule that lets this user configure the resolver.
///
/// Printed in full rather than described, because the point of this feature
/// is that the user has as little to do as possible, and "write a polkit
/// rule" is a great deal more work than pasting one.
///
/// It grants exactly the four actions this agent calls and nothing else.
/// polkit decides by user id — a capability does not help here — so there is
/// no way to do this from inside the process.
pub fn polkit_recipe(user: &str) -> String {
format!(
"sudo tee /etc/polkit-1/rules.d/50-tsunagi-resolved.rules > /dev/null <<'RULE'\n\
polkit.addRule(function(action, subject) {{\n\
\x20 var allowed = [\n\
\x20 \"org.freedesktop.resolve1.set-dns-servers\",\n\
\x20 \"org.freedesktop.resolve1.set-domains\",\n\
\x20 \"org.freedesktop.resolve1.set-default-route\",\n\
\x20 \"org.freedesktop.resolve1.revert\"\n\
\x20 ];\n\
\x20 if (allowed.indexOf(action.id) >= 0 && subject.user == \"{user}\") {{\n\
\x20 return polkit.Result.YES;\n\
\x20 }}\n\
}});\n\
RULE"
)
}
/// Arranges for the operating system to ask this server.
pub trait DnsPublisher: Send + Sync + std::fmt::Debug + 'static {
/// A short name used in diagnostics.
fn name(&self) -> &str;
/// Applies the setting, replacing whatever this publisher set before.
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>>;
/// Undoes it.
///
/// Reverting something that was never applied succeeds: this runs on the
/// shutdown path, where the setting being gone is the point.
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>>;
}
/// The kernel's index for an interface.
///
/// From sysfs, which needs no privileges and no netlink round trip.
#[cfg(target_os = "linux")]
pub fn interface_index(name: &str) -> Option<u32> {
std::fs::read_to_string(format!("/sys/class/net/{name}/ifindex"))
.ok()?
.trim()
.parse()
.ok()
}
/// The kernel's index for an interface.
///
/// Only the systemd-resolved publisher needs one, so off Linux there is
/// nothing to look up and this is always `None`.
#[cfg(not(target_os = "linux"))]
pub fn interface_index(_name: &str) -> Option<u32> {
None
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn only_a_refusal_suggests_what_to_do_about_it() {
// The other two are conditions of the host, not of the user.
assert!(PublishError::Refused("no".into()).remedy().is_some());
assert!(PublishError::Unavailable("none".into()).remedy().is_none());
assert!(PublishError::Failed("bang".into()).remedy().is_none());
}
#[test]
fn only_a_refusal_waits_for_a_person() {
// The rest may come right on their own, so they are worth retrying
// at the ordinary pace; a refusal is not.
assert!(PublishError::Refused("no".into()).needs_a_human());
assert!(!PublishError::Unavailable("none".into()).needs_a_human());
assert!(!PublishError::Failed("bang".into()).needs_a_human());
}
#[test]
fn the_polkit_recipe_grants_what_is_called_and_no_more() {
let recipe = polkit_recipe("ab");
for action in [
"set-dns-servers",
"set-domains",
"set-default-route",
"revert",
] {
assert!(recipe.contains(action), "{action} missing from:\n{recipe}");
}
// Nothing beyond what the agent calls: a rule that granted the lot
// would be handing out more than this feature needs.
for other in ["set-dnssec", "set-mdns", "register-service", "set-llmnr"] {
assert!(!recipe.contains(other), "{other} should not be granted");
}
assert!(recipe.contains("subject.user == \"ab\""));
// ES5: the rules engine is duktape and has no `startsWith`.
assert!(!recipe.contains("startsWith"));
}
#[cfg(target_os = "linux")]
#[test]
fn an_interface_index_is_read_from_the_running_kernel() {
assert_eq!(interface_index("lo"), Some(1));
assert_eq!(interface_index("tsunagi-no-such-interface"), None);
}
}
+275
View File
@@ -0,0 +1,275 @@
//! Telling systemd-resolved to send some questions here.
//!
//! Three calls on `org.freedesktop.resolve1.Manager`, all scoped to the
//! overlay interface:
//!
//! * `SetLinkDNSEx` — where to send them. The `Ex` form carries a **port**,
//! which is why this server does not have to sit on 53 and the agent needs
//! no `CAP_NET_BIND_SERVICE`. Systems before systemd 247 have only
//! `SetLinkDNS`, which has no port; there the fallback only works if the
//! server did get port 53, and it says so rather than appearing to work.
//! * `SetLinkDomains` with `routing_only` set — a *routing* suffix, the
//! `~domain` form. It says which questions come here and claims nothing
//! else.
//! * `SetLinkDefaultRoute(false)` — so this never becomes the resolver for
//! anything outside those suffixes. Without it resolved may fall back to
//! this link for ordinary names, and this server refuses those.
//!
//! # It cleans up by itself
//!
//! resolved keys all of this to the interface, and drops it when the
//! interface goes. The overlay interface belongs to a file descriptor the
//! agent holds, so it goes when the agent does — however the agent goes. The
//! explicit `RevertLink` on shutdown only makes that immediate.
//!
//! # Privilege
//!
//! resolved asks polkit, and polkit decides by user id, not by capability.
//! So `CAP_NET_ADMIN` does not help here: an ordinary user is prompted or
//! refused, while a system service running as root is not. That refusal is
//! reported as its own kind of error, because the answer to it is different
//! from the answer to "resolved is not installed".
use std::net::IpAddr;
use crate::BoxFuture;
use super::{DnsPublisher, PublishError, Published, interface_index};
/// `AF_INET`, as resolved wants it.
const AF_INET: i32 = 2;
/// `AF_INET6`.
const AF_INET6: i32 = 10;
#[zbus::proxy(
interface = "org.freedesktop.resolve1.Manager",
default_service = "org.freedesktop.resolve1",
default_path = "/org/freedesktop/resolve1"
)]
trait Resolved {
/// Servers for a link, with a port and a name. systemd 247 and later.
#[zbus(name = "SetLinkDNSEx")]
fn set_link_dns_ex(
&self,
ifindex: i32,
addresses: &[(i32, Vec<u8>, u16, String)],
) -> zbus::Result<()>;
/// Servers for a link, without a port. Always port 53.
#[zbus(name = "SetLinkDNS")]
fn set_link_dns(&self, ifindex: i32, addresses: &[(i32, Vec<u8>)]) -> zbus::Result<()>;
/// Suffixes for a link. The flag makes one routing-only.
#[zbus(name = "SetLinkDomains")]
fn set_link_domains(&self, ifindex: i32, domains: &[(String, bool)]) -> zbus::Result<()>;
/// Whether this link may answer for names outside its suffixes.
#[zbus(name = "SetLinkDefaultRoute")]
fn set_link_default_route(&self, ifindex: i32, enable: bool) -> zbus::Result<()>;
/// Forgets everything set for a link.
#[zbus(name = "RevertLink")]
fn revert_link(&self, ifindex: i32) -> zbus::Result<()>;
}
/// Configures systemd-resolved over D-Bus.
#[derive(Debug, Default)]
pub struct ResolvedPublisher {
/// The link last configured, so shutdown knows what to undo.
applied: std::sync::Mutex<Option<u32>>,
}
impl ResolvedPublisher {
/// Creates the publisher. Nothing is contacted until [`Self::apply`].
pub fn new() -> Self {
Self::default()
}
async fn proxy() -> Result<ResolvedProxy<'static>, PublishError> {
let connection = zbus::Connection::system().await.map_err(|err| {
PublishError::Unavailable(format!("no system D-Bus to talk to resolved on: {err}"))
})?;
ResolvedProxy::new(&connection).await.map_err(|err| {
PublishError::Unavailable(format!("systemd-resolved is not answering: {err}"))
})
}
}
/// Turns a D-Bus failure into the kind of failure it actually is.
fn classify(err: zbus::Error, what: &str) -> PublishError {
let name = match &err {
zbus::Error::MethodError(name, _, _) => name.as_str().to_string(),
_ => String::new(),
};
match name.as_str() {
"org.freedesktop.DBus.Error.InteractiveAuthorizationRequired"
| "org.freedesktop.DBus.Error.AccessDenied" => {
PublishError::Refused(format!("systemd-resolved refused {what}: {err}"))
}
"org.freedesktop.DBus.Error.UnknownMethod"
| "org.freedesktop.DBus.Error.ServiceUnknown" => {
PublishError::Unavailable(format!("systemd-resolved cannot do {what}: {err}"))
}
_ => PublishError::Failed(format!("systemd-resolved failed {what}: {err}")),
}
}
/// The address in the shape resolved wants: a family and raw octets.
fn wire_address(address: IpAddr) -> (i32, Vec<u8>) {
match address {
IpAddr::V4(address) => (AF_INET, address.octets().to_vec()),
IpAddr::V6(address) => (AF_INET6, address.octets().to_vec()),
}
}
impl DnsPublisher for ResolvedPublisher {
fn name(&self) -> &str {
"systemd-resolved"
}
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
Box::pin(async move {
let ifindex = interface_index(&published.interface).ok_or_else(|| {
PublishError::Unavailable(format!(
"interface `{}` is not on this host, so there is no link to configure",
published.interface
))
})?;
if published.servers.is_empty() {
return Err(PublishError::Unavailable(
"there is no listening address to send questions to".to_string(),
));
}
let proxy = Self::proxy().await?;
let index = ifindex as i32;
// Every family in one call, because this replaces the link's
// whole list: sending them one at a time would leave only the
// last. A resolver then picks whichever it can reach.
let servers: Vec<(i32, Vec<u8>, u16, String)> = published
.servers
.iter()
.map(|server| {
let (family, octets) = wire_address(server.ip());
(family, octets, server.port(), String::new())
})
.collect();
match proxy.set_link_dns_ex(index, &servers).await {
Ok(()) => {}
Err(err) => {
let classified = classify(err, "setting the link's DNS servers");
// Older systemd has no `Ex` form, and the plain one is
// always port 53. Falling back to it when the server is
// somewhere else would point resolved at nothing.
if !matches!(classified, PublishError::Unavailable(_)) {
return Err(classified);
}
if let Some(elsewhere) =
published.servers.iter().find(|server| server.port() != 53)
{
return Err(PublishError::Unavailable(format!(
"this systemd-resolved cannot be given a port, and the server is on \
{}. Run the server on port 53, or point your resolver at \
{elsewhere} yourself.",
elsewhere.port()
)));
}
let plain: Vec<(i32, Vec<u8>)> = servers
.into_iter()
.map(|(family, octets, _, _)| (family, octets))
.collect();
proxy
.set_link_dns(index, &plain)
.await
.map_err(|err| classify(err, "setting the link's DNS servers"))?;
}
}
let domains: Vec<(String, bool)> = published
.domains
.iter()
.map(|domain| (domain.clone(), true))
.collect();
proxy
.set_link_domains(index, &domains)
.await
.map_err(|err| classify(err, "setting the link's search domains"))?;
// Last, and deliberately: until this is off, resolved may send
// ordinary names here, and this server refuses them.
proxy
.set_link_default_route(index, false)
.await
.map_err(|err| classify(err, "clearing the link's default route"))?;
match self.applied.lock() {
Ok(mut guard) => *guard = Some(ifindex),
Err(poisoned) => *poisoned.into_inner() = Some(ifindex),
}
Ok(())
})
}
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
Box::pin(async move {
let applied = match self.applied.lock() {
Ok(mut guard) => guard.take(),
Err(poisoned) => poisoned.into_inner().take(),
};
let Some(ifindex) = applied else {
return Ok(());
};
let proxy = Self::proxy().await?;
match proxy.revert_link(ifindex as i32).await {
Ok(()) => Ok(()),
// The link going away takes the setting with it, so this is
// the outcome asked for rather than a failure.
Err(zbus::Error::MethodError(name, _, _))
if name.as_str().contains("NoSuchLink") =>
{
Ok(())
}
Err(err) => Err(classify(err, "reverting the link")),
}
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::net::{Ipv4Addr, SocketAddr};
#[test]
fn an_address_is_encoded_the_way_resolved_expects() {
assert_eq!(
wire_address(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 69))),
(AF_INET, vec![10, 13, 37, 69])
);
let (family, octets) = wire_address("fd55::1".parse().unwrap());
assert_eq!(family, AF_INET6);
assert_eq!(octets.len(), 16);
}
#[tokio::test]
async fn an_interface_that_is_not_here_is_unavailable_not_a_failure() {
// Nothing is contacted: there is no link to configure, and saying so
// is the honest answer without bothering the bus.
let publisher = ResolvedPublisher::new();
let published = Published {
interface: "tsunagi-no-such-interface".into(),
servers: vec![SocketAddr::from(([10, 13, 37, 69], 5354))],
domains: vec!["lab".into()],
};
let err = publisher.apply(&published).await.unwrap_err();
assert!(matches!(err, PublishError::Unavailable(_)), "{err}");
}
#[tokio::test]
async fn reverting_without_having_applied_does_nothing_and_succeeds() {
// The shutdown path must not fail because there was nothing to undo.
ResolvedPublisher::new().revert().await.unwrap();
}
}
@@ -0,0 +1,73 @@
//! The publisher for systems nobody has written one for.
//!
//! macOS needs `SystemConfiguration` and Windows the IP Helper API, and
//! neither is written. Saying so is more use than appearing to work: the
//! server is already answering, so all that is missing is the last step, and
//! the user can take it by hand once they know that is what is needed.
use crate::BoxFuture;
use super::{DnsPublisher, PublishError, Published};
/// Refuses to configure anything, with an explanation.
#[derive(Debug, Clone)]
pub struct UnsupportedPublisher {
platform: &'static str,
}
impl Default for UnsupportedPublisher {
fn default() -> Self {
Self::new()
}
}
impl UnsupportedPublisher {
/// A publisher naming the platform it stands in for.
pub fn new() -> Self {
Self {
platform: std::env::consts::OS,
}
}
}
impl DnsPublisher for UnsupportedPublisher {
fn name(&self) -> &str {
"unsupported"
}
fn apply<'a>(&'a self, _published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
Box::pin(async move {
Err(PublishError::Unavailable(format!(
"configuring the system resolver is not implemented on {} yet",
self.platform
)))
})
}
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
// Nothing was set, so there is nothing to undo and no reason to fail
// a shutdown over it.
Box::pin(async move { Ok(()) })
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[tokio::test]
async fn it_names_the_platform_and_still_reverts_quietly() {
let publisher = UnsupportedPublisher::new();
let published = Published {
interface: "tsundemo".into(),
servers: vec!["10.0.0.1:5354".parse().unwrap()],
domains: vec!["lab".into()],
};
let err = publisher.apply(&published).await.unwrap_err();
assert!(matches!(err, PublishError::Unavailable(_)));
assert!(err.to_string().contains(std::env::consts::OS));
publisher.revert().await.unwrap();
}
}
+301
View File
@@ -0,0 +1,301 @@
//! Telling the Windows DNS client to send some questions here, through the
//! Name Resolution Policy Table.
//!
//! The NRPT is how Windows does split DNS: a rule says "names under this
//! suffix are resolved by these servers", and names outside every rule are
//! resolved the ordinary way. That is exactly the contract of [`Published`] —
//! route these suffixes here and claim nothing else — so one NRPT rule per
//! suffix is the whole of it. The rules are keyed by namespace, not by
//! interface, so the interface name in [`Published`] is not needed here.
//!
//! # Why PowerShell
//!
//! The NRPT lives in the registry, but a rule only takes effect once the DNS
//! client is told to reload its policy, and that notification is an RPC with
//! no safe wrapper this crate may call — it forbids `unsafe`. The
//! `DnsClient` PowerShell module does the write *and* the reload, so it is
//! used by absolute path from `%SystemRoot%`. Every value passed to it is one
//! this agent produced: the suffixes are validated zone names (letters,
//! digits, `-`, `_`, `.` only) and the servers are addresses it is listening
//! on. They are still single-quoted and escaped when built into the script,
//! so nothing could be read as PowerShell rather than as data.
//!
//! # The port
//!
//! The Windows DNS client always asks on port 53; it cannot be told another.
//! So a server on a different port cannot be reached this way, and rather than
//! configure a rule that points at nothing, this says so — the same honest
//! failure the systemd-resolved path gives for an old resolver with no port.
//!
//! # Privilege
//!
//! Writing an NRPT rule needs an elevated process. An ordinary user is
//! refused, which is reported as its own kind of error because the answer to
//! it — run elevated, or as a service — differs from "this is not Windows".
use std::collections::BTreeSet;
use std::net::IpAddr;
use std::sync::Mutex;
use crate::BoxFuture;
use super::{DnsPublisher, PublishError, Published};
/// The port the Windows DNS client is fixed to.
const DNS_PORT: u16 = 53;
/// Configures the Name Resolution Policy Table through PowerShell.
#[derive(Debug, Default)]
pub struct NrptPublisher {
/// The namespaces last configured, so shutdown knows what to undo.
applied: Mutex<Vec<String>>,
}
impl NrptPublisher {
/// Creates the publisher. Nothing is contacted until [`Self::apply`].
pub fn new() -> Self {
Self::default()
}
}
/// A suffix as the NRPT wants it: a leading dot means "this and everything
/// under it".
fn namespace(domain: &str) -> String {
let trimmed = domain.trim_matches('.');
format!(".{trimmed}")
}
/// A PowerShell single-quoted literal, with any embedded quote doubled.
fn ps_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
/// A PowerShell array literal, `@('a','b')`, or `@()` when empty.
fn ps_array(values: &[String]) -> String {
let items: Vec<String> = values.iter().map(|value| ps_literal(value)).collect();
format!("@({})", items.join(","))
}
/// The script that clears our rules for these namespaces and then adds them.
fn apply_script(namespaces: &[String], servers: &[String]) -> String {
format!(
"$ErrorActionPreference = 'Stop'\n\
$ns = {ns}\n\
$srv = {srv}\n\
foreach ($n in $ns) {{ Get-DnsClientNrptRule | Where-Object {{ $_.Namespace -eq $n }} | \
ForEach-Object {{ Remove-DnsClientNrptRule -Name $_.Name -Force }} }}\n\
foreach ($n in $ns) {{ Add-DnsClientNrptRule -Namespace $n -NameServers $srv }}\n",
ns = ps_array(namespaces),
srv = ps_array(servers),
)
}
/// The script that clears our rules for these namespaces.
fn revert_script(namespaces: &[String]) -> String {
format!(
"$ErrorActionPreference = 'Stop'\n\
$ns = {ns}\n\
foreach ($n in $ns) {{ Get-DnsClientNrptRule | Where-Object {{ $_.Namespace -eq $n }} | \
ForEach-Object {{ Remove-DnsClientNrptRule -Name $_.Name -Force }} }}\n",
ns = ps_array(namespaces),
)
}
/// Turns what PowerShell said on failure into the kind of failure it is.
fn classify(text: &str) -> PublishError {
let lower = text.to_ascii_lowercase();
if lower.contains("access is denied")
|| lower.contains("requires elevation")
|| lower.contains("run as administrator")
|| lower.contains("administrator privilege")
|| lower.contains("permissiondenied")
{
PublishError::Refused(format!("the Windows DNS client refused the change: {text}"))
} else if lower.contains("is not recognized")
|| lower.contains("commandnotfoundexception")
|| lower.contains("not recognized as the name of a cmdlet")
{
PublishError::Unavailable(format!(
"the DnsClient PowerShell module is not available: {text}"
))
} else {
PublishError::Failed(format!("the Windows DNS client failed the change: {text}"))
}
}
/// Runs a PowerShell script from `%SystemRoot%` and maps a failure to a
/// [`PublishError`].
async fn powershell(script: String) -> Result<(), PublishError> {
let program = powershell_path();
let output = tokio::task::spawn_blocking(move || {
std::process::Command::new(program)
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
&script,
])
.output()
})
.await
.map_err(|err| PublishError::Failed(format!("could not run PowerShell: {err}")))?
.map_err(|err| PublishError::Unavailable(format!("could not run PowerShell: {err}")))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let text = if stderr.trim().is_empty() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
stderr.trim().to_string()
};
Err(classify(if text.is_empty() { "no output" } else { &text }))
}
/// The absolute path to Windows PowerShell, so nothing on `PATH` can stand in
/// for it.
fn powershell_path() -> std::path::PathBuf {
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into());
std::path::Path::new(&root)
.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe")
}
impl DnsPublisher for NrptPublisher {
fn name(&self) -> &str {
"windows-nrpt"
}
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
Box::pin(async move {
if published.domains.is_empty() {
return Err(PublishError::Unavailable(
"there are no suffixes to route, so there is no rule to write".to_string(),
));
}
// Windows can only ask on port 53, so a server anywhere else is
// unreachable this way. Keep the ones it can use.
let servers: BTreeSet<IpAddr> = published
.servers
.iter()
.filter(|server| server.port() == DNS_PORT)
.map(|server| server.ip())
.collect();
if servers.is_empty() {
let elsewhere = published
.servers
.iter()
.map(|server| server.to_string())
.collect::<Vec<_>>()
.join(", ");
return Err(PublishError::Unavailable(format!(
"the Windows DNS client only asks on port {DNS_PORT}, and the server is on \
{elsewhere}. Run the server on port {DNS_PORT}, or point your resolver at it \
yourself."
)));
}
let namespaces: Vec<String> = published.domains.iter().map(|d| namespace(d)).collect();
let servers: Vec<String> = servers.iter().map(|ip| ip.to_string()).collect();
powershell(apply_script(&namespaces, &servers)).await?;
match self.applied.lock() {
Ok(mut guard) => *guard = namespaces,
Err(poisoned) => *poisoned.into_inner() = namespaces,
}
Ok(())
})
}
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
Box::pin(async move {
let namespaces = match self.applied.lock() {
Ok(mut guard) => std::mem::take(&mut *guard),
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
};
if namespaces.is_empty() {
return Ok(());
}
powershell(revert_script(&namespaces)).await
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::net::SocketAddr;
#[test]
fn a_suffix_gets_a_leading_dot() {
assert_eq!(namespace("lab"), ".lab");
assert_eq!(namespace(".lab"), ".lab");
assert_eq!(namespace("a.b"), ".a.b");
}
#[test]
fn a_quote_in_a_value_cannot_escape_the_literal() {
// Zone names never contain a quote, but the escaping is what makes
// that a guarantee rather than a hope.
assert_eq!(ps_literal("a'b"), "'a''b'");
assert_eq!(ps_array(&["x".into(), "y".into()]), "@('x','y')");
assert_eq!(ps_array(&[]), "@()");
}
#[test]
fn the_apply_script_removes_before_it_adds() {
let script = apply_script(&[".lab".into()], &["10.13.37.69".into()]);
let remove = script.find("Remove-DnsClientNrptRule").unwrap();
let add = script.find("Add-DnsClientNrptRule").unwrap();
assert!(
remove < add,
"a stale rule must go before the new one:\n{script}"
);
assert!(script.contains("@('.lab')"));
assert!(script.contains("@('10.13.37.69')"));
}
#[test]
fn an_access_denied_is_a_refusal_a_missing_cmdlet_is_unavailable() {
assert!(matches!(
classify("Access is denied"),
PublishError::Refused(_)
));
assert!(matches!(
classify("The term 'Add-DnsClientNrptRule' is not recognized"),
PublishError::Unavailable(_)
));
assert!(matches!(
classify("something else"),
PublishError::Failed(_)
));
}
#[tokio::test]
async fn a_server_only_on_a_nonstandard_port_is_unavailable_not_a_failure() {
// Nothing is run: Windows cannot ask there, and saying so is the
// honest answer without touching the DNS client.
let publisher = NrptPublisher::new();
let published = Published {
interface: "tsun0".into(),
servers: vec![SocketAddr::from(([10, 13, 37, 69], 5354))],
domains: vec!["lab".into()],
};
let err = publisher.apply(&published).await.unwrap_err();
assert!(matches!(err, PublishError::Unavailable(_)), "{err}");
}
#[tokio::test]
async fn reverting_without_having_applied_does_nothing_and_succeeds() {
NrptPublisher::new().revert().await.unwrap();
}
}
+625
View File
@@ -0,0 +1,625 @@
//! Answering DNS questions about the overlay, over UDP and TCP.
//!
//! Authoritative for one zone and nothing else. There is no recursion, no
//! forwarding and no cache: a question this agent cannot answer from the
//! signed roster is refused rather than passed anywhere, so pointing a
//! resolver at this server can never make it a path to the outside.
//!
//! The decision of what to answer lives in [`super::zone`]; this module is
//! only the wire format and the sockets. [`respond`] sits between them and
//! takes bytes to bytes, so everything the server does to a packet is
//! testable without opening a socket.
use std::net::SocketAddr;
use std::sync::{Arc, RwLock};
use simple_dns::rdata::{A, PTR, RData, SOA};
use simple_dns::{Name, PacketFlag, QCLASS, QTYPE, RCODE, ResourceRecord, TYPE};
use super::zone::{Answer, Query, Zone, Zones};
/// How long an answer may be cached.
///
/// Short, because the roster changes when members come and go and a stale
/// answer is worse than another question.
pub const TTL: u32 = 30;
/// The largest question this server will read.
///
/// A DNS message is 512 bytes without EDNS and 4096 with it; anything past
/// that is not a question worth answering.
pub const MAX_MESSAGE_LEN: usize = 4096;
/// The largest answer sent over UDP without the client offering EDNS.
const CLASSIC_UDP_LIMIT: usize = 512;
/// How long a TCP client may take over one question.
const TCP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// How many TCP questions may be in flight at once.
const MAX_TCP_CONNECTIONS: usize = 32;
/// The zones the server answers from, swapped as the rosters change.
///
/// Shared rather than copied into the server so that a roster change — or a
/// network joining or leaving — is one write, not a restart: rebinding the
/// socket would drop questions in flight for no reason.
#[derive(Debug, Clone, Default)]
pub struct SharedZone(Arc<RwLock<Arc<Zones>>>);
impl SharedZone {
/// Wraps a set of zones.
pub fn new(zones: Zones) -> Self {
Self(Arc::new(RwLock::new(Arc::new(zones))))
}
/// Wraps a single zone, which is the common case in a test.
pub fn one(zone: Zone) -> Self {
Self::new(Zones::new([zone]))
}
/// Replaces them.
pub fn set(&self, zones: Zones) {
match self.0.write() {
Ok(mut guard) => *guard = Arc::new(zones),
Err(poisoned) => *poisoned.into_inner() = Arc::new(zones),
}
}
/// The zones as they are now.
pub fn get(&self) -> Arc<Zones> {
match self.0.read() {
Ok(guard) => Arc::clone(&guard),
Err(poisoned) => Arc::clone(&poisoned.into_inner()),
}
}
}
/// Builds the answer to one question.
///
/// `None` means say nothing at all: the message was not a question this
/// server should reply to, and replying anyway would make this a useful
/// amplifier for somebody spoofing a source address.
pub fn respond(zones: &Zones, query: &[u8]) -> Option<Vec<u8>> {
let packet = simple_dns::Packet::parse(query).ok()?;
if packet.has_flags(PacketFlag::RESPONSE) {
return None;
}
let mut reply = simple_dns::Packet::new_reply(packet.id());
// Recursion is not available here and the flag says so honestly; the
// desired bit is echoed because a resolver compares it.
if packet.has_flags(PacketFlag::RECURSION_DESIRED) {
reply.set_flags(PacketFlag::RECURSION_DESIRED);
}
if packet.opcode() != simple_dns::OPCODE::StandardQuery {
*reply.rcode_mut() = RCODE::NotImplemented;
return reply.build_bytes_vec().ok();
}
// Exactly one question. Zero is nothing to answer; more than one has no
// agreed meaning and every real server rejects it.
let [question] = packet.questions.as_slice() else {
*reply.rcode_mut() = RCODE::FormatError;
return reply.build_bytes_vec().ok();
};
if !matches!(question.qclass, QCLASS::CLASS(simple_dns::CLASS::IN)) {
*reply.rcode_mut() = RCODE::Refused;
return reply.build_bytes_vec().ok();
}
let qname = question.qname.to_string();
// Which zone answers is part of the answer: a denial is bounded by the
// SOA of the zone that denied it, and this server may hold several.
let (zone, answer) = match zones.lookup(&qname, query_kind(question.qtype)) {
Some(answered) => answered,
None => {
reply.questions.push(question.clone());
*reply.rcode_mut() = RCODE::Refused;
return reply.build_bytes_vec_compressed().ok();
}
};
reply.questions.push(question.clone());
let name = Name::new(&qname).ok()?;
match answer {
Answer::NotOurs => *reply.rcode_mut() = RCODE::Refused,
Answer::Addresses(addresses) => {
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
for address in addresses {
reply.answers.push(ResourceRecord::new(
name.clone(),
simple_dns::CLASS::IN,
TTL,
RData::A(A::from(address)),
));
}
}
Answer::Name(target) => {
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
let target = Name::new(&target).ok()?.into_owned();
reply.answers.push(ResourceRecord::new(
name.clone(),
simple_dns::CLASS::IN,
TTL,
RData::PTR(PTR(target)),
));
}
Answer::Soa => {
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
reply.answers.push(soa_record(zone)?);
}
Answer::NoData => {
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
// The authority section carries the SOA so a resolver knows how
// long it may remember that there is nothing here.
reply.name_servers.push(soa_record(zone)?);
}
Answer::NoSuchName => {
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
*reply.rcode_mut() = RCODE::NameError;
reply.name_servers.push(soa_record(zone)?);
}
}
reply.build_bytes_vec_compressed().ok()
}
/// The zone's start of authority.
fn soa_record(zone: &Zone) -> Option<ResourceRecord<'static>> {
let origin = Name::new(zone.origin().as_str()).ok()?.into_owned();
Some(ResourceRecord::new(
origin.clone(),
simple_dns::CLASS::IN,
TTL,
RData::SOA(SOA {
mname: origin.clone(),
// There is no mailbox behind this zone and inventing one would
// be a fiction; the origin itself is the honest answer.
rname: origin,
serial: zone.serial(),
refresh: TTL as i32,
retry: TTL as i32,
expire: 86_400,
minimum: TTL,
}),
))
}
fn query_kind(qtype: QTYPE) -> Query {
match qtype {
QTYPE::TYPE(TYPE::A) => Query::A,
QTYPE::TYPE(TYPE::PTR) => Query::Ptr,
QTYPE::TYPE(TYPE::SOA) => Query::Soa,
QTYPE::TYPE(TYPE::NS) => Query::Ns,
// ANY is answered as an address question rather than by dumping the
// zone: an ANY that returns everything is an amplification gift.
QTYPE::ANY => Query::A,
_ => Query::Other,
}
}
/// Whether the client offered EDNS, and so how large an answer it will take.
fn udp_limit(query: &[u8]) -> usize {
simple_dns::Packet::parse(query)
.ok()
.and_then(|packet| packet.opt().map(|opt| opt.udp_packet_size as usize))
.unwrap_or(CLASSIC_UDP_LIMIT as u16 as usize)
.clamp(CLASSIC_UDP_LIMIT, MAX_MESSAGE_LEN)
}
/// Cuts an answer down to what the client said it would take.
///
/// The records are dropped and the truncated bit set, which tells a resolver
/// to ask again over TCP. Sending a reply it cannot reassemble would just
/// look like packet loss.
fn truncate_for_udp(query: &[u8], reply: Vec<u8>) -> Vec<u8> {
let limit = udp_limit(query);
if reply.len() <= limit {
return reply;
}
let Ok(parsed) = simple_dns::Packet::parse(&reply) else {
return reply;
};
let mut short = simple_dns::Packet::new_reply(parsed.id());
short.set_flags(PacketFlag::TRUNCATION | PacketFlag::AUTHORITATIVE_ANSWER);
*short.rcode_mut() = parsed.rcode();
for question in &parsed.questions {
short.questions.push(question.clone());
}
short.build_bytes_vec().unwrap_or(reply)
}
/// A running DNS server.
#[derive(Debug)]
pub struct DnsServer {
local_addr: SocketAddr,
tasks: Vec<tokio::task::JoinHandle<()>>,
}
impl DnsServer {
/// Binds and starts answering.
///
/// Both transports on the same address and port, as a resolver expects:
/// it falls back to TCP when an answer does not fit, and a server that
/// only listened on UDP would leave it with nowhere to go.
pub async fn bind(addr: SocketAddr, zone: SharedZone) -> std::io::Result<Self> {
let udp = tokio::net::UdpSocket::bind(addr).await?;
let local_addr = udp.local_addr()?;
let tcp = tokio::net::TcpListener::bind(local_addr).await?;
let udp_zone = zone.clone();
let udp_task = tokio::spawn(async move {
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
loop {
let (read, from) = match udp.recv_from(&mut buffer).await {
Ok(pair) => pair,
Err(err) => {
tracing::debug!(%err, "dns udp receive failed");
continue;
}
};
let query = &buffer[..read];
let Some(reply) = respond(&udp_zone.get(), query) else {
continue;
};
let reply = truncate_for_udp(query, reply);
if let Err(err) = udp.send_to(&reply, from).await {
tracing::debug!(%err, "dns udp reply failed");
}
}
});
let tcp_zone = zone.clone();
let tcp_task = tokio::spawn(async move {
let permits = Arc::new(tokio::sync::Semaphore::new(MAX_TCP_CONNECTIONS));
loop {
let (stream, _) = match tcp.accept().await {
Ok(pair) => pair,
Err(err) => {
tracing::debug!(%err, "dns tcp accept failed");
continue;
}
};
let Ok(permit) = Arc::clone(&permits).acquire_owned().await else {
return;
};
let zone = tcp_zone.clone();
tokio::spawn(async move {
let _permit = permit;
// Bounded, so a client that connects and says nothing
// cannot hold a slot open.
let _ = tokio::time::timeout(TCP_TIMEOUT, serve_tcp(stream, zone)).await;
});
}
});
Ok(Self {
local_addr,
tasks: vec![udp_task, tcp_task],
})
}
/// The address it is answering on.
pub fn local_addr(&self) -> SocketAddr {
self.local_addr
}
}
impl Drop for DnsServer {
fn drop(&mut self) {
for task in &self.tasks {
task.abort();
}
}
}
async fn serve_tcp(mut stream: tokio::net::TcpStream, zone: SharedZone) -> std::io::Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
loop {
let mut header = [0u8; 2];
if stream.read_exact(&mut header).await.is_err() {
return Ok(());
}
// Checked before the buffer is allocated, as everywhere else that
// reads a length off a wire.
let len = u16::from_be_bytes(header) as usize;
if len == 0 || len > MAX_MESSAGE_LEN {
return Ok(());
}
let mut query = vec![0u8; len];
stream.read_exact(&mut query).await?;
let Some(reply) = respond(&zone.get(), &query) else {
return Ok(());
};
let Ok(len) = u16::try_from(reply.len()) else {
return Ok(());
};
stream.write_all(&len.to_be_bytes()).await?;
stream.write_all(&reply).await?;
stream.flush().await?;
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::dns::zone::ZoneName;
use std::net::Ipv4Addr;
fn zone() -> Zones {
Zones::new([one_zone()])
}
fn one_zone() -> Zone {
Zone::new(
ZoneName::new("lab").unwrap(),
[
("music".to_string(), Ipv4Addr::new(10, 13, 37, 237)),
("ai".to_string(), Ipv4Addr::new(10, 13, 37, 69)),
],
)
}
fn ask(name: &str, qtype: TYPE) -> Vec<u8> {
let mut packet = simple_dns::Packet::new_query(0x1234);
packet.questions.push(simple_dns::Question::new(
Name::new(name).unwrap(),
qtype.into(),
QCLASS::CLASS(simple_dns::CLASS::IN),
false,
));
packet.build_bytes_vec().unwrap()
}
/// The bytes of a reply. Parsed by each caller, because a parsed packet
/// borrows from them.
fn answer(query: &[u8]) -> Vec<u8> {
respond(&zone(), query).expect("a reply")
}
#[test]
fn a_member_is_answered_authoritatively() {
let bytes = answer(&ask("music.lab", TYPE::A));
let reply = simple_dns::Packet::parse(&bytes).unwrap();
assert_eq!(reply.rcode(), RCODE::NoError);
assert!(reply.has_flags(PacketFlag::RESPONSE));
assert!(reply.has_flags(PacketFlag::AUTHORITATIVE_ANSWER));
assert!(!reply.has_flags(PacketFlag::RECURSION_AVAILABLE));
assert_eq!(reply.answers.len(), 1);
assert_eq!(reply.questions.len(), 1, "the question is echoed");
match &reply.answers[0].rdata {
RData::A(a) => assert_eq!(Ipv4Addr::from(a.address), Ipv4Addr::new(10, 13, 37, 237)),
other => panic!("expected an A record, got {other:?}"),
}
}
#[test]
fn an_absent_name_is_denied_with_a_soa_to_cache_the_denial() {
let bytes = answer(&ask("nobody.lab", TYPE::A));
let reply = simple_dns::Packet::parse(&bytes).unwrap();
assert_eq!(reply.rcode(), RCODE::NameError);
assert!(reply.answers.is_empty());
assert_eq!(
reply.name_servers.len(),
1,
"a SOA bounds the negative cache"
);
}
#[test]
fn a_name_that_exists_without_that_record_is_not_denied() {
// NODATA, not NXDOMAIN: denying the name would stop a resolver
// asking for the A record it could have had.
let bytes = answer(&ask("music.lab", TYPE::AAAA));
let reply = simple_dns::Packet::parse(&bytes).unwrap();
assert_eq!(reply.rcode(), RCODE::NoError);
assert!(reply.answers.is_empty());
assert_eq!(reply.name_servers.len(), 1);
}
#[test]
fn anything_outside_the_zone_is_refused_and_never_forwarded() {
for name in ["example.com", "evillab", "google.com"] {
let bytes = answer(&ask(name, TYPE::A));
let reply = simple_dns::Packet::parse(&bytes).unwrap();
assert_eq!(reply.rcode(), RCODE::Refused, "{name}");
assert!(reply.answers.is_empty());
}
}
#[test]
fn an_address_is_answered_backwards() {
let bytes = answer(&ask("237.37.13.10.in-addr.arpa", TYPE::PTR));
let reply = simple_dns::Packet::parse(&bytes).unwrap();
assert_eq!(reply.rcode(), RCODE::NoError);
match &reply.answers[0].rdata {
RData::PTR(ptr) => assert_eq!(ptr.0.to_string(), "music.lab"),
other => panic!("expected a PTR record, got {other:?}"),
}
}
#[test]
fn a_reply_is_never_sent_to_something_that_was_not_a_question() {
// Answering a response would make this a reflector for anyone who
// can spoof a source address.
let mut packet = simple_dns::Packet::new_reply(1);
packet.set_flags(PacketFlag::RESPONSE);
assert!(respond(&zone(), &packet.build_bytes_vec().unwrap()).is_none());
assert!(respond(&zone(), b"").is_none());
assert!(respond(&zone(), b"not a dns packet at all").is_none());
}
#[test]
fn a_question_with_no_question_in_it_is_a_format_error() {
let packet = simple_dns::Packet::new_query(7);
let bytes = respond(&zone(), &packet.build_bytes_vec().unwrap()).unwrap();
let reply = simple_dns::Packet::parse(&bytes).unwrap();
assert_eq!(reply.rcode(), RCODE::FormatError);
}
#[test]
fn a_class_other_than_internet_is_refused() {
let mut packet = simple_dns::Packet::new_query(9);
packet.questions.push(simple_dns::Question::new(
Name::new("music.lab").unwrap(),
TYPE::A.into(),
QCLASS::CLASS(simple_dns::CLASS::CH),
false,
));
let bytes = respond(&zone(), &packet.build_bytes_vec().unwrap()).unwrap();
assert_eq!(
simple_dns::Packet::parse(&bytes).unwrap().rcode(),
RCODE::Refused
);
}
#[test]
fn an_answer_too_large_for_udp_is_truncated_rather_than_dropped() {
// A resolver that gets a truncated reply asks again over TCP; one
// that gets nothing back just waits.
let many: Vec<(String, Ipv4Addr)> = (0..200)
.map(|i| ("host".to_string(), Ipv4Addr::new(10, 13, 37, i as u8)))
.collect();
let wide = Zones::new([Zone::new(ZoneName::new("lab").unwrap(), many)]);
let query = ask("host.lab", TYPE::A);
let full = respond(&wide, &query).unwrap();
assert!(
full.len() > CLASSIC_UDP_LIMIT,
"the test needs a big answer"
);
let short = truncate_for_udp(&query, full);
assert!(short.len() <= CLASSIC_UDP_LIMIT);
let parsed = simple_dns::Packet::parse(&short).unwrap();
assert!(parsed.has_flags(PacketFlag::TRUNCATION));
assert_eq!(parsed.questions.len(), 1);
}
#[tokio::test]
async fn the_server_answers_over_udp_and_tcp_on_one_address() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let shared = SharedZone::new(zone());
let server = DnsServer::bind("127.0.0.1:0".parse().unwrap(), shared.clone())
.await
.unwrap();
let addr = server.local_addr();
let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
client
.send_to(&ask("music.lab", TYPE::A), addr)
.await
.unwrap();
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
let read = client.recv(&mut buffer).await.unwrap();
let reply = simple_dns::Packet::parse(&buffer[..read]).unwrap();
assert_eq!(reply.answers.len(), 1);
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
let query = ask("ai.lab", TYPE::A);
stream
.write_all(&(query.len() as u16).to_be_bytes())
.await
.unwrap();
stream.write_all(&query).await.unwrap();
let mut header = [0u8; 2];
stream.read_exact(&mut header).await.unwrap();
let mut body = vec![0u8; u16::from_be_bytes(header) as usize];
stream.read_exact(&mut body).await.unwrap();
let reply = simple_dns::Packet::parse(&body).unwrap();
assert_eq!(reply.answers.len(), 1);
}
#[tokio::test]
async fn the_same_zone_is_answered_over_ipv6_as_over_ipv4() {
// A question arrives over whichever family the resolver chooses, so
// a listener is opened from each list in the plan and both have to
// answer the same thing. Answering one family only leaves the other
// timing out, which looks like a broken overlay.
let shared = SharedZone::new(zone());
let plan = crate::dns::listen_plan(0);
let mut answered = 0;
for family in plan.families() {
let candidate = family[0];
let server = match DnsServer::bind(candidate, shared.clone()).await {
Ok(server) => server,
// A host with IPv6 switched off in the kernel cannot bind
// `::1`, and the agent copes with that by design; so does
// this. The other family still has to work.
Err(err) if candidate.is_ipv6() => {
eprintln!("no IPv6 loopback on this host ({err}); skipping that family");
continue;
}
Err(err) => panic!("cannot listen on {candidate}: {err}"),
};
let addr = server.local_addr();
assert_eq!(addr.is_ipv6(), candidate.is_ipv6());
let client = tokio::net::UdpSocket::bind(if addr.is_ipv6() {
"[::1]:0"
} else {
"127.0.0.1:0"
})
.await
.unwrap();
client
.send_to(&ask("music.lab", TYPE::A), addr)
.await
.unwrap();
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
let read = client.recv(&mut buffer).await.unwrap();
let reply = simple_dns::Packet::parse(&buffer[..read]).unwrap();
assert_eq!(reply.rcode(), RCODE::NoError, "over {addr}");
match &reply.answers[0].rdata {
// The same IPv4 answer either way: the family a question
// travelled over says nothing about what the answer is.
RData::A(a) => {
assert_eq!(Ipv4Addr::from(a.address), Ipv4Addr::new(10, 13, 37, 237))
}
other => panic!("expected an A record over {addr}, got {other:?}"),
}
answered += 1;
}
assert!(answered > 0, "at least one family must have answered");
}
#[tokio::test]
async fn replacing_the_zone_changes_what_the_running_server_answers() {
let shared = SharedZone::one(Zone::new(ZoneName::new("lab").unwrap(), []));
let server = DnsServer::bind("127.0.0.1:0".parse().unwrap(), shared.clone())
.await
.unwrap();
let addr = server.local_addr();
let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
client
.send_to(&ask("music.lab", TYPE::A), addr)
.await
.unwrap();
let read = client.recv(&mut buffer).await.unwrap();
assert_eq!(
simple_dns::Packet::parse(&buffer[..read]).unwrap().rcode(),
RCODE::NameError
);
// A member joins: no rebind, no dropped socket.
shared.set(zone());
client
.send_to(&ask("music.lab", TYPE::A), addr)
.await
.unwrap();
let read = client.recv(&mut buffer).await.unwrap();
let reply = simple_dns::Packet::parse(&buffer[..read]).unwrap();
assert_eq!(reply.rcode(), RCODE::NoError);
assert_eq!(reply.answers.len(), 1);
}
}
+749
View File
@@ -0,0 +1,749 @@
//! What the overlay answers to, as a DNS zone.
//!
//! Nothing here touches a socket or the operating system, and it does not
//! depend on a DNS wire library either: it takes a roster and a question and
//! says what the answer is. That is what makes the interesting parts — which
//! names exist, what "does not exist" means as against "exists with nothing
//! of that type", and what is outside the zone entirely — testable on their
//! own.
//!
//! # Where the names come from
//!
//! From signed state, which is why a member that is switched off still
//! resolves. Its claim outlived the session, so the name and the address are
//! both still there to answer with. Nothing is invented for a member that
//! claimed neither.
//!
//! Only IPv4 is served. The IPv6 overlay address is derived from a
//! WireGuard key that travels in live announcements and is not in signed
//! state, so it cannot be answered for a member that is away — and answering
//! for some members and not others depending on whether they happen to be
//! online is worse than not answering at all.
use std::collections::BTreeMap;
use std::net::Ipv4Addr;
/// Longest a DNS name may be, in the presentation form used here.
const MAX_NAME_LEN: usize = 253;
/// Longest one label may be.
const MAX_LABEL_LEN: usize = 63;
/// Why a zone name cannot be used.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ZoneError {
/// The name was empty, or became empty once normalised.
#[error("a zone name must have at least one label")]
Empty,
/// One label was unusable.
#[error("`{label}` is not a usable DNS label: {reason}")]
Label {
/// The offending label.
label: String,
/// What is wrong with it.
reason: &'static str,
},
/// The whole name is too long.
#[error("a zone name must be at most {MAX_NAME_LEN} characters")]
TooLong,
}
/// A validated, canonical zone name.
///
/// Held without a trailing dot and lower-cased, so comparison is a plain
/// string comparison rather than a special case at every use.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ZoneName(String);
impl ZoneName {
/// Validates and normalises a zone name.
pub fn new(raw: &str) -> Result<Self, ZoneError> {
let trimmed = raw.trim().trim_end_matches('.').to_ascii_lowercase();
if trimmed.is_empty() {
return Err(ZoneError::Empty);
}
if trimmed.len() > MAX_NAME_LEN {
return Err(ZoneError::TooLong);
}
for label in trimmed.split('.') {
let reason = if label.is_empty() {
Some("it is empty")
} else if label.len() > MAX_LABEL_LEN {
Some("it is longer than 63 characters")
} else if !label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
Some("only letters, digits, `-` and `_` are allowed")
} else if label.starts_with('-') || label.ends_with('-') {
Some("a label may not start or end with `-`")
} else {
None
};
if let Some(reason) = reason {
return Err(ZoneError::Label {
label: label.to_string(),
reason,
});
}
}
Ok(Self(trimmed))
}
/// The name, without a trailing dot.
pub fn as_str(&self) -> &str {
&self.0
}
/// The last label, which is what would collide with a real top-level
/// domain.
pub fn top_label(&self) -> &str {
self.0.rsplit('.').next().unwrap_or(&self.0)
}
/// What is worrying about this zone name, if anything.
///
/// A warning and never a refusal: the name is the user's to choose, and
/// a private zone that shadows a public one is a decision, not a
/// mistake. Saying nothing would let it be an accident.
pub fn collision(&self) -> Option<String> {
let top = self.top_label();
// Reserved for exactly this use and never delegated, so nothing to
// say. See RFC 6761 and RFC 8375.
const RESERVED: &[&str] = &[
"internal",
"home",
"test",
"example",
"invalid",
"localhost",
];
if RESERVED.contains(&top) {
return None;
}
if top == "local" {
return Some(
"`.local` belongs to multicast DNS: on a host running Avahi or \
systemd-resolved's mDNS, names under it are resolved by that and \
not by this agent"
.to_string(),
);
}
// A real top-level domain is the whole of it. A name that is not
// delegated today might be one day — and saying so fires on every
// private name anybody ever picks, which is a warning about
// nothing that teaches people to ignore warnings.
tld::exist_case_insensitive(top).then(|| {
format!(
"`.{top}` is a real top-level domain, so every public name under it \
becomes unreachable from this host while the overlay is up"
)
})
}
/// Whether `name` is this zone or sits under it.
///
/// Compared label-wise, so `evilzone` does not count as being under
/// `zone`.
pub fn covers(&self, name: &str) -> bool {
let name = name.trim_end_matches('.').to_ascii_lowercase();
name == self.0
|| name
.strip_suffix(&self.0)
.is_some_and(|rest| rest.ends_with('.'))
}
/// The part of `name` below this zone, if it is under it.
fn relative(&self, name: &str) -> Option<String> {
let name = name.trim_end_matches('.').to_ascii_lowercase();
if name == self.0 {
return Some(String::new());
}
let rest = name.strip_suffix(&self.0)?;
let rest = rest.strip_suffix('.')?;
(!rest.is_empty()).then(|| rest.to_string())
}
}
/// The kinds of question this zone knows how to answer.
///
/// Its own enum rather than the wire library's, so the decision of what to
/// answer does not depend on how a packet is encoded.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Query {
/// An IPv4 address.
A,
/// A name for an address.
Ptr,
/// The zone's start of authority.
Soa,
/// The zone's name servers.
Ns,
/// Anything else, including AAAA.
Other,
}
/// What the zone has to say.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Answer {
/// Addresses for the name asked about.
Addresses(Vec<Ipv4Addr>),
/// A name for the address asked about.
Name(String),
/// The zone's start of authority.
Soa,
/// The name exists here but has nothing of the type asked for.
///
/// Distinct from [`Answer::NoSuchName`] because the two are different
/// answers on the wire: this one is a success with no records, and a
/// resolver must not take it as proof the name is absent. Getting them
/// the wrong way round teaches a resolver to cache the wrong thing.
NoData,
/// No such name in this zone.
NoSuchName,
/// Not a name this zone is responsible for.
NotOurs,
}
/// The names and addresses of one network, ready to answer questions.
#[derive(Debug, Clone)]
pub struct Zone {
origin: ZoneName,
/// Name relative to the origin, to the addresses it answers to.
hosts: BTreeMap<String, Vec<Ipv4Addr>>,
/// Address to the name that holds it.
names: BTreeMap<Ipv4Addr, String>,
/// Changes whenever the contents do.
serial: u32,
}
impl Zone {
/// Builds a zone from the members that have both a name and an address.
///
/// A member with one but not the other contributes nothing: a name with
/// no address cannot be answered, and an address with no name has nothing
/// to be asked about.
pub fn new(origin: ZoneName, members: impl IntoIterator<Item = (String, Ipv4Addr)>) -> Self {
let mut hosts: BTreeMap<String, Vec<Ipv4Addr>> = BTreeMap::new();
let mut names: BTreeMap<Ipv4Addr, String> = BTreeMap::new();
for (hostname, address) in members {
let hostname = hostname.trim_matches('.').to_ascii_lowercase();
if hostname.is_empty() {
continue;
}
let addresses = hosts.entry(hostname.clone()).or_default();
if !addresses.contains(&address) {
addresses.push(address);
}
// First name wins, and the map is ordered, so the reverse answer
// is the same on every replica rather than depending on the order
// records happened to arrive in.
names.entry(address).or_insert(hostname);
}
for addresses in hosts.values_mut() {
addresses.sort();
}
let serial = content_serial(&hosts);
Self {
origin,
hosts,
names,
serial,
}
}
/// The zone's origin.
pub fn origin(&self) -> &ZoneName {
&self.origin
}
/// A number that changes whenever the contents do.
pub fn serial(&self) -> u32 {
self.serial
}
/// How many names it answers for.
pub fn len(&self) -> usize {
self.hosts.len()
}
/// Whether it answers for nothing.
pub fn is_empty(&self) -> bool {
self.hosts.is_empty()
}
/// Every name it answers for, with its addresses.
pub fn entries(&self) -> impl Iterator<Item = (&str, &[Ipv4Addr])> {
self.hosts
.iter()
.map(|(name, addresses)| (name.as_str(), addresses.as_slice()))
}
/// The reverse zones this zone is authoritative for.
///
/// Only when the range lands on an octet boundary. Claiming a reverse
/// zone larger than the range would shadow reverse lookups for addresses
/// that are nothing to do with us, which is worse than not answering.
pub fn reverse_origin(base: Ipv4Addr, prefix_len: u8) -> Option<String> {
let octets = base.octets();
match prefix_len {
8 => Some(format!("{}.in-addr.arpa", octets[0])),
16 => Some(format!("{}.{}.in-addr.arpa", octets[1], octets[0])),
24 => Some(format!(
"{}.{}.{}.in-addr.arpa",
octets[2], octets[1], octets[0]
)),
_ => None,
}
}
/// Answers one question.
pub fn lookup(&self, qname: &str, query: Query) -> Answer {
if let Some(address) = reverse_address(qname) {
return match self.names.get(&address) {
Some(name) if query == Query::Ptr => {
Answer::Name(format!("{name}.{}", self.origin.as_str()))
}
Some(_) => Answer::NoData,
None => Answer::NoSuchName,
};
}
let Some(relative) = self.origin.relative(qname) else {
return Answer::NotOurs;
};
// The apex: the zone itself exists whether or not anybody is in it.
if relative.is_empty() {
return match query {
Query::Soa => Answer::Soa,
Query::Ns => Answer::NoData,
_ => Answer::NoData,
};
}
match self.hosts.get(&relative) {
Some(addresses) if query == Query::A => Answer::Addresses(addresses.clone()),
// The name is here, it just has no AAAA and never will while
// only IPv4 is served. Saying "no such name" instead would tell
// a resolver to stop asking for the A record too.
Some(_) => Answer::NoData,
None => Answer::NoSuchName,
}
}
}
/// Every zone one agent serves.
///
/// One agent has one identity and as many networks as it likes, and each of
/// them is a zone of its own named after the network. They are served
/// together because they arrive at the same socket: a question is not
/// labelled with the network it belongs to, only with a name, and the
/// suffix is what decides. Nothing is shared between them — a member of one
/// network is not a name in another.
#[derive(Debug, Clone, Default)]
pub struct Zones {
zones: Vec<Zone>,
}
impl Zones {
/// Collects zones, dropping any that repeats an origin already taken.
///
/// Two networks can be given the same name, and then only one of them
/// can own the suffix. First wins, deterministically, rather than one
/// shadowing the other depending on the order they happened to start.
pub fn new(zones: impl IntoIterator<Item = Zone>) -> Self {
let mut taken: Vec<Zone> = Vec::new();
for zone in zones {
if taken.iter().any(|other| other.origin() == zone.origin()) {
continue;
}
taken.push(zone);
}
Self { zones: taken }
}
/// The zones, in the order they will be consulted.
pub fn iter(&self) -> impl Iterator<Item = &Zone> {
self.zones.iter()
}
/// How many zones are served.
pub fn len(&self) -> usize {
self.zones.len()
}
/// Whether nothing is served.
pub fn is_empty(&self) -> bool {
self.zones.is_empty()
}
/// How many names are answered for, across every zone.
pub fn names(&self) -> usize {
self.zones.iter().map(Zone::len).sum()
}
/// Answers one question, and says which zone answered it.
///
/// The answer has to travel with its zone because the SOA that bounds a
/// denial belongs to the zone that denied it, and serving several means
/// that is no longer a foregone conclusion.
///
/// A reverse question carries no suffix to match on, so every zone is
/// asked and the one that holds the address answers; if none does, the
/// denial comes from whichever is authoritative for that reverse zone,
/// and from nowhere at all when none is.
pub fn lookup(&self, qname: &str, query: Query) -> Option<(&Zone, Answer)> {
let mut denial: Option<(&Zone, Answer)> = None;
for zone in &self.zones {
match zone.lookup(qname, query) {
// Not this zone's question. Another may still own it.
Answer::NotOurs => continue,
// A zone that owns the suffix but not the name can still be
// overruled by one that has an answer: only reverse
// questions reach more than one zone, and there the zone
// holding the address is the one that knows.
answer @ (Answer::NoSuchName | Answer::NoData) => {
denial.get_or_insert((zone, answer));
}
answer => return Some((zone, answer)),
}
}
denial
}
}
/// The address a reverse name asks about, if it is one.
fn reverse_address(qname: &str) -> Option<Ipv4Addr> {
let name = qname.trim_end_matches('.').to_ascii_lowercase();
let rest = name.strip_suffix(".in-addr.arpa")?;
let mut octets = [0u8; 4];
let mut seen = 0;
for (index, part) in rest.split('.').enumerate() {
if index >= 4 {
return None;
}
octets[3 - index] = part.parse().ok()?;
seen += 1;
}
(seen == 4).then(|| Ipv4Addr::from(octets))
}
/// A serial that changes with the contents and not otherwise.
///
/// Derived rather than counted, so two agents holding the same roster agree,
/// and a restart does not go backwards.
fn content_serial(hosts: &BTreeMap<String, Vec<Ipv4Addr>>) -> u32 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
for (name, addresses) in hosts {
name.hash(&mut hasher);
for address in addresses {
address.octets().hash(&mut hasher);
}
}
// Never zero: a zero serial is legal but reads like "unset" in a log.
(hasher.finish() as u32).max(1)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn zone() -> Zone {
Zone::new(
ZoneName::new("lab").unwrap(),
[
("music".to_string(), Ipv4Addr::new(10, 13, 37, 237)),
("ai".to_string(), Ipv4Addr::new(10, 13, 37, 69)),
],
)
}
#[test]
fn a_zone_name_is_normalised_and_checked() {
assert_eq!(ZoneName::new("LAB.").unwrap().as_str(), "lab");
assert_eq!(
ZoneName::new(" lab.internal ").unwrap().as_str(),
"lab.internal"
);
assert_eq!(
ZoneName::new("lab.internal").unwrap().top_label(),
"internal"
);
assert_eq!(ZoneName::new("").unwrap_err(), ZoneError::Empty);
assert_eq!(ZoneName::new(".").unwrap_err(), ZoneError::Empty);
assert!(matches!(
ZoneName::new("a..b").unwrap_err(),
ZoneError::Label { .. }
));
assert!(matches!(
ZoneName::new("-lab").unwrap_err(),
ZoneError::Label { .. }
));
assert!(matches!(
ZoneName::new("la b").unwrap_err(),
ZoneError::Label { .. }
));
assert!(matches!(
ZoneName::new(&"x".repeat(64)).unwrap_err(),
ZoneError::Label { .. }
));
}
#[test]
fn a_zone_name_that_shadows_a_public_one_is_flagged_but_allowed() {
// Flagged, never refused: shadowing is the user's decision to make.
let ru = ZoneName::new("ru").unwrap();
let warning = ru.collision().expect("a real TLD is worth mentioning");
assert!(warning.contains("real top-level domain"), "{warning}");
assert!(ZoneName::new("com").unwrap().collision().is_some());
assert!(ZoneName::new("lab.com").unwrap().collision().is_some());
// Reserved for private use, so nothing to say.
for quiet in ["internal", "lab.internal", "home", "test", "invalid"] {
assert_eq!(ZoneName::new(quiet).unwrap().collision(), None, "{quiet}");
}
// `.local` is not a delegated TLD, but it is not free either.
let local = ZoneName::new("local").unwrap().collision().unwrap();
assert!(local.contains("multicast DNS"), "{local}");
// A name that collides with nothing is said nothing about. It
// might be delegated one day, and warning about that fires on
// every private name anybody picks — a warning about nothing,
// which is how people learn to ignore warnings.
for quiet in ["lab", "a.lab", "pidar", "twar", "kitchen-table"] {
assert_eq!(ZoneName::new(quiet).unwrap().collision(), None, "{quiet}");
}
}
#[test]
fn a_neighbouring_name_is_not_inside_the_zone() {
// `evillab` ends with `lab`, and a suffix comparison that forgot the
// label boundary would hand it to us.
let origin = ZoneName::new("lab").unwrap();
assert!(origin.covers("lab"));
assert!(origin.covers("music.lab."));
assert!(origin.covers("a.b.lab"));
assert!(!origin.covers("evillab"));
assert!(!origin.covers("lab.example.com"));
assert!(!origin.covers("example.com"));
}
#[test]
fn a_member_resolves_by_name() {
let zone = zone();
assert_eq!(
zone.lookup("music.lab", Query::A),
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 237)])
);
// Case and a trailing dot are the same question.
assert_eq!(
zone.lookup("MUSIC.LAB.", Query::A),
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 237)])
);
}
#[test]
fn a_name_with_no_record_of_that_type_is_not_a_missing_name() {
// AAAA for a member that exists must be NODATA, not NXDOMAIN: an
// NXDOMAIN would tell the resolver the name is absent and stop it
// asking for the A record.
let zone = zone();
assert_eq!(zone.lookup("music.lab", Query::Other), Answer::NoData);
assert_eq!(zone.lookup("nobody.lab", Query::A), Answer::NoSuchName);
}
#[test]
fn questions_outside_the_zone_are_refused_rather_than_denied() {
// Denying them would be a lie: this server knows nothing about them.
let zone = zone();
assert_eq!(zone.lookup("example.com", Query::A), Answer::NotOurs);
assert_eq!(zone.lookup("evillab", Query::A), Answer::NotOurs);
}
#[test]
fn the_apex_exists_even_with_nobody_in_the_network() {
let empty = Zone::new(ZoneName::new("lab").unwrap(), []);
assert!(empty.is_empty());
assert_eq!(empty.lookup("lab", Query::Soa), Answer::Soa);
assert_eq!(empty.lookup("lab", Query::A), Answer::NoData);
assert_eq!(empty.lookup("music.lab", Query::A), Answer::NoSuchName);
}
#[test]
fn an_address_resolves_back_to_its_name() {
let zone = zone();
assert_eq!(
zone.lookup("237.37.13.10.in-addr.arpa", Query::Ptr),
Answer::Name("music.lab".into())
);
assert_eq!(
zone.lookup("9.37.13.10.in-addr.arpa", Query::Ptr),
Answer::NoSuchName
);
// A reverse name asked for the wrong type is still a name we know.
assert_eq!(
zone.lookup("237.37.13.10.in-addr.arpa", Query::A),
Answer::NoData
);
}
#[test]
fn a_reverse_zone_is_claimed_only_when_it_is_exactly_ours() {
// Claiming a reverse zone wider than the range would shadow lookups
// for addresses that have nothing to do with this network.
assert_eq!(
Zone::reverse_origin(Ipv4Addr::new(10, 13, 37, 0), 24).as_deref(),
Some("37.13.10.in-addr.arpa")
);
assert_eq!(
Zone::reverse_origin(Ipv4Addr::new(10, 13, 0, 0), 16).as_deref(),
Some("13.10.in-addr.arpa")
);
assert_eq!(Zone::reverse_origin(Ipv4Addr::new(10, 13, 37, 0), 25), None);
assert_eq!(Zone::reverse_origin(Ipv4Addr::new(100, 64, 0, 0), 10), None);
}
#[test]
fn members_with_nothing_to_say_are_left_out() {
let zone = Zone::new(
ZoneName::new("lab").unwrap(),
[(String::new(), Ipv4Addr::new(10, 0, 0, 1))],
);
assert!(zone.is_empty());
}
#[test]
fn two_members_sharing_a_name_both_answer_and_the_order_is_stable() {
// The state layer resolves name ownership; if two records still
// reach here, answering with both beats picking one at random.
let build = |flip: bool| {
let members = if flip {
vec![
("music".to_string(), Ipv4Addr::new(10, 0, 0, 2)),
("music".to_string(), Ipv4Addr::new(10, 0, 0, 1)),
]
} else {
vec![
("music".to_string(), Ipv4Addr::new(10, 0, 0, 1)),
("music".to_string(), Ipv4Addr::new(10, 0, 0, 2)),
]
};
Zone::new(ZoneName::new("lab").unwrap(), members)
};
assert_eq!(
build(false).lookup("music.lab", Query::A),
build(true).lookup("music.lab", Query::A)
);
assert_eq!(build(false).serial(), build(true).serial());
}
#[test]
fn the_serial_follows_the_contents() {
let a = zone();
let b = zone();
assert_eq!(a.serial(), b.serial(), "the same roster, the same serial");
let changed = Zone::new(
ZoneName::new("lab").unwrap(),
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 238))],
);
assert_ne!(a.serial(), changed.serial());
}
#[test]
fn each_network_answers_only_for_its_own_zone() {
// One agent, several networks, one socket. A question carries a
// name and not the network it belongs to, so the suffix is what
// decides — and a member of one network is not a name in another.
let zones = Zones::new([
Zone::new(
ZoneName::new("lab").unwrap(),
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))],
),
Zone::new(
ZoneName::new("home").unwrap(),
[("music".to_string(), Ipv4Addr::new(10, 20, 0, 2))],
),
]);
assert_eq!(zones.len(), 2);
assert_eq!(zones.names(), 2);
let (zone, answer) = zones.lookup("music.lab", Query::A).unwrap();
assert_eq!(zone.origin().as_str(), "lab");
assert_eq!(
answer,
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 2)])
);
let (zone, answer) = zones.lookup("music.home", Query::A).unwrap();
assert_eq!(zone.origin().as_str(), "home");
assert_eq!(answer, Answer::Addresses(vec![Ipv4Addr::new(10, 20, 0, 2)]));
// A name under neither is nobody's business here.
assert!(zones.lookup("music.example.com", Query::A).is_none());
assert!(Zones::default().lookup("music.lab", Query::A).is_none());
}
#[test]
fn a_denial_comes_from_the_zone_that_owns_the_name() {
// The SOA that bounds a negative answer belongs to the zone that
// denied it; with several served that is no longer a given.
let zones = Zones::new([
Zone::new(ZoneName::new("lab").unwrap(), []),
Zone::new(
ZoneName::new("home").unwrap(),
[("music".to_string(), Ipv4Addr::new(10, 20, 0, 2))],
),
]);
let (zone, answer) = zones.lookup("nobody.lab", Query::A).unwrap();
assert_eq!(zone.origin().as_str(), "lab");
assert_eq!(answer, Answer::NoSuchName);
}
#[test]
fn a_reverse_question_is_answered_by_whichever_zone_holds_the_address() {
// It carries no suffix to match on, so every zone is asked.
let zones = Zones::new([
Zone::new(
ZoneName::new("lab").unwrap(),
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))],
),
Zone::new(
ZoneName::new("home").unwrap(),
[("kitchen".to_string(), Ipv4Addr::new(10, 20, 0, 2))],
),
]);
let (_, answer) = zones.lookup("2.0.20.10.in-addr.arpa", Query::Ptr).unwrap();
assert_eq!(answer, Answer::Name("kitchen.home".to_string()));
let (_, answer) = zones.lookup("2.37.13.10.in-addr.arpa", Query::Ptr).unwrap();
assert_eq!(answer, Answer::Name("music.lab".to_string()));
}
#[test]
fn two_networks_of_one_name_do_not_shadow_each_other_at_random() {
// Exactly the two-`LAB` case. Only one can own the suffix; which
// one must not depend on the order they happened to start in.
let first = Zone::new(
ZoneName::new("lab").unwrap(),
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))],
);
let second = Zone::new(
ZoneName::new("lab").unwrap(),
[("music".to_string(), Ipv4Addr::new(10, 99, 0, 2))],
);
let zones = Zones::new([first, second]);
assert_eq!(zones.len(), 1, "one suffix, one zone");
let (_, answer) = zones.lookup("music.lab", Query::A).unwrap();
assert_eq!(
answer,
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 2)])
);
}
}
@@ -110,6 +110,16 @@ pub enum Error {
#[error("agent is stopped")]
Stopped,
/// Something that had to answer in bounded time did not.
///
/// Reported instead of waited out: an agent that has wedged must not
/// leave a command hanging with nothing on screen and no way out.
#[error("{what} did not answer in time")]
Timeout {
/// What was asked, phrased so the message stands on its own.
what: String,
},
/// Discovery backend failure. Never fatal for the agent.
#[error("discovery error: {0}")]
Discovery(String),
@@ -49,11 +49,16 @@ impl DeviceIdentity {
self.secret.public()
}
/// The raw secret key bytes, for persistence only.
pub(crate) fn secret_bytes(&self) -> [u8; 32] {
/// The raw secret key bytes, for persistence and transport certificate generation.
pub fn secret_bytes(&self) -> [u8; 32] {
self.secret.to_bytes()
}
/// Signs a message with the device's persistent secret key.
pub fn sign(&self, message: &[u8]) -> iroh::Signature {
self.secret.sign(message)
}
/// A clone of the iroh secret key, for endpoint construction only.
pub(crate) fn secret_key(&self) -> SecretKey {
self.secret.clone()
@@ -340,6 +340,8 @@ pub struct NetworkKeys {
network_id: NetworkId,
discovery_key: DiscoveryKey,
auth_key: Zeroizing<[u8; 32]>,
// Separate from the semi-public DiscoveryKey. Existing derivations stay frozen.
dht_write_key: Zeroizing<[u8; 32]>,
name: NetworkName,
}
@@ -373,6 +375,7 @@ impl NetworkKeys {
network_id: NetworkId(expand("network-id")),
discovery_key: DiscoveryKey(expand("discovery-key")),
auth_key: Zeroizing::new(expand("handshake-auth")),
dht_write_key: Zeroizing::new(expand("mainline-rendezvous-write-v1")),
name: name.clone(),
}
}
@@ -405,6 +408,11 @@ impl NetworkKeys {
pub(crate) fn auth_key(&self) -> &[u8; 32] {
&self.auth_key
}
/// Secret signing material for network rendezvous; never exported or logged.
pub(crate) fn dht_write_key(&self) -> &[u8; 32] {
&self.dht_write_key
}
}
impl std::fmt::Debug for NetworkKeys {
+863
View File
@@ -0,0 +1,863 @@
//! The local control interface.
//!
//! This is how a command line tool asks a running agent what it is doing. It
//! is deliberately **an adapter over the public API, not part of the core**:
//! nothing in [`crate::agent`] knows this module exists, so a Windows named
//! pipe or an authenticated loopback socket can be added beside it without
//! touching anything else.
//!
//! It is also a different interface from the peer-to-peer control protocol in
//! [`crate::proto`]. That one is between machines and is authenticated by the
//! network secret; this one is between processes on one machine and is
//! authorised by filesystem permissions.
//!
//! # Access
//!
//! The socket lives inside the agent's state directory, which is owner-only,
//! and the socket itself is created with mode `0600`. There is no
//! unauthenticated listener reachable by other local users, and nothing is
//! exposed on the network.
//!
//! # Wire format
//!
//! Length-prefixed postcard, with the same frame bounds the network protocol
//! uses. The report types here are a stable data transfer format of their own
//! rather than the crate's internal structures, so internal refactors do not
//! silently change what a client sees.
#[cfg(unix)]
pub mod unix;
#[cfg(windows)]
pub mod windows;
// The one transport this build serves on. The two adapters expose the same
// items, so the rest of the crate — and the client wrappers below — name the
// transport through this alias and never a platform directly.
#[cfg(unix)]
use unix as transport;
#[cfg(windows)]
use windows as transport;
/// Serves the local control interface on this platform's transport.
///
/// A Unix socket on Unix, a named pipe on Windows; the same API either way.
#[cfg(any(unix, windows))]
pub use transport::ControlSocket;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use crate::BoxFuture;
use crate::error::{Error, Result};
/// Largest accepted local control message.
pub const MAX_MESSAGE_LEN: usize = 1024 * 1024;
/// Where the control socket for a state directory lives.
///
/// A Unix socket path is limited to around 100 bytes, which a state directory
/// nested deeply enough will exceed. So the runtime directory is preferred
/// when the platform provides one — which is also where a runtime socket
/// belongs — with a short name derived from the state directory so that two
/// agents with different state never share a socket. The state directory
/// itself is the fallback.
///
/// Both the agent and the client compute this the same way, so neither has to
/// be told where the other put it.
pub fn control_socket_path(state_dir: &Path) -> PathBuf {
let digest = Sha256::digest(state_dir.as_os_str().as_encoded_bytes());
let tag = hex::encode(&digest[..8]);
if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") {
let runtime = PathBuf::from(runtime);
if runtime.is_absolute() {
return runtime.join("tsunagi").join(format!("{tag}.sock"));
}
}
state_dir.join("agent.sock")
}
/// Where the default system control socket lives on this platform.
///
/// On Windows, the system-wide `%ProgramData%\tsunagi\agent.sock` is used so that
/// any local user, tray application, or CLI tool can manage the running agent
/// without needing to know which user account or service started it.
/// On macOS the agent runs as a root LaunchDaemon, so a fixed system path under
/// `/var/run` is used for the same reason, and it stays short enough for the
/// Unix socket path limit. On other Unix it derives from the default per-user
/// state directory.
pub fn default_control_socket_path() -> PathBuf {
#[cfg(windows)]
{
if let Some(program_data) = std::env::var_os("ProgramData") {
let pd = PathBuf::from(program_data);
if pd.is_absolute() {
return pd.join("tsunagi").join("agent.sock");
}
}
PathBuf::from(r"C:\ProgramData\tsunagi\agent.sock")
}
#[cfg(target_os = "macos")]
{
PathBuf::from("/var/run/tsunagi/agent.sock")
}
#[cfg(all(unix, not(target_os = "macos")))]
{
if let Ok(paths) = crate::config::StoragePaths::user_default() {
control_socket_path(&paths.state_dir)
} else {
PathBuf::from("/run/tsunagi/agent.sock")
}
}
}
/// What a client asks for.
///
/// `Debug` is written by hand rather than derived: one of these carries a
/// network secret, and a derived one would put it in any log line that
/// printed a request.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Request {
/// Report what the agent is doing.
Status,
/// Set local broadcast participation in one configured network.
SetBroadcast {
/// Public network identifier.
network_id: String,
/// Whether to originate and accept broadcasts.
enabled: bool,
},
/// Answer to a different name from now on.
///
/// Applied by the running agent rather than written behind its back, so
/// the change takes effect and reaches peers immediately instead of
/// waiting for a restart.
SetHostname(String),
/// Leave a network: give up what was claimed, then forget it.
///
/// Asked of the running agent rather than done behind its back, because
/// only it can publish the release, and only while its sessions are up.
/// The network is named by its id, in the text form `status` prints.
Leave(String),
/// Stop serving a network, or start serving it again.
///
/// Not leaving: the configuration, the secret and the signed state all
/// stay, so it can be resumed exactly where it was. The network is
/// named by its id, in the text form `status` prints.
SetActive {
/// Which network.
network_id: String,
/// Whether it should be running.
active: bool,
},
/// Turn the local resolver on or off, now and for future starts.
Dns {
/// Whether it should be serving.
enable: bool,
/// The port to serve on. Keeps the stored one when absent.
port: Option<u16>,
},
/// Join a network, or start one that is configured and not running.
///
/// Asked of the running agent because that is the only way to add a
/// network to an agent that is already up: the state directory belongs
/// to one live agent, so a second `tsunagi up` cannot.
Join {
/// The network name.
name: String,
/// The shared secret in its `tsn1…` text form.
///
/// It travels over a socket only its owner can open, to the agent
/// that stores it anyway, and never appears in `Debug`.
secret: String,
/// Explicit choice, or preserve the saved setting.
#[serde(default)]
broadcast: Option<bool>,
},
}
impl std::fmt::Debug for Request {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Request::Status => f.write_str("Status"),
Request::SetBroadcast {
network_id,
enabled,
} => write!(f, "SetBroadcast {{ {network_id}, enabled: {enabled} }}"),
Request::SetHostname(name) => write!(f, "SetHostname({name})"),
Request::Leave(network) => write!(f, "Leave({network})"),
Request::SetActive { network_id, active } => {
write!(f, "SetActive {{ {network_id}, active: {active} }}")
}
// The name is not a secret; the secret is.
Request::Dns { enable, port } => {
write!(f, "Dns {{ enable: {enable}, port: {port:?} }}")
}
Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: <redacted> }}"),
}
}
}
/// What the agent answers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Response {
/// A status report.
Status(Box<StatusReport>),
/// Accepted local broadcast participation.
Broadcast(bool),
/// The name the agent now answers to, after reducing it to canonical form.
Hostname(String),
/// A network was left.
Left(LeftReport),
/// A network was joined, or was already there and is now running.
Joined(JoinedReport),
/// A network was stopped or started.
Active(ActiveReport),
/// What the local resolver is doing, after being changed or asked.
///
/// `None` means it is not serving at all.
Dns(Option<DnsReport>),
/// The request could not be served.
Error(String),
}
/// What happened when a network was stopped or started.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActiveReport {
/// The network's name, for the message the user reads.
pub name: String,
/// Whether it is running now.
pub active: bool,
/// Whether this request is what changed it.
///
/// Stopping something already stopped is not an error — it is the state
/// asked for — but it is worth saying which happened.
pub changed: bool,
}
/// What happened when a network was joined.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct JoinedReport {
/// The network's name, as given.
pub name: String,
/// The derived network id, which is what tells two same-named networks
/// apart.
pub network_id: String,
/// Whether this device was already configured for exactly this network.
///
/// Joining is idempotent, so this is the difference between "added" and
/// "it was already there and is now running".
pub already_configured: bool,
/// Another configured network with the same name but a different
/// secret, if there is one.
///
/// Almost always a mistyped secret, and the one thing that makes two
/// sections of a report look like one network.
pub name_shared_with: Option<String>,
}
/// What happened when a network was left.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeftReport {
/// The network's name, for the message the user reads.
pub name: String,
/// Whether a signed release was published.
///
/// `false` when the network was not running: nothing could sign or send
/// it, so this was a local removal and the others keep the old claim.
pub announced: bool,
/// How many connected peers it was handed to.
///
/// They pass it on, so this is not the number of members that will
/// learn of it — but zero means none of them will.
pub peers_told: u32,
}
/// Everything the agent is doing, in one snapshot.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusReport {
/// This device's persistent endpoint id.
pub endpoint_id: String,
/// Hostname announced to peers.
pub hostname: String,
/// Sockets the endpoint is bound to.
pub bound_sockets: Vec<String>,
/// Whether the disposable cache is usable.
pub cache_healthy: bool,
/// One entry per configured network.
pub networks: Vec<NetworkReport>,
/// The local DNS service, when one was asked for.
pub dns: Option<DnsReport>,
}
/// The local DNS service.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DnsReport {
/// The zones it answers for: one per network, named after it.
pub zones: Vec<String>,
/// Every address it is listening on, one per family where it could.
///
/// Empty means it answers nowhere, and `bind_error` says why.
pub listening: Vec<String>,
/// Why it could not bind, if it did not.
pub bind_error: Option<String>,
/// Why the system resolver was not told, if it was not.
///
/// `None` means it was told. The server answers either way, so this is a
/// degraded overlay rather than a broken one.
pub publish_error: Option<String>,
/// What to do about that, when there is something.
pub publish_remedy: Option<String>,
/// Anything worth saying about those names, one line each.
pub zone_warnings: Vec<String>,
/// How many names it answers for.
pub names: u32,
}
/// One network.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NetworkReport {
/// Whether this agent participates in LAN broadcasts in this network.
#[serde(default)]
pub broadcast: bool,
/// Network name.
pub name: String,
/// Public network identifier.
pub network_id: String,
/// Whether the network is running locally.
pub active: bool,
/// Authenticated control plane peers.
pub peers: Vec<PeerReport>,
/// Members the signed state knows about, connected or not.
///
/// This is what makes "offline" sayable. Without it a member that is away
/// is indistinguishable from one that never existed, and the only thing
/// left to report is a dial-failure counter — which describes the symptom
/// and not the cause.
pub members: Vec<MemberReport>,
/// Unverified candidates this network currently knows of.
///
/// Not peers: somewhere to try. Zero of them, with nobody connected,
/// is the difference between "nobody has joined yet" and "this agent
/// has no way to reach anybody" — which look identical in a report
/// that counts only members.
pub candidates: u32,
/// Datagrams this agent passed on between two other peers.
pub relay_forwarded: u64,
/// Datagrams this agent sent to a peer through somebody else.
pub relay_sent_via: u64,
/// Datagrams that reached this agent through somebody else.
pub relay_received_via: u64,
/// Outbound dials that failed.
pub dial_failures: u64,
/// Handshakes rejected in either direction.
pub handshake_failures: u64,
/// Control messages sent and received.
pub control_messages: (u64, u64),
/// The overlay range this network uses, once it has one.
pub range: Option<String>,
/// The range it could not have, because another network on this agent
/// already holds it.
pub range_conflict: Option<String>,
/// The overlay, when a protocol is running one.
pub overlay: Option<OverlayReport>,
}
/// One member of the network, from signed state.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemberReport {
/// The member's device identity.
pub endpoint_id: String,
/// The IPv4 overlay address it claimed and signed for.
pub overlay_address_v4: Option<String>,
/// Consecutive failed dial attempts, when this agent is trying to reach it.
pub failed_dials: u32,
}
/// One control plane peer.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerReport {
/// The peer's endpoint id.
pub endpoint_id: String,
/// Hostname it announced, if any.
pub hostname: Option<String>,
/// How the connection reaches the peer: `direct`, `relay` or `unknown`,
/// as the transport reports it.
pub transport: String,
/// Round-trip time in milliseconds, when a path is selected.
pub rtt_ms: Option<u64>,
}
/// The WireGuard overlay of one network.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayReport {
/// Packet interface name.
pub interface: String,
/// Whether that interface exists on the host.
///
/// `false` under `--no-tun`: tunnels run and packets move between
/// agents, but the operating system has no interface, no address and no
/// route, so nothing local reaches the overlay.
pub on_host: bool,
/// Interface MTU.
pub mtu: u32,
/// This agent's overlay address, once the network has agreed one.
pub address: Option<String>,
/// Prefix length of the overlay range every member shares.
pub prefix_len: u8,
/// One entry per overlay peer.
pub peers: Vec<OverlayPeerReport>,
/// Unicast packets sent to an address no peer owns.
pub unroutable_packets: u64,
/// Multicast packets dropped. Expected, not a fault.
pub multicast_packets: u64,
/// One destination nobody owned, if there was one.
pub unroutable_sample: Option<String>,
/// The broadcast route and firewall allowance on this host, when the
/// agent installed them. `None` means none are installed.
pub broadcast_rules: Option<HostRulesReport>,
}
/// What the agent installed on the host so LAN broadcast reaches the overlay.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostRulesReport {
/// Whether both the route and the firewall allowance are in place.
pub ok: bool,
/// One line: `ok (route + firewall)`, or what is missing and why.
pub detail: String,
/// The overlay address the `255.255.255.255` route is bound to. With
/// several networks on one interface only one address can be its source.
pub source: String,
}
/// One overlay peer.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayPeerReport {
/// The peer's control plane identity, so a tunnel can be matched to the
/// session and the member it belongs to.
pub endpoint_id: String,
/// Protocol carrying overlay traffic for this peer (e.g. "tcp-tls", "wg-quic").
pub protocol: String,
/// The peer's WireGuard public key (if applicable).
pub public_key: String,
/// The overlay address the network agreed it holds.
pub address: Option<String>,
/// Seconds since the last WireGuard handshake.
///
/// `None` means the tunnel has never handshaken and cannot carry traffic.
pub handshake_secs_ago: Option<u64>,
/// Packets encrypted and sent to this peer.
pub tx_packets: u64,
/// Packets decrypted from this peer.
pub rx_packets: u64,
/// Data packets dropped: wrong source address, or too large for the path.
pub dropped: u64,
/// WireGuard protocol errors.
///
/// A few are normal while a tunnel is being set up, because both ends
/// start a handshake at once and one of the two is discarded.
pub protocol_errors: u64,
/// What the transport reports about the path in use.
pub path: String,
}
impl OverlayPeerReport {
/// Whether the tunnel has handshaken and can carry traffic.
pub fn is_up(&self) -> bool {
self.handshake_secs_ago.is_some()
}
}
// ---------------------------------------------------------------------------
// The transport-agnostic half of the local control interface.
//
// Everything below is the same on every platform: the request/response
// framing, the dispatch of a request onto a [`ReportSource`], and the client
// wrappers that ask one question and read one answer. Only the listener and
// the stream differ, and those live in the per-platform `unix` and `windows`
// adapters. Each adapter drives the shared [`serve_connection`] for its
// accepted streams and offers a `connect`/`is_serving` for the client side,
// so the logic here is written once.
// ---------------------------------------------------------------------------
/// Builds the report that answers a status request, and applies the changes a
/// client asks for.
///
/// Supplied by the caller, because only the caller knows which plugins are
/// running and what they can report or change. That is what keeps this module
/// free of any knowledge of them.
pub trait ReportSource: Send + Sync + 'static {
/// Produces a fresh report.
fn report(&self) -> BoxFuture<'_, StatusReport>;
/// Changes the name the agent answers to, returning the accepted form.
///
/// Defaulted to a refusal so that a source which only reports — the
/// closure impl below, and every test that uses it — stays valid and
/// says plainly that it cannot do this, rather than appearing to.
fn set_hostname(
&self,
_hostname: String,
) -> BoxFuture<'_, std::result::Result<String, String>> {
Box::pin(async move { Err("this agent cannot change its hostname".to_string()) })
}
/// Leaves a network, publishing a release first.
///
/// Defaulted to a refusal for the same reason as the above: a source
/// that only reports says so plainly rather than appearing to do it.
fn leave(&self, _network_id: String) -> BoxFuture<'_, std::result::Result<LeftReport, String>> {
Box::pin(async move { Err("this agent cannot leave a network".to_string()) })
}
/// Joins a network, or starts one that is configured and not running.
///
/// Defaulted to a refusal, like the others: a source that only reports
/// says so rather than appearing to have done it.
fn join(
&self,
_name: String,
_secret: String,
_broadcast: Option<bool>,
) -> BoxFuture<'_, std::result::Result<JoinedReport, String>> {
Box::pin(async move { Err("this agent cannot join a network".to_string()) })
}
/// Persists and applies local broadcast participation.
fn set_broadcast(
&self,
_network_id: String,
_enabled: bool,
) -> BoxFuture<'_, std::result::Result<bool, String>> {
Box::pin(async move { Err("this agent cannot change broadcast participation".into()) })
}
/// Stops serving a network, or starts serving it again.
///
/// Defaulted to a refusal, like the others.
fn set_active(
&self,
_network_id: String,
_active: bool,
) -> BoxFuture<'_, std::result::Result<ActiveReport, String>> {
Box::pin(async move { Err("this agent cannot stop or start a network".to_string()) })
}
/// Turns the local resolver on or off while the agent runs.
///
/// Defaulted to a refusal, like the others.
fn set_dns(
&self,
_enable: bool,
_port: Option<u16>,
) -> BoxFuture<'_, std::result::Result<Option<DnsReport>, String>> {
Box::pin(async move { Err("this agent cannot serve DNS".to_string()) })
}
}
impl<F> ReportSource for F
where
F: Fn() -> BoxFuture<'static, StatusReport> + Send + Sync + 'static,
{
fn report(&self) -> BoxFuture<'_, StatusReport> {
(self)()
}
}
/// How long either end waits for the other.
///
/// A local answer comes from memory, so anything this slow means the agent is
/// wedged rather than busy. Saying so beats waiting: unbounded, one wedged
/// runtime leaves `tsunagi status` hanging with nothing on screen and no way
/// out but Ctrl-C.
pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
/// Marks the wire format of the local control interface.
///
/// `b"TSN"` followed by the version, so a mismatch is recognised as one
/// instead of being read as a length. The encoding is postcard, which is not
/// self-describing: adding a field to a report changes how the bytes parse,
/// and without this a client one build ahead of its agent reports something
/// like "Found an Option discriminant that wasn't 0 or 1" — which says
/// nothing about the actual problem, that the two are different builds.
///
/// Bump it whenever [`Request`], [`Response`] or anything they contain
/// changes shape.
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 15]);
/// Reads one request off an accepted stream, answers it, writes the response.
///
/// The per-platform adapters call this for every connection they accept, so
/// the request handling is identical on every transport.
pub(crate) async fn serve_connection<S>(mut stream: S, source: Arc<dyn ReportSource>) -> Result<()>
where
S: AsyncRead + AsyncWrite + Unpin,
{
// Bounded, so a connection that sends nothing cannot hold a task open.
// Only the wait for the request: building the report afterwards takes as
// long as it takes, and cutting it off would answer a live client with a
// closed socket.
let request: Request =
match tokio::time::timeout(EXCHANGE_TIMEOUT, read_message(&mut stream)).await {
Ok(request) => request?,
Err(_) => {
return Err(Error::Timeout {
what: "a local control connection".to_string(),
});
}
};
let response = match request {
Request::Status => Response::Status(Box::new(source.report().await)),
Request::SetBroadcast {
network_id,
enabled,
} => match source.set_broadcast(network_id, enabled).await {
Ok(enabled) => Response::Broadcast(enabled),
Err(error) => Response::Error(error),
},
Request::SetHostname(hostname) => match source.set_hostname(hostname).await {
Ok(accepted) => Response::Hostname(accepted),
Err(reason) => Response::Error(reason),
},
Request::Leave(network_id) => match source.leave(network_id).await {
Ok(report) => Response::Left(report),
Err(reason) => Response::Error(reason),
},
Request::Join {
name,
secret,
broadcast,
} => match source.join(name, secret, broadcast).await {
Ok(report) => Response::Joined(report),
Err(reason) => Response::Error(reason),
},
Request::SetActive { network_id, active } => {
match source.set_active(network_id, active).await {
Ok(report) => Response::Active(report),
Err(reason) => Response::Error(reason),
}
}
Request::Dns { enable, port } => match source.set_dns(enable, port).await {
Ok(report) => Response::Dns(report),
Err(reason) => Response::Error(reason),
},
};
write_message(&mut stream, &response).await
}
/// Connects, sends one request and reads the answer, all within `within`.
///
/// The bound covers the whole exchange rather than each read: an agent that
/// answers the header and then stops is as stuck as one that never answers.
async fn exchange(path: &Path, request: &Request, within: Duration) -> Result<Response> {
let attempt = async {
let mut stream = transport::connect(path).await?;
write_message(&mut stream, request).await?;
read_message::<Response, _>(&mut stream).await
};
match tokio::time::timeout(within, attempt).await {
Ok(result) => result,
Err(_) => Err(Error::Timeout {
what: format!("the agent at {}", path.display()),
}),
}
}
/// Whether an agent is serving the control interface at `path`.
///
/// Used to tell a running agent from a leftover: on Unix a socket file with
/// nothing behind it, on Windows nothing at all.
/// A Windows pipe that denies access still counts as present; the subsequent
/// request reports the permission error instead of attempting an offline edit.
pub async fn is_serving(path: impl AsRef<Path>) -> bool {
transport::probe(path.as_ref()).await
}
/// Asks a running agent for its status.
pub async fn request_status(path: impl AsRef<Path>) -> Result<StatusReport> {
let path = path.as_ref();
match exchange(path, &Request::Status, EXCHANGE_TIMEOUT).await? {
Response::Status(report) => Ok(*report),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Asks a running agent to answer to a different name.
///
/// Returns the name it accepted, which is the canonical form of what was
/// asked for and may differ from it.
pub async fn set_hostname(path: impl AsRef<Path>, hostname: &str) -> Result<String> {
let path = path.as_ref();
let request = Request::SetHostname(hostname.to_string());
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
Response::Hostname(accepted) => Ok(accepted),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Asks a running agent to leave a network.
///
/// The agent publishes the release and removes the network; this only
/// carries the request and the outcome.
pub async fn leave_network(path: impl AsRef<Path>, network_id: &str) -> Result<LeftReport> {
let path = path.as_ref();
let request = Request::Leave(network_id.to_string());
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
Response::Left(report) => Ok(report),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Asks a running agent to join a network.
///
/// The one way to add a network to an agent that is already up: the state
/// directory belongs to one live agent, so a second `up` cannot.
pub async fn join_network(
path: impl AsRef<Path>,
name: &str,
secret: &str,
) -> Result<JoinedReport> {
let path = path.as_ref();
join_network_with_broadcast(path, name, secret, None).await
}
/// Joins with an explicit per-network broadcast choice.
pub async fn join_network_with_broadcast(
path: impl AsRef<Path>,
name: &str,
secret: &str,
broadcast: Option<bool>,
) -> Result<JoinedReport> {
let path = path.as_ref();
let request = Request::Join {
broadcast,
name: name.to_string(),
secret: secret.to_string(),
};
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
Response::Joined(report) => Ok(report),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Changes one network's broadcast participation while the agent runs.
pub async fn set_broadcast(
path: impl AsRef<Path>,
network_id: &str,
enabled: bool,
) -> Result<bool> {
let path = path.as_ref();
let request = Request::SetBroadcast {
network_id: network_id.to_owned(),
enabled,
};
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
Response::Broadcast(enabled) => Ok(enabled),
Response::Error(message) => Err(Error::Storage(message)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Asks a running agent to stop serving a network, or to serve it again.
pub async fn set_active(
path: impl AsRef<Path>,
network_id: &str,
active: bool,
) -> Result<ActiveReport> {
let path = path.as_ref();
let request = Request::SetActive {
network_id: network_id.to_string(),
active,
};
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
Response::Active(report) => Ok(report),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Turns the running agent's local resolver on or off.
pub async fn set_dns(
path: impl AsRef<Path>,
enable: bool,
port: Option<u16>,
) -> Result<Option<DnsReport>> {
let path = path.as_ref();
match exchange(path, &Request::Dns { enable, port }, EXCHANGE_TIMEOUT).await? {
Response::Dns(report) => Ok(report),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
pub(crate) async fn write_message<S, T>(stream: &mut S, value: &T) -> Result<()>
where
S: AsyncWrite + Unpin,
T: serde::Serialize,
{
let encoded = postcard::to_stdvec(value)
.map_err(|err| Error::Storage(format!("cannot encode a control message: {err}")))?;
if encoded.len() > MAX_MESSAGE_LEN {
return Err(Error::Storage("control message is too large".into()));
}
let len = encoded.len() as u32;
stream
.write_all(&CONTROL_PROTOCOL.to_be_bytes())
.await
.map_err(io_error)?;
stream
.write_all(&len.to_be_bytes())
.await
.map_err(io_error)?;
stream.write_all(&encoded).await.map_err(io_error)?;
stream.flush().await.map_err(io_error)
}
pub(crate) async fn read_message<T, S>(stream: &mut S) -> Result<T>
where
T: for<'de> serde::Deserialize<'de>,
S: AsyncRead + Unpin,
{
let mut header = [0u8; 4];
stream.read_exact(&mut header).await.map_err(io_error)?;
let version = u32::from_be_bytes(header);
if version != CONTROL_PROTOCOL {
return Err(Error::Storage(format!(
"the other end speaks control protocol {version:#010x} and this build speaks \
{CONTROL_PROTOCOL:#010x}; they are different builds of tsunagi, so restart the \
agent with the binary you are running now"
)));
}
stream.read_exact(&mut header).await.map_err(io_error)?;
let len = u32::from_be_bytes(header) as usize;
// Checked before allocating, exactly as on the network.
if len > MAX_MESSAGE_LEN {
return Err(Error::Storage(format!(
"control message of {len} bytes exceeds the {MAX_MESSAGE_LEN} byte limit"
)));
}
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload).await.map_err(io_error)?;
postcard::from_bytes(&payload)
.map_err(|err| Error::Storage(format!("cannot decode a control message: {err}")))
}
pub(crate) fn io_error(source: std::io::Error) -> Error {
Error::Io {
path: PathBuf::from("<local control socket>"),
source,
}
}
+182
View File
@@ -0,0 +1,182 @@
//! A Unix socket adapter for the local control interface.
//!
//! One of the per-platform adapters; see [`super`]. It provides only what is
//! particular to a Unix socket — binding a listener, connecting a client, and
//! the owner-only permissions — and hands every accepted connection to the
//! shared [`serve_connection`](super::serve_connection). The request framing,
//! the dispatch and the client wrappers are the same on every platform and
//! live in [`super`], re-exported here so `ipc::unix::request_status` and its
//! siblings keep resolving.
//!
//! It is reachable only by a process that can open a file inside the agent's
//! owner-only state directory, and the socket itself is created mode `0600`.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::net::{UnixListener, UnixStream};
use tokio::task::JoinHandle;
use crate::error::{Error, Result};
// The transport-agnostic surface, re-exported so this module is a complete
// view of the local control interface on its own.
pub use super::{
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
request_status, set_active, set_dns, set_hostname,
};
/// Serves the local control interface on a Unix socket.
#[derive(Debug)]
pub struct ControlSocket {
path: PathBuf,
task: Option<JoinHandle<()>>,
}
impl ControlSocket {
/// Binds the socket and starts serving.
///
/// A socket file left behind by a crashed agent is replaced, but only
/// after checking that nothing is listening on it, so two live agents
/// never fight over one path.
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
crate::storage::create_dir(parent)?;
}
if path.exists() {
if is_serving(&path).await {
return Err(Error::StateLocked { path: path.clone() });
}
// Nothing is listening, so the file is a leftover.
std::fs::remove_file(&path).map_err(|source| Error::Io {
path: path.clone(),
source,
})?;
}
let listener = UnixListener::bind(&path).map_err(|source| Error::Io {
path: path.clone(),
source,
})?;
restrict(&path)?;
let task = tokio::spawn(serve(listener, source));
Ok(Self {
path,
task: Some(task),
})
}
/// The path being served.
pub fn path(&self) -> &Path {
&self.path
}
/// Stops serving and removes the socket file.
pub async fn shutdown(mut self) {
if let Some(task) = self.task.take() {
task.abort();
let _ = task.await;
}
let _ = std::fs::remove_file(&self.path);
}
}
impl Drop for ControlSocket {
fn drop(&mut self) {
if let Some(task) = self.task.take() {
task.abort();
}
let _ = std::fs::remove_file(&self.path);
}
}
fn restrict(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
Error::Io {
path: path.to_path_buf(),
source,
}
})
}
async fn serve(listener: UnixListener, source: Arc<dyn ReportSource>) {
loop {
let Ok((stream, _)) = listener.accept().await else {
continue;
};
let source = Arc::clone(&source);
tokio::spawn(async move {
if let Err(err) = super::serve_connection(stream, source).await {
tracing::debug!(%err, "local control request failed");
}
});
}
}
/// Connects a client to the socket at `path`.
pub(crate) async fn connect(path: &Path) -> Result<UnixStream> {
UnixStream::connect(path).await.map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})
}
/// Whether an agent is listening on the socket at `path`.
pub(crate) async fn probe(path: &Path) -> bool {
UnixStream::connect(path).await.is_ok()
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::time::Duration;
use super::super::{Request, Response, StatusReport, exchange};
use super::*;
use crate::BoxFuture;
#[tokio::test]
async fn a_silent_agent_is_reported_rather_than_waited_out() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("control.sock");
// A socket that accepts and then says nothing, like a wedged agent:
// the connection succeeds, the answer never comes. Unbounded, this
// call would never return.
let _listener = UnixListener::bind(&path).unwrap();
let error = exchange(&path, &Request::Status, Duration::from_millis(100))
.await
.expect_err("a silent agent cannot be reported as healthy");
match error {
Error::Timeout { what } => assert!(
what.contains("control.sock"),
"the message names what did not answer: {what}"
),
other => panic!("expected a timeout, got {other:?}"),
}
}
#[tokio::test]
async fn an_answered_request_is_not_affected_by_the_bound() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("control.sock");
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
Box::pin(async { StatusReport::default() })
});
let control = ControlSocket::bind(&path, source).await.unwrap();
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
.await
.unwrap();
assert!(matches!(answer, Response::Status(_)));
control.shutdown().await;
}
}
+444
View File
@@ -0,0 +1,444 @@
//! A named-pipe adapter for the local control interface.
//!
//! The Windows counterpart of [`super::unix`]. It provides only what is
//! particular to a named pipe — creating the server instance, opening a client
//! and turning the state directory path into a pipe name — and hands every
//! accepted connection to the shared [`serve_connection`](super::serve_connection).
//! The framing, the dispatch and the client wrappers are the same on every
//! platform and live in [`super`], re-exported here so `ipc::windows::request_status`
//! and its siblings resolve just as their Unix equivalents do.
//!
//! # From a path to a pipe
//!
//! The rest of the agent addresses the control interface by a filesystem path,
//! the same one a Unix socket would live at. A named pipe has no filesystem
//! path, so the path is hashed into a stable name under `\\.\pipe\`. The agent
//! and a client derive it the same way from the same path, so neither has to
//! be told where the other put it.
//!
//! # Who may connect
//!
//! The pipe rejects clients from other machines (`reject_remote_clients(true)`).
//! An explicit SDDL security descriptor grants Full Control to Administrators
//! and LocalSystem, while granting Read and Write (`GRGW`) to all local users
//! with a Medium Integrity SACL label (`S:(ML;;NW;;;ME)`). This allows unprivileged
//! user-space tools (such as tray applications, status monitors, or CLI commands
//! launched by regular users) to connect to the agent even when the agent was
//! started by an elevated Administrator or as a system service.
//! A permission denial is reported as such, never as an absent agent.
#![allow(unsafe_code)]
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use sha2::{Digest, Sha256};
use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient, ServerOptions};
use tokio::task::JoinHandle;
use crate::error::{Error, Result};
pub use super::{
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
request_status, set_active, set_dns, set_hostname,
};
/// `ERROR_ACCESS_DENIED`: creating a first instance finds an existing pipe,
/// or a client cannot access an existing pipe with its current privileges.
const ERROR_ACCESS_DENIED: i32 = 5;
/// `ERROR_PIPE_BUSY`: every instance is serving a client right now. A server
/// is there; the client only has to wait for a free instance.
const ERROR_PIPE_BUSY: i32 = 231;
/// RAII wrapper for Windows named pipe security attributes with permissive local ACL.
///
/// SDDL specification:
/// - `D:(A;;GRGW;;;WD)`: DACL allows Generic Read and Generic Write to Everyone (`WD`).
/// Full control (`GA`) remains reserved to Administrators and LocalSystem.
/// - `(A;;GA;;;BA)`: DACL allows Generic All to Builtin Administrators (`BA`).
/// - `(A;;GA;;;SY)`: DACL allows Generic All to LocalSystem (`SY`).
/// - `S:(ML;;NW;;;ME)`: SACL Mandatory Label with Medium Integrity (`ME`) and No Write Up (`NW`).
/// This allows unprivileged user-space processes (Medium Integrity) to write to the
/// named pipe even when the agent runs as an elevated Administrator (High Integrity).
struct PipeSecurityAttributes {
attrs: windows_sys::Win32::Security::SECURITY_ATTRIBUTES,
}
impl std::fmt::Debug for PipeSecurityAttributes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PipeSecurityAttributes")
.field("nLength", &self.attrs.nLength)
.field("lpSecurityDescriptor", &self.attrs.lpSecurityDescriptor)
.field("bInheritHandle", &self.attrs.bInheritHandle)
.finish()
}
}
impl PipeSecurityAttributes {
fn new() -> Result<Self> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
};
const SDDL: &str = "D:(A;;GRGW;;;WD)(A;;GA;;;BA)(A;;GA;;;SY)S:(ML;;NW;;;ME)";
let wide: Vec<u16> = OsStr::new(SDDL)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut p_sd = std::ptr::null_mut();
// SAFETY: We pass a valid null-terminated wide string, standard revision 1,
// and a valid pointer to receive the descriptor.
let success = unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
wide.as_ptr(),
SDDL_REVISION_1,
&mut p_sd,
std::ptr::null_mut(),
)
};
if success == 0 || p_sd.is_null() {
let err = std::io::Error::last_os_error();
return Err(Error::Storage(format!(
"cannot create pipe security descriptor: {err}"
)));
}
let attrs = windows_sys::Win32::Security::SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<windows_sys::Win32::Security::SECURITY_ATTRIBUTES>()
as u32,
lpSecurityDescriptor: p_sd,
bInheritHandle: 0,
};
Ok(Self { attrs })
}
fn as_raw_mut(&mut self) -> *mut std::ffi::c_void {
&mut self.attrs as *mut _ as *mut std::ffi::c_void
}
}
impl Drop for PipeSecurityAttributes {
fn drop(&mut self) {
if !self.attrs.lpSecurityDescriptor.is_null() {
// SAFETY: The descriptor was allocated by ConvertStringSecurityDescriptorToSecurityDescriptorW
// and must be freed with LocalFree.
unsafe {
windows_sys::Win32::Foundation::LocalFree(self.attrs.lpSecurityDescriptor);
}
self.attrs.lpSecurityDescriptor = std::ptr::null_mut();
}
}
}
/// Serves the local control interface on a named pipe.
#[derive(Debug)]
pub struct ControlSocket {
path: PathBuf,
task: Option<JoinHandle<()>>,
}
impl ControlSocket {
/// Binds the pipe and starts serving.
///
/// Claiming the first instance of the name is what detects a second agent:
/// if the name already exists, the create is refused and that is reported
/// as the state being locked, so two live agents never share one pipe.
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
let path = path.as_ref().to_path_buf();
let name = pipe_name(&path);
let mut sec_attrs = PipeSecurityAttributes::new()?;
// SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES.
let server = match unsafe {
ServerOptions::new()
.first_pipe_instance(true)
.reject_remote_clients(true)
.create_with_security_attributes_raw(&name, sec_attrs.as_raw_mut())
} {
Ok(server) => server,
Err(err) if err.raw_os_error() == Some(ERROR_ACCESS_DENIED) => {
return Err(Error::StateLocked { path });
}
Err(source) => return Err(Error::Io { path, source }),
};
let task = tokio::spawn(serve(name, server, source));
Ok(Self {
path,
task: Some(task),
})
}
/// The path the pipe name was derived from.
pub fn path(&self) -> &Path {
&self.path
}
/// Stops serving. The pipe goes with the server, so there is nothing to
/// remove.
pub async fn shutdown(mut self) {
if let Some(task) = self.task.take() {
task.abort();
let _ = task.await;
}
}
}
impl Drop for ControlSocket {
fn drop(&mut self) {
if let Some(task) = self.task.take() {
task.abort();
}
}
}
/// Accepts one client at a time, keeping a fresh instance ready for the next.
///
/// A named pipe server instance serves a single client, so a new instance is
/// created as soon as one is taken — otherwise a second `tsunagi status` while
/// the first is mid-flight would find nothing listening.
async fn serve(
name: String,
first: tokio::net::windows::named_pipe::NamedPipeServer,
source: Arc<dyn ReportSource>,
) {
let mut server = first;
loop {
if server.connect().await.is_err() {
match next_instance(&name) {
Some(next) => {
server = next;
continue;
}
None => return,
}
}
let connected = server;
match next_instance(&name) {
Some(next) => server = next,
None => {
// Nothing left to accept the next client on, but the one in
// hand is still answered before the loop ends.
let source = Arc::clone(&source);
tokio::spawn(async move {
let _ = super::serve_connection(connected, source).await;
});
return;
}
}
let source = Arc::clone(&source);
tokio::spawn(async move {
if let Err(err) = super::serve_connection(connected, source).await {
tracing::debug!(%err, "local control request failed");
}
});
}
}
/// Creates the next pipe instance, or `None` if the name can no longer be
/// served.
fn next_instance(name: &str) -> Option<tokio::net::windows::named_pipe::NamedPipeServer> {
let mut sec_attrs = match PipeSecurityAttributes::new() {
Ok(attrs) => attrs,
Err(err) => {
tracing::debug!(%err, "cannot build pipe security attributes for next instance");
return None;
}
};
// SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES.
match unsafe {
ServerOptions::new()
.reject_remote_clients(true)
.create_with_security_attributes_raw(name, sec_attrs.as_raw_mut())
} {
Ok(server) => Some(server),
Err(err) => {
tracing::debug!(%err, "cannot create the next control pipe instance");
None
}
}
}
/// Connects a client to the pipe for `path`, waiting out a busy pipe.
///
/// The wait is bounded by the caller's exchange timeout, so a pipe that is
/// busy forever is given up on rather than spun on.
pub(crate) async fn connect(path: &Path) -> Result<NamedPipeClient> {
let name = pipe_name(path);
loop {
match ClientOptions::new().open(&name) {
Ok(client) => return Ok(client),
Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY) => {
tokio::time::sleep(Duration::from_millis(50)).await;
}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
return Err(Error::Io {
path: path.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"the agent's control pipe exists, but Windows denied access. \
Run this command as the same Windows user and with the same \
elevation as `tsunagi up` (use an administrator terminal if \
the agent is elevated). Windows error: {err}"
),
),
});
}
Err(source) => {
return Err(Error::Io {
path: path.to_path_buf(),
source,
});
}
}
}
}
/// Whether an agent is serving the pipe for `path`.
///
/// A busy or inaccessible pipe still means a server is there. In particular,
/// access denied must not send a mutating command down the offline path,
/// where it would only produce a misleading state-directory lock error.
pub(crate) async fn probe(path: &Path) -> bool {
let name = pipe_name(path);
match ClientOptions::new().open(&name) {
Ok(_) => true,
Err(err) => {
err.raw_os_error() == Some(ERROR_PIPE_BUSY)
|| err.kind() == std::io::ErrorKind::PermissionDenied
}
}
}
/// The pipe name for a control-socket path.
///
/// A hash rather than the path itself, because a pipe name may not contain the
/// separators and drive letters a path does, and because two agents with
/// different state directories must never collide.
fn pipe_name(path: &Path) -> String {
let digest = Sha256::digest(path.as_os_str().as_encoded_bytes());
format!(r"\\.\pipe\tsunagi-{}", hex::encode(&digest[..16]))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::time::Duration;
use super::super::{Request, Response, StatusReport, exchange};
use super::*;
use crate::BoxFuture;
#[test]
fn one_path_maps_to_one_name_and_two_paths_do_not_collide() {
let a = pipe_name(Path::new(r"C:\a\agent.sock"));
let b = pipe_name(Path::new(r"C:\b\agent.sock"));
assert!(a.starts_with(r"\\.\pipe\tsunagi-"), "{a}");
assert_eq!(a, pipe_name(Path::new(r"C:\a\agent.sock")));
assert_ne!(a, b);
}
#[tokio::test]
async fn a_request_makes_the_round_trip_over_a_real_pipe() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("agent.sock");
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
Box::pin(async { StatusReport::default() })
});
assert!(!is_serving(&path).await, "nothing serves the pipe yet");
let control = ControlSocket::bind(&path, source).await.unwrap();
assert!(is_serving(&path).await, "the agent serves it now");
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
.await
.unwrap();
assert!(matches!(answer, Response::Status(_)));
control.shutdown().await;
}
#[tokio::test]
async fn an_inaccessible_agent_is_not_mistaken_for_an_absent_one() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("agent.sock");
// An outbound-only pipe denies the client's duplex open even when
// both processes have the same privileges. This exercises the same
// Windows error as a client unable to access an elevated agent,
// without requiring elevation or changing any ACLs.
let _server = ServerOptions::new()
.first_pipe_instance(true)
.access_inbound(false)
.create(pipe_name(&path))
.unwrap();
assert!(is_serving(&path).await, "access denied is not absence");
for error in [
request_status(&path).await.unwrap_err(),
join_network(&path, "test", "local-ipc-test-secret")
.await
.unwrap_err(),
set_dns(&path, false, None).await.unwrap_err(),
] {
let Error::Io { source, .. } = error else {
panic!("expected an access error, got {error}");
};
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
let message = source.to_string();
assert!(message.contains("same Windows user"), "{message}");
assert!(message.contains("administrator"), "{message}");
assert!(!message.contains("local-ipc-test-secret"), "{message}");
}
}
#[tokio::test]
async fn a_silent_agent_is_reported_rather_than_waited_out() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("agent.sock");
// A server instance that accepts and then never answers, like a
// wedged agent. Unbounded, the exchange below would never return.
let name = pipe_name(&path);
let server = ServerOptions::new()
.first_pipe_instance(true)
.create(&name)
.unwrap();
let _accept = tokio::spawn(async move {
let _ = server.connect().await;
// Hold the connection open, answering nothing.
tokio::time::sleep(Duration::from_secs(30)).await;
});
let error = exchange(&path, &Request::Status, Duration::from_millis(200))
.await
.expect_err("a silent agent cannot be reported as healthy");
assert!(matches!(error, Error::Timeout { .. }), "{error:?}");
}
#[tokio::test]
async fn a_second_agent_on_the_same_pipe_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("agent.sock");
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
Box::pin(async { StatusReport::default() })
});
let first = ControlSocket::bind(&path, Arc::clone(&source))
.await
.unwrap();
let second = ControlSocket::bind(&path, source).await;
assert!(
matches!(second, Err(Error::StateLocked { .. })),
"{second:?}"
);
first.shutdown().await;
}
}
+5
View File
@@ -39,13 +39,18 @@ pub mod agent;
pub mod config;
pub mod dataplane;
pub mod discovery;
pub mod dns;
pub mod error;
pub mod identity;
pub mod ipc;
pub mod net;
pub mod overlay;
pub mod proto;
pub mod state;
pub mod storage;
pub mod task;
#[cfg(feature = "testing")]
pub mod testing;
pub use agent::{Agent, AgentStatus, Event, NetworkStatus, PeerStatus};
pub use config::{AgentConfig, Limits, ReconnectPolicy, StoragePaths, TransportPolicy};
+141
View File
@@ -240,6 +240,62 @@ impl EndpointAdapter {
TransportPolicy::N0Defaults => builder.preset(presets::N0),
};
if matches!(
config.transport,
TransportPolicy::N0Defaults | TransportPolicy::DirectOnly
) {
let filter = iroh::address_lookup::AddrFilter::new(|addrs| {
let mut overlay_ips = std::collections::HashSet::new();
for iface in netdev::get_interfaces() {
let name = iface.name.to_lowercase();
let friendly = iface
.friendly_name
.as_deref()
.map(str::to_lowercase)
.unwrap_or_default();
let desc = iface
.description
.as_deref()
.map(str::to_lowercase)
.unwrap_or_default();
if name.starts_with("tsun")
|| name.contains("tsunagi")
|| name.contains("wintun")
|| friendly.starts_with("tsun")
|| friendly.contains("tsunagi")
|| friendly.contains("wintun")
|| desc.contains("wintun")
|| desc.contains("tsunagi")
{
for ip in iface.ipv4 {
overlay_ips.insert(std::net::IpAddr::V4(ip.addr()));
}
for ip in iface.ipv6 {
overlay_ips.insert(std::net::IpAddr::V6(ip.addr()));
}
}
}
if overlay_ips.is_empty() {
return std::borrow::Cow::Borrowed(addrs);
}
let filtered: Vec<iroh::TransportAddr> = addrs
.iter()
.filter(|addr| match addr {
iroh::TransportAddr::Ip(sock) => !overlay_ips.contains(&sock.ip()),
_ => true,
})
.cloned()
.collect();
std::borrow::Cow::Owned(filtered)
});
builder = builder.addr_filter(filter);
}
#[cfg(feature = "testing")]
if let Some(transport) = &config.test_quic_transport {
builder = builder.transport_config(transport.clone());
}
if !config.bind_addrs.is_empty() {
builder = builder.clear_ip_transports();
for addr in &config.bind_addrs {
@@ -254,6 +310,91 @@ impl EndpointAdapter {
.await
.map_err(|err| Error::Endpoint(format!("cannot bind endpoint: {err}")))?;
tracing::debug!(
endpoint = %endpoint.id().fmt_short(),
bound_sockets = ?endpoint.bound_sockets(),
"iroh endpoint bound locally"
);
let ep_id = endpoint.id();
let mut addr_watcher = endpoint.watch_addr();
let mut relay_watcher = endpoint.home_relay_status();
let close_token = endpoint.clone();
tokio::spawn(async move {
use iroh::Watcher;
let mut last_addrs: Vec<SocketAddr> = Vec::new();
let mut last_relays: Vec<String> = Vec::new();
let warn_timer = tokio::time::sleep(Duration::from_secs(12));
tokio::pin!(warn_timer);
let mut warned = false;
loop {
tokio::select! {
biased;
_ = close_token.closed() => {
break;
}
() = &mut warn_timer, if !warned => {
warned = true;
if last_addrs.is_empty() && !last_relays.is_empty() {
tracing::warn!(
endpoint = %ep_id.fmt_short(),
relays = ?last_relays,
"No direct reflexive or UPnP addresses discovered after probe; communications will rely on relay servers"
);
} else if last_addrs.is_empty() && last_relays.is_empty() {
tracing::warn!(
endpoint = %ep_id.fmt_short(),
"No reachability discovered (neither direct reflexive/UPnP addresses nor relay servers available)"
);
}
}
res = addr_watcher.updated() => {
let addr = match res {
Ok(a) => a,
Err(_) => break,
};
let addrs: Vec<SocketAddr> = addr.ip_addrs().copied().collect();
let relays: Vec<String> = addr.relay_urls().map(|u| u.to_string()).collect();
if addrs != last_addrs || relays != last_relays {
tracing::debug!(
endpoint = %ep_id.fmt_short(),
direct_addrs = ?addrs,
relays = ?relays,
"Discovered endpoint reachability (STUN/UPnP/local/relay)"
);
last_addrs = addrs;
last_relays = relays;
}
}
res = relay_watcher.updated() => {
let relays = match res {
Ok(r) => r,
Err(_) => break,
};
for relay in &relays {
if relay.is_connected() {
tracing::debug!(
endpoint = %ep_id.fmt_short(),
relay = %relay.url(),
"Connected to home relay server"
);
} else if let Some(err) = relay.last_error() {
tracing::debug!(
endpoint = %ep_id.fmt_short(),
relay = %relay.url(),
error = %err,
"Relay connection attempt failed"
);
}
}
}
}
}
});
Ok(Self { endpoint })
}
+228
View File
@@ -0,0 +1,228 @@
//! Scoped LAN discovery fanout at IP ingress. A domain is one logical network;
//! routed copies use ordinary encrypted peer links and are never reflooded.
//! Future subnet exporters must supply explicitly authorized ingress domains
//! here, not teach the encrypted transit router to inspect application bytes.
use super::router::{NetworkRoutes, Route};
use crate::NetworkId;
use iroh::EndpointId;
use std::collections::{HashMap, HashSet};
use std::net::Ipv4Addr;
use std::sync::Arc;
/// Local participation and the currently authenticated participants in a domain.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BroadcastPolicy {
/// Whether this agent originates and accepts LAN broadcasts in this network.
pub enabled: bool,
/// Live members explicitly advertising that they accept broadcasts.
pub peers: HashSet<EndpointId>,
}
impl Default for BroadcastPolicy {
fn default() -> Self {
Self {
enabled: true,
peers: HashSet::new(),
}
}
}
#[derive(Debug)]
struct Domain {
network: NetworkId,
directed: Ipv4Addr,
enabled: bool,
recipients: Arc<[Route]>,
members: HashSet<EndpointId>,
}
/// Immutable ingress-domain index rebuilt with address/policy changes.
#[derive(Debug, Default)]
pub(crate) struct BroadcastTable {
sources: HashMap<Ipv4Addr, Arc<Domain>>,
networks: HashMap<NetworkId, Arc<Domain>>,
destinations: HashSet<Ipv4Addr>,
}
impl BroadcastTable {
pub fn build(networks: &HashMap<NetworkId, NetworkRoutes>) -> Self {
let mut table = Self::default();
for (&network, routes) in networks {
let (Some(range), Some(local)) = (routes.range, routes.local) else {
continue;
};
if range.prefix_len > 30 {
continue;
}
let directed = Ipv4Addr::from(u32::from(range.base) | (u32::MAX >> range.prefix_len));
let mut recipients: Vec<_> = routes
.peers
.iter()
.filter(|(_, peer)| routes.broadcast.peers.contains(peer))
.map(|(_, peer)| Route {
network,
peer: *peer,
})
.collect();
recipients.sort_by_key(|route| route.peer);
recipients.dedup();
let domain = Arc::new(Domain {
network,
directed,
enabled: routes.broadcast.enabled,
members: recipients.iter().map(|route| route.peer).collect(),
recipients: recipients.into(),
});
table.sources.insert(local, domain.clone());
table.networks.insert(network, domain);
table.destinations.insert(directed);
}
table
}
pub fn is_destination(&self, destination: Ipv4Addr) -> bool {
destination.is_broadcast() || self.destinations.contains(&destination)
}
pub fn outgoing(&self, source: Ipv4Addr, destination: Ipv4Addr) -> Option<Arc<[Route]>> {
let domain = self.sources.get(&source)?;
(domain.enabled && (destination.is_broadcast() || destination == domain.directed))
.then(|| domain.recipients.clone())
}
pub fn accepts(&self, network: NetworkId, peer: EndpointId, destination: Ipv4Addr) -> bool {
self.networks.get(&network).is_some_and(|domain| {
domain.network == network
&& domain.enabled
&& domain.members.contains(&peer)
&& (destination.is_broadcast() || destination == domain.directed)
})
}
}
/// Validates IPv4/UDP lengths before broadcast fanout. No game-specific ports,
/// payload rewriting or checksum changes. IP fragments retain their bytes and
/// are reassembled by the destination OS; even later fragments have protocol 17.
pub(crate) fn valid_udp(packet: &[u8]) -> bool {
if packet.len() < 20 || packet[0] >> 4 != 4 || packet[9] != 17 {
return false;
}
let header = usize::from(packet[0] & 15) * 4;
let total = usize::from(u16::from_be_bytes([packet[2], packet[3]]));
if header < 20 || total <= header || total > packet.len() {
return false;
}
let fragment = u16::from_be_bytes([packet[6], packet[7]]);
if fragment & 0x1fff != 0 {
return true;
}
if total < header + 8 {
return false;
}
let udp = usize::from(u16::from_be_bytes([packet[header + 4], packet[header + 5]]));
udp >= 8
&& if fragment & 0x2000 != 0 {
udp >= total - header
} else {
udp == total - header
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
fn id(name: &str) -> NetworkId {
NetworkKeys::derive(
&NetworkName::new(name).unwrap(),
&NetworkSecret::from_bytes([4; 32]).unwrap(),
)
.network_id()
}
#[test]
fn limited_and_directed_broadcasts_never_cross_domains_or_target_disabled_peers() {
let peer = iroh::SecretKey::from_bytes(&[8; 32]).public();
let a = NetworkRoutes {
range: Some("10.0.0.0/24".parse().unwrap()),
local: Some("10.0.0.1".parse().unwrap()),
peers: vec![("10.0.0.2".parse().unwrap(), peer)],
broadcast: BroadcastPolicy {
enabled: true,
peers: HashSet::from([peer]),
},
};
let b = NetworkRoutes {
range: Some("10.1.0.0/24".parse().unwrap()),
local: Some("10.1.0.1".parse().unwrap()),
peers: vec![("10.1.0.2".parse().unwrap(), peer)],
broadcast: BroadcastPolicy::default(),
};
let table = BroadcastTable::build(&HashMap::from([(id("a"), a.clone()), (id("b"), b)]));
let routes = table
.outgoing(a.local.unwrap(), Ipv4Addr::BROADCAST)
.unwrap();
assert_eq!(
&*routes,
&[Route {
network: id("a"),
peer
}]
);
assert!(
table
.outgoing(a.local.unwrap(), "10.1.0.255".parse().unwrap())
.is_none()
);
assert!(
table
.outgoing("192.168.1.2".parse().unwrap(), Ipv4Addr::BROADCAST)
.is_none()
);
assert!(
table
.outgoing("10.1.0.1".parse().unwrap(), Ipv4Addr::BROADCAST)
.unwrap()
.is_empty()
);
assert!(!table.accepts(id("a"), peer, "10.1.0.255".parse().unwrap()));
assert!(!table.accepts(id("b"), peer, Ipv4Addr::BROADCAST));
assert!(table.accepts(id("a"), peer, Ipv4Addr::BROADCAST));
let disabled = NetworkRoutes {
broadcast: BroadcastPolicy {
enabled: false,
..a.broadcast.clone()
},
..a.clone()
};
let table = BroadcastTable::build(&HashMap::from([(id("a"), disabled)]));
assert!(
table
.outgoing(a.local.unwrap(), Ipv4Addr::BROADCAST)
.is_none()
);
assert!(!table.accepts(id("a"), peer, Ipv4Addr::BROADCAST));
}
#[test]
fn malformed_udp_is_not_amplified_but_ipv4_fragments_are_supported() {
let mut packet = vec![0u8; 36];
packet[0] = 0x45;
packet[3] = 36;
packet[9] = 17;
packet[25] = 16;
assert!(valid_udp(&packet));
for length in 0..36 {
assert!(!valid_udp(&packet[..length]));
}
packet[9] = 6;
assert!(!valid_udp(&packet));
packet[9] = 17;
packet[0] = 0x44;
assert!(!valid_udp(&packet));
packet[0] = 0x45;
packet[25] = 8;
assert!(!valid_udp(&packet));
packet[6] = 0x20;
packet[25] = 64;
assert!(valid_udp(&packet));
packet[6] = 0;
packet[7] = 2;
assert!(valid_udp(&packet));
}
}
@@ -11,10 +11,11 @@
use std::net::{IpAddr, Ipv6Addr};
use crate::dataplane::PluginError;
use crate::identity::NetworkId;
use crate::overlay::OverlayError;
use super::overlay::OVERLAY_HOST_PREFIX_LEN;
/// Prefix length of a single IPv6 host address.
const HOST_PREFIX_LEN: u8 = 128;
/// Longest interface name Linux accepts, excluding the terminating NUL.
pub const MAX_INTERFACE_NAME_LEN: usize = 15;
@@ -33,13 +34,13 @@ pub struct Cidr {
impl Cidr {
/// Builds a CIDR, rejecting an impossible prefix length.
pub fn new(addr: IpAddr, prefix_len: u8) -> Result<Self, PluginError> {
pub fn new(addr: IpAddr, prefix_len: u8) -> Result<Self, OverlayError> {
let max = match addr {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
};
if prefix_len > max {
return Err(PluginError::Other(format!(
return Err(OverlayError::Other(format!(
"prefix length /{prefix_len} is impossible for {addr}"
)));
}
@@ -50,7 +51,7 @@ impl Cidr {
pub fn host(addr: Ipv6Addr) -> Self {
Self {
addr: IpAddr::V6(addr),
prefix_len: OVERLAY_HOST_PREFIX_LEN,
prefix_len: HOST_PREFIX_LEN,
}
}
}
@@ -66,9 +67,9 @@ impl std::fmt::Display for Cidr {
/// The name is stable across restarts and short enough for the platform. Two
/// agents on the same host in the same network must be given different
/// prefixes, or they would derive the same name.
pub fn interface_name(prefix: &str, network: NetworkId) -> Result<String, PluginError> {
pub fn interface_name(prefix: &str, network: NetworkId) -> Result<String, OverlayError> {
if prefix.is_empty() {
return Err(PluginError::Other(
return Err(OverlayError::Other(
"interface prefix must not be empty".into(),
));
}
@@ -76,12 +77,12 @@ pub fn interface_name(prefix: &str, network: NetworkId) -> Result<String, Plugin
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
{
return Err(PluginError::Other(
return Err(OverlayError::Other(
"interface prefix must be lowercase ASCII letters and digits".into(),
));
}
if prefix.len() >= MAX_INTERFACE_NAME_LEN {
return Err(PluginError::Other(format!(
return Err(OverlayError::Other(format!(
"interface prefix must be shorter than {MAX_INTERFACE_NAME_LEN} characters"
)));
}
@@ -0,0 +1,566 @@
//! Broadcast host rules on Linux: a netlink route and an `iptables` rule.
//!
//! The route is added by the netlink thread the interface provisioner already
//! owns (see [`super::super::provision`]), because that thread is the only one
//! that ever holds `CAP_NET_ADMIN`. It belongs to the interface and goes away
//! with it.
//!
//! The firewall allowance is a rule in the `INPUT` chain, inserted at the top
//! so a default-deny chain further down cannot drop discovery replies first:
//!
//! ```text
//! iptables -I INPUT 1 -i <if> -p udp -s <range> -m comment --comment tsunagi:<if> -j ACCEPT
//! ```
//!
//! `iptables` is a program, not a netlink call, so it is looked up in fixed
//! system directories rather than on `PATH`, and its arguments are built from
//! values this agent computed. It works with both the legacy backend and
//! `iptables-nft`. A host with only `nft` or `firewalld` has no `iptables`;
//! that is reported on the firewall half and the route is still installed.
//!
//! The rule carries a comment tag. That is how a rule left behind by a crashed
//! run is found and replaced, and why nothing else in the chain is ever
//! deleted.
use std::net::Ipv4Addr;
use crate::BoxFuture;
use crate::overlay::provision::RouteHandle;
use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome, tag_for};
const CHAIN: &str = "INPUT";
/// Fixed places `iptables` is installed. Never `PATH`: the agent is
/// privileged and a directory a user controls must not decide what it runs.
const IPTABLES_DIRECTORIES: [&str; 5] = [
"/usr/sbin/iptables",
"/sbin/iptables",
"/usr/bin/iptables",
"/bin/iptables",
"/usr/local/sbin/iptables",
];
/// The first candidate that exists.
fn pick_iptables(exists: impl Fn(&str) -> bool) -> Option<&'static str> {
IPTABLES_DIRECTORIES
.into_iter()
.find(|candidate| exists(candidate))
}
/// What running `iptables` produced.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Ran {
/// There is no `iptables` to run.
Missing,
/// It ran, and exited like this.
Exited {
success: bool,
stdout: String,
stderr: String,
},
}
/// Runs `iptables` with these arguments. A function value so the flow below
/// can be tested without touching the host.
type Run<'a> = &'a dyn Fn(&[String]) -> Ran;
/// The match and target of the rule, without the chain or position.
fn rule_spec(plan: &BroadcastRulesPlan) -> Vec<String> {
[
"-i",
plan.interface.as_str(),
"-p",
"udp",
"-s",
&plan.range.to_string(),
"-m",
"comment",
"--comment",
&plan.tag(),
"-j",
"ACCEPT",
]
.into_iter()
.map(str::to_string)
.collect()
}
/// Waits briefly for the xtables lock instead of failing when another tool
/// holds it.
fn locked(args: impl IntoIterator<Item = String>) -> Vec<String> {
["-w", "5"]
.into_iter()
.map(str::to_string)
.chain(args)
.collect()
}
fn check_args(plan: &BroadcastRulesPlan) -> Vec<String> {
locked(
["-C".to_string(), CHAIN.to_string()]
.into_iter()
.chain(rule_spec(plan)),
)
}
fn insert_args(plan: &BroadcastRulesPlan) -> Vec<String> {
locked(
["-I".to_string(), CHAIN.to_string(), "1".to_string()]
.into_iter()
.chain(rule_spec(plan)),
)
}
fn list_args() -> Vec<String> {
locked(["-S".to_string(), CHAIN.to_string()])
}
/// Splits one `iptables -S` line into words, honouring double quotes, which
/// `iptables` puts around comments that need them.
fn split_rule_line(line: &str) -> Vec<String> {
let mut words = Vec::new();
let mut current = String::new();
let mut quoted = false;
let mut started = false;
for c in line.chars() {
match c {
'"' => {
quoted = !quoted;
started = true;
}
c if c.is_whitespace() && !quoted => {
if started {
words.push(std::mem::take(&mut current));
started = false;
}
}
c => {
current.push(c);
started = true;
}
}
}
if started {
words.push(current);
}
words
}
/// The delete commands for every rule in an `iptables -S INPUT` listing that
/// carries exactly this tag. Anything else in the chain is left alone.
fn tagged_deletions(listing: &str, tag: &str) -> Vec<Vec<String>> {
listing
.lines()
.filter_map(|line| {
let words = split_rule_line(line);
let [append, chain, rest @ ..] = words.as_slice() else {
return None;
};
if append != "-A" || chain != CHAIN {
return None;
}
let tagged = rest
.windows(2)
.any(|pair| pair[0] == "--comment" && pair[1] == tag);
tagged.then(|| {
locked(
["-D".to_string(), CHAIN.to_string()]
.into_iter()
.chain(rest.iter().cloned()),
)
})
})
.collect()
}
/// A failure message with the likely fix for the likely cause.
fn failure(stderr: &str) -> String {
let text = stderr.trim();
let lower = text.to_ascii_lowercase();
if lower.contains("permission denied") || lower.contains("must be root") {
format!("iptables needs root: {text}")
} else if text.is_empty() {
"iptables failed without saying why".to_string()
} else {
format!("iptables failed: {text}")
}
}
fn missing(plan: &BroadcastRulesPlan) -> String {
format!(
"iptables not found; install it (the iptables-nft package is enough) or allow inbound \
UDP from {} on `{}` in your firewall",
plan.range, plan.interface
)
}
/// Makes the firewall hold exactly one rule for this interface, the planned one.
fn apply_firewall(run: Run<'_>, plan: &BroadcastRulesPlan) -> RuleOutcome {
let present = match run(&check_args(plan)) {
Ran::Missing => return RuleOutcome::Failed(missing(plan)),
Ran::Exited { success, .. } => success,
};
let listing = match run(&list_args()) {
Ran::Missing => return RuleOutcome::Failed(missing(plan)),
Ran::Exited {
success: true,
stdout,
..
} => stdout,
Ran::Exited { stderr, .. } => return RuleOutcome::Failed(failure(&stderr)),
};
let stale = tagged_deletions(&listing, &plan.tag());
// `-C` matched an identical rule and nothing else carries our tag, so
// there is nothing to do. Otherwise start from a clean slate for this
// interface: a rule from an earlier run may name another range.
if present && stale.len() == 1 {
return RuleOutcome::Applied;
}
for delete in &stale {
if let Ran::Exited {
success: false,
stderr,
..
} = run(delete)
{
return RuleOutcome::Failed(failure(&stderr));
}
}
match run(&insert_args(plan)) {
Ran::Missing => RuleOutcome::Failed(missing(plan)),
Ran::Exited { success: true, .. } => RuleOutcome::Applied,
Ran::Exited { stderr, .. } => RuleOutcome::Failed(failure(&stderr)),
}
}
/// Removes every rule tagged for this interface. Best effort.
fn clear_firewall(run: Run<'_>, interface: &str) {
let Ran::Exited {
success: true,
stdout,
..
} = run(&list_args())
else {
return;
};
for delete in tagged_deletions(&stdout, &tag_for(interface)) {
if let Ran::Exited {
success: false,
stderr,
..
} = run(&delete)
{
tracing::warn!(
interface,
"cannot remove the broadcast firewall rule: {}",
failure(&stderr)
);
}
}
}
/// Runs the real `iptables`.
fn run_iptables(args: &[String]) -> Ran {
let Some(program) = pick_iptables(|path| std::path::Path::new(path).is_file()) else {
return Ran::Missing;
};
match std::process::Command::new(program).args(args).output() {
Ok(output) => Ran::Exited {
success: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ran::Missing,
Err(err) => Ran::Exited {
success: false,
stdout: String::new(),
stderr: err.to_string(),
},
}
}
/// The Linux implementation: netlink for the route, `iptables` for the rule.
#[derive(Debug)]
pub struct LinuxHostRules {
route: RouteHandle,
}
impl LinuxHostRules {
pub(crate) fn new(route: RouteHandle) -> Self {
Self { route }
}
async fn set_route(&self, interface: String, source: Ipv4Addr) -> RuleOutcome {
let route = self.route.clone();
match tokio::task::spawn_blocking(move || route.set(&interface, source)).await {
Ok(Ok(())) => RuleOutcome::Applied,
Ok(Err(err)) => RuleOutcome::Failed(err.to_string()),
Err(err) => RuleOutcome::Failed(format!("the route task failed: {err}")),
}
}
}
impl BroadcastHostRules for LinuxHostRules {
fn name(&self) -> &str {
"netlink+iptables"
}
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
Box::pin(async move {
let route = self.set_route(plan.interface.clone(), plan.source).await;
let owned = plan.clone();
let firewall =
tokio::task::spawn_blocking(move || apply_firewall(&run_iptables, &owned))
.await
.unwrap_or_else(|err| {
RuleOutcome::Failed(format!("the firewall task failed: {err}"))
});
BroadcastRulesReport {
plan: plan.clone(),
route,
firewall,
}
})
}
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
let route = self.route.clone();
let name = interface.to_string();
let route_name = name.clone();
if let Ok(Err(err)) =
tokio::task::spawn_blocking(move || route.clear(&route_name)).await
{
tracing::warn!(interface = %name, %err, "cannot remove the broadcast route");
}
let name = interface.to_string();
let _ = tokio::task::spawn_blocking(move || clear_firewall(&run_iptables, &name)).await;
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::cell::RefCell;
fn plan() -> BroadcastRulesPlan {
BroadcastRulesPlan {
interface: "tsun0".into(),
source: Ipv4Addr::new(10, 13, 37, 142),
range: "10.13.37.0/24".parse().unwrap(),
}
}
fn words(args: &[String]) -> String {
args.join(" ")
}
#[test]
fn the_rule_is_udp_only_from_the_overlay_range_on_this_interface() {
let spec = words(&rule_spec(&plan()));
assert_eq!(
spec,
"-i tsun0 -p udp -s 10.13.37.0/24 -m comment --comment tsunagi:tsun0 -j ACCEPT"
);
// Nothing here may widen to other protocols or every source.
assert!(spec.contains("-p udp"));
assert!(!spec.contains("0.0.0.0/0"));
assert!(words(&insert_args(&plan())).starts_with("-w 5 -I INPUT 1 -i tsun0"));
assert!(words(&check_args(&plan())).starts_with("-w 5 -C INPUT -i tsun0"));
}
#[test]
fn iptables_is_found_in_fixed_directories_never_on_path() {
assert_eq!(pick_iptables(|_| false), None);
assert_eq!(
pick_iptables(|p| p == "/sbin/iptables"),
Some("/sbin/iptables")
);
assert_eq!(pick_iptables(|_| true), Some("/usr/sbin/iptables"));
}
#[test]
fn only_rules_with_our_exact_tag_are_selected_for_deletion() {
let listing = "\
-P INPUT DROP
-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -m comment --comment \"tsunagi:tsun0\" -j ACCEPT
-A INPUT -i tsun0 -m comment --comment tsunagi:tsun0 -j ACCEPT
-A INPUT -i tsun1 -p udp -m comment --comment tsunagi:tsun1 -j ACCEPT
-A INPUT -p tcp --dport 22 -m comment --comment \"allow ssh tsunagi:tsun0\" -j ACCEPT
-A FORWARD -i tsun0 -m comment --comment tsunagi:tsun0 -j ACCEPT
-A INPUT -p tcp --dport 22 -j ACCEPT
";
let deletions = tagged_deletions(listing, "tsunagi:tsun0");
assert_eq!(deletions.len(), 2, "{deletions:?}");
assert_eq!(
words(&deletions[0]),
"-w 5 -D INPUT -s 10.13.37.0/24 -i tsun0 -p udp -m comment --comment tsunagi:tsun0 -j ACCEPT"
);
assert!(words(&deletions[1]).contains("-D INPUT -i tsun0 -m comment"));
assert!(tagged_deletions("", "tsunagi:tsun0").is_empty());
}
/// A scripted `iptables`: answers by the leading verb, records every call.
struct Fake {
calls: RefCell<Vec<String>>,
check_ok: bool,
listing: String,
insert_stderr: Option<&'static str>,
missing: bool,
}
impl Fake {
fn new(check_ok: bool, listing: &str) -> Self {
Self {
calls: RefCell::new(Vec::new()),
check_ok,
listing: listing.to_string(),
insert_stderr: None,
missing: false,
}
}
fn run(&self, args: &[String]) -> Ran {
self.calls.borrow_mut().push(words(args));
if self.missing {
return Ran::Missing;
}
let ok = |stdout: &str| Ran::Exited {
success: true,
stdout: stdout.to_string(),
stderr: String::new(),
};
match args.get(2).map(String::as_str) {
Some("-C") if self.check_ok => ok(""),
Some("-C") => Ran::Exited {
success: false,
stdout: String::new(),
stderr: "Bad rule".into(),
},
Some("-S") => ok(&self.listing),
Some("-I") => match self.insert_stderr {
Some(stderr) => Ran::Exited {
success: false,
stdout: String::new(),
stderr: stderr.into(),
},
None => ok(""),
},
_ => ok(""),
}
}
fn verbs(&self) -> Vec<String> {
self.calls
.borrow()
.iter()
.map(|call| call.split(' ').nth(2).unwrap().to_string())
.collect()
}
}
const ONE_OURS: &str =
"-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -m comment --comment tsunagi:tsun0 -j ACCEPT\n";
#[test]
fn an_identical_rule_already_in_place_is_left_alone() {
let fake = Fake::new(true, ONE_OURS);
assert_eq!(
apply_firewall(&|a| fake.run(a), &plan()),
RuleOutcome::Applied
);
assert_eq!(fake.verbs(), ["-C", "-S"], "no insert, no delete");
}
#[test]
fn a_missing_rule_is_inserted_once() {
let fake = Fake::new(false, "-P INPUT ACCEPT\n");
assert_eq!(
apply_firewall(&|a| fake.run(a), &plan()),
RuleOutcome::Applied
);
assert_eq!(fake.verbs(), ["-C", "-S", "-I"]);
}
#[test]
fn a_stale_rule_for_another_range_is_replaced_not_stacked() {
// A crashed run left a rule for an old range under our tag.
let stale = "-A INPUT -s 10.99.0.0/16 -i tsun0 -p udp -m comment --comment tsunagi:tsun0 -j ACCEPT\n";
let fake = Fake::new(false, stale);
assert_eq!(
apply_firewall(&|a| fake.run(a), &plan()),
RuleOutcome::Applied
);
assert_eq!(fake.verbs(), ["-C", "-S", "-D", "-I"]);
}
#[test]
fn duplicates_of_the_rule_collapse_to_one() {
let doubled = format!("{ONE_OURS}{ONE_OURS}");
let fake = Fake::new(true, &doubled);
assert_eq!(
apply_firewall(&|a| fake.run(a), &plan()),
RuleOutcome::Applied
);
assert_eq!(fake.verbs(), ["-C", "-S", "-D", "-D", "-I"]);
}
#[test]
fn a_missing_iptables_says_what_to_install_and_what_to_allow() {
let mut fake = Fake::new(false, "");
fake.missing = true;
let RuleOutcome::Failed(reason) = apply_firewall(&|a| fake.run(a), &plan()) else {
panic!("expected a failure");
};
assert!(reason.contains("iptables not found"), "{reason}");
assert!(reason.contains("10.13.37.0/24"), "{reason}");
assert!(reason.contains("tsun0"), "{reason}");
}
#[test]
fn a_refused_insert_is_reported_with_the_cause() {
let mut fake = Fake::new(false, "");
fake.insert_stderr = Some(
"iptables v1.8.10: can't initialize iptables table `filter': Permission denied (you must be root)",
);
let RuleOutcome::Failed(reason) = apply_firewall(&|a| fake.run(a), &plan()) else {
panic!("expected a failure");
};
assert!(reason.starts_with("iptables needs root"), "{reason}");
}
#[test]
fn clearing_removes_only_tagged_rules_and_ignores_a_missing_iptables() {
let listing = format!(
"-A INPUT -p tcp --dport 22 -j ACCEPT\n{ONE_OURS}-A INPUT -i tsun1 -m comment --comment tsunagi:tsun1 -j ACCEPT\n"
);
let fake = Fake::new(true, &listing);
clear_firewall(&|a| fake.run(a), "tsun0");
assert_eq!(fake.verbs(), ["-S", "-D"]);
let mut gone = Fake::new(true, "");
gone.missing = true;
clear_firewall(&|a| gone.run(a), "tsun0");
assert_eq!(gone.verbs(), ["-S"]);
}
#[test]
fn rule_listing_words_honour_quotes() {
assert_eq!(
split_rule_line("-A INPUT -m comment --comment \"a b\" -j ACCEPT"),
[
"-A",
"INPUT",
"-m",
"comment",
"--comment",
"a b",
"-j",
"ACCEPT"
]
);
assert!(split_rule_line(" ").is_empty());
}
}
@@ -0,0 +1,189 @@
//! Broadcast host rules on macOS: a `route` entry; the firewall is deferred.
//!
//! The limited-broadcast route is installed with `/sbin/route`, so a game's
//! LAN discovery packet leaves through the overlay interface. It belongs to
//! the interface and is removed when broadcast is turned off, the network
//! leaves, or the agent stops — and, because a utun is torn down with the
//! process, it also goes away on its own if the agent is killed.
//!
//! The inbound-UDP firewall allowance is **not** implemented here. On macOS
//! that means a `pf` anchor, which is a good deal more machinery; until it is
//! written the firewall half reports itself as missing with what to allow by
//! hand, and the route is still installed. Failure is contained: neither half
//! can stop the agent, and `status` shows exactly which is in place.
//!
//! `route` is a program, not a system call, so it is run by absolute path from
//! `/sbin` rather than off `PATH`, and its arguments are built from values
//! this agent computed — the interface name the kernel assigned, nothing a
//! peer said.
use crate::BoxFuture;
use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome};
/// The limited-broadcast destination routed through the overlay interface.
const LIMITED_BROADCAST: &str = "255.255.255.255";
/// The `route` arguments that direct the limited broadcast out an interface.
fn route_add_args(interface: &str) -> Vec<String> {
vec![
"-q".into(),
"-n".into(),
"add".into(),
"-host".into(),
LIMITED_BROADCAST.into(),
"-interface".into(),
interface.into(),
]
}
/// The `route` arguments that remove it again.
fn route_del_args(interface: &str) -> Vec<String> {
vec![
"-q".into(),
"-n".into(),
"delete".into(),
"-host".into(),
LIMITED_BROADCAST.into(),
"-interface".into(),
interface.into(),
]
}
/// What to do by hand while the `pf` allowance is unimplemented.
fn firewall_todo(plan: &BroadcastRulesPlan) -> String {
format!(
"the inbound-UDP firewall allowance is not implemented on macOS yet; if the host \
firewall drops discovery replies, allow inbound UDP from {} on `{}` with a pf rule \
yourself",
plan.range, plan.interface
)
}
/// The macOS implementation: `route` for the route, no firewall yet.
#[derive(Debug, Default)]
pub struct MacosHostRules;
impl MacosHostRules {
/// Creates the host rules.
pub fn new() -> Self {
Self
}
/// Installs the route, replacing one this agent added earlier so the call
/// can be repeated. The delete is best effort: a route that is not there
/// is the state the add then reaches anyway.
async fn set_route(&self, interface: String) -> RuleOutcome {
let _ = run(route_del_args(&interface)).await;
match run(route_add_args(&interface)).await {
Ok(()) => RuleOutcome::Applied,
Err(reason) => RuleOutcome::Failed(reason),
}
}
}
impl BroadcastHostRules for MacosHostRules {
fn name(&self) -> &str {
"route"
}
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
Box::pin(async move {
let route = self.set_route(plan.interface.clone()).await;
BroadcastRulesReport {
plan: plan.clone(),
route,
firewall: RuleOutcome::Failed(firewall_todo(plan)),
}
})
}
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
if let Err(reason) = run(route_del_args(interface)).await {
tracing::debug!(interface = %interface, %reason, "cannot remove the broadcast route");
}
})
}
}
/// Runs `/sbin/route`, turning a non-zero exit into the message it printed.
async fn run(args: Vec<String>) -> Result<(), String> {
let display = format!("route {}", args.join(" "));
let output = tokio::task::spawn_blocking(move || {
std::process::Command::new("/sbin/route")
.args(&args)
.output()
})
.await
.map_err(|err| format!("could not run {display}: {err}"))?
.map_err(|err| format!("could not run {display}: {err}"))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let text = stderr.trim();
if text.is_empty() {
Err(format!("{display} failed with {}", output.status))
} else {
Err(format!("{display} failed: {text}"))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::Ipv4Addr;
use super::*;
fn plan() -> BroadcastRulesPlan {
BroadcastRulesPlan {
interface: "utun5".into(),
source: Ipv4Addr::new(10, 13, 37, 142),
range: "10.13.37.0/24".parse().unwrap(),
}
}
#[test]
fn the_route_is_the_limited_broadcast_out_the_interface() {
assert_eq!(
route_add_args("utun5"),
vec![
"-q",
"-n",
"add",
"-host",
"255.255.255.255",
"-interface",
"utun5"
]
);
assert_eq!(
route_del_args("utun5"),
vec![
"-q",
"-n",
"delete",
"-host",
"255.255.255.255",
"-interface",
"utun5"
]
);
}
#[test]
fn the_firewall_todo_names_the_range_and_interface() {
let todo = firewall_todo(&plan());
assert!(todo.contains("10.13.37.0/24"), "{todo}");
assert!(todo.contains("utun5"), "{todo}");
assert!(todo.contains("pf"), "{todo}");
// Deferred, so the firewall half is always reported as not in place;
// the route runs for real and so is left to an on-host check. Running
// `route` here would need root and would change the host routing
// table, which the default test suite must never do.
}
}
+330
View File
@@ -0,0 +1,330 @@
//! Host rules that let a game's LAN discovery reach the overlay interface.
//!
//! Broadcast fanout happens at local TUN ingress (see [`super::broadcast`]),
//! so a limited broadcast only reaches Tsunagi if the operating system routes
//! it through the overlay interface, and a discovery *reply* only reaches the
//! game if the host firewall lets it in. Left to the user that is two manual
//! steps per host that nobody remembers, so when a network's broadcast is on
//! the agent installs exactly these two things for the interface it owns:
//!
//! * a `255.255.255.255/32` route through the overlay interface, with the
//! overlay address as its preferred source, so the game's discovery packet
//! leaves with a source address the router recognises;
//! * an inbound firewall allowance for **UDP only**, from the overlay range
//! only, on that interface only. Replies to a broadcast come from a
//! different address than the one the request was sent to, so a stateful
//! firewall never matches them to the request. Opening every protocol
//! would expose services such as SSH to every member of the network, which
//! is not what a game needs.
//!
//! # Why this is allowed here
//!
//! The architecture says plugins never touch routing or firewall settings,
//! and nothing a peer announces becomes an OS setting. This is neither: it is
//! the system level, which owns the interface, configuring that interface from
//! values it computed itself (the interface name is derived locally, the
//! address and range come from signed state it already verified). Every object
//! is tagged `tsunagi:<interface>`, reversible, and removed when broadcast is
//! turned off, the network leaves, or the agent stops. A crash can leave a
//! firewall rule behind; the next apply for the same interface removes it.
//!
//! # Failure is contained
//!
//! Neither step can stop the agent. Each reports its own outcome, so a missing
//! firewall tool still leaves the route in place and says what is missing.
//!
//! # Why a trait
//!
//! Linux, Windows and the tests do this very differently, while *what* to
//! install is the same everywhere and lives in [`BroadcastRulesPlan`] and
//! [`rules_summary`], tested on every platform.
use std::net::Ipv4Addr;
use std::sync::{Arc, Mutex};
use crate::BoxFuture;
use crate::state::Ipv4Range;
#[cfg(all(feature = "tun-device", target_os = "linux"))]
mod linux;
#[cfg(all(feature = "tun-device", target_os = "linux"))]
pub use linux::LinuxHostRules;
// Compiled everywhere so the scripts it builds are tested everywhere; only
// the type that runs them is exported on Windows.
mod windows;
#[cfg(all(feature = "tun-device", target_os = "windows"))]
pub use windows::WindowsHostRules;
#[cfg(all(feature = "tun-device", target_os = "macos"))]
mod macos;
#[cfg(all(feature = "tun-device", target_os = "macos"))]
pub use macos::MacosHostRules;
/// What the host should be told for one interface.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BroadcastRulesPlan {
/// The overlay interface. Derived locally from the network id, never from
/// anything a peer said.
pub interface: String,
/// This agent's overlay address, the route's preferred source.
pub source: Ipv4Addr,
/// The overlay range inbound UDP is admitted from.
pub range: Ipv4Range,
}
impl BroadcastRulesPlan {
/// The tag every object carries, so it can be found again and nothing
/// else is ever touched.
pub fn tag(&self) -> String {
tag_for(&self.interface)
}
}
/// The tag for an interface's objects.
pub fn tag_for(interface: &str) -> String {
format!("tsunagi:{interface}")
}
/// What happened to one of the two host objects.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuleOutcome {
/// It is in place.
Applied,
/// It is not, with a reason a person can act on.
Failed(String),
}
impl RuleOutcome {
/// Whether the object is in place.
pub fn is_applied(&self) -> bool {
matches!(self, Self::Applied)
}
}
/// What applying a plan achieved.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BroadcastRulesReport {
/// What was asked for.
pub plan: BroadcastRulesPlan,
/// The `255.255.255.255` route.
pub route: RuleOutcome,
/// The inbound firewall allowance.
pub firewall: RuleOutcome,
}
impl BroadcastRulesReport {
/// Whether both objects are in place.
pub fn is_ok(&self) -> bool {
self.route.is_applied() && self.firewall.is_applied()
}
/// One line for `status`.
pub fn summary(&self) -> String {
rules_summary(&self.route, &self.firewall)
}
}
/// The status line for a pair of outcomes.
pub fn rules_summary(route: &RuleOutcome, firewall: &RuleOutcome) -> String {
match (route, firewall) {
(RuleOutcome::Applied, RuleOutcome::Applied) => "ok (route + firewall)".to_string(),
(route, firewall) => {
let part = |what: &str, outcome: &RuleOutcome| match outcome {
RuleOutcome::Applied => format!("{what} ok"),
RuleOutcome::Failed(reason) => format!("{what} failed: {reason}"),
};
format!(
"incomplete; {}; {}",
part("route", route),
part("firewall", firewall)
)
}
}
}
/// Installs and removes the host rules for an overlay interface.
///
/// Both calls are idempotent. Neither can fail as a whole: a step that cannot
/// be done is reported in the [`BroadcastRulesReport`], because the caller's
/// only useful response is to say so.
pub trait BroadcastHostRules: Send + Sync + std::fmt::Debug + 'static {
/// A short name used in diagnostics.
fn name(&self) -> &str;
/// Makes the host match the plan, replacing whatever this agent put there
/// for the same interface before.
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport>;
/// Removes what this agent put there for the interface. Succeeds when
/// nothing is there: it runs on the shutdown path.
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>;
}
/// The platform has no implementation, which is said rather than pretended.
#[derive(Debug, Default)]
pub struct UnsupportedHostRules;
impl BroadcastHostRules for UnsupportedHostRules {
fn name(&self) -> &str {
"unsupported"
}
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
Box::pin(async move {
let reason = "host rules are not implemented on this platform; route \
255.255.255.255 through the overlay interface and allow inbound \
UDP from the overlay range yourself"
.to_string();
BroadcastRulesReport {
plan: plan.clone(),
route: RuleOutcome::Failed(reason.clone()),
firewall: RuleOutcome::Failed(reason),
}
})
}
fn clear<'a>(&'a self, _interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {})
}
}
/// What a [`MockHostRules`] currently holds, per interface.
pub type MockRulesState = std::collections::BTreeMap<String, BroadcastRulesPlan>;
/// An in-memory host, for tests: nothing is touched.
#[derive(Debug, Clone, Default)]
pub struct MockHostRules {
state: Arc<Mutex<MockRulesState>>,
calls: Arc<Mutex<Vec<String>>>,
firewall_failure: Arc<Mutex<Option<String>>>,
}
impl MockHostRules {
/// An empty host.
pub fn new() -> Self {
Self::default()
}
/// Makes the firewall step fail with this reason from now on.
pub fn fail_firewall(&self, reason: impl Into<String>) {
*lock(&self.firewall_failure) = Some(reason.into());
}
/// What is installed now.
pub fn installed(&self) -> MockRulesState {
lock(&self.state).clone()
}
/// Every `apply:<interface>` / `clear:<interface>` call, in order.
pub fn calls(&self) -> Vec<String> {
lock(&self.calls).clone()
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
impl BroadcastHostRules for MockHostRules {
fn name(&self) -> &str {
"mock"
}
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
Box::pin(async move {
lock(&self.calls).push(format!("apply:{}", plan.interface));
lock(&self.state).insert(plan.interface.clone(), plan.clone());
let firewall = match lock(&self.firewall_failure).clone() {
Some(reason) => RuleOutcome::Failed(reason),
None => RuleOutcome::Applied,
};
BroadcastRulesReport {
plan: plan.clone(),
route: RuleOutcome::Applied,
firewall,
}
})
}
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
lock(&self.calls).push(format!("clear:{interface}"));
lock(&self.state).remove(interface);
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn plan() -> BroadcastRulesPlan {
BroadcastRulesPlan {
interface: "tsun0".into(),
source: Ipv4Addr::new(10, 13, 37, 142),
range: "10.13.37.0/24".parse().unwrap(),
}
}
#[test]
fn every_object_is_tagged_with_its_interface() {
assert_eq!(plan().tag(), "tsunagi:tsun0");
assert_eq!(tag_for("tsun7"), "tsunagi:tsun7");
}
#[test]
fn the_summary_says_which_half_is_missing() {
assert_eq!(
rules_summary(&RuleOutcome::Applied, &RuleOutcome::Applied),
"ok (route + firewall)"
);
let line = rules_summary(
&RuleOutcome::Applied,
&RuleOutcome::Failed("iptables not found".into()),
);
assert!(line.contains("route ok"), "{line}");
assert!(
line.contains("firewall failed: iptables not found"),
"{line}"
);
let report = BroadcastRulesReport {
plan: plan(),
route: RuleOutcome::Failed("denied".into()),
firewall: RuleOutcome::Applied,
};
assert!(!report.is_ok());
assert!(report.summary().contains("route failed: denied"));
}
#[tokio::test]
async fn the_mock_applies_replaces_and_clears_idempotently() {
let rules = MockHostRules::new();
let first = rules.apply(&plan()).await;
assert!(first.is_ok());
assert_eq!(rules.installed().len(), 1);
let mut moved = plan();
moved.source = Ipv4Addr::new(10, 13, 37, 7);
rules.apply(&moved).await;
assert_eq!(rules.installed()["tsun0"].source, moved.source);
rules.clear("tsun0").await;
rules.clear("tsun0").await;
assert!(rules.installed().is_empty());
assert_eq!(rules.calls().len(), 4);
}
#[tokio::test]
async fn an_unsupported_platform_reports_both_steps_failed() {
let report = UnsupportedHostRules.apply(&plan()).await;
assert!(!report.route.is_applied());
assert!(!report.firewall.is_applied());
UnsupportedHostRules.clear("tsun0").await;
}
}
@@ -0,0 +1,272 @@
//! Broadcast host rules on Windows: a route and a Defender Firewall rule.
//!
//! Both are written with the `NetTCPIP` and `NetSecurity` PowerShell modules,
//! run by absolute path from `%SystemRoot%` like the DNS publisher does: the
//! in-process alternatives are `unsafe` IP Helper calls this crate forbids.
//!
//! * The route is `255.255.255.255/32` on the overlay interface, on-link, with
//! metric 1, written to the active store only so it cannot outlive a reboot.
//! Windows picks the interface's own address as the source of a packet sent
//! along it; with several networks on one interface that is whichever
//! address Windows treats as primary, which this cannot choose.
//! * The firewall rule allows inbound UDP on that interface alias from the
//! overlay range only, in every profile. Wintun adapters are usually
//! classified as a public network, where inbound UDP is otherwise dropped.
//! Its display name is the tag `tsunagi:<interface>`, which is how it is
//! found again, replaced and removed.
//!
//! Every value in a script is one this agent produced: the interface name is
//! derived from the network id and the range comes from signed state. They are
//! still single-quoted and escaped, so nothing could be read as PowerShell
//! rather than as data. Changing routes and firewall rules needs an elevated
//! process, which is reported as its own cause.
//!
//! This module is compiled everywhere so its script building is tested
//! everywhere. It could not be run against a real Windows host while it was
//! written.
#![cfg_attr(not(target_os = "windows"), allow(dead_code))]
use crate::BoxFuture;
use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome, tag_for};
/// A PowerShell single-quoted literal, with any embedded quote doubled.
fn ps_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
/// The step that makes the route match the plan.
///
/// A leftover route (from a crashed run, or for an old interface index) is
/// removed first so the result is exactly one route.
fn route_script(plan: &BroadcastRulesPlan) -> String {
let alias = ps_literal(&plan.interface);
format!(
"$ErrorActionPreference = 'Stop'\n\
$alias = {alias}\n\
Get-NetRoute -DestinationPrefix '255.255.255.255/32' -InterfaceAlias $alias \
-PolicyStore ActiveStore -ErrorAction SilentlyContinue | \
Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
New-NetRoute -DestinationPrefix '255.255.255.255/32' -InterfaceAlias $alias \
-NextHop '0.0.0.0' -RouteMetric 1 -PolicyStore ActiveStore | Out-Null\n"
)
}
/// The step that makes the firewall hold exactly one rule for the interface.
fn firewall_script(plan: &BroadcastRulesPlan) -> String {
let alias = ps_literal(&plan.interface);
let tag = ps_literal(&plan.tag());
let range = ps_literal(&plan.range.to_string());
format!(
"$ErrorActionPreference = 'Stop'\n\
$alias = {alias}\n\
$tag = {tag}\n\
Get-NetFirewallRule -DisplayName $tag -ErrorAction SilentlyContinue | \
Remove-NetFirewallRule -ErrorAction SilentlyContinue\n\
New-NetFirewallRule -DisplayName $tag -Direction Inbound -Action Allow -Protocol UDP \
-InterfaceAlias $alias -RemoteAddress {range} -Profile Any | Out-Null\n"
)
}
/// The script that removes both, ignoring what is not there.
fn clear_script(interface: &str) -> String {
let alias = ps_literal(interface);
let tag = ps_literal(&tag_for(interface));
format!(
"$ErrorActionPreference = 'SilentlyContinue'\n\
Get-NetRoute -DestinationPrefix '255.255.255.255/32' -InterfaceAlias {alias} \
-PolicyStore ActiveStore | Remove-NetRoute -Confirm:$false\n\
Get-NetFirewallRule -DisplayName {tag} | Remove-NetFirewallRule\n\
exit 0\n"
)
}
/// Turns what PowerShell said into a cause a person can act on.
fn classify(what: &str, text: &str) -> String {
let lower = text.to_ascii_lowercase();
if lower.contains("access is denied")
|| lower.contains("requires elevation")
|| lower.contains("run as administrator")
|| lower.contains("permissiondenied")
{
format!("{what}: Windows refused; run tsunagi from an elevated (administrator) process")
} else if lower.contains("is not recognized") || lower.contains("commandnotfound") {
format!("{what}: the NetTCPIP/NetSecurity PowerShell modules are not available: {text}")
} else if text.is_empty() {
format!("{what}: failed without saying why")
} else {
format!("{what}: {text}")
}
}
/// The absolute path to Windows PowerShell, so nothing on `PATH` can stand in
/// for it.
fn powershell_path() -> std::path::PathBuf {
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into());
std::path::Path::new(&root)
.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe")
}
/// Runs a script and returns what it said on failure.
fn run_powershell(script: &str) -> Result<(), String> {
let output = std::process::Command::new(powershell_path())
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
])
.output()
.map_err(|err| format!("could not run PowerShell: {err}"))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let text = if stderr.trim().is_empty() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
stderr.trim().to_string()
};
Err(text)
}
async fn step(what: &'static str, script: String) -> RuleOutcome {
match tokio::task::spawn_blocking(move || run_powershell(&script)).await {
Ok(Ok(())) => RuleOutcome::Applied,
Ok(Err(text)) => RuleOutcome::Failed(classify(what, &text)),
Err(err) => RuleOutcome::Failed(format!("{what}: the task failed: {err}")),
}
}
/// The Windows implementation, through PowerShell.
#[derive(Debug, Default)]
pub struct WindowsHostRules;
impl WindowsHostRules {
/// Creates it. Nothing is run until [`BroadcastHostRules::apply`].
pub fn new() -> Self {
Self
}
}
impl BroadcastHostRules for WindowsHostRules {
fn name(&self) -> &str {
"windows-netsecurity"
}
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
Box::pin(async move {
let route = step("route", route_script(plan)).await;
let firewall = step("firewall", firewall_script(plan)).await;
BroadcastRulesReport {
plan: plan.clone(),
route,
firewall,
}
})
}
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
if let RuleOutcome::Failed(reason) = step("clear", clear_script(interface)).await {
tracing::warn!(
interface,
"cannot remove the broadcast host rules: {reason}"
);
}
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::net::Ipv4Addr;
fn plan() -> BroadcastRulesPlan {
BroadcastRulesPlan {
interface: "tsun0".into(),
source: Ipv4Addr::new(10, 13, 37, 142),
range: "10.13.37.0/24".parse().unwrap(),
}
}
#[test]
fn a_quote_in_a_value_cannot_escape_the_literal() {
assert_eq!(ps_literal("a'b"), "'a''b'");
let mut hostile = plan();
hostile.interface = "x'; Remove-Item C:\\ #".into();
let script = route_script(&hostile);
assert!(
script.contains("$alias = 'x''; Remove-Item C:\\ #'"),
"{script}"
);
assert!(firewall_script(&hostile).contains("'x''; Remove-Item C:\\ #'"));
}
#[test]
fn the_route_is_on_link_metric_one_in_the_active_store_and_replaces_its_own() {
let script = route_script(&plan());
assert!(script.contains("'255.255.255.255/32'"));
assert!(script.contains("-NextHop '0.0.0.0'"));
assert!(script.contains("-RouteMetric 1"));
assert!(script.contains("-PolicyStore ActiveStore"));
assert!(
script.find("Remove-NetRoute").unwrap() < script.find("New-NetRoute").unwrap(),
"a stale route goes before the new one:\n{script}"
);
}
#[test]
fn the_firewall_rule_is_udp_only_from_the_overlay_range_on_this_interface() {
let script = firewall_script(&plan());
assert!(script.contains("-Protocol UDP"));
assert!(script.contains("-Direction Inbound -Action Allow"));
assert!(script.contains("-InterfaceAlias $alias"));
assert!(script.contains("-RemoteAddress '10.13.37.0/24'"));
assert!(script.contains("$tag = 'tsunagi:tsun0'"));
assert!(!script.contains("-Protocol Any") && !script.contains("-Protocol TCP"));
assert!(
script.find("Remove-NetFirewallRule").unwrap()
< script.find("New-NetFirewallRule").unwrap(),
"{script}"
);
}
#[test]
fn clearing_touches_only_our_route_and_our_tagged_rule() {
let script = clear_script("tsun0");
assert!(script.contains("-InterfaceAlias 'tsun0'"));
assert!(script.contains("Get-NetFirewallRule -DisplayName 'tsunagi:tsun0'"));
assert!(script.contains("exit 0"));
}
#[test]
fn a_refusal_names_elevation_and_a_missing_module_names_the_module() {
assert!(classify("route", "Access is denied").contains("elevated"));
assert!(
classify(
"firewall",
"The term 'New-NetFirewallRule' is not recognized"
)
.contains("modules are not available")
);
assert_eq!(classify("route", "boom"), "route: boom");
assert!(classify("route", "").contains("without saying why"));
}
#[test]
fn powershell_is_found_by_absolute_path() {
let path = powershell_path();
assert!(path.ends_with("powershell.exe"));
assert!(path.to_string_lossy().contains("System32"), "{path:?}");
}
}
+981
View File
@@ -0,0 +1,981 @@
//! The one interface an agent owns, and the loop that moves packets across it.
//!
//! Outbound, a packet read from the interface is looked up in the
//! [`RoutingTable`] and handed to whatever can carry it to that peer.
//! Inbound, a packet a protocol has decrypted is checked against the same
//! table and written to the interface.
//!
//! Neither direction knows which protocol is involved, and that is the
//! point: the interface, the addresses on it and the decision of whose
//! packet this is all belong here, so several protocols can be carrying
//! traffic at once without any of them owning the thing they are carrying it
//! for.
//!
//! # What is counted rather than hidden
//!
//! A packet with nowhere to go, a packet from a peer that does not hold its
//! source address, a multicast packet the operating system emitted anyway:
//! each is dropped and counted, with a sample kept for the first kind,
//! because "the overlay is quiet" and "the overlay is dropping everything"
//! look identical without them.
use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use bytes::Bytes;
use iroh::EndpointId;
use tokio::task::JoinHandle;
use crate::identity::NetworkId;
use super::hostrules::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport};
use super::packet::IpHeader;
use super::router::{Route, RoutingTable};
use super::tun::{TunDevice, TunFactory, TunRequest};
use super::{Cidr, OverlayError};
/// Carries one packet to a peer, by whatever protocol currently can.
///
/// Implemented by the layer that knows which protocols are live for which
/// peers. The interface does not: it asks for a packet to reach a peer and
/// is told whether that was possible.
pub trait PacketCarrier: Send + Sync + 'static {
/// Carries a packet, answering `false` if nothing can right now.
fn carry(&self, route: Route, packet: Bytes) -> bool;
}
/// Why a decrypted packet was not written to the interface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rejected {
/// It was not a packet this agent could read.
Malformed,
/// The overlay is IPv4; anything else has no owner here.
WrongFamily,
/// The sending peer does not hold the source address it used.
WrongSource,
/// Broadcast disabled, outside this domain, or malformed/non-UDP.
BroadcastDenied,
/// The packet terminates outside this host's network address/domain.
WrongDestination,
}
/// Counters for one interface.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Counters {
/// Packets handed to a protocol.
pub sent: u64,
/// Broadcast copies handed to recipient tunnels.
pub broadcast_sent: u64,
/// Broadcast packets admitted to this host.
pub broadcast_received: u64,
/// Broadcasts refused by domain policy or packet validation.
pub broadcast_dropped: u64,
/// Packets written to the operating system.
pub received: u64,
/// Packets for an address nobody in any network holds.
pub unroutable: u64,
/// One such destination, so the number can be acted on.
pub unroutable_sample: Option<IpAddr>,
/// Packets nothing could carry, though their destination was known.
pub undeliverable: u64,
/// Unsupported multicast or unspecified-destination packets.
pub multicast: u64,
/// Packets from a peer that does not hold the source address used.
pub wrong_source: u64,
/// Decrypted packets addressed outside the receiving network's local host.
pub wrong_destination: u64,
/// Packets that could not be read at all.
pub malformed: u64,
}
#[derive(Debug, Default)]
struct Tally {
sent: AtomicU64,
broadcast_sent: AtomicU64,
broadcast_received: AtomicU64,
broadcast_dropped: AtomicU64,
received: AtomicU64,
unroutable: AtomicU64,
undeliverable: AtomicU64,
multicast: AtomicU64,
wrong_source: AtomicU64,
wrong_destination: AtomicU64,
malformed: AtomicU64,
sample: std::sync::Mutex<Option<IpAddr>>,
}
impl Tally {
fn snapshot(&self) -> Counters {
Counters {
sent: self.sent.load(Ordering::Relaxed),
broadcast_sent: self.broadcast_sent.load(Ordering::Relaxed),
broadcast_received: self.broadcast_received.load(Ordering::Relaxed),
broadcast_dropped: self.broadcast_dropped.load(Ordering::Relaxed),
received: self.received.load(Ordering::Relaxed),
unroutable: self.unroutable.load(Ordering::Relaxed),
unroutable_sample: match self.sample.lock() {
Ok(guard) => *guard,
Err(poisoned) => *poisoned.into_inner(),
},
undeliverable: self.undeliverable.load(Ordering::Relaxed),
multicast: self.multicast.load(Ordering::Relaxed),
wrong_source: self.wrong_source.load(Ordering::Relaxed),
wrong_destination: self.wrong_destination.load(Ordering::Relaxed),
malformed: self.malformed.load(Ordering::Relaxed),
}
}
fn note_unroutable(&self, destination: IpAddr) {
self.unroutable.fetch_add(1, Ordering::Relaxed);
// The first one is kept. A later sample would keep overwriting the
// one a reader is looking at.
let mut guard = match self.sample.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if guard.is_none() {
*guard = Some(destination);
}
}
}
/// The overlay interface.
#[derive(Debug)]
pub struct Interface {
device: Arc<dyn TunDevice>,
factory: Arc<dyn TunFactory>,
name: String,
mtu: u32,
routes: Arc<RoutingTable>,
tally: Arc<Tally>,
/// What was last applied to the host, so an unchanged table is not
/// re-applied on every pass.
applied: std::sync::Mutex<Vec<Cidr>>,
task: std::sync::Mutex<Option<JoinHandle<()>>>,
sync_lock: Arc<tokio::sync::Mutex<()>>,
/// The host rules for this interface, when the factory manages the host.
host_rules: Option<Arc<dyn BroadcastHostRules>>,
/// What those rules last reported, `None` while none are installed.
rules_report: std::sync::Mutex<Option<BroadcastRulesReport>>,
}
impl Interface {
/// Creates the interface and starts moving packets.
pub async fn start(
factory: Arc<dyn TunFactory>,
name: impl Into<String>,
mtu: u32,
routes: Arc<RoutingTable>,
carrier: Arc<dyn PacketCarrier>,
) -> Result<Self, OverlayError> {
let name = name.into();
let host_rules = factory.host_rules();
let device = factory.create(TunRequest::bare(name.clone(), mtu)).await?;
// The name the operating system actually gave the interface. On Linux
// and Windows that is the one we asked for; on macOS the kernel assigns
// the utun unit, so the real name differs and everything that
// configures the host for this interface afterwards — the broadcast
// route and firewall, the reconfigure on an address change — has to use
// the real one, not the requested `tsun…`.
let name = device.name().to_string();
let tally = Arc::new(Tally::default());
let task = {
let device = Arc::clone(&device);
let routes = Arc::clone(&routes);
let tally = Arc::clone(&tally);
tokio::spawn(async move {
while let Some(packet) = device.recv().await {
let Some(header) = IpHeader::parse(&packet) else {
tally.malformed.fetch_add(1, Ordering::Relaxed);
continue;
};
let destination = header.destination();
if let (IpAddr::V4(source), IpAddr::V4(destination)) =
(header.source(), destination)
&& routes.is_broadcast(destination)
{
let recipients = super::broadcast::valid_udp(&packet)
.then(|| routes.broadcast_recipients(source, destination))
.flatten();
if let Some(recipients) = recipients {
for &route in recipients.iter() {
if carrier.carry(route, packet.clone()) {
tally.sent.fetch_add(1, Ordering::Relaxed);
tally.broadcast_sent.fetch_add(1, Ordering::Relaxed);
} else {
tally.undeliverable.fetch_add(1, Ordering::Relaxed);
}
}
} else {
tally.broadcast_dropped.fetch_add(1, Ordering::Relaxed);
}
continue;
}
if is_multicast_or_broadcast(destination) {
// The operating system emits these on any interface.
// The overlay is a set of point-to-point tunnels and
// has nowhere to put them; expected, not a fault.
tally.multicast.fetch_add(1, Ordering::Relaxed);
continue;
}
let Some(route) = routes.route_v4(destination) else {
tally.note_unroutable(destination);
continue;
};
if carrier.carry(route, packet) {
tally.sent.fetch_add(1, Ordering::Relaxed);
} else {
tally.undeliverable.fetch_add(1, Ordering::Relaxed);
}
}
})
};
Ok(Self {
device,
factory,
name,
mtu,
routes,
tally,
applied: std::sync::Mutex::new(Vec::new()),
task: std::sync::Mutex::new(Some(task)),
sync_lock: Arc::new(tokio::sync::Mutex::new(())),
host_rules,
rules_report: std::sync::Mutex::new(None),
})
}
/// Brings the broadcast route and firewall allowance in line with the
/// routing table: installed while some network has broadcast on and an
/// address, removed when none does.
///
/// Called after the addresses are synced, because the route's preferred
/// source has to be on the interface already. A plan that has not changed
/// is not re-applied, so this is cheap to call whenever the table moves.
/// It never fails: the outcome is kept for [`Self::broadcast_rules`].
pub async fn sync_broadcast_rules(&self) {
let Some(rules) = &self.host_rules else {
return;
};
let _guard = self.sync_lock.lock().await;
let desired =
self.routes
.broadcast_host_target()
.map(|(source, range)| BroadcastRulesPlan {
interface: self.name.clone(),
source,
range,
});
let current = self.current_rules_report();
match (desired, current) {
(None, None) => {}
(None, Some(_)) => {
rules.clear(&self.name).await;
self.store_rules_report(None);
tracing::info!(interface = %self.name, "broadcast host rules removed");
}
(Some(plan), Some(report)) if report.plan == plan => {}
(Some(plan), _) => {
let report = rules.apply(&plan).await;
if report.is_ok() {
tracing::info!(
interface = %self.name,
source = %plan.source,
"broadcast host rules applied"
);
} else {
tracing::warn!(
interface = %self.name,
"broadcast host rules are incomplete: {}",
report.summary()
);
}
self.store_rules_report(Some(report));
}
}
}
/// What the broadcast host rules achieved, `None` when none are installed
/// or this interface has no host to configure.
pub fn broadcast_rules(&self) -> Option<BroadcastRulesReport> {
self.current_rules_report()
}
fn current_rules_report(&self) -> Option<BroadcastRulesReport> {
match self.rules_report.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
fn store_rules_report(&self, report: Option<BroadcastRulesReport>) {
match self.rules_report.lock() {
Ok(mut guard) => *guard = report,
Err(poisoned) => *poisoned.into_inner() = report,
}
}
/// Brings the addresses on the host in line with the routing table.
///
/// Called whenever a network agrees a different address for this agent.
/// The interface is never recreated for it: that would drop every tunnel
/// riding on it for the sake of one address.
pub async fn sync_addresses(&self) -> Result<(), OverlayError> {
let _guard = self.sync_lock.lock().await;
let wanted = self.wanted_addresses();
{
let applied = match self.applied.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
};
if applied == wanted {
return Ok(());
}
}
// Every address this agent holds, in every network it is in. The
// plan is exhaustive by contract, so this is also what takes an
// address off the interface when a network is left or stopped.
let request = TunRequest {
name: self.name.clone(),
addresses: wanted.clone(),
mtu: self.mtu,
};
self.factory.reconfigure(request).await?;
match self.applied.lock() {
Ok(mut guard) => *guard = wanted,
Err(poisoned) => *poisoned.into_inner() = wanted,
}
Ok(())
}
/// The interface name the operating system gave.
pub fn name(&self) -> &str {
self.device.name()
}
/// The interface MTU.
pub fn mtu(&self) -> u32 {
self.device.mtu()
}
/// Whether this interface exists on the host.
///
/// `false` with an in-memory device, where tunnels run and packets move
/// but the operating system knows nothing about any of it. Anything that
/// would configure the host — a route, a resolver setting, a complaint
/// that an address is missing from a link — has to ask first.
pub fn on_host(&self) -> bool {
self.factory.on_host()
}
/// Removes the interface from the host.
pub async fn remove(&self) {
// The route goes with the interface; the firewall rule does not, so
// it is taken out explicitly while the interface is still known.
if let Some(rules) = &self.host_rules {
rules.clear(&self.name).await;
self.store_rules_report(None);
}
// The packet loop holds the device open, and it ends only when the
// device reports end of stream — which a real interface never does
// while it exists. Stop it directly before destroying the interface.
let task = match self.task.lock() {
Ok(mut guard) => guard.take(),
Err(poisoned) => poisoned.into_inner().take(),
};
if let Some(task) = task {
task.abort();
let _ = task.await;
}
self.factory.destroy(self.device.name()).await;
}
/// The counters as they stand.
pub fn counters(&self) -> Counters {
self.tally.snapshot()
}
/// Writes a packet a protocol decrypted to the operating system.
///
/// The source is checked against what the network agreed that peer
/// holds. A protocol proved *who* sent the packet; only this level knows
/// what that member is entitled to say.
pub async fn deliver(
&self,
network: NetworkId,
peer: EndpointId,
packet: Bytes,
) -> Result<(), Rejected> {
let Some(header) = IpHeader::parse(&packet) else {
self.tally.malformed.fetch_add(1, Ordering::Relaxed);
return Err(Rejected::Malformed);
};
let IpAddr::V4(source) = header.source() else {
self.tally.wrong_source.fetch_add(1, Ordering::Relaxed);
return Err(Rejected::WrongFamily);
};
if !self.routes.may_send_from(network, peer, source) {
self.tally.wrong_source.fetch_add(1, Ordering::Relaxed);
return Err(Rejected::WrongSource);
}
let broadcast = match header.destination() {
IpAddr::V4(destination) if self.routes.is_broadcast(destination) => {
if !super::broadcast::valid_udp(&packet)
|| !self.routes.accepts_broadcast(network, peer, destination)
{
self.tally.broadcast_dropped.fetch_add(1, Ordering::Relaxed);
return Err(Rejected::BroadcastDenied);
}
true
}
IpAddr::V4(destination) if self.routes.is_local_destination(network, destination) => {
false
}
_ => {
self.tally.wrong_destination.fetch_add(1, Ordering::Relaxed);
return Err(Rejected::WrongDestination);
}
};
// Remote broadcasts terminate here. Only local TUN ingress can fan out.
if self.device.send(packet).await.is_ok() {
if broadcast {
self.tally
.broadcast_received
.fetch_add(1, Ordering::Relaxed);
}
self.tally.received.fetch_add(1, Ordering::Relaxed);
}
Ok(())
}
/// The addresses the interface should be carrying, from the table.
pub fn wanted_addresses(&self) -> Vec<Cidr> {
self.routes
.local_addresses()
.into_iter()
.filter_map(|(address, prefix_len)| Cidr::new(address.into(), prefix_len).ok())
.collect()
}
}
impl Drop for Interface {
fn drop(&mut self) {
if let Ok(mut guard) = self.task.lock()
&& let Some(task) = guard.take()
{
task.abort();
}
}
}
impl RoutingTable {
/// The route for a destination, when it is one the overlay carries.
fn route_v4(&self, destination: IpAddr) -> Option<Route> {
match destination {
IpAddr::V4(address) => self.route(address),
// The overlay is IPv4. An IPv6 packet has no owner here, and
// counting it as unroutable is the truth rather than a fault.
IpAddr::V6(_) => None,
}
}
}
/// Whether an address is one the overlay has nowhere to send.
fn is_multicast_or_broadcast(address: IpAddr) -> bool {
match address {
IpAddr::V4(address) => {
address.is_multicast() || address.is_broadcast() || address == Ipv4Addr::UNSPECIFIED
}
IpAddr::V6(address) => address.is_multicast(),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[tokio::test]
async fn an_in_memory_interface_says_it_is_not_on_the_host() {
// Everything that would configure the operating system for this
// interface asks first, and the answer here is no: the name is real
// to the agent and to nothing else. A real `tsun0` belonging to
// another agent looks identical from here, so acting on the name
// alone would configure that one.
let interface = Interface::start(
Arc::new(crate::overlay::MemoryTunFactory::new()),
"tsun0",
1280,
Arc::new(RoutingTable::new()),
Arc::new(Recorder::default()),
)
.await
.unwrap();
assert_eq!(interface.name(), "tsun0");
assert!(!interface.on_host());
interface.remove().await;
}
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
use crate::overlay::broadcast::BroadcastPolicy;
use crate::overlay::hostrules::{MockHostRules, RuleOutcome};
use crate::overlay::provision::{ManagedTunFactory, MockProvisioner};
use crate::overlay::router::NetworkRoutes;
use crate::overlay::tun::{MemoryTun, MemoryTunFactory};
fn network(name: &str) -> NetworkId {
NetworkKeys::derive(
&NetworkName::new(name).unwrap(),
&NetworkSecret::from_bytes(vec![7u8; 32]).unwrap(),
)
.network_id()
}
fn peer(seed: u8) -> EndpointId {
iroh::SecretKey::from_bytes(&[seed; 32]).public()
}
fn addr(last: u8) -> Ipv4Addr {
Ipv4Addr::new(10, 13, 37, last)
}
fn ipv4(source: Ipv4Addr, destination: Ipv4Addr, payload: &[u8]) -> Bytes {
let mut packet = vec![0u8; 20];
packet[0] = 0x45;
let total = (20 + payload.len()) as u16;
packet[2..4].copy_from_slice(&total.to_be_bytes());
packet[9] = 17;
packet[12..16].copy_from_slice(&source.octets());
packet[16..20].copy_from_slice(&destination.octets());
packet.extend_from_slice(payload);
Bytes::from(packet)
}
#[tokio::test]
async fn broadcast_ingress_is_validated_and_remote_delivery_never_refloods() {
use super::super::broadcast::BroadcastPolicy;
use std::collections::HashSet;
let (interface, device, routes, carrier, id) = interface(false).await;
let enabled = BroadcastPolicy {
enabled: true,
peers: HashSet::from([peer(2)]),
};
routes.set_broadcast(id, enabled.clone());
let packet = ipv4(addr(2), Ipv4Addr::BROADCAST, &[0, 1, 0, 2, 0, 8, 0, 0]);
interface
.deliver(id, peer(2), packet.clone())
.await
.unwrap();
assert_eq!(device.pop_to_os().await.unwrap(), packet);
assert!(
carrier.carried().is_empty(),
"remote ingress never originates a fanout"
);
assert_eq!(
interface.deliver(id, peer(3), packet.clone()).await,
Err(Rejected::WrongSource)
);
assert_eq!(
interface
.deliver(id, peer(2), ipv4(addr(2), Ipv4Addr::BROADCAST, b"bad"))
.await,
Err(Rejected::BroadcastDenied)
);
routes.set_broadcast(
id,
BroadcastPolicy {
enabled: false,
..enabled
},
);
assert_eq!(
interface.deliver(id, peer(2), packet).await,
Err(Rejected::BroadcastDenied)
);
// An authenticated sender cannot use TUN as a gateway to a physical LAN.
assert_eq!(
interface
.deliver(
id,
peer(2),
ipv4(
addr(2),
"192.168.1.255".parse().unwrap(),
&[0, 1, 0, 2, 0, 8, 0, 0]
)
)
.await,
Err(Rejected::WrongDestination)
);
device.push_from_os(ipv4(
addr(1),
Ipv4Addr::BROADCAST,
&[0, 1, 0, 2, 0, 8, 0, 0],
));
wait_for(&interface, |c| c.broadcast_dropped.saturating_sub(2)).await;
assert!(carrier.carried().is_empty());
assert_eq!(interface.counters().received, 1);
interface.remove().await;
}
/// Records what it was asked to carry, and can refuse.
#[derive(Debug, Default)]
struct Recorder {
carried: std::sync::Mutex<Vec<(Route, Bytes)>>,
refuse: bool,
}
impl PacketCarrier for Recorder {
fn carry(&self, route: Route, packet: Bytes) -> bool {
if self.refuse {
return false;
}
match self.carried.lock() {
Ok(mut guard) => guard.push((route, packet)),
Err(poisoned) => poisoned.into_inner().push((route, packet)),
}
true
}
}
impl Recorder {
fn carried(&self) -> Vec<(Route, Bytes)> {
match self.carried.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
}
async fn interface(
refuse: bool,
) -> (
Interface,
Arc<MemoryTun>,
Arc<RoutingTable>,
Arc<Recorder>,
NetworkId,
) {
let id = network("interface");
let routes = Arc::new(RoutingTable::new());
routes
.set_network(
id,
NetworkRoutes {
broadcast: Default::default(),
range: Some("10.13.37.0/24".parse().unwrap()),
local: Some(addr(1)),
peers: vec![(addr(2), peer(2)), (addr(3), peer(3))],
},
)
.unwrap();
let carrier = Arc::new(Recorder {
carried: std::sync::Mutex::new(Vec::new()),
refuse,
});
let factory = Arc::new(MemoryTunFactory::new());
let interface = Interface::start(
Arc::clone(&factory) as Arc<dyn TunFactory>,
"tsuntest",
1280,
Arc::clone(&routes),
Arc::clone(&carrier) as Arc<dyn PacketCarrier>,
)
.await
.unwrap();
// The factory keeps what it made, which is how a test reaches the
// device the interface is using without exposing it.
let device = factory.device("tsuntest").expect("the device was created");
(interface, device, routes, carrier, id)
}
/// Waits for a counter to move, so the interface's own task has run.
async fn wait_for(interface: &Interface, pick: impl Fn(&Counters) -> u64) {
for _ in 0..400 {
if pick(&interface.counters()) > 0 {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
panic!("the counter never moved: {:?}", interface.counters());
}
#[tokio::test]
async fn a_packet_leaving_the_interface_goes_to_the_peer_that_owns_it() {
let (interface, device, _routes, carrier, id) = interface(false).await;
device.push_from_os(ipv4(addr(1), addr(2), b"hello"));
wait_for(&interface, |counters| counters.sent).await;
let carried = carrier.carried();
assert_eq!(carried.len(), 1);
assert_eq!(carried[0].0.peer, peer(2));
assert_eq!(carried[0].0.network, id);
assert_eq!(&carried[0].1[20..], b"hello");
}
#[tokio::test]
async fn a_packet_for_nobody_is_counted_with_a_sample_and_not_sent() {
// Counted rather than dropped quietly: without the number and an
// example of it, "the overlay is quiet" and "the overlay is dropping
// everything" look the same.
let (interface, device, _routes, carrier, _id) = interface(false).await;
let nowhere = Ipv4Addr::new(10, 13, 37, 200);
device.push_from_os(ipv4(addr(1), nowhere, b"lost"));
wait_for(&interface, |counters| counters.unroutable).await;
assert_eq!(
interface.counters().unroutable_sample,
Some(IpAddr::V4(nowhere))
);
assert!(carrier.carried().is_empty(), "nothing was flooded");
}
#[tokio::test]
async fn multicast_is_expected_and_never_carried() {
let (interface, device, _routes, carrier, _id) = interface(false).await;
device.push_from_os(ipv4(addr(1), Ipv4Addr::new(224, 0, 0, 251), b"mdns"));
wait_for(&interface, |counters| counters.multicast).await;
assert_eq!(interface.counters().unroutable, 0, "not a routing failure");
assert!(carrier.carried().is_empty());
}
#[tokio::test]
async fn a_packet_nothing_can_carry_is_counted_separately() {
// Distinct from unroutable: the destination is known, there is just
// no live protocol for it. The two want different answers.
let (interface, device, _routes, _carrier, _id) = interface(true).await;
device.push_from_os(ipv4(addr(1), addr(2), b"no link"));
wait_for(&interface, |counters| counters.undeliverable).await;
let counters = interface.counters();
assert_eq!(counters.sent, 0);
assert_eq!(counters.unroutable, 0);
}
#[tokio::test]
async fn a_decrypted_packet_from_its_owner_reaches_the_operating_system() {
let (interface, device, _routes, _carrier, id) = interface(false).await;
interface
.deliver(id, peer(2), ipv4(addr(2), addr(1), b"inbound"))
.await
.unwrap();
let written = tokio::time::timeout(std::time::Duration::from_secs(5), device.pop_to_os())
.await
.expect("the packet reaches the interface")
.unwrap();
assert_eq!(&written[20..], b"inbound");
assert_eq!(interface.counters().received, 1);
}
#[tokio::test]
async fn a_peer_cannot_send_from_an_address_it_does_not_hold() {
let (interface, _device, _routes, _carrier, id) = interface(false).await;
// Another member's address.
assert_eq!(
interface
.deliver(id, peer(2), ipv4(addr(3), addr(1), b"spoofed"))
.await,
Err(Rejected::WrongSource)
);
// And this agent's own.
assert_eq!(
interface
.deliver(id, peer(2), ipv4(addr(1), addr(1), b"spoofed"))
.await,
Err(Rejected::WrongSource)
);
assert_eq!(interface.counters().wrong_source, 2);
assert_eq!(interface.counters().received, 0);
}
#[tokio::test]
async fn an_unreadable_or_wrong_family_packet_is_refused_without_panicking() {
let (interface, _device, _routes, _carrier, id) = interface(false).await;
assert_eq!(
interface.deliver(id, peer(2), Bytes::new()).await,
Err(Rejected::Malformed)
);
assert_eq!(
interface
.deliver(id, peer(2), Bytes::from_static(&[0xff; 8]))
.await,
Err(Rejected::Malformed)
);
// An IPv6 packet: readable, but the overlay has no owner for it.
let mut six = vec![0u8; 40];
six[0] = 0x60;
assert_eq!(
interface.deliver(id, peer(2), Bytes::from(six)).await,
Err(Rejected::WrongFamily)
);
}
#[tokio::test]
async fn the_wanted_addresses_follow_the_table() {
let (interface, _device, routes, _carrier, id) = interface(false).await;
assert_eq!(
interface.wanted_addresses(),
vec![Cidr::new(addr(1).into(), 24).unwrap()]
);
// The network agreed a different address for us.
routes
.set_network(
id,
NetworkRoutes {
broadcast: Default::default(),
range: Some("10.13.37.0/24".parse().unwrap()),
local: Some(addr(9)),
peers: vec![(addr(2), peer(2))],
},
)
.unwrap();
assert_eq!(
interface.wanted_addresses(),
vec![Cidr::new(addr(9).into(), 24).unwrap()]
);
}
async fn managed_interface(
rules: &MockHostRules,
broadcast_enabled: bool,
) -> (Interface, Arc<RoutingTable>, NetworkId) {
let id = network("rules");
let routes = Arc::new(RoutingTable::new());
routes
.set_network(
id,
NetworkRoutes {
broadcast: BroadcastPolicy {
enabled: broadcast_enabled,
..Default::default()
},
range: Some("10.13.37.0/24".parse().unwrap()),
local: Some(addr(1)),
peers: vec![(addr(2), peer(2))],
},
)
.unwrap();
let factory = Arc::new(
ManagedTunFactory::new(Arc::new(MockProvisioner::default()))
.with_host_rules(Arc::new(rules.clone())),
);
let interface = Interface::start(
factory as Arc<dyn TunFactory>,
"tsunrules",
1280,
Arc::clone(&routes),
Arc::new(Recorder::default()) as Arc<dyn PacketCarrier>,
)
.await
.unwrap();
(interface, routes, id)
}
#[tokio::test]
async fn host_rules_follow_broadcast_policy_address_and_interface_lifetime() {
let rules = MockHostRules::new();
let (interface, routes, id) = managed_interface(&rules, true).await;
assert!(
interface.broadcast_rules().is_none(),
"nothing before a sync"
);
interface.sync_broadcast_rules().await;
let installed = rules.installed();
assert_eq!(installed["tsunrules"].source, addr(1));
assert_eq!(
installed["tsunrules"].range,
"10.13.37.0/24".parse().unwrap()
);
assert!(interface.broadcast_rules().unwrap().is_ok());
// Unchanged: not applied a second time.
interface.sync_broadcast_rules().await;
interface.sync_broadcast_rules().await;
assert_eq!(rules.calls(), ["apply:tsunrules"]);
// A new address replaces the plan, it does not stack a second one.
routes
.set_network(
id,
NetworkRoutes {
broadcast: BroadcastPolicy::default(),
range: Some("10.13.37.0/24".parse().unwrap()),
local: Some(addr(9)),
peers: vec![(addr(2), peer(2))],
},
)
.unwrap();
interface.sync_broadcast_rules().await;
assert_eq!(rules.installed()["tsunrules"].source, addr(9));
assert_eq!(rules.installed().len(), 1);
// Turning broadcast off takes the rules away.
routes.set_broadcast(
id,
BroadcastPolicy {
enabled: false,
..Default::default()
},
);
interface.sync_broadcast_rules().await;
assert!(rules.installed().is_empty());
assert!(interface.broadcast_rules().is_none());
// And on again puts them back; leaving the agent removes them once more.
routes.set_broadcast(id, BroadcastPolicy::default());
interface.sync_broadcast_rules().await;
assert_eq!(rules.installed().len(), 1);
interface.remove().await;
assert!(rules.installed().is_empty(), "shutdown clears the host");
assert!(interface.broadcast_rules().is_none());
}
#[tokio::test]
async fn host_rules_are_not_installed_while_broadcast_is_off() {
let rules = MockHostRules::new();
let (interface, _routes, _id) = managed_interface(&rules, false).await;
interface.sync_broadcast_rules().await;
assert!(rules.installed().is_empty());
assert!(rules.calls().is_empty(), "the host was not even asked");
interface.remove().await;
}
#[tokio::test]
async fn a_failed_firewall_step_is_reported_and_does_not_undo_the_route() {
let rules = MockHostRules::new();
rules.fail_firewall("iptables not found");
let (interface, _routes, _id) = managed_interface(&rules, true).await;
interface.sync_broadcast_rules().await;
let report = interface.broadcast_rules().unwrap();
assert!(report.route.is_applied());
assert_eq!(
report.firewall,
RuleOutcome::Failed("iptables not found".into())
);
assert!(!report.is_ok());
// Not retried on every sync: the plan is the same.
interface.sync_broadcast_rules().await;
assert_eq!(rules.calls(), ["apply:tsunrules"]);
interface.remove().await;
}
#[tokio::test]
async fn an_interface_without_a_host_has_no_host_rules() {
let (interface, _device, _routes, _carrier, _id) = interface(false).await;
interface.sync_broadcast_rules().await;
assert!(interface.broadcast_rules().is_none());
interface.remove().await;
}
}
+78
View File
@@ -0,0 +1,78 @@
//! The overlay interface: the one network interface an agent owns.
//!
//! One agent, one interface. It belongs to the system level rather than to
//! any protocol, and that is the whole point: a packet leaving it is routed
//! to whichever peer owns its destination address, over whichever protocol
//! currently has a link to that peer. Several protocols can be live at once
//! without arguing over who holds the address, because neither of them
//! holds it — the agent does.
//!
//! What lives here:
//!
//! * [`provision`] creates the interface and configures it, and removes it
//! again. Platform mechanics behind one decision function.
//! * [`hostrules`] installs the route and firewall allowance that let a
//! game's LAN discovery reach the interface, while broadcast is on.
//! * [`tun`] is the packet interface itself, real or in memory.
//! * [`packet`] reads just enough of an IP header to route by it.
//! * [`config`] names interfaces and describes addresses.
//!
//! What does not live here: encryption, peer discovery, and how a packet
//! actually reaches another machine. Those belong to a protocol plugin,
//! which this level hands packets to and takes packets from.
/// Why the overlay interface cannot be brought into the state asked for.
///
/// Its own error rather than the plugin one it used to borrow: this level
/// owns the interface now, and a plugin failing is a different event from
/// the interface failing.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum OverlayError {
/// The interface cannot be created or configured right now, with the
/// reason and, where there is one, what to do about it.
#[error("overlay unavailable: {0}")]
Unavailable(String),
/// Anything else.
#[error("{0}")]
Other(String),
}
pub mod broadcast;
pub mod config;
pub mod hostrules;
pub mod interface;
pub mod packet;
pub mod provision;
pub mod router;
pub mod tun;
pub use config::{Cidr, DEFAULT_INTERFACE_PREFIX, MAX_INTERFACE_NAME_LEN, interface_name};
pub use hostrules::{
BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, MockHostRules, RuleOutcome,
UnsupportedHostRules,
};
pub use interface::{Counters, Interface, PacketCarrier, Rejected};
pub use packet::IpHeader;
pub use provision::{
Changes, InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, ManagedTunFactory,
MockHost, MockProvisioner, Privilege, Provisioned, UnsupportedProvisioner, plan_changes,
probe_net_admin,
};
pub use router::{NetworkRoutes, Route, RouteError, RoutingTable};
pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest, address_is_local};
#[cfg(all(feature = "tun-device", target_os = "linux"))]
pub use hostrules::LinuxHostRules;
#[cfg(all(feature = "tun-device", target_os = "macos"))]
pub use hostrules::MacosHostRules;
#[cfg(all(feature = "tun-device", target_os = "windows"))]
pub use hostrules::WindowsHostRules;
#[cfg(all(feature = "tun-device", target_os = "linux"))]
pub use provision::NetlinkProvisioner;
#[cfg(all(feature = "tun-device", target_os = "macos"))]
pub use provision::UtunProvisioner;
#[cfg(all(feature = "tun-device", target_os = "windows"))]
pub use provision::WintunProvisioner;
File renamed without changes.
@@ -9,22 +9,21 @@
use std::sync::Arc;
use crate::BoxFuture;
use crate::dataplane::PluginError;
use crate::overlay::OverlayError;
use super::super::config::Cidr;
use super::super::hostrules::BroadcastHostRules;
use super::super::tun::{TunDevice, TunFactory, TunRequest};
use super::{InterfacePlan, InterfaceProvisioner};
/// Turns a [`TunRequest`] into the plan for a host interface.
fn plan_for(request: &TunRequest) -> Result<InterfacePlan, PluginError> {
let mut addresses = vec![Cidr::new(request.address.into(), request.prefix_len)?];
if let Some(address) = request.address_v4 {
addresses.push(Cidr::new(address.into(), request.prefix_len_v4)?);
}
fn plan_for(request: &TunRequest) -> Result<InterfacePlan, OverlayError> {
// Every address, because the plan is exhaustive: the provisioner adds
// what is missing and removes what is not in it. Passing one network's
// address would take every other network's off the host.
Ok(InterfacePlan::new(
request.name.clone(),
request.mtu,
addresses,
request.addresses.clone(),
))
}
@@ -32,12 +31,23 @@ fn plan_for(request: &TunRequest) -> Result<InterfacePlan, PluginError> {
#[derive(Debug)]
pub struct ManagedTunFactory {
provisioner: Arc<dyn InterfaceProvisioner>,
host_rules: Option<Arc<dyn BroadcastHostRules>>,
}
impl ManagedTunFactory {
/// Wraps a provisioner.
pub fn new(provisioner: Arc<dyn InterfaceProvisioner>) -> Self {
Self { provisioner }
Self {
provisioner,
host_rules: None,
}
}
/// Lets the agent install the broadcast route and firewall allowance for
/// the interface this factory manages.
pub fn with_host_rules(mut self, rules: Arc<dyn BroadcastHostRules>) -> Self {
self.host_rules = Some(rules);
self
}
/// The provisioner underneath.
@@ -51,10 +61,19 @@ impl TunFactory for ManagedTunFactory {
self.provisioner.name()
}
/// It creates the interface on the host and holds it open.
fn on_host(&self) -> bool {
true
}
fn host_rules(&self) -> Option<Arc<dyn BroadcastHostRules>> {
self.host_rules.clone()
}
fn create<'a>(
&'a self,
request: TunRequest,
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, PluginError>> {
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>> {
Box::pin(async move {
let plan = plan_for(&request)?;
let provisioned = self.provisioner.reconcile(&plan).await?;
@@ -67,7 +86,7 @@ impl TunFactory for ManagedTunFactory {
// Reaching here would mean the interface already existed and
// was held open by us, which cannot be true on the path that
// creates a device.
PluginError::Other(format!(
OverlayError::Other(format!(
"interface `{}` was reconciled but no device came back",
plan.name
))
@@ -75,7 +94,7 @@ impl TunFactory for ManagedTunFactory {
})
}
fn reconfigure<'a>(&'a self, request: TunRequest) -> BoxFuture<'a, Result<(), PluginError>> {
fn reconfigure<'a>(&'a self, request: TunRequest) -> BoxFuture<'a, Result<(), OverlayError>> {
Box::pin(async move {
let plan = plan_for(&request)?;
let provisioned = self.provisioner.reconcile(&plan).await?;
@@ -106,18 +125,23 @@ impl TunFactory for ManagedTunFactory {
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::{Ipv4Addr, Ipv6Addr};
use std::net::Ipv4Addr;
use super::super::super::config::Cidr;
use super::super::{LinkKind, MockHost, MockProvisioner};
use super::*;
fn request(v4: Option<Ipv4Addr>) -> TunRequest {
addressed(v4.into_iter().collect())
}
fn addressed(v4: Vec<Ipv4Addr>) -> TunRequest {
TunRequest {
name: "tsunfactory".into(),
address: Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1),
prefix_len: 64,
address_v4: v4,
prefix_len_v4: 24,
addresses: v4
.into_iter()
.map(|address| Cidr::new(address.into(), 24).unwrap())
.collect(),
mtu: 1280,
}
}
@@ -137,7 +161,7 @@ mod tests {
let state = provisioner.host().get("tsunfactory").unwrap();
assert_eq!(state.kind, LinkKind::Tun);
assert_eq!(state.mtu, 1280);
assert_eq!(state.addresses.len(), 2, "both families are assigned");
assert_eq!(state.addresses.len(), 1, "the overlay address is assigned");
}
#[tokio::test]
@@ -198,4 +222,50 @@ mod tests {
let err = factory.create(request(None)).await.unwrap_err();
assert!(err.to_string().contains("bridge"), "{err}");
}
#[tokio::test]
async fn every_network_s_address_reaches_the_host_not_just_the_first() {
// One agent has one interface and a network apiece on it. Carrying
// only the first address is carrying only the first network: the
// rest have addresses the operating system has never heard of, and
// their traffic goes nowhere while the status says all is well.
let provisioner = Arc::new(MockProvisioner::default());
let factory = ManagedTunFactory::new(provisioner.clone());
factory
.create(addressed(vec![
Ipv4Addr::new(10, 13, 37, 69),
Ipv4Addr::new(10, 156, 200, 116),
]))
.await
.unwrap();
let state = provisioner.host().get("tsunfactory").unwrap();
let addresses: Vec<String> = state
.addresses
.iter()
.map(|entry| entry.to_string())
.collect();
assert!(
addresses.contains(&"10.13.37.69/24".to_string()),
"{addresses:?}"
);
assert!(
addresses.contains(&"10.156.200.116/24".to_string()),
"{addresses:?}"
);
// And leaving one network takes its address off, because the plan
// is what the interface should carry and nothing else.
factory
.reconfigure(addressed(vec![Ipv4Addr::new(10, 13, 37, 69)]))
.await
.unwrap();
let state = provisioner.host().get("tsunfactory").unwrap();
let addresses: Vec<String> = state
.addresses
.iter()
.map(|entry| entry.to_string())
.collect();
assert_eq!(addresses, vec!["10.13.37.69/24".to_string()]);
}
}
@@ -39,7 +39,7 @@
//! capability is raised immediately before that call and lowered immediately
//! after, and the connection task lives and dies inside it.
use std::net::IpAddr;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::{Arc, Mutex, mpsc};
use futures_util::TryStreamExt;
@@ -47,10 +47,11 @@ use futures_util::TryStreamExt;
// of step with the client that sends them.
use rtnetlink::packet_route::address::{AddressAttribute, AddressMessage};
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkFlags, LinkInfo, LinkMessage};
use rtnetlink::{LinkMessageBuilder, LinkUnspec};
use rtnetlink::packet_route::route::RouteScope;
use rtnetlink::{LinkMessageBuilder, LinkUnspec, RouteMessageBuilder};
use crate::BoxFuture;
use crate::dataplane::PluginError;
use crate::overlay::OverlayError;
use super::super::config::Cidr;
use super::super::tun::{TunDevice, TunRequest};
@@ -67,11 +68,15 @@ enum Command {
Delete(String, Reply<()>),
/// Apply MTU, link state and addresses.
Configure(Box<Configure>, Reply<()>),
/// Route `255.255.255.255/32` through this interface from this address.
SetBroadcastRoute(String, Ipv4Addr, Reply<()>),
/// Remove that route from this interface.
DelBroadcastRoute(String, Reply<()>),
/// Stop the thread.
Stop,
}
type Reply<T> = mpsc::Sender<Result<T, PluginError>>;
type Reply<T> = mpsc::Sender<Result<T, OverlayError>>;
/// The configuration half of a reconciliation.
struct Configure {
@@ -97,17 +102,17 @@ pub struct NetlinkProvisioner {
impl NetlinkProvisioner {
/// Starts the netlink thread, after checking this process can use it.
pub fn new() -> Result<Self, PluginError> {
pub fn new() -> Result<Self, OverlayError> {
match probe_net_admin() {
Privilege::Available => {}
Privilege::Missing(reason) => {
return Err(PluginError::Unavailable(format!(
return Err(OverlayError::Unavailable(format!(
"{reason}. {}",
Privilege::how_to_grant(&current_program())
)));
}
Privilege::Unsupported => {
return Err(PluginError::Unavailable(
return Err(OverlayError::Unavailable(
"interface management is not compiled in".to_string(),
));
}
@@ -118,7 +123,7 @@ impl NetlinkProvisioner {
.name("tsunagi-netlink".to_string())
.spawn(move || netlink_thread(requests))
.map_err(|err| {
PluginError::Unavailable(format!("cannot start the netlink thread: {err}"))
OverlayError::Unavailable(format!("cannot start the netlink thread: {err}"))
})?;
Ok(Self {
@@ -132,14 +137,19 @@ impl NetlinkProvisioner {
fn call<T: Send + 'static>(
&self,
make: impl FnOnce(Reply<T>) -> Command,
) -> Result<T, PluginError> {
let (reply_tx, reply_rx) = mpsc::channel();
self.commands
.send(make(reply_tx))
.map_err(|_| PluginError::Unavailable("the netlink thread has stopped".to_string()))?;
reply_rx.recv().map_err(|_| {
PluginError::Unavailable("the netlink thread stopped mid-request".to_string())
})?
) -> Result<T, OverlayError> {
call_thread(&self.commands, make)
}
/// The broadcast host rules for this host.
///
/// The route is added by this provisioner's netlink thread, because it is
/// the only one that ever holds `CAP_NET_ADMIN`, and only for the duration
/// of a call. Keep the provisioner alive for as long as the rules are used.
pub fn host_rules(&self) -> crate::overlay::hostrules::LinuxHostRules {
crate::overlay::hostrules::LinuxHostRules::new(RouteHandle {
commands: self.commands.clone(),
})
}
fn is_ours(&self, name: &str) -> bool {
@@ -150,13 +160,51 @@ impl NetlinkProvisioner {
///
/// Synchronous on purpose: the capability guard is raised and lowered
/// without an `await` in between, so it cannot outlive this thread.
fn create_device(&self, plan: &InterfacePlan) -> Result<Arc<dyn TunDevice>, PluginError> {
fn create_device(&self, plan: &InterfacePlan) -> Result<Arc<dyn TunDevice>, OverlayError> {
let request = TunRequest::bare(plan.name.clone(), plan.mtu);
let _guard = NetAdmin::acquire()?;
super::super::tun::open_tun(&request)
}
}
fn call_thread<T: Send + 'static>(
commands: &mpsc::Sender<Command>,
make: impl FnOnce(Reply<T>) -> Command,
) -> Result<T, OverlayError> {
let (reply_tx, reply_rx) = mpsc::channel();
commands
.send(make(reply_tx))
.map_err(|_| OverlayError::Unavailable("the netlink thread has stopped".to_string()))?;
reply_rx.recv().map_err(|_| {
OverlayError::Unavailable("the netlink thread stopped mid-request".to_string())
})?
}
/// Adds and removes the limited-broadcast route on the netlink thread.
#[derive(Debug, Clone)]
pub(crate) struct RouteHandle {
commands: mpsc::Sender<Command>,
}
impl RouteHandle {
/// Routes `255.255.255.255/32` through `interface`, preferring `source`.
/// Replaces a route this agent added earlier, so it can be repeated.
pub(crate) fn set(&self, interface: &str, source: Ipv4Addr) -> Result<(), OverlayError> {
let interface = interface.to_string();
call_thread(&self.commands, |reply| {
Command::SetBroadcastRoute(interface, source, reply)
})
}
/// Removes the route. Succeeds when it, or the interface, is gone.
pub(crate) fn clear(&self, interface: &str) -> Result<(), OverlayError> {
let interface = interface.to_string();
call_thread(&self.commands, |reply| {
Command::DelBroadcastRoute(interface, reply)
})
}
}
impl Drop for NetlinkProvisioner {
fn drop(&mut self) {
let _ = self.commands.send(Command::Stop);
@@ -174,7 +222,7 @@ impl InterfaceProvisioner for NetlinkProvisioner {
fn reconcile<'a>(
&'a self,
plan: &'a InterfacePlan,
) -> BoxFuture<'a, Result<Provisioned, PluginError>> {
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
Box::pin(async move {
let current = self.call(|reply| Command::Observe(plan.name.clone(), reply))?;
let changes = plan_changes(&current, plan, self.is_ours(&plan.name))?;
@@ -224,7 +272,7 @@ impl InterfaceProvisioner for NetlinkProvisioner {
})
}
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), PluginError>> {
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
Box::pin(async move {
// Dropping the device is what removes the interface; the explicit
// delete is only so it is gone by the time this returns rather
@@ -271,10 +319,13 @@ fn netlink_thread(requests: mpsc::Receiver<Command>) {
let message = format!("the netlink thread has no runtime: {err}");
match command {
Command::Observe(_, reply) => {
let _ = reply.send(Err(PluginError::Unavailable(message)));
let _ = reply.send(Err(OverlayError::Unavailable(message)));
}
Command::Delete(_, reply) | Command::Configure(_, reply) => {
let _ = reply.send(Err(PluginError::Unavailable(message)));
Command::Delete(_, reply)
| Command::Configure(_, reply)
| Command::SetBroadcastRoute(_, _, reply)
| Command::DelBroadcastRoute(_, reply) => {
let _ = reply.send(Err(OverlayError::Unavailable(message)));
}
Command::Stop => return,
}
@@ -306,17 +357,33 @@ fn netlink_thread(requests: mpsc::Receiver<Command>) {
});
let _ = reply.send(result);
}
Command::SetBroadcastRoute(name, source, reply) => {
let result = NetAdmin::acquire().and_then(|guard| {
let result = runtime.block_on(set_broadcast_route(&name, source));
drop(guard);
result
});
let _ = reply.send(result);
}
Command::DelBroadcastRoute(name, reply) => {
let result = NetAdmin::acquire().and_then(|guard| {
let result = runtime.block_on(del_broadcast_route(&name));
drop(guard);
result
});
let _ = reply.send(result);
}
}
}
}
/// Opens a netlink connection and runs one unit of work over it.
async fn with_netlink<T, F>(work: impl FnOnce(rtnetlink::Handle) -> F) -> Result<T, PluginError>
async fn with_netlink<T, F>(work: impl FnOnce(rtnetlink::Handle) -> F) -> Result<T, OverlayError>
where
F: std::future::Future<Output = Result<T, PluginError>>,
F: std::future::Future<Output = Result<T, OverlayError>>,
{
let (connection, handle, _messages) = rtnetlink::new_connection()
.map_err(|err| PluginError::Unavailable(format!("cannot open netlink: {err}")))?;
.map_err(|err| OverlayError::Unavailable(format!("cannot open netlink: {err}")))?;
let pump = tokio::spawn(connection);
let result = work(handle).await;
pump.abort();
@@ -361,7 +428,7 @@ fn link_kind(message: &LinkMessage, name: &str) -> LinkKind {
}
}
async fn observe(name: &str) -> Result<InterfaceState, PluginError> {
async fn observe(name: &str) -> Result<InterfaceState, OverlayError> {
with_netlink(|handle| async move {
let mut links = handle.link().get().match_name(name.to_string()).execute();
let message = match links.try_next().await {
@@ -373,7 +440,7 @@ async fn observe(name: &str) -> Result<InterfaceState, PluginError> {
if !std::path::Path::new(&format!("/sys/class/net/{name}")).exists() {
return Ok(InterfaceState::absent());
}
return Err(PluginError::Unavailable(format!(
return Err(OverlayError::Unavailable(format!(
"cannot read interface `{name}`: {err}"
)));
}
@@ -397,7 +464,7 @@ async fn observe(name: &str) -> Result<InterfaceState, PluginError> {
.set_link_index_filter(index)
.execute();
while let Some(message) = stream.try_next().await.map_err(|err| {
PluginError::Unavailable(format!("cannot read the addresses of `{name}`: {err}"))
OverlayError::Unavailable(format!("cannot read the addresses of `{name}`: {err}"))
})? {
if let Some(cidr) = address_of(&message)
&& !is_link_local(cidr.addr)
@@ -440,7 +507,7 @@ fn cidr(addr: IpAddr, prefix_len: u8) -> Option<Cidr> {
Cidr::new(addr, prefix_len).ok()
}
async fn delete_link(name: &str) -> Result<(), PluginError> {
async fn delete_link(name: &str) -> Result<(), OverlayError> {
with_netlink(|handle| async move {
let mut links = handle.link().get().match_name(name.to_string()).execute();
let index = match links.try_next().await {
@@ -449,13 +516,87 @@ async fn delete_link(name: &str) -> Result<(), PluginError> {
Ok(None) | Err(_) => return Ok(()),
};
handle.link().del(index).execute().await.map_err(|err| {
PluginError::Unavailable(format!("cannot remove interface `{name}`: {err}"))
OverlayError::Unavailable(format!("cannot remove interface `{name}`: {err}"))
})
})
.await
}
async fn configure_link(configure: &Configure) -> Result<(), PluginError> {
/// The message that names the limited-broadcast route on an interface.
///
/// On-link scope is what the kernel requires of a route with no gateway, and
/// what `ip route add 255.255.255.255/32 dev <if>` produces. Deleting needs the
/// same scope or the kernel finds no match.
fn broadcast_route_message(
index: u32,
source: Option<Ipv4Addr>,
) -> rtnetlink::packet_route::route::RouteMessage {
let mut builder = RouteMessageBuilder::<Ipv4Addr>::new()
.destination_prefix(Ipv4Addr::BROADCAST, 32)
.output_interface(index)
.scope(RouteScope::Link);
if let Some(source) = source {
builder = builder.pref_source(source);
}
builder.build()
}
async fn link_index(handle: &rtnetlink::Handle, name: &str) -> Option<u32> {
let mut links = handle.link().get().match_name(name.to_string()).execute();
links
.try_next()
.await
.ok()
.flatten()
.map(|message| message.header.index)
}
async fn set_broadcast_route(name: &str, source: Ipv4Addr) -> Result<(), OverlayError> {
with_netlink(|handle| async move {
let index = link_index(&handle, name).await.ok_or_else(|| {
OverlayError::Unavailable(format!(
"interface `{name}` disappeared before its broadcast route could be added"
))
})?;
handle
.route()
.add(broadcast_route_message(index, Some(source)))
.replace()
.execute()
.await
.map_err(|err| {
OverlayError::Unavailable(format!(
"cannot route 255.255.255.255 through `{name}` from {source}: {err}"
))
})
})
.await
}
async fn del_broadcast_route(name: &str) -> Result<(), OverlayError> {
with_netlink(|handle| async move {
// An interface that is gone took its routes with it.
let Some(index) = link_index(&handle, name).await else {
return Ok(());
};
match handle
.route()
.del(broadcast_route_message(index, None))
.execute()
.await
{
Ok(()) => Ok(()),
// "No such process": there was no such route, which is the goal.
Err(err) if err.to_string().contains("No such process") => Ok(()),
Err(err) => Err(OverlayError::Unavailable(format!(
"cannot remove the 255.255.255.255 route from `{name}`: {err}"
))),
}
})
.await
}
async fn configure_link(configure: &Configure) -> Result<(), OverlayError> {
let name = configure.name.as_str();
with_netlink(|handle| async move {
let mut links = handle.link().get().match_name(name.to_string()).execute();
@@ -466,7 +607,7 @@ async fn configure_link(configure: &Configure) -> Result<(), PluginError> {
.flatten()
.map(|message| message.header.index)
.ok_or_else(|| {
PluginError::Unavailable(format!(
OverlayError::Unavailable(format!(
"interface `{name}` disappeared before it could be configured"
))
})?;
@@ -485,7 +626,7 @@ async fn configure_link(configure: &Configure) -> Result<(), PluginError> {
.execute()
.await
.map_err(|err| {
PluginError::Unavailable(format!("cannot configure interface `{name}`: {err}"))
OverlayError::Unavailable(format!("cannot configure interface `{name}`: {err}"))
})?;
}
@@ -512,7 +653,7 @@ async fn configure_link(configure: &Configure) -> Result<(), PluginError> {
.execute()
.await
.map_err(|err| {
PluginError::Unavailable(format!(
OverlayError::Unavailable(format!(
"cannot remove {cidr} from interface `{name}`: {err}"
))
})?;
@@ -520,16 +661,19 @@ async fn configure_link(configure: &Configure) -> Result<(), PluginError> {
}
for cidr in &configure.add {
handle
let res = handle
.address()
.add(index, cidr.addr, cidr.prefix_len)
.execute()
.await
.map_err(|err| {
PluginError::Unavailable(format!(
.await;
if let Err(err) = res {
let err_str = err.to_string();
if !err_str.contains("File exists") && !err_str.contains("os error 17") {
return Err(OverlayError::Unavailable(format!(
"cannot add {cidr} to interface `{name}`: {err}"
))
})?;
)));
}
}
}
Ok(())
@@ -0,0 +1,531 @@
//! Managing the overlay interface on macOS, through utun and `ifconfig`/`route`.
//!
//! The shape is the same as the Linux and Windows provisioners: observe what
//! is there, [`plan_changes`](super::plan_changes) to decide what to do, apply
//! the difference. Only the mechanisms differ.
//!
//! # Creating the interface
//!
//! The interface is a `utun` device, created through the `tun` dependency's
//! safe wrapper — [`open_tun`](super::super::tun) — over the in-kernel
//! `com.apple.net.utun_control` control socket. No kernel extension and no
//! Network Extension entitlement is involved; what it needs is root. The
//! device is not made persistent, so closing the last file descriptor removes
//! the interface — it goes away when the agent does, however the agent ends.
//!
//! # The kernel names the interface, not us
//!
//! Unlike Linux and Windows, a utun interface cannot be given an arbitrary
//! name: it is always `utunN`, and the unit number is assigned by the kernel.
//! So the name derived from the network id (`tsun…`) is only ever a *request*;
//! the real name is read back off the device after it is opened, and
//! everything downstream — the host rules, the resolver publication — uses the
//! real one. The agent adopts it as soon as the device is created (see
//! [`Interface`](crate::overlay::Interface)).
//!
//! # Configuring it
//!
//! Addresses, MTU and routes are applied with `ifconfig` and `route`. On Linux
//! the same work is done in process over a netlink socket; here there is no
//! equivalent this crate may call, because the in-process route is raw `ioctl`
//! and `PF_ROUTE` and that is `unsafe`. The tools are invoked by absolute path
//! from `/sbin`, so nothing on `PATH` can stand in for them, and every value
//! handed to them is one this agent derived — an interface name chosen by the
//! kernel, an address it allocated — never anything a peer said.
//!
//! # Observing it
//!
//! The interface table is read through `netdev`, a safe wrapper over
//! `getifaddrs`. An interface is matched by its real `utunN` name, which only
//! matters on reconfiguration: the first create always makes a fresh device.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::{Arc, Mutex};
use crate::BoxFuture;
use crate::overlay::OverlayError;
use super::super::config::Cidr;
use super::super::tun::{TunDevice, TunRequest, open_tun};
use super::privilege::{Privilege, probe_net_admin};
use super::{
InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, Provisioned, plan_changes,
};
/// Manages the overlay interface with utun and `ifconfig`/`route`.
#[derive(Debug)]
pub struct UtunProvisioner {
/// Interfaces this process created, by their real `utunN` name, so a
/// reconfiguration adjusts them rather than trying to create them again.
ours: Mutex<Vec<String>>,
/// Devices kept alive for as long as the interface should exist. Dropping
/// one closes the last descriptor, which removes the utin interface.
held: Mutex<Vec<(String, Arc<dyn TunDevice>)>>,
}
impl UtunProvisioner {
/// Creates the provisioner, after checking the platform supports one.
///
/// Whether the process is actually root is not checked here — reading the
/// effective uid needs a libc call this crate forbids — so a missing
/// privilege surfaces at utun creation with a precise message, the same
/// way the Linux and Windows paths treat the open itself as the honest
/// answer.
pub fn new() -> Result<Self, OverlayError> {
match probe_net_admin() {
Privilege::Available => {}
Privilege::Missing(reason) => return Err(OverlayError::Unavailable(reason)),
Privilege::Unsupported => {
return Err(OverlayError::Unavailable(
"interface management is not compiled in".to_string(),
));
}
}
Ok(Self {
ours: Mutex::new(Vec::new()),
held: Mutex::new(Vec::new()),
})
}
fn is_ours(&self, name: &str) -> bool {
lock(&self.ours).iter().any(|owned| owned == name)
}
/// Opens the utun device, which is what creates it. The kernel assigns the
/// name, so the returned device reports a `utunN` that is not `plan.name`.
fn create_device(&self, plan: &InterfacePlan) -> Result<Arc<dyn TunDevice>, OverlayError> {
let request = TunRequest::bare(plan.name.clone(), plan.mtu);
open_tun(&request)
}
}
impl InterfaceProvisioner for UtunProvisioner {
fn name(&self) -> &str {
"utun"
}
fn reconcile<'a>(
&'a self,
plan: &'a InterfacePlan,
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
Box::pin(async move {
// A reconfiguration: the plan carries the real `utunN` name the
// agent adopted after the first create, so the interface is there
// and only the addresses and MTU need bringing into line.
if self.is_ours(&plan.name) {
let current = observe(&plan.name).await;
let changes = plan_changes(&current, plan, true)?;
configure(&plan.name, &changes).await?;
return Ok(Provisioned {
changes,
device: None,
});
}
// The first create. The kernel assigns the unit, so the device is
// opened first and its real name used for everything after.
let device = self.create_device(plan)?;
let real = device.name().to_string();
// A fresh interface starts with nothing on it, so the whole plan is
// applied. `plan_changes` against an absent interface is exactly
// that decision; its `create_link` flag is already satisfied by the
// open above, so `configure` ignores it and only applies the rest.
let changes = plan_changes(&InterfaceState::absent(), plan, false)?;
// A failure here leaves an interface that exists but cannot carry
// traffic. Drop the device so the utun closes, and report, rather
// than leave a trap behind.
if let Err(err) = configure(&real, &changes).await {
drop(device);
return Err(err);
}
lock(&self.ours).push(real.clone());
lock(&self.held).push((real, Arc::clone(&device)));
Ok(Provisioned {
changes,
device: Some(device),
})
})
}
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
Box::pin(async move {
// Dropping the device closes the last descriptor, which removes the
// utun interface and the routes that were bound to it. There is
// nothing to flush: a utun never outlives the process that holds it.
lock(&self.held).retain(|(held, _)| held != name);
lock(&self.ours).retain(|owned| owned != name);
Ok(())
})
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
/// `fe80::/10` and `169.254.0.0/16`, which macOS assigns on its own.
fn is_link_local(addr: IpAddr) -> bool {
match addr {
IpAddr::V4(addr) => addr.is_link_local(),
IpAddr::V6(addr) => (addr.segments()[0] & 0xffc0) == 0xfe80,
}
}
/// What the interface named `name` currently looks like.
///
/// Never fails: a name nothing is using reads as [`InterfaceState::absent`],
/// which is what the plan then acts on.
async fn observe(name: &str) -> InterfaceState {
let name = name.to_string();
let found = tokio::task::spawn_blocking(move || {
netdev::get_interfaces()
.into_iter()
.find(|iface| iface.name == name)
})
.await
.ok()
.flatten();
let Some(iface) = found else {
return InterfaceState::absent();
};
let mut addresses: Vec<Cidr> = Vec::new();
for net in &iface.ipv4 {
if let Ok(cidr) = Cidr::new(IpAddr::V4(net.addr()), net.prefix_len())
&& !is_link_local(cidr.addr)
{
addresses.push(cidr);
}
}
for net in &iface.ipv6 {
if let Ok(cidr) = Cidr::new(IpAddr::V6(net.addr()), net.prefix_len())
&& !is_link_local(cidr.addr)
{
addresses.push(cidr);
}
}
addresses.sort();
InterfaceState {
// Only ever reached for an interface this agent created (see
// `reconcile`), so it is ours and a TUN by construction.
kind: LinkKind::Tun,
attached: iface.is_up(),
up: iface.is_up(),
mtu: iface.mtu.unwrap_or(0),
addresses,
}
}
/// Applies MTU, link state, addresses and the on-link routes they imply.
async fn configure(name: &str, changes: &super::Changes) -> Result<(), OverlayError> {
if let Some(mtu) = changes.set_mtu {
set_mtu(name, mtu).await?;
}
for cidr in &changes.remove {
// Best effort: an address that is already gone is the outcome wanted.
if let Err(err) = address_del(name, *cidr).await {
tracing::debug!(interface = %name, %cidr, %err, "could not remove address");
}
if let Err(err) = route_del(name, *cidr).await {
tracing::debug!(interface = %name, %cidr, %err, "could not remove route");
}
}
for cidr in &changes.add {
address_add(name, *cidr).await?;
// The subnet route makes the whole overlay range reach the interface;
// a point-to-point utun does not install one from the address alone. A
// host address (/32 or /128) needs none. Best effort: a route that is
// already there is the outcome wanted.
if let Err(err) = route_add(name, *cidr).await {
tracing::debug!(interface = %name, %cidr, %err, "could not add route");
}
}
if changes.bring_up {
// Best effort: a utun with a descriptor open is up already, and a
// failure to nudge it is not a reason to fail the whole reconcile.
if let Err(err) = set_up(name).await {
tracing::debug!(interface = %name, %err, "could not enable the interface");
}
}
Ok(())
}
/// The network address of a CIDR, with the host bits cleared.
fn network_of(cidr: Cidr) -> IpAddr {
match cidr.addr {
IpAddr::V4(addr) => {
let bits = u32::from(addr);
let mask = if cidr.prefix_len == 0 {
0
} else {
u32::MAX << (32 - u32::from(cidr.prefix_len))
};
IpAddr::V4(Ipv4Addr::from(bits & mask))
}
IpAddr::V6(addr) => {
let bits = u128::from(addr);
let mask = if cidr.prefix_len == 0 {
0
} else {
u128::MAX << (128 - u32::from(cidr.prefix_len))
};
IpAddr::V6(Ipv6Addr::from(bits & mask))
}
}
}
/// Whether a CIDR is a single host address, which needs no subnet route.
fn is_host_route(cidr: Cidr) -> bool {
match cidr.addr {
IpAddr::V4(_) => cidr.prefix_len >= 32,
IpAddr::V6(_) => cidr.prefix_len >= 128,
}
}
/// The `ifconfig` arguments that add an address to a utun interface.
///
/// A utun is point-to-point, so the IPv4 form names the address as its own
/// peer; `alias` lets several coexist on the one interface.
fn address_add_args(name: &str, cidr: Cidr) -> Vec<String> {
match cidr.addr {
IpAddr::V4(addr) => vec![
name.into(),
"inet".into(),
format!("{addr}/{}", cidr.prefix_len),
addr.to_string(),
"alias".into(),
],
IpAddr::V6(addr) => vec![
name.into(),
"inet6".into(),
format!("{addr}/{}", cidr.prefix_len),
"alias".into(),
],
}
}
/// The `ifconfig` arguments that remove an address from a utun interface.
fn address_del_args(name: &str, cidr: Cidr) -> Vec<String> {
let family = match cidr.addr {
IpAddr::V4(_) => "inet",
IpAddr::V6(_) => "inet6",
};
vec![
name.into(),
family.into(),
cidr.addr.to_string(),
"-alias".into(),
]
}
/// The `route` arguments that point a CIDR's subnet at the interface.
fn route_add_args(name: &str, cidr: Cidr) -> Vec<String> {
let family = match cidr.addr {
IpAddr::V4(_) => "-inet",
IpAddr::V6(_) => "-inet6",
};
vec![
"-q".into(),
"-n".into(),
"add".into(),
family.into(),
"-net".into(),
format!("{}/{}", network_of(cidr), cidr.prefix_len),
"-interface".into(),
name.into(),
]
}
/// The `route` arguments that remove that subnet route.
fn route_del_args(name: &str, cidr: Cidr) -> Vec<String> {
let family = match cidr.addr {
IpAddr::V4(_) => "-inet",
IpAddr::V6(_) => "-inet6",
};
vec![
"-q".into(),
"-n".into(),
"delete".into(),
family.into(),
"-net".into(),
format!("{}/{}", network_of(cidr), cidr.prefix_len),
"-interface".into(),
name.into(),
]
}
async fn address_add(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
ifconfig(address_add_args(name, cidr)).await
}
async fn address_del(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
ifconfig(address_del_args(name, cidr)).await
}
async fn route_add(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
if is_host_route(cidr) {
return Ok(());
}
route(route_add_args(name, cidr)).await
}
async fn route_del(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
if is_host_route(cidr) {
return Ok(());
}
route(route_del_args(name, cidr)).await
}
async fn set_mtu(name: &str, mtu: u32) -> Result<(), OverlayError> {
ifconfig(vec![name.into(), "mtu".into(), mtu.to_string()]).await
}
async fn set_up(name: &str) -> Result<(), OverlayError> {
ifconfig(vec![name.into(), "up".into()]).await
}
/// Runs `/sbin/ifconfig`, turning a non-zero exit into an error with its text.
async fn ifconfig(args: Vec<String>) -> Result<(), OverlayError> {
run("/sbin/ifconfig", args).await
}
/// Runs `/sbin/route`, turning a non-zero exit into an error with its text.
async fn route(args: Vec<String>) -> Result<(), OverlayError> {
run("/sbin/route", args).await
}
/// Runs a system tool by absolute path, so nothing on `PATH` can stand in for
/// it, and turns a non-zero exit into an error carrying what it said.
async fn run(program: &'static str, args: Vec<String>) -> Result<(), OverlayError> {
let display = format!("{program} {}", args.join(" "));
let output = tokio::task::spawn_blocking(move || {
std::process::Command::new(program).args(&args).output()
})
.await
.map_err(|err| OverlayError::Other(format!("could not run {display}: {err}")))?
.map_err(|err| OverlayError::Unavailable(format!("could not run {display}: {err}")))?;
if output.status.success() {
return Ok(());
}
let message = {
let stderr = String::from_utf8_lossy(&output.stderr);
let text = if stderr.trim().is_empty() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
stderr.trim().to_string()
};
if text.is_empty() {
format!("exit code {}", output.status)
} else {
text
}
};
Err(OverlayError::Unavailable(format!(
"{display} failed: {message}"
)))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn v4(last: u8, prefix: u8) -> Cidr {
Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, last)), prefix).unwrap()
}
fn v6(last: u16, prefix: u8) -> Cidr {
Cidr::new(
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, last)),
prefix,
)
.unwrap()
}
#[test]
fn a_v4_address_is_added_point_to_point_with_an_alias() {
let args = address_add_args("utun5", v4(69, 24));
assert_eq!(
args,
vec!["utun5", "inet", "10.13.37.69/24", "10.13.37.69", "alias"]
);
}
#[test]
fn a_v6_address_is_added_with_a_prefix_and_an_alias() {
let args = address_add_args("utun5", v6(1, 128));
assert_eq!(args, vec!["utun5", "inet6", "fd00::1/128", "alias"]);
}
#[test]
fn deleting_an_address_names_the_family_and_removes_the_alias() {
let args = address_del_args("utun5", v4(69, 24));
assert_eq!(args, vec!["utun5", "inet", "10.13.37.69", "-alias"]);
let args = address_del_args("utun5", v6(1, 128));
assert_eq!(args, vec!["utun5", "inet6", "fd00::1", "-alias"]);
}
#[test]
fn a_subnet_route_points_the_masked_network_at_the_interface() {
let args = route_add_args("utun5", v4(69, 24));
assert_eq!(
args,
vec![
"-q",
"-n",
"add",
"-inet",
"-net",
"10.13.37.0/24",
"-interface",
"utun5"
]
);
}
#[test]
fn a_host_address_installs_no_subnet_route() {
assert!(is_host_route(v4(69, 32)));
assert!(is_host_route(v6(1, 128)));
assert!(!is_host_route(v4(69, 24)));
assert!(!is_host_route(v6(1, 64)));
}
#[test]
fn the_network_address_clears_the_host_bits() {
assert_eq!(
network_of(v4(69, 24)),
IpAddr::V4(Ipv4Addr::new(10, 13, 37, 0))
);
assert_eq!(
network_of(Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 69)), 16).unwrap()),
IpAddr::V4(Ipv4Addr::new(10, 13, 0, 0))
);
assert_eq!(
network_of(v6(0x1234, 64)),
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0))
);
}
#[test]
fn link_local_addresses_are_not_ours_to_manage() {
assert!(is_link_local(IpAddr::V6(Ipv6Addr::new(
0xfe80, 0, 0, 0, 0, 0, 0, 1
))));
assert!(is_link_local(IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1))));
assert!(!is_link_local(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 1))));
assert!(!is_link_local(IpAddr::V6(Ipv6Addr::new(
0xfd00, 0, 0, 0, 0, 0, 0, 1
))));
}
}
@@ -9,7 +9,7 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::BoxFuture;
use crate::dataplane::PluginError;
use crate::overlay::OverlayError;
use super::super::config::Cidr;
use super::super::tun::{MemoryTun, MemoryTunFactory, TunFactory, TunRequest};
@@ -125,9 +125,9 @@ impl MockProvisioner {
}
}
fn apply(&self, plan: &InterfacePlan) -> Result<Changes, PluginError> {
fn apply(&self, plan: &InterfacePlan) -> Result<Changes, OverlayError> {
if let Some(reason) = &self.failure {
return Err(PluginError::Unavailable(reason.clone()));
return Err(OverlayError::Unavailable(reason.clone()));
}
let ours = self.owned().iter().any(|name| name == &plan.name);
@@ -179,7 +179,7 @@ impl InterfaceProvisioner for MockProvisioner {
fn reconcile<'a>(
&'a self,
plan: &'a InterfacePlan,
) -> BoxFuture<'a, Result<Provisioned, PluginError>> {
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
Box::pin(async move {
let changes = self.apply(plan)?;
let device = if changes.create_link {
@@ -195,7 +195,7 @@ impl InterfaceProvisioner for MockProvisioner {
})
}
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), PluginError>> {
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
Box::pin(async move {
self.host.lock().remove(name);
self.owned().retain(|owned| owned != name);
@@ -20,9 +20,13 @@
//! pure and tested on every platform, and only the execution is behind
//! [`InterfaceProvisioner`].
//!
//! Three implementations:
//! Five implementations:
//!
//! * `NetlinkProvisioner` on Linux, which needs `CAP_NET_ADMIN`.
//! * `WintunProvisioner` on Windows, a Wintun adapter configured with `netsh`,
//! which needs an elevated process.
//! * `UtunProvisioner` on macOS, a utun interface configured with `ifconfig`
//! and `route`, which needs root.
//! * [`MockProvisioner`], an in-memory host used by the tests.
//! * [`UnsupportedProvisioner`] elsewhere, which fails with an explanation
//! and a pointer at the manual route rather than pretending to work.
@@ -38,7 +42,7 @@ use std::collections::BTreeSet;
use std::sync::Arc;
use crate::BoxFuture;
use crate::dataplane::PluginError;
use crate::overlay::OverlayError;
use super::config::Cidr;
use super::tun::TunDevice;
@@ -50,6 +54,18 @@ pub use mock::{MockHost, MockProvisioner};
mod linux;
#[cfg(all(feature = "tun-device", target_os = "linux"))]
pub use linux::NetlinkProvisioner;
#[cfg(all(feature = "tun-device", target_os = "linux"))]
pub(crate) use linux::RouteHandle;
#[cfg(all(feature = "tun-device", target_os = "windows"))]
mod windows;
#[cfg(all(feature = "tun-device", target_os = "windows"))]
pub use windows::WintunProvisioner;
#[cfg(all(feature = "tun-device", target_os = "macos"))]
mod macos;
#[cfg(all(feature = "tun-device", target_os = "macos"))]
pub use macos::UtunProvisioner;
mod privilege;
pub use privilege::{Privilege, probe_net_admin};
@@ -203,19 +219,19 @@ pub fn plan_changes(
current: &InterfaceState,
plan: &InterfacePlan,
ours: bool,
) -> Result<Changes, PluginError> {
) -> Result<Changes, OverlayError> {
let mut changes = Changes::default();
match &current.kind {
LinkKind::Foreign(kind) => {
return Err(PluginError::Unavailable(format!(
return Err(OverlayError::Unavailable(format!(
"`{}` already exists and is a {kind} interface, not one of ours. \
Refusing to touch it. Run with a different interface prefix.",
plan.name
)));
}
LinkKind::Tun if !ours && current.attached => {
return Err(PluginError::Unavailable(format!(
return Err(OverlayError::Unavailable(format!(
"`{}` already exists and another process is attached to it. \
That is most likely a second agent on this host in the same \
network; give one of them a different interface prefix.",
@@ -293,13 +309,13 @@ pub trait InterfaceProvisioner: Send + Sync + std::fmt::Debug + 'static {
fn reconcile<'a>(
&'a self,
plan: &'a InterfacePlan,
) -> BoxFuture<'a, Result<Provisioned, PluginError>>;
) -> BoxFuture<'a, Result<Provisioned, OverlayError>>;
/// Removes an interface this provisioner created.
///
/// Removing one that is already gone succeeds: this runs on the shutdown
/// path, where the interface having vanished is the desired outcome.
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), PluginError>>;
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>>;
}
#[cfg(test)]
@@ -15,7 +15,7 @@
//! Grant it with:
//!
//! ```text
//! sudo setcap cap_net_admin+p /usr/local/bin/tsunagi
//! sudo setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsunagi
//! ```
//!
//! `+p` rather than `+ep`: with `+p` the capability is permitted but not
@@ -49,7 +49,7 @@ impl Privilege {
/// How to obtain it, for a diagnostic.
pub fn how_to_grant(program: &str) -> String {
format!(
"Grant it once with `sudo setcap cap_net_admin+p {program}` \
"Grant it once with `sudo setcap cap_net_admin,cap_net_bind_service+p {program}` \
and the agent manages its own interface. Without it, run with \
`--no-tun`: the tunnels still form, they just do not reach the \
operating system."
@@ -60,10 +60,24 @@ impl Privilege {
#[cfg(all(feature = "tun-device", target_os = "linux"))]
pub use linux_impl::{NetAdmin, probe_net_admin};
#[cfg(not(all(feature = "tun-device", target_os = "linux")))]
#[cfg(all(feature = "tun-device", target_os = "windows"))]
pub use windows_impl::probe_net_admin;
#[cfg(all(feature = "tun-device", target_os = "macos"))]
pub use macos_impl::probe_net_admin;
#[cfg(not(any(
all(feature = "tun-device", target_os = "linux"),
all(feature = "tun-device", target_os = "windows"),
all(feature = "tun-device", target_os = "macos")
)))]
pub use other_impl::probe_net_admin;
#[cfg(not(all(feature = "tun-device", target_os = "linux")))]
#[cfg(not(any(
all(feature = "tun-device", target_os = "linux"),
all(feature = "tun-device", target_os = "windows"),
all(feature = "tun-device", target_os = "macos")
)))]
mod other_impl {
use super::Privilege;
@@ -73,12 +87,50 @@ mod other_impl {
}
}
#[cfg(all(feature = "tun-device", target_os = "windows"))]
mod windows_impl {
use super::Privilege;
/// Whether this process can configure interfaces.
///
/// Windows has no capability to hold and lower the way Linux does:
/// creating an adapter simply needs the process to be elevated. Reading
/// whether it *is* elevated means inspecting the process token through a
/// raw call this crate forbids, so the probe is deliberately optimistic —
/// it reports that the platform can manage interfaces — and the real
/// check is left to Wintun's adapter creation, which fails with a precise
/// message when the process is not elevated. This matches how the Linux
/// path treats the open itself as the honest answer.
pub fn probe_net_admin() -> Privilege {
Privilege::Available
}
}
#[cfg(all(feature = "tun-device", target_os = "macos"))]
mod macos_impl {
use super::Privilege;
/// Whether this process can configure interfaces.
///
/// macOS has no capability to hold and lower the way Linux does: creating
/// a utun interface simply needs the process to be root. Reading the
/// effective uid means a libc call this crate forbids, so the probe is
/// deliberately optimistic — it reports that the platform can manage
/// interfaces — and the real check is left to the utun open, which fails
/// with a precise "run with sudo" message when the process is not root.
/// This matches how the Linux path treats the open itself as the honest
/// answer.
pub fn probe_net_admin() -> Privilege {
Privilege::Available
}
}
#[cfg(all(feature = "tun-device", target_os = "linux"))]
mod linux_impl {
use caps::{CapSet, Capability};
use super::Privilege;
use crate::dataplane::PluginError;
use crate::overlay::OverlayError;
/// Whether this thread holds `CAP_NET_ADMIN` in its permitted set.
pub fn probe_net_admin() -> Privilege {
@@ -108,16 +160,16 @@ mod linux_impl {
impl NetAdmin {
/// Raises `CAP_NET_ADMIN` into the effective set.
pub fn acquire() -> Result<Self, PluginError> {
pub fn acquire() -> Result<Self, OverlayError> {
let already = caps::has_cap(None, CapSet::Effective, Capability::CAP_NET_ADMIN)
.map_err(|err| {
PluginError::Unavailable(format!("cannot read capabilities: {err}"))
OverlayError::Unavailable(format!("cannot read capabilities: {err}"))
})?;
if already {
return Ok(Self { raised: false });
}
caps::raise(None, CapSet::Effective, Capability::CAP_NET_ADMIN).map_err(|err| {
PluginError::Unavailable(format!(
OverlayError::Unavailable(format!(
"cannot raise CAP_NET_ADMIN: {err}. {}",
Privilege::how_to_grant("tsunagi")
))
@@ -153,7 +205,9 @@ mod tests {
#[test]
fn the_grant_instructions_name_the_program() {
let text = Privilege::how_to_grant("/usr/local/bin/tsunagi");
assert!(text.contains("setcap cap_net_admin+p /usr/local/bin/tsunagi"));
assert!(
text.contains("setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsunagi")
);
assert!(text.contains("--no-tun"), "the fallback is offered too");
}
@@ -7,7 +7,7 @@
//! of provisioning and says what to do instead.
use crate::BoxFuture;
use crate::dataplane::PluginError;
use crate::overlay::OverlayError;
use super::{InterfacePlan, InterfaceProvisioner, Provisioned};
@@ -31,8 +31,8 @@ impl UnsupportedProvisioner {
}
}
fn refusal(&self) -> PluginError {
PluginError::Unavailable(format!(
fn refusal(&self) -> OverlayError {
OverlayError::Unavailable(format!(
"managing the overlay interface is not implemented on {} yet. \
Run with `--no-tun` until it is: the tunnels still form, they just \
do not reach the operating system.",
@@ -49,11 +49,11 @@ impl InterfaceProvisioner for UnsupportedProvisioner {
fn reconcile<'a>(
&'a self,
_plan: &'a InterfacePlan,
) -> BoxFuture<'a, Result<Provisioned, PluginError>> {
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
Box::pin(async move { Err(self.refusal()) })
}
fn remove<'a>(&'a self, _name: &'a str) -> BoxFuture<'a, Result<(), PluginError>> {
fn remove<'a>(&'a self, _name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
// Nothing was ever created, so there is nothing to clean up and no
// reason to fail a shutdown path.
Box::pin(async move { Ok(()) })
@@ -0,0 +1,513 @@
//! Managing the overlay interface on Windows, through Wintun and `netsh`.
//!
//! The shape is the same as the Linux provisioner: observe what is there,
//! [`plan_changes`](super::plan_changes) to decide what to do, apply the
//! difference. Only the two mechanisms differ.
//!
//! # Creating the interface
//!
//! The interface is a Wintun adapter, created through the `tun` dependency's
//! safe wrapper — [`open_tun`](super::super::tun) — because loading
//! `wintun.dll` is an `unsafe` call and this crate forbids `unsafe`. The
//! adapter is created, not made persistent: closing the last handle to it
//! removes it, so it goes away when the agent does, however the agent ends.
//! The reader and writer inside the device hold that handle, so the adapter
//! lives exactly as long as the device does.
//!
//! # Configuring it
//!
//! Addresses and MTU are applied with `netsh`. On Linux the same work is done
//! in process over a netlink socket; here there is no equivalent that this
//! crate may call, because the in-process route is the IP Helper API and that
//! is `unsafe`. `netsh` is the supported tool for the job, it is invoked by
//! absolute path from `%SystemRoot%` so nothing on `PATH` can stand in for it,
//! and every value handed to it is one this agent derived — an interface name
//! from the network id, an address it allocated — never anything a peer said.
//!
//! # Observing it
//!
//! The interface table is read through `netdev`, whose enumeration is a safe
//! wrapper over the same IP Helper API. An adapter is matched by the friendly
//! name it was created with. A match that carries a default gateway is treated
//! as foreign and left alone: a tsunagi overlay interface never has one, so a
//! gateway is the mark of a real device that happens to share the name.
//!
//! # What Windows cannot do that Linux can
//!
//! Wintun exposes no way to delete an adapter this process did not create, so
//! a leftover from a run that was killed is *reused* rather than replaced: its
//! addresses are flushed and the planned ones applied, which reaches the same
//! end state. A reused adapter is not removed on exit, only closed — that is a
//! Wintun limitation, and the common case of a clean start and a clean stop is
//! unaffected.
use std::net::{IpAddr, Ipv4Addr};
use std::sync::{Arc, Mutex};
use crate::BoxFuture;
use crate::overlay::OverlayError;
use super::super::config::Cidr;
use super::super::tun::{TunDevice, TunRequest, open_tun};
use super::privilege::{Privilege, probe_net_admin};
use super::{
InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, Provisioned, plan_changes,
};
/// Manages the overlay interface with Wintun and `netsh`.
#[derive(Debug)]
pub struct WintunProvisioner {
/// Interfaces this process created, so they are adjusted rather than
/// replaced. See [`plan_changes`].
ours: Mutex<Vec<String>>,
/// Devices kept alive for as long as the interface should exist. Dropping
/// one closes the adapter, which removes it if this process created it.
held: Mutex<Vec<(String, Arc<dyn TunDevice>)>>,
}
impl WintunProvisioner {
/// Creates the provisioner, after checking the platform supports one.
///
/// Whether the process is actually elevated is not checked here — that
/// cannot be read without an `unsafe` call — so a missing privilege
/// surfaces at adapter creation with a precise message, the same way the
/// Linux path treats the open itself as the honest answer.
pub fn new() -> Result<Self, OverlayError> {
match probe_net_admin() {
Privilege::Available => {}
Privilege::Missing(reason) => return Err(OverlayError::Unavailable(reason)),
Privilege::Unsupported => {
return Err(OverlayError::Unavailable(
"interface management is not compiled in".to_string(),
));
}
}
Ok(Self {
ours: Mutex::new(Vec::new()),
held: Mutex::new(Vec::new()),
})
}
fn is_ours(&self, name: &str) -> bool {
lock(&self.ours)
.iter()
.any(|owned| owned.eq_ignore_ascii_case(name))
}
/// Opens the Wintun adapter, which is what creates it.
fn create_device(&self, plan: &InterfacePlan) -> Result<Arc<dyn TunDevice>, OverlayError> {
let request = TunRequest::bare(plan.name.clone(), plan.mtu);
open_tun(&request)
}
}
impl InterfaceProvisioner for WintunProvisioner {
fn name(&self) -> &str {
"wintun"
}
fn reconcile<'a>(
&'a self,
plan: &'a InterfacePlan,
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
Box::pin(async move {
let current = observe(&plan.name).await;
let changes = plan_changes(&current, plan, self.is_ours(&plan.name))?;
// Wintun cannot delete an adapter this process did not create, so a
// "delete then create" from `plan_changes` becomes "flush then
// reuse": clear the stale addresses off the interface that is
// there, and let the create step below reopen the same adapter.
// The end state — an adapter carrying exactly the plan's addresses
// — is identical.
let flush: Vec<Cidr> = if changes.delete_link {
tracing::info!(
interface = %plan.name,
"reusing an abandoned interface left by an earlier run"
);
current.addresses.clone()
} else {
Vec::new()
};
for cidr in flush.iter().chain(changes.remove.iter()) {
// Best effort: an address that is already gone is the outcome
// wanted, and refusing to start over one is not worth it.
if let Err(err) = address_del(&plan.name, *cidr).await {
tracing::debug!(interface = %plan.name, %cidr, %err, "could not remove address");
}
}
let device = if changes.create_link {
let device = self.create_device(plan)?;
lock(&self.ours).push(plan.name.clone());
lock(&self.held).push((plan.name.clone(), Arc::clone(&device)));
Some(device)
} else {
None
};
// A failure past this point leaves an interface that exists but
// cannot carry traffic. Undo the bookkeeping for one we just
// created so it is not mistaken for a working interface, and drop
// the device so the adapter closes.
let configured = configure(plan, &changes).await;
if let Err(err) = configured {
if changes.create_link {
lock(&self.held).retain(|(held, _)| held != &plan.name);
lock(&self.ours).retain(|owned| owned != &plan.name);
}
return Err(err);
}
Ok(Provisioned { changes, device })
})
}
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
Box::pin(async move {
// Dropping the device closes the adapter, which removes it if this
// process created it. A reused adapter is only closed, not removed;
// flushing its addresses first keeps no stale configuration behind.
let current = observe(name).await;
for cidr in &current.addresses {
if let Err(err) = address_del(name, *cidr).await {
tracing::debug!(interface = %name, %cidr, %err, "could not remove address");
}
}
lock(&self.held).retain(|(held, _)| !held.eq_ignore_ascii_case(name));
lock(&self.ours).retain(|owned| !owned.eq_ignore_ascii_case(name));
Ok(())
})
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
/// `fe80::/10` and `169.254.0.0/16`, which Windows assigns on its own.
fn is_link_local(addr: IpAddr) -> bool {
match addr {
IpAddr::V4(addr) => addr.is_link_local(),
IpAddr::V6(addr) => (addr.segments()[0] & 0xffc0) == 0xfe80,
}
}
/// What the interface named `name` currently looks like.
///
/// Never fails: a name nothing is using reads as [`InterfaceState::absent`],
/// which is what the plan then acts on.
async fn observe(name: &str) -> InterfaceState {
let name = name.to_string();
let found = tokio::task::spawn_blocking(move || {
netdev::get_interfaces()
.into_iter()
.find(|iface| match &iface.friendly_name {
Some(friendly) => friendly.eq_ignore_ascii_case(&name),
None => false,
})
})
.await
.ok()
.flatten();
let Some(iface) = found else {
return InterfaceState::absent();
};
let mut addresses: Vec<Cidr> = Vec::new();
for net in &iface.ipv4 {
if let Ok(cidr) = Cidr::new(IpAddr::V4(net.addr()), net.prefix_len())
&& !is_link_local(cidr.addr)
{
addresses.push(cidr);
}
}
for net in &iface.ipv6 {
if let Ok(cidr) = Cidr::new(IpAddr::V6(net.addr()), net.prefix_len())
&& !is_link_local(cidr.addr)
{
addresses.push(cidr);
}
}
addresses.sort();
// A tsunagi overlay interface only ever carries the addresses it was given
// and no gateway. One that has a gateway is a real device sharing the name,
// so it is foreign and never touched.
let kind = if iface.gateway.is_some() {
LinkKind::Foreign("gatewayed".to_string())
} else {
LinkKind::Tun
};
InterfaceState {
kind,
// Wintun reports an adapter with a live session as up and an abandoned
// one as down, so "up" doubles as "some process is holding it".
attached: iface.is_up(),
up: iface.is_up(),
mtu: iface.mtu.unwrap_or(0),
addresses,
}
}
/// Applies MTU, link state and addresses.
async fn configure(plan: &InterfacePlan, changes: &super::Changes) -> Result<(), OverlayError> {
if let Some(mtu) = changes.set_mtu {
set_mtu(&plan.name, mtu).await?;
}
for cidr in &changes.add {
if let Err(err) = address_add(&plan.name, *cidr).await {
let err_str = err.to_string();
if !err_str.contains("already exists") && !err_str.contains("0x80071392") {
return Err(err);
}
}
}
if changes.bring_up {
// Best effort: a Wintun adapter with a session is up already, and a
// failure to nudge it is not a reason to fail the whole reconcile.
if let Err(err) = set_up(&plan.name).await {
tracing::debug!(interface = %plan.name, %err, "could not enable the interface");
}
}
Ok(())
}
/// The dotted netmask for an IPv4 prefix length.
fn ipv4_mask(prefix_len: u8) -> Ipv4Addr {
if prefix_len == 0 {
Ipv4Addr::UNSPECIFIED
} else {
Ipv4Addr::from(u32::MAX << (32 - u32::from(prefix_len)))
}
}
/// The `netsh` arguments that add an address to an interface.
fn address_add_args(name: &str, cidr: Cidr) -> Vec<String> {
match cidr.addr {
IpAddr::V4(addr) => vec![
"interface".into(),
"ipv4".into(),
"add".into(),
"address".into(),
format!("name={name}"),
format!("address={addr}"),
format!("mask={}", ipv4_mask(cidr.prefix_len)),
"store=active".into(),
],
IpAddr::V6(addr) => vec![
"interface".into(),
"ipv6".into(),
"add".into(),
"address".into(),
format!("interface={name}"),
format!("address={addr}/{}", cidr.prefix_len),
"store=active".into(),
],
}
}
/// The `netsh` arguments that remove an address from an interface.
fn address_del_args(name: &str, cidr: Cidr) -> Vec<String> {
match cidr.addr {
IpAddr::V4(addr) => vec![
"interface".into(),
"ipv4".into(),
"delete".into(),
"address".into(),
format!("name={name}"),
format!("address={addr}"),
"store=active".into(),
],
IpAddr::V6(addr) => vec![
"interface".into(),
"ipv6".into(),
"delete".into(),
"address".into(),
format!("interface={name}"),
format!("address={addr}"),
"store=active".into(),
],
}
}
/// The `netsh` arguments that set an interface's MTU, one call per family.
fn mtu_args(name: &str, family: &str, mtu: u32) -> Vec<String> {
vec![
"interface".into(),
family.into(),
"set".into(),
"subinterface".into(),
name.into(),
format!("mtu={mtu}"),
"store=active".into(),
]
}
async fn address_add(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
netsh(address_add_args(name, cidr)).await
}
async fn address_del(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
netsh(address_del_args(name, cidr)).await
}
/// Sets the MTU on both families.
///
/// A family that is turned off on the adapter cannot take an MTU, so this
/// fails only when *neither* would — one family carrying the plan's MTU is
/// enough, and refusing the whole interface because the other is disabled
/// would be wrong.
async fn set_mtu(name: &str, mtu: u32) -> Result<(), OverlayError> {
let v4 = netsh(mtu_args(name, "ipv4", mtu)).await;
let v6 = netsh(mtu_args(name, "ipv6", mtu)).await;
match (&v4, &v6) {
(Err(_), Err(_)) => v4,
_ => {
if let Err(err) = &v4 {
tracing::debug!(interface = %name, %err, "could not set the IPv4 MTU");
}
if let Err(err) = &v6 {
tracing::debug!(interface = %name, %err, "could not set the IPv6 MTU");
}
Ok(())
}
}
}
async fn set_up(name: &str) -> Result<(), OverlayError> {
netsh(vec![
"interface".into(),
"set".into(),
"interface".into(),
format!("name={name}"),
"admin=enabled".into(),
])
.await
}
/// Runs `netsh` from `%SystemRoot%\System32`, so nothing on `PATH` can stand
/// in for it, and turns a non-zero exit into an error carrying what it said.
async fn netsh(args: Vec<String>) -> Result<(), OverlayError> {
let program = system32("netsh.exe");
let display = format!("netsh {}", args.join(" "));
let output = tokio::task::spawn_blocking(move || {
std::process::Command::new(program).args(&args).output()
})
.await
.map_err(|err| OverlayError::Other(format!("could not run {display}: {err}")))?
.map_err(|err| OverlayError::Unavailable(format!("could not run {display}: {err}")))?;
if output.status.success() {
return Ok(());
}
let message = {
let stderr = String::from_utf8_lossy(&output.stderr);
let text = if stderr.trim().is_empty() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
stderr.trim().to_string()
};
if text.is_empty() {
format!("exit code {}", output.status)
} else {
text
}
};
Err(OverlayError::Unavailable(format!(
"{display} failed: {message}"
)))
}
/// The absolute path to a program in `System32`.
fn system32(exe: &str) -> std::path::PathBuf {
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into());
std::path::Path::new(&root).join("System32").join(exe)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::net::Ipv6Addr;
fn v4(last: u8, prefix: u8) -> Cidr {
Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, last)), prefix).unwrap()
}
fn v6(last: u16) -> Cidr {
Cidr::new(
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, last)),
64,
)
.unwrap()
}
#[test]
fn a_prefix_length_becomes_the_right_dotted_mask() {
assert_eq!(ipv4_mask(24), Ipv4Addr::new(255, 255, 255, 0));
assert_eq!(ipv4_mask(16), Ipv4Addr::new(255, 255, 0, 0));
assert_eq!(ipv4_mask(32), Ipv4Addr::new(255, 255, 255, 255));
assert_eq!(ipv4_mask(0), Ipv4Addr::UNSPECIFIED);
}
#[test]
fn a_v4_address_is_added_by_name_with_a_dotted_mask() {
let args = address_add_args("tsun0", v4(69, 24));
assert!(args.contains(&"ipv4".to_string()));
assert!(args.contains(&"add".to_string()));
assert!(args.contains(&"name=tsun0".to_string()));
assert!(args.contains(&"address=10.13.37.69".to_string()));
assert!(args.contains(&"mask=255.255.255.0".to_string()));
}
#[test]
fn a_v6_address_is_added_by_interface_with_a_prefix() {
let args = address_add_args("tsun0", v6(1));
assert!(args.contains(&"ipv6".to_string()));
assert!(args.contains(&"interface=tsun0".to_string()));
assert!(args.contains(&"address=fd00::1/64".to_string()));
}
#[test]
fn deleting_an_address_names_no_mask() {
let args = address_del_args("tsun0", v4(69, 24));
assert!(args.contains(&"delete".to_string()));
assert!(args.contains(&"address=10.13.37.69".to_string()));
assert!(!args.iter().any(|arg| arg.starts_with("mask=")));
}
#[test]
fn the_mtu_is_set_per_family_on_the_subinterface() {
let args = mtu_args("tsun0", "ipv4", 1280);
assert!(args.contains(&"subinterface".to_string()));
assert!(args.contains(&"tsun0".to_string()));
assert!(args.contains(&"mtu=1280".to_string()));
}
#[test]
fn link_local_addresses_are_not_ours_to_manage() {
assert!(is_link_local(IpAddr::V6(Ipv6Addr::new(
0xfe80, 0, 0, 0, 0, 0, 0, 1
))));
assert!(is_link_local(IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1))));
assert!(!is_link_local(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 1))));
assert!(!is_link_local(IpAddr::V6(Ipv6Addr::new(
0xfd00, 0, 0, 0, 0, 0, 0, 1
))));
}
#[test]
fn system32_is_absolute_and_ends_with_the_program() {
let path = system32("netsh.exe");
assert!(path.is_absolute(), "{path:?}");
assert!(path.ends_with("netsh.exe"), "{path:?}");
}
}
+576
View File
@@ -0,0 +1,576 @@
//! Which peer a packet is for, and whether a peer may have sent it.
//!
//! One agent has one interface, so a packet coming off it could belong to any
//! network the agent is in and to any protocol currently carrying traffic.
//! Deciding which is this table's job, and it is the reason the interface
//! belongs to the system level: the answer comes from signed state, which no
//! protocol owns.
//!
//! Two questions, and they are not the same one:
//!
//! * **Outbound**: this destination address — whose is it? A packet for
//! nobody is dropped rather than broadcast.
//! * **Inbound**: this peer decrypted a packet with this source address —
//! is that address actually its? A peer may not speak for anybody else,
//! and the check consults the signed claim rather than anything the peer
//! said.
//!
//! Nothing here is derived from a protocol's key. An address is allocated at
//! the system level and signed by the member that holds it, so every protocol
//! carries traffic for the same addresses and the table is the same whichever
//! one is in use.
use super::broadcast::{BroadcastPolicy, BroadcastTable};
use arc_swap::ArcSwap;
use std::collections::HashMap;
use std::net::Ipv4Addr;
use std::sync::{Arc, RwLock};
use iroh::EndpointId;
use crate::identity::NetworkId;
use crate::state::Ipv4Range;
/// Where an outbound packet is going.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Route {
/// The network the destination belongs to.
pub network: NetworkId,
/// The member that holds the destination address.
pub peer: EndpointId,
}
/// What one network contributes to the table.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NetworkRoutes {
/// Local broadcast policy and authenticated willing recipients.
pub broadcast: BroadcastPolicy,
/// The range this network allocates from, once it has agreed one.
pub range: Option<Ipv4Range>,
/// This agent's own address in the network.
pub local: Option<Ipv4Addr>,
/// Every other member's address, as the signed state has it.
pub peers: Vec<(Ipv4Addr, EndpointId)>,
}
/// Why a network cannot be added to the table.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RouteError {
/// Two networks would answer for the same addresses.
#[error(
"network {network} uses {range}, which overlaps {other_range} already in use by \
{other}. One agent has one interface, so an address can belong to only one of \
them: give one network a different range."
)]
Overlap {
/// The network being added.
network: NetworkId,
/// The range it wants.
range: Ipv4Range,
/// The network already using an overlapping range.
other: NetworkId,
/// That network's range.
other_range: Ipv4Range,
},
}
/// Address ownership across every network this agent is in.
#[derive(Debug, Default)]
pub struct RoutingTable {
broadcast: ArcSwap<BroadcastTable>,
networks: RwLock<HashMap<NetworkId, NetworkRoutes>>,
}
impl RoutingTable {
/// An empty table.
pub fn new() -> Self {
Self::default()
}
fn read(&self) -> std::sync::RwLockReadGuard<'_, HashMap<NetworkId, NetworkRoutes>> {
match self.networks.read() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn write(&self) -> std::sync::RwLockWriteGuard<'_, HashMap<NetworkId, NetworkRoutes>> {
match self.networks.write() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
/// Replaces what one network contributes.
///
/// Rejected when its range overlaps another network's. With one
/// interface an address can belong to only one network, and guessing
/// which would send somebody's traffic to a stranger.
pub fn set_network(&self, network: NetworkId, routes: NetworkRoutes) -> Result<(), RouteError> {
let mut networks = self.write();
if let Some(range) = routes.range {
for (other, existing) in networks.iter() {
if *other == network {
continue;
}
let Some(other_range) = existing.range else {
continue;
};
if ranges_overlap(range, other_range) {
return Err(RouteError::Overlap {
network,
range,
other: *other,
other_range,
});
}
}
}
networks.insert(network, routes);
self.broadcast
.store(Arc::new(BroadcastTable::build(&networks)));
Ok(())
}
/// Forgets a network.
pub fn remove_network(&self, network: NetworkId) {
let mut networks = self.write();
networks.remove(&network);
self.broadcast
.store(Arc::new(BroadcastTable::build(&networks)));
}
/// Replaces only a network's broadcast participation, without changing addresses.
pub fn set_broadcast(&self, network: NetworkId, policy: BroadcastPolicy) {
let mut networks = self.write();
if let Some(routes) = networks.get_mut(&network) {
if routes.broadcast == policy {
return;
}
routes.broadcast = policy;
self.broadcast
.store(Arc::new(BroadcastTable::build(&networks)));
}
}
/// Recognizes limited and configured subnet broadcasts before unicast lookup.
pub fn is_broadcast(&self, destination: Ipv4Addr) -> bool {
self.broadcast.load().is_destination(destination)
}
/// A precomputed domain-scoped recipient list; no per-packet graph search.
pub fn broadcast_recipients(
&self,
source: Ipv4Addr,
destination: Ipv4Addr,
) -> Option<Arc<[Route]>> {
self.broadcast.load().outgoing(source, destination)
}
/// Local admission after the plugin authenticated and decrypted the sender.
pub fn accepts_broadcast(
&self,
network: NetworkId,
peer: EndpointId,
destination: Ipv4Addr,
) -> bool {
self.broadcast.load().accepts(network, peer, destination)
}
/// The peer that holds a destination address, if anybody does.
///
/// This agent's own address returns `None`: a packet for ourselves does
/// not go over a tunnel, and answering with a peer would send it to one.
pub fn route(&self, destination: Ipv4Addr) -> Option<Route> {
let networks = self.read();
for (network, routes) in networks.iter() {
if routes.local == Some(destination) {
return None;
}
if let Some((_, peer)) = routes
.peers
.iter()
.find(|(address, _)| *address == destination)
{
return Some(Route {
network: *network,
peer: *peer,
});
}
}
None
}
/// Whether a peer may send from a source address.
pub fn may_send_from(&self, network: NetworkId, peer: EndpointId, source: Ipv4Addr) -> bool {
self.read().get(&network).is_some_and(|routes| {
routes
.peers
.iter()
.any(|(address, holder)| *address == source && *holder == peer)
})
}
/// Whether an ordinary packet terminates on this host in this network.
/// Exported LAN subnets must extend this admission policy explicitly later.
pub fn is_local_destination(&self, network: NetworkId, destination: Ipv4Addr) -> bool {
self.read()
.get(&network)
.is_some_and(|routes| routes.local == Some(destination))
}
/// Every address this agent should answer to, with its prefix length.
pub fn local_addresses(&self) -> Vec<(Ipv4Addr, u8)> {
let mut addresses: Vec<(Ipv4Addr, u8)> = self
.read()
.values()
.filter_map(|routes| Some((routes.local?, routes.range?.prefix_len)))
.collect();
addresses.sort();
addresses
}
/// The one network whose address the limited broadcast route is bound to.
///
/// The host has a single `255.255.255.255` route per interface, and one
/// interface carries every network, so only one overlay address can be its
/// source. The choice is the lowest [`NetworkId`] among networks that have
/// broadcast enabled, a range and a local address: deterministic, so two
/// runs with the same networks pick the same one.
pub fn broadcast_host_target(&self) -> Option<(Ipv4Addr, Ipv4Range)> {
let networks = self.read();
networks
.iter()
.filter(|(_, routes)| routes.broadcast.enabled)
.filter_map(|(network, routes)| Some((*network, routes.local?, routes.range?)))
.min_by_key(|(network, _, _)| *network)
.map(|(_, local, range)| (local, range))
}
/// Whether a range would collide with one another network already uses.
///
/// Asked before proposing a range rather than after: with one interface
/// an agent that claimed an address it could not route would also be
/// telling everybody else to use that range, and "the range of the
/// lowest author wins" would spread the collision instead of containing
/// it.
pub fn would_overlap(&self, network: NetworkId, range: Ipv4Range) -> Option<Ipv4Range> {
self.read().iter().find_map(|(other, existing)| {
if *other == network {
return None;
}
let other_range = existing.range?;
ranges_overlap(range, other_range).then_some(other_range)
})
}
/// How many networks the table covers.
pub fn len(&self) -> usize {
self.read().len()
}
/// Whether it covers none.
pub fn is_empty(&self) -> bool {
self.read().is_empty()
}
}
/// Whether two ranges share any address.
fn ranges_overlap(one: Ipv4Range, other: Ipv4Range) -> bool {
// A range contains the other's base, or the other way round. With
// prefix-aligned ranges that is the whole of it: two blocks either nest
// or are disjoint.
one.contains(other.base) || other.contains(one.base)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
fn network(name: &str) -> NetworkId {
NetworkKeys::derive(
&NetworkName::new(name).unwrap(),
&NetworkSecret::from_bytes(vec![4u8; 32]).unwrap(),
)
.network_id()
}
fn peer(seed: u8) -> EndpointId {
iroh::SecretKey::from_bytes(&[seed; 32]).public()
}
fn addr(last: u8) -> Ipv4Addr {
Ipv4Addr::new(10, 13, 37, last)
}
fn routes() -> NetworkRoutes {
NetworkRoutes {
broadcast: Default::default(),
range: Some("10.13.37.0/24".parse().unwrap()),
local: Some(addr(1)),
peers: vec![(addr(2), peer(2)), (addr(3), peer(3))],
}
}
#[test]
fn a_packet_goes_to_the_member_that_holds_its_destination() {
let table = RoutingTable::new();
let id = network("routing");
table.set_network(id, routes()).unwrap();
assert_eq!(
table.route(addr(2)),
Some(Route {
network: id,
peer: peer(2)
})
);
assert_eq!(
table.route(addr(3)),
Some(Route {
network: id,
peer: peer(3)
})
);
}
#[test]
fn a_packet_for_nobody_has_nowhere_to_go() {
// Dropped rather than sent to everybody: a tunnel is not a broadcast
// domain, and flooding would hand one member another's traffic.
let table = RoutingTable::new();
table.set_network(network("routing"), routes()).unwrap();
assert_eq!(table.route(addr(200)), None);
assert_eq!(table.route(Ipv4Addr::new(192, 0, 2, 1)), None);
}
#[test]
fn a_packet_for_ourselves_is_not_sent_to_a_peer() {
let table = RoutingTable::new();
table.set_network(network("routing"), routes()).unwrap();
assert_eq!(table.route(addr(1)), None, "that address is ours");
}
#[test]
fn a_peer_may_only_send_from_the_address_it_holds() {
let table = RoutingTable::new();
let id = network("ownership");
table.set_network(id, routes()).unwrap();
assert!(table.may_send_from(id, peer(2), addr(2)));
// Somebody else's address, and its own network's.
assert!(!table.may_send_from(id, peer(2), addr(3)));
assert!(!table.may_send_from(id, peer(2), addr(1)));
assert!(!table.may_send_from(id, peer(2), addr(99)));
// A peer that holds nothing here.
assert!(!table.may_send_from(id, peer(9), addr(2)));
// And the question is per network, not global.
assert!(!table.may_send_from(network("elsewhere"), peer(2), addr(2)));
}
#[test]
fn two_networks_with_overlapping_ranges_are_refused() {
// One interface means an address belongs to one network. Accepting
// both and guessing would send somebody's traffic to a stranger.
let table = RoutingTable::new();
let first = network("first");
let second = network("second");
table.set_network(first, routes()).unwrap();
let err = table.set_network(second, routes()).unwrap_err();
let RouteError::Overlap { other, .. } = err;
assert_eq!(other, first);
assert!(err.to_string().contains("different range"), "{err}");
assert_eq!(table.len(), 1, "the overlapping network is not added");
// A range of its own is fine.
table
.set_network(
second,
NetworkRoutes {
broadcast: Default::default(),
range: Some("10.99.0.0/16".parse().unwrap()),
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
peers: vec![(Ipv4Addr::new(10, 99, 0, 2), peer(4))],
},
)
.unwrap();
assert_eq!(table.len(), 2);
assert_eq!(
table.route(Ipv4Addr::new(10, 99, 0, 2)),
Some(Route {
network: second,
peer: peer(4)
})
);
// And the first network still answers for its own.
assert_eq!(table.route(addr(2)).map(|route| route.network), Some(first));
}
#[test]
fn the_host_broadcast_target_is_the_lowest_enabled_network_with_an_address() {
let table = RoutingTable::new();
assert_eq!(table.broadcast_host_target(), None);
let (low, high) = {
let (a, b) = (network("first"), network("second"));
if a < b { (a, b) } else { (b, a) }
};
let mk = |enabled: bool, base: [u8; 4], local: Option<Ipv4Addr>| NetworkRoutes {
broadcast: BroadcastPolicy {
enabled,
..Default::default()
},
range: Some(
format!("{}.{}.{}.0/24", base[0], base[1], base[2])
.parse()
.unwrap(),
),
local,
peers: Vec::new(),
};
let a = Ipv4Addr::new(10, 1, 0, 1);
let b = Ipv4Addr::new(10, 2, 0, 1);
// The lower id has broadcast off, so the higher one is chosen.
table
.set_network(low, mk(false, [10, 1, 0, 0], Some(a)))
.unwrap();
table
.set_network(high, mk(true, [10, 2, 0, 0], Some(b)))
.unwrap();
assert_eq!(
table.broadcast_host_target(),
Some((b, "10.2.0.0/24".parse().unwrap()))
);
// Both enabled: the lowest id wins, whatever the insertion order.
table
.set_network(low, mk(true, [10, 1, 0, 0], Some(a)))
.unwrap();
assert_eq!(
table.broadcast_host_target(),
Some((a, "10.1.0.0/24".parse().unwrap()))
);
// No local address yet: it cannot be the route's source.
table
.set_network(low, mk(true, [10, 1, 0, 0], None))
.unwrap();
assert_eq!(
table.broadcast_host_target(),
Some((b, "10.2.0.0/24".parse().unwrap()))
);
table.remove_network(high);
assert_eq!(table.broadcast_host_target(), None);
}
#[test]
fn a_range_can_be_asked_about_before_it_is_proposed() {
// The point of asking first: an agent that claims an address it
// cannot route also tells everybody else to use that range.
let table = RoutingTable::new();
let first = network("first");
let second = network("second");
table.set_network(first, routes()).unwrap();
let mine: Ipv4Range = "10.13.37.0/24".parse().unwrap();
assert_eq!(table.would_overlap(second, mine), Some(mine));
// Its own range is not a collision with itself.
assert_eq!(table.would_overlap(first, mine), None);
assert_eq!(
table.would_overlap(second, "10.99.0.0/16".parse().unwrap()),
None
);
}
#[test]
fn a_nested_range_counts_as_overlapping() {
let table = RoutingTable::new();
table
.set_network(
network("wide"),
NetworkRoutes {
broadcast: Default::default(),
range: Some("10.0.0.0/8".parse().unwrap()),
..Default::default()
},
)
.unwrap();
// Inside the /8, in either direction.
assert!(
table
.set_network(
network("narrow"),
NetworkRoutes {
broadcast: Default::default(),
range: Some("10.13.37.0/24".parse().unwrap()),
..Default::default()
}
)
.is_err()
);
}
#[test]
fn replacing_a_network_does_not_count_as_overlapping_itself() {
let table = RoutingTable::new();
let id = network("refresh");
table.set_network(id, routes()).unwrap();
let mut updated = routes();
updated.peers.push((addr(4), peer(4)));
table.set_network(id, updated).unwrap();
assert_eq!(table.len(), 1);
assert!(table.route(addr(4)).is_some());
}
#[test]
fn forgetting_a_network_takes_its_addresses_with_it() {
let table = RoutingTable::new();
let id = network("leaving");
table.set_network(id, routes()).unwrap();
table.remove_network(id);
assert!(table.is_empty());
assert_eq!(table.route(addr(2)), None);
assert!(!table.may_send_from(id, peer(2), addr(2)));
}
#[test]
fn the_local_addresses_are_what_the_interface_should_carry() {
let table = RoutingTable::new();
table.set_network(network("one"), routes()).unwrap();
table
.set_network(
network("two"),
NetworkRoutes {
broadcast: Default::default(),
range: Some("10.99.0.0/16".parse().unwrap()),
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
peers: Vec::new(),
},
)
.unwrap();
assert_eq!(
table.local_addresses(),
vec![(addr(1), 24), (Ipv4Addr::new(10, 99, 0, 1), 16)]
);
}
#[test]
fn a_network_with_no_address_yet_contributes_nothing() {
let table = RoutingTable::new();
let id = network("waiting");
table.set_network(id, NetworkRoutes::default()).unwrap();
assert!(table.local_addresses().is_empty());
assert_eq!(table.route(addr(2)), None);
assert_eq!(table.len(), 1, "the network is known, it just has nothing");
}
}
+569
View File
@@ -0,0 +1,569 @@
//! The boundary to the operating system's packet interface.
//!
//! One agent has one of these, and it belongs to the agent rather than to
//! any protocol: a packet arriving here is routed to whichever peer owns its
//! destination, over whichever protocol has a link to that peer.
//!
//! Two implementations:
//!
//! * [`MemoryTun`] keeps packets in memory. It needs no privileges at all and
//! is what the test suite uses, so the entire data plane — handshake,
//! encryption, routing — is exercised without touching the host.
//! * `SystemTun`, behind the `tun-device` feature, is a real TUN interface.
//! Creating one needs `CAP_NET_ADMIN`, and it is
//! [`provision`](super::provision) that holds that and creates it.
use std::sync::Arc;
use bytes::Bytes;
use crate::BoxFuture;
use crate::overlay::OverlayError;
/// What a device should look like once created.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TunRequest {
/// Interface name to ask for.
pub name: String,
/// Every overlay address this host answers to, and no others.
///
/// One per network the agent is in — one agent has one interface, and
/// it carries them all. A list rather than one address because
/// carrying only the first is carrying only the first network: the
/// others have addresses the operating system has never heard of, and
/// their traffic goes nowhere.
///
/// Allocated and signed at the system level, never derived from a
/// protocol's key: every protocol carries traffic for the same
/// addresses.
pub addresses: Vec<super::Cidr>,
/// Interface MTU.
pub mtu: u32,
}
impl TunRequest {
/// A request carrying nothing but a name and an MTU.
///
/// Used where the addresses have already been applied to the host, so the
/// device itself only needs opening.
pub fn bare(name: impl Into<String>, mtu: u32) -> Self {
Self {
name: name.into(),
addresses: Vec::new(),
mtu,
}
}
}
/// A packet interface.
///
/// `recv` yields packets the operating system wants sent; `send` delivers
/// packets that arrived from a peer.
pub trait TunDevice: Send + Sync + std::fmt::Debug + 'static {
/// The interface name the operating system actually gave us.
fn name(&self) -> &str;
/// The interface MTU.
fn mtu(&self) -> u32;
/// The next packet the operating system wants to send, or `None` once the
/// device is gone.
fn recv(&self) -> BoxFuture<'_, Option<Bytes>>;
/// Delivers a packet to the operating system.
fn send<'a>(&'a self, packet: Bytes) -> BoxFuture<'a, Result<(), OverlayError>>;
}
/// Creates packet interfaces.
pub trait TunFactory: Send + Sync + std::fmt::Debug + 'static {
/// A short name used in diagnostics.
fn name(&self) -> &str;
/// Whether what this factory creates exists on the host.
///
/// `false` for an in-memory device: it has a name and an MTU and nothing
/// else — no link, no addresses, nothing for the operating system to
/// route through or to attach a resolver setting to. Stated rather than
/// guessed from the name, because a name is not evidence: an in-memory
/// `tsun0` and a real `tsun0` belonging to another agent look identical
/// from here, and configuring the operating system for the first would
/// land on the second.
fn on_host(&self) -> bool;
/// Creates a device.
fn create<'a>(
&'a self,
request: TunRequest,
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>>;
/// Applies a changed request to an interface that already exists.
///
/// The overlay IPv4 address is allocated at run time, so it can change
/// while the agent runs. A factory that manages the host applies that to
/// the live interface, without recreating it: recreating would drop every
/// tunnel riding on it.
///
/// The default does nothing, which is right for a factory that only
/// attaches to an interface somebody else prepared.
fn reconfigure<'a>(&'a self, _request: TunRequest) -> BoxFuture<'a, Result<(), OverlayError>> {
Box::pin(async move { Ok(()) })
}
/// The host rules for this factory's interface, when it manages the host.
///
/// `None` for an in-memory device or an interface somebody else prepared:
/// there is nothing of ours to route or to open in a firewall.
fn host_rules(&self) -> Option<Arc<dyn super::hostrules::BroadcastHostRules>> {
None
}
/// Removes an interface this factory created.
///
/// Runs on the teardown path, so it reports rather than fails: there is
/// nothing useful to do about a failure at that point, and an interface
/// that is already gone is the desired outcome anyway.
fn destroy<'a>(&'a self, _name: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {})
}
}
/// An in-memory packet interface.
///
/// Nothing reaches the operating system. Packets the device "sends" can be
/// read back with [`MemoryTun::pop_to_os`], and packets can be injected as if
/// the operating system produced them with [`MemoryTun::push_from_os`].
#[derive(Debug)]
pub struct MemoryTun {
name: String,
mtu: u32,
from_os_tx: tokio::sync::mpsc::UnboundedSender<Bytes>,
from_os_rx: tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<Bytes>>,
to_os_tx: tokio::sync::mpsc::UnboundedSender<Bytes>,
to_os_rx: tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<Bytes>>,
/// Set once the device is gone, so a reader stops rather than waiting
/// for a packet that will never come.
///
/// A `watch` rather than a `Notify`: closing must be seen by a reader
/// that has not started waiting yet as well as by one already waiting —
/// a device can be removed before the loop reading it has been polled
/// even once. The receiver is kept for the same reason: a `watch` send
/// with nobody subscribed does nothing at all.
closed: tokio::sync::watch::Sender<bool>,
closed_rx: tokio::sync::watch::Receiver<bool>,
}
impl MemoryTun {
/// Creates a device with the given name and MTU.
pub fn new(name: impl Into<String>, mtu: u32) -> Arc<Self> {
let (from_os_tx, from_os_rx) = tokio::sync::mpsc::unbounded_channel();
let (to_os_tx, to_os_rx) = tokio::sync::mpsc::unbounded_channel();
let (closed, closed_rx) = tokio::sync::watch::channel(false);
Arc::new(Self {
name: name.into(),
mtu,
from_os_tx,
from_os_rx: tokio::sync::Mutex::new(from_os_rx),
to_os_tx,
to_os_rx: tokio::sync::Mutex::new(to_os_rx),
closed,
closed_rx,
})
}
/// Marks the device gone, so the packet loop reading it ends.
///
/// A real interface reports end of stream when it is removed; this is
/// how that happens here. Without it teardown waits out the whole grace
/// period for a reader that had no way of knowing.
pub fn close(&self) {
let _ = self.closed.send(true);
}
/// Injects a packet as if the operating system had produced it.
pub fn push_from_os(&self, packet: Bytes) {
let _ = self.from_os_tx.send(packet);
}
/// Takes the next packet the device delivered to the operating system.
pub async fn pop_to_os(&self) -> Option<Bytes> {
self.to_os_rx.lock().await.recv().await
}
}
impl TunDevice for MemoryTun {
fn name(&self) -> &str {
&self.name
}
fn mtu(&self) -> u32 {
self.mtu
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move {
let mut closed = self.closed_rx.clone();
if *closed.borrow_and_update() {
return None;
}
let mut queue = self.from_os_rx.lock().await;
tokio::select! {
packet = queue.recv() => packet,
_ = closed.changed() => None,
}
})
}
fn send<'a>(&'a self, packet: Bytes) -> BoxFuture<'a, Result<(), OverlayError>> {
Box::pin(async move {
let _ = self.to_os_tx.send(packet);
Ok(())
})
}
}
/// Whether an address is assigned to some interface on this host.
///
/// Binding a UDP socket to a specific address only succeeds when the address
/// is local, which makes this a cheap check that needs no privileges and no
/// platform-specific code. It does not say *which* interface has it, which is
/// enough here: the agent chose the address, so anything else holding it is a
/// problem in its own right.
pub fn address_is_local(address: std::net::IpAddr) -> bool {
if std::net::UdpSocket::bind((address, 0)).is_ok() {
return true;
}
#[cfg(all(feature = "tun-device", target_os = "windows"))]
{
if let Ok(interfaces) = std::panic::catch_unwind(netdev::get_interfaces) {
for iface in interfaces {
if iface.is_up() {
match address {
std::net::IpAddr::V4(v4) => {
if iface.ipv4.iter().any(|ip| ip.addr() == v4) {
return true;
}
}
std::net::IpAddr::V6(v6) => {
if iface.ipv6.iter().any(|ip| ip.addr() == v6) {
return true;
}
}
}
}
}
}
}
false
}
/// Creates [`MemoryTun`] devices.
#[derive(Debug, Clone, Default)]
pub struct MemoryTunFactory {
created: Arc<std::sync::Mutex<Vec<Arc<MemoryTun>>>>,
}
impl MemoryTunFactory {
/// Creates a factory.
pub fn new() -> Self {
Self::default()
}
/// The device created for an interface name, if any.
pub fn device(&self, name: &str) -> Option<Arc<MemoryTun>> {
let guard = match self.created.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
guard
.iter()
.find(|device| device.name() == name)
.map(Arc::clone)
}
/// Every device created so far.
pub fn devices(&self) -> Vec<Arc<MemoryTun>> {
let guard = match self.created.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
guard.clone()
}
}
impl TunFactory for MemoryTunFactory {
fn name(&self) -> &str {
"memory"
}
/// Nothing here is on the host, which is the whole point of it.
fn on_host(&self) -> bool {
false
}
/// Closes the device so whatever is reading it stops.
///
/// The device itself is kept, because a test asks what went through it
/// after the agent that owned it has gone.
fn destroy<'a>(&'a self, name: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
if let Some(device) = self.device(name) {
device.close();
}
})
}
fn create<'a>(
&'a self,
request: TunRequest,
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>> {
Box::pin(async move {
let device = MemoryTun::new(request.name, request.mtu);
let mut guard = match self.created.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
guard.push(Arc::clone(&device));
Ok(device as Arc<dyn TunDevice>)
})
}
}
#[cfg(feature = "tun-device")]
pub(crate) use system::open_tun;
#[cfg(feature = "tun-device")]
mod system {
use std::sync::Arc;
use bytes::Bytes;
use tokio::sync::Mutex;
use super::{TunDevice, TunRequest};
use crate::BoxFuture;
use crate::overlay::OverlayError;
/// A real TUN interface.
///
/// On Linux it is created by opening `/dev/net/tun`, which needs
/// `CAP_NET_ADMIN`; on Windows it is a Wintun adapter, which needs the
/// process to be elevated and `wintun.dll` to be reachable. Either way
/// [`open_tun`] is only ever called from
/// [`provision`](super::super::provision), which holds whatever privilege
/// the platform requires for the length of the call and no longer.
///
/// It is deliberately **not** made persistent, so the operating system
/// removes the interface when this value is dropped — however the process
/// ends. (A Wintun adapter created this way is likewise torn down when the
/// last handle to it closes, which the reader and writer below hold.)
pub struct SystemTun {
name: String,
mtu: u32,
reader: Mutex<tokio::io::ReadHalf<tun::AsyncDevice>>,
writer: Mutex<tokio::io::WriteHalf<tun::AsyncDevice>>,
}
impl std::fmt::Debug for SystemTun {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SystemTun")
.field("name", &self.name)
.field("mtu", &self.mtu)
.finish()
}
}
impl TunDevice for SystemTun {
fn name(&self) -> &str {
&self.name
}
fn mtu(&self) -> u32 {
self.mtu
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move {
use tokio::io::AsyncReadExt;
let mut buffer = vec![0u8; self.mtu as usize + 64];
let mut reader = self.reader.lock().await;
match reader.read(&mut buffer).await {
Ok(0) => None,
Ok(read) => {
buffer.truncate(read);
Some(Bytes::from(buffer))
}
Err(err) => {
tracing::debug!(%err, "tun read failed");
None
}
}
})
}
fn send<'a>(&'a self, packet: Bytes) -> BoxFuture<'a, Result<(), OverlayError>> {
Box::pin(async move {
use tokio::io::AsyncWriteExt;
let mut writer = self.writer.lock().await;
writer
.write_all(&packet)
.await
.map_err(|err| OverlayError::Other(format!("tun write failed: {err}")))
})
}
}
/// Creates the TUN interface by opening it.
///
/// Synchronous, and deliberately so: the caller holds a capability guard
/// across this call, and such a guard must not span an `await` because
/// Linux capabilities are per thread.
pub(crate) fn open_tun(request: &TunRequest) -> Result<Arc<dyn TunDevice>, OverlayError> {
// macOS mutates neither field below (it neither names the interface nor
// sets a platform config), so `mut` is unused only there.
#[cfg_attr(target_os = "macos", allow(unused_mut))]
let mut config = tun::Configuration::default();
// macOS names its tunnels `utunN` and assigns the unit number itself.
// A name derived from the network id (`tsun…`) is not a legal utun
// name, so there the kernel is left to choose and the real name is read
// back below. Linux and Windows take the requested name verbatim.
#[cfg(not(target_os = "macos"))]
config.tun_name(&request.name);
#[cfg(target_os = "linux")]
config.platform_config(|platform| {
// The crate's own root check is not the check we want: this holds
// CAP_NET_ADMIN without being root. Whether the open succeeds is
// the honest answer.
platform.ensure_root_privileges(false);
});
// Packet information stays off, so reads and writes are raw IP
// packets. `ip tuntap add ... mode tun` also defaults to no packet
// information, so the flags match when attaching to one. The address
// and MTU are left off the configuration on purpose: the provisioner
// applies those to the interface separately, the same way on every
// platform, so the plan stays the single source of what it carries.
let device = tun::create_as_async(&config).map_err(|err| open_error(&request.name, err))?;
// The interface name the operating system actually gave us: the
// requested one everywhere except macOS, where the kernel chose the
// utun unit and so the real name has to be read back off the device.
#[cfg(target_os = "macos")]
let name = {
use tun::AbstractDevice;
device.tun_name().unwrap_or_else(|_| request.name.clone())
};
#[cfg(not(target_os = "macos"))]
let name = request.name.clone();
let (reader, writer) = tokio::io::split(device);
Ok(Arc::new(SystemTun {
name,
mtu: request.mtu,
reader: Mutex::new(reader),
writer: Mutex::new(writer),
}) as Arc<dyn TunDevice>)
}
fn open_error(name: &str, error: tun::Error) -> OverlayError {
// Wintun preserves the Windows code in its I/O variant. Normalize it
// before testing permissions; matching localized error text would
// miss the same failure on another Windows language.
#[cfg(target_os = "windows")]
let error = match error {
tun::Error::WintunError(error) => tun::Error::Io(error.into()),
error => error,
};
let denied = match &error {
tun::Error::Io(error) => {
error.kind() == std::io::ErrorKind::PermissionDenied
|| (cfg!(target_os = "windows")
&& matches!(error.raw_os_error(), Some(5 | 740 | 1314)))
}
_ => false,
};
if denied {
let hint = if cfg!(target_os = "windows") {
"Open PowerShell or Command Prompt with 'Run as administrator' and \
start `tsunagi up` there."
} else if cfg!(target_os = "linux") {
"Start `tsunagi up` as root or grant CAP_NET_ADMIN with \
`sudo setcap cap_net_admin,cap_net_bind_service+p /path/to/tsunagi`."
} else if cfg!(target_os = "macos") {
"Creating a utun interface needs root on macOS; start `tsunagi up` \
with `sudo`, or install it as a root LaunchDaemon."
} else {
"Start `tsunagi up` with the privileges required to create a TUN interface."
};
return OverlayError::Unavailable(format!(
"permission denied while creating the TUN interface `{name}`. \
{hint} OS error: {error}"
));
}
OverlayError::Unavailable(format!(
"cannot create the TUN interface `{name}`: {error}. {}",
open_hint()
))
}
/// The platform-appropriate tail of a "cannot create the interface" error:
/// what privilege it needs and the fallback that always works.
fn open_hint() -> &'static str {
#[cfg(target_os = "linux")]
{
"Creating one needs CAP_NET_ADMIN; grant it with `setcap cap_net_admin,cap_net_bind_service+p`, \
or run with `--no-tun` to keep the tunnels off the operating system."
}
#[cfg(target_os = "windows")]
{
"Creating one needs an elevated process and `wintun.dll` on the search path; \
run as Administrator with the DLL beside the executable, or run with `--no-tun` \
to keep the tunnels off the operating system."
}
#[cfg(target_os = "macos")]
{
"Creating a utun interface needs root; start the agent with `sudo` or as a root \
LaunchDaemon, or run with `--no-tun` to keep the tunnels off the operating system."
}
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
{
"Run with `--no-tun` to keep the tunnels off the operating system."
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_tun_permission_error_explains_how_to_start_the_agent() {
let error = tun::Error::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
let message = open_error("tsun0", error).to_string();
assert!(message.contains("permission denied"), "{message}");
assert!(message.contains("tsun0"), "{message}");
assert!(message.contains("tsunagi up"), "{message}");
#[cfg(target_os = "windows")]
assert!(message.contains("Run as administrator"), "{message}");
#[cfg(target_os = "linux")]
assert!(message.contains("CAP_NET_ADMIN"), "{message}");
}
#[cfg(target_os = "windows")]
#[test]
fn wintun_access_and_elevation_errors_get_the_permission_hint() {
for code in [5, 740, 1314] {
let error = tun::Error::WintunError(std::io::Error::from_raw_os_error(code).into());
let message = open_error("tsun0", error).to_string();
assert!(message.contains("permission denied"), "{message}");
assert!(message.contains("Run as administrator"), "{message}");
assert!(message.contains(&format!("os error {code}")), "{message}");
assert!(!message.contains("wintun.dll"), "{message}");
}
}
#[test]
fn other_tun_errors_keep_their_cause_without_claiming_access_was_denied() {
let message = open_error("tsun0", tun::Error::InvalidName).to_string();
assert!(message.contains("invalid device tun name"), "{message}");
assert!(!message.contains("permission denied"), "{message}");
}
}
}
File renamed without changes.
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more