Compare commits

..
123 Commits
Author SHA1 Message Date
Aleksandr Bogomiakov e701986a39 Add ephemeral pairwise Amnezia transport and runtime fallback
Plain WG and QUIC can be blocked or severely degraded while the iroh control
connection still works. Add an awg carrier with one independent UDP socket,
WireGuard engine and randomized masking profile per network/peer attempt.
The smaller endpoint id negotiates fresh outer keys, PSK, header protection,
header ranges, padding and signature templates through private authenticated
control messages. Restart or fallback reentry generates a new profile;
ordinary rekeying preserves the current profile. No pair parameters enter
signed state, capabilities, persistent storage or public message events.
Private payloads and serialized control buffers redact and zeroize themselves.

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

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

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

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

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

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

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

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

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

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

Earlier validation attempts caught outdated diagnostic assertions, a new
test helper scope error, and the inherited system control socket override.
Those were corrected before the successful runs. Plain cargo was unavailable
in the host shell, so validation used the Nix development toolchain. macOS,
Windows, privileged host TUN operations and manual tray interaction were not
exercised.
2026-10-11 11:58:27 +01:00
Aleksandr Bogomiakov 781e359775 Make tunnel and transport layers explicit in status
The tray called the end-to-end tunnel proto and the current route link, then put the tunnel handshake age beside the route. A valid wg tunnel over a tcp-tls transport consequently looked like contradictory state. The terminal status used similarly terse wording.\n\nThe tray now separates end-to-end from data path, renders direct paths as direct over TRANSPORT, keeps handshake or uptime beside the tunnel, and explains both layers in hover text. Terminal status uses the same end-to-end tunnel, data path, transport detail, and control path vocabulary. Routing, protocol selection, IPC, and wire behavior are unchanged.\n\nPresentation tests cover wg over tcp-tls, tcp-tls uptime, relayed and missing paths, a missing handshake, and offline devices. The WireGuard broadcast checksum test uses the current slice-array iterator spelling required by Clippy; its behavior is unchanged.\n\nChecks: cargo fmt --all -- --check; cargo clippy --locked --workspace --all-targets -- -D warnings; cargo test --locked --workspace --all-targets with TSUNAGI_CONTROL_SOCKET removed from the test environment so integration agents use their temporary control sockets. All passed on Linux.
2026-10-08 16:03:20 +01:00
Aleksandr Bogomiakov d5e02ec256 fixed gitignore 2026-10-08 15:35:38 +01:00
Aleksandr BogomiakovandClaude 97377e3703 Relay across any mix of links, one online answer for every client, first claim owns a hostname
A tunnel no longer depends on the kind of link under it. The relay hub keeps
one forwarding table per network instead of one per protocol, the envelope
names the end-to-end protocol, and a neighbour reached over several kinds of
transport is sent to over the best open one. A member in the middle needs no
protocol in common with either end. Which protocol a pair runs is settled by
the lower endpoint id's announced order, so both ends agree without asking.
A direct link that comes up later is shorter than the path through others, so
traffic moves to it with the same logical link.

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 17:47:44 +01:00
Aleksandr BogomiakovandClaude e6ec62bcba Windows installer: find the Wintun licence at the archive root
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 14:38:16 +01:00
UltradesuandClaude Sonnet 5.5 21328bf818 Windows installer: agent service, Wintun, tray and PATH in one setup program
Running on Windows needed wintun.dll by hand, an elevated console and a
manually started agent; without the DLL the agent silently ran with no
interface (and so no exit node or routing at all).

`tsng service` (hidden) runs the same `up` under the Windows service manager:
src/winservice.rs registers with the SCM through the windows-service crate,
reports Running/StopPending/Stopped, and maps a stop request onto the existing
shutdown path (stop_signal). The only unsafe is the extern "system" entry point
that define_windows_service! expands to, allowed in that module alone. A
service has no console, so it logs to %ProgramData%\Tsunagi\logs\tsng.log
(one older file kept past 8 MiB). Its state defaults to
%ProgramData%\Tsunagi\{data,cache} and its control socket to the system-wide
default every client already uses, so an unelevated `tsng status` and the tray
find it.

dist/windows/tsunagi.iss (Inno Setup) installs tsng.exe, the tray and Wintun
0.14.1 (pinned by SHA-256 in CI, licence text shipped), registers the
"Tsunagi" service as LocalSystem with auto start and restart-on-failure, locks
the data/cache/log directories to SYSTEM and Administrators by SID, and by
tasks adds tsng to the PATH, an inbound firewall rule for the agent and a tray
autostart. It stops the service, the tray and any hand-started agent before
replacing files; uninstall removes the service, rule and PATH entry and asks
before deleting the data (never on a silent uninstall). The release workflows
(GitHub and Gitea) build it on the Windows job and publish
tsunagi-setup-<version>-x86_64.exe.

.cargo/config.toml links the MSVC C runtime statically so nothing needs a
Visual C++ redistributable on a clean machine.

Compatibility: an agent run by hand keeps its data in the user profile; the
service has its own, so networks must be joined again once.

Checks: cargo fmt --check, clippy -D warnings, cargo test --workspace passed on
macOS (one unrelated wireguard restart test failed once under load and passed
on three reruns). winservice.rs was cargo-checked for x86_64-pc-windows-msvc in
a scratch crate; the whole tsunagi crate cannot be built for Windows from
macOS (aws-lc needs the Windows SDK). The .iss script and the CI step were NOT
compiled or run: no Inno Setup or Windows here.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 12:29:40 +01:00
Aleksandr BogomiakovandClaude 0cfc1cbafc Tray state icon and window picture; firewall rules without the comment match; macOS app bundle
The tray icon is dark red with no connection (or a lost exit node), blue when
connected and green while an exit node is in use; the windows get the logo and
an app id that matches the desktop entry, and the exit icon moves to the
bottom left of the tile. Exit-node and broadcast firewall rules fall back to
being recognised by their shape on kernels that have no `comment` match, as
small router builds do. The macOS archive carries Tsunagi.app for the Homebrew
cask, and the daemon plist opens the control socket to the admin group.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 03:30:47 +01:00
Aleksandr BogomiakovandClaude c9c653023d Release: build a static ARMv7 (armhf) Linux agent
Adds armv7-unknown-linux-musleabihf to both release pipelines: the archive,
a .deb for armhf and an Arch package for armv7h. Agent and CLI only, like the
other non-x86_64 Linux builds.

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 01:14:51 +01:00
Aleksandr BogomiakovandClaude c62d29fa14 Arch package: start the service and let the GUI reach the agent
The control socket is now opened to the tsunagi group (unit), the Arch
package enables and starts the service on install, adds the installing user
to the group, restarts a running agent on upgrade, and depends on the
libraries the tray loads at run time. The tray looks for the agent's socket
again on every poll instead of once at start, and a missing indicator
library is reported instead of panicking. Repository URLs unified.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 23:29:51 +01:00
Aleksandr BogomiakovandClaude a6d9edabee Point the project repository at github.com/house-of-vanity/tsunagi
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 23:06:48 +01:00
Aleksandr BogomiakovandClaude 2f7f9980c7 Tray: open the devices window wider and let its table scroll sideways
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 23:05:08 +01:00
Aleksandr BogomiakovandClaude 2e178a6f9d Tray: About window, fixed-width device table with totals; WTFPL
Release / tsunagi-linux-aarch64 (push) Successful in 2m19s
Release / tsunagi-linux-x86_64 (push) Successful in 3m56s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
Add an About window (client and agent details, protocol versions), pin the
device table's column widths and add a total row, show the version in the
header and drop the refresh button. Relicense under WTFPL.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 20:16:01 +01:00
Aleksandr BogomiakovandClaude f4dcdec662 Tray: separate tray and window processes; device list without self
Release / tsunagi-linux-aarch64 (push) Successful in 2m44s
Release / tsunagi-linux-x86_64 (push) Successful in 4m37s
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 tray process holds only the icon and starts the same binary with
--window on Open; the window ends when closed, which works on Wayland where
a window cannot be hidden. The devices table now names devices as
hostname (id5), leaves out this device and counts the others.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 19:23:06 +01:00
Aleksandr BogomiakovandClaude 69310108f4 Remember the last hostname each peer announced
Persist announced hostnames per network (state schema 5) and report them for
members, so a device that is away is still named in `status` and the tray.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 19:23:06 +01:00
Aleksandr BogomiakovandClaude e7dff34ec2 Status: ask the agent for its state directory and its own CAP_NET_ADMIN
The CLI reached a packaged service's socket but read state from its own
default directory, so `network` saw nothing next to a running network, and
`status` reported the CLI binary's capabilities instead of the agent's.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 18:31:01 +01:00
Aleksandr BogomiakovandClaude a55ea883a1 Tray: initialise GTK and run its main loop on Linux
Release / tsunagi-linux-aarch64 (push) Successful in 3m14s
Release / tsunagi-linux-x86_64 (push) Successful in 5m21s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
tray-icon needs gtk::init() and a running GTK loop on Linux, which
eframe/winit never provide, so the tray panicked at startup with "GTK has
not been initialized". Build the tray on a dedicated GTK thread instead.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 17:54:38 +01:00
UltradesuandClaude Opus 4.8 3e066f991f CI: install libxdo-dev for the Linux tray link
Release / tsunagi-linux-aarch64 (push) Successful in 3m23s
Release / tsunagi-linux-x86_64 (push) Successful in 6m58s
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 x86_64 Linux tray failed at link with "unable to find library -lxdo":
tray-icon pulls libxdo, whose .so was not present. Add libxdo-dev to the GUI
build's apt install, alongside libgtk-3-dev and libayatana-appindicator3-dev.
Everything else (aarch64 agent, macOS, Windows) already built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 16:51:53 +01:00
UltradesuandClaude Opus 4.8 3752419075 CI: build the Linux tray natively with GTK; drop the aarch64 GUI
Release / tsunagi-linux-aarch64 (push) Successful in 4m14s
Release / tsunagi-linux-x86_64 (push) Failing after 4m29s
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 aarch64 GUI cross-build failed: tray-icon links GTK and libappindicator
through pkg-config, which zig cross-compilation cannot satisfy without a target
sysroot. Rather than set one up, the Linux tray is now built natively on the
x86_64 runner with libgtk-3-dev and libayatana-appindicator3-dev installed, so
pkg-config just works. The agent is still static musl for both arches.

The aarch64 Linux GUI is dropped for now: that archive stays agent-only and no
aarch64 tsunagi-gui package is produced. x86_64 gets the tray in its archive
and the tsunagi-gui .deb/.pkg.tar.zst. macOS and Windows build the tray
natively as before. Guarded on matrix.gnu_target (set only on x86_64 Linux).

Note: the native build links the runner's glibc, so the Linux GUI binary needs a
reasonably recent glibc; fine for current desktops. A lower baseline would need
a GTK sysroot or an older runner image.

YAML validated; the Linux GUI build is exercised only by a real CI run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 16:38:58 +01:00
UltradesuandClaude Opus 4.8 f63343a6a0 CI: ship the tray GUI — in the archives and as tsunagi-gui deb/zst
Release / tsunagi-linux-aarch64 (push) Failing after 2m40s
Release / tsunagi-linux-x86_64 (push) Failing after 2m40s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
Adds the tray application to the release:

- Every target's archive now also contains the tsunagi-tray binary. On Linux it
  is built against an old glibc baseline (via cargo-zigbuild) rather than static
  musl, because a GUI must dlopen libGL/X11/Wayland at runtime and a static musl
  binary cannot; the agent stays static musl. macOS and Windows build the tray
  natively.
- New GUI package variants for Linux: `tsunagi-gui` .deb and .pkg.tar.zst, built
  by the existing scripts with an extra tray argument. They install the agent
  (service, polkit, user — same as the headless packages) plus the tray binary,
  a desktop launcher (dist/linux/tsunagi-tray.desktop) and an icon
  (dist/linux/tsunagi.svg), so the GUI appears in the applications menu. The GUI
  package conflicts/replaces/provides the headless tsunagi so the two do not
  coexist.
- The tray finds the system service with no configuration: the library's default
  control-socket path now prefers /run/tsunagi/agent.sock when present (both the
  tray and CLI use this), falling back to the per-user path.

No control-protocol change. Builds, fmt and clippy clean on macOS; the Linux GUI
cross-build and the packages are exercised only by a real CI run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 16:26:45 +01:00
UltradesuandClaude Opus 4.8 d13bb00623 Tray: one device table (online + offline), FQDN copy, green-fade copy feedback
- The devices window now shows a single device table instead of a separate
  "connected" box and an easily-missed "offline" box below it. Every endpoint
  known from a live tunnel or the signed membership appears, online first;
  offline rows show name and address with dashes for the live columns. The
  table fills the window and scrolls, so nothing hides off the bottom.
- Clicking an online peer's name copies hostname.network (the FQDN) so it
  resolves; a name without a hostname copies its short id.
- Copy feedback no longer appends "Copied" text (which reflowed the table for a
  second). The field just flashes green and fades back over a second, with no
  change to its text or width.

Builds and is clippy-clean (-D warnings) on macOS; not run in this headless
session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 16:09:33 +01:00
UltradesuandClaude Opus 4.8 2a0a8abd60 Tray: split rate/plot columns, add unit toggle to the devices window
The connected-peers rate and sparkline shared one grid cell, so the plot still
moved right as the rate text grew (e.g. "1.0 kB/s"). They are now two separate
grid columns — the plot column starts at the same x on every row, since grid
columns align.

The devices window also gets its own pkts/bytes toggle, bound to the same unit
setting as the main window so the two stay in step.

Builds and is clippy-clean (-D warnings) on macOS; not run in this headless
session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 16:04:39 +01:00
UltradesuandClaude Opus 4.8 db01c58a6f Tray: Phosphor icons, click-to-copy fields with feedback, steady plot column
- Bundle egui-phosphor (pinned to egui 0.31) and register its glyphs, so the
  icon buttons render real icons instead of tofu: refresh, shuffle (generate
  secret), pencil/check/x (rename host), copy (Copy secret).
- Copying is now the field itself, not a separate button: clicking the
  shortened device id, the network name, the network id, a range or an address
  copies the full value and the field flashes green "Copied" for a second
  (tracked in egui temp memory, so it clears itself). The standalone copy
  buttons are gone.
- In the devices window the connected-peers "rate / plot" column put the rate
  and the inline sparkline in a shared row, so the plot shifted as the rate
  text changed width ("0 B/s" vs "1.1 kB/s"). The rate now sits in a
  fixed-width, truncating cell, so the plot stays put.

Builds and is clippy-clean (-D warnings) on macOS; the UI has not been run in
this headless session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 16:00:23 +01:00
UltradesuandClaude Opus 4.8 de56258eb1 Tray: copy a network secret via ipc; use text buttons, not missing glyphs
Two fixes.

Secret copy: a new local control request returns a configured network's secret
in its tsn1 text form, so the tray's "Copy secret" button can put it on the
clipboard. The agent reads it from local state (same source as the CLI's
`network secret show`); it crosses only the owner/group socket and is wrapped in
a RedactedText type whose Debug prints "<redacted>", so it can never land in a
log. In the GUI the worker fetches it and hands it to the UI thread (which owns
the clipboard) through a one-shot slot kept out of the status snapshot, so it is
copied once and never stored in the cloned snapshot. Control protocol bumped
16 -> 17.

Icons: the button glyphs (⧉ ⟳ ✎ ✔ ✕) are not in egui's bundled fonts and showed
as tofu squares, so they are now plain text: copy / refresh / random / edit /
save / cancel. No icon font to embed.

Checks: fmt, clippy --workspace --all-targets -D warnings, and the tsunagi lib,
tsunagi-cli and wg-quic local_control tests pass on macOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 15:48:32 +01:00
UltradesuandClaude Opus 4.8 8f5ce21ce9 Tray: devices window, bytes/packets toggle, copy & edit affordances
Replaces the in-tile collapsing peer list with a "Show devices" button that
opens a separate, normal-sized window per network, and fills in the rest of the
feedback.

Main window:
- A global show: pkts/bytes toggle; all rates and sparklines follow it.
- Sparklines now reserve their space always (fixed height, "no data yet"
  placeholder) so the layout no longer jumps when data arrives, and each one
  overlays its peak (top-left) and current value (bottom-right) with units.
- Device id has a copy button; the hostname is editable in place (✎ → edit →
  ✔/Enter sends SetHostname); network name, range and address copy on click.
- The Leave button is a neutral red. Per-tile "Show devices" opens the detail.

Devices window (a separate egui viewport per network):
- Network summary: id (copy), active/broadcast, range, this device's address,
  candidates, relay fwd/via/recv, dial/handshake failures.
- A larger network traffic sparkline.
- Connected peers in a table: hostname when known (else short key), protocol,
  address, handshake age formatted (s/m/h/d), tx/rx packets, tx/rx bytes, and
  current rate with an inline sparkline. Names and addresses copy on click.
- Offline members listed below with address and failed-dial count.

Agent: a SetHostname command (over the existing ipc) backs the in-place rename.
Traffic sampler now tracks both packet and byte rates; the toggle selects which.

Deferred: a "copy secret" button on the tile — the secret is deliberately not
exported to the GUI (secrets never leave the agent), so it needs a small local
secret-export ipc, which is the next step.

Builds and is clippy-clean (-D warnings) on macOS; the UI has not been run in
this headless session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 15:37:08 +01:00
UltradesuandClaude Opus 4.8 0a344c1b4f Report per-peer byte counters, not just packets
The overlay peer report carried packet counts only, so the GUI could show
packets/s but no byte throughput. Adds tx_bytes/rx_bytes to OverlayPeerReport
and fills them from each data plane:

- the wg and wg-quic plugins already counted plaintext bytes per peer; the CLI
  now maps them into the report;
- the generic tunnel (tcp-tls) gains tx_bytes/rx_bytes on its peer state and
  report, incremented by the plaintext length at the same points it counts
  packets.

The local control protocol version is bumped (TSN 15 -> 16) since the report
shape changed; a client and agent on different builds already detect and report
the mismatch.

Checks: fmt, clippy --workspace --all-targets -D warnings, and the tsunagi lib,
tsunagi-cli and wg-quic local_control tests pass on macOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 15:30:37 +01:00
UltradesuandClaude Opus 4.8 2cda56e1f8 Log inbound handshake rejections at debug, not warn
Creating and leaving networks produced a stream of WARN lines like "Handshake
rejected: peer requested an unknown or inactive network network=None". This is
not a leave bug: leaving deactivates the network and publishes a release, but
remote peers that still consider this device a member keep dialing, and each
dial for a network no longer served is correctly rejected by the responder
(lookup returns None, so the network stays unidentified — network=None by
design, since a pre-handshake claim is unverified).

Such a rejection is routine and remotely triggerable — a left network a peer
has not caught up on, a stranger, a port scan, an old client — so logging it at
WARN let any peer spam the log. It is now debug; enable debug to see rejections
when diagnosing. The rejection behaviour itself is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 15:27:49 +01:00
UltradesuandClaude Opus 4.8 d1de61ba29 Tray UI: start hidden, fixed window, grid layout, traffic + peers
Reworks the tray window per feedback and fills it in.

- Starts hidden in the tray (no window on launch); the window opens from the
  menu. On macOS the app now uses the Accessory activation policy, so it lives
  in the menu bar with no Dock icon (via a winit event-loop hook, pinned to
  eframe's winit). Fixed, non-resizable 360x600 window; the layout targets it.
- Tidied layout: top bar (title + refresh), a bottom panel with the join form
  and the action banner, and a scrolling list of network tiles between them.
  Labels and values sit on two-column grids so they align; the join name and
  secret inputs are the same fixed width.
- A ⟳ button beside the secret generates a fresh random secret
  (NetworkSecret::generate), for creating a network.
- Each network tile shows id, range, this device's address, peers
  (connected/known), the active and broadcast switches, a Leave button, and a
  collapsing "peers (N)" section with a formatted per-peer table (short key,
  address, handshake age, tx/rx packets, rate).
- Traffic: a new sampler turns the agent's cumulative packet counters into a
  packets-per-second rate per network and per peer, from the deltas between
  snapshots (the snapshot now carries a generation and timestamp so each
  refresh is sampled once). The network tile shows the current rate and a small
  hand-drawn sparkline of recent rates (no extra dependency).

Note: rates are packets/s — the report has no byte counters yet; bytes would
need an agent change. Builds and is clippy-clean (-D warnings) on macOS; the UI
has not been run in this headless session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 15:08:29 +01:00
UltradesuandClaude Opus 4.8 19509dc833 Add the tsunagi-tray GUI (egui) — first increment
A native, lightweight tray application to manage a running agent — no web
engine. It is a thin client over the agent's local control socket, reusing
tsunagi::ipc and the report types directly; it owns its own runtime, logger and
event loop, like the CLI.

This first increment:
- tray icon with an Open/Quit menu (tray-icon); menu events are read on a
  background thread so Open works even while the window is hidden;
- a small window that hides to the tray on close and reopens from the menu;
- a background tokio worker that polls status every 2s and runs commands off the
  UI thread, writing a snapshot the UI renders from and repainting egui;
- UI: device header, a per-network card (name, id, range, this device's
  address, peers connected/known, a Leave button, and the per-network active
  and broadcast switches), a join form (name + secret), and a transient
  action/error banner; an "agent unreachable" state that names the socket.

Socket resolution: TSUNAGI_CONTROL_SOCKET if set, else the platform default
(/var/run/tsunagi/agent.sock on macOS) — the same socket the group-access agent
serves.

Scope/notes: macOS first (Linux and Windows follow). Not yet done — macOS
accessory activation policy (so there is no dock icon), light/dark tray icons
from assets (the icon is drawn in code for now), and packaging/autostart. Builds
and is clippy-clean (-D warnings) on macOS; the UI itself has not been run in
this headless session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 14:48:52 +01:00
UltradesuandClaude Opus 4.8 de8e48bee7 Let the agent grant the control socket to a group (0660)
For a GUI (or a CLI) running as a different user to reach the agent, the control
socket needs to be group-accessible. This is set by the agent when it binds the
socket — correct from creation, not patched afterwards.

- ipc::ControlSocketAccess { Private, Group(gid) }. Private is the existing
  owner-only 0600. Group sets the socket's group to the gid and mode 0660, and
  creates the socket's parent directory 0755 so the group can traverse to it; if
  the group cannot be set it falls back to owner-only rather than leaving it
  wrong. The chown uses std::os::unix::fs::chown — no unsafe, no new lib
  dependency.
- ControlSocket::bind takes the access policy (Windows accepts it and keeps its
  pipe default for now; a pipe security descriptor is a later follow-up).
- CLI: `tsunagi up --control-group <name>` (env TSUNAGI_CONTROL_GROUP) resolves
  the group to its gid via nix (safe wrapper, unix-only dep) and passes
  Group(gid); without it the socket stays Private. A name that does not resolve
  is reported, and the socket stays owner-only.
- control_socket() now serves the system path (default_control_socket_path) on
  macOS too, not just Windows: /var/run/tsunagi/agent.sock, a runtime directory
  the GUI can reach, rather than the owner-only state directory. A custom
  --state-dir still keeps its own per-user socket.

So the packaged/daemon agent runs e.g. `tsunagi up --control-group tsunagi` and a
user in that group talks to it; a plain per-user agent is unchanged (0600).

Checks: fmt, clippy --workspace --all-targets -D warnings, tsunagi lib tests and
the wg-quic local_control tests pass on macOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 14:41:58 +01:00
UltradesuandClaude Opus 4.8 8c812e6874 Bump the GitHub workflow actions off deprecated Node 20
Release / tsunagi-linux-aarch64 (push) Successful in 5m25s
Release / tsunagi-linux-x86_64 (push) Failing after 3h12m37s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
GitHub now forces actions/checkout@v4, actions/upload-artifact@v4 and
actions/download-artifact@v4 (all Node 20) onto Node 24 with a deprecation
warning. Move them to the current Node 24 majors — checkout@v5,
upload-artifact@v7, download-artifact@v8 — which keep the same inputs and the
per-artifact subdirectory layout (merge-multiple defaults to false), so the
publish step is unchanged.

Only the .github workflow is touched. The .gitea workflow stays on
checkout@v4 and the @v3 artifact actions, because Gitea does not support the v4+
artifact backend and may not have Node 24 on its runner image; it also does not
emit these deprecation warnings. mlugg/setup-zig has no Node 24 release yet
(latest v2.2.1 is still Node 20), so that one warning remains until upstream
updates — nothing to change on our side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 14:01:33 +01:00
UltradesuandClaude Opus 4.8 63178574ce Package as a dedicated tsunagi user; add Arch packages
Release / tsunagi-linux-aarch64 (push) Successful in 2m39s
Release / tsunagi-linux-x86_64 (push) Successful in 2m32s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
Reworks the Linux packaging so the service runs as an unprivileged, dedicated
"tsunagi" user instead of root — the point of a native package is to set things
up properly for that OS's users — and adds Arch Linux packages beside the deb.
Both package families are built from the already-compiled static binary; nothing
is recompiled.

Service model (shared systemd unit, dist/linux/tsunagi.service):
- User=tsunagi with AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE, so
  it creates the TUN and binds low ports without root.
- StateDirectory/CacheDirectory/RuntimeDirectory give it /var/lib/tsunagi,
  /var/cache/tsunagi and /run/tsunagi, and the agent is pointed at them (and the
  socket at /run/tsunagi/agent.sock) through TSUNAGI_* env in the unit.
- The polkit rule now grants the resolve1 actions to the "tsunagi" group; the
  service user is in it, and admins can add themselves.

CLI: when no state dir is chosen, the control commands prefer the system
service's socket at /run/tsunagi/agent.sock when it exists, so `sudo tsunagi
status`/`join` reach the packaged daemon with no flags. A per-user agent (its
socket absent from /run) still uses its own.

Packaging:
- deb (build-deb.sh): postinst now creates the tsunagi system user+group; unit
  installed to /lib/systemd/system, binary to /usr/bin.
- Arch (new dist/arch/build-pkg.sh): a dependency-free .pkg.tar.zst assembled
  with bsdtar + zstd (no makepkg, no Arch host) — .PKGINFO, a .INSTALL scriptlet
  that creates the user and reloads systemd, and a gzipped .MTREE; unit to
  /usr/lib/systemd/system.
- The polkit rule moved to dist/linux (shared by deb, Arch and the tarball), and
  the tarball install.sh now creates the user and installs the unit + rule.
- Both release workflows build the deb and the Arch package on the two Linux
  matrix entries (deb_arch/pkg_arch), install libarchive-tools+zstd for it, and
  attach both to the release (Gitea API loop / gh).

Not exercised in this session: the .deb and .pkg.tar.zst build and install only
on Linux (no dpkg-deb/bsdtar+pacman locally); CLI build, clippy, fmt and tests
pass, and all shell/YAML is syntax-checked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 13:46:27 +01:00
UltradesuandClaude Opus 4.8 a3856eed3b Build a dependency-free .deb and attach it to the release
Release / tsunagi-linux-aarch64 (push) Successful in 2m21s
Release / tsunagi-linux-x86_64 (push) Successful in 2m29s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
Adds a Debian package to the release for the x86_64 and aarch64 Linux builds, so
`apt install ./tsunagi_*.deb` is a convenient path on the Ubuntu hosts. Because
the binary is static musl, the package declares no Depends — it is built with
plain dpkg-deb on the runner (no cargo-deb or nfpm), from dist/deb/build-deb.sh.

The package installs:
- the static binary at /usr/bin/tsunagi;
- a systemd service at /lib/systemd/system/tsunagi.service that runs the agent
  as root (ExecStart rewritten to /usr/bin; the source unit targets
  /usr/local/bin for the tarball installer);
- a polkit rule at /usr/share/polkit-1/rules.d granting the resolve1 actions to
  a "tsunagi" group, for running the agent as a non-root user instead of via the
  root service. postinst creates that system group and runs daemon-reload; prerm
  disables the service on removal.

Both release workflows gain a "Build the Debian package" step on the two Linux
matrix entries (deb/deb_arch), include the .deb in the uploaded run artifacts,
and attach it to the release (Gitea via the API loop, GitHub via gh). macOS and
Windows are unchanged. Service runs as root by default; the polkit rule/group is
the documented opt-in for the non-root path — say if you'd rather the service
itself run as the tsunagi user.

YAML and shell syntax validated; the .deb build itself runs only on the Linux
runner (no dpkg-deb locally) and has not been exercised in this session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 13:34:33 +01:00
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
tsunagiandClaude Opus 5 43e8ac8159 Make identity something you can look at and change
`id` now shows what this device is — the key it signs with, the name it
answers to, the secret of every network it has joined — and changes all
of it. One shape throughout: name a thing to see it, name it with a value
to change it. `secret` folded in as `id secret generate`, and the path
flags became global so they work either side of a subcommand.

There is no separate signing certificate to show: the endpoint key is
what signs records, and the report says so rather than leaving it to be
guessed.

Secrets appear in `id`, which is where you go to ask for one, and stay
out of `status`, logs, `Debug` and anything sent to a peer.

The hostname is now a signed claim, which is what makes changing it a
revocation. Records are one per author, so a new version replaces the
whole claim and no replica can keep the old name standing. RecordBody
generalised to Claim { address, range, hostname } + Release for that,
with the signing domain bumped; a name is bounded and canonicalised, and
a non-canonical one is rejected rather than repaired, because a repaired
version is not what its author signed. Two members claiming one name
resolve it like an address: lowest id wins, computed identically
everywhere. A member with only a name now has a record too, so an
IPv6-only network finally has a durable roster and an absent member can
be named rather than shown as a bare id.

Replacing the signing key is allowed and does not break the store. The
outgoing key signs a release for every network first, so the address and
name it held are freed rather than reserved forever to a key nobody has
— nothing can sign for a retired author, and by design no authority
could overrule one. Identity and releases commit together: a crash
between them would leave the old key gone and unable to sign what it
owed. It refuses while an agent holds the directory, rather than failing
on the lock with a message that says nothing about what to do.

The version counter is keyed by author as well as network, so a
replacement key starts its own sequence. The migration drops records
written under the previous signing domain instead of carrying rows that
every read must reject and that look exactly like corruption.

The hostname defaults to the machine's own name. Also fixed a
pre-existing flaky test: 40 random authors in a /24 collide by the
birthday problem often enough that its threshold failed about one run in
six, so the authors are fixed now and it tests a property rather than a
coin flip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:59:39 +01:00
tsunagiandClaude Opus 5 776eedc669 Report members, not symptoms
A peer going away showed up as three unrelated yellow rows: no peers
authenticated, N dial failures, one packet to an address nobody owns.
Each was true and none of them said the actual thing, which is that a
member we know about is offline. Worse, they are cumulative, so after
the peer came back the report still looked broken.

Two changes behind that.

Members are now a list, joined from the three sources that each know
part of the answer: signed state says who belongs and keeps saying it
while they are away, the session list says who is here, the overlay says
whose tunnel is up. Online first, then away, this agent left out because
the device section already covers it. An absent member is stated rather
than flagged — in a mesh of laptops being away is the ordinary condition
— and its failed dials are attributed to it instead of floating free as
a network-wide number.

Counters are history and no longer grade anything. Grading them is what
kept the report red long after the cause had gone. The one exception is
context-sensitive rather than cumulative: handshake failures with nobody
connected is the signature of a mismatched secret, so that is called
out.

NetworkStatus grows a member roster from the signed records, and the
overlay report carries the endpoint id so a tunnel can be matched to its
session. Note what the roster cannot do: a member is in signed state
only once it has claimed something, which today means an IPv4 address,
so an IPv6-only network still has no durable roster. Hostnames are not
persisted either, so an absent member is named by its id.

The control socket gained a version word. Adding these fields changed
how postcard parses the bytes, and without it a client one build ahead
of its agent reported "Found an Option discriminant that wasn't 0 or 1".
The check names a mismatch for whichever side is newer; an older agent
reading a newer request just drops the connection, so the CLI offers
that as a possibility rather than asserting it. It also now separates an
agent that is absent, which is an ordinary answer, from one that is
there and will not answer, which is a fault.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:26:16 +01:00
tsunagiandClaude Opus 5 8a6fb39689 Fold doctor into status, and stop id taking the lock
`status` asked a running agent and failed without one; `doctor` checked
the host and ignored the agent. Between them they answered one question
in two halves. They are now one command: device, agent, networks, host
capability, local addresses, all graded and aligned the same way.

`id` was worse than either. It spawned a whole agent to print three
facts, which took the directory lock and so failed with "owned by
another running agent instance" exactly when the answer was most wanted.
The lock exists to keep one writer over the mandatory state; reading who
this device is needs no such thing.

So both commands now prefer the running agent, which is live and
authoritative, and fall back to the state store, which takes no lock.
StateStore gains device_identity(), which reads and never writes:
load_or_create had the side effect of deciding an identity as a
consequence of asking about one.

Presentation moved out of the library. StatusReport::render is gone and
the CLI renders the structured data, so there is one renderer rather than
two that would drift. Health gains an Info level for rows that are facts
rather than checks — an endpoint id is neither good nor bad, and a column
of green next to plain data teaches the eye to ignore the column. A
report with no checks in it now ends without a summary instead of
claiming that everything checked out.

PathAddr and TransportKind grew Display impls; both were reaching the
user through {:?}, which is how "Direct via Ip(88.198.17.44:49792)" got
printed. The transport grading compares without regard to case, because
the agent answering can be a different build from the client asking and
this field's spelling has now changed once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:07:54 +01:00
tsunagiandClaude Opus 5 3e83e33313 Grade and colour what doctor reports
Findings now carry a level: ok, warn or FAIL. The distinction between the
middle two is the part worth getting right — warn is something the agent
runs without and that the user can fix from the line printed beneath it,
FAIL is something it cannot work around. A diagnostic that grades those
the wrong way round is worse than an ungraded one, so each check states
which it is.

The clearest case is storage: the same failure on the state directory is
FAIL and on the cache directory is warn, because one is mandatory and the
other is disposable. That asymmetry is central to the design and the
report now shows it. The control plane check also became real — it binds
a UDP socket rather than asserting that it could.

Colour is redundant by construction. Every row carries its grade as a
word, so the report reads identically when the escapes are gone: piped to
a file, on a dumb terminal, under NO_COLOR, or to someone who cannot
distinguish the colours. anstream decides whether they survive, which
also gets virtual terminal processing right on Windows; it and anstyle
were already in the tree through clap.

What is reported and how it looks are separated, so the rendering is
tested without a terminal: that a plain render contains no escapes, that
a styled one says the same thing once they are stripped, that columns
line up across sections whose labels differ in length, and that the
summary names the worst thing found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 14:54:34 +01:00
tsunagiandClaude Opus 5 4759e47e31 Remove tun-setup and the attach path it served
The managed interface supersedes both. They go together because apart
they are useless: attaching needs an interface somebody prepared, and
tun-setup existed only to say how to prepare one.

This also corrects what the last commit's README claimed. It said the
manual route was needed on macOS and Windows; it was not, and could not
be. The recipe printed Linux `ip` commands, and a persistent TUN that a
second process can attach to is a Linux concept — macOS creates a utun
by opening a control socket and there is nothing to hand over. So those
platforms were never served by this path, and their honest state is that
a real interface waits on a provisioner, with --no-tun meanwhile.

Gone with it: the interface-existence check, the /proc/net/if_inet6
address inspection and its DAD flag decoding, and the --interface flag,
which had one mode left.

Kept: the check that the allocated IPv4 address is really on a local
interface. The agent now assigns that address itself, so the check is no
longer telling a user what to run — it verifies the outcome instead of
trusting it, which is worth keeping precisely because the assumptions
around Linux address behaviour have been wrong here more than once. Its
message says which interface should have had the address rather than a
command to run.

Boxing Up(UpArgs) is fallout: TunSetupArgs had been masking how much
larger that variant is than its siblings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 14:46:39 +01:00
tsunagiandClaude Opus 5 b23e832a73 Manage the overlay interface instead of asking for it
The agent printed a list of `ip` commands and asked a human to run them.
That is fragile in the way hand-held setup always is: a persistent TUN
does not survive a reboot, a changed address allocation needs another
manual round, and a run that died leaves a half-configured interface the
next run trips over.

On Linux the agent now creates the interface, sets the MTU, brings it up
and assigns both overlay addresses itself, over netlink in process. No
`ip` is invoked, so nothing this path does can be influenced by PATH, a
shell, or anything a remote peer said.

Cleanup stops being an action. The interface is tied to an open file
descriptor and is deliberately not persistent, so the kernel removes it
when the agent goes — cleanly, by panic, by SIGKILL or by power loss
alike. That also retires `keep_addr_on_down` and `nodad`, which existed
only because an interface nobody held open lost carrier.

Anything still left behind is repaired rather than tripped over: an
abandoned TUN is replaced along with its stale addresses. Two cases
refuse instead of guessing — a link that is not a TUN, because a name
collision is no reason to destroy somebody's bridge, and a TUN another
process holds open, because that is a working overlay belonging to
someone else.

CAP_NET_ADMIN is kept out of the effective set except around the calls
that use it. Two facts shape how: capabilities are per thread, and
netlink checks the credentials of whichever thread calls sendmsg, which
with an async client is the connection task rather than the caller. So
netlink runs on one dedicated thread with a current-thread runtime where
nothing is polled outside a block_on, and opening the TUN descriptor is
synchronous with no await between the guard and its release.

The decision of what to change is a pure function, tested on every
platform; only the execution is behind the provisioner trait. macOS and
Windows get an implementation that refuses with an explanation and falls
back to attaching to a prepared interface, plus a mock host the tests
drive the whole plugin lifecycle against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 14:33:19 +01:00
tsunagiandClaude Opus 5 944d98389f Print the allocated IPv4 address in tun-setup
The overlay IPv4 address is not derived from the keys: it is allocated at
run time and signed, so on a fresh state directory there is nothing for
tun-setup to print. Once the agent has run, the claim is in state.sqlite,
and reading it back takes no directory lock, so tun-setup can show the
`ip address add` line while the agent is running. Records are verified on
the way out; the database is not a trust boundary.

The line needs no keep_addr_on_down and no nodad, unlike its IPv6
counterpart: Linux keeps IPv4 addresses on an interface that has lost
carrier, and IPv4 has no duplicate address detection to stall.

Also fix a race in the four-agent test. A peer counts as connected once
its session authenticates, which can precede the announcement carrying
its hostname, so reading the hostnames straight away occasionally saw
only two. It now waits for them like every other success condition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 14:04:44 +01:00
tsunagiandClaude Opus 5 8b333455f1 Report an allocated IPv4 address that is not on the host
IPv6 works end to end between two machines; IPv4 silently did not, and the
agent said nothing useful about why.

Allocation moved the address from something derivable before startup to
something agreed at run time, so an interface configured by an earlier
`tun-setup` carries a different address than the one allocated. The kernel
then sends packets with that stale source and every peer drops them as not
belonging to us — correct behaviour, invisible cause. Meanwhile pings to
our own allocated address fall into the tunnel and land in the "nobody
owns this" counter.

The agent now checks whether its allocated address is assigned anywhere on
the host — by binding a UDP socket to it, which needs no privileges and no
platform code — and reports the exact `ip address add` command until it
is, mentioning that another address of the range has to go.

`tun-setup` no longer prints a derived IPv4 address, because that number
is now wrong by construction. It says the agent will print the real one.

The unroutable counter keeps one destination as a sample, in status output
too. A bare count says something is wrong; the address says what.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 13:53:42 +01:00
tsunagiandClaude Opus 5 84c06c6cac Allocate IPv4 addresses and keep them, as signed state
Derived IPv4 addresses could not survive anything: they changed with the
range, and there was no way for a member to come back to the one it had.
Addresses are now allocated and recorded as signed facts, which is the
first slice of the model in docs/sync-model.md.

src/state/ holds one record per author per network, carrying that author's
complete current statement, signed with its persistent device key over a
length-prefixed canonical encoding. Merging follows the model's rules: a
higher version wins, an older one never rolls back a newer, duplicates are
idempotent, absence from a snapshot is not deletion, and a same-version
conflict is resolved identically on every replica and reported rather than
letting replicas diverge. Records are persisted in state.sqlite, with the
record and the author's version counter committed in one transaction
before anything is announced, and distributed as a State control message
that is merged into what the receiver already holds.

No vote, deliberately, despite the request. A majority is not a trust root
here — anyone with the secret can mint identities — and a quorum would
stall with one peer online and diverge across a partition. Signatures plus
a deterministic merge converge without either failure mode: two members
claiming one address at once are resolved by the lower endpoint id, and
the loser allocates again with a higher version.

The range moved from the plugin to the agent, defaults to 10.13.37.0/24,
and is now agreed rather than configured per member: a joining agent
adopts what the network already uses, so --ipv4-range only matters for
whoever starts it. The announcement went back to identity only (version 3)
since the range travels in signed records now.

A release tombstone exists and merges correctly, but nothing emits one
yet.

116 tests. The headline ones: an address survives restarting both agents,
three members get three distinct addresses, and a member started with a
different range adopts the one in use. Confirmed by hand with two CLI
agents restarted end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 13:43:01 +01:00
tsunagiandClaude Opus 5 ce64264027 Make the IPv4 overlay opt-in and detect a range mismatch
100.64.0.0/10 was a bad default: it is exactly Tailscale's range, and
carrier-grade NAT's. There is no IPv4 range that is free on every host, so
there is now no default at all — IPv4 is off until --ipv4-range names one.
IPv6 is unaffected and still works out of the box, because a ULA derived
from the network id collides with essentially nothing.

The more serious problem this exposed: the range is an input to the address
derivation, and each agent derives every peer's address itself. Two members
configured with different ranges would therefore derive different addresses
for each other and IPv4 would silently misroute. So the range now travels
in the announcement — not as a request and never trusted, only so the
mismatch is seen. A peer whose range disagrees gets no IPv4 address here,
keeps working over IPv6, and the reason is reported with both ranges named.

The announcement format goes to version 2. postcard is not
self-describing, so an older peer cannot read it; the version check already
catches that and now says which side needs updating.

The (Ipv4Addr, u8) tuple that had spread across six modules is now an
Ipv4Range with validation, Display and FromStr, so a bad --ipv4-range is
refused with a reason instead of being accepted and misbehaving later. It
is also rejected when passed without --wireguard rather than ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 13:11:09 +01:00
tsunagiandClaude Opus 5 cfab38824d Make the overlay dual stack
Every member now also derives an IPv4 address, from the same inputs as its
IPv6 one, into 100.64.0.0/10 by default. The range is configurable and IPv4
can be turned off with --no-ipv4.

IPv4 is honestly weaker than IPv6 here and the code says so. A 64 bit
interface identifier makes an IPv6 collision impossible in practice; IPv4
has nothing like that room, and in a /10 with 50 members two will derive the
same address about 0.03% of the time. A mesh with no coordinator cannot
allocate around that, so a collision is detected and resolved instead: the
member whose public key sorts lower keeps the address, a rule every member
computes identically and therefore agrees on. The other keeps IPv6 and is
flagged in the status. IPv6 always works; IPv4 almost always works and
degrades predictably.

Routing and address-ownership enforcement now cover both families: a packet
goes to the peer that owns its destination, and a decrypted packet is
dropped unless its source is an address derived for the peer that sent it,
IPv4 included.

Six new tests, among them a real IPv4 packet crossing a tunnel next to an
IPv6 one, a spoofed IPv4 source being dropped, and an IPv6-only overlay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 13:00:35 +01:00
tsunagiandClaude Opus 5 be459e5bd0 Add tsunagi status over a local control socket
There was no way to ask a running agent what it was doing; the only status
came from the periodic print of the `up` process itself.

The new ipc module is an adapter over the public API: nothing in the agent
core knows it exists, so a Windows named pipe or an authenticated loopback
socket can be added beside it. It is also a different interface from the
peer-to-peer protocol — between processes on one machine, authorised by
filesystem permissions rather than the network secret. The socket is 0600
inside an owner-only directory, the wire format is length-prefixed postcard
with the same bounds the network protocol uses, and the report types are
their own stable format rather than the crate's internals.

The socket path is derived from the state directory into XDG_RUNTIME_DIR
when there is one. A Unix socket address is limited to about 100 bytes, and
a deeply nested state directory overflows it — which is exactly what
happened on the first attempt.

Two presentation fixes while here. Multicast is counted separately from
unroutable traffic, because Linux emits multicast on every IPv6 interface
and it was showing up as "packets for unknown addresses" on a healthy
agent. And WireGuard protocol errors are no longer added into the dropped
counter: a few are normal while both ends start a handshake at once, and a
working tunnel was reporting "dropped 3" with no traffic at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 12:53:04 +01:00
tsunagiandClaude Opus 5 d2e336f2f9 Raise the overlay MTU to 1280: below that Linux disables IPv6
The setup recipe failed with a missing sysctl directory and "RTNETLINK
answers: Invalid argument". The cause was the default MTU of 1100.

IPv6 requires a minimum MTU of 1280 (RFC 8200) and Linux enforces it by
tearing IPv6 down on any interface below it: the per-device
/proc/sys/net/ipv6/conf entries disappear and an address can no longer be
assigned. Evidence on the test host: every interface at 1280 or above has
an IPv6 conf directory, every interface below it (1230, 1100) has none.

So the overlay MTU is now 1280, which is also the floor. A smaller value is
refused when the plugin opens, naming the reason, rather than surfacing as
an obscure netlink error after the user has already run four commands.

That leaves no slack against the other constraint: a packet needs mtu + 32
bytes of transport datagram, so 1312. A direct QUIC path offers roughly
1380 and fits; a relayed path may not, so the plugin now reports the exact
numbers when a link cannot carry a full-size packet, instead of only
counting silent drops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 12:41:57 +01:00
tsunagiandClaude Opus 5 38beb762d8 Fix the TUN setup recipe: the overlay address was being flushed
The setup this tool printed did not work, and the agent then correctly
refused to start. A persistent TUN interface has no carrier until a process
attaches to it, and Linux flushes IPv6 addresses from an interface that
loses carrier unless net.ipv6.conf.<dev>.keep_addr_on_down is set, which it
is not by default. So `ip -6 address add` on a freshly created interface
silently lost the address before the agent ever ran.

The recipe now brings the link up first, sets keep_addr_on_down, and adds
the address with `nodad` — without which duplicate address detection can
never finish on an interface with no carrier and the address stays
tentative and unusable.

The agent's own retry loop made this worse: it attached, failed the address
check, dropped the device and toggled the carrier, which flushed the
address again. The check now runs before attaching to an existing
interface, so looking is not destructive.

Failures are self-diagnosing now: the check parses the IFA_F_* flags, tells
tentative and DAD-failed apart from missing, and lists the addresses the
interface actually has.

Four new tests, including one that reads this host's real /proc/net/if_inet6
and one that pins the ordering of the setup commands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 12:36:16 +01:00
232 changed files with 69120 additions and 5704 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Windows: link the C runtime statically, so the agent and the tray start on a
# machine that has no Visual C++ redistributable installed. The installer ships
# nothing but our own files and Wintun.
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
+350
View File
@@ -0,0 +1,350 @@
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
deb: true
deb_arch: amd64
pkg_arch: x86_64
gnu_target: x86_64-unknown-linux-gnu
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
asset: tsunagi-linux-aarch64
archive: tar.gz
zig: true
deb: true
deb_arch: arm64
pkg_arch: aarch64
# 32-bit ARM with hardware float (ARMv7, `v7l`): single-board and edge
# devices. Statically linked like the others.
- os: ubuntu-latest
target: armv7-unknown-linux-musleabihf
asset: tsunagi-linux-armv7
archive: tar.gz
zig: true
deb: true
deb_arch: armhf
pkg_arch: armv7h
- 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 and packaging tooling
if: matrix.zig
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang libclang-dev libarchive-tools zstd
- 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 tsng
else
cargo build --release --locked --target "${{ matrix.target }}" --bin tsng
fi
# The tray GUI. On Linux it is built against glibc (an old baseline for
# portability), not musl: a GUI must dlopen libGL/X11/Wayland at runtime,
# which a static musl binary cannot. macOS and Windows build it natively.
- name: Build the tray GUI
shell: bash
run: |
set -euo pipefail
if [[ "${{ runner.os }}" == "Linux" ]]; then
if [[ -n "${{ matrix.gnu_target }}" ]]; then
# tray-icon links GTK and libappindicator through pkg-config, which
# has no cross story under zig, so the Linux GUI is built natively
# (x86_64 only) against the runner's glibc.
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev libayatana-appindicator3-dev libxdo-dev
cargo build --release --locked \
--target "${{ matrix.gnu_target }}" --bin tsunagi-tray
else
echo "no GUI build for ${{ matrix.target }}"
fi
else
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi-tray
fi
- name: Package
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
stage="dist/pkg/${{ matrix.asset }}"
mkdir -p "$stage"
bin="tsng"
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsng.exe"
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
# The tray GUI alongside it, where it was built (Linux: x86_64 only).
tray="tsunagi-tray"
[[ "${{ runner.os }}" == "Windows" ]] && tray="tsunagi-tray.exe"
if [[ "${{ runner.os }}" == "Linux" ]]; then
if [[ -n "${{ matrix.gnu_target }}" ]]; then
cp "target/${{ matrix.gnu_target }}/release/${tray}" "$stage/"
fi
else
cp "target/${{ matrix.target }}/release/${tray}" "$stage/"
fi
# 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 \
dist/linux/50-tsunagi-resolved.rules "$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"
# The tray as an application bundle, for the Homebrew cask.
bash dist/macos/build-app.sh \
"target/${{ matrix.target }}/release/tsunagi-tray" \
"$version" "$stage/Tsunagi.app"
;;
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
# The Windows installer: the agent as a service, Wintun, the tray and the
# PATH entry in one setup program. Wintun is the signed library from
# wintun.net, pinned by hash so a changed download fails the build.
- name: Build the Windows installer
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$version = $env:GITHUB_REF_NAME.TrimStart('v')
Invoke-WebRequest -Uri https://www.wintun.net/builds/wintun-0.14.1.zip -OutFile wintun.zip
$hash = (Get-FileHash wintun.zip -Algorithm SHA256).Hash
if ($hash -ne '07C256185D6EE3652E09FA55C0B673E2624B565E02C4B9091C79CA7D2F24EF51') {
throw "wintun.zip has an unexpected hash: $hash"
}
Expand-Archive wintun.zip -DestinationPath wintun-dl
choco install innosetup -y --no-progress
$iscc = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'
$stage = Join-Path $PWD 'dist/pkg/${{ matrix.asset }}'
& $iscc "/DAppVersion=$version" "/DSourceDir=$stage" `
"/DWintunDir=$(Join-Path $PWD 'wintun-dl/wintun/bin/amd64')" `
"/O$PWD" dist/windows/tsunagi.iss
if ($LASTEXITCODE -ne 0) { throw "ISCC failed with $LASTEXITCODE" }
# A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops
# the static binary, the systemd service and the polkit rule. Built with
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
- name: Build the Debian package
if: matrix.deb
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
bash dist/deb/build-deb.sh \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.deb_arch }}" \
"$version" \
"tsunagi_${version}_${{ matrix.deb_arch }}.deb"
# A dependency-free Arch package (.pkg.tar.zst), assembled with bsdtar and
# zstd (no makepkg); same binary, service and polkit rule. See
# dist/arch/build-pkg.sh.
- name: Build the Arch package
if: matrix.pkg_arch
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
pkgver="${version//-/_}"
bash dist/arch/build-pkg.sh \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.pkg_arch }}" \
"$version" \
"tsunagi-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst"
# The GUI variants: the same agent plus the tray binary and a desktop
# launcher, named tsunagi-gui (conflicting with the headless packages).
- name: Build the GUI packages
if: matrix.gnu_target
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
pkgver="${version//-/_}"
tray="target/${{ matrix.gnu_target }}/release/tsunagi-tray"
bash dist/deb/build-deb.sh \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.deb_arch }}" "$version" \
"tsunagi-gui_${version}_${{ matrix.deb_arch }}.deb" "$tray"
bash dist/arch/build-pkg.sh \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.pkg_arch }}" "$version" \
"tsunagi-gui-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst" "$tray"
# @v3 on purpose: Gitea does not support the v4 artifact backend. This is
# what makes the archive (and the .deb, where built) show up under the run
# and hands it to `publish`.
- name: Upload the build outputs as run artifacts
uses: actions/upload-artifact@v3
with:
name: ${{ matrix.asset }}
path: |
${{ matrix.asset }}-*.*
*.deb
*.pkg.tar.zst
tsunagi-setup-*.exe
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' -o -name '*.deb' -o -name '*.pkg.tar.zst' -o -name 'tsunagi-setup-*.exe' \) -print0)
exit "$rc"
+322
View File
@@ -0,0 +1,322 @@
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: it uses the current
# artifact actions (Node 24) 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 (which pins
# the older @v3 artifact actions it supports); should a Gitea instance also read
# .github/workflows, the guard skips these jobs there so they never fail on the
# 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
deb: true
deb_arch: amd64
pkg_arch: x86_64
gnu_target: x86_64-unknown-linux-gnu
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
asset: tsunagi-linux-aarch64
archive: tar.gz
zig: true
deb: true
deb_arch: arm64
pkg_arch: aarch64
# 32-bit ARM with hardware float (ARMv7, `v7l`): single-board and edge
# devices. Statically linked like the others.
- os: ubuntu-latest
target: armv7-unknown-linux-musleabihf
asset: tsunagi-linux-armv7
archive: tar.gz
zig: true
deb: true
deb_arch: armhf
pkg_arch: armv7h
- 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@v5
- 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 and packaging tooling
if: matrix.zig
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang libclang-dev libarchive-tools zstd
- 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 tsng
else
cargo build --release --locked --target "${{ matrix.target }}" --bin tsng
fi
# The tray GUI. On Linux it is built against glibc (an old baseline for
# portability), not musl: a GUI must dlopen libGL/X11/Wayland at runtime,
# which a static musl binary cannot. macOS and Windows build it natively.
- name: Build the tray GUI
shell: bash
run: |
set -euo pipefail
if [[ "${{ runner.os }}" == "Linux" ]]; then
if [[ -n "${{ matrix.gnu_target }}" ]]; then
# tray-icon links GTK and libappindicator through pkg-config, which
# has no cross story under zig, so the Linux GUI is built natively
# (x86_64 only) against the runner's glibc.
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev libayatana-appindicator3-dev libxdo-dev
cargo build --release --locked \
--target "${{ matrix.gnu_target }}" --bin tsunagi-tray
else
echo "no GUI build for ${{ matrix.target }}"
fi
else
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi-tray
fi
- name: Package
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
stage="dist/pkg/${{ matrix.asset }}"
mkdir -p "$stage"
bin="tsng"
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsng.exe"
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
# The tray GUI alongside it, where it was built (Linux: x86_64 only).
tray="tsunagi-tray"
[[ "${{ runner.os }}" == "Windows" ]] && tray="tsunagi-tray.exe"
if [[ "${{ runner.os }}" == "Linux" ]]; then
if [[ -n "${{ matrix.gnu_target }}" ]]; then
cp "target/${{ matrix.gnu_target }}/release/${tray}" "$stage/"
fi
else
cp "target/${{ matrix.target }}/release/${tray}" "$stage/"
fi
# 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 \
dist/linux/50-tsunagi-resolved.rules "$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"
# The tray as an application bundle, for the Homebrew cask.
bash dist/macos/build-app.sh \
"target/${{ matrix.target }}/release/tsunagi-tray" \
"$version" "$stage/Tsunagi.app"
;;
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
# The Windows installer: the agent as a service, Wintun, the tray and the
# PATH entry in one setup program. Wintun is the signed library from
# wintun.net, pinned by hash so a changed download fails the build.
- name: Build the Windows installer
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$version = $env:GITHUB_REF_NAME.TrimStart('v')
Invoke-WebRequest -Uri https://www.wintun.net/builds/wintun-0.14.1.zip -OutFile wintun.zip
$hash = (Get-FileHash wintun.zip -Algorithm SHA256).Hash
if ($hash -ne '07C256185D6EE3652E09FA55C0B673E2624B565E02C4B9091C79CA7D2F24EF51') {
throw "wintun.zip has an unexpected hash: $hash"
}
Expand-Archive wintun.zip -DestinationPath wintun-dl
choco install innosetup -y --no-progress
$iscc = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'
$stage = Join-Path $PWD 'dist/pkg/${{ matrix.asset }}'
& $iscc "/DAppVersion=$version" "/DSourceDir=$stage" `
"/DWintunDir=$(Join-Path $PWD 'wintun-dl/wintun/bin/amd64')" `
"/O$PWD" dist/windows/tsunagi.iss
if ($LASTEXITCODE -ne 0) { throw "ISCC failed with $LASTEXITCODE" }
# A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops
# the static binary, the systemd service and the polkit rule. Built with
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
- name: Build the Debian package
if: matrix.deb
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
bash dist/deb/build-deb.sh \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.deb_arch }}" \
"$version" \
"tsunagi_${version}_${{ matrix.deb_arch }}.deb"
# A dependency-free Arch package (.pkg.tar.zst), assembled with bsdtar and
# zstd (no makepkg); same binary, service and polkit rule. See
# dist/arch/build-pkg.sh.
- name: Build the Arch package
if: matrix.pkg_arch
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
pkgver="${version//-/_}"
bash dist/arch/build-pkg.sh \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.pkg_arch }}" \
"$version" \
"tsunagi-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst"
# The GUI variants: the same agent plus the tray binary and a desktop
# launcher, named tsunagi-gui (conflicting with the headless packages).
- name: Build the GUI packages
if: matrix.gnu_target
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
pkgver="${version//-/_}"
tray="target/${{ matrix.gnu_target }}/release/tsunagi-tray"
bash dist/deb/build-deb.sh \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.deb_arch }}" "$version" \
"tsunagi-gui_${version}_${{ matrix.deb_arch }}.deb" "$tray"
bash dist/arch/build-pkg.sh \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.pkg_arch }}" "$version" \
"tsunagi-gui-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst" "$tray"
- name: Upload the build outputs as run artifacts
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.asset }}
path: |
${{ matrix.asset }}-*.*
*.deb
*.pkg.tar.zst
tsunagi-setup-*.exe
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@v8
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 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' -o -name '*.deb' -o -name '*.pkg.tar.zst' -o -name 'tsunagi-setup-*.exe' \))
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
+1
View File
@@ -1 +1,2 @@
/target
/.claude
+113 -23
View File
@@ -8,36 +8,96 @@ before touching the code. Do not restate the full design here; follow the links.
A proof-of-concept agent library for small private mesh networks. A user
supplies a network name and one shared secret; agents derive the same network
space independently, find each other, prove membership and exchange control
messages. Scope and non-scope are in [README.md](README.md).
messages. What it does and does not do is in [docs/architecture.md](docs/architecture.md) and [docs/threat-model.md](docs/threat-model.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-core/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 direct links, of
whatever kind. A link is a way to reach a neighbour and a protocol is an
end-to-end tunnel to a peer; the two meet only in the envelope, so a
tunnel never depends on which transports carry it. 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-core/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-core/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
key. Outbound packets are routed to the owner of the destination address;
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 `crates/tsunagi-core/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
stall with one peer online.
- **A record and the author's own version counter commit together**, in one
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.
@@ -57,7 +117,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-core/src/identity/network.rs` and `crates/tsunagi-core/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
@@ -68,7 +128,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-core/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
@@ -85,24 +145,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/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-core/src/identity/` | device identity; deterministic network space identity and derived keys |
| `crates/tsunagi-core/src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
| `crates/tsunagi-core/src/discovery.rs` | candidate sources; test and static backends |
| `crates/tsunagi-core/src/proto/` | framing, message formats, membership handshake |
| `crates/tsunagi-core/src/net.rs` | iroh endpoint adapter and observability snapshots |
| `crates/tsunagi-core/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
| `crates/tsunagi-core/src/state/` | signed records that outlive a session, their merge rules and address allocation |
| `crates/tsunagi-core/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks, broadcast host rules |
| `crates/tsunagi-core/src/dataplane/` | the protocol contract and the authenticated packet transport |
| `crates/tsunagi-core/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/` | the command line agent, built as `tsng`; 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
@@ -124,7 +195,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
+3660 -97
View File
File diff suppressed because it is too large. Load diff
+32 -56
View File
@@ -1,64 +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"]
license = "WTFPL"
repository = "https://github.com/house-of-vanity/tsunagi"
[features]
default = ["cli"]
# The `tsunagi` command line binary. Library users can opt out.
cli = ["dep:clap", "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"]
[[bin]]
name = "tsunagi"
path = "src/bin/tsunagi.rs"
required-features = ["cli"]
[dependencies]
clap = { version = "4.5", features = ["derive", "env"], 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"
netwatch = "0.19.3"
bytes = "1.12.1"
boringtun = { version = "0.7.1", default-features = false }
tun = { version = "0.8", features = ["async"], 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"] }
+13
View File
@@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.
+153 -255
View File
@@ -1,302 +1,200 @@
# tsunagi
<p align="center">
<img src="dist/linux/tsunagi.svg" width="96" alt="">
</p>
A proof-of-concept Rust library for **small private mesh networks** — a handful
of friends, home machines, a few servers. Units to dozens of participants, not
thousands.
<h1 align="center">tsunagi</h1>
The end user configures exactly two things:
<p align="center">
<b>A mesh VPN with no server.</b><br>
Pick a network name, share a secret, and your machines are on one private network.
</p>
```text
network_name
secret # one shared secret; "password" and "secret" mean the same value
<p align="center">
<a href="https://github.com/house-of-vanity/tsunagi/releases/latest"><img alt="release" src="https://img.shields.io/github/v/release/house-of-vanity/tsunagi?include_prereleases"></a>
<a href="https://github.com/house-of-vanity/tsunagi/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/house-of-vanity/tsunagi/actions/workflows/ci.yml/badge.svg"></a>
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-WTFPL-blue"></a>
</p>
```console
laptop$ tsng join -n home
joined `home`
secret tsn1u7c…
server$ tsng join -n home -s tsn1u7c…
server$ tsng id hostname server
laptop$ ssh server.home
```
From those, every agent independently derives the same network space. There is
no central server, no network owner with special powers, no registration and no
majority vote. Anyone who knows the parameters can join; nobody has to trust
anybody else.
No account, no coordination server, no admin console, no keys to approve.
Anyone who knows the **name** and the **secret** is in. Nobody else can find
the network, let alone enter it.
## What this proof of concept actually does
## Why
A working library with **real iroh connections** and integration tests:
Tailscale, Headscale and ZeroTier are great, but each needs a control server:
theirs, or one you have to run and keep alive. tsunagi doesn't. Machines find
each other through the public BitTorrent DHT, connect directly (hole punching,
with relay fallback), and keep the network state among themselves.
- persistent device identity stored in SQLite, stable across restarts;
- several independent networks at once in one agent;
- deterministic network identity derived from name + secret;
- candidates supplied by a replaceable discovery component;
- real iroh connections plus an explicit mutual proof of network membership;
- a small versioned control protocol: handshake, hostname/capability
announcement, ping/pong;
- automatic reconnect with bounded exponential backoff and jitter;
- status snapshots, an event stream and honest diagnostics;
- configuration restored after a restart;
- correct behaviour when the disposable cache is missing or corrupt;
- a **WireGuard data plane**, in userspace: its own key per network,
deterministic IPv6 overlay addressing, real tunnels carried over iroh, and
address ownership enforced rather than believed;
- a **command line agent**, `tsunagi`.
| | Tailscale | Headscale | ZeroTier | **tsunagi** |
| ---------------------- | ------------------ | ---------------- | ------------------- | ------------------- |
| Control server | hosted by vendor | you run it | hosted or self-run | **none** |
| Account / sign-in | yes | yes | yes | **no** |
| To join a network | sign in, get approved | pre-auth key | network ID, approval | **name + secret** |
| Direct connections | yes | yes | yes | yes |
| Exit nodes | yes | yes | via routes | yes |
### What it deliberately does **not** do
## Features
Not implemented, and not pretended to be: Mainline DHT, 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
[docs/sync-model.md](docs/sync-model.md). The WireGuard plugin's own limits,
including that its system backend is Linux-only, are in
[docs/wireguard.md](docs/wireguard.md#limits-and-future-work).
- **Two things to know:** a network name and a secret. A secret is generated for you when you make a network.
- **Direct, encrypted links** between members (WireGuard cryptography), with relay fallback behind strict NATs.
- **Names for machines:** `server.home` just works, even while `server` is switched off. A name belongs to whoever claimed it first; a later device with the same name keeps working by address and is told so.
- **Exit nodes:** send all your internet traffic through any member that offers one.
- **Multi-hop:** members that can't reach each other directly are routed through a member that can, over whatever kind of link each hop has. A member reached that way is online, and shown as relayed; traffic moves to a direct link by itself the moment one comes up.
- **LAN games:** UDP broadcasts are relayed, so "LAN" lobbies show up across the mesh.
- **Several networks at once** on one device.
- **Tray app** for Windows, macOS and Linux; a command line for everything.
**Control and data are separated logically, not physically.** Both ride on
iroh, on different ALPNs and different connections, so the data plane inherits
iroh's hole punching and relay fallback instead of reimplementing them — while
the control protocol still knows nothing about packets and can keep a different
transport underneath it later. Filtering user traffic remains the operating
system's and the user's responsibility, not this library's.
## Install
## Requirements
Grab a build from the [latest release](https://github.com/house-of-vanity/tsunagi/releases/latest).
- 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
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
`--no-tun` skips even that.
| Platform | How |
| --------------------- | -------------------------------------------------------------------------------- |
| **Windows** | Run `tsunagi-setup-<version>-x86_64.exe`. Installs the service, the tray and `tsng`. |
| **macOS** | `brew tap house-of-vanity/tap && brew install --cask tsunagi-gui`<br>CLI only: `brew install tsunagi` |
| **Debian / Ubuntu** | `sudo apt install ./tsunagi-gui_<version>_amd64.deb` (`tsunagi_…` for no tray) |
| **Arch** | `sudo pacman -U tsunagi-gui-<version>-1-x86_64.pkg.tar.zst` |
| **NixOS** | [Flake and module](#nixos) below |
| **Any other Linux** | Unpack the tarball, then `sudo ./install.sh && sudo systemctl enable --now tsunagi` |
## Trying it on two machines
Linux packages run the agent as an unprivileged service user. Add yourself to
the `tsunagi` group (`sudo usermod -aG tsunagi $USER`, then log in again) to
use `tsng` and the tray without `sudo`.
On the first machine:
## Quick start
```bash
cargo build --release
./target/release/tsunagi secret # prints tsn1...; share it privately
./target/release/tsunagi doctor # what this host can and cannot do
./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard
```sh
tsng join -n home # first machine: makes the network and prints a secret
tsng join -n home -s tsn1… # every other machine
tsng status # who is online, over which path
```
It prints its endpoint id and then waits. On the second machine, pass that id:
Every new network gets a stable pseudorandom `/24` in `10.0.0.0/8`, derived
from its network id. Its members agree on that range even when starting offline,
and each gets a name like `<host>.home`. Existing signed ranges are preserved.
```bash
./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard \
--peer <endpoint-id-from-the-first-machine>
If two configured networks have overlapping ranges, the agent runs only one.
`tsng status`, `tsng network` and the tray show which network blocks the
other. `tsng network start <name-or-id>` or the tray’s “Switch to this network”
button suspends the conflicting networks. They resume automatically when the
selected network stops; the selection survives restarting the agent. Explicitly
stopping a suspended network cancels its automatic resume.
Machines are called `tsunagi-<id>` until you rename them: `tsng id hostname laptop`.
### Exit nodes
```sh
tsng network exit-node home on # on the machine that should be the exit
tsng exit-node # on another one: who offers an exit
tsng exit-node laptop # send everything through it
tsng exit-node off
```
Within a few seconds both print something like:
The tray does the same with a checkbox. An exit node sees everything you send
through it, so use only members you trust.
[Details and per-OS notes](docs/exit-node.md).
```text
+ peer b47c958462 connected over Direct rtt=Some(4.5ms)
+ data link to b47c958462 for wireguard: Direct via Ip(…), datagram 1382
The default data transport order is `wg,awg,wg-quic,tcp-tls`. AWG negotiates a
fresh masking profile and outer keys for each pair connection attempt, including
reentry after fallback. Sustained data loss triggers switching independently of
the iroh control connection. [Configuration and limits](docs/amnezia.md).
--- status ---
control: 1 peer(s), 0 dial failure(s), 0 handshake failure(s)
wireguard: tsunkkcp43lmdje on fd15:1d9e:fa21:f201:…/64 mtu 1100, 1/1 tunnel(s) established
4jO4kx9Z fd15:1d9e:fa21:f201:… handshake 3s ago tx=0 rx=0 dropped=0 path=Direct via Ip(…)
```
## Platforms
`1/1 tunnel(s) established` means a real WireGuard handshake completed. Then
`ping6` the peer's overlay address.
| Platform | Build | CLI `tsng` | Tray | Exit node: offer / use | Packages |
| ------------------------- | ------------ | :--------: | :--: | :--------------------: | ----------------------------------- |
| Linux x86_64 | release | ✔ | ✔ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball, Nix |
| Linux aarch64 | release | ✔ | –¹ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball, Nix |
| Linux armv7 | release | ✔ | –¹ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball |
| macOS, Apple silicon | release | ✔ | ✔ | ✔ / ✔² | Homebrew, tarball |
| macOS, Intel | from source | ✔³ | ✔³ | ✔ / ✔²³ | – |
| Windows x86_64 | release | ✔ | ✔ | ✔ / ✔² | installer, zip |
Notes:
<sup>¹ Not in the release; builds from source (and through Nix).
² Newer and less battle-tested than on Linux.
³ Builds from source, not covered by CI.</sup>
- Only one side needs `--peer`; the link is bidirectional.
- The default `--transport relay` uses iroh's public address lookup and relays,
so two machines behind NAT find each other. `--transport local` keeps everything
on the local network. See *How peers find each other* below — it is worth
understanding what gets published.
- Without a network interface, add `--no-tun`: the mesh, the data links and the
WireGuard handshakes all still run and are visible in the status output, only
traffic does not reach the operating system. That is the quickest way to
confirm the network forms.
- For real traffic, see *Running unprivileged* below.
The Linux CLI is a single static binary: it runs on any distribution.
## Running unprivileged
## NixOS
The agent does not need to run as root. Creating a network interface and
giving it an address do need privileges, but they are a **one-time setup step**
that can be done separately.
The repository is a flake with a package and a module.
Ask the agent what it needs, then run that once as root:
```nix
# flake.nix
{
inputs.tsunagi.url = "github:house-of-vanity/tsunagi";
```bash
tsunagi tun-setup --network lab --secret "$SECRET"
```
```text
# Network lab (jwc6dcrtmo5zzdk7f6wfpkcqrpvqwr6po7vz3q2fvttgolt4ijfa)
# Interface tsunjwc6dcrtmo5, address fd80:1210:f724:f620:d1bb:f982:3b6e:19bd/64, mtu 1100
# Run once as root; then run `tsunagi up` as ab.
sudo ip tuntap add dev tsunjwc6dcrtmo5 mode tun user ab
sudo ip -6 address add fd80:1210:f724:f620:d1bb:f982:3b6e:19bd/64 dev tsunjwc6dcrtmo5
sudo ip link set dev tsunjwc6dcrtmo5 mtu 1100 up
```
`user ab` is the point: the interface is persistent and owned by that user, so
`tsunagi up` afterwards opens it with **no privileges and no capabilities at
all**. The interface name and address are derived, so they are stable — the
setup survives restarts and only has to be redone if the network name, the
secret or this agent's WireGuard key changes.
Other ways, and their trade-offs:
| approach | agent runs as | notes |
|---|---|---|
| `tsunagi tun-setup` (above) | ordinary user, no capabilities | recommended |
| `sudo setcap cap_net_admin+ep ./tsunagi` | ordinary user, one capability | the agent can then create the interface itself, but **still cannot assign the IPv6 address** (see below), so the `ip -6 address add` line is needed anyway. The capability is lost on every rebuild or copy. |
| systemd service | `User=`, `AmbientCapabilities=CAP_NET_ADMIN` | same caveat about the address |
| plain `sudo tsunagi up` | root | everything works, nothing is isolated |
**Known limitation.** The agent cannot assign the IPv6 overlay address itself:
the `tun` crate sets addresses through an IPv4-only ioctl, so an IPv6 address
has to come from `ip -6 address add` or an equivalent. Rather than start with
an interface that could never receive anything, the agent checks for the
address in `/proc/net/if_inet6` and refuses with the exact command to run.
Doing it in-process would mean talking netlink directly, which is possible but
not implemented.
## Checks
```bash
cargo fmt --all -- --check
cargo clippy --locked --workspace --all-targets -- -D warnings
cargo test --locked --workspace --all-targets
```
The whole suite runs offline on loopback. Set `TSUNAGI_TEST_LOG=tsunagi=debug`
to see agent logs while a test runs.
There are also two runnable demos, which are demos and not substitutes for the
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.
## Usage
```rust,no_run
use std::sync::Arc;
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::{NetworkName, NetworkSecret};
use tsunagi::proto::ControlMessage;
use tsunagi::{Agent, Result};
// The library never starts a runtime, installs a logger, handles signals,
// forks, or calls process::exit. The binary owns all of that.
#[tokio::main]
async fn main() -> Result<()> {
let config = AgentConfig::new(StoragePaths::user_default()?)
.with_transport(TransportPolicy::N0Defaults)
.with_discovery(Arc::new(SharedMemoryDiscovery::new()));
let agent = Agent::spawn(config).await?;
let name = NetworkName::new("kitchen-table")?;
let secret = NetworkSecret::generate(); // 32 random bytes
println!("share this: {}", secret.encode().as_str());
let network = agent.join_network(&name, &secret).await?;
let mut events = agent.subscribe();
tokio::spawn(async move {
while let Ok(event) = events.recv().await {
println!("{event:?}");
outputs = { nixpkgs, tsunagi, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
tsunagi.nixosModules.default
{
services.tsunagi = {
enable = true;
tray.enable = true; # optional, for desktops
users = [ "alice" ]; # may use tsng and the tray without sudo
};
}
});
for peer in agent.network_status(network).await?.connected_peers() {
agent
.send(network, peer, ControlMessage::Ping { seq: 1, payload: vec![] })
.await?;
}
agent.shutdown().await;
Ok(())
];
};
};
}
```
`TransportPolicy::LocalOnly` is the default, so a plain `AgentConfig::new` never
reaches the internet by accident. Opt into `DirectOnly` or `N0Defaults`
explicitly.
Just trying it: `nix run github:house-of-vanity/tsunagi#tsunagi-cli -- --help`.
`packages.<system>.tsunagi` (with the tray) and `tsunagi-cli` are also exported,
along with `overlays.default`.
## How peers find each other
## Build from source
Two different lookups are involved, and only one of them is this project's:
Rust 1.91 or newer.
**1. Resolving one endpoint's address — iroh's, and it works today.**
With `--transport relay` or `--transport 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.
```sh
cargo build --release --bin tsng # the agent and CLI
cargo build --release --bin tsunagi-tray # the tray (Linux needs GTK 3 and libayatana-appindicator)
```
**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.
Windows needs [Wintun](https://www.wintun.net) next to `tsng.exe`.
Per-platform notes: [Linux](dist/linux/README.md), [Arch](dist/arch/README.md),
[macOS](dist/macos/README.md), [Windows](dist/windows/README.md).
What this means in practice:
## How it works
- 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.
- 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.
- Your name and secret derive a network identity. Members publish signed
contact records to the public Mainline DHT under it, and find each other there.
- Every connection proves knowledge of the secret before it's accepted.
Traffic between members is end-to-end encrypted, relays included.
- Addresses and names are claimed with each device's own key and replicated
between members. There is no one in charge, so nothing to be taken down.
## Storage
Two physically separate SQLite files, placed wherever the library's
configuration says (`StoragePaths`). A future system service supplies its own
paths; tests always use temporary directories.
| file | holds | when damaged |
|----------------|--------------------------------------------------|-------------------------|
| `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 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.
The secret is the only key, so make it a good one (the generated ones are).
The [threat model](docs/threat-model.md) says what this does and doesn't protect against.
## Documentation
- [docs/architecture.md](docs/architecture.md) — module boundaries and runtime.
- [docs/wireguard.md](docs/wireguard.md) — the WireGuard plugin: overlay
addressing, announcements, backends, reconciliation.
- [docs/protocol.md](docs/protocol.md) — identity derivation, framing, handshake.
- [docs/sync-model.md](docs/sync-model.md) — the planned signed-state model and
what is deliberately not built yet.
- [docs/threat-model.md](docs/threat-model.md) — threat model and known limits.
- [docs/testing.md](docs/testing.md) — what the suite covers and what it does not.
- [AGENTS.md](AGENTS.md) — rules for anyone (human or agent) changing this repo.
## Security in one paragraph
Membership is proved by an HMAC over a transcript keyed by a value derived from
the shared secret, bound to the specific iroh connection through the TLS
exporter, to the network id, to both endpoint identities and to distinct role
labels. This targets high-entropy secrets: there is no PAKE here, so a short
human passphrase is guessable offline by anyone who can reach the handshake.
Anyone who knows the secret is a full participant and can create many
identities. Read [docs/threat-model.md](docs/threat-model.md) before relying on
any of this.
[Architecture](docs/architecture.md) ·
[Protocol](docs/protocol.md) ·
[WireGuard data plane](docs/wireguard.md) ·
[Pairwise Amnezia transport](docs/amnezia.md) ·
[Routing](docs/routing.md) ·
[Exit nodes](docs/exit-node.md) ·
[Threat model](docs/threat-model.md) ·
[Contributing](AGENTS.md)
## Licence
MIT OR Apache-2.0.
[WTFPL](LICENSE): do what the fuck you want to.
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "tsunagi-awg"
version = "0.1.0"
description = "Ephemeral pairwise Amnezia transport for Tsunagi."
edition.workspace = true
rust-version.workspace = true
license = "WTFPL AND Apache-2.0"
repository.workspace = true
[dependencies]
tsunagi-core = { path = "../tsunagi-core" }
tsunagi-wg-quic = { path = "../tsunagi-wg-quic" }
tsunagi-wireguard = { path = "../tsunagi-wireguard" }
iroh.workspace = true
tokio = { workspace = true, features = ["net", "time", "sync", "macros"] }
serde.workspace = true
postcard.workspace = true
bytes.workspace = true
tracing.workspace = true
rand = "0.10"
zeroize = { version = "1.9", features = ["derive"] }
boringtun = { version = "0.7.1", default-features = false }
chacha20 = "0.9"
[dev-dependencies]
tsunagi-core = { path = "../tsunagi-core", features = ["testing"] }
tempfile.workspace = true
[lints]
workspace = true
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Friderika Coulson (NanoBee)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
# Amnezia packet engine
`src/masking.rs` is adapted from `src/core/amnezia.rs` in
https://github.com/RunaRoo/AmneziaWG-RS at
`51579a7d6e8882d1d8d6d00822f09e18a9423b73`, under Apache-2.0.
The upstream license is retained in `LICENSE-AmneziaWG-RS`.
The adaptation replaces configuration-file loading with bounded typed pair
profiles, updates RNG calls to rand 0.10, removes an unnecessary unwrap, and
does not embed upstream's Node, global executors, OS interface operations or
detached tasks. Noise/WireGuard is supplied by boringtun, already used by
Tsunagi. Each pair owns one socket and one cancellable packet-engine task.
This is Tsunagi AWG wire version 1, not a claim of compatibility with external
Amnezia servers. The masking engine substitutes message headers outside the
standard WireGuard state machine and restores them before Noise validation.
There are no remote file paths, shell hooks, or persisted pair profiles.
The adapted masking source remains Apache-2.0. Newly written Tsunagi code
follows the repository's WTFPL. This package declares both licenses in Cargo
metadata; the workspace license alone does not describe its copied source.
+16
View File
@@ -0,0 +1,16 @@
//! Pairwise, ephemeral Amnezia packet transport with independent end-to-end tunnels.
//! All negotiation travels over authenticated Tsunagi control sessions.
mod masking;
mod plugin;
pub mod profile;
mod session;
mod stun;
mod transport;
pub use plugin::AwgPlugin;
pub use transport::{AwgTransport, PairOverview};
/// Protocol identifier and default second-choice direct transport.
pub const AWG_PROTOCOL: &str = "awg";
/// Tsunagi AWG wire version, independent of software releases.
pub const AWG_WIRE_VERSION: u16 = 1;
+336
View File
@@ -0,0 +1,336 @@
// Adapted from RunaRoo/AmneziaWG-RS, commit 51579a7d6e8882d1d8d6d00822f09e18a9423b73.
// Apache-2.0; see LICENSE-AmneziaWG-RS and UPSTREAM.md.
#![allow(dead_code, missing_docs)]
// AmneziaWG configuration, header protection, and traffic shaping.
use bytes::{Bytes, BytesMut};
use chacha20::{
ChaCha20,
cipher::{KeyIvInit, StreamCipher},
};
use rand::RngExt;
pub const HEADER_PROTECTION_NONCE_SIZE: usize = 12;
pub const DEFAULT_UDP_WINDOW: usize = 500;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct Range {
pub min: u32,
pub max: u32,
}
impl Range {
pub fn contains(&self, val: u32) -> bool {
val >= self.min && val <= self.max
}
pub fn random(&self) -> u32 {
if self.min >= self.max {
self.min
} else {
rand::rng().random_range(self.min..=self.max)
}
}
pub fn is_zero(&self) -> bool {
self.min == 0 && self.max == 0
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AwgKind {
Initiation,
Response,
Cookie,
Transport,
}
impl AwgKind {
pub fn fixed_size(self) -> usize {
match self {
Self::Initiation => 148,
Self::Response => 92,
Self::Cookie => 64,
Self::Transport => 32,
}
}
fn standard_type(self) -> u32 {
match self {
Self::Initiation => 1,
Self::Response => 2,
Self::Cookie => 3,
Self::Transport => 4,
}
}
}
#[derive(Debug)]
pub struct DecodedPacket {
pub kind: AwgKind,
pub message: Bytes,
pub wire_len: usize,
}
pub struct AmneziaConfig {
pub jc: u32,
pub jmin: usize,
pub jmax: usize,
pub s1: usize,
pub s2: usize,
pub s3: usize,
pub s4: usize,
pub h1: Range,
pub h2: Range,
pub h3: Range,
pub h4: Range,
pub i1: String,
pub i2: String,
pub i3: String,
pub i4: String,
pub i5: String,
pub header_protection_key: Option<[u8; 32]>,
pub content_padding_addition: Range,
pub random_trailers: bool,
pub mtu: usize,
}
impl AmneziaConfig {
pub fn random_header(&self, range: &Range) -> u32 {
range.random()
}
pub fn random_padding(&self, len: usize) -> Vec<u8> {
let mut padding = vec![0u8; len];
if len > 0 {
rand::fill(&mut padding);
}
padding
}
fn shape(&self, kind: AwgKind) -> (usize, Range) {
match kind {
AwgKind::Initiation => (self.s1, self.h1),
AwgKind::Response => (self.s2, self.h2),
AwgKind::Cookie => (self.s3, self.h3),
AwgKind::Transport => (self.s4, self.h4),
}
}
pub fn encode(&self, kind: AwgKind, payload: Bytes, udp_window: usize) -> Bytes {
let (padding_len, _) = self.shape(kind);
let trailer_len = if kind != AwgKind::Transport && self.random_trailers {
let base = padding_len.saturating_add(payload.len());
let room = udp_window.max(DEFAULT_UDP_WINDOW).saturating_sub(base);
if room == 0 {
0
} else {
rand::rng().random_range(0..room)
}
} else {
0
};
let mut out = BytesMut::zeroed(padding_len + payload.len() + trailer_len);
if padding_len > 0 {
rand::fill(&mut out[..padding_len]);
}
out[padding_len..padding_len + payload.len()].copy_from_slice(&payload);
if trailer_len > 0 {
rand::fill(&mut out[padding_len + payload.len()..]);
}
if self.header_protection_key.is_some() {
let protected_len = if kind == AwgKind::Transport {
16
} else {
kind.fixed_size()
};
self.apply_header_cipher(&mut out, padding_len, protected_len);
}
out.freeze()
}
pub fn decode(&self, datagram: &Bytes) -> Option<DecodedPacket> {
if datagram.len() < 4 {
return None;
}
for kind in [
AwgKind::Initiation,
AwgKind::Response,
AwgKind::Cookie,
AwgKind::Transport,
] {
let (padding_len, headers) = self.shape(kind);
let minimum = padding_len + kind.fixed_size();
let size_ok = match kind {
AwgKind::Transport | AwgKind::Initiation => datagram.len() >= minimum,
AwgKind::Response | AwgKind::Cookie if self.random_trailers => {
datagram.len() >= minimum
}
AwgKind::Response | AwgKind::Cookie => datagram.len() == minimum,
};
if !size_ok || datagram.len() < padding_len + 4 {
continue;
}
let protected_header =
u32::from_le_bytes(datagram[padding_len..padding_len + 4].try_into().ok()?);
let candidate = if self.header_protection_key.is_some() {
protected_header ^ self.header_type_hash(datagram)?
} else {
protected_header
};
if !headers.contains(candidate) {
continue;
}
let message_len = match kind {
AwgKind::Transport | AwgKind::Initiation => datagram.len() - padding_len,
AwgKind::Response | AwgKind::Cookie => kind.fixed_size(),
};
let mut payload = BytesMut::from(&datagram[padding_len..padding_len + message_len]);
if self.header_protection_key.is_some() {
let protected_len = if kind == AwgKind::Transport {
16
} else {
kind.fixed_size()
};
self.apply_header_cipher_with_nonce(
&mut payload,
0,
protected_len,
&datagram[..HEADER_PROTECTION_NONCE_SIZE],
);
}
return Some(DecodedPacket {
kind,
message: payload.freeze(),
wire_len: datagram.len(),
});
}
None
}
pub fn prepare_transport_plaintext(&self, packet: Bytes, udp_window: usize) -> Bytes {
if packet.is_empty() {
return packet;
}
let requested = if !self.content_padding_addition.is_zero() {
self.content_padding_addition.random() as usize
} else if self.random_trailers {
let wire_without_padding = self.s4 + AwgKind::Transport.fixed_size() + packet.len();
let room = udp_window
.max(DEFAULT_UDP_WINDOW)
.saturating_sub(wire_without_padding);
if room == 0 {
0
} else {
rand::rng().random_range(0..room)
}
} else {
let aligned = (packet.len() + 15) & !15;
aligned.saturating_sub(packet.len())
};
let padding = self.clamp_padding_to_mtu(packet.len(), requested);
if padding == 0 {
return packet;
}
let mut out = BytesMut::with_capacity(packet.len() + padding);
out.extend_from_slice(&packet);
out.resize(packet.len() + padding, 0);
out.freeze()
}
pub fn transport_content_length(&self, plaintext: &[u8]) -> Option<usize> {
if plaintext.is_empty() || plaintext[0] == 0 {
return Some(0);
}
match plaintext[0] >> 4 {
4 if plaintext.len() >= 20 => {
let total = u16::from_be_bytes([plaintext[2], plaintext[3]]) as usize;
(total >= 20 && total <= plaintext.len()).then_some(total)
}
6 if plaintext.len() >= 40 => {
let payload = u16::from_be_bytes([plaintext[4], plaintext[5]]) as usize;
let total = 40 + payload;
(total <= plaintext.len()).then_some(total)
}
_ => None,
}
}
fn clamp_padding_to_mtu(&self, packet_len: usize, requested: usize) -> usize {
if self.mtu == 0 {
return requested;
}
let normalized = if packet_len > self.mtu {
packet_len % self.mtu
} else {
packet_len
};
requested.min(self.mtu.saturating_sub(normalized))
}
fn header_type_hash(&self, datagram: &Bytes) -> Option<u32> {
if datagram.len() < HEADER_PROTECTION_NONCE_SIZE {
return None;
}
let mut hash = [0u8; 4];
self.apply_keystream(&mut hash, &datagram[..HEADER_PROTECTION_NONCE_SIZE]);
Some(u32::from_le_bytes(hash))
}
fn apply_header_cipher(&self, packet: &mut [u8], offset: usize, len: usize) {
if packet.len() < HEADER_PROTECTION_NONCE_SIZE || packet.len() < offset + len {
return;
}
let nonce: [u8; HEADER_PROTECTION_NONCE_SIZE] =
match packet[..HEADER_PROTECTION_NONCE_SIZE].try_into() {
Ok(nonce) => nonce,
Err(_) => return,
};
self.apply_header_cipher_with_nonce(packet, offset, len, &nonce);
}
fn apply_header_cipher_with_nonce(
&self,
packet: &mut [u8],
offset: usize,
len: usize,
nonce: &[u8],
) {
if packet.len() < offset + len {
return;
}
self.apply_keystream(&mut packet[offset..offset + len], nonce);
}
fn apply_keystream(&self, data: &mut [u8], nonce: &[u8]) {
let Some(key) = self.header_protection_key.as_ref() else {
return;
};
if nonce.len() != HEADER_PROTECTION_NONCE_SIZE {
return;
}
let Ok(nonce) = <&[u8; HEADER_PROTECTION_NONCE_SIZE]>::try_from(nonce) else {
return;
};
let mut cipher = ChaCha20::new(key.into(), nonce.into());
cipher.apply_keystream(data);
}
}
impl Drop for AmneziaConfig {
fn drop(&mut self) {
use zeroize::Zeroize;
self.header_protection_key.zeroize();
}
}
+132
View File
@@ -0,0 +1,132 @@
//! End-to-end encryption remains independent of physical Amnezia pair links.
use crate::{AWG_PROTOCOL, AWG_WIRE_VERSION, AwgTransport};
use iroh::EndpointId;
use std::sync::Arc;
use tsunagi::dataplane::transport::SharedLink;
use tsunagi::dataplane::{IpPlugin, PluginCapability, PluginContext, PluginError, ProtocolOption};
use tsunagi::state::Ipv4Range;
use tsunagi::{BoxFuture, NetworkId};
use tsunagi_wg_quic::{NetworkOverview, WireguardConfig, WireguardPlugin};
/// AWG capability and an independent end-to-end WireGuard engine.
/// Pair transport keys/profiles never enter its durable end-to-end key store.
#[derive(Debug)]
pub struct AwgPlugin {
inner: Arc<WireguardPlugin>,
transport: Arc<AwgTransport>,
}
impl AwgPlugin {
/// Uses standard tunnel MTU/keepalive options; pair ports and profiles are ephemeral.
pub const OPTIONS: &'static [ProtocolOption] = &[
ProtocolOption {
key: "keepalive",
value: "SECONDS",
help: "end-to-end tunnel keepalive; 0 disables it",
default: Some("25"),
},
ProtocolOption {
key: "mtu",
value: "BYTES",
help: "maximum overlay packet size",
default: Some("1280"),
},
ProtocolOption {
key: "stun",
value: "HOST:PORT|off",
help: "UDP public mapping discovery; disabled in local reach mode",
default: Some("stun.l.google.com:19302"),
},
];
/// Opens the end-to-end engine, retaining separate pair transport state.
pub async fn open(
mut config: WireguardConfig,
transport: Arc<AwgTransport>,
) -> Result<Arc<Self>, PluginError> {
config.protocol = AWG_PROTOCOL;
Ok(Arc::new(Self {
inner: WireguardPlugin::open(config).await?,
transport,
}))
}
/// Reports end-to-end tunnel health and traffic counters.
pub fn overview(&self, network: NetworkId) -> Option<NetworkOverview> {
self.inner.overview(network)
}
}
impl IpPlugin for AwgPlugin {
fn protocol_id(&self) -> &str {
AWG_PROTOCOL
}
fn protocol_version(&self) -> u16 {
AWG_WIRE_VERSION
}
fn options(&self) -> &'static [ProtocolOption] {
Self::OPTIONS
}
fn attach(&self, context: PluginContext) {
self.inner.attach(context);
}
fn local_capability(
&self,
network: NetworkId,
) -> Result<Option<PluginCapability>, PluginError> {
self.inner.local_capability(network).map(|cap| {
cap.map(|mut cap| {
cap.protocol = AWG_PROTOCOL.into();
cap.version = AWG_WIRE_VERSION;
cap
})
})
}
fn on_network_activated(&self, network: NetworkId) {
self.inner.on_network_activated(network);
}
fn on_peer_capability(
&self,
network: NetworkId,
peer: EndpointId,
capability: &PluginCapability,
) -> Result<(), PluginError> {
if capability.protocol != AWG_PROTOCOL
|| capability.version != AWG_WIRE_VERSION
|| !capability.enabled
{
return Err(PluginError::Rejected("unsupported AWG capability".into()));
}
self.inner.on_peer_capability(network, peer, capability)?;
self.transport.allow_peer(network, peer);
Ok(())
}
fn on_address_allocation(
&self,
network: NetworkId,
range: Ipv4Range,
allocations: &[(EndpointId, std::net::Ipv4Addr)],
) {
self.inner
.on_address_allocation(network, range, allocations);
}
fn carry(&self, network: NetworkId, peer: EndpointId, packet: bytes::Bytes) -> bool {
self.inner.carry(network, peer, packet)
}
fn on_peer_link(&self, network: NetworkId, peer: EndpointId, link: SharedLink) {
self.inner.on_peer_link(network, peer, link);
}
fn on_peer_gone(&self, network: NetworkId, peer: EndpointId) {
self.transport.forget_peer(network, peer);
self.inner.on_peer_gone(network, peer);
}
fn on_network_deactivated(&self, network: NetworkId) {
self.transport.stop_network(network);
self.inner.on_network_deactivated(network);
}
fn on_network_forgotten(&self, network: NetworkId) {
self.inner.on_network_forgotten(network);
}
fn shutdown(&self) -> BoxFuture<'_, ()> {
Box::pin(async move {
self.transport.shutdown();
self.inner.shutdown().await;
})
}
}
+226
View File
@@ -0,0 +1,226 @@
//! Bounded profiles, regenerated for every pair connection attempt.
use crate::masking::{AmneziaConfig, AwgKind, Range};
use bytes::Bytes;
use serde::{Deserialize, Serialize};
/// An ephemeral masking profile. Secret fields are redacted and erased on drop.
#[derive(Clone, Serialize, Deserialize, zeroize::ZeroizeOnDrop)]
pub struct PairProfile {
id: [u8; 16],
prefix: u16,
headers: [[u32; 2]; 4],
header_key: [u8; 32],
pub(crate) psk: [u8; 32],
junk_count: u8,
junk_min: u16,
junk_max: u16,
padding: u16,
signature: u8,
}
impl std::fmt::Debug for PairProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("PairProfile([REDACTED])")
}
}
impl PairProfile {
/// Generates a new safe profile; no deterministic seed or durable state.
pub fn generate() -> Self {
let headers = std::array::from_fn(|i| {
let base = (i as u32) * 0x4000_0000 + rand::random_range(1024..0x3fff_0000);
[base, base + rand::random_range(64..1024)]
});
Self {
id: rand::random(),
prefix: rand::random_range(12..49),
headers,
header_key: rand::random(),
psk: rand::random(),
junk_count: rand::random_range(4..9),
junk_min: rand::random_range(24..65),
junk_max: rand::random_range(80..161),
padding: rand::random_range(16..65),
signature: rand::random_range(0..2),
}
}
/// Public attempt identifier, useful for distinguishing retries without revealing secrets.
pub fn id(&self) -> [u8; 16] {
self.id
}
/// Header key, for packet engines and protocol tests; never log this value.
pub fn header_key(&self) -> &[u8; 32] {
&self.header_key
}
/// Rejects invalid remote profiles before allocating packets or entering the codec.
pub fn validate(&self) -> Result<(), &'static str> {
if !(12..=48).contains(&self.prefix)
|| !(4..=8).contains(&self.junk_count)
|| self.junk_min < 24
|| self.junk_min > self.junk_max
|| self.junk_max > 160
|| self.padding > 64
|| self.signature > 1
|| self.header_key == [0; 32]
|| self.psk == [0; 32]
{
return Err("invalid AWG profile bounds");
}
for (i, [lo, hi]) in self.headers.iter().copied().enumerate() {
if lo < 5 || lo > hi || hi - lo > 1024 {
return Err("invalid AWG header range");
}
for [other_lo, other_hi] in &self.headers[..i] {
if lo <= *other_hi && *other_lo <= hi {
return Err("overlapping AWG header ranges");
}
}
}
Ok(())
}
fn config(&self) -> AmneziaConfig {
let range = |i: usize| Range {
min: self.headers[i][0],
max: self.headers[i][1],
};
AmneziaConfig {
jc: self.junk_count.into(),
jmin: self.junk_min.into(),
jmax: self.junk_max.into(),
s1: self.prefix.into(),
s2: self.prefix.into(),
s3: self.prefix.into(),
s4: self.prefix.into(),
h1: range(0),
h2: range(1),
h3: range(2),
h4: range(3),
i1: String::new(),
i2: String::new(),
i3: String::new(),
i4: String::new(),
i5: String::new(),
header_protection_key: Some(self.header_key),
content_padding_addition: Range {
min: 0,
max: self.padding.into(),
},
random_trailers: true,
mtu: 1050,
}
}
/// Masks one standard WireGuard message; invalid types and oversized packets are refused.
pub fn encode(&self, raw: &[u8]) -> Option<Bytes> {
let kind = match raw.get(..4)? {
[1, 0, 0, 0] => AwgKind::Initiation,
[2, 0, 0, 0] => AwgKind::Response,
[3, 0, 0, 0] => AwgKind::Cookie,
[4, 0, 0, 0] => AwgKind::Transport,
_ => return None,
};
if raw.len() < kind.fixed_size() || raw.len() > 1050 {
return None;
}
let config = self.config();
let range = match kind {
AwgKind::Initiation => config.h1,
AwgKind::Response => config.h2,
AwgKind::Cookie => config.h3,
AwgKind::Transport => config.h4,
};
let mut payload = raw.to_vec();
payload[..4].copy_from_slice(&range.random().to_le_bytes());
Some(config.encode(kind, Bytes::from(payload), 1200))
}
/// Unmasks a packet and restores standard headers before WireGuard validation.
pub fn decode(&self, wire: &[u8]) -> Option<Vec<u8>> {
if wire.len() > 1200 {
return None;
}
let decoded = self.config().decode(&Bytes::copy_from_slice(wire))?;
let (kind, len) = match decoded.kind {
AwgKind::Initiation => (1, 148),
AwgKind::Response => (2, 92),
AwgKind::Cookie => (3, 64),
AwgKind::Transport => (4, decoded.message.len()),
};
let mut message = decoded.message.get(..len)?.to_vec();
message[..4].copy_from_slice(&(kind as u32).to_le_bytes());
Some(message)
}
pub(crate) fn pad(&self, packet: Bytes) -> Bytes {
self.config().prepare_transport_plaintext(packet, 1200)
}
/// Generates structured signature packets with fresh fields, never static captures.
/// These target the other Tsunagi peer, not DNS or SIP services.
pub fn signature_packets(&self) -> Vec<Vec<u8>> {
if self.signature == 0 {
// A complete DNS query. Counts, label lengths and qtype remain valid.
let label = format!("n{:016x}", rand::random::<u64>());
let mut packet = Vec::from(rand::random::<u16>().to_be_bytes());
packet.extend_from_slice(&[1, 0, 0, 1, 0, 0, 0, 0, 0, 0]);
packet.push(label.len() as u8);
packet.extend_from_slice(label.as_bytes());
packet.extend_from_slice(b"\x07example\x03net\0\0");
packet.push(if rand::random() { 1 } else { 28 });
packet.extend_from_slice(&[0, 1]);
vec![packet]
} else {
let id = rand::random::<u64>();
vec![format!("OPTIONS sip:n{id:x}@example.net SIP/2.0\r\nVia: SIP/2.0/UDP example.net;branch=z9hG4bK{id:x}\r\nMax-Forwards: 70\r\nFrom: <sip:a@example.net>;tag={id:x}\r\nTo: <sip:n{id:x}@example.net>\r\nCall-ID: {id:x}@example.net\r\nCSeq: 1 OPTIONS\r\nContent-Length: 0\r\n\r\n").into_bytes()]
}
}
pub(crate) fn junk_packets(&self) -> Vec<Vec<u8>> {
(0..self.junk_count)
.map(|_| {
let mut packet = vec![0; rand::random_range(self.junk_min..=self.junk_max).into()];
rand::fill(&mut packet[..]);
packet
})
.collect()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn unsafe_peer_profiles_are_rejected_before_the_codec() {
let valid = PairProfile::generate();
let mut bad = valid.clone();
bad.prefix = 11;
assert!(bad.validate().is_err());
let mut bad = valid.clone();
bad.headers[1] = bad.headers[0];
assert!(bad.validate().is_err());
let mut bad = valid.clone();
bad.junk_max = u16::MAX;
assert!(bad.validate().is_err());
let mut bad = valid.clone();
bad.padding = u16::MAX;
assert!(bad.validate().is_err());
let mut bad = valid.clone();
bad.psk = [0; 32];
assert!(bad.validate().is_err());
let mut bad = valid.clone();
bad.signature = 2;
assert!(bad.validate().is_err());
}
#[test]
fn signature_templates_have_consistent_framing_and_fresh_fields() {
let mut profile = PairProfile::generate();
profile.signature = 0;
let dns = profile.signature_packets().remove(0);
assert_eq!(&dns[2..12], &[1, 0, 0, 1, 0, 0, 0, 0, 0, 0]);
let mut at = 12;
while dns[at] != 0 {
at += 1 + usize::from(dns[at]);
}
assert_eq!(at + 5, dns.len());
assert_eq!(&dns[dns.len() - 2..], &[0, 1]);
profile.signature = 1;
let sip = profile.signature_packets().remove(0);
assert!(sip.starts_with(b"OPTIONS sip:"));
assert!(sip.ends_with(b"Content-Length: 0\r\n\r\n"));
assert_ne!(sip, profile.signature_packets().remove(0));
}
}
+402
View File
@@ -0,0 +1,402 @@
//! One cancellable actor and UDP socket per pair. All payload and health frames
//! are inside the Amnezia-masked authenticated WireGuard tunnel.
use crate::profile::PairProfile;
use boringtun::noise::{Tunn, TunnResult};
use bytes::Bytes;
use iroh::EndpointId;
use std::collections::{HashMap, VecDeque};
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::net::UdpSocket;
use tokio::sync::{mpsc, watch};
use tsunagi::dataplane::transport::{PacketLink, TransportError};
use tsunagi::{BoxFuture, NetworkId};
pub(crate) const MAX_PAYLOAD: usize = tsunagi::config::MAX_DATA_DATAGRAM;
const CHUNK: usize = 700;
const QUEUE: usize = 64;
const WINDOW: Duration = Duration::from_secs(20);
pub(crate) struct PairLink {
network: NetworkId,
peer: EndpointId,
pub(crate) attempt: [u8; 16],
tx: mpsc::Sender<Bytes>,
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
stop: watch::Sender<bool>,
closed: AtomicBool,
remote: Mutex<Option<SocketAddr>>,
reason: Mutex<Option<String>>,
}
impl std::fmt::Debug for PairLink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AwgPair")
.field("network", &self.network)
.field("peer", &self.peer)
.finish_non_exhaustive()
}
}
impl PairLink {
pub(crate) fn has_authenticated_path(&self) -> bool {
match self.remote.lock() {
Ok(remote) => remote.is_some(),
Err(_) => false,
}
}
}
impl PacketLink for PairLink {
fn network(&self) -> NetworkId {
self.network
}
fn peer(&self) -> EndpointId {
self.peer
}
fn max_datagram_size(&self) -> usize {
MAX_PAYLOAD
}
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
if self.is_closed() {
return Err(TransportError::Closed);
}
if payload.len() > MAX_PAYLOAD {
return Err(TransportError::TooLarge {
size: payload.len(),
limit: MAX_PAYLOAD,
});
}
self.tx
.try_send(payload)
.map_err(|_| TransportError::Other("AWG send queue full or closed".into()))
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move {
let mut stop = self.stop.subscribe();
if *stop.borrow() {
return None;
}
let mut rx = self.rx.lock().await;
tokio::select! {biased; _=stop.changed()=>None,packet=rx.recv()=>packet}
})
}
fn closed(&self) -> BoxFuture<'_, ()> {
Box::pin(async move {
let mut stop = self.stop.subscribe();
if !*stop.borrow() {
let _ = stop.changed().await;
}
})
}
fn is_closed(&self) -> bool {
self.closed.load(Ordering::Acquire) || *self.stop.borrow()
}
fn close(&self) {
self.stop.send_replace(true);
}
fn failure_reason(&self) -> Option<String> {
match self.reason.lock() {
Ok(reason) => reason.clone(),
Err(_) => None,
}
}
fn path_description(&self) -> String {
match self.remote.lock() {
Ok(addr) => match *addr {
Some(addr) => format!("awg UDP via {addr}"),
None => "awg UDP, establishing".into(),
},
Err(_) => "awg UDP".into(),
}
}
}
impl Drop for PairLink {
fn drop(&mut self) {
self.stop.send_replace(true);
}
}
pub(crate) fn start(
network: NetworkId,
peer: EndpointId,
socket: UdpSocket,
tunn: Tunn,
profile: PairProfile,
candidates: Vec<SocketAddr>,
parent: watch::Receiver<bool>,
) -> Arc<PairLink> {
let (tx, rx) = mpsc::channel(QUEUE);
let (in_tx, in_rx) = mpsc::channel(QUEUE);
let (stop, _) = watch::channel(false);
let link = Arc::new(PairLink {
network,
peer,
attempt: profile.id(),
tx,
rx: tokio::sync::Mutex::new(in_rx),
stop: stop.clone(),
closed: AtomicBool::new(false),
remote: Mutex::new(None),
reason: Mutex::new(None),
});
let weak = Arc::downgrade(&link);
tokio::spawn(async move {
let mut engine = Engine {
stop: stop.subscribe(),
socket,
tunn,
profile,
candidates,
remote: None,
inbox: in_tx,
rx,
counter: rand::random(),
sequence: 0,
pending: VecDeque::new(),
samples: VecDeque::new(),
assemblies: HashMap::new(),
started: Instant::now(),
last_authenticated: Instant::now(),
};
engine.run(parent, &weak).await;
if let Some(link) = weak.upgrade() {
link.closed.store(true, Ordering::Release);
link.stop.send_replace(true);
}
});
link
}
struct Assembly {
created: Instant,
count: u16,
chunks: Vec<Option<Bytes>>,
}
struct Engine {
stop: watch::Receiver<bool>,
socket: UdpSocket,
tunn: Tunn,
profile: PairProfile,
candidates: Vec<SocketAddr>,
remote: Option<SocketAddr>,
inbox: mpsc::Sender<Bytes>,
rx: mpsc::Receiver<Bytes>,
counter: u64,
sequence: u64,
pending: VecDeque<(u64, Instant)>,
samples: VecDeque<bool>,
assemblies: HashMap<u64, Assembly>,
started: Instant,
last_authenticated: Instant,
}
enum ResultOwned {
Wire(Vec<u8>),
Plain(Vec<u8>),
Done,
}
fn own(result: TunnResult<'_>) -> ResultOwned {
match result {
TunnResult::WriteToNetwork(p) => ResultOwned::Wire(p.to_vec()),
TunnResult::WriteToTunnelV4(p, _) => ResultOwned::Plain(p.to_vec()),
_ => ResultOwned::Done,
}
}
fn ip(payload: &[u8]) -> Bytes {
let mut packet = vec![0; 20];
packet[0] = 0x45;
packet[8] = 64;
packet[9] = 253;
packet[12..16].copy_from_slice(&Ipv4Addr::new(192, 0, 2, 1).octets());
packet[16..20].copy_from_slice(&Ipv4Addr::new(192, 0, 2, 2).octets());
packet[2..4].copy_from_slice(&((20 + payload.len()) as u16).to_be_bytes());
let sum = packet
.as_chunks::<2>()
.0
.iter()
.map(|b| u32::from(u16::from_be_bytes([b[0], b[1]])))
.sum::<u32>();
let sum = (sum & 0xffff) + (sum >> 16);
packet[10..12].copy_from_slice(&(!(sum as u16)).to_be_bytes());
packet.extend_from_slice(payload);
Bytes::from(packet)
}
impl Engine {
async fn run(&mut self, mut stop: watch::Receiver<bool>, link: &std::sync::Weak<PairLink>) {
if *self.stop.borrow() || *stop.borrow() {
return;
}
let mut buffer = [0; 1201];
let mut scratch = [0; 4096];
let mut timer = tokio::time::interval(Duration::from_millis(250));
timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut last_probe = Instant::now() - Duration::from_secs(2);
loop {
tokio::select! {
biased;
_=stop.changed()=>break,
_=self.stop.changed()=>break,
_=timer.tick()=>{
let result=own(self.tunn.update_timers(&mut scratch));self.process(result,None).await;
if last_probe.elapsed()>=Duration::from_secs(1) {
last_probe=Instant::now();self.sequence=self.sequence.wrapping_add(1);
self.pending.push_back((self.sequence,Instant::now()));
let mut probe=vec![1];probe.extend_from_slice(&self.sequence.to_be_bytes());
probe.resize(713,0);rand::fill(&mut probe[9..]);self.encrypt(&probe).await;
}
while self.pending.front().is_some_and(|(_,at)|at.elapsed()>=Duration::from_secs(3)) {
self.pending.pop_front();self.samples.push_back(false);
}
while self.samples.len()>20 {self.samples.pop_front();}
self.assemblies.retain(|_,a|a.created.elapsed()<Duration::from_secs(5));
if self.started.elapsed()>WINDOW && (self.last_authenticated.elapsed()>Duration::from_secs(10)
|| (self.samples.len()>=15&&self.samples.iter().filter(|ok|!**ok).count()*100>=self.samples.len()*60)) {
if let Some(link)=link.upgrade() && let Ok(mut reason)=link.reason.lock() {*reason=Some("AWG data delivery checks: persistent loss or timeout".into());}
break;
}
}
packet=self.socket.recv_from(&mut buffer)=>{
if let Ok((len,source))=packet && let Some(message)=self.profile.decode(&buffer[..len]) {
let result=own(self.tunn.decapsulate(None,&message,&mut scratch));
if matches!(result,ResultOwned::Plain(_)) {
self.remote=Some(source);self.last_authenticated=Instant::now();
if let Some(link)=link.upgrade() && let Ok(mut remote)=link.remote.lock(){*remote=Some(source);}
}
self.process(result,Some(source)).await;
for _ in 0..16 {
let result=own(self.tunn.decapsulate(None,&[],&mut scratch));
if matches!(result,ResultOwned::Done){break;}
self.process(result,Some(source)).await;
}
}
}
packet=self.rx.recv()=>{
let Some(packet)=packet else{break;};
let count=packet.len().max(1).div_ceil(CHUNK) as u16;
self.counter=self.counter.wrapping_add(1);
for (index,chunk) in packet.chunks(CHUNK).enumerate() {
let mut payload=vec![3];payload.extend_from_slice(&self.counter.to_be_bytes());
payload.extend_from_slice(&(index as u16).to_be_bytes());payload.extend_from_slice(&count.to_be_bytes());
payload.extend_from_slice(chunk);self.encrypt(&payload).await;
// Large logical datagrams must let local readers and other
// pairs drain their bounded socket/command queues.
if (index + 1).is_multiple_of(16) {tokio::task::yield_now().await;}
}
}
}
}
}
async fn encrypt(&mut self, payload: &[u8]) {
let packet = self.profile.pad(ip(payload));
let mut scratch = [0; 4096];
let result = own(self.tunn.encapsulate(&packet, &mut scratch));
self.process(result, None).await;
}
async fn wire(&self, raw: &[u8], target: Option<SocketAddr>) {
let Some(wire) = self.profile.encode(raw) else {
return;
};
let targets: Vec<_> = target
.or(self.remote)
.map(|a| vec![a])
.unwrap_or_else(|| self.candidates.clone());
for addr in targets {
if raw.first() == Some(&1) {
for signature in self.profile.signature_packets() {
let _ = self.send_wire(&signature, addr).await;
}
for junk in self.profile.junk_packets() {
let _ = self.send_wire(&junk, addr).await;
}
}
let _ = self.send_wire(&wire, addr).await;
}
}
async fn send_wire(&self, bytes: &[u8], address: SocketAddr) {
let mut stop = self.stop.clone();
if *stop.borrow() {
return;
}
tokio::select! {biased; _=stop.changed()=>{}, _=tokio::time::timeout(Duration::from_millis(100),self.socket.send_to(bytes,address))=>{}}
}
async fn process(&mut self, result: ResultOwned, target: Option<SocketAddr>) {
match result {
ResultOwned::Wire(raw) => self.wire(&raw, target).await,
ResultOwned::Plain(packet) => {
let Some(payload) = packet.get(20..) else {
return;
};
let Some(seq) = payload
.get(1..9)
.and_then(|b| b.try_into().ok())
.map(u64::from_be_bytes)
else {
return;
};
match payload.first() {
Some(1) => {
let mut pong = vec![2];
pong.extend_from_slice(&seq.to_be_bytes());
pong.resize(713, 0);
let packet = self.profile.pad(ip(&pong));
let mut scratch = [0; 4096];
if let ResultOwned::Wire(raw) =
own(self.tunn.encapsulate(&packet, &mut scratch))
{
self.wire(&raw, target).await;
}
}
Some(2) => {
if let Some(index) =
self.pending.iter().position(|(pending, _)| *pending == seq)
{
self.pending.remove(index);
self.samples.push_back(true);
}
}
Some(3) => self.fragment(seq, payload),
_ => {}
}
}
ResultOwned::Done => {}
}
}
fn fragment(&mut self, id: u64, payload: &[u8]) {
let Some(header) = payload.get(9..13) else {
return;
};
let index = u16::from_be_bytes([header[0], header[1]]);
let count = u16::from_be_bytes([header[2], header[3]]);
let chunk = &payload[13..];
if count == 0
|| usize::from(count) > MAX_PAYLOAD.div_ceil(CHUNK)
|| index >= count
|| chunk.len() > CHUNK
{
return;
}
if !self.assemblies.contains_key(&id) && self.assemblies.len() >= 8 {
return;
}
let assembly = self.assemblies.entry(id).or_insert_with(|| Assembly {
created: Instant::now(),
count,
chunks: vec![None; count.into()],
});
if assembly.count != count {
return;
}
assembly.chunks[usize::from(index)] = Some(Bytes::copy_from_slice(chunk));
if assembly.chunks.iter().all(Option::is_some)
&& let Some(assembly) = self.assemblies.remove(&id)
{
let mut packet = Vec::new();
for chunk in assembly.chunks.into_iter().flatten() {
packet.extend_from_slice(&chunk);
}
if packet.len() <= MAX_PAYLOAD {
let _ = self.inbox.try_send(Bytes::from(packet));
}
}
}
}
+101
View File
@@ -0,0 +1,101 @@
//! Optional, bounded server-reflexive discovery on the pair's own UDP socket.
//! A STUN answer is a candidate only; WireGuard authentication verifies a path.
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;
use tokio::net::UdpSocket;
const COOKIE: [u8; 4] = [0x21, 0x12, 0xa4, 0x42];
pub(crate) async fn mapped(socket: &UdpSocket, server: &str) -> Option<SocketAddr> {
tokio::time::timeout(Duration::from_millis(1200), async {
let address = tokio::net::lookup_host(server)
.await
.ok()?
.find(SocketAddr::is_ipv4)?;
let transaction: [u8; 12] = rand::random();
let mut request = [0; 20];
request[..2].copy_from_slice(&[0, 1]);
request[4..8].copy_from_slice(&COOKIE);
request[8..].copy_from_slice(&transaction);
socket.send_to(&request, address).await.ok()?;
let mut bytes = [0; 1024];
loop {
let (len, source) = socket.recv_from(&mut bytes).await.ok()?;
if source == address
&& let Some(mapped) = parse(&bytes[..len], &transaction)
{
return Some(mapped);
}
}
})
.await
.ok()
.flatten()
}
fn parse(bytes: &[u8], transaction: &[u8; 12]) -> Option<SocketAddr> {
if bytes.len() < 20
|| bytes[..2] != [1, 1]
|| bytes[4..8] != COOKIE
|| &bytes[8..20] != transaction
{
return None;
}
let declared = u16::from_be_bytes(bytes[2..4].try_into().ok()?) as usize;
if !declared.is_multiple_of(4) || declared != bytes.len() - 20 {
return None;
}
let mut at = 20;
while at + 4 <= bytes.len() {
let kind = u16::from_be_bytes(bytes[at..at + 2].try_into().ok()?);
let len = u16::from_be_bytes(bytes[at + 2..at + 4].try_into().ok()?) as usize;
at += 4;
let attribute = bytes.get(at..at.checked_add(len)?)?;
if kind == 0x0020 && attribute.len() == 8 && attribute[0..2] == [0, 1] {
let port = u16::from_be_bytes([attribute[2] ^ COOKIE[0], attribute[3] ^ COOKIE[1]]);
let ip = Ipv4Addr::new(
attribute[4] ^ COOKIE[0],
attribute[5] ^ COOKIE[1],
attribute[6] ^ COOKIE[2],
attribute[7] ^ COOKIE[3],
);
if port == 0 || ip.is_unspecified() || ip.is_multicast() || ip.is_broadcast() {
return None;
}
return Some(SocketAddr::new(IpAddr::V4(ip), port));
}
at = at.checked_add((len + 3) & !3)?;
}
None
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[tokio::test]
async fn mapping_uses_the_actual_socket_and_rejects_unrelated_or_truncated_answers() {
let server = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
let address = server.local_addr().unwrap();
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
let expected = socket.local_addr().unwrap();
let task = tokio::spawn(async move {
let mut request = [0; 128];
let (len, source) = server.recv_from(&mut request).await.unwrap();
assert_eq!(len, 20);
assert_eq!(source, expected);
let tx: [u8; 12] = request[8..20].try_into().unwrap();
let mut reply = request[..20].to_vec();
reply[..4].copy_from_slice(&[1, 1, 0, 12]);
reply.extend_from_slice(&[0, 0x20, 0, 8, 0, 1]);
let port = source.port().to_be_bytes();
reply.extend_from_slice(&[port[0] ^ COOKIE[0], port[1] ^ COOKIE[1]]);
for (i, b) in Ipv4Addr::LOCALHOST.octets().into_iter().enumerate() {
reply.push(b ^ COOKIE[i]);
}
assert!(parse(&reply, &[0; 12]).is_none());
for len in 0..reply.len() {
assert!(parse(&reply[..len], &tx).is_none());
}
server.send_to(&reply, source).await.unwrap();
});
assert_eq!(mapped(&socket, &address.to_string()).await, Some(expected));
task.await.unwrap();
}
}
+720
View File
@@ -0,0 +1,720 @@
//! Private pair negotiation. No secret profiles in capabilities or durable state.
use crate::{
AWG_PROTOCOL, AWG_WIRE_VERSION,
profile::PairProfile,
session::{self, PairLink},
};
use boringtun::{
noise::Tunn,
x25519::{PublicKey, StaticSecret},
};
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::Duration;
use tokio::net::UdpSocket;
use tokio::sync::{mpsc, oneshot, watch};
use tsunagi::dataplane::transport::TransportPayload;
use tsunagi::dataplane::transport::{
ControlSender, InboundLink, PacketLink, PacketTransport, SharedLink, TransportError,
};
use tsunagi::{BoxFuture, NetworkId};
type Pair = (NetworkId, EndpointId);
#[derive(Serialize, Deserialize)]
enum Negotiation {
Offer {
version: u16,
profile: PairProfile,
key: [u8; 32],
#[serde(deserialize_with = "bounded_addresses")]
addresses: Vec<SocketAddr>,
},
Answer {
attempt: [u8; 16],
key: [u8; 32],
#[serde(deserialize_with = "bounded_addresses")]
addresses: Vec<SocketAddr>,
},
}
fn bounded_addresses<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<SocketAddr>, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = Vec<SocketAddr>;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("at most 32 UDP candidates")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut sequence: A,
) -> Result<Self::Value, A::Error> {
if sequence.size_hint().is_some_and(|n| n > 32) {
return Err(serde::de::Error::custom("too many UDP candidates"));
}
let mut addresses = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(32));
while let Some(address) = sequence.next_element()? {
if addresses.len() == 32 {
return Err(serde::de::Error::custom("too many UDP candidates"));
}
addresses.push(address);
}
Ok(addresses)
}
}
deserializer.deserialize_seq(Visitor)
}
struct Pending {
profile: PairProfile,
key: StaticSecret,
socket: UdpSocket,
reply: oneshot::Sender<Result<SharedLink, TransportError>>,
}
struct Inner {
stopped: watch::Sender<bool>,
local: EndpointId,
bind: Ipv4Addr,
stun: Option<String>,
busy: Mutex<HashSet<Pair>>,
control: OnceLock<Arc<dyn ControlSender>>,
allowed: Mutex<HashSet<Pair>>,
pending: Mutex<HashMap<Pair, Pending>>,
links: Mutex<HashMap<Pair, Arc<PairLink>>>,
inbound: mpsc::Sender<InboundLink>,
port_by_pair: Mutex<HashMap<Pair, u16>>,
}
/// Public diagnostics for one pair. No key or masking parameters are exposed.
#[derive(Debug, Clone)]
pub struct PairOverview {
/// Other endpoint's authenticated identity.
pub peer: EndpointId,
/// Fresh connection-attempt identifier.
pub attempt: [u8; 16],
/// Last known UDP port; None if diagnostics race with pair retirement.
pub local_port: Option<u16>,
/// Actual transport path, if established.
pub path: String,
/// Whether this attempt has been retired.
pub closed: bool,
}
/// A bounded set of independently configured pair engines.
pub struct AwgTransport {
inner: Arc<Inner>,
commands: mpsc::Sender<(NetworkId, EndpointId, Negotiation)>,
incoming: tokio::sync::Mutex<mpsc::Receiver<InboundLink>>,
}
impl std::fmt::Debug for AwgTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AwgTransport")
.field("local", &self.inner.local)
.finish_non_exhaustive()
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match mutex.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
}
}
fn error(text: &str) -> TransportError {
TransportError::Other(text.into())
}
async fn addresses(inner: &Inner, socket: &UdpSocket) -> Result<Vec<SocketAddr>, TransportError> {
let local = socket
.local_addr()
.map_err(|_| error("AWG socket address unavailable"))?;
let mut candidates = if inner.bind.is_loopback() {
vec![local]
} else {
tsunagi_wireguard::local_ip_candidates()
.into_iter()
.filter(|ip| ip.is_ipv4())
.take(31)
.map(|ip| SocketAddr::new(ip, local.port()))
.collect()
};
if let Some(server) = &inner.stun
&& let Some(mapped) = crate::stun::mapped(socket, server).await
&& !candidates.contains(&mapped)
{
candidates.push(mapped);
}
if candidates.is_empty() {
return Err(error("AWG has no UDP address candidates"));
}
Ok(candidates)
}
fn validate_endpoint(key: &[u8; 32], addresses: &[SocketAddr]) -> Result<(), TransportError> {
if *key == [0; 32]
|| addresses.is_empty()
|| addresses.len() > 32
|| addresses.iter().any(|address| {
address.port() == 0
|| !address.is_ipv4()
|| address.ip().is_unspecified()
|| address.ip().is_multicast()
|| matches!(address.ip(), std::net::IpAddr::V4(ip) if ip.is_broadcast())
})
{
return Err(error("invalid AWG endpoint"));
}
Ok(())
}
impl AwgTransport {
/// Starts the coordinator. Production binds wildcard IPv4; tests use loopback.
pub fn new(local: EndpointId, bind: Ipv4Addr) -> Arc<Self> {
Self::new_with_stun(local, bind, None)
}
/// Enables an optional STUN server (host:port) for this pair's own socket.
/// Local-only callers pass None; failure still permits local UDP candidates.
pub fn new_with_stun(local: EndpointId, bind: Ipv4Addr, stun: Option<String>) -> Arc<Self> {
let (commands, mut rx) = mpsc::channel(64);
let (inbound, incoming) = mpsc::channel(64);
let (stopped, _) = watch::channel(false);
let inner = Arc::new(Inner {
stopped,
local,
bind,
stun,
busy: Mutex::default(),
control: OnceLock::new(),
allowed: Mutex::default(),
pending: Mutex::default(),
links: Mutex::default(),
inbound,
port_by_pair: Mutex::default(),
});
let weak = Arc::downgrade(&inner);
let mut stopped = inner.stopped.subscribe();
tokio::spawn(async move {
while let Some((network, peer, message)) =
tokio::select! { biased; _=stopped.changed()=>None, message=rx.recv()=>message }
{
let Some(inner) = weak.upgrade() else {
break;
};
if !lock(&inner.allowed).contains(&(network, peer))
|| !lock(&inner.busy).insert((network, peer))
{
continue;
}
tokio::spawn(async move {
let mut stop = inner.stopped.subscribe();
tokio::select! {biased; _=stop.changed()=>{}, _=async {
if let Err(err)=receive(&inner,(network,peer),message).await {tracing::debug!(%err,"AWG pair negotiation rejected");}
}=>{}}
lock(&inner.busy).remove(&(network, peer));
});
}
});
Arc::new(Self {
inner,
commands,
incoming: tokio::sync::Mutex::new(incoming),
})
}
/// Registers a peer after its authenticated capability has been validated.
pub fn allow_peer(&self, network: NetworkId, peer: EndpointId) {
let mut allowed = lock(&self.inner.allowed);
if !*self.inner.stopped.borrow() && allowed.len() < 128 {
allowed.insert((network, peer));
}
}
/// Retires a pair when its network membership session goes away.
pub fn forget_peer(&self, network: NetworkId, peer: EndpointId) {
let pair = (network, peer);
lock(&self.inner.allowed).remove(&pair);
lock(&self.inner.pending).remove(&pair);
lock(&self.inner.port_by_pair).remove(&pair);
if let Some(link) = lock(&self.inner.links).remove(&pair) {
link.close();
}
}
/// Stops all pair engines in this network.
pub fn stop_network(&self, network: NetworkId) {
let peers: Vec<_> = lock(&self.inner.allowed)
.iter()
.filter(|(n, _)| *n == network)
.map(|(_, p)| *p)
.collect();
for peer in peers {
self.forget_peer(network, peer);
}
}
/// Stops all sockets and coordinator work without waiting for remote peers.
pub fn shutdown(&self) {
self.inner.stopped.send_replace(true);
lock(&self.inner.allowed).clear();
lock(&self.inner.pending).clear();
for (_, link) in lock(&self.inner.links).drain() {
link.close();
}
lock(&self.inner.port_by_pair).clear();
}
/// Receives a successfully negotiated and authenticated inbound pair link.
pub async fn next_inbound(&self) -> Option<InboundLink> {
let mut stopped = self.inner.stopped.subscribe();
if *stopped.borrow() {
return None;
}
let mut incoming = self.incoming.lock().await;
tokio::select! {biased; _=stopped.changed()=>None, link=incoming.recv()=>link}
}
/// Takes a public snapshot of this network's pair attempts.
pub fn overview(&self, network: NetworkId) -> Vec<PairOverview> {
let links = lock(&self.inner.links);
let ports = lock(&self.inner.port_by_pair);
links
.iter()
.filter(|((n, _), _)| *n == network)
.map(|((_, peer), link)| PairOverview {
peer: *peer,
attempt: link.attempt,
local_port: ports.get(&(network, *peer)).copied(),
path: link.path_description(),
closed: link.is_closed(),
})
.collect()
}
}
impl PacketTransport for AwgTransport {
fn name(&self) -> &str {
AWG_PROTOCOL
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn attach_control(&self, control: Arc<dyn ControlSender>) {
let _ = self.inner.control.set(control);
}
fn on_control(
&self,
network: NetworkId,
peer: EndpointId,
protocol: &str,
payload: &[u8],
) -> Result<(), TransportError> {
if protocol != AWG_PROTOCOL || !lock(&self.inner.allowed).contains(&(network, peer)) {
return Err(TransportError::Declined(protocol.into()));
}
if payload.len() > 4096 {
return Err(error("AWG negotiation too large"));
}
let message: Negotiation =
postcard::from_bytes(payload).map_err(|_| error("malformed AWG negotiation"))?;
match &message {
Negotiation::Offer {
version,
profile,
key,
addresses,
} => {
if *version != AWG_WIRE_VERSION || peer.as_bytes() >= self.inner.local.as_bytes() {
return Err(error("invalid AWG offer role/version"));
}
profile.validate().map_err(error)?;
validate_endpoint(key, addresses)?;
}
Negotiation::Answer { key, addresses, .. } => validate_endpoint(key, addresses)?,
}
self.commands
.try_send((network, peer, message))
.map_err(|_| error("AWG negotiation queue full"))
}
fn open<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
protocol: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
Box::pin(async move {
let pair = (network, peer);
if protocol != AWG_PROTOCOL
|| self.inner.local.as_bytes() >= peer.as_bytes()
|| !lock(&self.inner.allowed).contains(&pair)
{
return Err(TransportError::Declined(protocol.into()));
}
let control = self
.inner
.control
.get()
.ok_or_else(|| error("AWG control plane unavailable"))?;
let socket = UdpSocket::bind((self.inner.bind, 0))
.await
.map_err(|_| error("AWG UDP bind failed"))?;
let profile = PairProfile::generate();
let attempt = profile.id();
let key = StaticSecret::from(rand::random::<[u8; 32]>());
let public = PublicKey::from(&key).to_bytes();
let message = Negotiation::Offer {
version: AWG_WIRE_VERSION,
profile: profile.clone(),
key: public,
addresses: addresses(&self.inner, &socket).await?,
};
let (reply, rx) = oneshot::channel();
if lock(&self.inner.pending).len() >= 128 {
return Err(error("too many AWG pair negotiations"));
}
lock(&self.inner.pending).insert(
pair,
Pending {
profile,
key,
socket,
reply,
},
);
// A cancellation guard also retires partially started attempts.
let mut guard = AttemptGuard {
inner: Arc::downgrade(&self.inner),
pair,
attempt,
completed: false,
};
let bytes =
postcard::to_stdvec(&message).map_err(|_| error("AWG offer encoding failed"))?;
control
.send(network, peer, AWG_PROTOCOL, TransportPayload::new(bytes))
.await?;
let result = tokio::time::timeout(Duration::from_secs(15), rx)
.await
.map_err(|_| error("AWG negotiation/handshake timed out"))?
.map_err(|_| error("AWG attempt cancelled"))?;
guard.completed = result.is_ok();
result
})
}
}
struct AttemptGuard {
inner: Weak<Inner>,
pair: Pair,
attempt: [u8; 16],
completed: bool,
}
impl Drop for AttemptGuard {
fn drop(&mut self) {
if let Some(inner) = self.inner.upgrade() {
let mut pending = lock(&inner.pending);
if pending
.get(&self.pair)
.is_some_and(|p| p.profile.id() == self.attempt)
{
pending.remove(&self.pair);
}
drop(pending);
if !self.completed
&& let Some(link) = lock(&inner.links).get(&self.pair)
&& link.attempt == self.attempt
{
link.close();
}
}
}
}
async fn receive(
inner: &Arc<Inner>,
pair: Pair,
message: Negotiation,
) -> Result<(), TransportError> {
if !lock(&inner.allowed).contains(&pair) {
return Err(error("AWG peer no longer authenticated"));
}
match message {
Negotiation::Offer {
profile,
key,
addresses: candidates,
..
} => {
if let Some(link) = lock(&inner.links).get(&pair) {
if link.attempt == profile.id() {
return Err(error("duplicate AWG offer"));
}
link.close();
}
let socket = UdpSocket::bind((inner.bind, 0))
.await
.map_err(|_| error("AWG UDP bind failed"))?;
let local_port = socket
.local_addr()
.map_err(|_| error("AWG socket address unavailable"))?
.port();
let local_key = StaticSecret::from(rand::random::<[u8; 32]>());
let public = PublicKey::from(&local_key).to_bytes();
let answer = Negotiation::Answer {
attempt: profile.id(),
key: public,
addresses: addresses(inner, &socket).await?,
};
let tunn = Tunn::new(
local_key,
PublicKey::from(key),
Some(profile.psk),
Some(1),
rand::random(),
None,
);
let link = session::start(
pair.0,
pair.1,
socket,
tunn,
profile,
candidates,
inner.stopped.subscribe(),
);
if !lock(&inner.allowed).contains(&pair) {
link.close();
return Err(error("AWG membership ended"));
}
lock(&inner.links).insert(pair, link.clone());
lock(&inner.port_by_pair).insert(pair, local_port);
let control = inner
.control
.get()
.ok_or_else(|| error("AWG control unavailable"))?;
if let Err(err) = control
.send(
pair.0,
pair.1,
AWG_PROTOCOL,
TransportPayload::new(
postcard::to_stdvec(&answer)
.map_err(|_| error("AWG answer encoding failed"))?,
),
)
.await
{
link.close();
return Err(err);
}
publish_when_up(Arc::downgrade(inner), pair, link, None);
}
Negotiation::Answer {
attempt,
key,
addresses: candidates,
} => {
if !lock(&inner.pending)
.get(&pair)
.is_some_and(|p| p.profile.id() == attempt)
{
return Err(error("stale AWG answer"));
}
let pending = lock(&inner.pending)
.remove(&pair)
.ok_or_else(|| error("AWG attempt cancelled"))?;
let local_port = pending
.socket
.local_addr()
.map_err(|_| error("AWG socket address unavailable"))?
.port();
let tunn = Tunn::new(
pending.key,
PublicKey::from(key),
Some(pending.profile.psk),
Some(1),
rand::random(),
None,
);
let link = session::start(
pair.0,
pair.1,
pending.socket,
tunn,
pending.profile,
candidates,
inner.stopped.subscribe(),
);
if !lock(&inner.allowed).contains(&pair) {
link.close();
return Err(error("AWG membership ended"));
}
lock(&inner.links).insert(pair, link.clone());
lock(&inner.port_by_pair).insert(pair, local_port);
publish_when_up(Arc::downgrade(inner), pair, link, Some(pending.reply));
}
}
Ok(())
}
fn publish_when_up(
inner: Weak<Inner>,
pair: Pair,
link: Arc<PairLink>,
reply: Option<oneshot::Sender<Result<SharedLink, TransportError>>>,
) {
tokio::spawn(async move {
let mut reply = reply;
let result = tokio::time::timeout(Duration::from_secs(14), async {
loop {
if link.is_closed() || reply.as_ref().is_some_and(oneshot::Sender::is_closed) {
return Err(error("AWG pair closed during handshake"));
}
if link.has_authenticated_path() {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await;
if !matches!(result, Ok(Ok(()))) {
link.close();
if let Some(reply) = reply.take() {
let _ = reply.send(Err(error("AWG authenticated data path timed out")));
}
return;
}
let Some(inner) = inner.upgrade() else {
link.close();
return;
};
if !lock(&inner.allowed).contains(&pair) {
link.close();
return;
}
if link.is_closed()
|| !lock(&inner.links)
.get(&pair)
.is_some_and(|current| current.attempt == link.attempt)
{
link.close();
return;
}
let shared: SharedLink = link.clone();
if let Some(reply) = reply {
if reply.send(Ok(shared)).is_err() {
link.close();
}
} else if inner
.inbound
.try_send(InboundLink {
network: pair.0,
peer: pair.1,
protocol: AWG_PROTOCOL.into(),
link: shared,
})
.is_err()
{
link.close();
}
});
}
impl Drop for AwgTransport {
fn drop(&mut self) {
self.shutdown();
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[tokio::test]
async fn concurrent_admission_is_bounded_across_networks_and_retirement_releases_slots() {
let identity = tsunagi::identity::DeviceIdentity::generate();
let transport = AwgTransport::new(identity.endpoint_id(), Ipv4Addr::LOCALHOST);
let peer = tsunagi::identity::DeviceIdentity::generate().endpoint_id();
std::thread::scope(|scope| {
for thread in 0..8u8 {
let transport = &transport;
scope.spawn(move || {
for item in 0..32u8 {
let mut id = [0; 32];
id[0] = thread;
id[1] = item;
transport.allow_peer(NetworkId::from_bytes(id), peer);
}
});
}
});
assert_eq!(lock(&transport.inner.allowed).len(), 128);
let network = lock(&transport.inner.allowed).iter().next().unwrap().0;
transport.stop_network(network);
let new = NetworkId::from_bytes([255; 32]);
transport.allow_peer(new, peer);
assert!(lock(&transport.inner.allowed).contains(&(new, peer)));
assert_eq!(lock(&transport.inner.allowed).len(), 128);
transport.shutdown();
assert!(lock(&transport.inner.allowed).is_empty());
}
#[tokio::test]
async fn private_negotiation_bounds_roles_and_membership_before_starting_engines() {
let mut ids = [
tsunagi::identity::DeviceIdentity::generate().endpoint_id(),
tsunagi::identity::DeviceIdentity::generate().endpoint_id(),
];
ids.sort_by_key(|id| *id.as_bytes());
let peer = ids[0];
let local = ids[1];
let network = NetworkId::from_bytes([7; 32]);
let transport = AwgTransport::new(local, Ipv4Addr::LOCALHOST);
let offer = |version, key, addresses| Negotiation::Offer {
version,
profile: PairProfile::generate(),
key,
addresses,
};
let address = SocketAddr::from((Ipv4Addr::LOCALHOST, 12345));
let encode = |message: &Negotiation| postcard::to_stdvec(message).unwrap();
let valid = encode(&offer(AWG_WIRE_VERSION, [1; 32], vec![address]));
assert!(
transport
.on_control(network, peer, AWG_PROTOCOL, &valid)
.is_err()
);
transport.allow_peer(network, peer);
assert!(
transport
.on_control(network, peer, "wrong-protocol", &valid)
.is_err()
);
assert!(
transport
.on_control(network, peer, AWG_PROTOCOL, &[0; 4097])
.is_err()
);
for bytes in [
encode(&offer(99, [1; 32], vec![address])),
encode(&offer(AWG_WIRE_VERSION, [0; 32], vec![address])),
encode(&offer(AWG_WIRE_VERSION, [1; 32], vec![])),
encode(&offer(AWG_WIRE_VERSION, [1; 32], vec![address; 33])),
] {
assert!(
transport
.on_control(network, peer, AWG_PROTOCOL, &bytes)
.is_err()
);
}
for len in 0..valid.len() {
assert!(
transport
.on_control(network, peer, AWG_PROTOCOL, &valid[..len])
.is_err()
);
}
let wrong_role = AwgTransport::new(peer, Ipv4Addr::LOCALHOST);
wrong_role.allow_peer(network, local);
assert!(
wrong_role
.on_control(network, local, AWG_PROTOCOL, &valid)
.is_err()
);
transport.forget_peer(network, peer);
assert!(
transport
.on_control(network, peer, AWG_PROTOCOL, &valid)
.is_err()
);
assert!(transport.overview(network).is_empty());
transport.shutdown();
wrong_role.shutdown();
}
}
+530
View File
@@ -0,0 +1,530 @@
//! Real membership, UDP sockets, pair negotiation and nested WireGuard encryption.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use bytes::Bytes;
use std::{
net::Ipv4Addr,
path::Path,
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::Duration,
};
use tempfile::TempDir;
use tsunagi::dataplane::IpPlugin;
use tsunagi::dataplane::transport::{
ControlSender, PacketLink, PacketTransport, SharedLink, TransportError,
};
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::overlay::{MemoryTun, MemoryTunFactory};
use tsunagi::testing::{config_with, network, wait_for_peers, wait_until};
use tsunagi::{Agent, BoxFuture, NetworkId};
use tsunagi_awg::{AWG_PROTOCOL, AwgPlugin, AwgTransport};
use tsunagi_wg_quic::WireguardConfig;
struct Node {
agent: Agent,
plugin: Arc<AwgPlugin>,
transport: Arc<AwgTransport>,
tuns: MemoryTunFactory,
loss: Arc<AtomicBool>,
}
impl Node {
async fn open(root: &Path, discovery: &SharedMemoryDiscovery, interface: &str) -> Self {
Self::open_with_fallback(root, discovery, interface, false).await
}
async fn open_with_fallback(
root: &Path,
discovery: &SharedMemoryDiscovery,
interface: &str,
fallback: bool,
) -> Self {
Self::open_config(root, discovery, interface, fallback, 1280).await
}
async fn open_config(
root: &Path,
discovery: &SharedMemoryDiscovery,
interface: &str,
fallback: bool,
mtu: u32,
) -> Self {
let identity =
tsunagi::storage::StateStore::open(tsunagi::StoragePaths::under(root).state_db())
.unwrap()
.load_or_create_device_identity()
.unwrap();
let transport = AwgTransport::new(identity.endpoint_id(), Ipv4Addr::LOCALHOST);
let plugin = AwgPlugin::open(
WireguardConfig::new(root.join("awg"))
.with_mtu(mtu)
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
transport.clone(),
)
.await
.unwrap();
let loss = Arc::new(AtomicBool::new(false));
let faults = Arc::new(FaultTransport {
inner: transport.clone(),
loss: loss.clone(),
});
let tuns = MemoryTunFactory::new();
let mut config = config_with(root, discovery)
.with_interface(Arc::new(tuns.clone()), interface, mtu)
.with_overlay_ipv4_range(Some(tsunagi::state::DEFAULT_IPV4_RANGE))
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>)
.with_custom_transport(AWG_PROTOCOL, faults);
if fallback {
let quic = tsunagi_wg_quic::WireguardPlugin::open(
WireguardConfig::new(root.join("wg-quic"))
.with_mtu(mtu)
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
)
.await
.unwrap();
config = config.with_plugin(quic);
}
let agent = Agent::spawn(config).await.unwrap();
let inbound_agent = agent.clone();
let inbound_transport = transport.clone();
let inbound_loss = loss.clone();
tokio::spawn(async move {
while let Some(mut link) = inbound_transport.next_inbound().await {
link.link = impaired(link.link, inbound_loss.clone());
let _ = inbound_agent.install_inbound_link(link).await;
}
});
Self {
agent,
plugin,
transport,
tuns,
loss,
}
}
async fn ready(&self, id: NetworkId, n: usize) {
wait_until("AWG outer paths and end-to-end handshakes", || async {
(self
.transport
.overview(id)
.iter()
.filter(|p| !p.closed && !p.path.contains("establishing"))
.count()
== n
&& self
.plugin
.overview(id)?
.peers
.iter()
.filter(|p| {
p.tunnel
.as_ref()
.is_some_and(|t| t.health.is_up() && t.path.contains("awg UDP via"))
})
.count()
== n)
.then_some(())
})
.await;
}
fn tun(&self) -> Arc<MemoryTun> {
self.tuns
.device(&self.agent.overlay().unwrap().interface)
.unwrap()
}
fn addr(&self, id: NetworkId) -> Ipv4Addr {
self.plugin
.overview(id)
.unwrap()
.overlay_address_v4
.unwrap()
}
}
fn packet(source: Ipv4Addr, dest: Ipv4Addr, len: usize) -> Bytes {
let mut bytes = vec![0; len];
bytes[0] = 0x45;
bytes[2..4].copy_from_slice(&(len as u16).to_be_bytes());
bytes[8] = 64;
bytes[9] = 253;
bytes[12..16].copy_from_slice(&source.octets());
bytes[16..20].copy_from_slice(&dest.octets());
rand::fill(&mut bytes[20..]);
Bytes::from(bytes)
}
async fn exchange(a: &Node, b: &Node, id: NetworkId) {
for len in [64, 1097, 1280] {
let bytes = packet(a.addr(id), b.addr(id), len);
a.tun().push_from_os(bytes.clone());
assert_eq!(
tokio::time::timeout(Duration::from_secs(5), b.tun().pop_to_os())
.await
.unwrap()
.unwrap(),
bytes
);
}
}
#[tokio::test]
async fn real_pairs_carry_full_size_packets_and_restart_with_fresh_profiles() {
tsunagi::testing::init_tracing();
let discovery = SharedMemoryDiscovery::new();
let roots = [
TempDir::new().unwrap(),
TempDir::new().unwrap(),
TempDir::new().unwrap(),
];
let a = Node::open(roots[0].path(), &discovery, "awga").await;
let b = Node::open(roots[1].path(), &discovery, "awgb").await;
let c = Node::open(roots[2].path(), &discovery, "awgc").await;
let (name, secret) = network("awg-pairs");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
c.agent.join_network(&name, &secret).await.unwrap();
for node in [&a, &b, &c] {
wait_for_peers(&node.agent, id, 2).await;
node.ready(id, 2).await;
}
let ids: std::collections::HashSet<_> = [&a, &b, &c]
.into_iter()
.flat_map(|n| n.transport.overview(id).into_iter().map(|p| p.attempt))
.collect();
assert_eq!(
ids.len(),
3,
"one independent profile for each of three pairs"
);
exchange(&a, &b, id).await;
exchange(&b, &a, id).await;
exchange(&a, &c, id).await;
let old_ports: Vec<_> = a
.transport
.overview(id)
.into_iter()
.filter_map(|p| p.local_port)
.collect();
a.agent.shutdown().await;
for port in old_ports {
wait_until("old pair socket released", || async {
tokio::net::UdpSocket::bind((Ipv4Addr::LOCALHOST, port))
.await
.ok()
})
.await;
}
let restarted = Node::open(roots[0].path(), &discovery, "awga").await;
restarted.ready(id, 2).await;
assert!(
restarted
.transport
.overview(id)
.iter()
.all(|p| !ids.contains(&p.attempt))
);
exchange(&restarted, &b, id).await;
exchange(&c, &restarted, id).await;
restarted.agent.shutdown().await;
b.agent.shutdown().await;
c.agent.shutdown().await;
assert!(
tokio::time::timeout(Duration::from_secs(1), restarted.transport.next_inbound())
.await
.unwrap()
.is_none()
);
}
// Drops datagrams at the link boundary. Membership, control, sockets, crypto,
// addressing and storage remain real. This substitutes a lossy physical path.
#[derive(Debug)]
struct FaultTransport {
inner: Arc<AwgTransport>,
loss: Arc<AtomicBool>,
}
impl PacketTransport for FaultTransport {
fn name(&self) -> &str {
AWG_PROTOCOL
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn attach_control(&self, sender: Arc<dyn ControlSender>) {
self.inner.attach_control(sender);
}
fn on_control(
&self,
network: NetworkId,
peer: iroh::EndpointId,
protocol: &str,
bytes: &[u8],
) -> Result<(), TransportError> {
self.inner.on_control(network, peer, protocol, bytes)
}
fn open<'a>(
&'a self,
network: NetworkId,
peer: iroh::EndpointId,
protocol: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
Box::pin(async move {
Ok(impaired(
self.inner.open(network, peer, protocol).await?,
self.loss.clone(),
))
})
}
}
fn impaired(raw: SharedLink, loss: Arc<AtomicBool>) -> SharedLink {
Arc::new(FaultLink {
raw,
loss,
sequence: AtomicU64::new(0),
})
}
#[derive(Debug)]
struct FaultLink {
raw: SharedLink,
loss: Arc<AtomicBool>,
sequence: AtomicU64,
}
impl PacketLink for FaultLink {
fn network(&self) -> NetworkId {
self.raw.network()
}
fn peer(&self) -> iroh::EndpointId {
self.raw.peer()
}
fn max_datagram_size(&self) -> usize {
self.raw.max_datagram_size()
}
fn send(&self, bytes: Bytes) -> Result<(), TransportError> {
if self.loss.load(Ordering::Relaxed)
&& !self
.sequence
.fetch_add(1, Ordering::Relaxed)
.is_multiple_of(5)
{
Ok(())
} else {
self.raw.send(bytes)
}
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
self.raw.recv()
}
fn closed(&self) -> BoxFuture<'_, ()> {
self.raw.closed()
}
fn is_closed(&self) -> bool {
self.raw.is_closed()
}
fn close(&self) {
self.raw.close();
}
fn path_description(&self) -> String {
self.raw.path_description()
}
}
#[tokio::test]
async fn persistent_runtime_loss_retires_the_pair_and_negotiates_fresh_parameters() {
let discovery = SharedMemoryDiscovery::new();
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
let a = Node::open(roots[0].path(), &discovery, "lossa").await;
let b = Node::open(roots[1].path(), &discovery, "lossb").await;
let (name, secret) = network("awg-loss");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
a.ready(id, 1).await;
b.ready(id, 1).await;
exchange(&a, &b, id).await;
let old = a.transport.overview(id)[0].attempt;
let mut events = a.agent.subscribe();
a.loss.store(true, Ordering::Relaxed);
tsunagi::testing::wait_event(&mut events, |event| match event {
tsunagi::Event::DataLinkDown {
network, reason, ..
} if *network == id && reason.contains("delivery checks") => Some(()),
_ => None,
})
.await;
a.loss.store(false, Ordering::Relaxed);
wait_until("fresh AWG profile after runtime loss", || async {
a.transport
.overview(id)
.iter()
.any(|p| p.attempt != old && !p.closed && !p.path.contains("establishing"))
.then_some(())
})
.await;
a.ready(id, 1).await;
b.ready(id, 1).await;
let initiator = if a.agent.endpoint_id().as_bytes() < b.agent.endpoint_id().as_bytes() {
&a
} else {
&b
};
let received = initiator.plugin.overview(id).unwrap().peers[0]
.tunnel
.as_ref()
.unwrap()
.stats
.rx_packets;
let attempt = initiator.transport.overview(id)[0].attempt;
wait_until(
"fresh pair delivery checks answered after installation",
|| async {
let view = initiator.plugin.overview(id)?;
(view.peers.first()?.tunnel.as_ref()?.stats.rx_packets >= received + 3).then_some(())
},
)
.await;
assert_eq!(initiator.transport.overview(id)[0].attempt, attempt);
wait_for_peers(&a.agent, id, 1).await;
exchange(&a, &b, id).await;
exchange(&b, &a, id).await;
a.agent.shutdown().await;
b.agent.shutdown().await;
}
#[tokio::test]
async fn runtime_loss_falls_back_to_real_iroh_without_interrupting_control_or_ip() {
let discovery = SharedMemoryDiscovery::new();
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
let a = Node::open_with_fallback(roots[0].path(), &discovery, "fallba", true).await;
let b = Node::open_with_fallback(roots[1].path(), &discovery, "fallbb", true).await;
let (name, secret) = network("awg-fallback");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
a.ready(id, 1).await;
b.ready(id, 1).await;
exchange(&a, &b, id).await;
let mut events = a.agent.subscribe();
a.loss.store(true, Ordering::Relaxed);
tsunagi::testing::wait_event(&mut events, |event| match event {
tsunagi::Event::DataLinkDown {
network, reason, ..
} if *network == id && reason.contains("delivery checks") => Some(()),
_ => None,
})
.await;
for node in [&a, &b] {
wait_until("fallback carrier is wg-quic",||async {
let status=node.agent.status().await.ok()?;
let network=status.networks.iter().find(|n|n.network_id==id)?;
(network.members.iter().any(|m|matches!(&m.data_path,tsunagi::agent::DataPath::Direct{transport} if transport=="wg-quic"))).then_some(())
}).await;
wait_for_peers(&node.agent, id, 1).await;
}
exchange(&a, &b, id).await;
exchange(&b, &a, id).await;
a.agent.shutdown().await;
b.agent.shutdown().await;
}
#[tokio::test]
async fn opaque_pair_carrier_fragments_its_full_advertised_capacity() {
let discovery = SharedMemoryDiscovery::new();
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
let mut nodes = Vec::new();
for root in &roots {
let identity = tsunagi::storage::StateStore::open(
tsunagi::StoragePaths::under(root.path()).state_db(),
)
.unwrap()
.load_or_create_device_identity()
.unwrap();
let transport = AwgTransport::new(identity.endpoint_id(), Ipv4Addr::LOCALHOST);
let agent = Agent::spawn(
config_with(root.path(), &discovery)
.with_custom_transport(AWG_PROTOCOL, transport.clone()),
)
.await
.unwrap();
nodes.push((agent, transport));
}
let (name, secret) = network("awg-capacity");
let id = nodes[0].0.join_network(&name, &secret).await.unwrap();
nodes[1].0.join_network(&name, &secret).await.unwrap();
for (i, (agent, transport)) in nodes.iter().enumerate() {
wait_for_peers(agent, id, 1).await;
// This fixture tests only the transport contract: register the member
// after the real core has authenticated it, without an IP plugin/TUN.
transport.allow_peer(id, nodes[1 - i].0.endpoint_id());
}
nodes.sort_by_key(|(agent, _)| *agent.endpoint_id().as_bytes());
let outgoing = nodes[0]
.1
.open(id, nodes[1].0.endpoint_id(), AWG_PROTOCOL)
.await
.unwrap();
let incoming = tokio::time::timeout(Duration::from_secs(5), nodes[1].1.next_inbound())
.await
.unwrap()
.unwrap()
.link;
for (from, to) in [(&outgoing, &incoming), (&incoming, &outgoing)] {
let mut bytes = vec![0; from.max_datagram_size()];
rand::fill(&mut bytes[..]);
let bytes = Bytes::from(bytes);
from.send(bytes.clone()).unwrap();
assert_eq!(
tokio::time::timeout(Duration::from_secs(5), to.recv())
.await
.unwrap()
.unwrap(),
bytes
);
assert!(matches!(
from.send(Bytes::from(vec![0; from.max_datagram_size() + 1])),
Err(TransportError::TooLarge { .. })
));
}
outgoing.close();
incoming.close();
for (agent, transport) in nodes {
transport.shutdown();
agent.shutdown().await;
}
}
#[tokio::test]
async fn encrypted_health_checks_fit_a_smaller_peers_configured_interface_mtu() {
let discovery = SharedMemoryDiscovery::new();
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
let a = Node::open_config(roots[0].path(), &discovery, "smalla", false, 1280).await;
let b = Node::open_config(roots[1].path(), &discovery, "smallb", false, 576).await;
let (name, secret) = network("awg-small-mtu");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
a.ready(id, 1).await;
b.ready(id, 1).await;
let bytes = packet(a.addr(id), b.addr(id), 576);
a.tun().push_from_os(bytes.clone());
assert_eq!(
tokio::time::timeout(Duration::from_secs(5), b.tun().pop_to_os())
.await
.unwrap()
.unwrap(),
bytes
);
wait_until(
"encrypted delivery checks answered with a small MTU",
|| async {
let view = a.plugin.overview(id)?;
let peer = view.peers.first()?.tunnel.as_ref()?;
(peer.stats.rx_packets >= 3).then_some(())
},
)
.await;
for node in [&a, &b] {
assert_eq!(
node.plugin.overview(id).unwrap().peers[0]
.tunnel
.as_ref()
.unwrap()
.stats
.dropped_oversize,
0
);
}
a.agent.shutdown().await;
b.agent.shutdown().await;
}
+36
View File
@@ -0,0 +1,36 @@
//! Fresh and bounded masking profiles, and rejection of foreign/truncated wire packets.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tsunagi_awg::profile::PairProfile;
#[test]
fn profiles_are_fresh_bounded_and_redacted() {
let profiles: Vec<_> = (0..128).map(|_| PairProfile::generate()).collect();
for (i, profile) in profiles.iter().enumerate() {
profile.validate().unwrap();
assert!(!format!("{profile:?}").contains(&format!("{:?}", profile.header_key())));
for other in &profiles[..i] {
assert_ne!(profile.header_key(), other.header_key());
assert_ne!(profile.id(), other.id());
}
for packet in profile.signature_packets() {
assert!(packet.len() < 1200);
}
}
}
#[test]
fn masked_packets_round_trip_and_reject_other_profiles_and_truncation() {
let profile = PairProfile::generate();
let other = PairProfile::generate();
for (kind, size) in [(1, 148), (2, 92), (3, 64), (4, 800)] {
let mut message = vec![0x7b; size];
message[..4].copy_from_slice(&(kind as u32).to_le_bytes());
let wire = profile.encode(&message).unwrap();
assert_ne!(&wire[..4], &message[..4]);
assert_eq!(profile.decode(&wire).unwrap(), message);
assert!(other.decode(&wire).is_none());
for length in 0..44 {
assert!(profile.decode(&wire[..length]).is_none());
}
}
}
+113
View File
@@ -0,0 +1,113 @@
[package]
name = "tsunagi-core"
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
[lib]
name = "tsunagi"
[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-core = { 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
@@ -129,6 +129,19 @@ pub enum Event {
/// Why it is not up.
reason: String,
},
/// The name this agent claimed was claimed first by another member.
///
/// Nothing stops working: this agent keeps its address and every
/// connection. It just is not found by that name until it is renamed or
/// the member that holds the name lets it go.
HostnameTaken {
/// The network.
network: NetworkId,
/// The name that is taken.
name: String,
/// The member that holds it.
holder: EndpointId,
},
/// An IP plugin reported an error. Never fatal.
PluginError {
/// The network the call was scoped to.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -70,20 +70,24 @@ pub(crate) struct Session {
pub(crate) conn: Connection,
/// Already encoded frame payloads, so the runtime can account for the exact
/// number of control bytes it queues.
pub(crate) outbound: mpsc::Sender<Vec<u8>>,
pub(crate) outbound: mpsc::Sender<zeroize::Zeroizing<Vec<u8>>>,
pub(crate) hostname: Option<String>,
pub(crate) capabilities: Vec<PluginCapability>,
pub(crate) broadcast: bool,
/// The peer offers itself as an exit node in this network.
pub(crate) exit_node: 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 +97,7 @@ impl Session {
conn,
reader,
writer,
path_monitor,
shutdown,
..
} = self;
@@ -102,23 +107,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 +163,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,19 +229,22 @@ pub(crate) fn spawn(
outbound: outbound_tx,
hostname: None,
capabilities: Vec::new(),
broadcast: false,
exit_node: false,
messages_sent: 0,
messages_received: 0,
bytes_sent: 0,
bytes_received: 0,
reader,
writer,
path_monitor,
shutdown,
}
}
async fn writer_task(
mut send: SendStream,
mut outbound: mpsc::Receiver<Vec<u8>>,
mut outbound: mpsc::Receiver<zeroize::Zeroizing<Vec<u8>>>,
limits: Arc<Limits>,
shutdown: Shutdown,
parent: Shutdown,
@@ -273,6 +331,7 @@ async fn reader_task(
}
};
let payload = zeroize::Zeroizing::new(payload);
let bytes = payload.len();
let envelope: Envelope = match decode(&payload) {
Ok(envelope) => envelope,
@@ -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;
}
}
+303
View File
@@ -0,0 +1,303 @@
//! Status snapshots.
//!
//! Metrics are reported at the level they actually belong to. Values that
//! genuinely cannot be attributed to a single network — everything the iroh
//! endpoint aggregates, for instance — stay at the endpoint level rather than
//! being split between networks with invented precision.
use std::time::Duration;
use iroh::{EndpointAddr, EndpointId};
use crate::dataplane::PluginCapability;
use crate::discovery::CandidateSource;
use crate::identity::{NetworkDescriptor, NetworkId, NetworkName};
use crate::net::{ConnectionCounters, PathAddr, PathInfo, TransportKind};
use crate::proto::handshake::Role;
use crate::storage::CacheOutcome;
/// Whether a configured network is running locally.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkState {
/// Configured and running.
Active,
/// Configured but not running.
Inactive,
}
/// An unverified candidate as seen by a network runtime.
#[derive(Debug, Clone)]
pub struct CandidateStatus {
/// Candidate endpoint id.
pub endpoint_id: EndpointId,
/// Where it came from.
pub source: CandidateSource,
/// Consecutive failed dial attempts since the last success.
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>,
}
/// How packets reach a member right now.
///
/// Being reachable is one thing and being reachable well is another, and
/// the second is never a reason to call a member away: a member that can only
/// be reached through somebody else is online, over a longer path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DataPath {
/// Nothing carries packets to it yet. The member is connected for
/// control only, which is the state of a data path being set up.
None,
/// A direct link, of the named kind of transport.
Direct {
/// The kind of transport carrying it, e.g. `wg`.
transport: String,
},
/// Through other members.
Relayed {
/// Number of links on the way, at least two.
hops: u8,
/// The member the first of them goes to.
via: EndpointId,
},
}
/// A name that a member claimed and somebody else holds.
///
/// A name belongs to whoever claimed it first. The member that claimed it
/// later is still a member and still reachable by its address; it is just
/// not found by that name, on any member of the network.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostnameConflict {
/// The name that is taken.
pub name: String,
/// The member that holds it.
pub holder: EndpointId,
}
/// A member the signed state knows about, connected or not.
///
/// This is the durable roster: it comes from signed records, so a member that
/// went away last month is still here. That is what makes it possible to say
/// "this peer is offline" rather than only "nobody is connected".
///
/// It is not a complete membership list, and cannot be. A member is in signed
/// state once it has claimed something — an address, a name, or both — so a
/// member that has joined but never published is visible only while it is
/// connected.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberStatus {
/// The member's device identity.
pub endpoint_id: EndpointId,
/// The IPv4 overlay address it claimed and signed for.
pub overlay_address_v4: Option<std::net::Ipv4Addr>,
/// The name it claimed, when it holds that name uncontested.
///
/// Signed, so it is still known while the member is away — which is what
/// lets an absent member be named rather than shown as a bare id.
pub hostname: Option<String>,
/// The last name this agent saw it announce, whether or not it is here.
///
/// Not signed, so it is a convenience for naming a member that is away
/// and never a claim: the zone is served from `hostname`.
pub last_hostname: Option<String>,
/// The name it claimed, when somebody else claimed it first.
pub hostname_conflict: Option<HostnameConflict>,
/// Whether this agent can reach it now, by any path: a live control
/// session, or a data path through other members. The one answer to "is
/// it online" for every client, so that none of them has to work it out
/// from a different part of the picture. This agent counts as online.
pub online: bool,
/// How packets reach it. [`DataPath::None`] while it is offline.
pub data_path: DataPath,
}
/// Status of one authenticated session.
#[derive(Debug, Clone)]
pub struct PeerStatus {
/// This authenticated peer accepts network broadcasts.
pub broadcast: bool,
/// This authenticated peer offers itself as an exit node.
pub exit_node: bool,
/// Authenticated endpoint id.
pub endpoint_id: EndpointId,
/// Which side this agent played in the handshake.
pub role: Role,
/// Hostname the peer announced, if it has announced one yet.
///
/// 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.
pub connected_for: Duration,
/// Verified paths of the underlying connection.
pub paths: Vec<PathInfo>,
/// How the connection currently reaches the peer.
pub transport: TransportKind,
/// RTT of the selected path, when iroh reported one.
pub rtt: Option<Duration>,
/// Per-connection counters.
pub connection: ConnectionCounters,
/// Control messages sent on this session.
pub control_messages_sent: u64,
/// Control messages received on this session.
pub control_messages_received: u64,
/// Control payload bytes queued for this session.
pub control_bytes_sent: u64,
/// Control payload bytes read from this session.
pub control_bytes_received: u64,
/// How packets reach this peer right now.
pub data_path: DataPath,
}
/// Counters scoped to one logical network.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NetworkMetrics {
/// Outbound dial attempts started.
pub dial_attempts: u64,
/// Outbound dials that failed before or during the handshake.
pub dial_failures: u64,
/// Handshakes rejected, in either direction.
pub handshake_failures: u64,
/// Sessions that reached the authenticated state.
pub sessions_established: u64,
/// Sessions that ended.
pub disconnects: u64,
/// Control messages sent in this network.
pub control_messages_sent: u64,
/// Control messages received in this network.
pub control_messages_received: u64,
/// Control bytes sent in this network, payload only.
pub control_bytes_sent: u64,
/// Control bytes received in this network, payload only.
pub control_bytes_received: u64,
/// Messages or sessions rejected for protocol violations.
pub protocol_violations: u64,
/// Errors reported by IP plugins. Never fatal.
pub plugin_errors: u64,
/// Data plane links that were established.
pub data_links_established: u64,
/// Attempts to open a data plane link that failed.
pub data_link_failures: u64,
}
/// An active network preventing another network from starting locally.
#[derive(Debug, Clone)]
pub struct RangeConflict {
/// The blocking network's public identifier.
pub network_id: NetworkId,
/// Its configured name.
pub name: NetworkName,
/// Its overlapping overlay range.
pub range: crate::state::Ipv4Range,
}
/// Status of one network.
#[derive(Debug, Clone)]
pub struct NetworkStatus {
/// Whether this network is requested to start or resume automatically.
pub auto_start: bool,
/// Local broadcast participation in this network.
pub broadcast: bool,
/// This agent offers itself as an exit node in this network.
pub exit_offer: bool,
/// The member this device sends all its internet traffic through.
pub exit_via: Option<EndpointId>,
/// Whether that member is connected and still offering to be one.
pub exit_via_online: bool,
/// What the host rules for the exit settings are doing.
pub exit_rules: crate::overlay::ExitRulesReport,
/// Immutable deterministic description of the network space.
pub descriptor: NetworkDescriptor,
/// Network name, for convenience.
pub name: NetworkName,
/// Public network identifier.
pub network_id: NetworkId,
/// Whether the network is running locally.
pub state: NetworkState,
/// Authenticated sessions.
pub peers: Vec<PeerStatus>,
/// Unverified candidates currently known. Not peers.
pub candidates: Vec<CandidateStatus>,
/// Members the signed state knows about, whether connected or not.
pub members: Vec<MemberStatus>,
/// This agent's own name, when another member claimed it first and so
/// holds it. This agent keeps working by address; nobody finds it by
/// this name until it is renamed or the other member lets it go.
pub hostname_conflict: Option<HostnameConflict>,
/// The overlay range this network uses, once it has one.
pub range: Option<crate::state::Ipv4Range>,
/// The first overlapping active range; `range_conflicts` identifies all blockers.
pub range_conflict: Option<crate::state::Ipv4Range>,
/// Active networks that would be suspended by switching to this network.
pub range_conflicts: Vec<RangeConflict>,
/// 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 {
/// Endpoint ids of peers with an authenticated session.
pub fn connected_peers(&self) -> Vec<EndpointId> {
self.peers.iter().map(|peer| peer.endpoint_id).collect()
}
}
/// Status of the whole agent.
#[derive(Debug, Clone)]
pub struct AgentStatus {
/// This device's persistent endpoint id.
pub endpoint_id: EndpointId,
/// Hostname announced to peers.
pub hostname: String,
/// Sockets actually bound.
pub bound_sockets: Vec<std::net::SocketAddr>,
/// Addresses iroh believes this endpoint has. Observed, not verified.
pub observed_addrs: Vec<PathAddr>,
/// The dialable address of this endpoint, as iroh currently reports it.
pub endpoint_addr: EndpointAddr,
/// What happened to the disposable cache at startup.
pub cache_outcome: CacheOutcome,
/// Whether the cache is currently usable.
pub cache_healthy: bool,
/// Per-network status, including configured but inactive networks.
pub networks: Vec<NetworkStatus>,
}
impl AgentStatus {
/// Looks up one network's status.
pub fn network(&self, network_id: &NetworkId) -> Option<&NetworkStatus> {
self.networks
.iter()
.find(|status| &status.network_id == network_id)
}
}
@@ -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.
@@ -119,10 +166,14 @@ pub struct Limits {
pub max_capabilities: usize,
/// Largest opaque plugin payload, in bytes.
pub max_capability_data_len: usize,
/// Largest private transport negotiation payload (also hard-capped at 4096 bytes).
pub max_transport_payload_len: usize,
/// Largest accepted echo payload in a ping/pong exchange, in bytes.
pub max_echo_payload_len: usize,
/// Largest accepted free-text reason string, in bytes.
pub max_reason_len: usize,
/// Largest number of signed records accepted in one snapshot.
pub max_state_records: usize,
/// Deadline for the whole handshake.
pub handshake_timeout: Duration,
/// Deadline for one outbound dial attempt.
@@ -135,6 +186,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.
@@ -153,13 +206,16 @@ impl Default for Limits {
max_frame_len: 64 * 1024,
max_hostname_len: 255,
max_capabilities: 16,
max_transport_payload_len: 4096,
max_capability_data_len: 4 * 1024,
max_echo_payload_len: 4 * 1024,
max_reason_len: 256,
max_state_records: crate::state::MAX_RECORDS_PER_MESSAGE,
handshake_timeout: Duration::from_secs(10),
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,
@@ -209,6 +265,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 {
@@ -220,13 +303,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,
@@ -234,6 +328,31 @@ 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,
/// Whether new networks derive their IPv4 range from their network id.
///
/// Enabled by default. Explicit ranges and IPv4 opt-out disable this.
pub overlay_ipv4_auto: bool,
/// The IPv4 overlay range this agent proposes when automatic selection is off.
///
/// Addresses are allocated from it and recorded in signed state, so a
/// participant keeps the same one across restarts. A network that has
/// already settled on another range wins: a joining agent adopts what it
/// finds rather than imposing this.
pub overlay_ipv4_range: Option<crate::state::Ipv4Range>,
}
impl AgentConfig {
@@ -243,12 +362,26 @@ 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_auto: true,
overlay_ipv4_range: None,
}
}
@@ -267,6 +400,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;
@@ -279,7 +427,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
@@ -291,12 +451,42 @@ impl AgentConfig {
self
}
/// Sets the IPv4 overlay range this agent proposes, or disables IPv4.
pub fn with_overlay_ipv4_range(mut self, range: Option<crate::state::Ipv4Range>) -> Self {
self.overlay_ipv4_auto = false;
self.overlay_ipv4_range = range;
self
}
/// Registers an IP plugin.
pub fn with_plugin(mut self, plugin: SharedPlugin) -> Self {
self.plugins.push(plugin);
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;
@@ -318,6 +508,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)
@@ -332,3 +524,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.
@@ -94,18 +155,73 @@ pub(crate) enum PluginRequest {
#[derive(Clone)]
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>) -> 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),
}
}
/// A context that discards everything, for plugins used outside an agent.
pub fn detached() -> Self {
Self { sender: None }
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,
}
}
/// This agent's own endpoint id, when the context is attached.
///
/// A plugin needs it to find itself in the agreed allocation.
pub fn local_endpoint_id(&self) -> Option<EndpointId> {
self.local
}
fn send(&self, request: PluginRequest) {
@@ -142,12 +258,29 @@ 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 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PluginContext")
.field("attached", &self.sender.is_some())
.field("local", &self.local.map(|id| id.fmt_short().to_string()))
.finish()
}
}
@@ -157,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
@@ -197,6 +353,34 @@ pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
capability: &PluginCapability,
) -> std::result::Result<(), PluginError>;
/// The overlay addresses the network has agreed on.
///
/// Allocated rather than derived, and backed by the signed records in
/// [`crate::state`], so a participant keeps its address across restarts
/// and long absences. Called whenever the agreed picture changes.
fn on_address_allocation(
&self,
network: NetworkId,
range: crate::state::Ipv4Range,
allocations: &[(EndpointId, std::net::Ipv4Addr)],
) {
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
@@ -216,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.
@@ -263,6 +460,10 @@ impl IpPlugin for TestCapabilityPlugin {
&self.protocol
}
fn protocol_version(&self) -> u16 {
1
}
fn local_capability(
&self,
_network: NetworkId,
+638
View File
@@ -0,0 +1,638 @@
//! 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),
}
}
}
/// The links to one neighbour, best first. Which transport carries a frame
/// is decided here and nowhere else: the end-to-end protocol is in the
/// envelope and never depends on it.
#[derive(Debug)]
struct NextHop {
peer: PeerId,
links: Box<[SharedLink]>,
}
impl NextHop {
fn usable(&self) -> bool {
self.links.iter().any(|link| !link.is_closed())
}
fn first_open(&self) -> Option<&SharedLink> {
self.links.iter().find(|link| !link.is_closed())
}
/// Hands a frame to the best open link that accepts it. A link that
/// cannot carry the frame (too large for its path, closed a moment ago)
/// leaves it to the next one; the frame is only copied when there is
/// another to try.
fn send(&self, frame: Bytes) -> Result<(), TransportError> {
let mut open = self.links.iter().filter(|link| !link.is_closed());
let Some(mut current) = open.next() else {
return Err(TransportError::Closed);
};
for next in open {
if current.send(frame.clone()).is_ok() {
return Ok(());
}
current = next;
}
current.send(frame)
}
}
#[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.usable());
}
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.usable())
}
}
#[derive(Debug, Default)]
struct ForwardingTable {
routes: HashMap<PeerId, ForwardRoute>,
/// Local delivery, by who sent it and which end-to-end protocol it is.
inboxes: HashMap<(PeerId, u64), 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, header.protocol)) 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.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);
}
}
}
/// One physical link to a neighbour, of whatever kind of transport.
#[derive(Debug)]
struct RawLink {
kind: String,
link: SharedLink,
reader: JoinHandle<()>,
}
/// How a destination is reached right now.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RouteSummary {
/// Number of transport links to it. One is a direct link.
pub hops: u8,
/// The neighbour the first link goes to. For a direct link that is the
/// destination itself.
pub via: EndpointId,
}
#[derive(Debug)]
struct State {
plane: Arc<Plane>,
/// Transport kinds, best first. A neighbour with several links is
/// reached over the first of these it has.
priority: Vec<String>,
graph: HashMap<PeerId, Vec<PeerId>>,
members: HashSet<PeerId>,
raw: HashMap<PeerId, Vec<RawLink>>,
links: HashMap<(PeerId, String), Arc<PeerLink>>,
}
impl State {
fn rank(&self, kind: &str) -> usize {
self.priority
.iter()
.position(|known| known == kind)
.unwrap_or(usize::MAX)
}
/// A neighbour's links, best kind first.
fn ordered(&self, peer: &PeerId) -> Vec<&RawLink> {
let mut links: Vec<&RawLink> = self
.raw
.get(peer)
.map(|links| links.iter().collect())
.unwrap_or_default();
links.sort_by_key(|raw| (self.rank(&raw.kind), raw.kind.clone()));
links
}
fn publish(&self) {
let mut graph = self.graph.clone();
let mut neighbours: Vec<PeerId> = self
.raw
.iter()
.filter(|(_, links)| links.iter().any(|raw| !raw.link.is_closed()))
.map(|(peer, _)| *peer)
.collect();
neighbours.sort_unstable();
graph.insert(self.plane.local, neighbours);
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| {
let links: Box<[_]> = self
.ordered(id)
.into_iter()
.map(|raw| raw.link.clone())
.collect();
(!links.is_empty()).then_some(NextHop { peer: *id, links })
})
.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.tag), link.inbox_tx.clone()))
.collect(),
}));
}
fn stop_readers(&mut self) {
for raw in self.raw.values().flatten() {
raw.reader.abort();
}
}
}
impl Drop for State {
fn drop(&mut self) {
self.stop_readers();
for link in self.links.values() {
link.close();
}
self.plane.table.store(Arc::default());
}
}
/// One network's transport links and its published forwarding table.
///
/// There is one table for the whole network, whatever protocols run over it.
/// A link is a way to reach a neighbour; a protocol is an end-to-end channel
/// to a peer. The two meet only in the envelope, which names the protocol, so
/// a tunnel keeps working over any mix of transports and a middle member
/// needs no protocol in common with either end.
#[derive(Debug)]
pub struct RelayHub {
network: NetworkId,
state: Mutex<State>,
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> {
let tally = Arc::new(Tally::default());
Arc::new(Self {
network,
state: Mutex::new(State {
plane: Arc::new(Plane {
local: *local.as_bytes(),
table: ArcSwap::from_pointee(ForwardingTable::default()),
tally: tally.clone(),
}),
priority: Vec::new(),
graph: HashMap::new(),
members: HashSet::new(),
raw: HashMap::new(),
links: HashMap::new(),
}),
tally,
changed: Arc::default(),
})
}
fn state(&self) -> std::sync::MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(|error| error.into_inner())
}
/// Sets which kinds of transport are preferred, best first, when a
/// neighbour can be reached over more than one.
pub fn set_priority(&self, kinds: Vec<String>) {
let mut state = self.state();
if state.priority != kinds {
state.priority = kinds;
state.publish();
}
}
/// 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.
///
/// Every row of `graph` is one member's own account of who it has a link
/// to; what kind of link it is does not matter here.
pub fn set_topology(&self, graph: HashMap<PeerId, Vec<PeerId>>, members: HashSet<PeerId>) {
let mut state = self.state();
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) -> bool {
self.state()
.plane
.table
.load()
.routes
.contains_key(peer.as_bytes())
}
/// How a destination is reached right now, if it is.
pub fn route(&self, peer: EndpointId) -> Option<RouteSummary> {
let table = self.state().plane.table.load_full();
let route = table.routes.get(peer.as_bytes())?;
let via = EndpointId::from_bytes(&route.next.first()?.peer).ok()?;
Some(RouteSummary {
hops: route.hops,
via,
})
}
/// The kinds of transport with an open direct link to a peer, best
/// first.
pub fn direct_kinds(&self, peer: EndpointId) -> Vec<String> {
let state = self.state();
state
.ordered(peer.as_bytes())
.into_iter()
.filter(|raw| !raw.link.is_closed())
.map(|raw| raw.kind.clone())
.collect()
}
/// Stable end-to-end channel for a protocol, preserved across topology
/// and transport changes.
pub fn link(&self, peer: EndpointId, protocol: &str) -> Arc<PeerLink> {
let mut state = self.state();
let key = (*peer.as_bytes(), protocol.to_owned());
if let Some(link) = state.links.get(&key) {
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,
tag: envelope::protocol_tag(protocol),
plane: state.plane.clone(),
inbox_tx,
inbox_rx: tokio::sync::Mutex::new(inbox_rx),
closed,
});
state.links.insert(key, link.clone());
state.publish();
link
}
/// Whether the protocol already owns its end-to-end channel to a peer.
pub fn has_link(&self, peer: EndpointId, protocol: &str) -> bool {
self.state()
.links
.contains_key(&(*peer.as_bytes(), protocol.to_owned()))
}
/// Installs a raw transport link and starts its independent ingress reader.
///
/// A neighbour may have one link per kind of transport; installing a
/// second of the same kind replaces the first.
pub fn set_direct(&self, peer: EndpointId, kind: &str, raw: SharedLink) {
if raw.network() != self.network || raw.peer() != peer {
return;
}
let mut state = self.state();
let id = *peer.as_bytes();
let plane = state.plane.clone();
let changed = self.changed.clone();
let reader_link = raw.clone();
let reader = tokio::spawn(async move {
let mut batch = 0;
while let Some(frame) = reader_link.recv().await {
plane.receive(id, frame);
batch += 1;
if batch == 64 {
tokio::task::yield_now().await;
batch = 0;
}
}
changed.notify_one();
});
let links = state.raw.entry(id).or_default();
if let Some(position) = links.iter().position(|known| known.kind == kind) {
links.remove(position).reader.abort();
}
links.push(RawLink {
kind: kind.to_owned(),
link: raw,
reader,
});
state.publish();
}
/// Removes one physical link without tearing down end-to-end protocol state.
pub fn clear_direct(&self, peer: EndpointId, kind: &str) {
let mut state = self.state();
let mut changed = false;
if let Some(links) = state.raw.get_mut(peer.as_bytes()) {
if let Some(position) = links.iter().position(|known| known.kind == kind) {
links.remove(position).reader.abort();
changed = true;
}
if links.is_empty() {
state.raw.remove(peer.as_bytes());
}
}
if changed {
state.publish();
}
}
/// Revokes all links and topology involving a departed authenticated peer.
pub fn remove_peer(&self, peer: EndpointId) {
let mut state = self.state();
if let Some(links) = state.raw.remove(peer.as_bytes()) {
for raw in links {
raw.reader.abort();
}
}
let gone: Vec<_> = state
.links
.keys()
.filter(|(id, _)| id == peer.as_bytes())
.cloned()
.collect();
for key in gone {
if let Some(link) = state.links.remove(&key) {
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) {
let mut state = self.state();
state.stop_readers();
state.raw.clear();
for link in state.links.values() {
link.close();
}
state.links.clear();
state.graph.clear();
state.members.clear();
state.plane.table.store(Arc::default());
}
}
/// A protocol's end-to-end channel, independent of its current next hop.
#[derive(Debug)]
pub struct PeerLink {
network: NetworkId,
peer: EndpointId,
tag: u64,
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.send(envelope::encode(
self.plane.local,
*self.peer.as_bytes(),
flow,
self.tag,
&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]
.first_open()
.map_or_else(|| "unreachable".into(), |link| 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;
@@ -0,0 +1,418 @@
#![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()
}
fn tag() -> u64 {
envelope::protocol_tag("ip")
}
#[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, edges: &[(u8, &[u8])], members: &[u8]) {
hub.set_topology(
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, &[(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, &[(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, &[(3, &[4])], &[1, 2, 3, 4]);
let frame = envelope::encode(id(1), id(4), 91, tag(), 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");
assert_eq!(hub.counters().forwarded, 1);
hub.close();
}
#[tokio::test]
async fn a_frame_is_delivered_only_to_the_protocol_it_names() {
let hub = RelayHub::new(network(), peer(2));
hub.set_direct(peer(1), "ip", Wire::new(1, true));
let ip = hub.link(peer(1), "ip");
let other = hub.link(peer(1), "other-ip");
let plane = ip.plane.clone();
plane.receive(id(1), envelope::encode(id(1), id(2), 0, tag(), b"for ip"));
plane.receive(
id(1),
envelope::encode(
id(1),
id(2),
0,
envelope::protocol_tag("other-ip"),
b"for other",
),
);
plane.receive(
id(1),
envelope::encode(
id(1),
id(2),
0,
envelope::protocol_tag("nobody-runs-this"),
b"for nobody",
),
);
assert_eq!(ip.recv().await.unwrap(), Bytes::from_static(b"for ip"));
assert_eq!(
other.recv().await.unwrap(),
Bytes::from_static(b"for other")
);
assert_eq!(hub.counters().dropped_unknown, 1);
hub.close();
}
/// A middle member joins two neighbours over different transports, and the
/// two ends share a protocol the middle has never heard of.
#[tokio::test]
async fn a_tunnel_crosses_a_middle_member_whose_links_are_of_different_kinds() {
let a = RelayHub::new(network(), peer(1));
let c = RelayHub::new(network(), peer(3));
let b = RelayHub::new(network(), peer(2));
let a_to_c = Wire::new(3, true);
let c_to_a = Wire::new(1, true);
let c_to_b = Wire::new(2, true);
let b_to_c = Wire::new(3, true);
a.set_direct(peer(3), "wg", a_to_c.clone());
c.set_direct(peer(1), "wg", c_to_a);
c.set_direct(peer(2), "wg-quic", c_to_b.clone());
b.set_direct(peer(3), "wg-quic", b_to_c.clone());
topology(&a, &[(3, &[1, 2])], &[1, 2, 3]);
topology(&b, &[(3, &[1, 2])], &[1, 2, 3]);
topology(&c, &[(1, &[3]), (2, &[3])], &[1, 2, 3]);
let outgoing = a.link(peer(2), "tunnel");
let incoming = b.link(peer(1), "tunnel");
outgoing
.send_flow(Bytes::from_static(b"sealed"), 5)
.unwrap();
let frame = a_to_c.take().remove(0);
// The middle forwards it onto its other kind of link without looking
// inside.
c.state().plane.receive(id(1), frame);
let frame = c_to_b.take().remove(0);
assert_eq!(c.counters().forwarded, 1);
b.state().plane.receive(id(3), frame);
assert_eq!(
incoming.recv().await.unwrap(),
Bytes::from_static(b"sealed")
);
assert_eq!(b.counters().received_via, 1);
assert_eq!(
b.route(peer(1)),
Some(RouteSummary {
hops: 2,
via: peer(3)
})
);
a.close();
b.close();
c.close();
}
#[tokio::test]
async fn a_neighbour_is_reached_over_the_preferred_kind_and_falls_back_when_it_is_lost() {
let hub = RelayHub::new(network(), peer(1));
hub.set_priority(vec!["wg".into(), "tcp-tls".into(), "wg-quic".into()]);
let slow = Wire::new(2, true);
let fast = Wire::new(2, true);
hub.set_direct(peer(2), "wg-quic", slow.clone());
let link = hub.link(peer(2), "tunnel");
link.send(Bytes::from_static(b"one")).unwrap();
assert_eq!(slow.take().len(), 1);
assert_eq!(hub.direct_kinds(peer(2)), ["wg-quic"]);
// A better kind appears: traffic moves to it with nothing to redo.
hub.set_direct(peer(2), "wg", fast.clone());
assert_eq!(hub.direct_kinds(peer(2)), ["wg", "wg-quic"]);
link.send(Bytes::from_static(b"two")).unwrap();
assert_eq!(fast.take().len(), 1);
assert!(slow.take().is_empty());
// It dies: the worse one carries on, and the logical link is untouched.
fast.dead.store(true, Ordering::Relaxed);
link.send(Bytes::from_static(b"three")).unwrap();
assert_eq!(slow.take().len(), 1);
assert!(!link.is_closed());
assert_eq!(
hub.route(peer(2)),
Some(RouteSummary {
hops: 1,
via: peer(2)
})
);
hub.clear_direct(peer(2), "wg-quic");
hub.clear_direct(peer(2), "wg");
assert!(!hub.reachable(peer(2)));
assert!(Arc::ptr_eq(&link, &hub.link(peer(2), "tunnel")));
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, tag(), 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, tag(), b"unknown member"),
);
assert_eq!(hub.counters().dropped_unknown, 2);
plane.receive(
id(1),
envelope::encode(id(1), id(9), 0, tag(), 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, &[(2, &[4])], &[1, 2, 3, 4]);
topology(&b, &[(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, tag(), 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, &[(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, tag(), &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,104 @@
//! 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 + 8;
const VERSION: u8 = 2;
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Header {
pub source: PeerId,
pub destination: PeerId,
pub remaining: u8,
pub flow: FlowId,
/// Which end-to-end protocol the payload belongs to, whatever links carry it.
pub protocol: u64,
}
/// A stable 64-bit tag for a protocol id (FNV-1a), the same on every member.
pub(crate) fn protocol_tag(name: &str) -> u64 {
name.bytes().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
})
}
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()?),
protocol: u64::from_be_bytes(bytes[74..82].try_into().ok()?),
})
}
pub(crate) fn encode(
source: PeerId,
destination: PeerId,
flow: FlowId,
protocol: u64,
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.put_u64(protocol);
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, protocol_tag("wg"), &[42; 1280]);
let address = frame.as_ptr();
let frame = decrement(frame);
assert_eq!(
frame.as_ptr(),
address,
"no payload copy for an owned buffer"
);
let header = decode(&frame).unwrap();
assert_eq!(header.remaining, ROUTING_HOP_LIMIT - 1);
assert_eq!(header.protocol, protocol_tag("wg"));
assert_ne!(protocol_tag("wg"), protocol_tag("wg-quic"));
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]);
}
}
}
@@ -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);
}
}
@@ -0,0 +1,350 @@
//! Authenticated probes of the actual data link, independent of control RTT.
//! Keys are negotiated privately for each physical link. No fixed wire magic.
use super::{PacketLink, SharedLink, TransportError};
use crate::dataplane::SharedPlugin;
use crate::overlay::RoutingTable;
use crate::{BoxFuture, NetworkId};
use bytes::Bytes;
use hmac::{Hmac, KeyInit, Mac};
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch};
#[derive(Clone, Serialize, Deserialize, zeroize::ZeroizeOnDrop)]
pub(crate) struct Settings {
marker: [u8; 16],
key: [u8; 32],
}
impl Settings {
pub(crate) fn fresh() -> Self {
Self {
marker: rand::random(),
key: rand::random(),
}
}
}
impl std::fmt::Debug for Settings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("HealthSettings([REDACTED])")
}
}
struct Samples {
pending: VecDeque<(u64, Instant)>,
recent: VecDeque<bool>,
}
impl Samples {
fn new() -> Self {
Self {
pending: VecDeque::new(),
recent: VecDeque::new(),
}
}
fn sent(&mut self, seq: u64, now: Instant) {
self.pending.push_back((seq, now));
}
fn acknowledge(&mut self, seq: u64) {
if let Some(i) = self.pending.iter().position(|(n, _)| *n == seq) {
self.pending.remove(i);
self.recent.push_back(true);
}
}
fn expire(&mut self, now: Instant) {
while self
.pending
.front()
.is_some_and(|(_, at)| now.duration_since(*at) >= Duration::from_secs(3))
{
self.pending.pop_front();
self.recent.push_back(false);
}
while self.recent.len() > 20 {
self.recent.pop_front();
}
}
fn degraded(&self) -> bool {
self.recent.len() >= 15
&& self.recent.iter().filter(|ok| !**ok).count() * 100 >= self.recent.len() * 60
}
}
// This prefix is inside end-to-end encryption. It never appears on the UDP wire.
const IP_MAGIC: &[u8; 16] = b"tsunagi-health01";
pub(crate) fn ip_payload(packet: &[u8]) -> Option<&[u8]> {
(packet.len() >= 36 && packet[0] == 0x45 && packet[9] == 253 && &packet[20..36] == IP_MAGIC)
.then(|| &packet[36..])
}
/// Inject probes through the same encrypted tunnel as application IP packets.
/// This matters for pure WG: raw UDP probes could pass DPI while WG is blocked.
pub(crate) struct ProbeRoute {
pub(crate) plugin: SharedPlugin,
pub(crate) routes: Arc<RoutingTable>,
pub(crate) network: NetworkId,
pub(crate) peer: EndpointId,
pub(crate) max_probe_size: usize,
}
impl ProbeRoute {
fn send(&self, payload: Bytes) -> bool {
let Some((source, destination)) = self.routes.probe_addresses(self.network, self.peer)
else {
return false;
};
let mut packet = vec![0; 36];
packet[0] = 0x45;
packet[8] = 64;
packet[9] = 253;
packet[12..16].copy_from_slice(&source.octets());
packet[16..20].copy_from_slice(&destination.octets());
packet[20..36].copy_from_slice(IP_MAGIC);
let length = (packet.len() + payload.len()) as u16;
packet[2..4].copy_from_slice(&length.to_be_bytes());
let sum = packet[..20]
.as_chunks::<2>()
.0
.iter()
.map(|b| u32::from(u16::from_be_bytes(*b)))
.sum::<u32>();
let sum = (sum & 0xffff) + (sum >> 16);
packet[10..12].copy_from_slice(&(!(sum as u16)).to_be_bytes());
packet.extend_from_slice(&payload);
self.plugin
.carry(self.network, self.peer, Bytes::from(packet))
}
}
/// A monitored physical link. It forwards application bytes unchanged.
pub(crate) struct HealthLink {
probes: mpsc::Sender<Bytes>,
raw: SharedLink,
settings: Mutex<Option<Settings>>,
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
stop: watch::Sender<bool>,
reason: Mutex<Option<String>>,
}
impl std::fmt::Debug for HealthLink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HealthLink")
.field("raw", &self.raw)
.finish_non_exhaustive()
}
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match m.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
}
}
impl HealthLink {
pub(crate) fn new(
raw: SharedLink,
local: EndpointId,
settings: Option<Settings>,
route: Option<ProbeRoute>,
) -> Arc<Self> {
let (tx, rx) = mpsc::channel(64);
let (probe_tx, mut probe_rx) = mpsc::channel(64);
let (stop, _) = watch::channel(false);
let link = Arc::new(Self {
probes: probe_tx,
raw: raw.clone(),
settings: Mutex::new(settings),
rx: tokio::sync::Mutex::new(rx),
stop,
reason: Mutex::new(None),
});
let weak = Arc::downgrade(&link);
let mut stopped = link.stop.subscribe();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut samples = Samples::new();
let mut sequence = rand::random::<u64>();
let mut since: Option<Instant> = None;
let mut marker = None;
loop {
tokio::select! {
biased;
_=stopped.changed()=>break,
_=interval.tick()=>{
let Some(link)=weak.upgrade()else{break;};let Some(settings)=lock(&link.settings).clone()else{continue;};
if marker != Some(settings.marker) { marker = Some(settings.marker); samples = Samples::new(); since = None; }
let start=*since.get_or_insert_with(Instant::now);let now=Instant::now();samples.expire(now);
if now.duration_since(start)>=Duration::from_secs(20) && samples.degraded() {
*lock(&link.reason)=Some("data delivery checks: at least 60% loss over the last 20 probes".into());break;
}
sequence=sequence.wrapping_add(1);
let size=route.as_ref().map_or(raw.max_datagram_size(),|route|route.max_probe_size);
let packet=probe(&settings,local,1,sequence,size.min(1000));
if let Some(route)=&route { if route.send(packet) {samples.sent(sequence,now);} }
else { let _=raw.send(packet); samples.sent(sequence,now); }
}
packet=raw.recv()=>{
let Some(packet)=packet else{break;};let Some(link)=weak.upgrade()else{break;};
let settings=lock(&link.settings).clone();
if route.is_none() && let Some(settings)=settings && packet.starts_with(&settings.marker) {
if let Some((kind,seq))=verify(&settings,raw.peer(),&packet) {
if kind==1 {let _=raw.send(probe(&settings,local,2,seq,packet.len()));}
else if kind==2 {samples.acknowledge(seq);}
}
} else {let _=tx.try_send(packet);}
}
packet=probe_rx.recv(),if route.is_some()=>{
let Some(packet)=packet else {break;};let Some(link)=weak.upgrade()else{break;};
let Some(settings)=lock(&link.settings).clone()else{continue;};
if let Some((kind,seq))=verify(&settings,raw.peer(),&packet) {
if kind==1 { if let Some(route)=&route { route.send(probe(&settings,local,2,seq,packet.len().min(route.max_probe_size))); } }
else if kind==2 {samples.acknowledge(seq);}
}
}
}
}
raw.close();
if let Some(link) = weak.upgrade() {
link.stop.send_replace(true);
}
});
link
}
pub(crate) fn receive_probe(&self, packet: Bytes) {
let _ = self.probes.try_send(packet);
}
pub(crate) fn configure(&self, settings: Settings) {
*lock(&self.settings) = Some(settings);
}
}
fn probe(settings: &Settings, sender: EndpointId, kind: u8, seq: u64, size: usize) -> Bytes {
let mut bytes = vec![0; size.max(57)];
bytes[..16].copy_from_slice(&settings.marker);
bytes[16] = kind;
bytes[17..25].copy_from_slice(&seq.to_be_bytes());
rand::fill(&mut bytes[25..size.max(57) - 32]);
let tail = bytes.len() - 32;
if let Ok(mut mac) = <Hmac<Sha256> as KeyInit>::new_from_slice(&settings.key) {
mac.update(sender.as_bytes());
mac.update(&bytes[..tail]);
bytes[tail..].copy_from_slice(&mac.finalize().into_bytes());
}
Bytes::from(bytes)
}
fn verify(settings: &Settings, sender: EndpointId, bytes: &[u8]) -> Option<(u8, u64)> {
if bytes.len() < 57 || !bytes.starts_with(&settings.marker) {
return None;
}
let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(&settings.key).ok()?;
let tail = bytes.len() - 32;
mac.update(sender.as_bytes());
mac.update(&bytes[..tail]);
mac.verify_slice(&bytes[tail..]).ok()?;
Some((
bytes[16],
u64::from_be_bytes(bytes.get(17..25)?.try_into().ok()?),
))
}
impl PacketLink for HealthLink {
fn network(&self) -> NetworkId {
self.raw.network()
}
fn peer(&self) -> EndpointId {
self.raw.peer()
}
fn max_datagram_size(&self) -> usize {
self.raw.max_datagram_size()
}
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
if self.is_closed() {
Err(TransportError::Closed)
} else {
self.raw.send(payload)
}
}
fn send_flow(
&self,
payload: Bytes,
flow: super::super::routing::FlowId,
) -> Result<(), TransportError> {
if self.is_closed() {
Err(TransportError::Closed)
} else {
self.raw.send_flow(payload, flow)
}
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move {
let mut stop = self.stop.subscribe();
if *stop.borrow() {
return None;
}
let mut rx = self.rx.lock().await;
tokio::select! {biased;_=stop.changed()=>None,packet=rx.recv()=>packet}
})
}
fn closed(&self) -> BoxFuture<'_, ()> {
Box::pin(async move {
let mut stop = self.stop.subscribe();
if !*stop.borrow() {
let _ = stop.changed().await;
}
})
}
fn is_closed(&self) -> bool {
*self.stop.borrow() || self.raw.is_closed()
}
fn close(&self) {
self.stop.send_replace(true);
self.raw.close();
}
fn failure_reason(&self) -> Option<String> {
lock(&self.reason)
.clone()
.or_else(|| self.raw.failure_reason())
}
fn path_description(&self) -> String {
self.raw.path_description()
}
}
impl Drop for HealthLink {
fn drop(&mut self) {
self.stop.send_replace(true);
self.raw.close();
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn no_false_failure_before_window_but_persistent_loss_is_detected() {
let mut samples = Samples::new();
let now = Instant::now();
for seq in 0..20 {
samples.sent(seq, now);
if seq % 5 == 0 {
samples.acknowledge(seq);
}
}
assert!(!samples.degraded());
samples.expire(now + Duration::from_secs(4));
assert!(samples.degraded());
for seq in 20..40 {
samples.sent(seq, now);
samples.acknowledge(seq);
}
samples.expire(now + Duration::from_secs(4));
assert!(!samples.degraded());
}
#[test]
fn a_forged_or_reflected_probe_cannot_claim_delivery() {
let a = crate::identity::DeviceIdentity::generate().endpoint_id();
let b = crate::identity::DeviceIdentity::generate().endpoint_id();
let settings = Settings::fresh();
let packet = probe(&settings, a, 1, 42, 1100);
assert_eq!(verify(&settings, a, &packet), Some((1, 42)));
assert!(verify(&settings, b, &packet).is_none());
let mut forged = packet.to_vec();
forged[16] = 2;
assert!(verify(&settings, a, &forged).is_none());
}
}
@@ -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),
}
@@ -91,6 +97,9 @@ impl IrohLink {
}
impl PacketLink for IrohLink {
fn close(&self) {
self.conn.close(0u32.into(), b"data link retired");
}
fn network(&self) -> NetworkId {
self.network
}
@@ -110,17 +119,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<'_, ()> {
@@ -137,8 +194,8 @@ impl PacketLink for IrohLink {
// Report what iroh actually knows, never a guess.
let snapshot = crate::net::snapshot_connection(&self.conn);
match snapshot.paths.iter().find(|path| path.is_selected) {
Some(path) => format!("{:?} via {:?}", snapshot.transport, path.remote),
None => format!("{:?}, no selected path yet", snapshot.transport),
Some(path) => format!("{} via {}", snapshot.transport, path.remote),
None => format!("{}, no selected path yet", snapshot.transport),
}
}
}
@@ -208,10 +265,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 +287,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 +357,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 +379,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)
})
@@ -0,0 +1,317 @@
//! The data plane transport boundary.
//!
//! This is the seam that keeps the control protocol and the data plane
//! independent. An [`IpPlugin`] never learns how its packets are moved: it is
//! handed a [`PacketLink`] to a peer and writes datagrams into it. Whether
//! that link runs over iroh today, a raw UDP socket, or something else
//! entirely tomorrow is the transport's business alone.
//!
//! [`IpPlugin`]: crate::dataplane::IpPlugin
//!
//! # Why the transport may use iroh
//!
//! The separation between control and data is **logical**, not a ban on
//! sharing technology. Refusing to use iroh for data would throw away exactly
//! what iroh is good at — hole punching a direct path between two peers behind
//! NAT, with a relay as fallback — and force the data plane to reimplement it.
//! So the default transport is [`iroh_link::IrohTransport`], which gives every
//! plugin that connectivity for free.
//!
//! What the separation does buy is that the control protocol in
//! [`crate::proto`] knows nothing about packets, and this module knows nothing
//! about WireGuard. Either side can be replaced on its own.
//!
//! # Semantics
//!
//! A link is an **unreliable, unordered datagram** channel, because that is
//! what a tunnelled UDP protocol needs: no retransmission, no head-of-line
//! blocking, loss is normal rather than an error. It is authenticated and
//! encrypted by the transport, and scoped to exactly one network, one peer and
//! one plugin protocol.
mod fragments;
pub(crate) mod health;
pub mod iroh_link;
use std::sync::Arc;
use bytes::Bytes;
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use crate::BoxFuture;
use crate::identity::NetworkId;
/// Why a data plane link failed.
///
/// None of these ever stop the control plane.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TransportError {
/// The peer is not reachable for the data plane right now.
#[error("peer is unreachable: {0}")]
Unreachable(String),
/// The peer declined to open a channel for this protocol.
#[error("peer declined a data channel for protocol `{0}`")]
Declined(String),
/// The link is closed.
#[error("data link is closed")]
Closed,
/// A datagram was larger than the link can carry.
#[error("datagram of {size} bytes exceeds the {limit} byte link limit")]
TooLarge {
/// Size that was attempted.
size: usize,
/// Largest datagram this link accepts.
limit: usize,
},
/// Anything else.
#[error("data transport error: {0}")]
Other(String),
}
/// An authenticated datagram channel to one peer, for one plugin protocol.
///
/// Dropping the link closes it.
pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
/// The network this link belongs to.
fn network(&self) -> NetworkId;
/// The authenticated peer on the other end.
fn peer(&self) -> EndpointId;
/// The largest datagram this link can carry, in bytes.
///
/// 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.
///
/// Delivery is not guaranteed. Returning `Ok` means the datagram was
/// 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>>;
/// Resolves once the link is closed, for whatever reason.
fn closed(&self) -> BoxFuture<'_, ()>;
/// Whether the link is already closed.
///
/// Lets the owner notice a dead link and ask for a new one without
/// keeping a task parked on [`PacketLink::closed`].
fn is_closed(&self) -> bool;
/// Explicitly retires a physical link. Implementations should wake readers.
fn close(&self) {}
/// Observed reason for retiring this link, if available.
fn failure_reason(&self) -> Option<String> {
None
}
/// A short description of the path in use, for diagnostics.
///
/// Reports what the transport actually knows. It must not invent a value.
fn path_description(&self) -> String;
}
/// A shared handle to a link.
pub type SharedLink = std::sync::Arc<dyn PacketLink>;
/// Maximum opaque pair-negotiation payload.
pub const MAX_TRANSPORT_PAYLOAD: usize = 4096;
/// Private transport bytes, never included in Debug output.
#[derive(Clone, PartialEq, Eq, Serialize, zeroize::ZeroizeOnDrop)]
pub struct TransportPayload(Vec<u8>);
impl<'de> Deserialize<'de> for TransportPayload {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = TransportPayload;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "at most {MAX_TRANSPORT_PAYLOAD} private transport bytes")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut sequence: A,
) -> std::result::Result<Self::Value, A::Error> {
if sequence
.size_hint()
.is_some_and(|size| size > MAX_TRANSPORT_PAYLOAD)
{
return Err(serde::de::Error::custom("transport payload too large"));
}
let mut payload = TransportPayload(Vec::with_capacity(
sequence.size_hint().unwrap_or(0).min(MAX_TRANSPORT_PAYLOAD),
));
while let Some(byte) = sequence.next_element::<u8>()? {
if payload.0.len() == MAX_TRANSPORT_PAYLOAD {
return Err(serde::de::Error::custom("transport payload too large"));
}
payload.0.push(byte);
}
Ok(payload)
}
}
deserializer.deserialize_seq(Visitor)
}
}
impl TransportPayload {
/// Wraps bytes for one authenticated control session.
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
/// Borrows private bytes; callers must not log them.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl std::fmt::Debug for TransportPayload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("TransportPayload([REDACTED])")
}
}
/// Private control-plane sender for transports. Contains no network secret.
pub trait ControlSender: Send + Sync + std::fmt::Debug + 'static {
/// Queues a bounded message to one authenticated member of one network.
fn send<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
protocol: &'a str,
payload: TransportPayload,
) -> BoxFuture<'a, Result<(), TransportError>>;
}
/// An inbound link a peer opened towards us.
#[derive(Debug)]
pub struct InboundLink {
/// The network it belongs to.
pub network: NetworkId,
/// The peer that opened it.
pub peer: EndpointId,
/// The plugin protocol it carries.
pub protocol: String,
/// The link itself.
pub link: SharedLink,
}
/// Opens and accepts data plane links.
///
/// The agent owns one of these and hands links to plugins; plugins never call
/// it directly.
pub trait PacketTransport: Send + Sync + std::fmt::Debug + 'static {
/// Supplies a private sender before network activation.
fn attach_control(&self, _control: Arc<dyn ControlSender>) {}
/// Validates and queues private negotiation received from an authenticated member.
fn on_control(
&self,
_network: NetworkId,
_peer: EndpointId,
_protocol: &str,
_payload: &[u8],
) -> Result<(), TransportError> {
Err(TransportError::Declined(self.name().into()))
}
/// A short name used in diagnostics.
fn name(&self) -> &str;
/// Lets the agent recover the concrete transport to drive its accept side.
///
/// Accepting is inherently transport-specific — it starts from whatever
/// the transport's own listener produced — so it is not part of this
/// trait's uniform interface.
fn as_any(&self) -> &dyn std::any::Any;
/// Opens a link to `peer` in `network` for `protocol`.
fn open<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
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 attach_control(&self, control: Arc<dyn ControlSender>) {
self.default.attach_control(control.clone());
for transport in self.by_protocol.values() {
transport.attach_control(control.clone());
}
}
fn on_control(
&self,
network: NetworkId,
peer: EndpointId,
protocol: &str,
payload: &[u8],
) -> Result<(), TransportError> {
match self.by_protocol.get(protocol) {
Some(transport) => transport.on_control(network, peer, protocol, payload),
None => Err(TransportError::Declined(protocol.into())),
}
}
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)
}
}
}
+380
View File
@@ -0,0 +1,380 @@
//! 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,
tx_bytes: AtomicU64,
rx_bytes: 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,
/// Plaintext bytes successfully encrypted and sent.
pub tx_bytes: u64,
/// Plaintext bytes successfully received and decrypted.
pub rx_bytes: 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),
tx_bytes: state.tx_bytes.load(Ordering::Relaxed),
rx_bytes: state.rx_bytes.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);
state
.tx_bytes
.fetch_add(packet.len() as u64, 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),
tx_bytes: AtomicU64::new(0),
rx_bytes: 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);
rx_state
.rx_bytes
.fetch_add(packet.len() as u64, 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(())
})
}
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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)])
);
}
}
@@ -97,6 +97,19 @@ pub enum Error {
#[error("network {0} is not configured")]
NetworkUnknown(NetworkId),
/// An overlay range overlaps networks already active on this agent.
#[error(
"network {network} cannot start: {range} overlaps active networks {conflicts:?}; switch to it to suspend those networks"
)]
NetworkRangeConflict {
/// Network that could not start.
network: NetworkId,
/// Its agreed or proposed range.
range: crate::state::Ipv4Range,
/// Active networks that must be suspended first.
conflicts: Vec<NetworkId>,
},
/// No session with that peer exists in the given network.
#[error("no authenticated session with peer {peer} in network {network}")]
NoSuchPeer {
@@ -110,6 +123,23 @@ 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,
},
/// The request is understood and cannot be done as things stand.
#[error("{reason}")]
Rejected {
/// Why, phrased so the message stands on its own.
reason: String,
},
/// Discovery backend failure. Never fatal for the agent.
#[error("discovery error: {0}")]
Discovery(String),
@@ -49,15 +49,28 @@ 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()
}
/// A clone of the key used to sign this device's own state records.
///
/// The same persistent identity the control plane authenticates, so a
/// record signed today is still attributable after any absence.
pub(crate) fn signing_key(&self) -> SecretKey {
self.secret.clone()
}
}
impl std::fmt::Debug for DeviceIdentity {
@@ -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 {
File diff suppressed because it is too large. Load diff
+227
View File
@@ -0,0 +1,227 @@
//! 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, ControlSocketAccess, 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>,
access: ControlSocketAccess,
) -> Result<Self> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
create_parent(parent, &access)?;
}
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,
})?;
apply_access(&path, &access)?;
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);
}
}
/// Creates the socket's parent directory.
///
/// A group-shared socket must sit in a directory the group can traverse, so it
/// is `0755`; a private socket keeps the owner-only state-directory default.
fn create_parent(parent: &Path, access: &ControlSocketAccess) -> Result<()> {
match access {
ControlSocketAccess::Private => crate::storage::create_dir(parent),
ControlSocketAccess::Group(_) => {
std::fs::create_dir_all(parent).map_err(|source| Error::Io {
path: parent.to_path_buf(),
source,
})?;
set_mode(parent, 0o755)
}
}
}
/// Applies the access policy to the bound socket.
///
/// Private is owner-only `0600`. Group sets the socket's group to the gid and
/// the mode to `0660`; if the group cannot be set — the agent is not a member,
/// say — it falls back to owner-only rather than leaving the group wrong, so
/// the socket is never more open than intended.
fn apply_access(path: &Path, access: &ControlSocketAccess) -> Result<()> {
match access {
ControlSocketAccess::Private => set_mode(path, 0o600),
ControlSocketAccess::Group(gid) => {
if let Err(source) = std::os::unix::fs::chown(path, None, Some(*gid)) {
tracing::warn!(
path = %path.display(), gid, %source,
"cannot set the control socket group; keeping it owner-only"
);
return set_mode(path, 0o600);
}
set_mode(path, 0o660)
}
}
}
fn set_mode(path: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).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, ControlSocketAccess::Private)
.await
.unwrap();
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
.await
.unwrap();
assert!(matches!(answer, Response::Status(_)));
control.shutdown().await;
}
}
+453
View File
@@ -0,0 +1,453 @@
//! 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, ControlSocketAccess, 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.
///
/// `_access` is accepted for parity with the Unix socket; granting a group
/// here means a pipe security descriptor and is a separate follow-up, so for
/// now the pipe keeps its default (creator plus the built-in access the
/// `PipeSecurityAttributes` set).
pub async fn bind(
path: impl AsRef<Path>,
source: Arc<dyn ReportSource>,
_access: ControlSocketAccess,
) -> 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 `tsng 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 `tsng 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;
}
}
+13 -1
View File
@@ -14,7 +14,12 @@
//! * [`proto`] — the control protocol: framing, messages, handshake.
//! * [`net`] — the iroh connectivity adapter and its observability surface.
//! * [`agent`] — the runtime: agent lifecycle, per-network runtimes, reconnect.
//! * [`dataplane`] — the minimal contract future IP plugins must satisfy.
//! * [`dataplane`] — the contract IP plugins satisfy, the packet transport,
//! and the WireGuard data plane.
//! * [`state`] — signed records that outlive a session, and the rules for
//! merging them between replicas.
//! * [`ipc`] — the local control interface a command line tool talks to. An
//! adapter over the public API; the core does not know it exists.
//!
//! # What this library deliberately does not do
//!
@@ -34,11 +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};
@@ -45,6 +45,16 @@ pub enum PathAddr {
Other(String),
}
impl std::fmt::Display for PathAddr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PathAddr::Ip(addr) => write!(f, "{addr}"),
PathAddr::Relay(url) => write!(f, "relay {url}"),
PathAddr::Other(what) => write!(f, "{what}"),
}
}
}
/// One verified network path of a connection.
#[derive(Debug, Clone)]
pub struct PathInfo {
@@ -81,6 +91,17 @@ pub enum TransportKind {
Unknown,
}
impl std::fmt::Display for TransportKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let word = match self {
TransportKind::Direct => "direct",
TransportKind::Relay => "relay",
TransportKind::Unknown => "unknown",
};
f.write_str(word)
}
}
/// Counters for one connection.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConnectionCounters {
@@ -219,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 {
@@ -233,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 })
}
@@ -0,0 +1,231 @@
//! 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 {
exit: Default::default(),
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 {
exit: Default::default(),
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 {
exit: Default::default(),
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"
)));
}
+32
View File
@@ -0,0 +1,32 @@
//! Exit-node host rules: what makes this device an exit node for others, and
//! what sends this device's own traffic through one.
//!
//! The packet side lives in [`super::router`]; this is the side the operating
//! system sees. It is kept apart from the broadcast rules, which have a
//! different lifetime and different tags.
/// How one set of host rules went.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RuleSetReport {
/// Whether every step took.
pub ok: bool,
/// One line: what is in place, or what is missing and why.
pub detail: String,
}
/// What the host rules of one network's exit-node settings are doing.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExitRulesReport {
/// Masquerading and forwarding for members' traffic, when this agent
/// offers itself as an exit node here. `None` means none are wanted.
pub offer: Option<RuleSetReport>,
/// Whether the kernel forwards packets for the overlay interface.
///
/// `Some(false)` means the rules are in place but nothing passes until
/// forwarding is turned on, which the agent never does itself. `None`
/// when it could not be read.
pub forwarding: Option<bool>,
/// The routes that send this device's traffic through its exit node.
/// `None` means it uses none through this network.
pub client: Option<RuleSetReport>,
}
@@ -0,0 +1,263 @@
//! Host rules for exit nodes: what the operating system needs for this agent
//! to carry other members' internet traffic, and for this device's own
//! traffic to go through a member.
//!
//! Two independent halves, planned together because one interface carries
//! both:
//!
//! * **Offering** — one set of rules per network that offers this agent as an
//! exit node, chosen by that network's range: masquerade the range out of
//! every other interface and let it through the forward chain. A network
//! that does not offer has no rules, so its members' packets are dropped by
//! the firewall as well as by the agent.
//! * **Using** — policy routing that sends everything the host does not have
//! a more specific route for into the overlay interface, while the agent's
//! own sockets keep the ordinary route (see `linux_exit`).
//!
//! Nothing a peer announces becomes a host setting. Offering is the owner's
//! switch; using is the user's choice of a member. Every object is tagged,
//! reversible, and removed when it is no longer wanted, when the network
//! leaves or stops, and when the agent stops.
use std::sync::{Arc, Mutex};
use crate::BoxFuture;
use crate::state::Ipv4Range;
use super::RuleOutcome;
/// What the host should hold for one interface's exit settings.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExitHostPlan {
/// The overlay interface.
pub interface: String,
/// The ranges of every network that offers this agent as an exit node.
pub offer: Vec<Ipv4Range>,
/// Whether this device sends its internet traffic through a member.
pub client: bool,
/// The range of every network on the interface. Only the using side
/// reads it, to keep this agent's own traffic *to* the overlay inside it
/// where the host has no per-user routing (macOS).
pub overlay: Vec<Ipv4Range>,
/// Underlay addresses this agent talks to directly and must keep reaching
/// outside the overlay's default route. Only filled while `client` is set
/// and only for hosts that report [`ExitHostRules::needs_bypass`].
pub bypass: Vec<std::net::Ipv4Addr>,
/// Host names (relay servers) that must stay reachable the same way; the
/// host rules resolve them.
pub bypass_hosts: Vec<String>,
}
impl ExitHostPlan {
/// Whether there is nothing to hold.
pub fn is_empty(&self) -> bool {
self.offer.is_empty() && !self.client
}
}
/// What applying a plan achieved.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExitHostReport {
/// One outcome per offered range: its masquerade and forward rules.
pub offer: Vec<(Ipv4Range, RuleOutcome)>,
/// Whether the kernel forwards packets for the interface. `None` when it
/// could not be read. The agent only reads this: turning forwarding on is
/// the owner's decision.
pub forwarding: Option<bool>,
/// The routes and rules that send this device's traffic through the
/// interface, when the plan asked for them.
pub client: Option<RuleOutcome>,
}
/// Installs and removes exit-node rules for an overlay interface.
///
/// Like the broadcast rules, no call fails as a whole: a step that cannot be
/// done is in the report, because the caller's only useful response is to
/// say so.
pub trait ExitHostRules: Send + Sync + std::fmt::Debug + 'static {
/// Makes the host match the plan, replacing what this agent put there
/// for the interface before.
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport>;
/// Removes everything this agent put there for the interface. Succeeds
/// when nothing is there: it runs on the shutdown path, and once at
/// startup to take away what a crashed run left.
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>;
/// Whether using an exit node on this host needs the underlay addresses
/// the agent talks to, because the host cannot exempt the agent's own
/// traffic from the default route by user id (Windows). The default is no.
fn needs_bypass(&self) -> bool {
false
}
}
/// The tag every firewall object of one offered range carries.
pub fn exit_tag(interface: &str, range: &Ipv4Range) -> String {
format!("{}{range}", exit_tag_prefix(interface))
}
/// The start of every tag for an interface; what cleanup selects by.
pub fn exit_tag_prefix(interface: &str) -> String {
format!("tsunagi-exit:{interface}:")
}
/// The one-line detail for a set of offer outcomes.
pub fn offer_detail(outcome: &RuleOutcome, range: &Ipv4Range) -> String {
match outcome {
RuleOutcome::Applied => format!("masquerade + forward for {range} in place"),
RuleOutcome::Failed(reason) => reason.clone(),
}
}
/// An in-memory host for exit rules, for tests: nothing is touched.
#[derive(Debug, Clone, Default)]
pub struct MockExitRules {
state: Arc<Mutex<Option<ExitHostPlan>>>,
calls: Arc<Mutex<Vec<String>>>,
forwarding: Arc<Mutex<Option<bool>>>,
failure: Arc<Mutex<Option<String>>>,
needs_bypass: Arc<Mutex<bool>>,
}
impl MockExitRules {
/// An empty host with forwarding turned on.
pub fn new() -> Self {
let rules = Self::default();
*lock(&rules.forwarding) = Some(true);
rules
}
/// What the host reports for kernel forwarding from now on.
pub fn set_forwarding(&self, forwarding: Option<bool>) {
*lock(&self.forwarding) = forwarding;
}
/// Whether this host asks for the underlay addresses, like Windows.
pub fn set_needs_bypass(&self, needs: bool) {
*lock(&self.needs_bypass) = needs;
}
/// Makes every step fail with this reason from now on.
pub fn fail_with(&self, reason: Option<String>) {
*lock(&self.failure) = reason;
}
/// What is installed now.
pub fn installed(&self) -> Option<ExitHostPlan> {
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 ExitHostRules for MockExitRules {
fn needs_bypass(&self) -> bool {
*lock(&self.needs_bypass)
}
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
Box::pin(async move {
lock(&self.calls).push(format!("apply:{}", plan.interface));
let outcome = match lock(&self.failure).clone() {
Some(reason) => RuleOutcome::Failed(reason),
None => {
*lock(&self.state) = Some(plan.clone());
RuleOutcome::Applied
}
};
ExitHostReport {
offer: plan
.offer
.iter()
.map(|range| (*range, outcome.clone()))
.collect(),
forwarding: *lock(&self.forwarding),
client: plan.client.then_some(outcome),
}
})
}
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
lock(&self.calls).push(format!("clear:{interface}"));
*lock(&self.state) = None;
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn range() -> Ipv4Range {
"10.13.37.0/24".parse().unwrap()
}
#[test]
fn tags_name_the_interface_and_the_range() {
assert_eq!(
exit_tag("tsun0", &range()),
"tsunagi-exit:tsun0:10.13.37.0/24"
);
assert!(exit_tag("tsun0", &range()).starts_with(&exit_tag_prefix("tsun0")));
// Another interface's tag never matches this one's prefix.
assert!(!exit_tag("tsun01", &range()).starts_with(&exit_tag_prefix("tsun0")));
}
#[test]
fn an_empty_plan_is_nothing_to_hold() {
let mut plan = ExitHostPlan::default();
assert!(plan.is_empty());
plan.client = true;
assert!(!plan.is_empty());
}
#[tokio::test]
async fn the_mock_applies_reports_and_clears() {
let rules = MockExitRules::new();
let plan = ExitHostPlan {
interface: "tsun0".into(),
offer: vec![range()],
client: true,
overlay: vec![range()],
..Default::default()
};
let report = rules.apply(&plan).await;
assert_eq!(report.offer[0].1, RuleOutcome::Applied);
assert_eq!(report.forwarding, Some(true));
assert_eq!(report.client, Some(RuleOutcome::Applied));
assert_eq!(rules.installed(), Some(plan));
rules.set_forwarding(Some(false));
rules.fail_with(Some("denied".into()));
let report = rules
.apply(&ExitHostPlan {
interface: "tsun0".into(),
offer: vec![range()],
client: false,
overlay: Vec::new(),
..Default::default()
})
.await;
assert_eq!(report.forwarding, Some(false));
assert!(matches!(report.offer[0].1, RuleOutcome::Failed(_)));
assert_eq!(report.client, None);
rules.clear("tsun0").await;
assert_eq!(rules.installed(), None);
assert_eq!(rules.calls(), ["apply:tsun0", "apply:tsun0", "clear:tsun0"]);
}
}
@@ -0,0 +1,687 @@
//! 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)]
pub(super) 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.
pub(super) type Run<'a> = &'a dyn Fn(&[String]) -> Ran;
/// The match and target of the rule, without the chain or position.
///
/// `comment` false leaves the tag off, for a kernel that has no `comment`
/// match; the rule is then recognised by its shape, see [`tagged_deletions`].
fn rule_spec(plan: &BroadcastRulesPlan, comment: bool) -> Vec<String> {
let range = plan.range.to_string();
let tag = plan.tag();
let mut words = vec!["-i", plan.interface.as_str(), "-p", "udp", "-s", &range];
if comment {
words.extend(["-m", "comment", "--comment", &tag]);
}
words.extend(["-j", "ACCEPT"]);
words.into_iter().map(str::to_string).collect()
}
/// Whether the kernel has the `comment` match, which a rule needs to carry
/// its tag. Asked of iptables itself: checking a rule that does not exist
/// fails either way, but only a missing match says so.
pub(super) fn comment_supported(run: Run<'_>) -> bool {
let probe = locked(
[
"-t",
"filter",
"-C",
"FORWARD",
"-m",
"comment",
"--comment",
"tsunagi-probe",
"-j",
"ACCEPT",
]
.map(str::to_string),
);
match run(&probe) {
Ran::Exited {
success: false,
stderr,
..
} => !stderr.contains("No chain/target/match by that name"),
_ => true,
}
}
/// `iptables -S` words as option clauses: an option with its value, a negated
/// one with its `!`. It prints the options in its own order, not the one a
/// rule was written in, so rules are compared by their clauses, sorted.
fn clauses(words: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut at = 0;
while at < words.len() {
let take = if words[at] == "!" { 3 } else { 2 };
let end = (at + take).min(words.len());
out.push(words[at..end].join(" "));
at = end;
}
out.sort();
out
}
/// Whether two rules say the same thing, whatever order they list it in.
pub(super) fn same_rule(a: &[String], b: &[String]) -> bool {
clauses(a) == clauses(b)
}
/// Waits briefly for the xtables lock instead of failing when another tool
/// holds it.
pub(super) 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, comment: bool) -> Vec<String> {
locked(
["-C".to_string(), CHAIN.to_string()]
.into_iter()
.chain(rule_spec(plan, comment)),
)
}
fn insert_args(plan: &BroadcastRulesPlan, comment: bool) -> Vec<String> {
locked(
["-I".to_string(), CHAIN.to_string(), "1".to_string()]
.into_iter()
.chain(rule_spec(plan, comment)),
)
}
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.
pub(super) 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
}
/// Whether a rule is the one this module writes for some range of the
/// interface the tag belongs to, with no comment on it.
fn is_uncommented_rule(tag: &str, rest: &[String]) -> bool {
let Some(interface) = tag.strip_prefix("tsunagi:") else {
return false;
};
let Some(range) = rest
.windows(2)
.find(|pair| pair[0] == "-s")
.map(|pair| pair[1].as_str())
else {
return false;
};
let ours: Vec<String> = ["-i", interface, "-p", "udp", "-s", range, "-j", "ACCEPT"]
.map(str::to_string)
.to_vec();
same_rule(&ours, rest)
}
/// The delete commands for every rule in an `iptables -S INPUT` listing that
/// carries exactly this tag, or, with no `comment` match in the kernel, is
/// exactly the rule written for some range. Anything else 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)
|| is_uncommented_rule(tag, rest);
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.
pub(super) 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 comment = comment_supported(run);
let present = match run(&check_args(plan, comment)) {
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, comment)) {
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`.
pub(super) 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 {
pub(super) 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 exit_rules(&self) -> Option<&dyn super::ExitHostRules> {
Some(self)
}
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(), true));
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(), true)).starts_with("-w 5 -I INPUT 1 -i tsun0"));
assert!(words(&check_args(&plan(), true)).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());
}
#[test]
fn a_kernel_without_the_comment_match_says_so_in_its_error() {
let answer = |stderr: &'static str| {
move |_: &[String]| Ran::Exited {
success: false,
stdout: String::new(),
stderr: stderr.to_string(),
}
};
assert!(!comment_supported(&answer(
"iptables: No chain/target/match by that name."
)));
assert!(comment_supported(&answer(
"iptables: Bad rule (does a matching rule exist in that chain?)."
)));
assert!(comment_supported(&|_: &[String]| Ran::Missing));
}
#[test]
fn a_rule_written_without_a_comment_is_found_by_its_shape() {
// Listed the way iptables prints it: its own option order.
let listing = "\
-P INPUT ACCEPT
-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -j ACCEPT
-A INPUT -s 10.99.0.0/16 -i tsun0 -p udp -j ACCEPT
-A INPUT -s 10.13.37.0/24 -i tsun1 -p udp -j ACCEPT
-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -j DROP
-A INPUT -s 10.13.37.0/24 -i tsun0 -p tcp -j ACCEPT
";
let deletions = tagged_deletions(listing, "tsunagi:tsun0");
assert_eq!(deletions.len(), 2, "{deletions:?}");
assert!(words(&deletions[0]).ends_with("-s 10.13.37.0/24 -i tsun0 -p udp -j ACCEPT"));
assert!(words(&deletions[1]).contains("10.99.0.0/16"));
}
#[test]
fn without_a_comment_the_rule_has_none() {
let with = words(&rule_spec(&plan(), true));
let without = words(&rule_spec(&plan(), false));
assert!(with.contains("-m comment --comment tsunagi:tsun0"));
assert_eq!(without, "-i tsun0 -p udp -s 10.13.37.0/24 -j ACCEPT");
}
/// 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 {
// The probe for the `comment` match: not part of what is tested.
if args.iter().any(|word| word == "tsunagi-probe") {
return Ran::Exited {
success: false,
stdout: String::new(),
stderr: "Bad rule".into(),
};
}
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,813 @@
//! Exit-node host rules on Linux: `iptables` for the offering side, policy
//! routing over netlink for the using side.
//!
//! # Offering
//!
//! For every network that offers this agent as an exit node, three rules,
//! selected by that network's range and carrying the tag
//! `tsunagi-exit:<if>:<range>`:
//!
//! ```text
//! -t nat POSTROUTING -s <range> ! -o <if> -j MASQUERADE
//! -t filter FORWARD -i <if> -s <range> ! -o <if> -j ACCEPT
//! -t filter FORWARD -o <if> -d <range> -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
//! ```
//!
//! `! -o <if>` is the egress without naming it: whatever interface the
//! default route uses, now or after it changes. Kernel forwarding is only
//! *read*. Turning it on changes how the whole host behaves, which is the
//! owner's decision, so when it is off the rules are installed anyway and the
//! report says so.
//!
//! # Using
//!
//! Policy routing, with no `/1` routes and nothing in the main table:
//!
//! ```text
//! table 0x7473: default dev <if>
//! 5280: from all uidrange <euid>-<euid> lookup main
//! 5290: from all lookup main suppress_prefixlength 0
//! 5300: from all lookup 0x7473
//! ```
//!
//! The first keeps the agent's *own* sockets off the tunnel: its QUIC, relay,
//! DHT and DNS traffic would otherwise be routed into the interface it is
//! carrying, a loop. The second lets every specific route — the LAN, another
//! VPN, a tunnel to somewhere else — keep working, because only the default
//! route is suppressed. The priorities sit after Tailscale's 5270.
//!
//! The first rule exempts a *user id*, so it is only right when the agent has
//! one of its own, as the packaged service does. An agent started from a login
//! session would take that user's own applications out of the tunnel with it,
//! and the exit node would silently do nothing for them. That is refused, not
//! guessed at: see [`client_refusal`].
use crate::BoxFuture;
use crate::state::Ipv4Range;
use super::RuleOutcome;
use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules, exit_tag, exit_tag_prefix};
use super::linux::{
LinuxHostRules, Ran, Run, comment_supported, failure, locked, run_iptables, same_rule,
split_rule_line,
};
/// The chains the offering rules live in: `(table, chain)`.
const CHAINS: [(&str, &str); 2] = [("nat", "POSTROUTING"), ("filter", "FORWARD")];
/// One firewall rule: where it goes, and its match and target.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Rule {
table: &'static str,
chain: &'static str,
spec: Vec<String>,
}
fn owned(words: &[&str]) -> Vec<String> {
words.iter().map(|word| word.to_string()).collect()
}
/// The three rules of one offered range.
///
/// Each carries a comment naming its interface and range, which is how it is
/// found again. A kernel without the `comment` match (small router builds
/// leave it out) cannot take that, so `comment` false leaves it off and the
/// rules are recognised by their shape instead; see [`tagged_rules`].
fn offer_rules(interface: &str, range: &Ipv4Range, comment: bool) -> Vec<Rule> {
let range_text = range.to_string();
let tag = exit_tag(interface, range);
let comment: Vec<String> = if comment {
owned(&["-m", "comment", "--comment", tag.as_str()])
} else {
Vec::new()
};
let spec = |head: &[&str], tail: &[&str]| -> Vec<String> {
owned(head)
.into_iter()
.chain(comment.iter().cloned())
.chain(owned(tail))
.collect()
};
vec![
Rule {
table: "nat",
chain: "POSTROUTING",
spec: spec(
&["-s", &range_text, "!", "-o", interface],
&["-j", "MASQUERADE"],
),
},
Rule {
table: "filter",
chain: "FORWARD",
spec: spec(
&["-i", interface, "-s", &range_text, "!", "-o", interface],
&["-j", "ACCEPT"],
),
},
Rule {
table: "filter",
chain: "FORWARD",
spec: spec(
&[
"-o",
interface,
"-d",
&range_text,
"-m",
"conntrack",
"--ctstate",
"RELATED,ESTABLISHED",
],
&["-j", "ACCEPT"],
),
},
]
}
fn check_args(rule: &Rule) -> Vec<String> {
locked(
owned(&["-t", rule.table, "-C", rule.chain])
.into_iter()
.chain(rule.spec.iter().cloned()),
)
}
fn insert_args(rule: &Rule) -> Vec<String> {
locked(
owned(&["-t", rule.table, "-I", rule.chain, "1"])
.into_iter()
.chain(rule.spec.iter().cloned()),
)
}
fn list_args(table: &str, chain: &str) -> Vec<String> {
locked(owned(&["-t", table, "-S", chain]))
}
/// A rule found in a listing whose comment starts with our prefix.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Found {
/// The whole tag.
tag: String,
/// The words after `-A <chain>`, which is what `-D` needs.
rest: Vec<String>,
}
/// Every rule in an `iptables -S <chain>` listing that is ours: carrying a
/// tag that starts with `prefix`, or, on a kernel with no `comment` match,
/// being exactly one of the three rules this module writes for some range.
/// Anything else in the chain is left alone.
fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec<Found> {
// `tsunagi-exit:<interface>:`
let interface = prefix
.strip_prefix("tsunagi-exit:")
.and_then(|rest| rest.strip_suffix(':'))
.unwrap_or_default();
listing
.lines()
.filter_map(|line| {
let words = split_rule_line(line);
let [append, found_chain, rest @ ..] = words.as_slice() else {
return None;
};
if append != "-A" || found_chain != chain {
return None;
}
let tag = rest
.windows(2)
.find(|pair| pair[0] == "--comment" && pair[1].starts_with(prefix))
.map(|pair| pair[1].clone())
.or_else(|| uncommented_tag(interface, chain, rest))?;
Some(Found {
tag,
rest: rest.to_vec(),
})
})
.collect()
}
/// The tag a rule would have carried, when it is one of ours written without
/// a comment: its words are those of the rule for the range it names.
fn uncommented_tag(interface: &str, chain: &str, rest: &[String]) -> Option<String> {
if interface.is_empty() {
return None;
}
let range: Ipv4Range = rest
.windows(2)
.find(|pair| pair[0] == "-s" || pair[0] == "-d")
.and_then(|pair| pair[1].parse().ok())?;
offer_rules(interface, &range, false)
.iter()
.any(|rule| rule.chain == chain && same_rule(&rule.spec, rest))
.then(|| exit_tag(interface, &range))
}
fn delete_args(table: &str, chain: &str, found: &Found) -> Vec<String> {
locked(
owned(&["-t", table, "-D", chain])
.into_iter()
.chain(found.rest.iter().cloned()),
)
}
fn missing_iptables(range: &Ipv4Range, interface: &str) -> String {
format!(
"iptables not found; install it (the iptables-nft package is enough), or masquerade \
{range} out of every interface but `{interface}` and forward it yourself"
)
}
/// Reads one chain, or says why not.
fn read_chain(run: Run<'_>, table: &str, chain: &str) -> Result<String, Failure> {
match run(&list_args(table, chain)) {
Ran::Missing => Err(Failure::Missing),
Ran::Exited {
success: true,
stdout,
..
} => Ok(stdout),
Ran::Exited { stderr, .. } => Err(Failure::Failed(failure(&stderr))),
}
}
enum Failure {
Missing,
Failed(String),
}
/// Deletes found rules; the first failure ends it.
fn delete_all(
run: Run<'_>,
table: &str,
chain: &str,
found: &[Found],
keep: impl Fn(&Found) -> bool,
) -> Result<(), String> {
for rule in found.iter().filter(|rule| !keep(rule)) {
if let Ran::Exited {
success: false,
stderr,
..
} = run(&delete_args(table, chain, rule))
{
return Err(failure(&stderr));
}
}
Ok(())
}
/// Makes the firewall hold exactly the offering rules of these ranges for the
/// interface, and no others of ours.
fn apply_offer(
run: Run<'_>,
interface: &str,
ranges: &[Ipv4Range],
) -> Vec<(Ipv4Range, RuleOutcome)> {
let fail_all = |reason: String| -> Vec<(Ipv4Range, RuleOutcome)> {
ranges
.iter()
.map(|range| (*range, RuleOutcome::Failed(reason.clone())))
.collect()
};
let prefix = exit_tag_prefix(interface);
let wanted: Vec<String> = ranges.iter().map(|r| exit_tag(interface, r)).collect();
let comment = comment_supported(run);
// What is there now, per chain.
let mut present: Vec<(&str, &str, Vec<Found>)> = Vec::new();
for (table, chain) in CHAINS {
match read_chain(run, table, chain) {
Ok(listing) => present.push((table, chain, tagged_rules(&listing, chain, &prefix))),
Err(Failure::Missing) => {
return match ranges.first() {
Some(range) => fail_all(missing_iptables(range, interface)),
None => Vec::new(),
};
}
Err(Failure::Failed(reason)) => return fail_all(reason),
}
}
// Rules for a range no longer offered, or left by an earlier run.
for (table, chain, found) in &present {
if let Err(reason) = delete_all(run, table, chain, found, |rule| wanted.contains(&rule.tag))
{
return fail_all(reason);
}
}
ranges
.iter()
.map(|range| {
let rules = offer_rules(interface, range, comment);
let tag = exit_tag(interface, range);
let in_place = rules
.iter()
.all(|rule| matches!(run(&check_args(rule)), Ran::Exited { success: true, .. }))
&& present.iter().all(|(_, chain, found)| {
let have = found.iter().filter(|rule| rule.tag == tag).count();
have == rules.iter().filter(|rule| rule.chain == *chain).count()
});
if in_place {
return (*range, RuleOutcome::Applied);
}
// Start this range from a clean slate: a half-installed set, or
// one that differs from the plan, is replaced as a whole.
for (table, chain, found) in &present {
if let Err(reason) = delete_all(run, table, chain, found, |rule| rule.tag != tag) {
return (*range, RuleOutcome::Failed(reason));
}
}
for rule in &rules {
match run(&insert_args(rule)) {
Ran::Missing => {
return (
*range,
RuleOutcome::Failed(missing_iptables(range, interface)),
);
}
Ran::Exited { success: true, .. } => {}
Ran::Exited { stderr, .. } => {
return (*range, RuleOutcome::Failed(failure(&stderr)));
}
}
}
(*range, RuleOutcome::Applied)
})
.collect()
}
/// Removes every offering rule of ours for the interface. Best effort.
fn clear_offer(run: Run<'_>, interface: &str) {
let prefix = exit_tag_prefix(interface);
for (table, chain) in CHAINS {
let Ok(listing) = read_chain(run, table, chain) else {
continue;
};
let found = tagged_rules(&listing, chain, &prefix);
if let Err(reason) = delete_all(run, table, chain, &found, |_| false) {
tracing::warn!(
interface,
"cannot remove an exit node firewall rule: {reason}"
);
}
}
}
/// The kernel's forwarding switch for packets arriving on the interface,
/// read from the text of the sysctl files. Never written.
fn forwarding_state(read: impl Fn(&str) -> Option<String>, interface: &str) -> Option<bool> {
let flag = |text: String| match text.trim() {
"1" => Some(true),
"0" => Some(false),
_ => None,
};
// The name is derived here, but it ends up in a path.
if !interface.is_empty() && !interface.contains(['/', '\0']) && interface != ".." {
let per_interface = format!("/proc/sys/net/ipv4/conf/{interface}/forwarding");
if let Some(state) = read(&per_interface).and_then(flag) {
return Some(state);
}
}
read("/proc/sys/net/ipv4/ip_forward").and_then(flag)
}
fn read_sysctl(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
/// The effective user id from the text of `/proc/self/status`.
fn parse_euid(status: &str) -> Option<u32> {
status
.lines()
.find_map(|line| line.strip_prefix("Uid:"))
.and_then(|rest| rest.split_whitespace().nth(1))
.and_then(|effective| effective.parse().ok())
}
/// Why the using side must not be installed for this process, if it must not.
///
/// The bypass rule exempts this process's *user id* from the tunnel. That is
/// right for a service with an id of its own and wrong for a process started
/// from somebody's login session, which shares its id with every application
/// that person runs. A login session is recognised by `XDG_RUNTIME_DIR`,
/// which `systemd --user` and the login managers set and a system service
/// does not. Crude on purpose: refusing a service that was started oddly is
/// a message, and exempting a user's applications is a silent failure.
fn client_refusal(xdg_runtime_dir: Option<&str>) -> Option<String> {
xdg_runtime_dir.filter(|dir| !dir.is_empty()).map(|_| {
"the exit node needs the agent to run as a system service with a user of its own: \
this agent was started from a login session, so routing everything through the \
overlay would take that user's own applications out of the tunnel with it"
.to_string()
})
}
impl LinuxHostRules {
async fn blocking<T: Send + 'static>(
work: impl FnOnce() -> T + Send + 'static,
) -> Result<T, String> {
tokio::task::spawn_blocking(work)
.await
.map_err(|err| format!("the task failed: {err}"))
}
async fn apply_client(&self, interface: &str) -> RuleOutcome {
let xdg = std::env::var("XDG_RUNTIME_DIR").ok();
if let Some(reason) = client_refusal(xdg.as_deref()) {
// Whatever an earlier run left must not stay in force.
self.clear_client().await;
return RuleOutcome::Failed(reason);
}
let Some(euid) = std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|status| parse_euid(&status))
else {
return RuleOutcome::Failed("cannot tell which user this agent runs as".to_string());
};
let route = self.route.clone();
let interface = interface.to_string();
match Self::blocking(move || route.set_exit_client(&interface, euid)).await {
Ok(Ok(())) => RuleOutcome::Applied,
Ok(Err(err)) => RuleOutcome::Failed(err.to_string()),
Err(reason) => RuleOutcome::Failed(reason),
}
}
async fn clear_client(&self) {
let route = self.route.clone();
if let Ok(Err(err)) = Self::blocking(move || route.clear_exit_client()).await {
tracing::warn!(%err, "cannot remove the exit node routing rules");
}
}
}
impl ExitHostRules for LinuxHostRules {
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
Box::pin(async move {
let interface = plan.interface.clone();
let ranges = plan.offer.clone();
let offer = {
let (job_interface, job_ranges) = (interface.clone(), ranges.clone());
Self::blocking(move || apply_offer(&run_iptables, &job_interface, &job_ranges))
.await
.unwrap_or_else(|reason| {
ranges
.iter()
.map(|range| (*range, RuleOutcome::Failed(reason.clone())))
.collect()
})
};
let forwarding = if ranges.is_empty() {
None
} else {
forwarding_state(read_sysctl, &interface)
};
let client = if plan.client {
Some(self.apply_client(&interface).await)
} else {
self.clear_client().await;
None
};
ExitHostReport {
offer,
forwarding,
client,
}
})
}
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
let name = interface.to_string();
let _ = Self::blocking(move || clear_offer(&run_iptables, &name)).await;
self.clear_client().await;
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::cell::RefCell;
use super::*;
fn range() -> Ipv4Range {
"10.13.37.0/24".parse().unwrap()
}
fn words(args: &[String]) -> String {
args.join(" ")
}
#[test]
fn the_rules_masquerade_the_range_out_of_every_other_interface_and_forward_it() {
let rules = offer_rules("tsun0", &range(), true);
assert_eq!(rules.len(), 3);
assert_eq!(
words(&rules[0].spec),
"-s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j MASQUERADE"
);
assert_eq!((rules[0].table, rules[0].chain), ("nat", "POSTROUTING"));
assert_eq!(
words(&rules[1].spec),
"-i tsun0 -s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT"
);
assert_eq!((rules[1].table, rules[1].chain), ("filter", "FORWARD"));
assert_eq!(
words(&rules[2].spec),
"-o tsun0 -d 10.13.37.0/24 -m conntrack --ctstate RELATED,ESTABLISHED -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT"
);
// Nothing may widen to every source or to the other direction.
for rule in &rules {
assert!(!words(&rule.spec).contains("0.0.0.0/0"));
}
assert!(words(&insert_args(&rules[1])).starts_with("-w 5 -t filter -I FORWARD 1 -i tsun0"));
assert!(words(&check_args(&rules[0])).starts_with("-w 5 -t nat -C POSTROUTING -s"));
}
#[test]
fn only_rules_with_our_tag_for_this_interface_are_selected() {
let listing = "\
-P FORWARD DROP
-A FORWARD -i tsun0 -s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT
-A FORWARD -i tsun1 -m comment --comment tsunagi-exit:tsun1:10.99.0.0/24 -j ACCEPT
-A FORWARD -i tsun01 -m comment --comment tsunagi-exit:tsun01:10.5.0.0/24 -j ACCEPT
-A FORWARD -m comment --comment \"allow tsunagi-exit:tsun0:x\" -j ACCEPT
-A FORWARD -m comment --comment tsunagi:tsun0 -j ACCEPT
-A INPUT -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT
-A FORWARD -j ACCEPT
";
let found = tagged_rules(listing, "FORWARD", &exit_tag_prefix("tsun0"));
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].tag, "tsunagi-exit:tsun0:10.13.37.0/24");
assert_eq!(
words(&delete_args("filter", "FORWARD", &found[0])),
"-w 5 -t filter -D FORWARD -i tsun0 -s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT"
);
assert!(tagged_rules("", "FORWARD", "tsunagi-exit:tsun0:").is_empty());
}
/// A scripted `iptables` that remembers which rules it was given.
struct Fake {
calls: RefCell<Vec<String>>,
/// Rules present, as `-S` lines per `(table, chain)`.
listing: RefCell<Vec<(String, String)>>,
missing: bool,
check_ok: bool,
/// A kernel with no `comment` match, as small router builds have.
no_comment: bool,
}
impl Fake {
fn new(check_ok: bool) -> Self {
Self {
calls: RefCell::new(Vec::new()),
listing: RefCell::new(Vec::new()),
missing: false,
check_ok,
no_comment: false,
}
}
fn with_rule(self, table: &str, line: &str) -> Self {
self.listing
.borrow_mut()
.push((table.to_string(), line.to_string()));
self
}
fn run(&self, args: &[String]) -> Ran {
if self.missing {
return Ran::Missing;
}
let line = args.join(" ");
self.calls.borrow_mut().push(line.clone());
let table = args
.iter()
.position(|word| word == "-t")
.map(|at| args[at + 1].clone())
.unwrap_or_default();
let ok = |stdout: String| Ran::Exited {
success: true,
stdout,
stderr: String::new(),
};
if self.no_comment && line.contains("-m comment") {
return Ran::Exited {
success: false,
stdout: String::new(),
stderr: "iptables: No chain/target/match by that name.".to_string(),
};
}
if line.contains(" -S ") {
let listing: Vec<String> = self
.listing
.borrow()
.iter()
.filter(|(t, _)| *t == table)
.map(|(_, l)| l.clone())
.collect();
return ok(listing.join("\n"));
}
if line.contains(" -C ") {
return Ran::Exited {
success: self.check_ok,
stdout: String::new(),
stderr: String::new(),
};
}
ok(String::new())
}
fn verbs(&self, verb: &str) -> Vec<String> {
self.calls
.borrow()
.iter()
.filter(|call| call.contains(&format!(" {verb} ")))
.cloned()
.collect()
}
}
#[test]
fn a_kernel_without_the_comment_match_gets_the_rules_without_comments() {
let mut fake = Fake::new(false);
fake.no_comment = true;
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]);
let inserted = fake.verbs("-I");
assert_eq!(inserted.len(), 3, "{inserted:?}");
assert!(
inserted.iter().all(|call| !call.contains("comment")),
"{inserted:?}"
);
}
#[test]
fn rules_written_without_a_comment_are_ours_and_are_removed_with_them() {
// The way iptables prints them: its own option order.
let listing = "\
-P FORWARD ACCEPT
-A FORWARD -s 10.13.37.0/24 -i tsun0 ! -o tsun0 -j ACCEPT
-A FORWARD -d 10.13.37.0/24 -o tsun0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -i tsun0 -j ACCEPT
-A FORWARD -s 10.13.37.0/24 -i tsun1 ! -o tsun1 -j ACCEPT
";
let found = tagged_rules(listing, "FORWARD", &exit_tag_prefix("tsun0"));
assert_eq!(found.len(), 2, "{found:?}");
assert!(
found
.iter()
.all(|rule| rule.tag == exit_tag("tsun0", &range()))
);
let nat = "-A POSTROUTING -s 10.13.37.0/24 ! -o tsun0 -j MASQUERADE\n";
assert_eq!(
tagged_rules(nat, "POSTROUTING", &exit_tag_prefix("tsun0")).len(),
1
);
}
#[test]
fn a_fresh_host_gets_the_three_rules() {
let fake = Fake::new(false);
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]);
let inserted = fake.verbs("-I");
assert_eq!(inserted.len(), 3, "{inserted:?}");
assert!(inserted[0].contains("-t nat -I POSTROUTING 1"));
assert!(fake.verbs("-D").is_empty());
}
#[test]
fn rules_already_in_place_are_left_alone() {
let tag = exit_tag("tsun0", &range());
let fake = Fake::new(true)
.with_rule(
"nat",
&format!("-A POSTROUTING -s 10.13.37.0/24 ! -o tsun0 -m comment --comment {tag} -j MASQUERADE"),
)
.with_rule("filter", &format!("-A FORWARD -i tsun0 -m comment --comment {tag} -j ACCEPT"))
.with_rule("filter", &format!("-A FORWARD -o tsun0 -m comment --comment {tag} -j ACCEPT"));
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]);
assert!(fake.verbs("-I").is_empty());
assert!(fake.verbs("-D").is_empty());
}
#[test]
fn a_stale_range_is_removed_and_a_half_installed_set_is_replaced() {
let old: Ipv4Range = "10.99.0.0/24".parse().unwrap();
let old_tag = exit_tag("tsun0", &old);
let tag = exit_tag("tsun0", &range());
let fake = Fake::new(true)
.with_rule(
"nat",
&format!(
"-A POSTROUTING -s 10.99.0.0/24 -m comment --comment {old_tag} -j MASQUERADE"
),
)
// Only one of the three for the current range.
.with_rule(
"filter",
&format!("-A FORWARD -i tsun0 -m comment --comment {tag} -j ACCEPT"),
)
// Somebody else's rule, which must survive.
.with_rule("filter", "-A FORWARD -i eth0 -j ACCEPT");
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]);
let deleted = fake.verbs("-D");
assert!(
deleted.iter().any(|call| call.contains(&old_tag)),
"{deleted:?}"
);
assert!(
deleted.iter().any(|call| call.contains(&tag)),
"{deleted:?}"
);
assert!(
deleted.iter().all(|call| !call.contains("eth0")),
"{deleted:?}"
);
assert_eq!(fake.verbs("-I").len(), 3);
}
#[test]
fn clearing_removes_only_our_rules_for_the_interface() {
let tag = exit_tag("tsun0", &range());
let other = exit_tag("tsun1", &range());
let fake = Fake::new(true)
.with_rule(
"nat",
&format!("-A POSTROUTING -m comment --comment {tag} -j MASQUERADE"),
)
.with_rule(
"nat",
&format!("-A POSTROUTING -m comment --comment {other} -j MASQUERADE"),
)
.with_rule("filter", "-A FORWARD -j ACCEPT");
clear_offer(&|args| fake.run(args), "tsun0");
let deleted = fake.verbs("-D");
assert_eq!(deleted.len(), 1, "{deleted:?}");
assert!(deleted[0].contains(&tag));
}
#[test]
fn a_missing_iptables_is_a_failure_that_says_what_to_do() {
let mut fake = Fake::new(false);
fake.missing = true;
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
let RuleOutcome::Failed(reason) = &outcome[0].1 else {
panic!("{outcome:?}");
};
assert!(reason.contains("iptables not found"), "{reason}");
assert!(reason.contains("10.13.37.0/24"), "{reason}");
// Nothing wanted and nothing to run is not a failure of anything.
assert!(apply_offer(&|args| fake.run(args), "tsun0", &[]).is_empty());
}
#[test]
fn forwarding_is_read_per_interface_then_globally_and_never_written() {
let files = |per: Option<&'static str>, global: Option<&'static str>| {
move |path: &str| match path {
"/proc/sys/net/ipv4/conf/tsun0/forwarding" => per.map(str::to_string),
"/proc/sys/net/ipv4/ip_forward" => global.map(str::to_string),
_ => None,
}
};
assert_eq!(
forwarding_state(files(Some("1\n"), Some("0\n")), "tsun0"),
Some(true)
);
assert_eq!(
forwarding_state(files(Some("0\n"), Some("1\n")), "tsun0"),
Some(false)
);
assert_eq!(
forwarding_state(files(None, Some("1\n")), "tsun0"),
Some(true)
);
assert_eq!(forwarding_state(files(None, None), "tsun0"), None);
assert_eq!(forwarding_state(files(Some("junk"), None), "tsun0"), None);
// A name that would leave the directory is not looked up.
assert_eq!(forwarding_state(files(Some("1"), None), "../../x"), None);
}
#[test]
fn the_effective_uid_is_the_second_field() {
let status = "Name:\ttsunagi\nUid:\t1001\t1002\t1001\t1001\nGid:\t5\t5\t5\t5\n";
assert_eq!(parse_euid(status), Some(1002));
assert_eq!(parse_euid("Name:\tx\n"), None);
}
#[test]
fn a_login_session_is_refused_and_a_service_is_not() {
let reason = client_refusal(Some("/run/user/1000")).unwrap();
assert!(reason.contains("system service"), "{reason}");
assert!(client_refusal(None).is_none());
assert!(client_refusal(Some("")).is_none());
}
}
@@ -0,0 +1,233 @@
//! 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 std::sync::{Arc, Mutex};
use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules};
use super::macos_exit::{apply_plan, clear_all, run_tool};
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, and
/// `pf` plus `route` for exit nodes (see [`super::macos_exit`]).
#[derive(Debug, Default)]
pub struct MacosHostRules {
/// The reference `pfctl -E` handed out, held while exit-node rules need
/// `pf` on and released when they are removed.
pub(super) pf_token: Arc<Mutex<Option<String>>>,
}
impl MacosHostRules {
/// Creates the host rules.
pub fn new() -> Self {
Self::default()
}
/// 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 ExitHostRules for MacosHostRules {
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
Box::pin(async move {
let job_plan = plan.clone();
let token = Arc::clone(&self.pf_token);
tokio::task::spawn_blocking(move || apply_plan(&run_tool, &token, &job_plan))
.await
.unwrap_or_else(|err| {
let failed = RuleOutcome::Failed(format!("the task failed: {err}"));
ExitHostReport {
offer: plan
.offer
.iter()
.map(|range| (*range, failed.clone()))
.collect(),
forwarding: None,
client: plan.client.then_some(failed),
}
})
})
}
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
let name = interface.to_string();
let token = Arc::clone(&self.pf_token);
let _ = tokio::task::spawn_blocking(move || clear_all(&run_tool, &token, &name)).await;
})
}
}
impl BroadcastHostRules for MacosHostRules {
fn name(&self) -> &str {
"route"
}
fn exit_rules(&self) -> Option<&dyn ExitHostRules> {
Some(self)
}
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.
}
}
@@ -0,0 +1,915 @@
//! Exit-node host rules on macOS: a `pf` anchor and `route` entries.
//!
//! Compiled on every platform so the rule text and the parsers are tested
//! everywhere; only `MacosHostRules` runs them, and only on macOS.
//!
//! # Offering
//!
//! Per offered range, in the anchor `com.apple/tsunagi-exit-<if>`:
//!
//! ```text
//! nat on <egress> inet from <range> to any -> (<egress>)
//! pass in quick on <if> inet from <range> to ! <range>
//! ```
//!
//! The anchor lives under `com.apple/` on purpose: the stock `/etc/pf.conf`
//! evaluates `nat-anchor "com.apple/*"` and `anchor "com.apple/*"`, so the
//! rules take effect without editing the user's ruleset. `pf` cannot say
//! "every interface but one" in a translation address, so the egress is the
//! interface of the default route **when the rules are applied**; after the
//! default route moves (Wi-Fi to Ethernet) the rules are re-applied by
//! switching the exit node off and on, or restarting the agent.
//!
//! The kernel's `net.inet.ip.forwarding` is only read, never written: it
//! changes how the whole host behaves, which is the owner's decision.
//!
//! # Using
//!
//! macOS has no `uidrange` policy routing, and the agent is root, so the
//! Linux scheme does not carry over. Instead:
//!
//! ```text
//! route add -net 0.0.0.0/1 -interface <if>
//! route add -net 128.0.0.0/1 -interface <if>
//! pass out quick on <if> route-to (<egress> <gateway>) inet proto { tcp udp }
//! from any to ! <overlay ranges> user <agent uid>
//! nat on <egress> inet from (<if>) to any -> (<egress>)
//! ```
//!
//! The two halves outrank the default route without replacing it, and every
//! more specific route (LAN, another VPN) keeps working. The `route-to` rule
//! sends the *agent's own* traffic — QUIC, relay, DHT, which would otherwise
//! loop into the tunnel it carries — back out the physical interface, and the
//! translation gives it that interface's address, since an unbound socket
//! picked the overlay address when the route pointed at the overlay. Like the
//! Linux exemption this is by user id, so anything else running as root also
//! bypasses the tunnel.
//!
//! IPv6 is blocked while an exit node is in use, as on Linux: `::/1` and
//! `8000::/1` are rejecting routes, so applications fall back to IPv4 at once
//! instead of leaking.
//!
//! Every object is derived from local state and the interface the agent owns;
//! none of it comes from a peer.
use std::net::Ipv4Addr;
use std::sync::Mutex;
use crate::state::Ipv4Range;
use super::RuleOutcome;
use super::exit::{ExitHostPlan, ExitHostReport};
/// The programs this module runs, always by absolute path.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Tool {
Route,
Pfctl,
Sysctl,
Id,
}
impl Tool {
fn path(self) -> &'static str {
match self {
Self::Route => "/sbin/route",
Self::Pfctl => "/sbin/pfctl",
Self::Sysctl => "/usr/sbin/sysctl",
Self::Id => "/usr/bin/id",
}
}
}
/// What running a tool came to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Ran {
/// The program is not there.
Missing,
/// It ran.
Exited {
success: bool,
stdout: String,
stderr: String,
},
}
/// Runs a tool with optional standard input; injectable so the logic is
/// tested without touching the host.
pub(super) type Run<'a> = &'a dyn Fn(Tool, &[String], Option<&str>) -> Ran;
/// Runs a real tool. Blocking: callers use `spawn_blocking`.
pub(super) fn run_tool(tool: Tool, args: &[String], stdin: Option<&str>) -> Ran {
use std::io::Write;
use std::process::{Command, Stdio};
let mut command = Command::new(tool.path());
command
.args(args)
.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ran::Missing,
Err(err) => {
return Ran::Exited {
success: false,
stdout: String::new(),
stderr: format!("could not run {}: {err}", tool.path()),
};
}
};
if let (Some(text), Some(mut pipe)) = (stdin, child.stdin.take()) {
// A write error shows up as the tool's own failure below.
let _ = pipe.write_all(text.as_bytes());
}
match child.wait_with_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) => Ran::Exited {
success: false,
stdout: String::new(),
stderr: format!("could not run {}: {err}", tool.path()),
},
}
}
fn owned(words: &[&str]) -> Vec<String> {
words.iter().map(|word| word.to_string()).collect()
}
/// A name that is safe in a `pf` rule, an anchor path and a table name.
fn safe_name(name: &str) -> bool {
!name.is_empty() && name.len() <= 32 && name.chars().all(|c| c.is_ascii_alphanumeric())
}
/// The anchor holding every rule of one interface.
fn anchor_name(interface: &str) -> Result<String, String> {
if safe_name(interface) {
Ok(format!("com.apple/tsunagi-exit-{interface}"))
} else {
Err(format!(
"`{interface}` is not an interface name this agent can write a pf rule for"
))
}
}
/// The default route as `route -n get default` reports it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct DefaultRoute {
pub interface: String,
pub gateway: Option<Ipv4Addr>,
}
/// Reads `interface:` and `gateway:` out of `route -n get default`.
fn parse_default_route(text: &str) -> Option<DefaultRoute> {
let field = |name: &str| {
text.lines().find_map(|line| {
let (key, value) = line.split_once(':')?;
(key.trim() == name).then(|| value.trim().to_string())
})
};
let interface = field("interface").filter(|name| safe_name(name))?;
let gateway = field("gateway").and_then(|value| value.parse().ok());
Some(DefaultRoute { interface, gateway })
}
fn failure(stderr: &str, stdout: &str) -> String {
let text = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
if text.is_empty() {
"the command failed without saying why".to_string()
} else {
text.to_string()
}
}
/// Runs a tool and turns a failure into a sentence a person can act on.
fn run_ok(
run: Run<'_>,
tool: Tool,
args: &[String],
stdin: Option<&str>,
) -> Result<String, String> {
match run(tool, args, stdin) {
Ran::Missing => Err(format!("{} not found", tool.path())),
Ran::Exited {
success: true,
stdout,
..
} => Ok(stdout),
Ran::Exited { stderr, stdout, .. } => Err(format!(
"{} {} failed: {}",
tool.path(),
args.join(" "),
failure(&stderr, &stdout)
)),
}
}
/// The route traffic leaves by now, which is the egress for translation and
/// for the agent's own sockets. Read *before* the overlay routes are added.
fn default_route(run: Run<'_>, overlay: &str) -> Result<DefaultRoute, String> {
let out = run_ok(run, Tool::Route, &owned(&["-n", "get", "default"]), None)
.map_err(|why| format!("cannot find the default route: {why}"))?;
let route = parse_default_route(&out)
.ok_or_else(|| "the host has no default route to send traffic out of".to_string())?;
if route.interface == overlay {
return Err(format!(
"the default route already goes through `{overlay}`"
));
}
Ok(route)
}
/// Whether the kernel forwards IPv4. Read only.
fn forwarding_state(run: Run<'_>) -> Option<bool> {
let out = run_ok(
run,
Tool::Sysctl,
&owned(&["-n", "net.inet.ip.forwarding"]),
None,
)
.ok()?;
match out.trim() {
"1" => Some(true),
"0" => Some(false),
_ => None,
}
}
/// The effective user id of this process, from `id -u`.
fn agent_uid(run: Run<'_>) -> Result<u32, String> {
run_ok(run, Tool::Id, &owned(&["-u"]), None)
.ok()
.and_then(|out| out.trim().parse().ok())
.ok_or_else(|| "cannot tell which user this agent runs as".to_string())
}
/// What the using side adds to the rules.
#[derive(Debug, Clone, PartialEq, Eq)]
struct ClientRules<'a> {
uid: u32,
/// Every overlay range: the agent's own traffic *to* those still goes
/// through the interface.
overlay: &'a [Ipv4Range],
}
/// The text of the anchor: translation first, filtering after, which is the
/// order `pf` insists on.
fn pf_rules(
interface: &str,
egress: &DefaultRoute,
offer: &[Ipv4Range],
client: Option<&ClientRules<'_>>,
) -> String {
let out = &egress.interface;
let mut nat = Vec::new();
let mut filter = Vec::new();
for range in offer {
nat.push(format!("nat on {out} inet from {range} to any -> ({out})"));
filter.push(format!(
"pass in quick on {interface} inet from {range} to ! {range}"
));
}
let mut tables = Vec::new();
if let Some(client) = client {
nat.push(format!(
"nat on {out} inet from ({interface}) to any -> ({out})"
));
let target = if client.overlay.is_empty() {
"any".to_string()
} else {
let ranges: Vec<String> = client.overlay.iter().map(|r| r.to_string()).collect();
// A table, because `! { a, b }` expands to one rule per element
// in older `pf`, which matches everything.
tables.push(format!(
"table <tsunagi_overlay_{interface}> const {{ {} }}",
ranges.join(", ")
));
format!("! <tsunagi_overlay_{interface}>")
};
let via = match egress.gateway {
Some(gateway) => format!("({out} {gateway})"),
None => out.clone(),
};
filter.push(format!(
"pass out quick on {interface} route-to {via} inet proto {{ tcp udp }} \
from any to {target} user {}",
client.uid
));
}
tables
.into_iter()
.chain(nat)
.chain(filter)
.collect::<Vec<_>>()
.join("\n")
+ "\n"
}
/// Turns `pf` on if nobody has, holding the reference `pfctl` hands out so it
/// can be released when the agent is done. One reference at most.
fn acquire_pf(run: Run<'_>, slot: &Mutex<Option<String>>) -> Result<(), String> {
if lock(slot).is_some() {
return Ok(());
}
match run(Tool::Pfctl, &owned(&["-E"]), None) {
Ran::Missing => Err(format!("{} not found", Tool::Pfctl.path())),
Ran::Exited {
success,
stdout,
stderr,
} => {
// `pfctl -E` reports `Token : <n>` on stderr, and also says so
// when pf is already enabled.
let token = stdout.lines().chain(stderr.lines()).find_map(parse_token);
if !success && token.is_none() {
return Err(format!("cannot enable pf: {}", failure(&stderr, &stdout)));
}
*lock(slot) = token;
Ok(())
}
}
}
fn parse_token(line: &str) -> Option<String> {
let (key, value) = line.split_once(':')?;
let value = value.trim();
(key.trim().eq_ignore_ascii_case("token")
&& !value.is_empty()
&& value.chars().all(|c| c.is_ascii_digit()))
.then(|| value.to_string())
}
fn release_pf(run: Run<'_>, slot: &Mutex<Option<String>>) {
if let Some(token) = lock(slot).take()
&& let Err(why) = run_ok(run, Tool::Pfctl, &["-X".to_string(), token], None)
{
tracing::debug!(%why, "cannot release the pf reference");
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn flush_args(anchor: &str) -> Vec<String> {
owned(&["-q", "-a", anchor, "-F", "all"])
}
fn load_args(anchor: &str) -> Vec<String> {
owned(&["-q", "-a", anchor, "-f", "-"])
}
/// The two halves that out-rank the default route.
const HALVES: [&str; 2] = ["0.0.0.0/1", "128.0.0.0/1"];
/// The IPv6 halves, made unreachable.
const HALVES_V6: [&str; 2] = ["::/1", "8000::/1"];
fn route_add_args(half: &str, interface: &str) -> Vec<String> {
owned(&["-q", "-n", "add", "-net", half, "-interface", interface])
}
fn route_del_args(half: &str, interface: &str) -> Vec<String> {
owned(&["-q", "-n", "delete", "-net", half, "-interface", interface])
}
fn route_add_v6_args(half: &str) -> Vec<String> {
owned(&["-q", "-n", "add", "-inet6", "-net", half, "::1", "-reject"])
}
fn route_del_v6_args(half: &str) -> Vec<String> {
owned(&["-q", "-n", "delete", "-inet6", "-net", half, "::1"])
}
/// Takes away the overlay default routes. Best effort: what is not there is
/// the state wanted.
fn clear_routes(run: Run<'_>, interface: &str) {
for half in HALVES {
let _ = run(Tool::Route, &route_del_args(half, interface), None);
}
for half in HALVES_V6 {
let _ = run(Tool::Route, &route_del_v6_args(half), None);
}
}
fn set_routes(run: Run<'_>, interface: &str) -> Result<(), String> {
clear_routes(run, interface);
for half in HALVES {
if let Err(why) = run_ok(run, Tool::Route, &route_add_args(half, interface), None) {
clear_routes(run, interface);
return Err(why);
}
}
for half in HALVES_V6 {
// A host without IPv6 has nothing to block; not a failure.
if let Err(why) = run_ok(run, Tool::Route, &route_add_v6_args(half), None) {
tracing::debug!(%why, "cannot block IPv6 for the exit node");
}
}
Ok(())
}
/// Makes the host match the plan.
pub(super) fn apply_plan(
run: Run<'_>,
token: &Mutex<Option<String>>,
plan: &ExitHostPlan,
) -> ExitHostReport {
let forwarding = if plan.offer.is_empty() {
None
} else {
forwarding_state(run)
};
let outcome = apply_rules(run, token, plan);
let verdict = |ok: bool, why: &str| {
if ok {
RuleOutcome::Applied
} else {
RuleOutcome::Failed(why.to_string())
}
};
match outcome {
Ok(()) => ExitHostReport {
offer: plan
.offer
.iter()
.map(|range| (*range, RuleOutcome::Applied))
.collect(),
forwarding,
client: plan.client.then_some(RuleOutcome::Applied),
},
Err(why) => ExitHostReport {
offer: plan
.offer
.iter()
.map(|range| (*range, verdict(false, &why)))
.collect(),
forwarding,
client: plan.client.then(|| verdict(false, &why)),
},
}
}
fn apply_rules(
run: Run<'_>,
token: &Mutex<Option<String>>,
plan: &ExitHostPlan,
) -> Result<(), String> {
let interface = plan.interface.as_str();
let anchor = anchor_name(interface)?;
let egress = default_route(run, interface)?;
let uid = if plan.client {
Some(agent_uid(run)?)
} else {
None
};
let client = uid.map(|uid| ClientRules {
uid,
overlay: &plan.overlay,
});
let rules = pf_rules(interface, &egress, &plan.offer, client.as_ref());
acquire_pf(run, token)?;
run_ok(run, Tool::Pfctl, &load_args(&anchor), Some(&rules))
.map_err(|why| format!("cannot load the pf rules: {why}"))?;
// The routes come after the rules that keep the agent's own traffic off
// them: the other order would loop for as long as it takes.
if plan.client {
set_routes(run, interface)?;
} else {
clear_routes(run, interface);
}
Ok(())
}
/// Removes everything this agent put there for the interface.
pub(super) fn clear_all(run: Run<'_>, token: &Mutex<Option<String>>, interface: &str) {
clear_routes(run, interface);
if let Ok(anchor) = anchor_name(interface) {
// Flushing an anchor nothing was ever loaded into is not an error
// worth reporting.
if let Err(why) = run_ok(run, Tool::Pfctl, &flush_args(&anchor), None) {
tracing::debug!(%why, "cannot flush the exit node pf anchor");
}
}
release_pf(run, token);
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::cell::RefCell;
use super::*;
fn range() -> Ipv4Range {
"10.13.37.0/24".parse().unwrap()
}
const DEFAULT_ROUTE: &str = " route to: default\n\
destination: default\n\
mask: default\n\
gateway: 192.168.1.1\n\
interface: en0\n\
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>\n";
fn egress() -> DefaultRoute {
DefaultRoute {
interface: "en0".into(),
gateway: Some(Ipv4Addr::new(192, 168, 1, 1)),
}
}
/// A scripted host that records every call.
struct Fake {
calls: RefCell<Vec<String>>,
stdins: RefCell<Vec<String>>,
default_route: Option<String>,
forwarding: &'static str,
pf_missing: bool,
pf_load_fails: bool,
route_add_fails: bool,
pf_enable_output: &'static str,
}
impl Fake {
fn new() -> Self {
Self {
calls: RefCell::new(Vec::new()),
stdins: RefCell::new(Vec::new()),
default_route: Some(DEFAULT_ROUTE.to_string()),
forwarding: "1\n",
pf_missing: false,
pf_load_fails: false,
route_add_fails: false,
pf_enable_output: "pf enabled\nToken : 4242\n",
}
}
fn run(&self, tool: Tool, args: &[String], stdin: Option<&str>) -> Ran {
let line = format!("{:?} {}", tool, args.join(" "));
self.calls.borrow_mut().push(line);
if let Some(text) = stdin {
self.stdins.borrow_mut().push(text.to_string());
}
let ok = |stdout: &str, stderr: &str| Ran::Exited {
success: true,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
};
let fail = |why: &str| Ran::Exited {
success: false,
stdout: String::new(),
stderr: why.to_string(),
};
match tool {
Tool::Id => ok("0\n", ""),
Tool::Sysctl => ok(self.forwarding, ""),
Tool::Route if args.first().map(String::as_str) == Some("-n") => {
match &self.default_route {
Some(text) => ok(text, ""),
None => fail("route: writing to routing socket: not in table"),
}
}
Tool::Route if args.contains(&"add".to_string()) && self.route_add_fails => {
fail("route: writing to routing socket: File exists")
}
Tool::Route => ok("", ""),
Tool::Pfctl if self.pf_missing => Ran::Missing,
Tool::Pfctl if args == ["-E"] => ok("", self.pf_enable_output),
Tool::Pfctl if args.contains(&"-f".to_string()) && self.pf_load_fails => {
fail("pfctl: Syntax error")
}
Tool::Pfctl => ok("", ""),
}
}
fn calls(&self) -> Vec<String> {
self.calls.borrow().clone()
}
fn position(&self, needle: &str) -> Option<usize> {
self.calls.borrow().iter().position(|c| c.contains(needle))
}
fn count(&self, needle: &str) -> usize {
self.calls
.borrow()
.iter()
.filter(|c| c.contains(needle))
.count()
}
}
fn plan(offer: bool, client: bool) -> ExitHostPlan {
ExitHostPlan {
interface: "utun5".into(),
offer: if offer { vec![range()] } else { Vec::new() },
client,
overlay: vec![range()],
..Default::default()
}
}
#[test]
fn the_default_route_is_read_from_route_output() {
assert_eq!(parse_default_route(DEFAULT_ROUTE), Some(egress()));
// A point-to-point default has no gateway line to use.
let ppp = "destination: default\n interface: ppp0\n";
assert_eq!(
parse_default_route(ppp),
Some(DefaultRoute {
interface: "ppp0".into(),
gateway: None
})
);
assert_eq!(parse_default_route("route: not in table"), None);
// An interface name that is not plain letters and digits never ends
// up in a rule.
assert_eq!(parse_default_route("interface: en0; pass all\n"), None);
}
#[test]
fn the_anchor_is_under_com_apple_and_only_for_plain_names() {
assert_eq!(
anchor_name("utun5").unwrap(),
"com.apple/tsunagi-exit-utun5"
);
assert!(anchor_name("").is_err());
assert!(anchor_name("utun5 pass all").is_err());
assert!(anchor_name("../x").is_err());
}
#[test]
fn offering_translates_the_range_and_admits_it_in() {
let text = pf_rules("utun5", &egress(), &[range()], None);
assert_eq!(
text,
"nat on en0 inet from 10.13.37.0/24 to any -> (en0)\n\
pass in quick on utun5 inet from 10.13.37.0/24 to ! 10.13.37.0/24\n"
);
// Translation precedes filtering, as pf requires.
assert!(text.find("nat on").unwrap() < text.find("pass in").unwrap());
}
#[test]
fn using_sends_the_agents_own_traffic_back_out_the_physical_interface() {
let other: Ipv4Range = "10.99.0.0/16".parse().unwrap();
let overlay = [range(), other];
let client = ClientRules {
uid: 0,
overlay: &overlay,
};
let text = pf_rules("utun5", &egress(), &[], Some(&client));
assert!(
text.starts_with(
"table <tsunagi_overlay_utun5> const { 10.13.37.0/24, 10.99.0.0/16 }\n"
),
"{text}"
);
assert!(
text.contains("nat on en0 inet from (utun5) to any -> (en0)\n"),
"{text}"
);
assert!(
text.contains(
"pass out quick on utun5 route-to (en0 192.168.1.1) inet proto { tcp udp } \
from any to ! <tsunagi_overlay_utun5> user 0\n"
),
"{text}"
);
// Only the agent's user, and only what is not the overlay.
assert!(!text.contains("from any to any"));
let direct = DefaultRoute {
interface: "ppp0".into(),
gateway: None,
};
let text = pf_rules(
"utun5",
&direct,
&[],
Some(&ClientRules {
uid: 501,
overlay: &[],
}),
);
assert!(text.contains("route-to ppp0 inet"), "{text}");
assert!(text.contains("to any user 501"), "{text}");
assert!(!text.contains("table"), "{text}");
}
#[test]
fn the_overlay_default_routes_are_halves_and_ipv6_is_rejected() {
assert_eq!(
route_add_args("0.0.0.0/1", "utun5").join(" "),
"-q -n add -net 0.0.0.0/1 -interface utun5"
);
assert_eq!(
route_del_args("128.0.0.0/1", "utun5").join(" "),
"-q -n delete -net 128.0.0.0/1 -interface utun5"
);
assert_eq!(
route_add_v6_args("8000::/1").join(" "),
"-q -n add -inet6 -net 8000::/1 ::1 -reject"
);
assert_eq!(
route_del_v6_args("::/1").join(" "),
"-q -n delete -inet6 -net ::/1 ::1"
);
}
#[test]
fn the_pf_token_is_found_in_either_stream() {
assert_eq!(parse_token("Token : 1234"), Some("1234".into()));
assert_eq!(parse_token("token: 9"), Some("9".into()));
assert_eq!(parse_token("pf enabled"), None);
assert_eq!(parse_token("Token : x; rm"), None);
}
#[test]
fn offering_loads_the_anchor_and_reads_but_never_writes_forwarding() {
let fake = Fake::new();
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
assert_eq!(report.offer, vec![(range(), RuleOutcome::Applied)]);
assert_eq!(report.forwarding, Some(true));
assert_eq!(report.client, None);
let calls = fake.calls();
assert!(
calls
.iter()
.any(|c| c == "Pfctl -q -a com.apple/tsunagi-exit-utun5 -f -"),
"{calls:?}"
);
assert_eq!(
fake.stdins.borrow()[0],
"nat on en0 inet from 10.13.37.0/24 to any -> (en0)\n\
pass in quick on utun5 inet from 10.13.37.0/24 to ! 10.13.37.0/24\n"
);
// sysctl is only ever asked, never told.
assert!(
calls
.iter()
.filter(|c| c.starts_with("Sysctl"))
.all(|c| c == "Sysctl -n net.inet.ip.forwarding"),
"{calls:?}"
);
// No client routes were added for an offer alone.
assert_eq!(fake.count(" add "), 0);
assert_eq!(token.lock().unwrap().as_deref(), Some("4242"));
}
#[test]
fn forwarding_off_is_reported_and_unreadable_is_none() {
let mut fake = Fake::new();
fake.forwarding = "0\n";
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
assert_eq!(report.forwarding, Some(false));
fake.forwarding = "junk";
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
assert_eq!(report.forwarding, None);
}
#[test]
fn the_client_rules_are_loaded_before_the_routes_that_need_them() {
let fake = Fake::new();
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(false, true));
assert_eq!(report.client, Some(RuleOutcome::Applied));
assert!(report.offer.is_empty());
let load = fake.position("-f -").unwrap();
let add = fake.position(" add -net 0.0.0.0/1").unwrap();
assert!(load < add, "{:?}", fake.calls());
assert!(fake.position("add -net 128.0.0.0/1").is_some());
assert!(fake.position("-inet6 -net ::/1 ::1 -reject").is_some());
assert!(fake.stdins.borrow()[0].contains("user 0"));
// The default route was read before any overlay route existed.
assert!(fake.position("-n get default").unwrap() < add);
}
#[test]
fn no_routes_are_added_when_the_rules_did_not_load() {
let mut fake = Fake::new();
fake.pf_load_fails = true;
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, true));
let Some(RuleOutcome::Failed(why)) = report.client else {
panic!("{report:?}");
};
assert!(why.contains("cannot load the pf rules"), "{why}");
assert!(why.contains("Syntax error"), "{why}");
assert!(matches!(report.offer[0].1, RuleOutcome::Failed(_)));
// Nothing sent the host's traffic into a tunnel it could not exempt
// the agent from.
assert_eq!(fake.count(" add "), 0);
}
#[test]
fn a_failing_route_is_a_failure_and_leaves_nothing_behind() {
let mut fake = Fake::new();
fake.route_add_fails = true;
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(false, true));
let Some(RuleOutcome::Failed(why)) = report.client else {
panic!("{report:?}");
};
assert!(why.contains("File exists"), "{why}");
// The cleanup after the failed add removed whatever did land.
let last_add = fake
.calls()
.iter()
.rposition(|c| c.contains(" add "))
.unwrap();
assert!(
fake.calls()[last_add..]
.iter()
.any(|c| c.contains("delete"))
);
}
#[test]
fn a_missing_pfctl_or_default_route_says_what_is_wrong() {
let mut fake = Fake::new();
fake.pf_missing = true;
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
panic!("{report:?}");
};
assert!(why.contains("/sbin/pfctl not found"), "{why}");
let mut fake = Fake::new();
fake.default_route = None;
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
panic!("{report:?}");
};
assert!(why.contains("default route"), "{why}");
assert_eq!(fake.count("-f -"), 0);
}
#[test]
fn a_default_route_through_the_overlay_is_refused() {
let mut fake = Fake::new();
fake.default_route = Some("gateway: 10.13.37.1\ninterface: utun5\n".into());
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
panic!("{report:?}");
};
assert!(why.contains("already goes through"), "{why}");
}
#[test]
fn pf_is_enabled_once_and_released_once_on_clear() {
let fake = Fake::new();
let token = Mutex::new(None);
let run = |t, a: &[String], s: Option<&str>| fake.run(t, a, s);
apply_plan(&run, &token, &plan(true, false));
apply_plan(&run, &token, &plan(true, true));
assert_eq!(fake.count("Pfctl -E"), 1, "{:?}", fake.calls());
clear_all(&run, &token, "utun5");
assert_eq!(fake.count("Pfctl -X 4242"), 1);
assert!(
fake.position("-a com.apple/tsunagi-exit-utun5 -F all")
.is_some()
);
assert!(
fake.position("delete -net 0.0.0.0/1 -interface utun5")
.is_some()
);
assert_eq!(*token.lock().unwrap(), None);
// A second clear has nothing to release and still succeeds.
clear_all(&run, &token, "utun5");
assert_eq!(fake.count("Pfctl -X"), 1);
}
#[test]
fn dropping_the_client_removes_its_routes() {
let fake = Fake::new();
let token = Mutex::new(None);
let run = |t, a: &[String], s: Option<&str>| fake.run(t, a, s);
apply_plan(&run, &token, &plan(true, false));
assert!(fake.position("delete -net 0.0.0.0/1").is_some());
assert_eq!(fake.count(" add "), 0);
}
}
@@ -0,0 +1,365 @@
//! 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;
mod exit;
pub use exit::{
ExitHostPlan, ExitHostReport, ExitHostRules, MockExitRules, exit_tag, exit_tag_prefix,
offer_detail,
};
#[cfg(all(feature = "tun-device", target_os = "linux"))]
mod linux;
#[cfg(all(feature = "tun-device", target_os = "linux"))]
mod linux_exit;
#[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_attr(not(target_os = "windows"), allow(dead_code))]
mod windows_exit;
#[cfg(all(feature = "tun-device", target_os = "windows"))]
pub use windows::WindowsHostRules;
// Compiled everywhere so its rule text and parsers are tested everywhere.
#[cfg(all(feature = "tun-device", target_os = "macos"))]
mod macos;
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
mod macos_exit;
#[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 exit-node rules that go with these, when the platform has them.
///
/// One object per host, because both halves need the same privileged
/// helpers; the default is that this platform has none, which is said
/// where it matters rather than pretended.
fn exit_rules(&self) -> Option<&dyn ExitHostRules> {
None
}
}
/// 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 {
exit: MockExitRules,
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 {
exit: MockExitRules::new(),
..Self::default()
}
}
/// The exit-node half of this host, for inspecting and steering tests.
pub fn exit(&self) -> &MockExitRules {
&self.exit
}
/// 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 exit_rules(&self) -> Option<&dyn ExitHostRules> {
Some(&self.exit)
}
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,341 @@
//! 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::exit::{ExitHostPlan, ExitHostReport, ExitHostRules};
use super::windows_exit;
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 tsng 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> {
run_powershell_output(script).map(|_| ())
}
/// Runs a script and returns what it printed, or what it said on failure.
fn run_powershell_output(script: &str) -> Result<String, 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(String::from_utf8_lossy(&output.stdout).into_owned());
}
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
}
}
/// The IPv4 addresses a host name resolves to; empty when it does not.
fn resolve_host(host: &str) -> Vec<std::net::Ipv4Addr> {
use std::net::{IpAddr, ToSocketAddrs};
(host, 443)
.to_socket_addrs()
.map(|addrs| {
addrs
.filter_map(|addr| match addr.ip() {
IpAddr::V4(ip) => Some(ip),
IpAddr::V6(_) => None,
})
.collect()
})
.unwrap_or_default()
}
impl ExitHostRules for WindowsHostRules {
fn needs_bypass(&self) -> bool {
true
}
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
Box::pin(async move {
let job = plan.clone();
tokio::task::spawn_blocking(move || {
windows_exit::apply_plan(
&|script| run_powershell_output(script),
&resolve_host,
&job,
)
})
.await
.unwrap_or_else(|err| {
let failed = RuleOutcome::Failed(format!("the task failed: {err}"));
ExitHostReport {
offer: plan
.offer
.iter()
.map(|range| (*range, failed.clone()))
.collect(),
forwarding: None,
client: plan.client.then_some(failed),
}
})
})
}
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
let name = interface.to_string();
let _ = tokio::task::spawn_blocking(move || {
windows_exit::clear_all(&|script| run_powershell_output(script), &name)
})
.await;
})
}
}
impl BroadcastHostRules for WindowsHostRules {
fn name(&self) -> &str {
"windows-netsecurity"
}
fn exit_rules(&self) -> Option<&dyn ExitHostRules> {
Some(self)
}
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:?}");
}
}
@@ -0,0 +1,603 @@
//! Exit-node host rules on Windows: WinNAT through PowerShell.
//!
//! Compiled on every platform so the scripts and the plan logic are tested
//! everywhere; only `WindowsHostRules` runs them, and only on Windows. It
//! could not be run against a real Windows host while it was written.
//!
//! # Offering
//!
//! One NAT object per offered range, named with the usual tag
//! `tsunagi-exit:<if>:<range>`:
//!
//! ```text
//! New-NetNat -Name <tag> -InternalIPInterfaceAddressPrefix <range>
//! ```
//!
//! WinNAT translates that range out of whichever interface the traffic leaves
//! by, which is the Windows counterpart of the Linux masquerade. Windows
//! forwards per interface, and the egress is not known here, so forwarding is
//! only *read* for the overlay interface; turning it on for the interfaces
//! involved is the owner's decision (`Set-NetIPInterface -Forwarding Enabled`).
//! Some Windows versions allow a single NAT per host: a second offered range
//! then reports its own failure.
//!
//! # Using
//!
//! Windows has no per-user or per-process routing, so the Linux scheme (an
//! exemption for the agent's user) does not carry over. Instead the agent's
//! own underlay traffic is exempted by *destination*:
//!
//! ```text
//! New-NetRoute 0.0.0.0/1 on the overlay interface, metric 1
//! New-NetRoute 128.0.0.0/1 on the overlay interface, metric 1
//! New-NetRoute <ip>/32 via the physical default gateway, metric 7373
//! ```
//!
//! The halves out-rank the default route without replacing it, so every more
//! specific route (LAN, another VPN) keeps working. A `/32` per address the
//! agent talks to directly (the verified direct paths of its connections, and
//! the relays it uses, resolved when the rules are applied) keeps that traffic
//! on the physical network instead of looping into the tunnel it carries. The
//! metric 7373 marks those routes as ours, which is how stale ones are found
//! and removed. Everything is written to the active store, so a reboot clears
//! it. IPv6 is blackholed with `::/1` and `8000::/1` on the overlay interface,
//! which has no IPv6: applications fall back to IPv4 after a delay, where
//! Linux and macOS answer at once. The bypass follows the agent's paths as
//! they change; a path that appears after the last apply is reached through
//! the tunnel until the next one.
//!
//! The bypass routes are written before the halves, and removed after them.
use super::RuleOutcome;
use std::net::Ipv4Addr;
use super::exit::{ExitHostPlan, ExitHostReport, exit_tag, exit_tag_prefix};
use crate::state::Ipv4Range;
/// The route metric that marks a bypass route as this agent's.
const BYPASS_METRIC: u32 = 7373;
/// Runs a PowerShell script and returns what it printed.
pub(super) type RunScript<'a> = &'a dyn Fn(&str) -> Result<String, String>;
/// Resolves a host name to its IPv4 addresses.
pub(super) type Resolve<'a> = &'a dyn Fn(&str) -> Vec<Ipv4Addr>;
/// A PowerShell single-quoted literal, with any embedded quote doubled.
fn ps_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
/// Creates the NAT of one range, replacing one this agent made before.
fn nat_script(interface: &str, range: &Ipv4Range) -> String {
let name = ps_literal(&exit_tag(interface, range));
let prefix = ps_literal(&range.to_string());
format!(
"$ErrorActionPreference = 'Stop'\n\
$name = {name}\n\
Get-NetNat -Name $name -ErrorAction SilentlyContinue | \
Remove-NetNat -Confirm:$false -ErrorAction SilentlyContinue\n\
New-NetNat -Name $name -InternalIPInterfaceAddressPrefix {prefix} | Out-Null\n"
)
}
/// Removes this interface's NAT objects except those named in `keep`.
fn prune_script(interface: &str, keep: &[Ipv4Range]) -> String {
let prefix = ps_literal(&exit_tag_prefix(interface));
let keep: Vec<String> = keep
.iter()
.map(|range| ps_literal(&exit_tag(interface, range)))
.collect();
format!(
"$ErrorActionPreference = 'SilentlyContinue'\n\
$prefix = {prefix}\n\
$keep = @({})\n\
Get-NetNat | Where-Object {{ $_.Name.StartsWith($prefix) -and \
($keep -notcontains $_.Name) }} | Remove-NetNat -Confirm:$false\n\
exit 0\n",
keep.join(", ")
)
}
/// Prints `Enabled` or `Disabled` for the interface's IPv4 forwarding.
fn forwarding_script(interface: &str) -> String {
let alias = ps_literal(interface);
format!(
"$ErrorActionPreference = 'Stop'\n\
(Get-NetIPInterface -InterfaceAlias {alias} -AddressFamily IPv4).Forwarding\n"
)
}
/// Prints `<interface index> <next hop>` of the best default route that is
/// not through the overlay interface.
fn gateway_script(interface: &str) -> String {
let alias = ps_literal(interface);
format!(
"$ErrorActionPreference = 'Stop'\n\
$alias = {alias}\n\
$best = Get-NetRoute -DestinationPrefix '0.0.0.0/0' -AddressFamily IPv4 \
-PolicyStore ActiveStore | Where-Object {{ $_.InterfaceAlias -ne $alias -and \
$_.NextHop -ne '0.0.0.0' }} | Sort-Object {{ $_.RouteMetric + \
(Get-NetIPInterface -InterfaceIndex $_.InterfaceIndex -AddressFamily IPv4).InterfaceMetric }} | \
Select-Object -First 1\n\
if (-not $best) {{ throw 'the host has no default gateway outside the overlay' }}\n\
\"$($best.InterfaceIndex) $($best.NextHop)\"\n"
)
}
/// The default gateway as the script above printed it.
fn parse_gateway(text: &str) -> Option<(u32, Ipv4Addr)> {
let mut words = text.split_whitespace();
let index = words.next()?.parse().ok()?;
let hop = words.next()?.parse().ok()?;
words.next().is_none().then_some((index, hop))
}
/// Replaces the bypass routes with one per address, through the gateway.
fn bypass_script(index: u32, gateway: Ipv4Addr, addresses: &[Ipv4Addr]) -> String {
let list: Vec<String> = addresses
.iter()
.map(|address| ps_literal(&address.to_string()))
.collect();
format!(
"$ErrorActionPreference = 'Stop'\n\
Get-NetRoute -AddressFamily IPv4 -PolicyStore ActiveStore -ErrorAction SilentlyContinue | \
Where-Object {{ $_.RouteMetric -eq {BYPASS_METRIC} -and $_.DestinationPrefix.EndsWith('/32') }} | \
Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
foreach ($ip in @({})) {{\n\
New-NetRoute -DestinationPrefix \"$ip/32\" -InterfaceIndex {index} -NextHop '{gateway}' \
-RouteMetric {BYPASS_METRIC} -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Out-Null\n\
}}\n",
list.join(", ")
)
}
/// Sends everything else through the overlay interface.
fn halves_script(interface: &str) -> String {
let alias = ps_literal(interface);
format!(
"$ErrorActionPreference = 'Stop'\n\
$alias = {alias}\n\
foreach ($p in '0.0.0.0/1', '128.0.0.0/1') {{\n\
Get-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -PolicyStore ActiveStore \
-ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
New-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -NextHop '0.0.0.0' \
-RouteMetric 1 -PolicyStore ActiveStore | Out-Null\n\
}}\n\
foreach ($p in '::/1', '8000::/1') {{\n\
Get-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -PolicyStore ActiveStore \
-ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
New-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -NextHop '::' \
-RouteMetric 1 -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Out-Null\n\
}}\n"
)
}
/// Removes the halves and every bypass route. What is not there is the state
/// wanted.
fn remove_client_script(interface: &str) -> String {
let alias = ps_literal(interface);
format!(
"$ErrorActionPreference = 'SilentlyContinue'\n\
$alias = {alias}\n\
foreach ($p in '0.0.0.0/1', '128.0.0.0/1', '::/1', '8000::/1') {{\n\
Get-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -PolicyStore ActiveStore | \
Remove-NetRoute -Confirm:$false\n\
}}\n\
Get-NetRoute -AddressFamily IPv4 -PolicyStore ActiveStore | \
Where-Object {{ $_.RouteMetric -eq {BYPASS_METRIC} -and $_.DestinationPrefix.EndsWith('/32') }} | \
Remove-NetRoute -Confirm:$false\n\
exit 0\n"
)
}
/// Every address to keep direct: the ones given and what the names resolve
/// to, sorted and without duplicates.
fn bypass_addresses(
resolve: Resolve<'_>,
addresses: &[Ipv4Addr],
hosts: &[String],
) -> Vec<Ipv4Addr> {
let mut all: std::collections::BTreeSet<Ipv4Addr> = addresses.iter().copied().collect();
for host in hosts {
all.extend(resolve(host));
}
all.into_iter()
.filter(|a| !a.is_loopback() && !a.is_unspecified() && !a.is_multicast())
.collect()
}
fn apply_client(
run: RunScript<'_>,
resolve: Resolve<'_>,
plan: &ExitHostPlan,
) -> Result<(), String> {
let interface = plan.interface.as_str();
let gateway = run(&gateway_script(interface))?;
let (index, hop) = parse_gateway(&gateway)
.ok_or_else(|| format!("cannot read the default gateway from `{}`", gateway.trim()))?;
let addresses = bypass_addresses(resolve, &plan.bypass, &plan.bypass_hosts);
run(&bypass_script(index, hop, &addresses))?;
if let Err(why) = run(&halves_script(interface)) {
// Do not leave a half-installed set: bypass routes alone are harmless
// but the next apply starts clean.
let _ = run(&remove_client_script(interface));
return Err(why);
}
Ok(())
}
fn parse_forwarding(text: &str) -> Option<bool> {
match text.trim().to_ascii_lowercase().as_str() {
"enabled" | "1" => Some(true),
"disabled" | "0" => Some(false),
_ => None,
}
}
/// Makes the host match the plan.
pub(super) fn apply_plan(
run: RunScript<'_>,
resolve: Resolve<'_>,
plan: &ExitHostPlan,
) -> ExitHostReport {
let interface = plan.interface.as_str();
// What an earlier plan left and this one no longer wants.
let pruned = run(&prune_script(interface, &plan.offer));
let offer = plan
.offer
.iter()
.map(|range| {
let outcome = match &pruned {
Err(why) => RuleOutcome::Failed(why.clone()),
Ok(_) => match run(&nat_script(interface, range)) {
Ok(_) => RuleOutcome::Applied,
Err(why) => RuleOutcome::Failed(why),
},
};
(*range, outcome)
})
.collect();
let client = if plan.client {
Some(match apply_client(run, resolve, plan) {
Ok(()) => RuleOutcome::Applied,
Err(why) => RuleOutcome::Failed(why),
})
} else {
// Whatever an earlier plan left must not stay in force.
let _ = run(&remove_client_script(interface));
None
};
let forwarding = if plan.offer.is_empty() {
None
} else {
run(&forwarding_script(interface))
.ok()
.and_then(|text| parse_forwarding(&text))
};
ExitHostReport {
offer,
forwarding,
client,
}
}
/// Removes every NAT object of this agent's for the interface.
pub(super) fn clear_all(run: RunScript<'_>, interface: &str) {
if let Err(why) = run(&prune_script(interface, &[])) {
tracing::warn!(interface, "cannot remove the exit node NAT: {why}");
}
if let Err(why) = run(&remove_client_script(interface)) {
tracing::warn!(interface, "cannot remove the exit node routes: {why}");
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::cell::RefCell;
use super::*;
fn range() -> Ipv4Range {
"10.13.37.0/24".parse().unwrap()
}
fn plan(client: bool) -> ExitHostPlan {
ExitHostPlan {
interface: "tsun0".into(),
offer: vec![range()],
client,
overlay: vec![range()],
..Default::default()
}
}
#[test]
fn the_nat_is_named_with_the_tag_and_replaces_its_own() {
let script = nat_script("tsun0", &range());
assert!(
script.contains("$name = 'tsunagi-exit:tsun0:10.13.37.0/24'"),
"{script}"
);
assert!(script.contains("-InternalIPInterfaceAddressPrefix '10.13.37.0/24'"));
assert!(
script.find("Remove-NetNat").unwrap() < script.find("New-NetNat").unwrap(),
"{script}"
);
}
#[test]
fn a_quote_in_a_value_cannot_escape_the_literal() {
let script = nat_script("x'; Remove-Item C:\\ #", &range());
assert!(script.contains("'tsunagi-exit:x''; Remove-Item C:\\ #:10.13.37.0/24'"));
assert!(prune_script("x'y", &[]).contains("'tsunagi-exit:x''y:'"));
assert!(forwarding_script("x'y").contains("'x''y'"));
}
#[test]
fn pruning_keeps_wanted_ranges_and_only_selects_this_interfaces_nats() {
let script = prune_script("tsun0", &[range()]);
assert!(script.contains("$prefix = 'tsunagi-exit:tsun0:'"));
assert!(script.contains("$keep = @('tsunagi-exit:tsun0:10.13.37.0/24')"));
assert!(script.contains("StartsWith($prefix)"));
assert!(script.contains("exit 0"));
assert!(prune_script("tsun0", &[]).contains("$keep = @()"));
}
#[test]
fn forwarding_text_is_read_never_written() {
assert_eq!(parse_forwarding("Enabled\r\n"), Some(true));
assert_eq!(parse_forwarding("Disabled"), Some(false));
assert_eq!(parse_forwarding("what"), None);
let script = forwarding_script("tsun0");
assert!(script.contains("Get-NetIPInterface"));
assert!(!script.contains("Set-NetIPInterface"));
}
#[test]
fn offering_prunes_then_creates_and_reads_forwarding() {
let calls = RefCell::new(Vec::new());
let run = |script: &str| -> Result<String, String> {
calls.borrow_mut().push(script.to_string());
Ok(if script.contains("Get-NetIPInterface") {
"Disabled\n".to_string()
} else {
String::new()
})
};
let report = apply_plan(&run, &no_names, &plan(false));
assert_eq!(report.offer, vec![(range(), RuleOutcome::Applied)]);
assert_eq!(report.forwarding, Some(false));
assert_eq!(report.client, None);
let calls = calls.borrow();
assert!(calls[0].contains("StartsWith"), "{calls:?}");
assert!(calls[1].contains("New-NetNat"), "{calls:?}");
// Without a client, the routes of an earlier plan are taken away.
assert!(calls[2].contains("RouteMetric -eq 7373"), "{calls:?}");
assert!(calls[3].contains("Get-NetIPInterface"), "{calls:?}");
}
#[test]
fn a_failing_nat_is_that_ranges_failure() {
let run = |script: &str| -> Result<String, String> {
if script.contains("New-NetNat") {
Err("A NAT with this prefix already exists".into())
} else {
Ok(String::new())
}
};
let report = apply_plan(&run, &no_names, &plan(false));
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
panic!("{report:?}");
};
assert!(why.contains("already exists"), "{why}");
}
fn no_names(_: &str) -> Vec<Ipv4Addr> {
Vec::new()
}
fn client_plan() -> ExitHostPlan {
ExitHostPlan {
interface: "tsun0".into(),
offer: Vec::new(),
client: true,
overlay: vec![range()],
bypass: vec![Ipv4Addr::new(138, 201, 61, 182)],
bypass_hosts: vec!["relay.example".into()],
}
}
#[test]
fn the_gateway_is_read_from_the_script_output() {
assert_eq!(
parse_gateway("12 192.168.1.1\r\n"),
Some((12, Ipv4Addr::new(192, 168, 1, 1)))
);
assert_eq!(parse_gateway(""), None);
assert_eq!(parse_gateway("12"), None);
assert_eq!(parse_gateway("12 gw"), None);
assert_eq!(parse_gateway("12 1.1.1.1 extra"), None);
let script = gateway_script("tsun0");
assert!(script.contains("-ne $alias"));
assert!(script.contains("-ne '0.0.0.0'"));
}
#[test]
fn bypass_routes_are_marked_host_routes_through_the_physical_gateway() {
let script = bypass_script(
12,
Ipv4Addr::new(192, 168, 1, 1),
&[Ipv4Addr::new(1, 2, 3, 4), Ipv4Addr::new(5, 6, 7, 8)],
);
assert!(script.contains("@('1.2.3.4', '5.6.7.8')"), "{script}");
assert!(script.contains("-InterfaceIndex 12 -NextHop '192.168.1.1'"));
assert!(script.contains("-RouteMetric 7373"));
assert!(script.contains("-PolicyStore ActiveStore"));
// Stale marked routes go first, and only /32 ones with our metric.
assert!(script.find("Remove-NetRoute").unwrap() < script.find("New-NetRoute").unwrap());
assert!(script.contains("RouteMetric -eq 7373"));
assert!(script.contains("EndsWith('/32')"));
}
#[test]
fn the_halves_go_through_the_overlay_and_ipv6_is_blackholed() {
let script = halves_script("tsun0");
assert!(script.contains("'0.0.0.0/1', '128.0.0.0/1'"));
assert!(script.contains("-InterfaceAlias $alias -NextHop '0.0.0.0'"));
assert!(script.contains("'::/1', '8000::/1'"));
assert!(script.contains("-PolicyStore ActiveStore"));
assert!(
!script.contains("0.0.0.0/0"),
"the default route is left alone"
);
assert!(halves_script("x'y").contains("'x''y'"));
}
#[test]
fn addresses_are_resolved_merged_and_cleaned() {
let resolve = |host: &str| -> Vec<Ipv4Addr> {
match host {
"relay.example" => vec![Ipv4Addr::new(9, 9, 9, 9), Ipv4Addr::new(1, 2, 3, 4)],
_ => Vec::new(),
}
};
let all = bypass_addresses(
&resolve,
&[
Ipv4Addr::new(1, 2, 3, 4),
Ipv4Addr::LOCALHOST,
Ipv4Addr::UNSPECIFIED,
],
&["relay.example".into(), "gone.example".into()],
);
assert_eq!(
all,
vec![Ipv4Addr::new(1, 2, 3, 4), Ipv4Addr::new(9, 9, 9, 9)]
);
}
#[test]
fn the_bypass_is_written_before_the_halves_that_need_it() {
let calls = RefCell::new(Vec::new());
let run = |script: &str| -> Result<String, String> {
calls.borrow_mut().push(script.to_string());
Ok(if script.contains("Select-Object -First 1") {
"12 192.168.1.1\n".to_string()
} else {
String::new()
})
};
let resolve = |_: &str| vec![Ipv4Addr::new(9, 9, 9, 9)];
let report = apply_plan(&run, &resolve, &client_plan());
assert_eq!(report.client, Some(RuleOutcome::Applied));
let calls = calls.borrow();
let at = |needle: &str| calls.iter().position(|c| c.contains(needle)).unwrap();
assert!(at("Select-Object -First 1") < at("-RouteMetric 7373"));
assert!(at("-RouteMetric 7373") < at("'0.0.0.0/1', '128.0.0.0/1'"));
let bypass = &calls[at("-RouteMetric 7373")];
assert!(
bypass.contains("'9.9.9.9'") && bypass.contains("'138.201.61.182'"),
"{bypass}"
);
}
#[test]
fn nothing_goes_through_the_overlay_when_the_bypass_cannot_be_written() {
let run = |script: &str| -> Result<String, String> {
if script.contains("Select-Object -First 1") {
Ok("12 192.168.1.1".to_string())
} else if script.contains("-RouteMetric 7373") && script.contains("foreach ($ip") {
Err("Access is denied".to_string())
} else {
assert!(!script.contains("-NextHop '0.0.0.0'"), "{script}");
Ok(String::new())
}
};
let report = apply_plan(&run, &no_names, &client_plan());
assert_eq!(
report.client,
Some(RuleOutcome::Failed("Access is denied".into()))
);
}
#[test]
fn a_missing_gateway_is_a_failure_that_installs_no_route() {
let run = |script: &str| -> Result<String, String> {
assert!(!script.contains("New-NetRoute"), "{script}");
if script.contains("Select-Object -First 1") {
Err("the host has no default gateway outside the overlay".into())
} else {
Ok(String::new())
}
};
let report = apply_plan(&run, &no_names, &client_plan());
let Some(RuleOutcome::Failed(why)) = report.client else {
panic!("{report:?}");
};
assert!(why.contains("no default gateway"), "{why}");
}
#[test]
fn a_failed_halves_step_removes_what_was_written() {
let calls = RefCell::new(Vec::new());
let run = |script: &str| -> Result<String, String> {
calls.borrow_mut().push(script.to_string());
if script.contains("Select-Object -First 1") {
Ok("12 192.168.1.1".to_string())
} else if script.contains("'128.0.0.0/1'") && script.contains("New-NetRoute") {
Err("boom".to_string())
} else {
Ok(String::new())
}
};
let report = apply_plan(&run, &no_names, &client_plan());
assert!(matches!(report.client, Some(RuleOutcome::Failed(_))));
assert!(calls.borrow().last().unwrap().contains("Remove-NetRoute"));
}
#[test]
fn dropping_the_client_removes_its_routes_and_clearing_does_too() {
let calls = RefCell::new(Vec::new());
let run = |script: &str| -> Result<String, String> {
calls.borrow_mut().push(script.to_string());
Ok(String::new())
};
let report = apply_plan(&run, &no_names, &plan(false));
assert_eq!(report.client, None);
assert!(
calls
.borrow()
.iter()
.any(|c| c.contains("RouteMetric -eq 7373"))
);
let script = remove_client_script("tsun0");
assert!(script.contains("'0.0.0.0/1', '128.0.0.0/1', '::/1', '8000::/1'"));
assert!(script.contains("exit 0"));
// Only routes on our interface, or marked ours, are touched.
assert!(script.contains("-InterfaceAlias $alias"));
calls.borrow_mut().clear();
clear_all(&run, "tsun0");
assert!(
calls
.borrow()
.iter()
.any(|c| c.contains("RouteMetric -eq 7373"))
);
}
#[test]
fn clearing_prunes_everything_for_the_interface() {
let seen = RefCell::new(Vec::<String>::new());
let run = |script: &str| -> Result<String, String> {
seen.borrow_mut().push(script.to_string());
Ok(String::new())
};
clear_all(&run, "tsun0");
let seen = seen.borrow();
assert!(seen[0].contains("$keep = @()"));
assert!(seen[0].contains("'tsunagi-exit:tsun0:'"));
}
}
File diff suppressed because it is too large. Load diff
+80
View File
@@ -0,0 +1,80 @@
//! 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 exit;
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 exit::{ExitRulesReport, RuleSetReport};
pub use hostrules::{
BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, ExitHostPlan, ExitHostReport,
ExitHostRules, MockExitRules, 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::{ExitPolicy, 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;
@@ -65,6 +65,22 @@ impl IpHeader {
IpHeader::V4 { .. } => None,
}
}
/// The destination, whichever family it is.
pub fn destination(&self) -> std::net::IpAddr {
match self {
IpHeader::V4 { destination, .. } => std::net::IpAddr::V4(*destination),
IpHeader::V6 { destination, .. } => std::net::IpAddr::V6(*destination),
}
}
/// The source, whichever family it is.
pub fn source(&self) -> std::net::IpAddr {
match self {
IpHeader::V4 { source, .. } => std::net::IpAddr::V4(*source),
IpHeader::V6 { source, .. } => std::net::IpAddr::V6(*source),
}
}
}
#[cfg(test)]
@@ -0,0 +1,271 @@
//! The adapter between the plugin's view of a packet interface and the
//! host-management view.
//!
//! The plugin asks a [`TunFactory`] for a device and knows nothing else. This
//! factory answers by reconciling the host — creating the interface, fixing
//! up whatever an earlier run left behind, assigning the addresses — and
//! handing back the device that came out of it.
use std::sync::Arc;
use crate::BoxFuture;
use crate::overlay::OverlayError;
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, 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,
request.addresses.clone(),
))
}
/// A [`TunFactory`] backed by an [`InterfaceProvisioner`].
#[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,
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.
pub fn provisioner(&self) -> &Arc<dyn InterfaceProvisioner> {
&self.provisioner
}
}
impl TunFactory for ManagedTunFactory {
fn name(&self) -> &str {
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>, OverlayError>> {
Box::pin(async move {
let plan = plan_for(&request)?;
let provisioned = self.provisioner.reconcile(&plan).await?;
tracing::info!(
interface = %plan.name,
changes = %provisioned.changes.summary(),
"overlay interface reconciled"
);
provisioned.device.ok_or_else(|| {
// 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.
OverlayError::Other(format!(
"interface `{}` was reconciled but no device came back",
plan.name
))
})
})
}
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?;
if !provisioned.changes.is_empty() {
tracing::info!(
interface = %plan.name,
changes = %provisioned.changes.summary(),
"overlay interface updated"
);
}
Ok(())
})
}
fn destroy<'a>(&'a self, name: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
match self.provisioner.remove(name).await {
Ok(()) => tracing::info!(interface = %name, "overlay interface removed"),
Err(err) => {
tracing::warn!(interface = %name, %err, "cannot remove the overlay interface")
}
}
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
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(),
addresses: v4
.into_iter()
.map(|address| Cidr::new(address.into(), 24).unwrap())
.collect(),
mtu: 1280,
}
}
#[tokio::test]
async fn creating_a_device_provisions_the_host_and_returns_it() {
let provisioner = Arc::new(MockProvisioner::default());
let factory = ManagedTunFactory::new(provisioner.clone());
let device = factory
.create(request(Some(Ipv4Addr::new(10, 13, 37, 69))))
.await
.unwrap();
assert_eq!(device.name(), "tsunfactory");
assert_eq!(device.mtu(), 1280);
let state = provisioner.host().get("tsunfactory").unwrap();
assert_eq!(state.kind, LinkKind::Tun);
assert_eq!(state.mtu, 1280);
assert_eq!(state.addresses.len(), 1, "the overlay address is assigned");
}
#[tokio::test]
async fn a_reallocated_address_is_applied_to_the_live_interface() {
let provisioner = Arc::new(MockProvisioner::default());
let factory = ManagedTunFactory::new(provisioner.clone());
factory
.create(request(Some(Ipv4Addr::new(10, 13, 37, 69))))
.await
.unwrap();
factory
.reconfigure(request(Some(Ipv4Addr::new(10, 13, 37, 70))))
.await
.unwrap();
let state = provisioner.host().get("tsunfactory").unwrap();
assert!(state.attached, "the interface was not recreated");
let addresses: Vec<String> = state
.addresses
.iter()
.map(|entry| entry.to_string())
.collect();
assert!(
addresses.contains(&"10.13.37.70/24".to_string()),
"{addresses:?}"
);
assert!(
!addresses.contains(&"10.13.37.69/24".to_string()),
"{addresses:?}"
);
}
#[tokio::test]
async fn destroying_takes_the_interface_off_the_host() {
let provisioner = Arc::new(MockProvisioner::default());
let factory = ManagedTunFactory::new(provisioner.clone());
factory.create(request(None)).await.unwrap();
factory.destroy("tsunfactory").await;
assert!(provisioner.host().names().is_empty());
}
#[tokio::test]
async fn a_host_that_cannot_be_provisioned_fails_the_create() {
let host = MockHost::new();
host.insert(
"tsunfactory",
super::super::InterfaceState {
kind: LinkKind::Foreign("bridge".into()),
attached: true,
up: true,
mtu: 1500,
addresses: Vec::new(),
},
);
let factory = ManagedTunFactory::new(Arc::new(MockProvisioner::new(host)));
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()]);
}
}
File diff suppressed because it is too large. Load diff
@@ -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
))));
}
}
@@ -0,0 +1,336 @@
//! An in-memory host, so provisioning is tested without touching this one.
//!
//! [`MockHost`] is a pretend `/sys/class/net`: a test can seed it with the
//! leftovers of a crashed run, or with somebody else's bridge, then check
//! what the provisioner did about it. It is also what the platforms that have
//! no provisioner yet are wired to in their own tests.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::BoxFuture;
use crate::overlay::OverlayError;
use super::super::config::Cidr;
use super::super::tun::{MemoryTun, MemoryTunFactory, TunFactory, TunRequest};
use super::{
Changes, InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, Provisioned,
plan_changes,
};
/// A pretend host with interfaces on it.
#[derive(Debug, Clone, Default)]
pub struct MockHost {
links: Arc<Mutex<HashMap<String, InterfaceState>>>,
}
impl MockHost {
/// An empty host.
pub fn new() -> Self {
Self::default()
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, InterfaceState>> {
match self.links.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
/// Puts an interface on the host.
pub fn insert(&self, name: impl Into<String>, state: InterfaceState) {
self.lock().insert(name.into(), state);
}
/// Seeds the leftovers of a run that died: a TUN nobody holds open, still
/// carrying whatever addresses it had.
pub fn insert_stale_tun(&self, name: impl Into<String>, addresses: Vec<Cidr>) {
self.insert(
name,
InterfaceState {
kind: LinkKind::Tun,
attached: false,
up: true,
mtu: 1280,
addresses,
},
);
}
/// The state of an interface, if it exists.
pub fn get(&self, name: &str) -> Option<InterfaceState> {
self.lock().get(name).cloned()
}
/// The names currently on the host.
pub fn names(&self) -> Vec<String> {
let mut names: Vec<String> = self.lock().keys().cloned().collect();
names.sort();
names
}
}
/// Applies plans to a [`MockHost`].
#[derive(Debug, Clone)]
pub struct MockProvisioner {
host: MockHost,
ours: Arc<Mutex<Vec<String>>>,
/// The devices handed out, so a test can drive packets through them.
devices: MemoryTunFactory,
/// Set to fail every call, to exercise the error path.
failure: Option<String>,
}
impl Default for MockProvisioner {
fn default() -> Self {
Self::new(MockHost::new())
}
}
impl MockProvisioner {
/// A provisioner over a host.
pub fn new(host: MockHost) -> Self {
Self {
host,
ours: Arc::new(Mutex::new(Vec::new())),
devices: MemoryTunFactory::new(),
failure: None,
}
}
/// A provisioner that refuses everything, with this reason.
pub fn failing(reason: impl Into<String>) -> Self {
Self {
host: MockHost::new(),
ours: Arc::new(Mutex::new(Vec::new())),
devices: MemoryTunFactory::new(),
failure: Some(reason.into()),
}
}
/// The host it applies to.
pub fn host(&self) -> &MockHost {
&self.host
}
/// The device created for an interface name, if any.
pub fn device(&self, name: &str) -> Option<Arc<MemoryTun>> {
self.devices.device(name)
}
fn owned(&self) -> std::sync::MutexGuard<'_, Vec<String>> {
match self.ours.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn apply(&self, plan: &InterfacePlan) -> Result<Changes, OverlayError> {
if let Some(reason) = &self.failure {
return Err(OverlayError::Unavailable(reason.clone()));
}
let ours = self.owned().iter().any(|name| name == &plan.name);
let current = self
.host
.get(&plan.name)
.unwrap_or_else(InterfaceState::absent);
let changes = plan_changes(&current, plan, ours)?;
let mut state = current;
if changes.delete_link {
self.host.lock().remove(&plan.name);
state = InterfaceState::absent();
}
if changes.create_link {
state = InterfaceState {
kind: LinkKind::Tun,
// Creating it means holding it open, so it has carrier.
attached: true,
up: false,
mtu: 1500,
addresses: Vec::new(),
};
self.owned().push(plan.name.clone());
}
if let Some(mtu) = changes.set_mtu {
state.mtu = mtu;
}
if changes.bring_up {
state.up = true;
}
state
.addresses
.retain(|addr| !changes.remove.contains(addr));
state.addresses.extend(changes.add.iter().copied());
state.addresses.sort();
state.addresses.dedup();
self.host.insert(plan.name.clone(), state);
Ok(changes)
}
}
impl InterfaceProvisioner for MockProvisioner {
fn name(&self) -> &str {
"mock"
}
fn reconcile<'a>(
&'a self,
plan: &'a InterfacePlan,
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
Box::pin(async move {
let changes = self.apply(plan)?;
let device = if changes.create_link {
Some(
self.devices
.create(TunRequest::bare(plan.name.clone(), plan.mtu))
.await?,
)
} else {
None
};
Ok(Provisioned { changes, device })
})
}
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);
Ok(())
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
fn v6() -> Cidr {
Cidr {
addr: IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)),
prefix_len: 64,
}
}
fn v4(last: u8) -> Cidr {
Cidr {
addr: IpAddr::V4(Ipv4Addr::new(10, 13, 37, last)),
prefix_len: 24,
}
}
fn plan(addresses: Vec<Cidr>) -> InterfacePlan {
InterfacePlan::new("tsunmock", 1280, addresses)
}
#[tokio::test]
async fn reconciling_an_empty_host_creates_a_configured_interface() {
let provisioner = MockProvisioner::default();
let plan = plan(vec![v6(), v4(69)]);
let changes = provisioner.reconcile(&plan).await.unwrap().changes;
assert!(changes.create_link);
let state = provisioner.host().get("tsunmock").unwrap();
assert_eq!(state.kind, LinkKind::Tun);
assert!(state.up);
assert_eq!(state.mtu, 1280);
assert_eq!(state.addresses, vec![v4(69), v6()]);
}
#[tokio::test]
async fn reconciling_is_idempotent() {
let provisioner = MockProvisioner::default();
let plan = plan(vec![v6(), v4(69)]);
provisioner.reconcile(&plan).await.unwrap();
let second = provisioner.reconcile(&plan).await.unwrap().changes;
assert!(second.is_empty(), "{second:?}");
}
#[tokio::test]
async fn a_crashed_run_is_repaired_on_the_next_start() {
let host = MockHost::new();
// What the previous run left: the interface, with the address it had
// been allocated back then.
host.insert_stale_tun("tsunmock", vec![v4(178)]);
let provisioner = MockProvisioner::new(host);
let changes = provisioner
.reconcile(&plan(vec![v6(), v4(69)]))
.await
.unwrap()
.changes;
assert!(changes.delete_link && changes.create_link);
let state = provisioner.host().get("tsunmock").unwrap();
assert_eq!(
state.addresses,
vec![v4(69), v6()],
"the stale address is gone and the current one is there"
);
assert!(state.attached, "the new interface is held open by us");
}
#[tokio::test]
async fn a_changed_allocation_is_applied_without_recreating_the_interface() {
let provisioner = MockProvisioner::default();
provisioner
.reconcile(&plan(vec![v6(), v4(69)]))
.await
.unwrap();
// The overlay agreed on a different address for us while running.
let changes = provisioner
.reconcile(&plan(vec![v6(), v4(70)]))
.await
.unwrap()
.changes;
assert!(
!changes.delete_link && !changes.create_link,
"recreating would drop every tunnel"
);
assert_eq!(changes.add, vec![v4(70)]);
assert_eq!(changes.remove, vec![v4(69)]);
assert_eq!(
provisioner.host().get("tsunmock").unwrap().addresses,
vec![v4(70), v6()]
);
}
#[tokio::test]
async fn removing_takes_the_interface_off_the_host_and_is_idempotent() {
let provisioner = MockProvisioner::default();
provisioner.reconcile(&plan(vec![v6()])).await.unwrap();
assert_eq!(provisioner.host().names(), vec!["tsunmock".to_string()]);
provisioner.remove("tsunmock").await.unwrap();
assert!(provisioner.host().names().is_empty());
provisioner.remove("tsunmock").await.unwrap();
}
#[tokio::test]
async fn a_foreign_interface_makes_reconciling_fail_and_changes_nothing() {
let host = MockHost::new();
host.insert(
"tsunmock",
InterfaceState {
kind: LinkKind::Foreign("bridge".into()),
attached: true,
up: true,
mtu: 1500,
addresses: vec![v4(1)],
},
);
let provisioner = MockProvisioner::new(host);
assert!(provisioner.reconcile(&plan(vec![v6()])).await.is_err());
let state = provisioner.host().get("tsunmock").unwrap();
assert_eq!(state.kind, LinkKind::Foreign("bridge".into()));
assert_eq!(state.addresses, vec![v4(1)], "left exactly as it was");
}
}
@@ -0,0 +1,456 @@
//! Bringing a host interface into the state the overlay needs.
//!
//! The agent used to print a list of `ip` commands and ask a human to run
//! them. That is fragile in exactly the way hand-held setup always is: the
//! interface does not survive a reboot, a changed address allocation needs
//! another manual round, and a crashed run leaves a half-configured interface
//! that the next run then trips over.
//!
//! So the agent does it itself, and the shape of that is a *reconciliation*:
//! it is handed an [`InterfacePlan`] describing what the interface should look
//! like, it observes what is actually there, and it applies the difference.
//! Running it twice changes nothing the second time, and running it after a
//! crash repairs whatever was left behind.
//!
//! # Why this is a trait
//!
//! Every platform does this differently — netlink on Linux, `SystemConfiguration`
//! on macOS, the Windows IP Helper API — while the *decision* of what to change
//! is the same everywhere. So the decision lives in [`plan_changes`], which is
//! pure and tested on every platform, and only the execution is behind
//! [`InterfaceProvisioner`].
//!
//! 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.
//!
//! # What it is not allowed to do
//!
//! Nothing here takes a name, an address or a command from the network. The
//! interface name is derived from the network id, the addresses come from the
//! local plugin, and an interface this agent did not create is never deleted
//! or reconfigured — see [`plan_changes`].
use std::collections::BTreeSet;
use std::sync::Arc;
use crate::BoxFuture;
use crate::overlay::OverlayError;
use super::config::Cidr;
use super::tun::TunDevice;
mod mock;
pub use mock::{MockHost, MockProvisioner};
#[cfg(all(feature = "tun-device", target_os = "linux"))]
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};
mod unsupported;
pub use unsupported::UnsupportedProvisioner;
mod factory;
pub use factory::ManagedTunFactory;
/// What an interface should look like.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfacePlan {
/// Interface name. Derived from the network id, never from the network.
pub name: String,
/// Interface MTU.
pub mtu: u32,
/// Every address the interface should carry, and no others.
pub addresses: Vec<Cidr>,
}
impl InterfacePlan {
/// Builds a plan, normalising the address list.
pub fn new(name: impl Into<String>, mtu: u32, addresses: Vec<Cidr>) -> Self {
let unique: BTreeSet<Cidr> = addresses.into_iter().collect();
Self {
name: name.into(),
mtu,
addresses: unique.into_iter().collect(),
}
}
}
/// What kind of link is sitting on a name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkKind {
/// Nothing is there.
Absent,
/// A TUN interface.
Tun,
/// Something else entirely — a bridge, a physical device, a VPN from
/// another program. Never ours to touch.
Foreign(String),
}
/// What an interface currently looks like.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfaceState {
/// What kind of link holds the name.
pub kind: LinkKind,
/// Whether a process is attached to the TUN, which the kernel reports as
/// carrier. A leftover interface from a crashed run has none.
pub attached: bool,
/// Whether the link is administratively up.
pub up: bool,
/// The current MTU.
pub mtu: u32,
/// The addresses currently assigned.
pub addresses: Vec<Cidr>,
}
impl InterfaceState {
/// The state of a name nothing is using.
pub fn absent() -> Self {
Self {
kind: LinkKind::Absent,
attached: false,
up: false,
mtu: 0,
addresses: Vec::new(),
}
}
}
/// The steps that turn an [`InterfaceState`] into an [`InterfacePlan`].
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Changes {
/// Remove a stale interface left behind by an earlier run.
pub delete_link: bool,
/// Create the interface.
pub create_link: bool,
/// Set the MTU, when it is not already right.
pub set_mtu: Option<u32>,
/// Bring the link up.
pub bring_up: bool,
/// Addresses to add.
pub add: Vec<Cidr>,
/// Addresses to remove, because the plan no longer contains them.
pub remove: Vec<Cidr>,
}
impl Changes {
/// Whether anything at all needs doing.
pub fn is_empty(&self) -> bool {
!self.delete_link
&& !self.create_link
&& self.set_mtu.is_none()
&& !self.bring_up
&& self.add.is_empty()
&& self.remove.is_empty()
}
/// A one-line summary for a log line.
pub fn summary(&self) -> String {
if self.is_empty() {
return "already as planned".to_string();
}
let mut parts = Vec::new();
if self.delete_link {
parts.push("remove a stale interface".to_string());
}
if self.create_link {
parts.push("create the interface".to_string());
}
if let Some(mtu) = self.set_mtu {
parts.push(format!("set mtu {mtu}"));
}
if self.bring_up {
parts.push("bring it up".to_string());
}
for address in &self.add {
parts.push(format!("add {address}"));
}
for address in &self.remove {
parts.push(format!("remove {address}"));
}
parts.join(", ")
}
}
/// Decides what to change, or refuses.
///
/// `ours` says whether this process created the interface in its current run.
/// It is the whole reason this can be safe: an interface we made is adjusted
/// in place, and an interface we did not make is only ever *replaced* when it
/// is plainly abandoned.
///
/// The refusals matter more than the changes:
///
/// * A link that is not a TUN is never touched. Deriving the name from the
/// network id makes a collision with a real device unlikely, not impossible,
/// and destroying somebody's bridge because it happened to share a name is
/// not a recoverable mistake.
/// * A TUN with a process attached to it is never deleted. It is somebody
/// else's working interface — most likely another agent on this host — and
/// yanking it out from under them would break a running overlay.
///
/// What is left is a TUN with nothing attached, which is precisely the
/// footprint of a run that died: those are removed and rebuilt.
pub fn plan_changes(
current: &InterfaceState,
plan: &InterfacePlan,
ours: bool,
) -> Result<Changes, OverlayError> {
let mut changes = Changes::default();
match &current.kind {
LinkKind::Foreign(kind) => {
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(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.",
plan.name
)));
}
LinkKind::Tun if !ours => {
// Abandoned: a TUN with no carrier is one nothing holds open. It
// is either a leftover from a run that died or an interface made
// by the old manual recipe. Either way it is replaced, which also
// discards whatever stale addresses it carried.
changes.delete_link = true;
changes.create_link = true;
}
LinkKind::Tun => {}
LinkKind::Absent => changes.create_link = true,
}
if changes.create_link {
// A fresh interface starts down, with the kernel default MTU and no
// addresses, so everything in the plan has to be applied.
changes.set_mtu = Some(plan.mtu);
changes.bring_up = true;
changes.add = plan.addresses.clone();
return Ok(changes);
}
if current.mtu != plan.mtu {
changes.set_mtu = Some(plan.mtu);
}
if !current.up {
changes.bring_up = true;
}
let wanted: BTreeSet<Cidr> = plan.addresses.iter().copied().collect();
let present: BTreeSet<Cidr> = current.addresses.iter().copied().collect();
changes.add = wanted.difference(&present).copied().collect();
changes.remove = present.difference(&wanted).copied().collect();
Ok(changes)
}
/// The result of a reconciliation.
pub struct Provisioned {
/// What was changed to get here.
pub changes: Changes,
/// The packet interface, when this call is what created it.
///
/// Creating the interface and configuring it are the same privileged act
/// and belong together, so the provisioner owns both. A reconciliation
/// that only adjusted an interface already in place returns `None`.
pub device: Option<Arc<dyn TunDevice>>,
}
impl std::fmt::Debug for Provisioned {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Provisioned")
.field("changes", &self.changes)
.field("device", &self.device.as_ref().map(|device| device.name()))
.finish()
}
}
/// Applies an [`InterfacePlan`] to the host.
///
/// Implementations are expected to be idempotent: calling [`reconcile`] twice
/// with the same plan changes nothing the second time.
///
/// [`reconcile`]: InterfaceProvisioner::reconcile
pub trait InterfaceProvisioner: Send + Sync + std::fmt::Debug + 'static {
/// A short name used in diagnostics.
fn name(&self) -> &str;
/// Brings the interface in line with the plan, and reports what it did.
fn reconcile<'a>(
&'a self,
plan: &'a InterfacePlan,
) -> 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<(), OverlayError>>;
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
fn v6(last: u16) -> Cidr {
Cidr {
addr: IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, last)),
prefix_len: 64,
}
}
fn v4(last: u8) -> Cidr {
Cidr {
addr: IpAddr::V4(Ipv4Addr::new(10, 13, 37, last)),
prefix_len: 24,
}
}
fn plan() -> InterfacePlan {
InterfacePlan::new("tsuntest", 1280, vec![v6(1), v4(69)])
}
#[test]
fn an_absent_interface_is_created_and_fully_configured() {
let changes = plan_changes(&InterfaceState::absent(), &plan(), false).unwrap();
assert!(changes.create_link);
assert!(!changes.delete_link);
assert_eq!(changes.set_mtu, Some(1280));
assert!(changes.bring_up);
assert_eq!(changes.add, vec![v4(69), v6(1)]);
assert!(changes.remove.is_empty());
}
#[test]
fn an_abandoned_tun_from_a_crashed_run_is_replaced() {
// The footprint of a run that died: the interface is still there, it
// still has its old addresses, and nothing holds it open.
let current = InterfaceState {
kind: LinkKind::Tun,
attached: false,
up: true,
mtu: 1280,
addresses: vec![v4(178)],
};
let changes = plan_changes(&current, &plan(), false).unwrap();
assert!(changes.delete_link, "the stale interface goes");
assert!(changes.create_link);
// Replacing it discards the stale address, so it need not be removed
// one by one.
assert_eq!(changes.add, vec![v4(69), v6(1)]);
assert!(changes.remove.is_empty());
}
#[test]
fn a_foreign_interface_is_never_touched() {
let current = InterfaceState {
kind: LinkKind::Foreign("bridge".into()),
attached: true,
up: true,
mtu: 1500,
addresses: vec![v4(1)],
};
let err = plan_changes(&current, &plan(), false).unwrap_err();
let message = err.to_string();
assert!(message.contains("bridge"), "{message}");
assert!(message.contains("Refusing to touch it"), "{message}");
}
#[test]
fn a_tun_another_process_is_using_is_never_deleted() {
let current = InterfaceState {
kind: LinkKind::Tun,
attached: true,
up: true,
mtu: 1280,
addresses: vec![v6(1)],
};
let err = plan_changes(&current, &plan(), false).unwrap_err();
assert!(err.to_string().contains("another process"), "{err}");
}
#[test]
fn our_own_interface_is_adjusted_in_place() {
// The live case: our address allocation changed while running. The
// interface must not be recreated, or every tunnel on it would drop.
let current = InterfaceState {
kind: LinkKind::Tun,
attached: true,
up: true,
mtu: 1280,
addresses: vec![v6(1), v4(178)],
};
let changes = plan_changes(&current, &plan(), true).unwrap();
assert!(!changes.delete_link && !changes.create_link);
assert_eq!(changes.add, vec![v4(69)]);
assert_eq!(changes.remove, vec![v4(178)]);
}
#[test]
fn reconciling_an_interface_that_already_matches_changes_nothing() {
let current = InterfaceState {
kind: LinkKind::Tun,
attached: true,
up: true,
mtu: 1280,
addresses: vec![v4(69), v6(1)],
};
let changes = plan_changes(&current, &plan(), true).unwrap();
assert!(changes.is_empty(), "{changes:?}");
assert_eq!(changes.summary(), "already as planned");
}
#[test]
fn a_wrong_mtu_or_a_down_link_is_corrected() {
let current = InterfaceState {
kind: LinkKind::Tun,
attached: true,
up: false,
mtu: 1500,
addresses: vec![v4(69), v6(1)],
};
let changes = plan_changes(&current, &plan(), true).unwrap();
assert_eq!(changes.set_mtu, Some(1280));
assert!(changes.bring_up);
assert!(changes.add.is_empty() && changes.remove.is_empty());
}
#[test]
fn a_plan_deduplicates_and_orders_its_addresses() {
let plan = InterfacePlan::new("x", 1280, vec![v6(1), v4(69), v6(1)]);
assert_eq!(plan.addresses, vec![v4(69), v6(1)]);
}
}
@@ -0,0 +1,222 @@
//! Holding `CAP_NET_ADMIN` for as short a time as possible.
//!
//! Creating a TUN interface and assigning addresses to it needs
//! `CAP_NET_ADMIN`, and there is no way around that on Linux. What *is* in
//! our control is how long the process can actually use it.
//!
//! Linux splits capabilities into sets. The *permitted* set is what a process
//! may use; the *effective* set is what it may use **right now**. A process
//! can lower a capability out of effective and raise it back later, but it
//! can never add to permitted. So the agent keeps `CAP_NET_ADMIN` out of the
//! effective set and raises it only around the handful of netlink calls that
//! need it, which is a few milliseconds at startup and again whenever the
//! address allocation changes.
//!
//! Grant it with:
//!
//! ```text
//! sudo setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsng
//! ```
//!
//! `+p` rather than `+ep`: with `+p` the capability is permitted but not
//! effective at exec, which is exactly the resting state this module wants.
//! `+ep` also works — [`NetAdmin::acquire`] lowers it on the way in.
//!
//! # Capabilities are per thread
//!
//! `capset` affects the calling thread only, so raising one inside an async
//! block would be a bug the moment the task migrated to another worker. Every
//! caller here runs on a single dedicated thread; see
//! [`linux`](super::linux).
/// Whether this process can configure interfaces.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Privilege {
/// `CAP_NET_ADMIN` is available. The agent manages the interface itself.
Available,
/// It is not, with a description of what was found.
Missing(String),
/// This platform has no provisioner yet.
Unsupported,
}
impl Privilege {
/// Whether interfaces can be managed.
pub fn is_available(&self) -> bool {
matches!(self, Privilege::Available)
}
/// 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,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."
)
}
}
#[cfg(all(feature = "tun-device", target_os = "linux"))]
pub use linux_impl::{NetAdmin, probe_net_admin};
#[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(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;
/// Whether this process can configure interfaces.
pub fn probe_net_admin() -> Privilege {
Privilege::Unsupported
}
}
#[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::overlay::OverlayError;
/// Whether this thread holds `CAP_NET_ADMIN` in its permitted set.
pub fn probe_net_admin() -> Privilege {
match caps::has_cap(None, CapSet::Permitted, Capability::CAP_NET_ADMIN) {
Ok(true) => Privilege::Available,
Ok(false) => Privilege::Missing(
"this process does not hold CAP_NET_ADMIN, so it cannot create \
or configure a network interface"
.to_string(),
),
Err(err) => {
Privilege::Missing(format!("cannot read this process's capabilities: {err}"))
}
}
}
/// `CAP_NET_ADMIN`, raised for as long as this value is alive.
///
/// Dropping it lowers the capability again, including on the error paths,
/// which is the point of it being a guard rather than a pair of calls.
#[derive(Debug)]
pub struct NetAdmin {
/// Whether this guard is the one that raised it, and so the one that
/// must lower it. Nested acquisition leaves the inner guard inert.
raised: bool,
}
impl NetAdmin {
/// Raises `CAP_NET_ADMIN` into the effective set.
pub fn acquire() -> Result<Self, OverlayError> {
let already = caps::has_cap(None, CapSet::Effective, Capability::CAP_NET_ADMIN)
.map_err(|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| {
OverlayError::Unavailable(format!(
"cannot raise CAP_NET_ADMIN: {err}. {}",
Privilege::how_to_grant("tsng")
))
})?;
Ok(Self { raised: true })
}
/// Lowers `CAP_NET_ADMIN` out of the effective set of this thread.
///
/// Called on the way in as well as on the way out, so that a binary
/// granted `cap_net_admin+ep` — which starts with it effective — still
/// spends almost all of its life unable to use it.
pub fn lower() {
let _ = caps::drop(None, CapSet::Effective, Capability::CAP_NET_ADMIN);
}
}
impl Drop for NetAdmin {
fn drop(&mut self) {
if self.raised {
Self::lower();
}
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn the_grant_instructions_name_the_program() {
let text = Privilege::how_to_grant("/usr/local/bin/tsng");
assert!(text.contains("setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsng"));
assert!(text.contains("--no-tun"), "the fallback is offered too");
}
#[test]
fn probing_says_something_definite_about_this_host() {
// Whatever the answer is, it must be one of the three, and a missing
// capability must come with a reason rather than a bare `false`.
match probe_net_admin() {
Privilege::Available => {}
Privilege::Missing(reason) => assert!(!reason.is_empty()),
Privilege::Unsupported => {}
}
}
}
@@ -0,0 +1,80 @@
//! The provisioner for platforms that do not have one yet.
//!
//! macOS and Windows both need real work here — `utun` plus the
//! `SystemConfiguration` framework on one, the IP Helper API and a Wintun
//! adapter on the other — and neither is written. Rather than let the agent
//! come up and fail obscurely at the first packet, this refuses at the point
//! of provisioning and says what to do instead.
use crate::BoxFuture;
use crate::overlay::OverlayError;
use super::{InterfacePlan, InterfaceProvisioner, Provisioned};
/// Refuses to provision, with an explanation.
#[derive(Debug, Clone)]
pub struct UnsupportedProvisioner {
platform: &'static str,
}
impl Default for UnsupportedProvisioner {
fn default() -> Self {
Self::new()
}
}
impl UnsupportedProvisioner {
/// A provisioner naming the platform it is standing in for.
pub fn new() -> Self {
Self {
platform: std::env::consts::OS,
}
}
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.",
self.platform
))
}
}
impl InterfaceProvisioner for UnsupportedProvisioner {
fn name(&self) -> &str {
"unsupported"
}
fn reconcile<'a>(
&'a self,
_plan: &'a InterfacePlan,
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
Box::pin(async move { Err(self.refusal()) })
}
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(()) })
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[tokio::test]
async fn it_refuses_with_a_usable_pointer_and_still_cleans_up_quietly() {
let provisioner = UnsupportedProvisioner::new();
let plan = InterfacePlan::new("tsuntest", 1280, Vec::new());
let err = provisioner.reconcile(&plan).await.unwrap_err();
let message = err.to_string();
assert!(message.contains("--no-tun"), "{message}");
assert!(message.contains(std::env::consts::OS), "{message}");
provisioner.remove("tsuntest").await.unwrap();
}
}
@@ -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:?}");
}
}
+974
View File
@@ -0,0 +1,974 @@
//! 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,
}
/// A network's exit-node settings, as far as packets are concerned.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ExitPolicy {
/// This agent offers itself as an exit node here: packets from members
/// for addresses outside every overlay range are handed to the host.
pub offer: bool,
/// The member all other traffic is sent to, if this device uses one.
pub via: Option<EndpointId>,
}
/// What one network contributes to the table.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NetworkRoutes {
/// Exit-node settings of this network.
pub exit: ExitPolicy,
/// 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: stop the other network before starting this one."
)]
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)));
}
}
/// Replaces only a network's exit-node settings, without changing addresses.
///
/// A network that has no entry yet (its range is not agreed) takes them
/// from [`NetworkRoutes::exit`] when it is added.
pub fn set_exit(&self, network: NetworkId, policy: ExitPolicy) {
if let Some(routes) = self.write().get_mut(&network) {
routes.exit = policy;
}
}
/// 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.
///
/// An address nobody holds, outside every overlay range and an ordinary
/// unicast destination, goes to this device's exit node when it has one:
/// that is what sending *all* traffic through a member means.
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,
});
}
}
// Inside an overlay range, an unowned address is nobody's, not the
// internet's.
if !is_internet_destination(destination) || in_any_range(&networks, destination) {
return None;
}
exit_via(&networks).map(|(network, peer)| Route { network, peer })
}
/// Whether a peer may send from a source address.
///
/// A peer speaks only for the address the network agreed it holds. The
/// one exception is this device's exit node: what comes back from the
/// internet carries the internet's addresses, so from *that* peer any
/// source outside the overlay ranges is accepted.
pub fn may_send_from(&self, network: NetworkId, peer: EndpointId, source: Ipv4Addr) -> bool {
let networks = self.read();
let Some(routes) = networks.get(&network) else {
return false;
};
if routes
.peers
.iter()
.any(|(address, holder)| *address == source && *holder == peer)
{
return true;
}
routes.exit.via == Some(peer)
&& is_internet_destination(source)
&& !in_any_range(&networks, source)
}
/// Whether this agent, as an exit node of `network`, takes a member's
/// packet for `destination` to the host.
///
/// Only an ordinary unicast address outside every overlay range, so a
/// packet for another member, for this agent or for the local network
/// segment never leaves by this door. The sender's own source address is
/// checked separately, by [`Self::may_send_from`].
pub fn accepts_exit_traffic(&self, network: NetworkId, destination: Ipv4Addr) -> bool {
let networks = self.read();
networks
.get(&network)
.is_some_and(|routes| routes.exit.offer)
&& is_internet_destination(destination)
&& !in_any_range(&networks, destination)
}
/// The network and member this device sends its internet traffic through.
///
/// One default route, so one answer; if several networks claim one (they
/// should not) the lowest network id decides, so two runs agree.
pub fn exit_via(&self) -> Option<(NetworkId, EndpointId)> {
exit_via(&self.read())
}
/// Networks that offer this agent as an exit node, with the range each
/// agreed. A network that has not agreed one yet has nothing to
/// masquerade and is left out.
pub fn exit_offers(&self) -> Vec<(NetworkId, Ipv4Range)> {
let mut offers: Vec<(NetworkId, Ipv4Range)> = self
.read()
.iter()
.filter(|(_, routes)| routes.exit.offer)
.filter_map(|(network, routes)| Some((*network, routes.range?)))
.collect();
offers.sort_by_key(|(network, _)| *network);
offers
}
/// The range of every network in the table that has agreed one, sorted.
pub fn overlay_ranges(&self) -> Vec<Ipv4Range> {
let mut ranges: Vec<Ipv4Range> = self
.read()
.values()
.filter_map(|routes| routes.range)
.collect();
ranges.sort_by_key(|range| (range.base, range.prefix_len));
ranges.dedup();
ranges
}
/// One network's exit-node settings and its range, if it is in the table.
pub fn exit_settings(&self, network: NetworkId) -> Option<(ExitPolicy, Option<Ipv4Range>)> {
self.read()
.get(&network)
.map(|routes| (routes.exit, routes.range))
}
/// 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)
})
}
/// All active networks whose reserved ranges intersect this range.
pub fn overlapping_networks(
&self,
network: NetworkId,
range: Ipv4Range,
) -> Vec<(NetworkId, Ipv4Range)> {
let mut conflicts: Vec<_> = self
.read()
.iter()
.filter_map(|(other, routes)| {
let other_range = routes.range?;
(*other != network && ranges_overlap(range, other_range))
.then_some((*other, other_range))
})
.collect();
conflicts.sort_by_key(|(id, _)| *id);
conflicts
}
/// Current signed allocations used for encrypted data delivery checks.
pub(crate) fn probe_addresses(
&self,
network: NetworkId,
peer: EndpointId,
) -> Option<(Ipv4Addr, Ipv4Addr)> {
let table = self.read();
let routes = table.get(&network)?;
Some((
routes.local?,
routes
.peers
.iter()
.find(|(_, holder)| *holder == peer)
.map(|(address, _)| *address)?,
))
}
/// 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 an address is an ordinary unicast one: not this host, not a
/// multicast or broadcast group, not link-local, not unspecified.
fn is_internet_destination(address: Ipv4Addr) -> bool {
!(address.is_unspecified()
|| address.is_loopback()
|| address.is_multicast()
|| address.is_broadcast()
|| address.is_link_local())
}
/// Whether an address lies in any network's range.
fn in_any_range(networks: &HashMap<NetworkId, NetworkRoutes>, address: Ipv4Addr) -> bool {
networks
.values()
.any(|routes| routes.range.is_some_and(|range| range.contains(address)))
}
/// The exit node in use, if any, from the lowest network that names one.
fn exit_via(networks: &HashMap<NetworkId, NetworkRoutes>) -> Option<(NetworkId, EndpointId)> {
networks
.iter()
.filter_map(|(network, routes)| Some((*network, routes.exit.via?)))
.min_by_key(|(network, _)| *network)
}
/// 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 {
exit: Default::default(),
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("stop the other network"), "{err}");
assert_eq!(table.len(), 1, "the overlapping network is not added");
// A range of its own is fine.
table
.set_network(
second,
NetworkRoutes {
exit: Default::default(),
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 {
exit: Default::default(),
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 {
exit: Default::default(),
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 {
exit: Default::default(),
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 {
exit: Default::default(),
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");
}
fn with_exit(offer: bool, via: Option<EndpointId>) -> NetworkRoutes {
NetworkRoutes {
exit: ExitPolicy { offer, via },
..routes()
}
}
const INTERNET: Ipv4Addr = Ipv4Addr::new(93, 184, 216, 34);
#[test]
fn without_an_exit_node_the_internet_has_nowhere_to_go() {
let table = RoutingTable::new();
table.set_network(network("plain"), routes()).unwrap();
assert_eq!(table.route(INTERNET), None);
assert_eq!(table.exit_via(), None);
}
#[test]
fn with_an_exit_node_every_ordinary_destination_goes_to_it() {
let table = RoutingTable::new();
let id = network("client");
table
.set_network(id, with_exit(false, Some(peer(2))))
.unwrap();
let exit = Route {
network: id,
peer: peer(2),
};
assert_eq!(table.route(INTERNET), Some(exit));
assert_eq!(table.route(Ipv4Addr::new(192, 168, 1, 10)), Some(exit));
assert_eq!(table.exit_via(), Some((id, peer(2))));
// A member's own address still goes to that member.
assert_eq!(
table.route(addr(3)),
Some(Route {
network: id,
peer: peer(3)
})
);
}
#[test]
fn the_exit_node_is_never_the_answer_for_what_is_not_the_internet() {
let table = RoutingTable::new();
table
.set_network(network("client"), with_exit(false, Some(peer(2))))
.unwrap();
// Ourselves, a free address inside the overlay range, and everything
// that is not an ordinary unicast destination.
for destination in [
addr(1),
addr(200),
Ipv4Addr::new(127, 0, 0, 1),
Ipv4Addr::new(224, 0, 0, 251),
Ipv4Addr::new(239, 255, 255, 250),
Ipv4Addr::BROADCAST,
Ipv4Addr::UNSPECIFIED,
Ipv4Addr::new(169, 254, 1, 1),
] {
assert_eq!(table.route(destination), None, "{destination}");
}
}
#[test]
fn another_networks_range_is_not_the_internet_either() {
let table = RoutingTable::new();
table
.set_network(network("client"), with_exit(false, Some(peer(2))))
.unwrap();
table
.set_network(
network("other"),
NetworkRoutes {
range: Some("10.99.0.0/16".parse().unwrap()),
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
..Default::default()
},
)
.unwrap();
assert_eq!(table.route(Ipv4Addr::new(10, 99, 7, 7)), None);
}
#[test]
fn two_exit_choices_resolve_to_the_lowest_network_id() {
let table = RoutingTable::new();
let (a, b) = (network("one"), network("two"));
table
.set_network(a, with_exit(false, Some(peer(2))))
.unwrap();
table
.set_network(
b,
NetworkRoutes {
exit: ExitPolicy {
offer: false,
via: Some(peer(9)),
},
range: Some("10.99.0.0/16".parse().unwrap()),
..Default::default()
},
)
.unwrap();
let (chosen, _) = table.exit_via().unwrap();
assert_eq!(chosen, a.min(b));
assert_eq!(table.route(INTERNET).unwrap().network, a.min(b));
}
#[test]
fn the_exit_peer_may_send_from_any_internet_address_but_nobody_else_may() {
let table = RoutingTable::new();
let id = network("client");
table
.set_network(id, with_exit(false, Some(peer(2))))
.unwrap();
// What comes back from the internet carries the internet's addresses.
assert!(table.may_send_from(id, peer(2), INTERNET));
// Another member still speaks only for its own address.
assert!(!table.may_send_from(id, peer(3), INTERNET));
assert!(table.may_send_from(id, peer(3), addr(3)));
// The exit peer cannot pass itself off as somebody else in the
// overlay, nor as this agent, nor as something that is not a source.
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(200)));
for source in [
Ipv4Addr::LOCALHOST,
Ipv4Addr::UNSPECIFIED,
Ipv4Addr::new(224, 0, 0, 1),
Ipv4Addr::BROADCAST,
] {
assert!(!table.may_send_from(id, peer(2), source), "{source}");
}
// Without choosing an exit node nobody gets the exception.
let plain = RoutingTable::new();
plain.set_network(id, routes()).unwrap();
assert!(!plain.may_send_from(id, peer(2), INTERNET));
}
#[test]
fn an_exit_node_takes_a_members_internet_packets_only_while_it_offers() {
let table = RoutingTable::new();
let id = network("server");
table.set_network(id, with_exit(false, None)).unwrap();
assert!(!table.accepts_exit_traffic(id, INTERNET));
table.set_exit(
id,
ExitPolicy {
offer: true,
via: None,
},
);
assert!(table.accepts_exit_traffic(id, INTERNET));
assert!(table.accepts_exit_traffic(id, Ipv4Addr::new(192, 168, 1, 10)));
// Never for another member, for this agent, a free overlay address,
// or anything that is not an ordinary unicast destination.
for destination in [
addr(1),
addr(2),
addr(200),
Ipv4Addr::LOCALHOST,
Ipv4Addr::new(224, 0, 0, 251),
Ipv4Addr::BROADCAST,
Ipv4Addr::UNSPECIFIED,
] {
assert!(
!table.accepts_exit_traffic(id, destination),
"{destination}"
);
}
// An unknown network offers nothing.
assert!(!table.accepts_exit_traffic(network("elsewhere"), INTERNET));
table.set_exit(id, ExitPolicy::default());
assert!(!table.accepts_exit_traffic(id, INTERNET));
}
#[test]
fn offers_are_listed_by_network_with_the_range_each_agreed() {
let table = RoutingTable::new();
let (a, b) = (network("a"), network("b"));
table.set_network(a, with_exit(true, None)).unwrap();
table
.set_network(
b,
NetworkRoutes {
exit: ExitPolicy {
offer: true,
via: None,
},
// No range agreed yet: nothing to masquerade.
..Default::default()
},
)
.unwrap();
assert_eq!(
table.exit_offers(),
vec![(a, "10.13.37.0/24".parse().unwrap())]
);
assert_eq!(
table.exit_settings(a),
Some((
ExitPolicy {
offer: true,
via: None
},
Some("10.13.37.0/24".parse().unwrap())
))
);
assert_eq!(table.exit_settings(network("none")), None);
}
#[test]
fn setting_exit_for_a_network_with_no_entry_is_remembered_by_the_caller() {
// The runtime keeps the policy and passes it with the network when
// its range is agreed; a setter for a network the table does not know
// yet changes nothing.
let table = RoutingTable::new();
table.set_exit(
network("later"),
ExitPolicy {
offer: true,
via: None,
},
);
assert!(table.is_empty());
}
}
+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 `tsng up` there."
} else if cfg!(target_os = "linux") {
"Start `tsng 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 `tsng up` \
with `sudo`, or install it as a root LaunchDaemon."
} else {
"Start `tsng 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("tsng 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.
File renamed without changes.
@@ -20,7 +20,7 @@ use crate::error::ProtocolError;
/// The version in the ALPN is the wire-compatibility version of the control
/// protocol. It is independent of the network identity scheme version, so
/// bumping it must not change any existing [`crate::NetworkId`].
pub const ALPN: &[u8] = b"tsunagi/ctrl/1";
pub const ALPN: &[u8] = b"tsunagi/ctrl/6";
/// ALPN of the tsunagi data plane.
///
@@ -28,13 +28,24 @@ pub const ALPN: &[u8] = b"tsunagi/ctrl/1";
/// They carry one IP plugin's packets for one network and nothing else, so a
/// saturated or broken data plane cannot disturb control traffic, and the
/// transport underneath can be replaced without touching the control protocol.
pub const DATA_ALPN: &[u8] = b"tsunagi/data/1";
///
/// Version 4 adds source, destination, hop limit and stable flow id above
/// transport fragmentation. Version 5 adds the end-to-end protocol to that
/// envelope, so a tunnel no longer depends on the kind of link under it, and
/// a first-claimed time to a signed hostname claim. All members must upgrade
/// together. Persistent identities and network configuration do not change.
pub const DATA_ALPN: &[u8] = b"tsunagi/data/5";
/// Largest plugin protocol identifier accepted when opening a data channel.
pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
/// Largest accepted signature on a signed record, in bytes.
pub const MAX_SIGNATURE_LEN: usize = 64;
/// Control protocol version carried inside the handshake.
pub const PROTOCOL_VERSION: u16 = 1;
pub const PROTOCOL_VERSION: u16 = 6;
pub use crate::dataplane::transport::{MAX_TRANSPORT_PAYLOAD, TransportPayload};
/// First message of the handshake, sent by the initiator.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -66,6 +77,13 @@ pub struct AuthProof {
/// What this agent tells a peer about itself.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Announcement {
/// Local opt-in to receive LAN broadcasts in this network.
pub broadcast: bool,
/// This agent offers itself as an exit node in this network: members may
/// send all their internet traffic through it. Off unless its owner
/// turned it on, and only said once the rules that make it work are in
/// place.
pub exit_node: bool,
/// Human-readable hostname. A mutable binding, not an identity.
pub hostname: String,
/// Announced IP plugin capabilities. Opaque to the core.
@@ -78,6 +96,8 @@ pub struct Announcement {
pub struct DataOpen {
/// Which IP plugin's packets this channel will carry.
pub protocol: String,
/// Maximum reassembled payload the initiator is willing to receive.
pub max_datagram: u32,
}
/// The responder's answer to [`DataOpen`].
@@ -96,6 +116,13 @@ pub struct DataOpenAck {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ControlMessage {
/// Private transport negotiation; never persisted or rebroadcast.
Transport {
/// Transport protocol id; the payload stays opaque to the core.
protocol: String,
/// Secret negotiation data, redacted in diagnostics.
payload: TransportPayload,
},
/// Hostname and capability announcement.
Announce(Announcement),
/// A small request used to verify that the exchange works.
@@ -112,6 +139,41 @@ pub enum ControlMessage {
/// Echoed payload.
payload: Vec<u8>,
},
/// A snapshot of signed records this agent holds for the network.
///
/// A snapshot is merged into what the receiver already has, never
/// substituted for it: an author missing from the batch is left alone,
/// because absence is not deletion.
State {
/// The records. Bounded by [`crate::config::Limits::max_state_records`].
records: Vec<crate::state::SignedRecord>,
},
/// Members this sender knows of, and where it has seen them.
///
/// An introduction, not a vouching: everything here is an unverified
/// candidate, and membership is still decided by the handshake. It is
/// what turns a star around whoever was named on the command line into
/// a mesh — a device that was told about one member ends up talking to
/// all of them — and it is the same shape a lookup in a distributed
/// hash table would return.
Peers {
/// The members, with whatever addresses the sender has for them.
peers: Vec<PeerHint>,
},
/// Which peers this sender has a live data link with, right now.
///
/// First-hand and nothing else: a sender speaks only for itself, never
/// about what somebody else can reach. That is what makes it usable
/// without weighing hearsay — the claim is proved or disproved by
/// sending through it.
///
/// A snapshot rather than a change, because a snapshot is idempotent
/// and repairs itself; and volatile, so it lives here and not in
/// signed state, which is for what has to survive a member being away.
Reachable {
/// Direct links, scoped to the plugin protocol they actually carry.
links: Vec<ReachableLink>,
},
/// Graceful goodbye.
///
/// A peer going away is not a revocation of anything.
@@ -121,6 +183,38 @@ pub enum ControlMessage {
},
}
/// Somewhere a member has been seen.
///
/// Addresses are in iroh's own text form, which is what the endpoint takes
/// back: this is a hint to try, never a fact about where anybody is.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerHint {
/// The member's endpoint key.
pub endpoint: [u8; 32],
/// Where the sender has seen it, as `ip:<socket>` or `relay:<url>`.
///
/// The same spelling the disposable cache uses, so one decoder serves
/// both and neither can drift from the other. An empty list is still
/// worth sending: an id alone is enough for a receiver whose endpoint
/// can resolve it.
pub addrs: Vec<String>,
}
/// One directly observed edge, advertised only by its authenticated source.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ReachableLink {
/// The other end of this transport link.
pub peer: [u8; 32],
/// Protocol carried by the link.
pub protocol: String,
}
/// Longest address text accepted in a hint.
pub const MAX_HINT_ADDR_LEN: usize = 128;
/// Most addresses accepted for one member in a hint.
pub const MAX_HINT_ADDRS: usize = 8;
/// A control message together with the network it belongs to.
///
/// Every session is bound to exactly one network at handshake time. The
@@ -179,6 +273,39 @@ pub fn validate_capability(
/// network, the other networks or the agent.
pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), ProtocolError> {
match message {
ControlMessage::Transport { protocol, payload } => {
if protocol.is_empty() {
return Err(ProtocolError::Malformed("empty transport protocol"));
}
check_len("transport.protocol", protocol.len(), MAX_PROTOCOL_ID_LEN)?;
check_len(
"transport.payload",
payload.as_bytes().len(),
limits.max_transport_payload_len.min(MAX_TRANSPORT_PAYLOAD),
)?;
}
ControlMessage::Peers { peers } => {
check_len("peers", peers.len(), limits.max_state_records)?;
for hint in peers {
check_len("peers.addrs", hint.addrs.len(), MAX_HINT_ADDRS)?;
for addr in &hint.addrs {
check_len("peers.addr", addr.len(), MAX_HINT_ADDR_LEN)?;
}
}
}
ControlMessage::Reachable { links } => {
check_len("reachable.links", links.len(), limits.max_state_records)?;
for link in links {
if link.protocol.is_empty() {
return Err(ProtocolError::Malformed("empty routing protocol"));
}
check_len(
"reachable.protocol",
link.protocol.len(),
MAX_PROTOCOL_ID_LEN,
)?;
}
}
ControlMessage::Announce(announcement) => {
check_len(
"announce.hostname",
@@ -197,6 +324,12 @@ pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), Protoco
ControlMessage::Ping { payload, .. } | ControlMessage::Pong { payload, .. } => {
check_len("echo.payload", payload.len(), limits.max_echo_payload_len)?;
}
ControlMessage::State { records } => {
check_len("state.records", records.len(), limits.max_state_records)?;
for record in records {
check_len("state.signature", record.signature.len(), MAX_SIGNATURE_LEN)?;
}
}
ControlMessage::Bye { reason } => {
check_len("bye.reason", reason.len(), limits.max_reason_len)?;
}
@@ -207,9 +340,13 @@ pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), Protoco
/// A short, stable label for a message kind, for metrics and diagnostics.
pub fn kind(message: &ControlMessage) -> &'static str {
match message {
ControlMessage::Transport { .. } => "transport",
ControlMessage::Announce(_) => "announce",
ControlMessage::Ping { .. } => "ping",
ControlMessage::Pong { .. } => "pong",
ControlMessage::State { .. } => "state",
ControlMessage::Reachable { .. } => "reachable",
ControlMessage::Peers { .. } => "peers",
ControlMessage::Bye { .. } => "bye",
}
}
File renamed without changes.
+284
View File
@@ -0,0 +1,284 @@
//! Choosing a free overlay address.
//!
//! Allocation, not derivation. Derivation needs no coordination but cannot
//! avoid collisions in a space as small as IPv4; allocation avoids them but
//! has to look at what everybody else already holds. The signed records in
//! [`super`] are what makes that possible without a coordinator.
//!
//! The rules:
//!
//! * an address a participant already holds is kept, because stability across
//! an absence is the whole point;
//! * otherwise the search starts at a position derived from the participant's
//! own identity, so two participants joining at once rarely start in the
//! same place;
//! * the search then walks the range, so a free address is found whenever one
//! exists.
use std::collections::HashSet;
use std::net::Ipv4Addr;
use iroh::EndpointId;
use sha2::{Digest, Sha256};
use super::Ipv4Range;
use crate::identity::NetworkId;
/// Why no address could be allocated.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum AllocationError {
/// Every address in the range is taken.
#[error("the overlay range {range} is full: {holders} of {usable} addresses are taken")]
RangeFull {
/// The range that is full.
range: Ipv4Range,
/// How many are held.
holders: usize,
/// How many the range has.
usable: u64,
},
/// The range has no usable host addresses.
#[error("the overlay range {range} has no room for hosts")]
NoRoom {
/// The offending range.
range: Ipv4Range,
},
}
/// How many host addresses a range holds, excluding network and broadcast.
pub fn usable_addresses(range: Ipv4Range) -> u64 {
let host_bits = 32u32.saturating_sub(u32::from(range.prefix_len));
if host_bits < 2 {
return 0;
}
(1u64 << host_bits) - 2
}
/// The nth host address of a range.
fn address_at(range: Ipv4Range, offset: u64) -> Ipv4Addr {
let host_bits = 32u32.saturating_sub(u32::from(range.prefix_len));
let mask = if host_bits >= 32 {
0
} else {
u32::MAX << host_bits
};
let network_part = u32::from(range.base) & mask;
// Offsets run 1..=usable, so the network address is never handed out.
Ipv4Addr::from(network_part | ((offset % (1u64 << host_bits)) as u32))
}
/// Picks an address for `author`, keeping `current` if it is still usable.
///
/// `taken` is what every other participant is known to hold.
pub fn allocate(
network: NetworkId,
author: EndpointId,
range: Ipv4Range,
taken: &HashSet<Ipv4Addr>,
current: Option<Ipv4Addr>,
) -> Result<Ipv4Addr, AllocationError> {
let usable = usable_addresses(range);
if usable == 0 {
return Err(AllocationError::NoRoom { range });
}
// Keeping what we already hold is what lets a participant come back to
// the same address after any length of absence.
if let Some(current) = current
&& range.contains(current)
&& !taken.contains(&current)
{
return Ok(current);
}
// Start somewhere derived from who we are, so two newcomers do not both
// begin at the first address and collide every time.
let mut hash = Sha256::new();
hash.update(b"tsunagi-ipv4-allocation-v1");
hash.update(network.as_bytes());
hash.update(author.as_bytes());
let digest = hash.finalize();
let seed = u64::from_be_bytes([
digest[0], digest[1], digest[2], digest[3], digest[4], digest[5], digest[6], digest[7],
]);
for step in 0..usable {
let offset = ((seed.wrapping_add(step)) % usable) + 1;
let candidate = address_at(range, offset);
if !taken.contains(&candidate) {
return Ok(candidate);
}
}
Err(AllocationError::RangeFull {
range,
holders: taken.len(),
usable,
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
use iroh::SecretKey;
fn network(name: &str) -> NetworkId {
NetworkKeys::derive(
&NetworkName::new(name).unwrap(),
&NetworkSecret::from_bytes(vec![7u8; 32]).unwrap(),
)
.network_id()
}
fn slash24() -> Ipv4Range {
"10.13.37.0/24".parse().unwrap()
}
#[test]
fn a_range_reports_its_usable_size() {
assert_eq!(usable_addresses(slash24()), 254);
assert_eq!(usable_addresses("10.0.0.0/16".parse().unwrap()), 65534);
assert_eq!(usable_addresses("10.0.0.0/30".parse().unwrap()), 2);
// The type refuses a /31, but the function is defensive anyway.
assert!("10.0.0.0/31".parse::<Ipv4Range>().is_err());
assert_eq!(
usable_addresses(Ipv4Range {
base: "10.0.0.0".parse().unwrap(),
prefix_len: 31
}),
0
);
}
#[test]
fn an_allocated_address_is_inside_the_range_and_not_its_edges() {
let id = network("inside");
let taken = HashSet::new();
for _ in 0..64 {
let author = SecretKey::generate().public();
let address = allocate(id, author, slash24(), &taken, None).unwrap();
assert!(slash24().contains(address));
assert_ne!(address.octets()[3], 0, "never the network address");
assert_ne!(address.octets()[3], 255, "never the broadcast address");
}
}
#[test]
fn an_address_already_held_is_kept() {
let id = network("sticky");
let author = SecretKey::generate().public();
let mine: Ipv4Addr = "10.13.37.42".parse().unwrap();
// This is what lets a participant return to the same address.
let taken = HashSet::new();
assert_eq!(
allocate(id, author, slash24(), &taken, Some(mine)).unwrap(),
mine
);
// Unless somebody else took it while we were away.
let taken = HashSet::from([mine]);
assert_ne!(
allocate(id, author, slash24(), &taken, Some(mine)).unwrap(),
mine
);
// Or unless the range changed under us.
let elsewhere: Ipv4Range = "10.99.0.0/16".parse().unwrap();
let moved = allocate(id, author, elsewhere, &HashSet::new(), Some(mine)).unwrap();
assert!(elsewhere.contains(moved));
}
#[test]
fn allocation_is_deterministic_and_spread_out() {
let id = network("spread");
// Fixed keys, not random ones. With 40 random authors in a /24 the
// birthday problem alone makes a handful of collisions likely, so a
// threshold on the count was a coin flip rather than a property.
let authors: Vec<_> = (0..40u8)
.map(|seed| SecretKey::from_bytes(&[seed; 32]).public())
.collect();
let first: Vec<_> = authors
.iter()
.map(|author| allocate(id, *author, slash24(), &HashSet::new(), None).unwrap())
.collect();
let again: Vec<_> = authors
.iter()
.map(|author| allocate(id, *author, slash24(), &HashSet::new(), None).unwrap())
.collect();
assert_eq!(first, again, "the same inputs give the same answer");
// Starting points are spread, so concurrent newcomers rarely clash.
let distinct: HashSet<_> = first.iter().collect();
assert!(
distinct.len() >= 35,
"only {} distinct starting points out of 40",
distinct.len()
);
}
#[test]
fn the_search_walks_past_everything_taken() {
let id = network("crowded");
let author = SecretKey::generate().public();
// Everything taken except one address.
let free: Ipv4Addr = "10.13.37.200".parse().unwrap();
let taken: HashSet<Ipv4Addr> = (1..=254u8)
.map(|host| Ipv4Addr::new(10, 13, 37, host))
.filter(|addr| *addr != free)
.collect();
assert_eq!(allocate(id, author, slash24(), &taken, None).unwrap(), free);
}
#[test]
fn a_full_range_is_an_error_rather_than_a_duplicate() {
let id = network("full");
let author = SecretKey::generate().public();
let taken: HashSet<Ipv4Addr> = (1..=254u8)
.map(|host| Ipv4Addr::new(10, 13, 37, host))
.collect();
assert!(matches!(
allocate(id, author, slash24(), &taken, None),
Err(AllocationError::RangeFull { .. })
));
assert!(matches!(
allocate(
id,
author,
Ipv4Range {
base: "10.0.0.0".parse().unwrap(),
prefix_len: 31
},
&HashSet::new(),
None
),
Err(AllocationError::NoRoom { .. })
));
}
#[test]
fn every_address_in_a_small_range_can_be_handed_out() {
let id = network("exhaustive");
let small: Ipv4Range = "10.13.37.0/29".parse().unwrap();
let mut taken = HashSet::new();
let mut handed = Vec::new();
for _ in 0..usable_addresses(small) {
let author = SecretKey::generate().public();
let address = allocate(id, author, small, &taken, None).unwrap();
assert!(taken.insert(address), "handed out {address} twice");
handed.push(address);
}
assert_eq!(handed.len(), 6);
// And then it is genuinely full.
let author = SecretKey::generate().public();
assert!(allocate(id, author, small, &taken, None).is_err());
}
}
File diff suppressed because it is too large. Load diff
File renamed without changes.
File renamed without changes.
@@ -27,6 +27,7 @@ use std::sync::{Arc, Mutex, MutexGuard};
use crate::config::StoragePaths;
use crate::error::{Error, Result};
use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
use crate::state::SignedRecord;
/// Applies the pragmas both stores share.
fn apply_common_pragmas(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
@@ -55,8 +56,13 @@ fn restrict_permissions(_file: &std::fs::File, _path: &Path) -> Result<()> {
Ok(())
}
/// Restricts a file to the user that owns it.
///
/// Part of what the system level offers a protocol: a plugin keeping keys of
/// its own on disk has the same obligation as the agent, and should not have
/// to work out the platform details again to meet it.
#[cfg(unix)]
pub(crate) fn restrict_path_permissions(path: &Path) -> Result<()> {
pub fn restrict_path_permissions(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 {
@@ -66,13 +72,15 @@ pub(crate) fn restrict_path_permissions(path: &Path) -> Result<()> {
})
}
/// Restricts a file to the user that owns it. A no-op off Unix.
#[cfg(not(unix))]
pub(crate) fn restrict_path_permissions(_path: &Path) -> Result<()> {
pub fn restrict_path_permissions(_path: &Path) -> Result<()> {
Ok(())
}
/// Restricts a directory to the user that owns it.
#[cfg(unix)]
pub(crate) fn restrict_dir_permissions(path: &Path) -> Result<()> {
pub fn restrict_dir_permissions(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| {
Error::Io {
@@ -82,12 +90,14 @@ pub(crate) fn restrict_dir_permissions(path: &Path) -> Result<()> {
})
}
/// Restricts a directory to the user that owns it. A no-op off Unix.
#[cfg(not(unix))]
pub(crate) fn restrict_dir_permissions(_path: &Path) -> Result<()> {
pub fn restrict_dir_permissions(_path: &Path) -> Result<()> {
Ok(())
}
pub(crate) fn create_dir(path: &Path) -> Result<()> {
/// Creates a directory and every parent, restricted to this user.
pub fn create_dir(path: &Path) -> Result<()> {
std::fs::create_dir_all(path).map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
@@ -238,16 +248,65 @@ impl Storage {
.await
}
/// Persists local broadcast participation for one configured network.
pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
self.with_state(move |state| state.set_broadcast(network_id, enabled))
.await
}
/// Persists whether this agent offers itself as an exit node in a network.
pub async fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
self.with_state(move |state| state.set_exit_node(network_id, enabled))
.await
}
/// Persists the member this device sends its internet traffic through.
pub async fn set_exit_via(&self, network_id: NetworkId, via: Option<[u8; 32]>) -> Result<()> {
self.with_state(move |state| state.set_exit_via(network_id, via.as_ref()))
.await
}
/// Updates the auto-start flag of a network.
pub async fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
self.with_state(move |state| state.set_auto_start(network_id, auto_start))
.await
}
/// Configured networks in the user's most recently selected order.
pub async fn network_start_order(&self) -> Result<Vec<NetworkId>> {
self.with_state(|state| {
let configured = state.list_networks()?;
let text = state
.get_setting("network_start_order")?
.unwrap_or_default();
let mut order = Vec::new();
for word in text.split(',') {
if let Ok(id) = word.parse::<NetworkId>()
&& configured.iter().any(|n| n.network_id == id)
&& !order.contains(&id)
{
order.push(id);
}
}
Ok(order)
})
.await
}
/// Prefers this network at startup without disabling suspended networks.
pub async fn prefer_network(&self, network: NetworkId) -> Result<()> {
self.with_state(move |state| state.prefer_network(network))
.await
}
/// Removes a network configuration and its cached hints.
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
self.with_state(move |state| state.remove_network(network_id))
.await?;
self.with_state(move |state| {
state.remove_network(network_id)?;
state.forget_signed_records(network_id)?;
state.forget_peer_hostnames(network_id)
})
.await?;
self.with_cache((), move |cache| cache.forget_network(network_id))
.await;
Ok(())
@@ -264,6 +323,26 @@ impl Storage {
.await
}
/// Remembers the hostname a member announced, so it can still be named
/// once it is away.
pub async fn remember_peer_hostname(
&self,
network_id: NetworkId,
endpoint_id: [u8; 32],
hostname: String,
) -> Result<()> {
self.with_state(move |state| {
state.remember_peer_hostname(network_id, &endpoint_id, &hostname)
})
.await
}
/// The hostnames members of a network last announced.
pub async fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
self.with_state(move |state| state.peer_hostnames(network_id))
.await
}
/// Records an address hint. Failures are non-fatal.
pub async fn record_hint(
&self,
@@ -278,6 +357,35 @@ impl Storage {
.await;
}
/// Loads every signed record known for a network.
pub async fn signed_records(&self, network_id: NetworkId) -> Result<Vec<SignedRecord>> {
self.with_state(move |state| state.signed_records(network_id))
.await
}
/// Stores a record received from another replica.
pub async fn put_signed_record(&self, record: SignedRecord) -> Result<()> {
self.with_state(move |state| state.put_signed_record(&record))
.await
}
/// Stores one of this agent's own records and bumps its counter in one
/// transaction, which must happen before the record is announced.
pub async fn publish_own_record(&self, record: SignedRecord) -> Result<()> {
self.with_state(move |state| state.publish_own_record(&record))
.await
}
/// The highest version this agent has ever published for a network.
pub async fn own_record_version(
&self,
network_id: NetworkId,
author: iroh::EndpointId,
) -> Result<u64> {
self.with_state(move |state| state.own_record_version(network_id, author))
.await
}
/// Reads cached address hints. Returns an empty list if the cache is gone.
pub async fn hints_for_network(&self, network_id: NetworkId) -> Vec<AddressHint> {
self.with_cache(Vec::new(), move |cache| cache.hints_for_network(network_id))
@@ -308,3 +416,137 @@ impl Storage {
guard.take();
}
}
/// What a wipe covers: every entry in the two directories.
///
/// Reported before anything is removed, and again afterwards, because a
/// command whose whole job is to destroy state has to be able to say exactly
/// what it is about to destroy.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WipePlan {
/// Entries in the state directory, which is the mandatory state and
/// anything a plugin keeps beside it.
pub state: Vec<std::path::PathBuf>,
/// Entries in the cache directory.
pub cache: Vec<std::path::PathBuf>,
}
impl WipePlan {
/// Whether there is anything to remove at all.
pub fn is_empty(&self) -> bool {
self.state.is_empty() && self.cache.is_empty()
}
/// Every entry, state first.
pub fn entries(&self) -> impl Iterator<Item = &std::path::Path> {
self.state
.iter()
.chain(self.cache.iter())
.map(std::path::PathBuf::as_path)
}
}
/// Lists what [`wipe`] would remove, without removing anything.
pub fn wipe_plan(paths: &StoragePaths) -> Result<WipePlan> {
Ok(WipePlan {
state: removable(&paths.state_dir, &paths.state_db())?,
cache: removable(&paths.cache_dir, &paths.cache_db())?,
})
}
/// Removes everything in both directories, leaving this device a stranger.
///
/// The device identity, every network it belongs to, every signed record and
/// everything a plugin kept beside them. The next start generates a new
/// identity and knows nothing, which is the point: after this there is no
/// membership left to be surprised by.
///
/// What it is **not** is a goodbye. Other members keep the claims this device
/// signed, because signed state has no expiry and there is nobody left here
/// to sign a release. Leaving each network first with
/// [`crate::Agent::leave_network`] is what frees the address and the name.
///
/// Refuses while an agent owns the state directory, and refuses a directory
/// with no `state.sqlite` in it — a wipe pointed at the wrong place by a
/// mistyped `--state-dir` would otherwise remove somebody's documents.
pub fn wipe(paths: &StoragePaths) -> Result<WipePlan> {
let plan = wipe_plan(paths)?;
// Held across the removal, so an agent cannot start into a directory
// that is half gone. The lock file is itself removed at the end: on
// Unix the lock lives on the open handle, not on the name.
let lock = DirectoryLock::acquire(paths.lock_file())?;
for entry in plan.entries() {
remove_entry(entry)?;
}
drop(lock);
// Created by acquiring the lock a moment ago, so it is ours to remove
// and nothing is left behind claiming the directory.
let _ = std::fs::remove_file(paths.lock_file());
Ok(plan)
}
/// Every entry of `dir`, once `marker` proves it is one of ours.
///
/// An empty or missing directory is nothing to remove rather than an error:
/// wiping twice must be as ordinary as wiping once.
fn removable(dir: &Path, marker: &Path) -> Result<Vec<std::path::PathBuf>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
let read = std::fs::read_dir(dir).map_err(|source| Error::Io {
path: dir.to_path_buf(),
source,
})?;
for entry in read {
let entry = entry.map_err(|source| Error::Io {
path: dir.to_path_buf(),
source,
})?;
entries.push(entry.path());
}
if entries.is_empty() {
return Ok(entries);
}
// The marker is what tells a state directory from a directory that
// merely got named as one. The lock file counts too: an agent that
// crashed before writing anything still leaves that.
let ours = marker.exists() || dir.join("state.lock").exists();
if !ours {
return Err(Error::Storage(format!(
"{} does not look like a tsunagi directory: no {} in it. Nothing was removed.",
dir.display(),
marker.file_name().map_or_else(
|| marker.display().to_string(),
|name| name.to_string_lossy().into_owned()
)
)));
}
entries.sort();
Ok(entries)
}
fn remove_entry(path: &Path) -> Result<()> {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
// Gone between the plan and the removal is the outcome asked for.
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(source) => {
return Err(Error::Io {
path: path.to_path_buf(),
source,
});
}
};
let removed = if metadata.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
};
removed.map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})
}
File diff suppressed because it is too large. Load diff
+74
View File
@@ -0,0 +1,74 @@
//! Stopping the tasks an agent started.
//!
//! Shutdown is cooperative first and bounded always: every loop watches a
//! cancellation token, and anything still running when its grace period ends
//! is aborted. Nothing an agent owns may outlive the agent, and no peer may
//! hold shutdown open by refusing to read.
//!
//! Public because a protocol crate spawns tasks of its own and is held to
//! the same rule: the agent gives each plugin a grace period, but abandoning
//! the future it is awaiting does not stop the task behind it.
use std::time::Duration;
use tokio::task::JoinHandle;
/// How long a task gets to notice cancellation before it is cut off.
///
/// Winding down is cooperative first: a task in the middle of a write to a
/// peer gets a moment to finish it. After that it is aborted, because a peer
/// that stops reading must not be able to hold shutdown open.
pub const TASK_GRACE: Duration = Duration::from_secs(5);
/// Waits for `task` to finish, aborting it if it outlasts `grace`.
///
/// Returns whether it finished on its own. `what` names it in the warning,
/// which is the only signal that something was cut off rather than stopped.
pub async fn wind_down(task: JoinHandle<()>, grace: Duration, what: &str) -> bool {
let abort = task.abort_handle();
if tokio::time::timeout(grace, task).await.is_err() {
tracing::warn!(
task = what,
grace_secs = grace.as_secs_f32(),
"task did not wind down in time; aborting it"
);
abort.abort();
return false;
}
true
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[tokio::test]
async fn a_task_that_stops_on_its_own_is_not_aborted() {
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
let task = tokio::spawn(async move {
tx.send(()).await.ok();
});
assert!(wind_down(task, Duration::from_secs(5), "cooperative").await);
assert_eq!(rx.recv().await, Some(()), "it ran to completion");
}
#[tokio::test]
async fn a_task_that_ignores_the_grace_is_aborted() {
// A stand-in for a task blocked writing to a peer that stopped
// reading: it will never finish by itself.
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
let task = tokio::spawn(async move {
let _held = tx;
std::future::pending::<()>().await;
});
let finished = wind_down(task, Duration::from_millis(50), "wedged").await;
assert!(!finished, "it had to be cut off");
// Aborted for real: the task is gone, so what it held is dropped.
let closed = tokio::time::timeout(Duration::from_secs(5), rx.recv()).await;
assert_eq!(closed, Ok(None), "the aborted task dropped its sender");
}
}
@@ -1,26 +1,31 @@
//! Shared helpers for the integration tests.
//! A harness for testing against a real agent.
//!
//! Every test uses real iroh endpoints on loopback, its own temporary SQLite
//! files and independent agent instances. Only discovery is substituted; iroh,
//! the handshake, message passing and persistent storage are not.
//! Behind the `testing` feature, because it is only of use to a test and
//! pulls in `tempfile` and a tracing subscriber. It lives here rather than
//! being copied into each crate's `tests/` directory: a protocol plugin in
//! its own crate needs exactly the same harness, and two copies of it would
//! drift.
//!
//! Synchronisation is always "wait for a specific event or condition, under one
//! overall deadline", never a fixed multi-second sleep.
//! Everything here builds agents that touch nothing outside a temporary
//! directory: loopback only, no relay, no address publication.
#![allow(dead_code, clippy::unwrap_used, clippy::expect_used, clippy::panic)]
// A harness fails a test loudly and at once; that is the whole job. The
// crate forbids these elsewhere for the opposite reason — nothing off the
// network may panic — and this module never sees anything off the network.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::future::Future;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::agent::Event;
use crate::config::{AgentConfig, StoragePaths, TransportPolicy};
use crate::discovery::SharedMemoryDiscovery;
use crate::identity::{NetworkName, NetworkSecret};
use crate::{Agent, Result};
use tempfile::TempDir;
use tokio::sync::broadcast::error::RecvError;
use tsunagi::agent::Event;
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::{NetworkName, NetworkSecret};
use tsunagi::{Agent, Result};
/// Installs a tracing subscriber when `TSUNAGI_TEST_LOG` is set.
///
@@ -50,7 +55,7 @@ const POLL_INTERVAL: Duration = Duration::from_millis(25);
/// port mapping, so the suite needs neither the internet nor privileges.
/// Timeouts are shortened so that failure paths finish quickly.
pub fn local_config(dir: &Path) -> AgentConfig {
let limits = tsunagi::Limits {
let limits = crate::Limits {
dial_timeout: Duration::from_millis(1500),
handshake_timeout: Duration::from_secs(5),
..Default::default()
@@ -69,7 +74,9 @@ pub fn config_with(dir: &Path, discovery: &SharedMemoryDiscovery) -> AgentConfig
/// A temporary directory plus the agent running on it.
pub struct TestAgent {
/// The directory it keeps its state in, removed when this is dropped.
pub dir: TempDir,
/// The agent itself.
pub agent: Agent,
}
@@ -148,14 +155,22 @@ where
{
let deadline = Instant::now() + DEADLINE;
loop {
if let Some(value) = probe().await {
return value;
}
let remaining = deadline
.checked_duration_since(Instant::now())
.unwrap_or_default();
assert!(
Instant::now() < deadline,
!remaining.is_zero(),
"timed out waiting for condition: {what}"
);
tokio::time::sleep(POLL_INTERVAL).await;
// The probe is bounded too. A deadline checked only between probes is
// no deadline at all: one call into a wedged agent that never answers
// would hang the test process for ever instead of failing it.
match tokio::time::timeout(remaining, probe()).await {
Ok(Some(value)) => return value,
Ok(None) => tokio::time::sleep(POLL_INTERVAL).await,
Err(_) => panic!("timed out waiting for condition: {what}"),
}
}
}
@@ -170,7 +185,7 @@ pub async fn settle() {
/// Waits until `agent` has `count` authenticated peers in `network`.
pub async fn wait_for_peers(
agent: &Agent,
network: tsunagi::NetworkId,
network: crate::NetworkId,
count: usize,
) -> Vec<iroh::EndpointId> {
wait_until(&format!("{count} peers in {network}"), || async move {
@@ -183,3 +198,20 @@ pub async fn wait_for_peers(
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(start_paused = true)]
#[should_panic(expected = "timed out waiting for condition")]
async fn a_probe_that_never_answers_fails_the_deadline_instead_of_hanging() {
// Virtual time, so the deadline arrives at once rather than in thirty
// real seconds — and the deadline does arrive, which is the point: a
// probe that never returns used to hang the process instead.
wait_until::<(), _, _>("an answer that never comes", || async {
std::future::pending::<Option<()>>().await
})
.await;
}
}
@@ -8,9 +8,6 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use common::{TestAgent, network, settle, wait_event};
use iroh::endpoint::{Connection, PortmapperConfig, RecvStream, SendStream, presets};
use iroh::{Endpoint, EndpointAddr, RelayMode};
use tsunagi::agent::Event;
@@ -22,6 +19,7 @@ use tsunagi::proto::message::{
};
use tsunagi::proto::{read_frame, write_frame};
use tsunagi::test_support;
use tsunagi::testing::{TestAgent, network, settle, wait_event};
/// A bare iroh endpoint with no tsunagi agent behind it.
async fn raw_endpoint() -> Endpoint {
@@ -2,14 +2,12 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::io::Write;
use common::{TestAgent, config_with, local_config, network, wait_for_peers};
use tsunagi::config::StoragePaths;
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::storage::CacheOutcome;
use tsunagi::testing::{TestAgent, config_with, local_config, network, wait_for_peers};
use tsunagi::{Agent, Error};
/// Overwrites a file with bytes that are definitely not a SQLite database.
@@ -177,6 +175,89 @@ async fn a_state_store_from_a_newer_build_is_refused() {
);
}
/// A store written by the previous schema must come up, not break.
///
/// The record body gained a hostname, which changed the signing domain, so
/// records written before it can never verify again. Leaving them in place
/// would mean every read rejecting rows that look exactly like corruption.
#[tokio::test]
async fn a_state_store_from_the_previous_schema_is_migrated_and_stays_usable() {
let dir = tempfile::TempDir::new().unwrap();
let paths = StoragePaths::under(dir.path());
std::fs::create_dir_all(&paths.state_dir).unwrap();
// Build a schema-2 store by hand, with a record in it.
{
let conn = rusqlite::Connection::open(paths.state_db()).unwrap();
conn.execute_batch(
"BEGIN;
CREATE TABLE device_identity (
id INTEGER PRIMARY KEY CHECK (id = 1),
secret_key BLOB NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE networks (
network_id BLOB PRIMARY KEY,
name TEXT NOT NULL,
secret BLOB NOT NULL,
auto_start INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL
);
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE signed_records (
network_id BLOB NOT NULL,
author BLOB NOT NULL,
version INTEGER NOT NULL,
body BLOB NOT NULL,
signature BLOB NOT NULL,
PRIMARY KEY (network_id, author)
);
CREATE TABLE own_record_version (
network_id BLOB PRIMARY KEY,
version INTEGER NOT NULL
);
INSERT INTO signed_records VALUES (x'00', x'11', 7, x'2222', x'3333');
INSERT INTO own_record_version VALUES (x'00', 7);
PRAGMA user_version = 2;
COMMIT;",
)
.unwrap();
}
// An agent comes up on it, which is the whole point.
let discovery = SharedMemoryDiscovery::new();
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
let (name, secret) = network("migrated");
let network_id = agent.join_network(&name, &secret).await.unwrap();
assert!(agent.network_status(network_id).await.is_ok());
agent.shutdown().await;
let conn = rusqlite::Connection::open(paths.state_db()).unwrap();
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, tsunagi::storage::SCHEMA_VERSION);
// The unverifiable record is gone rather than left to be rejected for
// ever, and the counter is keyed by author now.
let stale: i64 = conn
.query_row(
"SELECT count(*) FROM signed_records WHERE author = x'11'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(stale, 0, "records from the old signing domain are dropped");
conn.query_row(
"SELECT count(*) FROM own_record_version WHERE author IS NOT NULL",
[],
|row| row.get::<_, i64>(0),
)
.expect("the counter is keyed by author");
}
#[tokio::test]
async fn secrets_never_appear_in_status_or_debug_output() {
let discovery = SharedMemoryDiscovery::new();
@@ -201,3 +282,85 @@ async fn secrets_never_appear_in_status_or_debug_output() {
agent.agent.shutdown().await;
}
#[tokio::test]
async fn a_wipe_removes_everything_and_the_next_start_is_a_stranger() {
let dir = tempfile::tempdir().unwrap();
let paths = StoragePaths::under(dir.path());
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("wiped");
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
let before = agent.endpoint_id();
agent.join_network(&name, &secret).await.unwrap();
// Something a plugin keeps beside the state, which a wipe must take too.
std::fs::create_dir_all(paths.state_dir.join("wireguard")).unwrap();
std::fs::write(paths.state_dir.join("wireguard/keys.sqlite"), b"key").unwrap();
// Not while an agent owns the directory: a half-wiped state under a
// running agent is worse than no wipe at all.
let refused = tsunagi::storage::wipe(&paths).unwrap_err();
assert!(matches!(refused, Error::StateLocked { .. }), "{refused}");
agent.shutdown().await;
let plan = tsunagi::storage::wipe_plan(&paths).unwrap();
assert!(
plan.entries().any(|path| path.ends_with("state.sqlite")),
"{plan:?}"
);
assert!(
plan.entries().any(|path| path.ends_with("wireguard")),
"what a plugin kept is state too: {plan:?}"
);
let wiped = tsunagi::storage::wipe(&paths).unwrap();
assert_eq!(wiped, plan);
assert!(!paths.state_db().exists());
assert!(!paths.state_dir.join("wireguard").exists());
assert!(!paths.lock_file().exists(), "no lock is left claiming it");
// A stranger: new identity, no networks, nothing to be surprised by.
let fresh = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
assert_ne!(fresh.endpoint_id(), before);
assert!(fresh.list_networks().await.unwrap().is_empty());
fresh.shutdown().await;
}
#[tokio::test]
async fn wiping_twice_is_as_ordinary_as_wiping_once() {
let dir = tempfile::tempdir().unwrap();
let paths = StoragePaths::under(dir.path());
let discovery = SharedMemoryDiscovery::new();
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
agent.shutdown().await;
assert!(!tsunagi::storage::wipe(&paths).unwrap().is_empty());
let again = tsunagi::storage::wipe(&paths).unwrap();
assert!(again.is_empty(), "nothing left to remove: {again:?}");
}
#[tokio::test]
async fn a_directory_that_is_not_ours_is_refused_rather_than_emptied() {
// The mistyped `--state-dir` that would otherwise remove somebody's
// documents. A marker decides, not the name of the directory.
let dir = tempfile::tempdir().unwrap();
let paths = StoragePaths::new(dir.path(), dir.path().join("cache"));
std::fs::write(dir.path().join("thesis.txt"), b"years of work").unwrap();
let err = tsunagi::storage::wipe(&paths).unwrap_err();
assert!(
err.to_string().contains("does not look like a tsunagi"),
"{err}"
);
assert!(
dir.path().join("thesis.txt").exists(),
"nothing was removed"
);
}
@@ -0,0 +1,242 @@
//! The device's own identity: the name it answers to and the key it signs
//! with, and what happens when either is changed.
//!
//! Both are things a user may reasonably change on a machine they own, and
//! neither may leave the state store in a shape the next start cannot use.
//! That is what these check: not that changing them is prevented, but that
//! the store survives it and says something true afterwards.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tempfile::TempDir;
use tsunagi::Agent;
use tsunagi::config::StoragePaths;
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
use tsunagi::state::{RecordBody, StateSet};
use tsunagi::storage::StateStore;
use tsunagi::testing::{config_with, network, wait_until};
#[tokio::test]
async fn a_changed_name_reaches_peers_and_replaces_the_old_claim() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("renaming");
let a = TempDir::new().unwrap();
let b = TempDir::new().unwrap();
let agent_a = Agent::spawn(config_with(a.path(), &discovery))
.await
.unwrap();
let agent_b = Agent::spawn(config_with(b.path(), &discovery))
.await
.unwrap();
let network_id = agent_a.join_network(&name, &secret).await.unwrap();
agent_b.join_network(&name, &secret).await.unwrap();
let accepted = agent_a.set_hostname("Renamed Host").await.unwrap();
assert_eq!(accepted, "renamedhost", "reduced to a canonical form");
assert_eq!(agent_a.hostname(), "renamedhost");
// The peer is told, rather than finding out on its next restart.
wait_until("the peer learns the new name", || async {
let status = agent_b.network_status(network_id).await.ok()?;
status
.peers
.iter()
.any(|peer| peer.hostname.as_deref() == Some("renamedhost"))
.then_some(())
})
.await;
// And the signed claim says it, so the name outlives the session.
wait_until("the claim carries the new name", || async {
let status = agent_b.network_status(network_id).await.ok()?;
status
.members
.iter()
.any(|member| member.hostname.as_deref() == Some("renamedhost"))
.then_some(())
})
.await;
agent_a.shutdown().await;
agent_b.shutdown().await;
}
#[tokio::test]
async fn a_name_that_reduces_to_nothing_is_refused_rather_than_stored() {
let discovery = SharedMemoryDiscovery::new();
let dir = TempDir::new().unwrap();
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
let before = agent.hostname();
assert!(agent.set_hostname("---").await.is_err());
assert!(agent.set_hostname("").await.is_err());
assert_eq!(agent.hostname(), before, "the old name still stands");
agent.shutdown().await;
}
/// Replacing the signing key must leave a store the next run can use.
///
/// The user is entitled to do this on a machine they own, and they lose the
/// address and name the old key held — there is no way to sign on a dead
/// key's behalf, and nothing here may overrule an author. What must not
/// happen is that the store is left in a shape that breaks.
#[tokio::test]
async fn rotating_the_signing_key_releases_what_it_held_and_leaves_a_usable_store() {
let dir = TempDir::new().unwrap();
let paths = StoragePaths::under(dir.path());
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("rotation");
let network_id = NetworkKeys::derive(&name, &secret).network_id();
// Run once so there is an identity, a network, and a claim to give up.
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
agent.join_network(&name, &secret).await.unwrap();
let before = agent.endpoint_id();
wait_until("the agent claims an address", || async {
let status = agent.network_status(network_id).await.ok()?;
status
.members
.iter()
.any(|member| member.endpoint_id == before && member.overlay_address_v4.is_some())
.then_some(())
})
.await;
let claimed = agent
.network_status(network_id)
.await
.unwrap()
.members
.iter()
.find(|member| member.endpoint_id == before)
.and_then(|member| member.overlay_address_v4)
.expect("an address was claimed");
agent.shutdown().await;
let (replacement, released) = {
let store = StateStore::open(paths.state_db()).unwrap();
store.rotate_device_identity().unwrap()
};
assert_ne!(replacement.endpoint_id(), before, "a different author");
assert_eq!(released, vec![network_id]);
// The outgoing key signed a release, and it still verifies: a record
// whose author no longer runs is not thereby invalid.
{
let store = StateStore::open(paths.state_db()).unwrap();
assert_eq!(
store.device_identity().unwrap().map(|id| id.endpoint_id()),
Some(replacement.endpoint_id())
);
let mut set = StateSet::new();
for record in store.signed_records(network_id).unwrap() {
set.merge(network_id, record)
.expect("every record verifies");
}
let old = set.get(&before).expect("the old author is still on record");
assert!(matches!(old.body, RecordBody::Release));
assert_eq!(
set.address_of(&before),
None,
"the address it held is free again"
);
assert!(
!set.address_holders().contains_key(&claimed),
"{claimed} is no longer reserved"
);
}
// The whole point: the next run comes up on it.
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
assert_eq!(agent.endpoint_id(), replacement.endpoint_id());
let network_id = agent.join_network(&name, &secret).await.unwrap();
wait_until("the new identity claims an address of its own", || async {
let status = agent.network_status(network_id).await.ok()?;
status
.members
.iter()
.any(|member| {
member.endpoint_id == replacement.endpoint_id()
&& member.overlay_address_v4.is_some()
})
.then_some(())
})
.await;
agent.shutdown().await;
}
#[tokio::test]
async fn rotating_a_store_that_has_never_run_just_creates_an_identity() {
let dir = TempDir::new().unwrap();
let paths = StoragePaths::under(dir.path());
let store = StateStore::open(paths.state_db()).unwrap();
assert!(store.device_identity().unwrap().is_none());
let (identity, released) = store.rotate_device_identity().unwrap();
assert!(
released.is_empty(),
"nothing was held, so nothing is given up"
);
assert_eq!(
store.device_identity().unwrap().map(|id| id.endpoint_id()),
Some(identity.endpoint_id())
);
}
#[tokio::test]
async fn the_hostname_defaults_to_the_machines_own_name() {
let discovery = SharedMemoryDiscovery::new();
let dir = TempDir::new().unwrap();
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
// Whatever this machine is called, the agent uses it rather than
// inventing a name from the key — unless the host has no usable one.
match tsunagi::agent::system_hostname() {
Some(system) => assert_eq!(agent.hostname(), system),
None => assert!(agent.hostname().starts_with("tsunagi-")),
}
agent.shutdown().await;
}
/// A secret must not reach the control socket, whatever else `id` prints.
#[tokio::test]
async fn secrets_stay_out_of_the_status_report() {
let discovery = SharedMemoryDiscovery::new();
let dir = TempDir::new().unwrap();
let (name, secret) = network("no-secrets-on-the-wire");
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
agent.join_network(&name, &secret).await.unwrap();
let status = agent.status().await.unwrap();
let rendered = format!("{status:?}");
assert!(!rendered.contains(secret.encode().as_str()));
agent.shutdown().await;
}
#[test]
fn a_network_name_and_secret_are_unaffected_by_the_device_key() {
// Network identity is derived from the name and secret only. Replacing
// the device key must not move the network the device belongs to.
let name = NetworkName::new("stable").unwrap();
let secret = NetworkSecret::from_bytes(vec![9u8; 32]).unwrap();
let first = NetworkKeys::derive(&name, &secret).network_id();
let second = NetworkKeys::derive(&name, &secret).network_id();
assert_eq!(first, second);
}
@@ -3,16 +3,14 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::sync::Arc;
use common::{TestAgent, local_config, network, settle, wait_for_peers};
use tsunagi::Agent;
use tsunagi::discovery::{
CandidateSource, CompositeDiscovery, NetworkDiscovery, SharedMemoryDiscovery, StaticBootstrap,
};
use tsunagi::identity::NetworkKeys;
use tsunagi::testing::{TestAgent, local_config, network, settle, wait_for_peers};
#[tokio::test]
async fn a_static_bootstrap_candidate_is_enough_to_join() {
@@ -139,3 +137,63 @@ async fn forgetting_a_network_removes_it_from_the_state_store() {
drop(restarted);
drop(dir);
}
#[tokio::test]
async fn being_told_about_one_member_is_enough_to_meet_them_all() {
// A network is a mesh, and which member happened to be named on a
// command line must not decide who a device ends up talking to. The
// newcomer is given one address and nothing else; everybody else is
// learned from the members it meets.
let (name, secret) = network("introductions");
// Three that already know each other, and nothing that resolves by
// network: no shared discovery here, only what members pass on.
let first_dir = tempfile::TempDir::new().unwrap();
let first = Agent::spawn(local_config(first_dir.path())).await.unwrap();
let network_id = first.join_network(&name, &secret).await.unwrap();
let second_dir = tempfile::TempDir::new().unwrap();
let second = Agent::spawn(
local_config(second_dir.path())
.with_discovery(Arc::new(StaticBootstrap::new([first.local_addr()]))),
)
.await
.unwrap();
second.join_network(&name, &secret).await.unwrap();
wait_for_peers(&first, network_id, 1).await;
let third_dir = tempfile::TempDir::new().unwrap();
let third = Agent::spawn(
local_config(third_dir.path())
.with_discovery(Arc::new(StaticBootstrap::new([first.local_addr()]))),
)
.await
.unwrap();
third.join_network(&name, &secret).await.unwrap();
// Each of the two newcomers was given only the first one's address,
// and each ends up with both of the others.
wait_for_peers(&second, network_id, 2).await;
wait_for_peers(&third, network_id, 2).await;
wait_for_peers(&first, network_id, 2).await;
// And the one they were not told about is there as an introduction:
// a candidate like any other, still authenticated by the handshake.
let status = second.network_status(network_id).await.unwrap();
let introduced = status
.candidates
.iter()
.find(|candidate| candidate.endpoint_id == third.endpoint_id())
.map(|candidate| candidate.source);
assert!(
matches!(
introduced,
Some(CandidateSource::Introduced) | Some(CandidateSource::Member)
),
"the third should have arrived from the others: {introduced:?}"
);
second.shutdown().await;
third.shutdown().await;
first.shutdown().await;
}
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff. Show more