Compare commits

..
92 Commits
Author SHA1 Message Date
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
201 changed files with 49632 additions and 6635 deletions

No files matched your search

+312
View File
@@ -0,0 +1,312 @@
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
- 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 tsunagi
else
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
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="tsunagi"
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.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"
;;
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
# 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/tsunagi" \
"${{ 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/tsunagi" \
"${{ 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/tsunagi" \
"${{ matrix.deb_arch }}" "$version" \
"tsunagi-gui_${version}_${{ matrix.deb_arch }}.deb" "$tray"
bash dist/arch/build-pkg.sh \
"target/${{ matrix.target }}/release/tsunagi" \
"${{ 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
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' \) -print0)
exit "$rc"
+284
View File
@@ -0,0 +1,284 @@
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
- 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 tsunagi
else
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
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="tsunagi"
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.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"
;;
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
# 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/tsunagi" \
"${{ 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/tsunagi" \
"${{ 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/tsunagi" \
"${{ matrix.deb_arch }}" "$version" \
"tsunagi-gui_${version}_${{ matrix.deb_arch }}.deb" "$tray"
bash dist/arch/build-pkg.sh \
"target/${{ matrix.target }}/release/tsunagi" \
"${{ 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
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' \))
printf 'asset: %s\n' "${files[@]}"
prerelease=()
case "$TAG" in *-*) prerelease=(--prerelease) ;; esac
# Create the release on the first run; on a re-run, add/replace assets
# on the release that is already there.
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release upload "$TAG" "${files[@]}" --repo "$GITHUB_REPOSITORY" --clobber
else
gh release create "$TAG" "${files[@]}" \
--repo "$GITHUB_REPOSITORY" \
--title "tsunagi $TAG" \
--generate-notes \
--verify-tag \
"${prerelease[@]}"
fi
+103 -24
View File
@@ -13,23 +13,67 @@ messages. Scope and non-scope are in [README.md](README.md).
There is no central server, no owner, no registration and no majority vote.
Design accordingly: a majority is not a root of trust.
## User experience
User convenience is a core requirement. The CLI should feel logical and
predictable to an experienced administrator: use consistent selectors and
flags, sensible defaults, and actionable errors. Let users refer to configured
networks by their exact name as well as by id or a unique id prefix; do not
make them copy an opaque identifier when the stored name identifies one network.
When a selector is ambiguous, list the matching names and ids and change
nothing. Never guess which resource a destructive command should affect.
Keep help text and examples in sync with behavior, and preserve existing
scripts when improving the interactive workflow.
## Architectural boundaries
Keep these separate. Crossing them is the main thing to review for.
- **Control plane vs data plane.** The separation is **logical, not physical**.
The control protocol in `src/proto/` knows nothing about packets, and
`src/dataplane/` knows nothing about the control protocol; either can be
replaced on its own. Both may ride on iroh — refusing to would throw away
iroh's NAT traversal and force the data plane to reimplement it. They use
different ALPNs and different connections, so a busy or broken data plane
cannot disturb control traffic.
The control protocol in `crates/tsunagi/src/proto/` knows nothing about
packets, and a protocol crate knows nothing about the control protocol;
either can be replaced on its own. Both may ride on iroh — refusing to
would throw away iroh's NAT traversal and force the data plane to
reimplement it. They use different ALPNs and different connections, so a
busy or broken data plane cannot disturb control traffic.
- **One agent, one interface.** It belongs to the system level, along with
the addresses on it and the decision of whose packet is whose. Several
protocols may be carrying traffic at once and none of them owns the thing
they carry it for. A protocol is handed a routed packet and hands back a
decrypted one; it never creates an interface and never picks an address.
- **One state directory, one identity, one live agent — many networks.** A
network is added to the agent that is already running, never by starting a
second one on the same directory. A second agent is a second identity and
is isolated: its own directories, its own interface, its own runtime. Do
not add anything that lets two agents share a directory, and do not make a
network's lifetime depend on the process that happened to start it.
- **A protocol is a separate crate with its own version.** The version peers
compare is the *wire* version, never the software version: two peers on
different releases work together for as long as the bytes between them
have not changed. Nothing negotiated may be derived from anything that
moves with a release.
- **A protocol never sees the network secret.** It proves who is at the other
end of a tunnel; proving membership of a network stays in the core, which
is why the authenticated transport does too.
- **A relay carries bytes, never packets.** A datagram passed on for two
other peers goes link in, link out: it is never written to the middle's
interface or decrypted there. Multihop routing has a bounded hop limit;
equal-cost next hops are chosen per flow. Reachability is first-hand and
volatile: each authenticated member advertises its own protocol-specific
links. Build routing tables on topology changes, never per packet. Transit
must not acquire a routing mutex or wait for a protocol/TUN reader.
- **Broadcast fanout belongs at local IP ingress.** Participation is local to
each network, enabled by default, persisted and advertised to authenticated
peers. Resolve one source domain and target only its willing members. Remote
delivery never triggers another fanout. Physical LAN exports must extend
explicit ingress and source/destination admission policies; never bypass
ownership checks or add broadcast flooding to encrypted transit.
- **Plugins never learn reachability.** An `IpPlugin` is handed a `PacketLink`
per peer and moves datagrams over it. Addresses, hole punching and relays
belong to `src/dataplane/transport/`. A plugin announcement says *who*, never
belong to `crates/tsunagi/src/dataplane/transport/`. A plugin announcement says *who*, never
*where*.
- **The core never parses a plugin payload.** See `src/dataplane/mod.rs`. Only
`src/dataplane/wireguard/announcement.rs` interprets WireGuard payloads, and
- **The core never parses a plugin payload.** See `crates/tsunagi/src/dataplane/mod.rs`. Only
`crates/tsunagi-wg-quic/src/announcement.rs` interprets `wg-quic` payloads, and
only after bounding every field. A data plane failure must never stop the
control plane.
- **Derived, not claimed.** A peer's overlay address is derived from its public
@@ -37,7 +81,7 @@ Keep these separate. Crossing them is the main thing to review for.
inbound packets are dropped unless their source is the address derived for
the peer that sent them. Never trust an address a peer announces.
- **Signed state is the only durable agreement.** A fact that must survive a
participant being away goes in `src/state/` as a record signed by its
participant being away goes in `crates/tsunagi/src/state/` as a record signed by its
author, never in a session. Merging is deterministic, an older version never
rolls back a newer one, and absence from a snapshot is not deletion. Never
add a vote or a quorum: a majority is not a trust root here, and it would
@@ -46,6 +90,12 @@ Keep these separate. Crossing them is the main thing to review for.
transaction, **before** the record is announced.
- **Plugins own their system objects.** A plugin creates and removes its own
interface and nothing else. Never touch routing, DNS or firewall settings.
The one exception is the system level, not a plugin: while a network's
broadcast is on, `overlay::hostrules` may install exactly a
`255.255.255.255/32` route and an inbound UDP firewall allowance (overlay range
only) for the interface the agent owns. Both are tagged `tsunagi:<interface>`,
reversible, removed on broadcast off, leave and shutdown, and computed from
local state, never from a remote announcement.
- **Device identity vs network identity.** The iroh endpoint id is the device's
public key. `NetworkId` is derived from name + secret only. Never conflate
them, and never let one change the other.
@@ -65,7 +115,7 @@ Keep these separate. Crossing them is the main thing to review for.
normal path *and* the important failures. See [docs/testing.md](docs/testing.md).
Do not add tests for getters or to move a coverage number.
2. **Never change `IDENTITY_SCHEME`, the derivation labels, or the transcript
encoding** in `src/identity/network.rs` and `src/proto/handshake.rs` without
encoding** in `crates/tsunagi/src/identity/network.rs` and `crates/tsunagi/src/proto/handshake.rs` without
treating it as an incompatible protocol change. Bumping the crate version or
the control protocol version must not change an existing `NetworkId`.
3. **Secrets never leak.** Not into logs, not into `Debug`, not into status
@@ -76,7 +126,7 @@ Keep these separate. Crossing them is the main thing to review for.
the library; tests opt out explicitly at the top of each file.
5. **Bounds before allocation.** Frame lengths are checked against
`Limits::max_frame_len` before a buffer is allocated. Every string, list and
queue has a limit in `src/config.rs`.
queue has a limit in `crates/tsunagi/src/config.rs`.
6. **Failure is contained.** A bad signature, wrong secret, malformed packet or
unknown version rejects one message or one session. It never stops another
network and never stops the agent. There is no irreversible global error
@@ -93,25 +143,35 @@ Keep these separate. Crossing them is the main thing to review for.
up shutdown. Wind tasks down with a grace period and then abort.
10. **Nothing from a remote announcement becomes a shell command, a filesystem
path or an OS setting.** The agent never touches interfaces or OS settings
it did not create.
it did not create (the tagged broadcast host rules above are for its own
interface).
## Where things live
| path | responsibility |
|---------------------|----------------|
| `src/identity/` | device identity; deterministic network space identity and derived keys |
| `src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
| `src/discovery.rs` | candidate sources; test and static backends |
| `src/proto/` | framing, message formats, membership handshake |
| `src/net.rs` | iroh endpoint adapter and observability snapshots |
| `src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
| `src/state/` | signed records that outlive a session, their merge rules and address allocation |
| `src/dataplane/` | the plugin contract, the packet transport, and the WireGuard plugin |
| `src/bin/tsunagi.rs`| the command line agent; the only place that owns a runtime, a logger and signals |
| `crates/tsunagi/src/identity/` | device identity; deterministic network space identity and derived keys |
| `crates/tsunagi/src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
| `crates/tsunagi/src/discovery.rs` | candidate sources; test and static backends |
| `crates/tsunagi/src/proto/` | framing, message formats, membership handshake |
| `crates/tsunagi/src/net.rs` | iroh endpoint adapter and observability snapshots |
| `crates/tsunagi/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
| `crates/tsunagi/src/state/` | signed records that outlive a session, their merge rules and address allocation |
| `crates/tsunagi/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks, broadcast host rules |
| `crates/tsunagi/src/dataplane/` | the protocol contract and the authenticated packet transport |
| `crates/tsunagi/src/dns/` | the DNS view of a network: zone, server, resolver publication |
| `crates/tsunagi-wg-quic/` | the `wg-quic` protocol: its keys, its announcement, its tunnels |
| `crates/tsunagi-cli/` | the command line agent; the only place that owns a runtime, a logger and signals |
| `tests/` | integration tests; `tests/common/` is the shared harness |
Add abstractions only at real substitution or testing boundaries. Do not add a
trait per struct. Prefer one crate with clear modules over many small crates.
trait per struct.
**The system level and its plugins are separate crates**, so that boundary is
checked by the compiler and not by discipline: a plugin can reach only what
`tsunagi` makes public, and carries its own version. Everything else stays
one crate with clear modules — do not split further without a reason of that
kind.
## Testing rules
@@ -133,7 +193,26 @@ trait per struct. Prefer one crate with clear modules over many small crates.
- Running several library instances in one process is not a test of several
system processes; do not describe it as one.
## Before you open a change
## Commit every completed change
Agents must create a Git commit before finishing any task that changes this
repository. Do not leave the user to make the commit. Inspect recent commit
messages and follow their style: a short English subject stating the change,
then a useful body explaining the problem, final behavior, architectural
decisions, compatibility/migration effects, and checks actually performed.
Write enough context for a future maintainer or LLM to understand why the code
exists. Record failed or skipped checks honestly; never claim unrun tests.
Before committing, inspect the diff and stage only this task's changes. Preserve
unrelated user changes and never commit secrets or generated build artifacts.
If Git has no effective author name or email, set only the missing repository-
local values to `AB` and `ab@hexor.cy`. Do not override an existing identity or
change global Git configuration. Report the resulting commit hash.
Version bumps, release tags, and pushing commits/tags belong to the user unless
they explicitly ask the agent to do them. Read-only tasks need no empty commit.
## Required validation
```bash
cargo fmt --all -- --check
Generated
+3269 -97
View File
File diff suppressed because it is too large. Load diff
+32 -86
View File
@@ -1,94 +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:anstream", "dep:anstyle", "dep:tracing-subscriber", "tokio/signal", "tun-device", "dns-publish"]
# A real TUN device, so the WireGuard plugin can carry actual IP traffic.
# Needs CAP_NET_ADMIN at run time; without it the plugin still runs and its
# in-memory device can be used for tests.
tun-device = ["dep:tun", "dep:rtnetlink", "dep:caps", "dep:futures-util"]
# Telling the operating system where to send its questions. Linux only for
# now; the zone and the server work without it.
dns-publish = ["dep:zbus"]
[[bin]]
name = "tsunagi"
path = "src/bin/tsunagi.rs"
required-features = ["cli"]
[dependencies]
clap = { version = "4.5", features = ["derive", "env"], optional = true }
# Already in the tree through clap. `anstream` strips the escapes when stdout
# is not a terminal and turns on virtual terminal processing on Windows, so
# colour is never written where it would show up as rubbish.
anstream = { version = "1.0", optional = true }
anstyle = { version = "1.0", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
iroh = { version = "1.2", default-features = false, features = ["tls-ring"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
rusqlite = { version = "0.40", features = ["bundled"] }
hkdf = "0.13"
hmac = "0.13"
sha2 = "0.11"
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
rand = "0.10"
serde = { version = "1.0", features = ["derive"] }
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
# 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"
data-encoding = "2.11"
hex = "0.4"
thiserror = "2.0"
tracing = "0.1"
fs4 = { version = "1.1", features = ["sync"] }
directories = "6.0"
# The real system hostname, without a libc call of our own: this crate is
# forbidden `unsafe` and will not make one.
gethostname = "1.1"
netwatch = "0.19.3"
bytes = "1.12.1"
boringtun = { version = "0.7.1", default-features = false }
tun = { version = "0.8", features = ["async"], optional = true }
# Linux-only interface provisioning. `rtnetlink` configures the interface in
# process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN
# out of the effective set except during the moments it is used.
[target.'cfg(target_os = "linux")'.dependencies]
rtnetlink = { version = "0.23", optional = true }
# 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 }
[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.
+385 -75
View File
@@ -24,6 +24,7 @@ A working library with **real iroh connections** and integration tests:
- several independent networks at once in one agent;
- deterministic network identity derived from name + secret;
- candidates supplied by a replaceable discovery component;
- automatic first contact through the public Mainline DHT (BEP44);
- real iroh connections plus an explicit mutual proof of network membership;
- a small versioned control protocol: handshake, hostname/capability
announcement, ping/pong;
@@ -39,7 +40,7 @@ A working library with **real iroh connections** and integration tests:
### What it deliberately does **not** do
Not implemented, and not pretended to be: Mainline DHT, DNS, routing through
Not implemented, and not pretended to be: DNS, routing through
intermediate participants, a full CRDT, dynamically loaded plugins, a system
service, a complete CLI, or a local control socket. Snapshot synchronisation
and signed revocations are designed for but not implemented — see
@@ -58,7 +59,7 @@ system's and the user's responsibility, not this library's.
- Rust 1.91 or newer (iroh 1.2 requires it), edition 2024. Pinned dependencies
in `Cargo.lock`.
- No internet, no DHT, no public relay, no administrator rights and no changes
- No internet, no public DHT, no public relay, no administrator rights and no changes
to OS network settings are needed to build or test.
- WireGuard runs in userspace (boringtun): **no kernel module and no `wg`
tool**. Only creating a real network interface needs `CAP_NET_ADMIN`, and
@@ -70,29 +71,67 @@ On the first machine:
```bash
cargo build --release
./target/release/tsunagi id secret generate # prints tsn1...; share it privately
./target/release/tsunagi status # this device, the agent, and this host
./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard
./target/release/tsunagi network # the networks this device belongs to
```
It prints its endpoint id and then waits. On the second machine, pass that id:
Two commands do the work, and they are separate on purpose: `up` runs the
agent — one per device, for as long as you want it — and `join` decides
what it belongs to, at any time, while it runs.
```bash
./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard \
--peer <endpoint-id-from-the-first-machine>
# Terminal one: the agent. It prints its endpoint id and then serves.
./target/release/tsunagi up
# Terminal two: make a network. With no --secret it invents one and prints
# it, along with the line to send the other machine.
./target/release/tsunagi join --network lab
```
On the second machine, start its agent and join the same network with the
secret that was printed:
```bash
./target/release/tsunagi up
./target/release/tsunagi join --network lab --secret tsn1...
```
**Name and secret are enough for the first meeting.** Mainline DHT discovery
is enabled by default. Each active network publishes this device's endpoint
and searches until the first authenticated connection. While connected it
only republishes; after 60 seconds without any authenticated connection it
searches again. Publication is approximately every five minutes, and records
older than 15 minutes are ignored. DHT storage nodes may retain them longer.
Use `--no-dht` to disable DHT, or `--peer <endpoint-id>` for optional manual
bootstrap. `--reach local` disables public DHT regardless of the DHT flag.
Endpoint records are public, signed, and contain no network secret. Discovery
is not authentication: every connection must still prove network membership.
Details and failure behavior are in [docs/mainline-dht.md](docs/mainline-dht.md).
**One introduction is enough for the whole network.** Members tell each
other about the members they know, and every author of a signed record is
somebody to try, so a device pointed at one member ends up talking to all
of them rather than to the one that happened to be on its command line.
What travels is a candidate — an address somebody has seen — and it is
authenticated by the handshake like any other; being introduced grants
nothing. It is deliberately the same shape a lookup in a distributed hash
table would return, so that is a source to add beside this one rather than
a redesign.
An agent with nobody to contact says so in `status` rather than sitting
there looking patient.
Within a few seconds both print something like:
```text
+ peer b47c958462 connected over Direct rtt=Some(4.5ms)
+ data link to b47c958462 for wireguard: Direct via Ip(…), datagram 1382
+ peer b47c958462 connected over direct rtt=Some(4.5ms)
+ data link to b47c958462 for wg-quic: direct via 192.0.2.7:41234, datagram 1382
--- status ---
control: 1 peer(s), 0 dial failure(s), 0 handshake failure(s)
wireguard: tsunkkcp43lmdje on fd15:1d9e:fa21:f201:…/64 mtu 1280, 1/1 tunnel(s) established
4jO4kx9Z fd15:1d9e:fa21:f201:… handshake 3s ago tx=0 rx=0 dropped=0 path=Direct via Ip(…)
wg-quic: tsun0 on 10.13.37.69/24 mtu 1280, 1/1 tunnel(s) established
4jO4kx9Z 10.13.37.237 handshake 3s ago tx=0 rx=0 dropped=0 path=direct via 192.0.2.7:41234
```
`1/1 tunnel(s) established` means a real WireGuard handshake completed.
@@ -126,18 +165,21 @@ ping 100.65.243.53
`tx` and `rx` in the status should start moving.
IPv6 works out of the box: each member's address is derived from the network
id and collides with essentially nothing.
**IPv4 addresses are allocated and then remembered.** The default range is
**Addresses are allocated and then remembered.** The default range is
`10.13.37.0/24`; the first member to join settles it and later members adopt
what they find, so `--ipv4-range` only matters for whoever starts the network:
```bash
tsunagi up --network lab --secret "$SECRET" --wireguard --ipv4-range 10.44.0.0/16
tsunagi up --network lab --secret "$SECRET" --wireguard --ipv4-range none # IPv6 only
tsunagi up --ipv4-range 10.44.0.0/16
tsunagi up --ipv4-range none # no data plane at all
```
One agent has one interface, so two of its networks cannot both use that
range. The second takes the range **derived from its own network id**: not
picked locally — every member derives the same one from something they all
already have — so it is an agreement rather than a guess, and a device in
several networks gets an address in each.
An address is claimed with a record signed by that member's persistent device
key, stored, and merged between every replica. A member that disappears for a
month comes back to the same address, because the claim outlived the session.
@@ -149,40 +191,118 @@ Because the address is allocated at run time rather than derived, it is not
known until the agent has started and agreed with its peers. The agent then
assigns it to the interface itself.
## Names
## Levels
`--dns` serves a local DNS zone for the network's members, so they can be
reached by name instead of by address:
The command line is split the way the design is. A bare `tsunagi up` needs
only a network name and a secret; everything else sits under the level it
belongs to, which `--help` shows as two sections:
* **System** — what the agent itself does: how it reaches peers (`--reach`),
the one overlay interface it owns (`--interface`, `--mtu`, `--no-tun`), the
address range (`--ipv4-range`), and the local resolver (`--no-dns` to disable).
* **Transport** — which protocols carry packets (`--protocol`, a list) and
their own settings (`-o key=value`, or `-o protocol:key=value`).
```bash
tsunagi up --network lab --secret "$SECRET" --wireguard --dns
dig @10.13.37.69 -p 5354 music.lab
tsunagi protocols # what this build can carry packets with
```
Names come from signed state, which is the point: **a member that is
switched off still resolves**, because its claim outlived the session. IPv4
only — the IPv6 overlay address derives from a key that travels in live
announcements, so it cannot be answered for a member that is away, and
answering for some members and not others depending on who is online is
worse than not answering.
```text
wg-quic (wire version 4)
what WireGuard's cryptography carried in iroh's QUIC datagrams
-o keepalive=SECONDS keeps a tunnel and its link warm through a NAT
-o mtu=BYTES largest packet a tunnel will carry, at least 576
```
The zone is the network name unless `--dns-zone` says otherwise. It is
yours to choose, so a name that shadows a real public domain is reported and
then used: `--dns-zone ru` warns that every public `.ru` name becomes
unreachable from this host, and then does it. `.internal` is reserved for
exactly this and is never mentioned.
Each protocol declares its own settings, so the agent can list them without
knowing anything about the protocol, and a setting no selected protocol takes
is refused rather than ignored. `--protocol none` runs the control plane by
itself.
A pair of peers uses a protocol they both have **at the same wire version**.
That is not the software version: two peers on different builds carry traffic
for each other for as long as the bytes between them have not changed. A peer
with nothing in common keeps its control plane — messages and signed state
still flow — and simply has no data plane, which `tsunagi status` shows as a
session with no agreed protocol.
## Names
DNS is enabled by default and serves a local zone for **every network this device is in**,
each named after the network, so members can be reached by name instead of
by address:
```bash
tsunagi up # DNS is already enabled
dig @127.0.0.1 -p 5354 music.lab
```
An explicit choice is remembered across restarts. `--no-dns` or `tsunagi dns off`
disables it; `--dns` or `tsunagi dns on` enables it again. Existing saved opt-outs
remain respected. The `dns on` and `dns off` commands also take effect immediately
on a running agent. `--dns` and `--no-dns` cannot be combined.
The zone of a network is its name — `--dns-zone` is gone, because with
several networks there is no single zone to name. A network name may contain
dots, so `--network lab.internal` is how you get `music.lab.internal`.
Names come from signed state, which is the point: **a member that is
switched off still resolves**, because its claim outlived the session.
The answers are IPv4 addresses, because that is what the overlay is. The
*questions* are taken on `127.0.0.1` and `[::1]`, over UDP and TCP, so a
resolver reaches it over whichever family it uses; both are published to the
system resolver together. Loopback and nothing else: the zones are a view
for the host running the agent, and binding an overlay address would put
them in front of the whole mesh — an agent in two networks would then answer
one network's questions about the other's names.
The zone is the network name, and it is yours to choose. A name that
shadows a real public domain is reported and then used: a network called
`ru` warns that every public `.ru` name becomes unreachable from this host,
and then does it. Anything that collides with nothing — which is most names
— is said nothing about, because a warning that fires on every private name
anybody picks is how people learn to ignore warnings.
On Linux the agent tells systemd-resolved to send questions for that suffix
here, over D-Bus, scoped to the overlay interface and as a *routing* domain
so it never becomes the resolver for anything else. resolved drops the whole
setting when the interface goes, and the interface goes with the agent.
Only an interface the agent created, though. Under `--no-tun` there is no
host interface at all, and the agent says so and serves the zone on
loopback rather than configuring whatever else on the host happens to share
the name.
That last step needs permission that `CAP_NET_ADMIN` does not give:
systemd-resolved asks polkit, and polkit decides by user. Running as a
system service is enough; so is a polkit rule granting this user the
`org.freedesktop.resolve1.set-*` actions. **Without it the server still
runs** — `tsunagi status` prints where it is listening and the exact `dig`
line — so the automatic part is missing, not the feature.
systemd-resolved asks polkit, and polkit decides by **user**, not by
capability, so there is no way for the agent to arrange it from inside. On
a desktop the refusal reads `Interactive authentication required`.
Running as a system service is enough. Otherwise the agent prints the rule
that grants it — the four actions it calls and nothing else — ready to
paste:
```bash
sudo tee /etc/polkit-1/rules.d/50-tsunagi-resolved.rules > /dev/null <<'RULE'
polkit.addRule(function(action, subject) {
var allowed = [
"org.freedesktop.resolve1.set-dns-servers",
"org.freedesktop.resolve1.set-domains",
"org.freedesktop.resolve1.set-default-route",
"org.freedesktop.resolve1.revert"
];
if (allowed.indexOf(action.id) >= 0 && subject.user == "YOUR-USER") {
return polkit.Result.YES;
}
});
RULE
```
**Without it the server still runs** — `tsunagi status` prints where it is
listening and the exact `dig` line — so the automatic part is missing, not
the feature. The refusal is said once rather than on every pass, and retried
slowly, because nothing but a person will change it.
The server is authoritative for its zone and nothing else. No recursion, no
forwarding, no cache: pointing a resolver at it can never make it a route to
@@ -223,10 +343,14 @@ sayable — it remembers who belongs while they are gone, so the report can say
dial-failure counter to imply it. Counters are history and are never graded:
a peer that left and came back should not leave the report looking broken.
`id` is the other half: it shows what this device is — its signing key, the
name it answers to, and the secret of every network it has joined — and
changes those. Every item takes the same shape, so there is nothing to
remember: name it to see it, name it with a value to change it.
The other two commands split along the line the system itself draws. `id` is
this **device**: the key it signs with and the name it answers to. `network`
is what it **belongs to**: which networks, their secrets, joining and
leaving. A device outlives every network it is in, and a network outlives
any device in it, so a command that mixed them had to be read twice.
Every item takes the same shape, so there is nothing to remember: name it to
see it, name it with a value to change it.
```
tsunagi id everything about this device
@@ -234,12 +358,66 @@ tsunagi id hostname the name it answers to
tsunagi id hostname mango change it
tsunagi id key the key it signs with
tsunagi id key rotate replace that key
tsunagi id secret the secret of each joined network
tsunagi id secret generate a fresh secret for a network that does not exist yet
tsunagi network the networks this device belongs to
tsunagi network join -n lab -s tsn1… join one; adds it to a running agent
tsunagi network join -n lab resume one this device has, or make it on the spot
tsunagi network stop lab stop serving it, keeping everything
tsunagi network start lab serve it again, from where it left off
tsunagi network leave lab give up the address and name, then forget it
tsunagi network secret the secret of each joined network
tsunagi network secret lab just that one, for copying
tsunagi network secret generate a fresh secret for a network that does not exist yet
tsunagi dns whether the local resolver is serving, and what
tsunagi dns on start it, now and after every restart
tsunagi dns off stop it, now and after every restart
```
Secrets appear in `id`, which is where you go to ask for one, and never in
`status`, in a log, in a `Debug` rendering or in anything sent to a peer.
**A network without a secret makes one.** `tsunagi join --network lab`
resolves a bare name in the obvious way: if this device is already in
exactly one network called `lab`, that one — so the name alone resumes what
you have; if it is in none, a fresh random secret, printed in full with the
line to send the other machine:
```
joined `lab` (k2on43wadbi5x267vp6z3ogkm7nbjedfdoxyauxhtxhwqrprylba)
secret tsn1u7c…
Run this on the other machine:
tsunagi join --network lab --secret tsn1u7c…
Its agent has to be running. If it is not:
tsunagi up --peer 91e83a6e2b7a…
```
Two networks of one name is a thing that happens — a mistyped secret makes
one — so joining says whether the network was already here, and warns when
another configured network answers to the same name. A name is a label and
the id is the identity.
That is the ad-hoc case: one person makes a network and sends the command
round. The secret is printed *only* when the agent invented it — there is
nowhere else to read it from — and never when it was supplied, because then
it is already yours. Two networks of one name and no secret is the one case
with no answer, and it says so instead of choosing.
A secret is printed by `network secret` and nowhere else — not by `id`, not
by `status`, not in a log, a `Debug` rendering or anything sent to a peer.
Asking for it is deliberate, because these reports get pasted into chats.
**`up` takes no network at all** if you would rather decide later: it
brings up the agent and whatever it is already configured for, and waits.
That is the shape of a daemon in one terminal and `tsunagi network join`
in another.
`network join` is also the answer to a question `up` cannot: a state
directory belongs to one live agent, so a second `tsunagi up` cannot add a
network to the one already running. This adds it over the control socket and
it starts at once. With no agent running it is written to the configuration
and starts with the next `up`.
The name is part of the signed state, so changing it revokes the previous
one: there is one record per author, a new version replaces the whole claim,
@@ -286,13 +464,12 @@ its whole life has carrier for its whole life.
### The MTU is 1280
That is the minimum IPv6 requires (RFC 8200), and Linux enforces it by
disabling IPv6 outright on an interface below it — the per-device
`/proc/sys/net/ipv6` entries vanish and adding an address fails with
`Invalid argument`. A smaller MTU cannot work at all, so the agent refuses one
rather than letting it fail later. See
[docs/wireguard.md](docs/wireguard.md#mtu) for the ceiling that pushes back
from the other side.
The default interface MTU stays at 1280 on direct paths, relays and paths
inside another VPN. Large encrypted packets are split into smaller QUIC
datagrams and reassembled before reaching WireGuard. SSH and full-size TCP
segments therefore do not require a manual MTU override or MSS adjustment.
The original IP packet, including its DF bit, is preserved. See
[docs/wireguard.md](docs/wireguard.md#mtu) for limits and compatibility.
### Summary
@@ -301,14 +478,36 @@ from the other side.
| `setcap cap_net_admin+p` | ordinary user, one capability | recommended: nothing to prepare, nothing left behind. Lost on every rebuild or copy of the binary. |
| systemd service | `User=`, `AmbientCapabilities=CAP_NET_ADMIN` | the same, for an installed service |
| `sudo tsunagi up` | root | everything works, nothing is isolated |
| macOS root LaunchDaemon | root | the native macOS deployment; see below |
| `--no-tun` | ordinary user, no capabilities | tunnels run and handshake, traffic never reaches the OS |
**Not implemented yet.** macOS and Windows have no provisioner: both need
real platform work — `utun` and `SystemConfiguration` on one, the IP Helper
API and a Wintun adapter on the other. There the agent says so and `--no-tun`
is the way to run it; the control plane and the tunnels are unaffected. The
decision logic that says *what* to change is shared and tested on every
platform, so only the execution is left to write.
On Windows, put `wintun.dll` beside the executable and start `tsunagi up`
from PowerShell or Command Prompt opened with **Run as administrator**.
Run commands controlling that agent (`join`, `status`, `dns`, `network`)
as the same Windows user with the same elevation. If Windows denies access
to the agent's control pipe, the command reports a permission error instead
of claiming the agent is absent or trying to edit its locked state.
Creating a TUN without sufficient privileges also explains how to restart
the agent with the required permissions.
On macOS the agent creates a `utun` interface, configures it with `ifconfig`
and `route`, and points the system resolver at the overlay by writing
`/etc/resolver/<network>` files (which carry a port, so the DNS server needs no
privileged socket). All of this needs **root**: macOS has no per-capability
grant like Linux, and a `utun` plus `/etc/resolver` are root-only. The kernel
assigns the interface its `utunN` name, so the name derived from the network id
is only a request and the real one is adopted once the interface is up.
Run it as root — `sudo tsunagi up` for a quick start, or install it as a root
**LaunchDaemon** for the native always-on deployment. As a daemon the agent runs
unprivileged commands (`join`, `status`, `dns`, `network`) over its control
socket like any other platform; the socket lives at `/var/run/tsunagi/agent.sock`
and the stores at `/var/db/tsunagi`, both fixed system paths so they do not
depend on whose shell started `sudo`. A ready-to-edit plist and install notes
are in [dist/macos/](dist/macos/). The broadcast route is installed; the
inbound-UDP firewall allowance (a `pf` anchor) is not yet, so `status` reports
that half as incomplete and names what to allow by hand. The decision logic that
says *what* to change is shared and tested on every platform.
## Checks
@@ -326,7 +525,6 @@ tests:
```bash
cargo run --example two_agents # control plane only
cargo run --example wireguard_mesh # a WireGuard overlay carrying a real packet
```
Both run with no privileges and change nothing on the host.
@@ -379,31 +577,85 @@ async fn main() -> Result<()> {
reaches the internet by accident. Opt into `DirectOnly` or `N0Defaults`
explicitly.
### Through somebody in the middle
Tsunagi routes the overlay in userspace. A direct data link always wins;
otherwise the shortest available path can pass through several members.
Each authenticated member advertises only its own live, protocol-specific
transport links. The control plane collects these into a graph and replaces
the routing table when connectivity changes; announcements expire after
90 seconds without refresh.
Transit goes transport → router → transport, without entering WireGuard or
the intermediate host's TUN. The payload stays encrypted between the original
endpoints. TUN connects only the local OS to Tsunagi; the kernel needs only
the overlay route through that interface, with no IP forwarding configuration.
A 16-hop limit bounds loops while topology updates converge. Equal shortest
paths use a stable flow hash derived before encryption, so packets of a TCP
or UDP flow keep their path while topology is unchanged. Physical link changes
do not replace end-to-end WireGuard tunnels or change the 1280-byte default MTU.
`status` reports the hop count and first next hop for relayed paths.
The hot path reads an immutable routing snapshot and sends directly to a cached
transport handle. It takes no routing mutex, walks no graph and does not parse
the encrypted payload. See [routing.md](docs/routing.md) for the architecture,
limits and reproducible forwarding microbenchmark.
This wire format requires all members to upgrade together (control ALPN 3,
data ALPN 4); saved identities, network names, secrets and addresses survive.
### LAN game discovery
IPv4 UDP broadcast relay is enabled by default for each network. The IP router
sends one encrypted copy to each participating peer, including through multihop
paths. It supports `255.255.255.255` and the overlay subnet's broadcast address;
received broadcasts never trigger another fanout.
```sh
tsunagi join -n games --no-broadcast
tsunagi join -n games --broadcast
tsunagi network broadcast games off
tsunagi network broadcast games on
```
The choice persists across restarts and a plain `join`. It can be changed while
the agent runs; `status` shows it for each network. While broadcast is on, the
agent routes `255.255.255.255` through the Tsunagi interface and allows inbound
UDP from the overlay range on it (Linux and Windows, needs the same privilege as
the interface), and removes both when it is turned off or the agent stops. Games
bound to a physical address still bypass it. Physical LAN capture/subnet sharing
is not implemented.
See [broadcast.md](docs/broadcast.md) for domain isolation and future LAN gateways.
## How peers find each other
Two different lookups are involved, and only one of them is this project's:
**1. Resolving one endpoint's address — iroh's, and it works today.**
With `--transport relay` or `--transport direct`, iroh publishes a signed
With `--reach relay` or `--reach direct`, iroh publishes a signed
record of this endpoint's addresses, keyed by its endpoint id, to the public
service run by Number 0 — "n0", the company behind iroh — at `dns.iroh.link`,
over pkarr and DNS, and resolves other endpoints the same way. That is why `--peer <endpoint-id>` works with no address attached:
iroh looks it up. None of that code is ours.
**2. Finding who is in a network — ours, and it is still manual.**
`NetworkDiscovery` maps a secret-derived `DiscoveryKey` to a set of *candidate*
members. Two backends exist: `StaticBootstrap` (what `--peer` feeds) and an
in-memory one for tests. The planned Mainline DHT backend, which would let
members find each other from the network secret alone, is **not implemented**.
So today you bootstrap by passing one peer's id; after that the mesh is
whatever those agents reach.
**2. Finding who is in a network — Mainline DHT by default.**
`NetworkDiscovery` supplies unverified candidates. The Mainline backend uses
a signing key derived from the network name and secret to publish signed
BEP44 records containing each writer's endpoint ID and addresses. Its 16
slots provide starting candidates, not a complete membership list or a limit
on network size. After the first authenticated connection, introductions and
signed state reveal the other members. `StaticBootstrap` (`--peer`) remains
available, and tests use either a local Mainline Testnet or in-memory discovery.
What this means in practice:
- With `relay` or `direct`, **your endpoint id and IP addresses are published
to a public third-party service** (Number 0's, unless you change it). They are not secret, and the network secret is
never published, but an observer of that service learns that your endpoint
exists and where it is. `--transport local` publishes nothing.
exists and where it is. `--reach local` publishes nothing.
- With DHT enabled, endpoint records are also published to the public Mainline
DHT. They are signed, not encrypted; membership still requires the secret.
- A relay, when one is needed, sees the volume and timing of your traffic — not
its contents. The default relays are Number 0's, in the US, EU and
Asia-Pacific.
@@ -419,15 +671,72 @@ paths; tests always use temporary directories.
| `state.sqlite` | device identity, network configuration, hostname | clear error, never reset |
| `cache.sqlite` | address hints and other recoverable data | discarded and recreated |
The WireGuard plugin keeps its own keys in its own `wireguard.sqlite`, wherever
its configuration points, because plugin keys are neither the iroh identity nor
the network secret.
The `wg-quic` protocol keeps its own keys in its own store under `wg-quic/`,
because a protocol's keys are neither the iroh identity nor the network
secret.
The command line agent puts everything under the platform's per-user
directories by default; `--state-dir` and `--cache-dir` override them.
One state directory belongs to one live agent, enforced with a real OS file
lock rather than an existence check.
lock rather than an existence check. That directory *is* the identity: one
agent, one device key, one interface, and as many networks as you like on it
— `tsunagi network join` adds them to the agent that is already running,
which is why a second `tsunagi up` on the same directory is refused rather
than made to work. A second agent on the same host is a second identity, and
needs everything of its own: its own state and cache directories, its own
interface name and its own overlay range. The library owns no globals, so
several of them run side by side in one process as readily as in one host.
### Leaving, and starting over
Membership outlives a session, so it also has to be possible to end it.
```bash
tsunagi network # what this device belongs to
tsunagi network leave lab # give up the address and the name, then forget it
tsunagi wipe --yes # remove everything and be a stranger again
```
**Stopping is not leaving.** `stop` closes this network's sessions, takes
its address off the interface and keeps it from starting again, and that is
all: the configuration, the secret, the signed state and the protocol key
stay exactly as they are, nothing is announced, and to the others this
device is simply away — an ordinary condition they already handle, with its
address and name still reserved for it. `start` picks it up where it left
off. A network named on the `up` command line is started by that command
whatever its stored state, and the start-up banner says so rather than
letting a `stop` quietly come back.
`leave` is the other one, and it removes, locally: the configuration and
the secret, this network's signed records, its cached address hints and the
protocol key it used there. What it keeps is this author's version counter
for that network — a rejoin has to continue above the release, or every
replica would treat the new claim as stale. Everything about *other*
networks, and the device identity itself, is untouched.
`leave` publishes a signed `Release` **first**, while the agent is running and
its sessions are up, so the address and the name are freed for the others
instead of staying reserved to a member that has gone. They pass the tombstone
on, so a member that was away hears it from them. With no agent running,
nothing can sign or send it: the command says so and refuses, and `--offline`
drops the network locally while leaving the others holding the old claim. The
protocol key for that network goes too — rejoining is joining, not resuming.
`leave`, `stop`, `start`, `secret` and `broadcast` accept an exact stored network
name, its full id, or a unique id prefix (including a copied report ellipsis).
Names are case-sensitive. If several networks share a name, or a name matches
another network's id prefix, the command lists matching names and full ids and
changes nothing. Use a full id to choose explicitly; a full id always takes
precedence over a name. No secret is needed to select a configured network.
`wipe` removes both directories' contents: the device identity, every network,
every signed record and everything a protocol kept beside them. It refuses
while an agent is running, and refuses a directory with no `state.sqlite` in
it, so a mistyped `--state-dir` cannot take somebody's documents with it.
Without `--yes` it only says what it would remove. It is not a goodbye: nobody
is told, because after it there is no key left to sign anything with. Leave the
networks first if the addresses should be freed.
## Documentation
@@ -437,6 +746,7 @@ lock rather than an existence check.
- [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/exit-node.md](docs/exit-node.md) — exit nodes: sending all traffic through a member.
- [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.
@@ -454,4 +764,4 @@ any of this.
## Licence
MIT OR Apache-2.0.
[WTFPL](LICENSE): do what the fuck you want to.
+47
View File
@@ -0,0 +1,47 @@
[package]
name = "tsunagi-cli"
version = "0.1.0"
description = "The tsunagi command line agent."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[[bin]]
name = "tsunagi"
path = "src/main.rs"
# The binary and the library share a name, which `cargo doc` cannot map to
# two directories. The command line is documented by `--help`, not rustdoc,
# so the library keeps the name and this opts out.
doc = false
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0", features = ["tun-device", "dns-publish"] }
tsunagi-wireguard = { path = "../tsunagi-wireguard", version = "0.1.0" }
tsunagi-wg-quic = { path = "../tsunagi-wg-quic", version = "0.1.0" }
tsunagi-tcp-tls = { path = "../tsunagi-tcp-tls", version = "0.1.0" }
iroh.workspace = true
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "signal"] }
tracing.workspace = true
tracing-subscriber.workspace = true
clap = { version = "4.5", features = ["derive", "env"] }
# Already in the tree through clap. `anstream` strips the escapes when stdout
# is not a terminal and turns on virtual terminal processing on Windows, so
# colour is never written where it would show up as rubbish.
anstream = "1.0"
anstyle = "1.0"
# Local interface addresses, for the diagnostics in `status`.
netwatch = "0.19.3"
# Resolving a group name to its gid for `up --control-group`, through a safe
# wrapper (no unsafe of our own). Unix only.
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", features = ["user"] }
[dev-dependencies]
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
tempfile.workspace = true
simple-dns = "0.12"
[lints]
workspace = true
File diff suppressed because it is too large. Load diff
+347
View File
@@ -0,0 +1,347 @@
//! The DNS service as the binary actually runs it.
//!
//! The zone and the server have their own tests. What this covers is the
//! wiring between them and the agent, which is where the interesting
//! mistakes live: choosing an address to listen on, and deciding when to
//! rebuild the listener. Both were wrong once, and neither was reachable
//! from a unit test, so this runs the real binary.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::{SocketAddr, UdpSocket};
use std::time::{Duration, Instant};
use simple_dns::{Name, Packet, QCLASS, RCODE, TYPE, rdata::RData};
use tempfile::TempDir;
// A port per test, high enough to need no privileges and fixed so the query
// knows where to look. Distinct because these tests run in parallel and each
// starts its own agent.
const PORT_BINDS: u16 = 15361;
const PORT_REBIND: u16 = 15362;
const PORT_REFUSE: u16 = 15363;
const PORT_TWO_ZONES: u16 = 15364;
const PORT_SWITCH: u16 = 15365;
const PORT_FLAGS: u16 = 15366;
/// Asks, and returns the raw reply. Raw because a parsed packet borrows
/// from the bytes it came out of.
fn query(server: SocketAddr, name: &str, qtype: TYPE) -> Option<Vec<u8>> {
let mut packet = Packet::new_query(0x2468);
packet.questions.push(simple_dns::Question::new(
Name::new(name).unwrap(),
qtype.into(),
QCLASS::CLASS(simple_dns::CLASS::IN),
false,
));
let bytes = packet.build_bytes_vec().unwrap();
let socket = UdpSocket::bind("127.0.0.1:0").ok()?;
socket
.set_read_timeout(Some(Duration::from_millis(500)))
.ok()?;
socket.send_to(&bytes, server).ok()?;
let mut buffer = vec![0u8; 4096];
let read = socket.recv(&mut buffer).ok()?;
buffer.truncate(read);
Packet::parse(&buffer).ok()?;
Some(buffer)
}
/// Whether a reply carries at least one answer record.
fn has_answer(reply: &[u8]) -> bool {
Packet::parse(reply).is_ok_and(|packet| !packet.answers.is_empty())
}
/// Blocks until the server answers, or gives up.
fn wait_for_answer(server: SocketAddr, name: &str) -> Vec<u8> {
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some(reply) = query(server, name, TYPE::A)
&& has_answer(&reply)
{
return reply;
}
assert!(Instant::now() < deadline, "the dns server never answered");
std::thread::sleep(Duration::from_millis(200));
}
}
/// The agent, running as a real process with its DNS service on.
struct Running {
child: std::process::Child,
dir: TempDir,
}
impl Running {
fn wait_ready(&mut self) {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let socket = tsunagi::ipc::control_socket_path(&self.dir.path().join("state"));
let deadline = Instant::now() + Duration::from_secs(20);
while !runtime.block_on(tsunagi::ipc::is_serving(&socket)) {
assert!(
self.child.try_wait().unwrap().is_none(),
"the agent exited before becoming ready"
);
assert!(
Instant::now() < deadline,
"the control socket never became ready"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn restart(&mut self, dns_flag: Option<&str>) {
self.child.kill().unwrap();
self.child.wait().unwrap();
self.child = spawn_child(&self.dir, None, dns_flag);
self.wait_ready();
}
/// Runs another `tsunagi` command against this agent's directory.
fn run(&self, args: &[&str]) -> std::process::Output {
std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.args(args)
.arg("--state-dir")
.arg(self.dir.path().join("state"))
.arg("--cache-dir")
.arg(self.dir.path().join("cache"))
.output()
.expect("the agent binary runs")
}
}
impl Drop for Running {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// Starts an agent in one network, whose name is therefore the zone.
///
/// There is no separate zone setting: an agent serves a zone per network,
/// named after it, so the network name is the zone name.
fn start(zone: &str, port: u16) -> Running {
let agent = start_without_dns_at(zone, Some(port));
let joined = agent.run(&[
"join",
"--network",
zone,
"--secret",
"a-secret-for-the-dns-test",
]);
assert!(
joined.status.success(),
"{}",
String::from_utf8_lossy(&joined.stderr)
);
agent
}
/// Starts an agent in one network with the resolver off, to switch on later.
fn start_without_dns(network: &str) -> Running {
let agent = start_without_dns_at(network, None);
let joined = agent.run(&[
"join",
"--network",
network,
"--secret",
"a-secret-for-the-dns-test",
]);
assert!(
joined.status.success(),
"{}",
String::from_utf8_lossy(&joined.stderr)
);
agent
}
/// The agent alone: `up` runs it, and what it belongs to is decided after.
fn start_without_dns_at(_network: &str, dns_port: Option<u16>) -> Running {
let dir = TempDir::new().unwrap();
let child = spawn_child(&dir, dns_port, dns_port.is_none().then_some("--no-dns"));
let mut agent = Running { child, dir };
agent.wait_ready();
agent
}
fn spawn_child(
dir: &TempDir,
dns_port: Option<u16>,
dns_flag: Option<&str>,
) -> std::process::Child {
let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"));
command
.arg("up")
.arg("--state-dir")
.arg(dir.path().join("state"))
.arg("--cache-dir")
.arg(dir.path().join("cache"))
// No real interface and no internet: this is about the wiring.
.args(["--reach", "local", "--no-tun"]);
if let Some(port) = dns_port {
command.args(["--dns-port", &port.to_string()]);
}
if let Some(flag) = dns_flag {
command.arg(flag);
}
command
.args(["--log", "error", "--status-interval", "0"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("the agent binary starts")
}
#[test]
fn the_resolver_is_on_by_default_even_without_an_overlay_interface() {
// The promise is that the port is served whatever else fails: with
// `--no-tun` there is no interface to attach a resolver setting to,
// and the zone is answered on loopback all the same. Getting this
// wrong left the feature silently dead.
let _agent = start("lab.internal", PORT_BINDS);
let server: SocketAddr = format!("127.0.0.1:{PORT_BINDS}").parse().unwrap();
let reply = wait_for_answer(server, &format!("{}.lab.internal", hostname()));
let answer = Packet::parse(&reply).unwrap();
assert_eq!(answer.rcode(), RCODE::NoError);
match &answer.answers[0].rdata {
RData::A(_) => {}
other => panic!("expected an A record, got {other:?}"),
}
}
#[test]
fn the_listener_is_not_rebuilt_on_every_pass() {
// The listener is bound once and kept, and the zones are swapped
// underneath it as networks and members come and go. Rebuilding it on
// the way past — which an earlier version did on every tick — shut the
// port for a moment each time.
let _agent = start("rebind.internal", PORT_REBIND);
let server: SocketAddr = format!("127.0.0.1:{PORT_REBIND}").parse().unwrap();
let name = format!("{}.rebind.internal", hostname());
wait_for_answer(server, &name);
// Long enough to cross several of the supervisor's passes.
for round in 0..6 {
std::thread::sleep(Duration::from_millis(900));
assert!(
query(server, &name, TYPE::A).is_some_and(|reply| has_answer(&reply)),
"the server stopped answering on round {round}"
);
}
}
#[test]
fn a_name_outside_the_zone_is_refused_and_never_forwarded() {
let _agent = start("refuse.internal", PORT_REFUSE);
let server: SocketAddr = format!("127.0.0.1:{PORT_REFUSE}").parse().unwrap();
wait_for_answer(server, &format!("{}.refuse.internal", hostname()));
let reply = query(server, "example.com", TYPE::A).expect("an answer");
let answer = Packet::parse(&reply).unwrap();
assert_eq!(answer.rcode(), RCODE::Refused);
assert!(answer.answers.is_empty());
}
fn hostname() -> String {
tsunagi::agent::system_hostname().unwrap_or_else(|| "unknown".into())
}
#[test]
fn every_network_gets_a_zone_of_its_own() {
// One agent, one identity, several networks — and a question carries a
// name, not the network it belongs to. Each network is a zone named
// after it, and joining one while the agent runs adds its zone without
// restarting anything.
let agent = start("first.internal", PORT_TWO_ZONES);
let server: SocketAddr = format!("127.0.0.1:{PORT_TWO_ZONES}").parse().unwrap();
let host = hostname();
wait_for_answer(server, &format!("{host}.first.internal"));
let joined = agent.run(&[
"join",
"--network",
"second.internal",
"--secret",
"another-secret-for-the-dns-test",
]);
assert!(
joined.status.success(),
"joining failed: {}",
String::from_utf8_lossy(&joined.stderr)
);
// The second network's zone answers too, and neither leaks into the
// other: a member of one is not a name in the other.
let reply = wait_for_answer(server, &format!("{host}.second.internal"));
assert_eq!(Packet::parse(&reply).unwrap().rcode(), RCODE::NoError);
let first = wait_for_answer(server, &format!("{host}.first.internal"));
assert_eq!(Packet::parse(&first).unwrap().rcode(), RCODE::NoError);
}
#[test]
fn the_resolver_can_be_switched_on_and_off_while_the_agent_runs() {
// An intentional --no-dns can be reversed at runtime without restarting.
let agent = start_without_dns("switch.internal");
let server: SocketAddr = format!("127.0.0.1:{PORT_SWITCH}").parse().unwrap();
let host = hostname();
assert!(
query(server, &format!("{host}.switch.internal"), TYPE::A).is_none(),
"nothing should be answering yet"
);
let on = agent.run(&["dns", "on", "--port", &PORT_SWITCH.to_string()]);
assert!(
on.status.success(),
"dns on failed: {}",
String::from_utf8_lossy(&on.stderr)
);
wait_for_answer(server, &format!("{host}.switch.internal"));
let off = agent.run(&["dns", "off"]);
assert!(off.status.success());
let deadline = Instant::now() + Duration::from_secs(10);
while query(server, &format!("{host}.switch.internal"), TYPE::A).is_some() {
assert!(
Instant::now() < deadline,
"it kept answering after `dns off`"
);
std::thread::sleep(Duration::from_millis(200));
}
}
#[test]
fn explicit_dns_choices_override_the_saved_setting_and_survive_restart() {
let mut agent = start("flags.internal", PORT_FLAGS);
let server = format!("127.0.0.1:{PORT_FLAGS}").parse().unwrap();
let name = format!("{}.flags.internal", hostname());
wait_for_answer(server, &name);
for flag in [Some("--no-dns"), None] {
agent.restart(flag);
let state = agent.run(&["dns"]);
assert!(state.status.success());
assert!(String::from_utf8_lossy(&state.stdout).contains("not serving"));
assert!(query(server, &name, TYPE::A).is_none());
}
agent.restart(Some("--dns"));
wait_for_answer(server, &name);
agent.restart(None);
wait_for_answer(server, &name);
}
#[test]
fn opposing_dns_flags_are_rejected() {
let result = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.args(["up", "--dns", "--no-dns"])
.output()
.unwrap();
assert!(!result.status.success());
assert!(String::from_utf8_lossy(&result.stderr).contains("cannot be used with"));
}
+56
View File
@@ -0,0 +1,56 @@
//! What the command line says about this device, against a running agent.
//!
//! These drive the real binary, which is why they live beside it rather
//! than with the library's own tests: the library cannot depend on a binary
//! built from a crate that depends on it.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tempfile::TempDir;
use tsunagi::Agent;
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
/// Asking who this device is must not need the directory lock.
///
/// The lock belongs to the one agent allowed to *write* the state. `id` only
/// reads, so making it take the lock would mean the question could never be
/// answered while an agent was running — which is exactly when you want to
/// ask it.
#[tokio::test]
async fn identity_can_be_read_while_an_agent_holds_the_directory() {
let dir = TempDir::new().unwrap();
// Hold the directory the way a running agent does.
let agent = Agent::spawn(
AgentConfig::new(StoragePaths::under(dir.path()))
.with_transport(TransportPolicy::LocalOnly)
.with_loopback_bind(),
)
.await
.unwrap();
let expected = agent.endpoint_id().to_string();
let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.arg("id")
// The same layout `StoragePaths::under` gives the agent above.
.arg("--state-dir")
.arg(dir.path().join("state"))
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.output()
.await
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"`id` failed while an agent was running:\n{stderr}"
);
assert!(
stdout.contains(&expected),
"expected {expected} in:\n{stdout}"
);
agent.shutdown().await;
}
+463
View File
@@ -0,0 +1,463 @@
//! Making and joining a network from the command line, against a real agent.
//!
//! The rules here are small and easy to get wrong in a way no unit test
//! notices: what a bare network name means, when a secret is invented, and
//! whether what is printed is enough for the other person to paste.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::time::Duration;
use tempfile::TempDir;
/// An agent running as a real process, with its own directories.
struct Running {
child: std::process::Child,
dir: TempDir,
}
impl Drop for Running {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Running {
fn run(&self, args: &[&str]) -> std::process::Output {
run_in(&self.dir, args)
}
}
fn run_in(dir: &TempDir, args: &[&str]) -> std::process::Output {
std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.args(args)
.arg("--state-dir")
.arg(dir.path().join("state"))
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.output()
.expect("the agent binary runs")
}
fn read_networks(dir: &TempDir) -> Vec<tsunagi::storage::StoredNetwork> {
tsunagi::storage::StateStore::open(dir.path().join("state/state.sqlite"))
.unwrap()
.list_networks()
.unwrap()
}
/// An agent with no network at all, the way a daemon is started before
/// anything has been decided.
fn start_bare(port: u16) -> Running {
let dir = TempDir::new().unwrap();
let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.arg("up")
.arg("--state-dir")
.arg(dir.path().join("state"))
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.args(["--reach", "local", "--no-tun", "--no-dns"])
.arg("--bind")
.arg(format!("127.0.0.1:{port}"))
.args(["--log", "error", "--status-interval", "0"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("the agent binary starts");
std::thread::sleep(Duration::from_secs(2));
Running { child, dir }
}
/// An agent already in one network, the way most of these start.
fn start(network: &str, port: u16) -> Running {
let agent = start_bare(port);
let joined = agent.run(&[
"join",
"--network",
network,
"--secret",
"a-secret-for-the-cli-test",
]);
assert!(
joined.status.success(),
"{}",
String::from_utf8_lossy(&joined.stderr)
);
agent
}
#[test]
fn joining_with_no_secret_makes_one_and_prints_what_to_send() {
// The hurry case: a network with somebody, for as long as it is needed,
// and then gone. Asking for a secret first is a step with no purpose,
// and a secret that is not printed is a network nobody else can join.
let agent = start("resident", 45071);
let joined = agent.run(&["network", "join", "--network", "spontaneous"]);
assert!(
joined.status.success(),
"joining failed: {}",
String::from_utf8_lossy(&joined.stderr)
);
let out = String::from_utf8_lossy(&joined.stdout);
assert!(out.contains("joined `spontaneous`"), "{out}");
// The secret in full, and one line the other side can paste as it is.
let secret = out
.lines()
.find_map(|line| line.trim().strip_prefix("secret "))
.expect("the invented secret is printed");
assert!(secret.starts_with("tsn1"), "{out}");
let command = out
.lines()
.find(|line| line.contains("tsunagi join --network spontaneous"))
.expect("a command to send");
assert!(command.contains(secret), "with the secret in it: {out}");
// And where to find this device, for an agent that is not up yet.
assert!(out.contains("--peer "), "somewhere to find us: {out}");
// Joining the same name again resumes it rather than making another
// network that merely looks the same.
let again = agent.run(&["network", "join", "--network", "spontaneous"]);
let out = String::from_utf8_lossy(&again.stdout);
assert!(again.status.success());
assert!(out.contains("already here"), "{out}");
assert!(!out.contains("secret tsn1"), "no second secret: {out}");
}
#[test]
fn a_bare_name_resumes_the_network_of_that_name_rather_than_inventing_one() {
// `join --network resident` with no secret is the same rule: this device
// has exactly one network of that name, so that is the one meant.
let agent = start("resident", 45072);
let listed = agent.run(&["network"]);
let before = String::from_utf8_lossy(&listed.stdout).to_string();
let id = before
.lines()
.find(|line| line.contains("resident"))
.and_then(|line| line.split_whitespace().nth(1))
.expect("the network is listed")
.to_string();
let joined = agent.run(&["network", "join", "--network", "resident"]);
assert!(joined.status.success());
let out = String::from_utf8_lossy(&joined.stdout);
assert!(out.contains(&id), "the same network, not a new one: {out}");
assert!(out.contains("already here"), "{out}");
}
#[test]
fn a_network_can_be_stopped_and_resumed_without_losing_anything() {
// Leaving gives everything up; stopping is being away. The difference
// is what somebody wants when they will be back.
let agent = start("resident", 45073);
let listed = agent.run(&["network"]);
let out = String::from_utf8_lossy(&listed.stdout).to_string();
let id = out
.lines()
.find(|line| line.contains("resident"))
.and_then(|line| line.split_whitespace().nth(1))
.expect("the network is listed")
.to_string();
let secret_before = agent.run(&["network", "secret", &id[..10]]);
let secret_before = String::from_utf8_lossy(&secret_before.stdout)
.trim()
.to_string();
assert!(secret_before.starts_with("tsn1"));
let stopped = agent.run(&["network", "stop", "resident"]);
assert!(
stopped.status.success(),
"{}",
String::from_utf8_lossy(&stopped.stderr)
);
assert!(
String::from_utf8_lossy(&stopped.stdout).contains("stopped `resident`"),
"{}",
String::from_utf8_lossy(&stopped.stdout)
);
// Still configured, and said to be stopped rather than missing.
let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string();
assert!(listed.contains(&id), "still configured: {listed}");
assert!(listed.contains("stopped"), "{listed}");
// Stopping what is stopped is the state asked for, not an error.
let again = agent.run(&["network", "stop", &id[..10]]);
assert!(again.status.success());
assert!(
String::from_utf8_lossy(&again.stdout).contains("already stopped"),
"{}",
String::from_utf8_lossy(&again.stdout)
);
let started = agent.run(&["network", "start", "resident"]);
assert!(started.status.success());
assert!(
String::from_utf8_lossy(&started.stdout).contains("started `resident`"),
"{}",
String::from_utf8_lossy(&started.stdout)
);
let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string();
assert!(listed.contains("running"), "{listed}");
// And nothing was given up on the way: the same network, same secret.
let secret_after = agent.run(&["network", "secret", "resident"]);
assert_eq!(
String::from_utf8_lossy(&secret_after.stdout).trim(),
secret_before,
"the secret is kept, so this is the same network"
);
}
#[test]
fn an_agent_starts_with_no_network_and_takes_one_later() {
// A daemon in one terminal and the deciding done in another: the
// agent is the identity, and which networks it is in is a separate
// question it can answer at any time.
let agent = start_bare(45074);
let listed = agent.run(&["network"]);
assert!(listed.status.success());
assert!(
String::from_utf8_lossy(&listed.stderr).contains("no network has been joined"),
"{}",
String::from_utf8_lossy(&listed.stderr)
);
let joined = agent.run(&["network", "join", "--network", "afterwards"]);
assert!(
joined.status.success(),
"{}",
String::from_utf8_lossy(&joined.stderr)
);
let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string();
assert!(listed.contains("afterwards"), "{listed}");
assert!(listed.contains("running"), "and it is live: {listed}");
}
#[test]
fn up_is_the_agent_and_takes_no_network_at_all() {
// The two commands are separate on purpose: `up` runs the device's
// agent, `join` decides what it is in. A network on `up` would be a
// second way to do the same thing, and the one that cannot be undone
// without a restart.
for argument in ["--network", "--secret"] {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.args(["up", argument, "whatever"])
.arg("--state-dir")
.arg(TempDir::new().unwrap().path().join("state"))
.output()
.expect("the agent binary runs");
assert!(
!out.status.success(),
"`up {argument}` should not be a thing"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("unexpected argument"),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn broadcast_choice_is_per_network_live_persistent_and_preserved_by_rejoin() {
let agent = start_bare(0);
for args in [
vec!["join", "-n", "broadcast-default"],
vec!["join", "-n", "broadcast-off", "--no-broadcast"],
] {
let result = agent.run(&args);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
}
let read = || {
tsunagi::storage::StateStore::open(agent.dir.path().join("state/state.sqlite"))
.unwrap()
.list_networks()
.unwrap()
};
let networks = read();
let enabled = networks
.iter()
.find(|n| n.name.as_str() == "broadcast-default")
.unwrap();
let disabled = networks
.iter()
.find(|n| n.name.as_str() == "broadcast-off")
.unwrap();
assert!(enabled.broadcast);
assert!(!disabled.broadcast);
assert!(agent.run(&["join", "-n", "broadcast-off"]).status.success());
assert!(
!read()
.iter()
.find(|n| n.name.as_str() == "broadcast-off")
.unwrap()
.broadcast
);
let id = disabled.network_id.to_string();
let change = agent.run(&["network", "broadcast", "broadcast-off", "on"]);
assert!(
change.status.success(),
"{}",
String::from_utf8_lossy(&change.stderr)
);
assert!(read().iter().all(|n| n.broadcast));
let change = agent.run(&["join", "-n", "broadcast-off", "--no-broadcast"]);
assert!(
change.status.success(),
"{}",
String::from_utf8_lossy(&change.stderr)
);
let status = agent.run(&["network", "broadcast", &id]);
assert!(String::from_utf8_lossy(&status.stdout).contains("broadcast off"));
assert!(
!agent
.run(&["join", "-n", "conflict", "--broadcast", "--no-broadcast"])
.status
.success()
);
}
#[test]
fn leaving_by_name_removes_only_that_network_from_a_running_agent() {
let agent = start("resident", 0);
assert!(agent.run(&["join", "-n", "games"]).status.success());
let resident = read_networks(&agent.dir)
.into_iter()
.find(|network| network.name.as_str() == "resident")
.unwrap();
let left = agent.run(&["network", "leave", "games"]);
assert!(
left.status.success(),
"{}",
String::from_utf8_lossy(&left.stderr)
);
assert!(String::from_utf8_lossy(&left.stdout).contains("left `games`"));
let remaining = read_networks(&agent.dir);
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].network_id, resident.network_id);
assert_eq!(
remaining[0].secret.encode().as_str(),
resident.secret.encode().as_str()
);
let status = agent.run(&["network"]);
assert!(status.status.success());
let out = String::from_utf8_lossy(&status.stdout);
assert!(out.contains("resident") && out.contains("running"), "{out}");
assert!(!out.contains("games"), "{out}");
}
#[test]
fn leaving_by_name_offline_keeps_the_explicit_offline_requirement() {
let dir = TempDir::new().unwrap();
for name in ["resident", "games"] {
assert!(run_in(&dir, &["join", "-n", name]).status.success());
}
let refused = run_in(&dir, &["network", "leave", "games"]);
assert!(!refused.status.success());
assert!(String::from_utf8_lossy(&refused.stderr).contains("--offline"));
assert_eq!(read_networks(&dir).len(), 2);
let left = run_in(&dir, &["network", "leave", "games", "--offline"]);
assert!(
left.status.success(),
"{}",
String::from_utf8_lossy(&left.stderr)
);
let remaining = read_networks(&dir);
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].name.as_str(), "resident");
}
#[test]
fn ambiguous_and_unknown_names_leave_every_network_untouched() {
let agent = start_bare(0);
for secret in [
"first-secret-for-the-cli-test",
"second-secret-for-the-cli-test",
] {
assert!(
agent
.run(&["join", "-n", "games", "-s", secret])
.status
.success()
);
}
let before = read_networks(&agent.dir);
assert_eq!(before.len(), 2);
for name in ["games", "missing-network"] {
let result = agent.run(&["network", "leave", name]);
assert!(!result.status.success());
let err = String::from_utf8_lossy(&result.stderr);
if name == "games" {
assert!(err.contains("matches 2 networks"), "{err}");
for network in &before {
assert!(err.contains(&network.network_id.to_string()), "{err}");
assert!(!err.contains(network.secret.encode().as_str()), "{err}");
}
} else {
assert!(err.contains("no configured network"), "{err}");
}
assert_eq!(read_networks(&agent.dir).len(), 2);
}
// The suggested full id still selects exactly one of the same-name networks.
let left = agent.run(&["network", "leave", &before[0].network_id.to_string()]);
assert!(
left.status.success(),
"{}",
String::from_utf8_lossy(&left.stderr)
);
let remaining = read_networks(&agent.dir);
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].network_id, before[1].network_id);
}
#[test]
fn an_exit_node_is_offered_per_network_only_when_asked_and_nobody_offers_by_default() {
let agent = start_bare(0);
assert!(agent.run(&["join", "-n", "exit-offer"]).status.success());
let offered = || {
read_networks(&agent.dir)
.iter()
.find(|n| n.name.as_str() == "exit-offer")
.unwrap()
.exit_node
};
assert!(!offered(), "off unless somebody turns it on");
let on = agent.run(&["network", "exit-node", "exit-offer", "on"]);
assert!(
on.status.success(),
"{}",
String::from_utf8_lossy(&on.stderr)
);
assert!(String::from_utf8_lossy(&on.stdout).contains("exit node on"));
assert!(offered());
let off = agent.run(&["network", "exit-node", "exit-offer", "off"]);
assert!(off.status.success());
assert!(!offered());
let listed = agent.run(&["exit-node"]);
assert!(listed.status.success());
assert!(
String::from_utf8_lossy(&listed.stdout).contains("no connected device offers"),
"{}",
String::from_utf8_lossy(&listed.stdout)
);
let unknown = agent.run(&["exit-node", "nobody"]);
assert!(!unknown.status.success());
assert!(
String::from_utf8_lossy(&unknown.stderr).contains("no connected device matches"),
"{}",
String::from_utf8_lossy(&unknown.stderr)
);
}
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "tsunagi-tcp-tls"
version = "0.1.0"
description = "TCP TLS 443 transport and E2EE tunnel protocol for tsunagi mesh networks."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0" }
iroh.workspace = true
tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macros"] }
rustls = "0.23"
tokio-rustls = "0.26"
rcgen = { version = "0.14", default-features = false, features = ["ring"] }
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
chacha20poly1305 = "0.10"
sha2 = "0.11"
hkdf = "0.13"
rand = "0.10"
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
bytes.workspace = true
serde.workspace = true
postcard.workspace = true
tracing.workspace = true
thiserror.workspace = true
hex.workspace = true
netdev = "0.46"
[target.'cfg(target_os = "linux")'.dependencies]
caps = "0.5"
[dev-dependencies]
tsunagi = { path = "../tsunagi", features = ["testing"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
[lints]
workspace = true
+147
View File
@@ -0,0 +1,147 @@
//! TLS certificate generation and verification.
//!
//! Generates a self-signed TLS certificate derived from the device's persistent
//! identity, and verifies remote certificates against the expected peer's
//! [`iroh::EndpointId`].
use std::sync::Arc;
use iroh::EndpointId;
use rustls::client::danger::{ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, Error as RustlsError, ServerConfig};
/// Verifies that the server certificate's public key matches the expected peer.
#[derive(Debug)]
pub struct PinnedEndpointVerifier {
expected: EndpointId,
}
impl PinnedEndpointVerifier {
/// Creates a verifier expecting `peer`.
pub fn new(peer: EndpointId) -> Self {
Self { expected: peer }
}
}
impl ServerCertVerifier for PinnedEndpointVerifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, RustlsError> {
// Parse the certificate's SPKI / public key
let cert_bytes = end_entity.as_ref();
let expected_bytes = self.expected.as_bytes();
// Check if the expected 32-byte Ed25519 public key is in the certificate
if cert_bytes
.windows(32)
.any(|window| window == expected_bytes)
{
Ok(ServerCertVerified::assertion())
} else {
Err(RustlsError::InvalidCertificate(
rustls::CertificateError::NotValidForName,
))
}
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, RustlsError> {
// We require TLS 1.3
Err(RustlsError::PeerIncompatible(
rustls::PeerIncompatible::NoSignatureSchemesInCommon,
))
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, RustlsError> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![rustls::SignatureScheme::ED25519]
}
}
/// Default SNI domain used to disguise TLS connections.
pub const DEFAULT_SNI: &str = "cloudflare.com";
/// Generates a self-signed certificate and private key for TLS 1.3 using
/// a given 32-byte Ed25519 secret key and specified SNI domain.
pub fn generate_self_signed_cert(
secret_key_bytes: &[u8; 32],
sni: &str,
) -> Result<
(
CertificateDer<'static>,
rustls::pki_types::PrivateKeyDer<'static>,
),
Box<dyn std::error::Error + Send + Sync>,
> {
// PKCS#8 prefix for v1 Ed25519 private keys:
// 30 2e 02 01 00 30 05 06 03 2b 65 70 04 22 04 20 <32 bytes>
let mut pkcs8 = Vec::with_capacity(48);
pkcs8.extend_from_slice(&[
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04,
0x20,
]);
pkcs8.extend_from_slice(secret_key_bytes);
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(pkcs8);
let key_pair = rcgen::KeyPair::from_pkcs8_der_and_sign_algo(&key_der, &rcgen::PKCS_ED25519)?;
let mut params = rcgen::CertificateParams::new(vec![sni.to_string()])?;
params
.distinguished_name
.push(rcgen::DnType::CommonName, sni);
let cert = params.self_signed(&key_pair)?;
let cert_der = CertificateDer::from(cert.der().to_vec());
let priv_key = rustls::pki_types::PrivateKeyDer::Pkcs8(key_der);
Ok((cert_der, priv_key))
}
/// Creates a `rustls::ServerConfig` for the node.
pub fn make_server_config(
cert: CertificateDer<'static>,
key: rustls::pki_types::PrivateKeyDer<'static>,
) -> Result<Arc<ServerConfig>, rustls::Error> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut config = ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()?
.with_no_client_auth()
.with_single_cert(vec![cert], key)?;
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(Arc::new(config))
}
/// Creates a `rustls::ClientConfig` expecting a specific peer's `EndpointId`.
pub fn make_client_config(expected_peer: EndpointId) -> Result<Arc<ClientConfig>, rustls::Error> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let verifier = Arc::new(PinnedEndpointVerifier::new(expected_peer));
let mut config = ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()?
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_no_client_auth();
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(Arc::new(config))
}
+104
View File
@@ -0,0 +1,104 @@
//! End-to-end packet encryption for `tcp-tls`.
//!
//! Uses X25519 Diffie-Hellman key exchange and ChaCha20-Poly1305 AEAD.
//! This ensures that even when traffic passes through intermediate relay nodes,
//! the intermediate nodes cannot inspect or tamper with the payload.
use std::sync::atomic::{AtomicU64, Ordering};
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
use hkdf::Hkdf;
use iroh::EndpointId;
use sha2::Sha256;
use tsunagi::dataplane::PluginError;
use x25519_dalek::{PublicKey, StaticSecret};
/// Length of the X25519 public key.
pub const KEY_LEN: usize = 32;
/// Domain separator for session key derivation.
const HKDF_SALT: &[u8] = b"tsunagi/e2ee/noise/v1";
/// A session cipher for communicating with one peer.
pub struct PeerCipher {
tx_cipher: ChaCha20Poly1305,
rx_cipher: ChaCha20Poly1305,
tx_nonce: AtomicU64,
}
impl PeerCipher {
/// Establishes a new cipher between `local_id` and `remote_id` given
/// our local secret and their public key.
pub fn new(
local_id: EndpointId,
remote_id: EndpointId,
local_secret: &StaticSecret,
remote_public: &PublicKey,
) -> Self {
let shared = local_secret.diffie_hellman(remote_public);
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), shared.as_bytes());
let mut key_a_to_b = [0u8; 32];
let mut key_b_to_a = [0u8; 32];
let _ = hk.expand(b"tsunagi-key-a-to-b", &mut key_a_to_b);
let _ = hk.expand(b"tsunagi-key-b-to-a", &mut key_b_to_a);
// Deterministic role: smaller EndpointId is party A.
let is_party_a = local_id.as_bytes() < remote_id.as_bytes();
let (tx_key, rx_key) = if is_party_a {
(key_a_to_b, key_b_to_a)
} else {
(key_b_to_a, key_a_to_b)
};
let tx_cipher = ChaCha20Poly1305::new(&tx_key.into());
let rx_cipher = ChaCha20Poly1305::new(&rx_key.into());
Self {
tx_cipher,
rx_cipher,
tx_nonce: AtomicU64::new(0),
}
}
/// Encrypts an outbound packet. Prefixes the ciphertext with the 8-byte nonce.
pub fn encrypt(&self, packet: &[u8]) -> Result<bytes::Bytes, PluginError> {
let counter = self.tx_nonce.fetch_add(1, Ordering::Relaxed);
let mut nonce_bytes = [0u8; 12];
nonce_bytes[4..12].copy_from_slice(&counter.to_be_bytes());
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = self
.tx_cipher
.encrypt(nonce, packet)
.map_err(|err| PluginError::Other(format!("encryption failed: {err}")))?;
let mut out = Vec::with_capacity(8 + ciphertext.len());
out.extend_from_slice(&counter.to_be_bytes());
out.extend_from_slice(&ciphertext);
Ok(bytes::Bytes::from(out))
}
/// Decrypts an inbound packet prefixed with the 8-byte nonce.
pub fn decrypt(&self, payload: &[u8]) -> Result<bytes::Bytes, PluginError> {
if payload.len() < 8 + 16 {
return Err(PluginError::Other("payload too short".into()));
}
let counter_bytes: [u8; 8] = match payload[..8].try_into() {
Ok(b) => b,
Err(_) => return Err(PluginError::Other("invalid nonce".into())),
};
let mut nonce_bytes = [0u8; 12];
nonce_bytes[4..12].copy_from_slice(&counter_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let plaintext = self
.rx_cipher
.decrypt(nonce, &payload[8..])
.map_err(|err| PluginError::Other(format!("decryption failed: {err}")))?;
Ok(bytes::Bytes::from(plaintext))
}
}
+11
View File
@@ -0,0 +1,11 @@
//! The `tcp-tls` protocol: TCP with TLS 1.3 on port 443 with Noise E2EE.
pub mod cert;
pub mod crypto;
pub mod plugin;
pub mod transport;
pub use plugin::{
TCP_TLS_PROTOCOL, TCP_TLS_VERSION, TcpTlsCodec, TcpTlsConfig, TcpTlsPlugin, local_ip_candidates,
};
pub use transport::TcpTlsTransport;
+530
View File
@@ -0,0 +1,530 @@
//! The `tcp-tls` IP plugin implementation.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use bytes::Bytes;
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use x25519_dalek::{PublicKey, StaticSecret};
use tsunagi::dataplane::tunnel::{GenericTunnelPlugin, TunnelCodec};
use tsunagi::dataplane::{PluginError, ProtocolOption};
use tsunagi::identity::NetworkId;
use crate::crypto::PeerCipher;
use crate::transport::TcpTlsTransport;
/// The wire protocol identifier for `tcp-tls`.
pub const TCP_TLS_PROTOCOL: &str = "tcp-tls";
/// Wire version for `tcp-tls`.
pub const TCP_TLS_VERSION: u16 = 1;
/// Configuration for the `tcp-tls` protocol plugin.
#[derive(Debug, Clone, Default)]
pub struct TcpTlsConfig {
/// Requested port to bind.
pub port: Option<u16>,
/// Custom SNI domain to disguise TLS connections.
pub sni: Option<String>,
}
impl TcpTlsConfig {
/// Creates a configuration with default settings.
pub fn new() -> Self {
Self::default()
}
/// Sets the TCP listen port.
pub fn with_port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
/// Sets the SNI domain used to disguise TLS connections.
pub fn with_sni(mut self, sni: impl Into<String>) -> Self {
self.sni = Some(sni.into());
self
}
}
/// Announcement payload exchanged between peers for `tcp-tls`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TcpTlsAnnouncement {
/// X25519 public key for E2EE noise encryption.
pub noise_public: [u8; 32],
/// TCP port the peer is listening on.
pub port: u16,
/// Candidate IP addresses where this peer can be reached on `port`.
#[serde(default)]
pub addrs: Vec<std::net::IpAddr>,
}
/// Discovers local network interface IPs to announce to peers.
pub fn local_ip_candidates() -> Vec<std::net::IpAddr> {
let mut candidates = Vec::new();
for iface in netdev::get_interfaces() {
if !is_candidate_interface(&iface) {
continue;
}
for ip in &iface.ipv4 {
let addr = ip.addr();
if is_candidate_ipv4(addr) {
let priority = ip_priority(&iface, std::net::IpAddr::V4(addr));
candidates.push((priority, std::net::IpAddr::V4(addr)));
}
}
for ip in &iface.ipv6 {
let addr = ip.addr();
if is_candidate_ip(std::net::IpAddr::V6(addr)) {
let priority = ip_priority(&iface, std::net::IpAddr::V6(addr));
candidates.push((priority, std::net::IpAddr::V6(addr)));
}
}
}
// Sort descending by priority so best interface IP is candidate #0
candidates.sort_by_key(|b| std::cmp::Reverse(b.0));
let mut seen = std::collections::HashSet::new();
let mut ips = Vec::new();
for (_, ip) in candidates {
if seen.insert(ip) {
ips.push(ip);
}
}
ips
}
pub(crate) fn is_candidate_interface(iface: &netdev::Interface) -> bool {
if !iface.is_up() || iface.is_loopback() {
return false;
}
let name = iface.name.to_lowercase();
let friendly = iface
.friendly_name
.as_deref()
.map(str::to_lowercase)
.unwrap_or_default();
let desc = iface
.description
.as_deref()
.map(str::to_lowercase)
.unwrap_or_default();
// Skip Tsunagi / Wintun overlay interfaces
if name.starts_with("tsun")
|| name.contains("tsunagi")
|| name.contains("wintun")
|| friendly.starts_with("tsun")
|| friendly.contains("tsunagi")
|| friendly.contains("wintun")
|| desc.contains("wintun")
|| desc.contains("tsunagi")
{
return false;
}
// Skip container, VM, and virtual bridge interfaces
if name.starts_with("docker")
|| name.starts_with("br-")
|| name.starts_with("veth")
|| name.starts_with("virbr")
|| name.starts_with("cni")
|| name.starts_with("flannel")
|| name.starts_with("cali")
|| name.starts_with("kube")
|| name.starts_with("dummy")
|| name.starts_with("lxc")
|| name.starts_with("podman")
|| friendly.contains("vethernet")
|| friendly.contains("vboxnet")
|| friendly.contains("vmnet")
|| desc.contains("virtualbox")
|| desc.contains("hyper-v")
|| desc.contains("vmware")
{
return false;
}
true
}
pub(crate) fn is_candidate_ipv4(addr: std::net::Ipv4Addr) -> bool {
if addr.is_loopback() || addr.is_unspecified() || addr.is_link_local() || addr.is_broadcast() {
return false;
}
if addr.is_documentation() {
return false;
}
true
}
pub(crate) fn is_candidate_ip(addr: std::net::IpAddr) -> bool {
match addr {
std::net::IpAddr::V4(v4) => is_candidate_ipv4(v4),
std::net::IpAddr::V6(v6) => {
if v6.is_loopback() || v6.is_unspecified() {
return false;
}
let segments = v6.segments();
// Filter link-local fe80::/10
if (segments[0] & 0xffc0) == 0xfe80 {
return false;
}
true
}
}
}
pub(crate) fn ip_priority(iface: &netdev::Interface, ip: std::net::IpAddr) -> u32 {
let has_gateway = iface.gateway.is_some();
let base = if has_gateway { 100 } else { 0 };
match ip {
std::net::IpAddr::V4(v4) => {
let octets = v4.octets();
// Tailscale: 100.64.0.0/10
let is_tailscale = octets[0] == 100 && (octets[1] & 0xc0) == 64;
let is_private = v4.is_private();
if !is_private && !is_tailscale {
base + 50
} else if has_gateway && is_private {
base + 40
} else if is_tailscale {
base + 30
} else {
base + 10
}
}
std::net::IpAddr::V6(v6) => {
let segments = v6.segments();
let is_global = (segments[0] & 0xe000) == 0x2000;
if is_global { base + 45 } else { base + 20 }
}
}
}
/// The codec implementing cryptographic transformation and announcements for `tcp-tls`.
pub struct TcpTlsCodec {
local_id: EndpointId,
static_secret: StaticSecret,
public_key: PublicKey,
transport: Arc<TcpTlsTransport>,
ciphers: RwLock<HashMap<(NetworkId, EndpointId), Arc<PeerCipher>>>,
}
impl std::fmt::Debug for TcpTlsCodec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TcpTlsCodec")
.field("local_id", &self.local_id.fmt_short().to_string())
.field("port", &self.transport.bound_port())
.finish()
}
}
impl TcpTlsCodec {
/// Supported options for `tcp-tls`.
pub const OPTIONS: &'static [ProtocolOption] = &[
ProtocolOption {
key: "port",
value: "PORT",
help: "TCP port to listen on for TLS 1.3 connections",
default: Some("443"),
},
ProtocolOption {
key: "sni",
value: "DOMAIN",
help: "Server Name Indication (SNI) domain to disguise TLS connections",
default: Some(crate::cert::DEFAULT_SNI),
},
];
/// Creates a new `TcpTlsCodec`.
pub fn new(local_id: EndpointId, transport: Arc<TcpTlsTransport>) -> Self {
let mut secret_bytes = [0u8; 32];
rand::fill(&mut secret_bytes);
let static_secret = StaticSecret::from(secret_bytes);
let public_key = PublicKey::from(&static_secret);
Self {
local_id,
static_secret,
public_key,
transport,
ciphers: RwLock::new(HashMap::new()),
}
}
/// Configures settings from parsed `-o` options.
pub fn configure(
mut config: TcpTlsConfig,
options: &[(String, String)],
) -> Result<TcpTlsConfig, String> {
for (key, value) in options {
match key.as_str() {
"port" => {
let port: u16 = value
.parse()
.map_err(|_| format!("invalid port `{value}`"))?;
config.port = Some(port);
}
"sni" => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("sni domain cannot be empty".into());
}
config.sni = Some(trimmed.to_string());
}
other => return Err(format!("unknown option `{other}` for tcp-tls")),
}
}
Ok(config)
}
}
impl TunnelCodec for TcpTlsCodec {
fn protocol_id(&self) -> &str {
TCP_TLS_PROTOCOL
}
fn protocol_version(&self) -> u16 {
TCP_TLS_VERSION
}
fn options(&self) -> &'static [ProtocolOption] {
Self::OPTIONS
}
fn local_capability_data(&self, _network: NetworkId) -> Result<Vec<u8>, PluginError> {
let announcement = TcpTlsAnnouncement {
noise_public: *self.public_key.as_bytes(),
port: self.transport.bound_port(),
addrs: local_ip_candidates(),
};
postcard::to_allocvec(&announcement)
.map_err(|err| PluginError::Other(format!("encode announcement failed: {err}")))
}
fn on_peer_capability_data(
&self,
network: NetworkId,
peer: EndpointId,
data: &[u8],
) -> Result<(), PluginError> {
let announcement: TcpTlsAnnouncement = postcard::from_bytes(data)
.map_err(|err| PluginError::Rejected(format!("invalid announcement: {err}")))?;
for ip in &announcement.addrs {
if is_candidate_ip(*ip) {
self.transport
.set_peer_addr(peer, std::net::SocketAddr::new(*ip, announcement.port));
}
}
let peer_noise_pubkey = PublicKey::from(announcement.noise_public);
let cipher = Arc::new(PeerCipher::new(
self.local_id,
peer,
&self.static_secret,
&peer_noise_pubkey,
));
let mut guard = match self.ciphers.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.insert((network, peer), cipher);
Ok(())
}
fn on_peer_down(&self, network: NetworkId, peer: EndpointId) {
let mut guard = match self.ciphers.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.remove(&(network, peer));
}
fn encrypt(
&self,
network: NetworkId,
peer: EndpointId,
packet: &[u8],
) -> Result<Bytes, PluginError> {
let cipher = {
let guard = match self.ciphers.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.get(&(network, peer)).cloned()
};
let Some(cipher) = cipher else {
return Err(PluginError::Unavailable("no cipher for peer".into()));
};
cipher.encrypt(packet)
}
fn decrypt(
&self,
network: NetworkId,
peer: EndpointId,
payload: &[u8],
) -> Result<Bytes, PluginError> {
let cipher = {
let guard = match self.ciphers.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.get(&(network, peer)).cloned()
};
let Some(cipher) = cipher else {
return Err(PluginError::Unavailable("no cipher for peer".into()));
};
cipher.decrypt(payload)
}
}
/// The complete `tcp-tls` plugin.
pub type TcpTlsPlugin = GenericTunnelPlugin<TcpTlsCodec>;
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
#[test]
fn test_is_candidate_interface_filters_overlay_and_bridges() {
let mut iface = netdev::Interface::dummy();
iface.flags = 1; // UP
// Standard Ethernet - should pass
iface.name = "eth0".to_string();
assert!(is_candidate_interface(&iface));
// Windows Ethernet with GUID name - should pass
iface.name = "{B6149D2C-1234-5678-9ABC-DEF012345678}".to_string();
iface.friendly_name = Some("Ethernet".to_string());
iface.description = Some("Intel(R) Ethernet Connection".to_string());
assert!(is_candidate_interface(&iface));
// Linux tsun0 overlay - must be filtered
iface.name = "tsun0".to_string();
iface.friendly_name = None;
iface.description = None;
assert!(!is_candidate_interface(&iface));
// Windows Wintun overlay (GUID name with tsun0 friendly name) - must be filtered!
iface.name = "{72D85D76-F1A5-42AC-BC49-2169527CDDDF}".to_string();
iface.friendly_name = Some("tsun0".to_string());
iface.description = Some("Wintun Userspace Tunnel".to_string());
assert!(!is_candidate_interface(&iface));
// Windows Wintun overlay with description containing Wintun - must be filtered!
iface.friendly_name = Some("Local Area Connection 2".to_string());
iface.description = Some("Wintun Userspace Tunnel".to_string());
assert!(!is_candidate_interface(&iface));
// Docker bridge - must be filtered
iface.name = "docker0".to_string();
iface.friendly_name = None;
iface.description = None;
assert!(!is_candidate_interface(&iface));
// Docker network bridge br-* - must be filtered
iface.name = "br-4f6d3a9b1c2e".to_string();
assert!(!is_candidate_interface(&iface));
// Virtual ethernet veth* - must be filtered
iface.name = "veth12345".to_string();
assert!(!is_candidate_interface(&iface));
// Libvirt NAT bridge virbr* - must be filtered
iface.name = "virbr0".to_string();
assert!(!is_candidate_interface(&iface));
// CNI bridge - must be filtered
iface.name = "cni0".to_string();
assert!(!is_candidate_interface(&iface));
// Hyper-V / WSL vEthernet switch on Windows - must be filtered
iface.name = "{A1B2C3D4-E5F6-7890-1234-567890ABCDEF}".to_string();
iface.friendly_name = Some("vEthernet (WSL)".to_string());
iface.description = Some("Hyper-V Virtual Ethernet Adapter".to_string());
assert!(!is_candidate_interface(&iface));
// Down interface - must be filtered
let mut down_iface = netdev::Interface::dummy();
down_iface.flags = 0; // Not UP
down_iface.name = "eth0".to_string();
assert!(!is_candidate_interface(&down_iface));
}
#[test]
fn test_is_candidate_ip() {
// Valid LAN IP
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
192, 168, 1, 117
))));
// Valid Tailscale IP
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
100, 77, 155, 120
))));
// Valid Public IP
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
1, 1, 1, 1
))));
// Valid IPv6 GUA
assert!(is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::new(
0x2a01, 0x4b00, 0xb8e3, 0x4e00, 0, 0, 0, 1
))));
// Loopback - must be rejected
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
127, 0, 0, 1
))));
assert!(!is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::LOCALHOST)));
// Unspecified - must be rejected
assert!(!is_candidate_ip(std::net::IpAddr::V4(
Ipv4Addr::UNSPECIFIED
)));
assert!(!is_candidate_ip(std::net::IpAddr::V6(
Ipv6Addr::UNSPECIFIED
)));
// Link-local - must be rejected
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
169, 254, 1, 1
))));
assert!(!is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::new(
0xfe80, 0, 0, 0, 0, 0x1ff, 0xfe00, 1
))));
// Broadcast - must be rejected
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::BROADCAST)));
}
#[test]
fn test_ip_priority_ordering() {
let mut iface_lan = netdev::Interface::dummy();
iface_lan.flags = 1;
iface_lan.gateway = Some(netdev::net::device::NetworkDevice::new());
let mut iface_vpn = netdev::Interface::dummy();
iface_vpn.flags = 1;
iface_vpn.name = "tailscale0".to_string();
let lan_ip = std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 1, 117));
let tailscale_ip = std::net::IpAddr::V4(Ipv4Addr::new(100, 77, 155, 120));
let p_lan = ip_priority(&iface_lan, lan_ip);
let p_ts = ip_priority(&iface_vpn, tailscale_ip);
// LAN IP with gateway should score higher than VPN IP without gateway
assert!(p_lan > p_ts);
}
}
+627
View File
@@ -0,0 +1,627 @@
//! TCP TLS 1.3 transport implementation for Tsunagi.
//!
//! Provides [`TcpTlsTransport`] implementing [`PacketTransport`]
//! and [`TcpTlsLink`] implementing [`PacketLink`].
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use bytes::Bytes;
use iroh::{EndpointId, Signature};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{Notify, mpsc};
use tokio_rustls::TlsConnector;
use tsunagi::BoxFuture;
use tsunagi::dataplane::transport::{
InboundLink, PacketLink, PacketTransport, SharedLink, TransportError,
};
use tsunagi::identity::{DeviceIdentity, NetworkId};
use crate::cert::{generate_self_signed_cert, make_client_config, make_server_config};
/// Default TCP port to bind and listen on.
pub const DEFAULT_PORT: u16 = 443;
/// Fallback port if 443 is unprivileged on Linux and denied.
pub const FALLBACK_PORT: u16 = 8443;
/// Maximum payload length for a single framed datagram over TLS.
pub const MAX_FRAME_SIZE: usize = 65535;
/// Initial handshake message on established TLS stream (128 bytes).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LinkHandshake {
/// The overlay network ID.
pub network: NetworkId,
/// The connecting client's endpoint ID.
pub client_id: EndpointId,
/// Ed25519 signature over `tsunagi-tcp-tls-link-v1` + network + server ID.
pub signature: [u8; 64],
}
impl LinkHandshake {
/// Total encoded length in bytes.
pub const LEN: usize = 32 + 32 + 64; // 128
/// Serializes to fixed-size byte array.
pub fn to_bytes(&self) -> [u8; Self::LEN] {
let mut buf = [0u8; Self::LEN];
buf[0..32].copy_from_slice(self.network.as_bytes());
buf[32..64].copy_from_slice(self.client_id.as_bytes());
buf[64..128].copy_from_slice(&self.signature);
buf
}
/// Deserializes from byte slice.
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
if bytes.len() != Self::LEN {
return None;
}
let network = NetworkId::from_bytes(bytes[0..32].try_into().ok()?);
let client_id = EndpointId::from_bytes(bytes[32..64].try_into().ok()?).ok()?;
let signature: [u8; 64] = bytes[64..128].try_into().ok()?;
Some(Self {
network,
client_id,
signature,
})
}
}
/// Acknowledgement message on established TLS stream (1 byte).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LinkAck {
/// Whether the link was accepted by the server.
pub accepted: bool,
}
impl LinkAck {
/// Total encoded length in bytes.
pub const LEN: usize = 1;
/// Serializes to fixed-size byte array.
pub fn to_bytes(&self) -> [u8; 1] {
[if self.accepted { 1 } else { 0 }]
}
/// Deserializes from byte slice.
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
if bytes.len() != 1 {
return None;
}
Some(Self {
accepted: bytes[0] == 1,
})
}
}
fn link_handshake_bytes(network: NetworkId, server_id: EndpointId) -> Vec<u8> {
let mut bytes = Vec::with_capacity(23 + 32 + 32);
bytes.extend_from_slice(b"tsunagi-tcp-tls-link-v1");
bytes.extend_from_slice(network.as_bytes());
bytes.extend_from_slice(server_id.as_bytes());
bytes
}
/// An authenticated datagram link carried over a TCP TLS 1.3 stream.
#[derive(Debug)]
pub struct TcpTlsLink {
network: NetworkId,
peer: EndpointId,
tx: mpsc::Sender<Bytes>,
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
closed_notify: Arc<Notify>,
is_closed: Arc<AtomicBool>,
remote_addr: String,
}
impl TcpTlsLink {
/// Creates a new `TcpTlsLink` from a stream that has already passed the TLS handshake.
pub fn new<S>(network: NetworkId, peer: EndpointId, stream: S, remote_addr: String) -> Self
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static,
{
let (mut reader, mut writer) = tokio::io::split(stream);
let (tx_out, mut rx_out) = mpsc::channel::<Bytes>(256);
let (tx_in, rx_in) = mpsc::channel::<Bytes>(256);
let is_closed = Arc::new(AtomicBool::new(false));
let closed_notify = Arc::new(Notify::new());
let closed_flag_w = Arc::clone(&is_closed);
let closed_notify_w = Arc::clone(&closed_notify);
tokio::spawn(async move {
while let Some(msg) = rx_out.recv().await {
if msg.len() > MAX_FRAME_SIZE {
continue;
}
let len = msg.len() as u16;
if writer.write_all(&len.to_be_bytes()).await.is_err() {
break;
}
if writer.write_all(&msg).await.is_err() {
break;
}
if writer.flush().await.is_err() {
break;
}
}
closed_flag_w.store(true, Ordering::Release);
closed_notify_w.notify_waiters();
});
let closed_flag_r = Arc::clone(&is_closed);
let closed_notify_r = Arc::clone(&closed_notify);
tokio::spawn(async move {
let mut len_buf = [0u8; 2];
loop {
if reader.read_exact(&mut len_buf).await.is_err() {
break;
}
let len = u16::from_be_bytes(len_buf) as usize;
if len > MAX_FRAME_SIZE {
break;
}
let mut buf = vec![0u8; len];
if reader.read_exact(&mut buf).await.is_err() {
break;
}
if tx_in.send(Bytes::from(buf)).await.is_err() {
break;
}
}
closed_flag_r.store(true, Ordering::Release);
closed_notify_r.notify_waiters();
});
Self {
network,
peer,
tx: tx_out,
rx: tokio::sync::Mutex::new(rx_in),
closed_notify,
is_closed,
remote_addr,
}
}
}
impl PacketLink for TcpTlsLink {
fn network(&self) -> NetworkId {
self.network
}
fn peer(&self) -> EndpointId {
self.peer
}
fn max_datagram_size(&self) -> usize {
MAX_FRAME_SIZE
}
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
if payload.len() > MAX_FRAME_SIZE {
return Err(TransportError::TooLarge {
size: payload.len(),
limit: MAX_FRAME_SIZE,
});
}
if self.is_closed.load(Ordering::Acquire) {
return Err(TransportError::Closed);
}
self.tx
.try_send(payload)
.map_err(|_| TransportError::Closed)
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move {
let mut guard = self.rx.lock().await;
guard.recv().await
})
}
fn closed(&self) -> BoxFuture<'_, ()> {
Box::pin(async move {
if self.is_closed.load(Ordering::Acquire) {
return;
}
self.closed_notify.notified().await;
})
}
fn is_closed(&self) -> bool {
self.is_closed.load(Ordering::Acquire)
}
fn path_description(&self) -> String {
format!("tcp-tls via {}", self.remote_addr)
}
}
/// The `tcp-tls` packet transport.
pub struct TcpTlsTransport {
identity: DeviceIdentity,
local_id: EndpointId,
listen_port: u16,
sni: String,
peer_addresses: std::sync::RwLock<HashMap<EndpointId, Vec<SocketAddr>>>,
server_config: Arc<rustls::ServerConfig>,
listener: Option<Arc<TcpListener>>,
}
impl std::fmt::Debug for TcpTlsTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TcpTlsTransport")
.field("local_id", &self.local_id.fmt_short().to_string())
.field("listen_port", &self.listen_port)
.field("sni", &self.sni)
.finish()
}
}
#[cfg(target_os = "linux")]
struct NetBindServiceGuard {
raised: bool,
}
#[cfg(target_os = "linux")]
impl NetBindServiceGuard {
fn acquire() -> Self {
let already = caps::has_cap(
None,
caps::CapSet::Effective,
caps::Capability::CAP_NET_BIND_SERVICE,
)
.unwrap_or(false);
if already {
return Self { raised: false };
}
let permitted = caps::has_cap(
None,
caps::CapSet::Permitted,
caps::Capability::CAP_NET_BIND_SERVICE,
)
.unwrap_or(false);
if permitted
&& caps::raise(
None,
caps::CapSet::Effective,
caps::Capability::CAP_NET_BIND_SERVICE,
)
.is_ok()
{
Self { raised: true }
} else {
Self { raised: false }
}
}
}
#[cfg(target_os = "linux")]
impl Drop for NetBindServiceGuard {
fn drop(&mut self) {
if self.raised {
let _ = caps::drop(
None,
caps::CapSet::Effective,
caps::Capability::CAP_NET_BIND_SERVICE,
);
}
}
}
impl TcpTlsTransport {
/// Binds a new TCP TLS transport using the device identity, configured port, and SNI domain.
pub async fn bind(
identity: &DeviceIdentity,
requested_port: Option<u16>,
requested_sni: Option<String>,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let sni = requested_sni
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::cert::DEFAULT_SNI.to_string());
let (cert, key) = generate_self_signed_cert(&identity.secret_bytes(), &sni)?;
let server_config = make_server_config(cert, key)?;
let target_port = requested_port.unwrap_or(DEFAULT_PORT);
let bind_addr = SocketAddr::from(([0, 0, 0, 0], target_port));
let bind_result = {
#[cfg(target_os = "linux")]
let _guard = if target_port < 1024 {
Some(NetBindServiceGuard::acquire())
} else {
None
};
std::net::TcpListener::bind(bind_addr)
};
let listener = match bind_result {
Ok(std_listener) => {
std_listener.set_nonblocking(true)?;
let l = TcpListener::from_std(std_listener)?;
tracing::info!(port = target_port, "TCP TLS listening");
Some(Arc::new(l))
}
Err(err) if requested_port.is_none() => {
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "tsunagi".into());
if err.kind() == std::io::ErrorKind::PermissionDenied {
tracing::warn!(
port = target_port,
%err,
"Cannot bind to default port {target_port} without privileges. \
To allow port 443 and TUN on Linux, grant capabilities once with:\n \
sudo setcap cap_net_admin,cap_net_bind_service+p {exe}\n\
Falling back to port {FALLBACK_PORT}."
);
} else {
tracing::warn!(
port = target_port,
%err,
"Cannot bind to default port {target_port}: {err}. \
Falling back to port {FALLBACK_PORT}."
);
}
let fallback_addr = SocketAddr::from(([0, 0, 0, 0], FALLBACK_PORT));
match TcpListener::bind(fallback_addr).await {
Ok(l) => {
tracing::info!(port = FALLBACK_PORT, "TCP TLS listening on fallback port");
Some(Arc::new(l))
}
Err(fallback_err) => {
tracing::warn!(
port = FALLBACK_PORT,
err = %fallback_err,
"Cannot bind to fallback port {FALLBACK_PORT}: {fallback_err}. \
Falling back to ephemeral port."
);
let ephemeral_addr = SocketAddr::from(([0, 0, 0, 0], 0));
let l = TcpListener::bind(ephemeral_addr).await?;
let bound = l.local_addr()?.port();
tracing::info!(port = bound, "TCP TLS listening on ephemeral port");
Some(Arc::new(l))
}
}
}
Err(err) => {
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "tsunagi".into());
tracing::warn!(
port = target_port,
%err,
"Cannot bind to requested port {target_port}: {err}. \
To allow port {target_port} and TUN on Linux, grant capabilities once with:\n \
sudo setcap cap_net_admin,cap_net_bind_service+p {exe}"
);
return Err(Box::new(err));
}
};
let bound_port = listener
.as_ref()
.and_then(|l| l.local_addr().ok())
.map(|a| a.port())
.unwrap_or(target_port);
Ok(Self {
identity: identity.clone(),
local_id: identity.endpoint_id(),
listen_port: bound_port,
sni,
peer_addresses: std::sync::RwLock::new(HashMap::new()),
server_config,
listener,
})
}
/// The port this transport is listening on.
pub fn bound_port(&self) -> u16 {
self.listen_port
}
/// The SNI domain this transport advertises during TLS handshake.
pub fn sni(&self) -> &str {
&self.sni
}
/// Records an observed TCP target address for a peer.
pub fn set_peer_addr(&self, peer: EndpointId, addr: SocketAddr) {
if let Ok(mut guard) = self.peer_addresses.write() {
let addrs = guard.entry(peer).or_default();
if !addrs.contains(&addr) {
addrs.push(addr);
}
}
}
/// Runs the accept loop for inbound TCP TLS connections.
pub async fn accept_loop<F>(&self, on_inbound: F)
where
F: Fn(InboundLink) + Send + Sync + Clone + 'static,
{
let Some(listener) = self.listener.as_ref().cloned() else {
return;
};
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::clone(&self.server_config));
let local_id = self.local_id;
while let Ok((socket, remote_addr)) = listener.accept().await {
let acceptor = acceptor.clone();
let on_inbound = on_inbound.clone();
tokio::spawn(async move {
let mut tls_stream = match acceptor.accept(socket).await {
Ok(s) => s,
Err(err) => {
tracing::debug!(%err, "inbound TLS handshake failed");
return;
}
};
let mut hello_buf = [0u8; LinkHandshake::LEN];
if tls_stream.read_exact(&mut hello_buf).await.is_err() {
return;
}
let Some(hello) = LinkHandshake::from_bytes(&hello_buf) else {
return;
};
let msg = link_handshake_bytes(hello.network, local_id);
let sig = Signature::from_bytes(&hello.signature);
if hello.client_id.verify(&msg, &sig).is_err() {
tracing::debug!("LinkHandshake signature verification failed");
return;
}
let ack = LinkAck { accepted: true };
let ack_bytes = ack.to_bytes();
if tls_stream.write_all(&ack_bytes).await.is_err() {
return;
}
if tls_stream.flush().await.is_err() {
return;
}
let link = TcpTlsLink::new(
hello.network,
hello.client_id,
tls_stream,
remote_addr.to_string(),
);
on_inbound(InboundLink {
network: hello.network,
peer: hello.client_id,
protocol: "tcp-tls".into(),
link: Arc::new(link),
});
});
}
}
}
impl PacketTransport for TcpTlsTransport {
fn name(&self) -> &str {
"tcp-tls"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn open<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
protocol: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
Box::pin(async move {
if protocol != "tcp-tls" {
return Err(TransportError::Declined(protocol.to_string()));
}
let target_addrs = {
let guard = self
.peer_addresses
.read()
.map_err(|_| TransportError::Other("lock poisoned".into()))?;
guard.get(&peer).cloned().unwrap_or_default()
};
if target_addrs.is_empty() {
return Err(TransportError::Unreachable(
"no known TCP address for peer".into(),
));
}
let mut connected_socket = None;
let mut connected_addr = None;
for addr in &target_addrs {
if addr.ip().is_loopback() && addr.port() == self.listen_port {
continue;
}
match tokio::time::timeout(Duration::from_secs(3), TcpStream::connect(*addr)).await
{
Ok(Ok(socket)) => {
connected_socket = Some(socket);
connected_addr = Some(*addr);
break;
}
_ => continue,
}
}
let (socket, addr) = match (connected_socket, connected_addr) {
(Some(s), Some(a)) => (s, a),
_ => {
return Err(TransportError::Unreachable(
"cannot connect to any TCP address for peer".into(),
));
}
};
let client_config =
make_client_config(peer).map_err(|err| TransportError::Other(err.to_string()))?;
let connector = TlsConnector::from(client_config);
let server_name = rustls::pki_types::ServerName::try_from(self.sni.as_str())
.map_err(|err| TransportError::Other(err.to_string()))?
.to_owned();
let mut tls_stream = connector
.connect(server_name, socket)
.await
.map_err(|err| TransportError::Other(format!("TLS handshake failed: {err}")))?;
// Send LinkHandshake (fixed 128 bytes)
let msg = link_handshake_bytes(network, peer);
let sig = self.identity.sign(&msg);
let hello = LinkHandshake {
network,
client_id: self.local_id,
signature: sig.to_bytes(),
};
let hello_bytes = hello.to_bytes();
tls_stream
.write_all(&hello_bytes)
.await
.map_err(|err| TransportError::Other(err.to_string()))?;
tls_stream
.flush()
.await
.map_err(|err| TransportError::Other(err.to_string()))?;
// Read LinkAck (fixed 1 byte)
let mut ack_buf = [0u8; LinkAck::LEN];
tls_stream
.read_exact(&mut ack_buf)
.await
.map_err(|err| TransportError::Other(err.to_string()))?;
let ack = LinkAck::from_bytes(&ack_buf)
.ok_or_else(|| TransportError::Other("invalid link ack".into()))?;
if !ack.accepted {
return Err(TransportError::Declined("link declined".into()));
}
let link = TcpTlsLink::new(network, peer, tls_stream, addr.to_string());
Ok(Arc::new(link) as SharedLink)
})
}
}
@@ -0,0 +1,53 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use rustls::client::danger::ServerCertVerifier;
use rustls::pki_types::{ServerName, UnixTime};
use tsunagi::identity::DeviceIdentity;
use tsunagi_tcp_tls::cert::{PinnedEndpointVerifier, generate_self_signed_cert};
#[test]
fn test_generate_and_pin_certificate() {
let identity = DeviceIdentity::generate();
let peer_id = identity.endpoint_id();
let (cert, _key) =
generate_self_signed_cert(&identity.secret_bytes(), tsunagi_tcp_tls::cert::DEFAULT_SNI)
.expect("cert generation should succeed");
let verifier = PinnedEndpointVerifier::new(peer_id);
let server_name = ServerName::try_from(tsunagi_tcp_tls::cert::DEFAULT_SNI).unwrap();
let verified = verifier.verify_server_cert(&cert, &[], &server_name, &[], UnixTime::now());
assert!(
verified.is_ok(),
"certificate matching peer endpoint id must verify"
);
let other_identity = DeviceIdentity::generate();
let wrong_verifier = PinnedEndpointVerifier::new(other_identity.endpoint_id());
let wrong_verified =
wrong_verifier.verify_server_cert(&cert, &[], &server_name, &[], UnixTime::now());
assert!(
wrong_verified.is_err(),
"certificate with wrong endpoint id must fail verification"
);
}
#[test]
fn test_make_client_and_server_config() {
let identity = DeviceIdentity::generate();
let peer_id = identity.endpoint_id();
let (cert, key) = generate_self_signed_cert(&identity.secret_bytes(), "custom.example.com")
.expect("cert generation should succeed");
let server_config = tsunagi_tcp_tls::cert::make_server_config(cert, key);
assert!(server_config.is_ok());
let client_config = tsunagi_tcp_tls::cert::make_client_config(peer_id);
assert!(client_config.is_ok());
}
@@ -0,0 +1,73 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tsunagi_tcp_tls::crypto::PeerCipher;
use x25519_dalek::{PublicKey, StaticSecret};
#[test]
fn test_peer_cipher_bidirectional_roundtrip() {
let peer_a_id = iroh::SecretKey::generate().public();
let peer_b_id = iroh::SecretKey::generate().public();
let secret_a = StaticSecret::from([10u8; 32]);
let public_a = PublicKey::from(&secret_a);
let secret_b = StaticSecret::from([20u8; 32]);
let public_b = PublicKey::from(&secret_b);
let cipher_a = PeerCipher::new(peer_a_id, peer_b_id, &secret_a, &public_b);
let cipher_b = PeerCipher::new(peer_b_id, peer_a_id, &secret_b, &public_a);
// A -> B
let msg_from_a = b"hello from node A to node B over Noise E2EE";
let encrypted_by_a = cipher_a
.encrypt(msg_from_a)
.expect("encryption should succeed");
let decrypted_by_b = cipher_b
.decrypt(&encrypted_by_a)
.expect("decryption should succeed");
assert_eq!(&decrypted_by_b[..], msg_from_a);
// B -> A
let msg_from_b = b"hello back from node B to node A";
let encrypted_by_b = cipher_b
.encrypt(msg_from_b)
.expect("encryption should succeed");
let decrypted_by_a = cipher_a
.decrypt(&encrypted_by_b)
.expect("decryption should succeed");
assert_eq!(&decrypted_by_a[..], msg_from_b);
// Sequence nonces increment
let msg2 = b"packet 2";
let enc2 = cipher_a.encrypt(msg2).expect("encryption should succeed");
assert_ne!(encrypted_by_a[..8], enc2[..8], "nonces should differ");
let dec2 = cipher_b.decrypt(&enc2).expect("decryption should succeed");
assert_eq!(&dec2[..], msg2);
}
#[test]
fn test_tampered_payload_rejected() {
let peer_a_id = iroh::SecretKey::generate().public();
let peer_b_id = iroh::SecretKey::generate().public();
let secret_a = StaticSecret::from([10u8; 32]);
let public_a = PublicKey::from(&secret_a);
let secret_b = StaticSecret::from([20u8; 32]);
let public_b = PublicKey::from(&secret_b);
let cipher_a = PeerCipher::new(peer_a_id, peer_b_id, &secret_a, &public_b);
let cipher_b = PeerCipher::new(peer_b_id, peer_a_id, &secret_b, &public_a);
let encrypted = cipher_a.encrypt(b"secret payload").expect("encrypt");
// Corrupt one byte of ciphertext
let len = encrypted.len();
let mut modified = encrypted.to_vec();
modified[len - 1] ^= 0xff;
assert!(
cipher_b.decrypt(&modified).is_err(),
"corrupted ciphertext must fail AEAD verification"
);
}
@@ -0,0 +1,149 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use tokio::sync::mpsc;
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
use tsunagi::dataplane::{IpPlugin, PacketSink, PluginContext};
use tsunagi::identity::{DeviceIdentity, NetworkId};
use tsunagi_tcp_tls::{TcpTlsCodec, TcpTlsPlugin, TcpTlsTransport};
struct TestSink {
delivered: mpsc::Sender<(NetworkId, iroh::EndpointId, Bytes)>,
}
impl PacketSink for TestSink {
fn deliver(
&self,
network: NetworkId,
from: iroh::EndpointId,
packet: Bytes,
) -> tsunagi::BoxFuture<'_, ()> {
let delivered = self.delivered.clone();
Box::pin(async move {
let _ = delivered.send((network, from, packet)).await;
})
}
}
#[tokio::test]
async fn test_tcp_tls_plugin_end_to_end() {
let identity_a = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([77u8; 32]);
// Transports
let transport_b = Arc::new(
TcpTlsTransport::bind(&identity_b, Some(0), None)
.await
.expect("bind B"),
);
let port_b = transport_b.bound_port();
let transport_a = Arc::new(
TcpTlsTransport::bind(&identity_a, Some(0), None)
.await
.expect("bind A"),
);
// Codecs and plugins
let codec_a = TcpTlsCodec::new(peer_a, Arc::clone(&transport_a));
let plugin_a = Arc::new(TcpTlsPlugin::new(codec_a));
let codec_b = TcpTlsCodec::new(peer_b, Arc::clone(&transport_b));
let plugin_b = Arc::new(TcpTlsPlugin::new(codec_b));
// Attach sinks
let (sink_a_tx, mut sink_a_rx) = mpsc::channel(10);
plugin_a.attach(PluginContext::with_sink(Arc::new(TestSink {
delivered: sink_a_tx,
})));
let (sink_b_tx, mut sink_b_rx) = mpsc::channel(10);
plugin_b.attach(PluginContext::with_sink(Arc::new(TestSink {
delivered: sink_b_tx,
})));
// Capability exchange
let cap_a = plugin_a
.local_capability(network)
.expect("cap A")
.expect("some cap A");
let cap_b = plugin_b
.local_capability(network)
.expect("cap B")
.expect("some cap B");
plugin_a
.on_peer_capability(network, peer_b, &cap_b)
.expect("A on cap B");
plugin_b
.on_peer_capability(network, peer_a, &cap_a)
.expect("B on cap A");
// Spawn accept loop on B
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone
.accept_loop(move |inbound| {
let _ = inbound_tx.try_send(inbound);
})
.await;
});
// Make sure A knows B's loopback address
transport_a.set_peer_addr(peer_b, format!("127.0.0.1:{port_b}").parse().unwrap());
// Connect link from A to B
let link_a = transport_a
.open(network, peer_b, "tcp-tls")
.await
.expect("open link from A");
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
.await
.expect("recv timeout")
.expect("recv inbound");
let link_b = inbound_b.link;
// Install links into plugins
plugin_a.on_peer_link(network, peer_b, link_a);
plugin_b.on_peer_link(network, peer_a, link_b);
// Carry packet from A to B
let ip_packet_a = Bytes::from_static(b"4500003c000100004011e0e8c0a80101c0a8010200000000");
let carried_a = plugin_a.carry(network, peer_b, ip_packet_a.clone());
assert!(carried_a, "plugin A should carry packet");
let (net_received, from_peer, delivered_pkt) =
tokio::time::timeout(Duration::from_secs(3), sink_b_rx.recv())
.await
.expect("timeout waiting for delivery on B")
.expect("packet on B");
assert_eq!(net_received, network);
assert_eq!(from_peer, peer_a);
assert_eq!(delivered_pkt, ip_packet_a);
// Carry reply packet from B to A
let ip_packet_b = Bytes::from_static(b"reply-packet-from-b");
let carried_b = plugin_b.carry(network, peer_a, ip_packet_b.clone());
assert!(carried_b, "plugin B should carry packet");
let (net_received, from_peer, delivered_pkt) =
tokio::time::timeout(Duration::from_secs(3), sink_a_rx.recv())
.await
.expect("timeout waiting for delivery on A")
.expect("packet on A");
assert_eq!(net_received, network);
assert_eq!(from_peer, peer_b);
assert_eq!(delivered_pkt, ip_packet_b);
}
@@ -0,0 +1,98 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use tokio::sync::mpsc;
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
use tsunagi::identity::{DeviceIdentity, NetworkId};
use tsunagi_tcp_tls::TcpTlsTransport;
#[tokio::test]
async fn test_tcp_tls_transport_loopback() {
let identity_a = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([42u8; 32]);
// Bind transport B on port 0 (ephemeral)
let transport_b = Arc::new(
TcpTlsTransport::bind(&identity_b, Some(0), Some("custom.example.com".to_string()))
.await
.expect("bind server should succeed"),
);
let port_b = transport_b.bound_port();
assert!(port_b > 0);
assert_eq!(transport_b.sni(), "custom.example.com");
// Bind transport A on port 0 (ephemeral)
let transport_a = Arc::new(
TcpTlsTransport::bind(&identity_a, Some(0), Some("custom.example.com".to_string()))
.await
.expect("bind client should succeed"),
);
assert_eq!(transport_a.sni(), "custom.example.com");
// Register B's address on transport A
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
transport_a.set_peer_addr(peer_b, addr_b);
// Spawn accept loop on B
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone
.accept_loop(move |inbound| {
let _ = inbound_tx.try_send(inbound);
})
.await;
});
// Open link from A to B
let link_a = tokio::time::timeout(
Duration::from_secs(5),
transport_a.open(network, peer_b, "tcp-tls"),
)
.await
.expect("open should not timeout")
.expect("open should succeed");
// Receive inbound link on B
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
.await
.expect("accept should not timeout")
.expect("inbound link should arrive");
assert_eq!(inbound_b.network, network);
assert_eq!(inbound_b.peer, peer_a);
assert_eq!(inbound_b.protocol, "tcp-tls");
let link_b = inbound_b.link;
// Send from A to B
let data_a_to_b = Bytes::from_static(b"packet from A to B");
link_a.send(data_a_to_b.clone()).expect("send A->B");
let received_on_b = tokio::time::timeout(Duration::from_secs(2), link_b.recv())
.await
.expect("recv B timeout")
.expect("recv B option");
assert_eq!(received_on_b, data_a_to_b);
// Send from B to A
let data_b_to_a = Bytes::from_static(b"reply from B to A");
link_b.send(data_b_to_a.clone()).expect("send B->A");
let received_on_a = tokio::time::timeout(Duration::from_secs(2), link_a.recv())
.await
.expect("recv A timeout")
.expect("recv A option");
assert_eq!(received_on_a, data_b_to_a);
// Path descriptions
assert!(link_a.path_description().contains("tcp-tls"));
assert!(link_b.path_description().contains("tcp-tls"));
}
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "tsunagi-tray"
version = "0.1.0"
description = "Tray application to manage a running tsunagi agent."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[[bin]]
name = "tsunagi-tray"
path = "src/main.rs"
[dependencies]
# The agent control client and the report types: the GUI is a thin client over
# the same local control interface the CLI uses, so it reuses these directly
# and never re-implements the protocol.
tsunagi = { path = "../tsunagi", version = "0.1.0" }
# Native, lightweight GUI — no web engine. eframe re-exports egui.
eframe = { version = "0.31", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
# Cross-platform tray icon + menu (Windows tray, macOS menu bar, Linux SNI).
tray-icon = "0.21"
# Background worker that talks to the agent off the UI thread.
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
tracing.workspace = true
tracing-subscriber.workspace = true
egui-phosphor = "0.9"
# Linux only: tray-icon (already GTK-based there) needs the app to initialise
# GTK and run its main loop.
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
# Windows and macOS: the tray process has no GUI toolkit, only an event loop to
# serve the icon (and, on macOS, the Accessory activation policy that keeps it
# out of the Dock). Pinned to eframe's winit so the types unify.
[target.'cfg(any(windows, target_os = "macos"))'.dependencies]
winit = "0.30"
[lints]
workspace = true
+175
View File
@@ -0,0 +1,175 @@
//! The About window: what this software is, and what it is talking to.
//!
//! Compact on purpose — one table for the client, one for the agent. The agent
//! half is whatever the agent says about itself, so a client and an agent that
//! are not the same build show up as such.
use eframe::egui;
use tsunagi::ipc::{PrivilegeReport, StatusReport};
use crate::agent::Snapshot;
use crate::format;
const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
const AUTHOR: &str = "AB <tsunagi@hexor.cy>";
const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Draws the window body into its viewport.
pub(crate) fn show(ctx: &egui::Context, socket: &std::path::Path, snapshot: &Snapshot) {
egui::CentralPanel::default().show(ctx, |ui| {
// Long paths and a taller agent table must scroll rather than be cut.
egui::ScrollArea::both()
.auto_shrink([false, false])
.show(ui, |ui| body(ui, socket, snapshot));
});
}
fn body(ui: &mut egui::Ui, socket: &std::path::Path, snapshot: &Snapshot) {
{
ui.horizontal(|ui| {
ui.heading("tsunagi");
ui.label(egui::RichText::new(VERSION).small().weak());
});
ui.label("Peer-to-peer mesh network with no server.");
ui.add_space(6.0);
section(ui, "client");
grid(ui, "about client", |ui| {
row(ui, "repository", |ui| {
ui.hyperlink(REPOSITORY);
});
row(ui, "author", |ui| {
format::copy_field(ui, AUTHOR, AUTHOR);
});
row(ui, "license", |ui| {
ui.label(env!("CARGO_PKG_LICENSE"));
});
row(ui, "build", |ui| {
ui.label(format!(
"{} {} · {}",
std::env::consts::OS,
std::env::consts::ARCH,
if cfg!(debug_assertions) {
"debug"
} else {
"release"
}
));
});
});
ui.add_space(8.0);
section(ui, "agent");
let socket = socket.display().to_string();
match &snapshot.status {
None => {
grid(ui, "about agent", |ui| {
row(ui, "socket", |ui| format::copy_field(ui, &socket, &socket));
row(ui, "state", |ui| {
ui.label("connecting…");
});
});
}
Some(Err(error)) => {
grid(ui, "about agent", |ui| {
row(ui, "socket", |ui| format::copy_field(ui, &socket, &socket));
row(ui, "state", |ui| {
ui.colored_label(egui::Color32::from_rgb(0xd8, 0x4a, 0x3a), "unreachable");
});
row(ui, "reason", |ui| {
ui.add(egui::Label::new(error).wrap());
});
});
}
Some(Ok(report)) => agent_table(ui, &socket, report),
}
}
}
fn agent_table(ui: &mut egui::Ui, socket: &str, report: &StatusReport) {
grid(ui, "about agent", |ui| {
row(ui, "socket", |ui| format::copy_field(ui, socket, socket));
row(ui, "state", |ui| {
ui.colored_label(egui::Color32::from_rgb(0x3c, 0xb0, 0x4a), "connected");
});
row(ui, "version", |ui| {
if report.version.is_empty() {
ui.weak("unknown");
} else if report.version == VERSION {
ui.label(&report.version);
} else {
ui.colored_label(
egui::Color32::from_rgb(0xd8, 0x9a, 0x2a),
format!("{} (client is {VERSION})", report.version),
);
}
});
if !report.protocols.is_empty() {
row(ui, "protocols", |ui| {
ui.label(report.protocols.join(" · "));
});
}
row(ui, "endpoint id", |ui| {
format::copy_field(ui, &format::short(&report.endpoint_id), &report.endpoint_id);
});
row(ui, "hostname", |ui| {
ui.label(&report.hostname);
});
if !report.program.is_empty() {
row(ui, "binary", |ui| {
format::copy_field(ui, &report.program, &report.program);
});
}
if !report.state_dir.is_empty() {
row(ui, "state dir", |ui| {
format::copy_field(ui, &report.state_dir, &report.state_dir);
});
row(ui, "cache dir", |ui| {
format::copy_field(ui, &report.cache_dir, &report.cache_dir);
});
}
row(ui, "interface", |ui| match &report.privilege {
PrivilegeReport::Unknown => {
ui.weak("unknown");
}
PrivilegeReport::Available => {
ui.label("can be managed (CAP_NET_ADMIN)");
}
PrivilegeReport::Missing(_) => {
ui.colored_label(egui::Color32::from_rgb(0xd8, 0x9a, 0x2a), "no privileges");
}
PrivilegeReport::Unsupported => {
ui.label("not supported on this platform");
}
});
row(ui, "cache", |ui| {
ui.label(if report.cache_healthy {
"usable"
} else {
"unavailable"
});
});
row(ui, "listening", |ui| {
ui.label(report.bound_sockets.join(", "));
});
});
}
fn section(ui: &mut egui::Ui, title: &str) {
ui.label(egui::RichText::new(title).strong());
}
fn grid(ui: &mut egui::Ui, id: &str, add: impl FnOnce(&mut egui::Ui)) {
egui::Grid::new(id)
.num_columns(2)
.spacing([12.0, 3.0])
.show(ui, add);
}
/// One label/value line of a table.
fn row(ui: &mut egui::Ui, label: &str, value: impl FnOnce(&mut egui::Ui)) {
ui.weak(label);
value(ui);
ui.end_row();
}
+256
View File
@@ -0,0 +1,256 @@
//! The client side: a background worker that talks to the agent over the local
//! control socket, off the UI thread.
//!
//! The UI never blocks on IO. A worker on a tokio runtime polls the agent for
//! status on an interval and runs the commands the UI sends (join, leave,
//! activate, broadcast), writing the latest snapshot into shared state and
//! asking egui to repaint. The whole thing is a thin layer over
//! [`tsunagi::ipc`]; no protocol is re-implemented here.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use eframe::egui;
use tokio::sync::mpsc;
use tsunagi::ipc::{self, StatusReport};
/// How often the worker refreshes status while idle.
const POLL_INTERVAL: Duration = Duration::from_secs(2);
/// A request from the UI to the agent.
pub(crate) enum Command {
/// Stop serving a network, or start serving it again.
SetActive { network_id: String, active: bool },
/// Turn local broadcast participation on or off for a network.
SetBroadcast { network_id: String, enabled: bool },
/// Offer this device as an exit node in a network, or stop.
SetExitOffer { network_id: String, enabled: bool },
/// Send all this device's internet traffic through a member of a
/// network, or (`None`) back the ordinary way.
SetExitNode {
network_id: String,
peer: Option<String>,
},
/// Join a network, or start one already configured.
Join { name: String, secret: String },
/// Leave a network entirely.
Leave { network_id: String },
/// Change the hostname this device announces.
SetHostname(String),
/// Fetch a network's shared secret and place it on the clipboard.
CopySecret { network_id: String },
}
/// The latest the worker knows, read by the UI each frame.
#[derive(Clone, Default)]
pub(crate) struct Snapshot {
/// The last status, or the reason it could not be read (agent down, no
/// permission on the socket, …).
pub status: Option<Result<StatusReport, String>>,
/// The outcome of the most recent command, for a transient banner.
pub last_action: Option<Result<String, String>>,
/// Whether a command is currently being executed.
pub busy: bool,
/// Bumped each time `status` is refreshed, so the UI can tell new data from
/// a repaint of the same data and sample traffic rates only on change.
pub generation: u64,
/// When this status was read, for computing rates between refreshes.
pub at: Option<Instant>,
/// The socket the last request went to.
pub socket: PathBuf,
}
/// Handle the UI holds: send commands, read the latest snapshot.
pub(crate) struct AgentClient {
tx: mpsc::UnboundedSender<Command>,
shared: Arc<Mutex<Snapshot>>,
/// A secret fetched for the clipboard, taken by the UI thread which owns
/// the clipboard; kept out of [`Snapshot`] so it is copied once, not every
/// frame, and never travels in the cloned snapshot.
pending_copy: Arc<Mutex<Option<String>>>,
}
impl AgentClient {
/// Starts the worker on `runtime`, reporting to `ctx` (for repaints).
pub(crate) fn spawn(runtime: &tokio::runtime::Handle, ctx: egui::Context) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
let shared = Arc::new(Mutex::new(Snapshot {
socket: resolve_socket(),
..Snapshot::default()
}));
let pending_copy = Arc::new(Mutex::new(None));
runtime.spawn(worker(
rx,
Arc::clone(&shared),
Arc::clone(&pending_copy),
ctx,
));
Self {
tx,
shared,
pending_copy,
}
}
/// Takes any secret the worker fetched for the clipboard.
pub(crate) fn take_pending_copy(&self) -> Option<String> {
lock(&self.pending_copy).take()
}
/// The control socket this client talks to, as last resolved.
pub(crate) fn socket(&self) -> PathBuf {
lock(&self.shared).socket.clone()
}
/// Sends a command; a dropped worker simply means nothing happens.
pub(crate) fn send(&self, command: Command) {
let _ = self.tx.send(command);
}
/// A copy of the latest snapshot.
pub(crate) fn snapshot(&self) -> Snapshot {
lock(&self.shared).clone()
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
/// The worker loop: refresh on an interval and whenever a command runs.
async fn worker(
mut rx: mpsc::UnboundedReceiver<Command>,
shared: Arc<Mutex<Snapshot>>,
pending_copy: Arc<Mutex<Option<String>>>,
ctx: egui::Context,
) {
refresh(&shared, &ctx).await;
let mut tick = tokio::time::interval(POLL_INTERVAL);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = tick.tick() => {
refresh(&shared, &ctx).await;
}
command = rx.recv() => {
let Some(command) = command else { return };
run(&resolve_socket(), command, &shared, &pending_copy, &ctx).await;
refresh(&shared, &ctx).await;
}
}
}
}
/// Reads status and stores it, mapping any failure to a human string.
///
/// The socket is looked for again each time: a window opened before the agent
/// was started would otherwise keep asking the per-user path for ever, while
/// the service came up at the system one.
async fn refresh(shared: &Arc<Mutex<Snapshot>>, ctx: &egui::Context) {
let socket = resolve_socket();
let status = ipc::request_status(&socket)
.await
.map_err(|err| err.to_string());
{
let mut guard = lock(shared);
guard.status = Some(status);
guard.generation = guard.generation.wrapping_add(1);
guard.at = Some(Instant::now());
guard.socket = socket;
}
ctx.request_repaint();
}
/// Runs one command and records its outcome.
async fn run(
socket: &std::path::Path,
command: Command,
shared: &Arc<Mutex<Snapshot>>,
pending_copy: &Arc<Mutex<Option<String>>>,
ctx: &egui::Context,
) {
lock(shared).busy = true;
ctx.request_repaint();
let outcome: Result<String, String> = match command {
Command::SetActive { network_id, active } => ipc::set_active(socket, &network_id, active)
.await
.map(|report| {
let state = if report.active { "started" } else { "stopped" };
format!("{} {}", state, report.name)
})
.map_err(|err| err.to_string()),
Command::SetBroadcast {
network_id,
enabled,
} => ipc::set_broadcast(socket, &network_id, enabled)
.await
.map(|on| format!("broadcast {}", if on { "on" } else { "off" }))
.map_err(|err| err.to_string()),
Command::SetExitOffer {
network_id,
enabled,
} => ipc::set_exit_offer(socket, &network_id, enabled)
.await
.map(|on| format!("exit node {}", if on { "on" } else { "off" }))
.map_err(|err| err.to_string()),
Command::SetExitNode { network_id, peer } => {
ipc::set_exit_node(socket, &network_id, peer.as_deref())
.await
.map(|peer| match peer {
Some(_) => "now using the exit node".to_string(),
None => "stopped using an exit node".to_string(),
})
.map_err(|err| err.to_string())
}
Command::Join { name, secret } => ipc::join_network(socket, &name, &secret)
.await
.map(|report| format!("joined {}", report.name))
.map_err(|err| err.to_string()),
Command::Leave { network_id } => ipc::leave_network(socket, &network_id)
.await
.map(|report| format!("left {}", report.name))
.map_err(|err| err.to_string()),
Command::SetHostname(name) => ipc::set_hostname(socket, &name)
.await
.map(|accepted| format!("hostname: {accepted}"))
.map_err(|err| err.to_string()),
Command::CopySecret { network_id } => {
match ipc::network_secret(socket, &network_id).await {
// The secret goes only to the clipboard slot, never into the
// banner text or any log line.
Ok(secret) => {
*lock(pending_copy) = Some(secret);
Ok("secret copied to clipboard".to_string())
}
Err(err) => Err(err.to_string()),
}
}
};
{
let mut guard = lock(shared);
guard.busy = false;
guard.last_action = if matches!(&outcome, Ok(message) if message.is_empty()) {
None
} else {
Some(outcome)
};
}
ctx.request_repaint();
}
/// Where the GUI looks for the agent: `TSUNAGI_CONTROL_SOCKET` if set, else the
/// platform default (the system service's path).
pub(crate) fn resolve_socket() -> PathBuf {
if let Some(path) = std::env::var_os("TSUNAGI_CONTROL_SOCKET") {
return PathBuf::from(path);
}
ipc::default_control_socket_path()
}
+140
View File
@@ -0,0 +1,140 @@
//! The eframe application behind the window: the main view, the per-network
//! devices windows, and the agent worker.
//!
//! It runs in its own process, started by the tray when the user chooses Open
//! and gone when the window is closed. The tray, which has to outlive the
//! window, lives in the other process; see [`crate::tray`].
use std::time::Duration;
use eframe::egui;
use crate::about;
use crate::agent::AgentClient;
use crate::devices;
use crate::stats::Traffic;
use crate::ui::{self, UiState};
/// The window's state.
pub(crate) struct App {
agent: AgentClient,
state: UiState,
/// Traffic rates derived from successive snapshots.
traffic: Traffic,
/// The snapshot generation last folded into `traffic`.
sampled: u64,
}
impl App {
/// Builds the app: resolves the socket and starts the worker.
pub(crate) fn new(
cc: &eframe::CreationContext<'_>,
runtime: tokio::runtime::Handle,
) -> Result<Self, String> {
let ctx = cc.egui_ctx.clone();
// Add the Phosphor icon glyphs to the font set so the icon buttons
// render instead of showing as tofu.
let mut fonts = egui::FontDefinitions::default();
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
ctx.set_fonts(fonts);
let agent = AgentClient::spawn(&runtime, ctx);
Ok(Self {
agent,
state: UiState::default(),
traffic: Traffic::default(),
sampled: 0,
})
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let snapshot = self.agent.snapshot();
if snapshot.generation != self.sampled
&& let (Some(Ok(report)), Some(at)) = (&snapshot.status, snapshot.at)
{
self.traffic.observe(report, at);
self.sampled = snapshot.generation;
}
ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic);
self.show_devices_windows(ctx, &snapshot);
self.show_about_window(ctx, &snapshot);
// A secret the worker fetched for the clipboard: copy it here, on the
// UI thread that owns the clipboard, exactly once.
if let Some(secret) = self.agent.take_pending_copy() {
ctx.copy_text(secret);
}
// Keep ticking so fresh status is picked up.
ctx.request_repaint_after(Duration::from_millis(500));
}
}
impl App {
/// Renders the About window while it is open.
fn show_about_window(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) {
if !self.state.about_open {
return;
}
let builder = egui::ViewportBuilder::default()
.with_title("About tsunagi")
.with_inner_size([560.0, 520.0])
.with_min_inner_size([380.0, 280.0]);
let socket = self.agent.socket();
let keep = ctx.show_viewport_immediate(
egui::ViewportId::from_hash_of("about"),
builder,
|vctx, _class| {
about::show(vctx, &socket, snapshot);
!vctx.input(|i| i.viewport().close_requested())
},
);
if !keep {
self.state.about_open = false;
}
}
/// Renders one separate window per open network-devices view.
fn show_devices_windows(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) {
let Some(Ok(report)) = &snapshot.status else {
return;
};
let open: Vec<String> = self.state.open_devices.iter().cloned().collect();
for id in open {
let Some(network) = report.networks.iter().find(|n| n.network_id == id) else {
// The network is gone (left/forgotten); close its window.
self.state.open_devices.remove(&id);
continue;
};
let viewport_id = egui::ViewportId::from_hash_of(("devices", &id));
let builder = egui::ViewportBuilder::default()
.with_title(devices::title(network))
.with_inner_size([940.0, 540.0])
.with_min_inner_size([480.0, 320.0]);
let agent = &self.agent;
let traffic = &self.traffic;
// The window shares the single unit setting, so its toggle and the
// main window's stay in step.
let unit = &mut self.state.unit;
let confirm = &mut self.state.exit_confirm;
let keep = ctx.show_viewport_immediate(viewport_id, builder, |vctx, _class| {
devices::show(
vctx,
agent,
unit,
confirm,
network,
&report.endpoint_id,
traffic,
);
!vctx.input(|i| i.viewport().close_requested())
});
if !keep {
self.state.open_devices.remove(&id);
}
}
}
}
+580
View File
@@ -0,0 +1,580 @@
//! The per-network devices window: full detail about one network — its
//! settings and counters, and one table of every device, online first, with
//! inline traffic plots for the connected ones.
//!
//! Rendered in a separate OS window (an egui viewport) opened from a tile in
//! the main window.
use std::collections::HashMap;
use eframe::egui;
use tsunagi::ipc::{NetworkReport, OverlayPeerReport};
use crate::agent::{AgentClient, Command};
use crate::exit;
use crate::format;
use crate::stats::{Traffic, Unit};
/// A readable title for a network's window.
pub(crate) fn title(network: &NetworkReport) -> String {
format!("tsunagi · {}", network.name)
}
/// One row of the device table, built from a tunnel and/or a signed member.
struct Device<'a> {
online: bool,
/// The last hostname seen for it, kept while it is offline.
hostname: Option<&'a str>,
/// What clicking the hostname copies: `hostname.network`, so it resolves.
copy: Option<String>,
/// The full endpoint id, which clicking the short form copies.
id: &'a str,
proto: &'a str,
address: Option<String>,
handshake_secs: Option<u64>,
overlay: Option<&'a OverlayPeerReport>,
/// It is connected and offers to be an exit node.
exit_node: bool,
/// It is the exit node this device sends its internet traffic through.
in_use: bool,
}
/// An exit-node choice the user has to confirm: all their traffic goes through
/// somebody else's device, or stops doing so.
pub(crate) struct ExitConfirm {
network_id: String,
peer_id: String,
name: String,
/// Stop using it, rather than start.
stop: bool,
}
impl Device<'_> {
fn name(&self) -> String {
self.hostname
.map_or_else(|| format::short(self.id), str::to_string)
}
fn sort_key(&self) -> String {
self.hostname.unwrap_or(self.id).to_lowercase()
}
}
/// The first characters of an endpoint id, enough to tell devices apart.
fn short_id(id: &str) -> String {
id.chars().take(5).collect()
}
/// The device cell: the hostname and, in brackets, the start of the id.
/// Clicking either copies its own full value.
fn name_cell(ui: &mut egui::Ui, device: &Device<'_>) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
match (device.hostname, &device.copy) {
(Some(hostname), Some(copy)) => {
format::copy_field(ui, hostname, copy);
format::copy_field(ui, &format!("({})", short_id(device.id)), device.id);
}
_ => format::copy_field(ui, &short_id(device.id), device.id),
}
});
}
/// Draws the window body into its viewport.
pub(crate) fn show(
ctx: &egui::Context,
agent: &AgentClient,
unit: &mut Unit,
confirm: &mut Option<ExitConfirm>,
network: &NetworkReport,
own_id: &str,
traffic: &Traffic,
) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.horizontal(|ui| {
ui.heading(&network.name);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.selectable_value(unit, Unit::Bytes, "bytes");
ui.selectable_value(unit, Unit::Packets, "pkts");
ui.label("show:");
});
});
// A copy of the chosen unit for the read-only rendering below.
let unit = *unit;
summary(ui, network);
ui.add_space(6.0);
ui.label(egui::RichText::new("traffic").strong());
format::sparkline(ui, traffic.network(&network.network_id), unit, 48.0);
ui.add_space(8.0);
let devices = devices(network, own_id);
let online = devices.iter().filter(|d| d.online).count();
ui.label(
egui::RichText::new(format!(
"devices ({} online / {} known)",
online,
devices.len()
))
.strong(),
);
egui::ScrollArea::both()
.auto_shrink([false, false])
.show(ui, |ui| {
device_table(ui, unit, network, &devices, traffic, confirm);
});
});
confirm_exit(ctx, agent, confirm, &network.network_id);
}
/// The confirmation for an exit-node choice, drawn in the window it was
/// made in. Everything behind it is blocked until it is answered.
fn confirm_exit(
ctx: &egui::Context,
agent: &AgentClient,
confirm: &mut Option<ExitConfirm>,
network_id: &str,
) {
let Some(pending) = confirm.as_ref().filter(|c| c.network_id == network_id) else {
return;
};
egui::Area::new(egui::Id::new("exit confirm backdrop"))
.order(egui::Order::Middle)
.fixed_pos(egui::Pos2::ZERO)
.show(ctx, |ui| {
let screen = ctx.screen_rect();
ui.allocate_response(screen.size(), egui::Sense::click_and_drag());
ui.painter()
.rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140));
});
let (mut accepted, mut cancelled) = (false, false);
egui::Window::new(if pending.stop {
"Stop using exit node"
} else {
"Use as exit node"
})
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.set_max_width(380.0);
if pending.stop {
ui.label(format!("Stop using {} as exit node?", pending.name));
ui.label("Your internet traffic will leave this device the ordinary way again.");
} else {
ui.label(
egui::RichText::new(format!(
"Send ALL your internet traffic through {}?",
pending.name
))
.strong(),
);
ui.label(
"Everything not on this network will leave through that device, which can \
see it.",
);
}
ui.add_space(8.0);
ui.horizontal(|ui| {
let label = if pending.stop {
"Stop"
} else {
"Use as exit node"
};
if ui.button(label).clicked() {
accepted = true;
}
if ui.button("Cancel").clicked() {
cancelled = true;
}
});
});
if accepted {
agent.send(Command::SetExitNode {
network_id: pending.network_id.clone(),
peer: (!pending.stop).then(|| pending.peer_id.clone()),
});
}
if accepted || cancelled || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
*confirm = None;
}
}
/// The network's own settings and counters.
fn summary(ui: &mut egui::Ui, network: &NetworkReport) {
egui::Grid::new("summary")
.num_columns(2)
.spacing([12.0, 3.0])
.show(ui, |ui| {
ui.weak("id");
format::copy_field(ui, &network.network_id, &network.network_id);
ui.end_row();
ui.weak("active");
ui.label(yes_no(network.active));
ui.end_row();
ui.weak("broadcast");
ui.label(yes_no(network.broadcast));
ui.end_row();
if let Some(range) = &network.range {
ui.weak("range");
format::copy_field(ui, range, range);
ui.end_row();
}
if let Some(address) = network.overlay.as_ref().and_then(|o| o.address.as_ref()) {
ui.weak("this device");
format::copy_field(ui, address, address);
ui.end_row();
}
ui.weak("candidates");
ui.label(network.candidates.to_string());
ui.end_row();
ui.weak("relay fwd/via/recv");
ui.label(format!(
"{} / {} / {}",
network.relay_forwarded, network.relay_sent_via, network.relay_received_via
));
ui.end_row();
ui.weak("dial/handshake fails");
ui.label(format!(
"{} / {}",
network.dial_failures, network.handshake_failures
));
ui.end_row();
});
}
/// Every other device this network knows of, from a live tunnel or the signed
/// membership. This device is in the roster too but is not one of the others.
fn known_ids<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<&'a str> {
let overlay_peers = network
.overlay
.as_ref()
.map(|o| o.peers.as_slice())
.unwrap_or(&[]);
let mut ids: Vec<&str> = Vec::new();
let candidates = overlay_peers
.iter()
.map(|p| p.endpoint_id.as_str())
.chain(network.peers.iter().map(|p| p.endpoint_id.as_str()))
.chain(network.members.iter().map(|m| m.endpoint_id.as_str()))
.chain(network.exit.via.as_deref());
for id in candidates {
if id != own_id && !ids.contains(&id) {
ids.push(id);
}
}
ids
}
/// How many other devices this network knows of, connected or not.
pub(crate) fn known_count(network: &NetworkReport, own_id: &str) -> usize {
known_ids(network, own_id).len()
}
/// Builds the device list: every endpoint known from a live tunnel or the
/// signed membership, online ones first.
fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<Device<'a>> {
// The signed members carry the last name seen, so a device that is away
// is still called by it; a live announcement is fresher and wins.
let mut hostnames: HashMap<&str, &str> = network
.members
.iter()
.filter_map(|m| m.hostname.as_deref().map(|h| (m.endpoint_id.as_str(), h)))
.collect();
hostnames.extend(
network
.peers
.iter()
.filter_map(|p| p.hostname.as_deref().map(|h| (p.endpoint_id.as_str(), h))),
);
let overlay_peers = network
.overlay
.as_ref()
.map(|o| o.peers.as_slice())
.unwrap_or(&[]);
let ids = known_ids(network, own_id);
let mut devices: Vec<Device> = ids
.into_iter()
.map(|id| {
let overlay = overlay_peers.iter().find(|p| p.endpoint_id == id);
let member = network.members.iter().find(|m| m.endpoint_id == id);
let online = overlay.is_some_and(|p| p.handshake_secs_ago.is_some());
let hostname = hostnames.get(id).copied();
Device {
online,
hostname,
copy: hostname.map(|h| format!("{h}.{}", network.name)),
id,
proto: overlay.map_or("—", |p| p.protocol.as_str()),
address: overlay
.and_then(|p| p.address.clone())
.or_else(|| member.and_then(|m| m.overlay_address_v4.clone())),
handshake_secs: overlay.and_then(|p| p.handshake_secs_ago),
overlay,
exit_node: online
&& network
.peers
.iter()
.any(|p| p.endpoint_id == id && p.exit_node),
in_use: network.exit.via.as_deref() == Some(id),
}
})
.collect();
devices.sort_by(|a, b| {
b.online
.cmp(&a.online)
.then_with(|| a.sort_key().cmp(&b.sort_key()))
});
devices
}
/// The widest value each column is expected to hold, so the table starts out
/// wide enough and does not reflow as numbers grow.
const COLUMN_SAMPLES: [&str; 9] = [
"",
"exit",
"tcp-tls",
"255.255.255.255",
"23h 59m",
"99999/99999",
"1023.9 MB / 1023.9 MB",
"1023.9 kB/s",
"",
];
/// Column widths that only ever grow, kept across frames.
///
/// A grid sizes each column to its widest cell *this frame*, so a value that
/// gets shorter — `14.4 MB/s` giving way to `382 pkt/s` — would pull the
/// column in and push every cell after it. Remembering the widest seen keeps
/// the table where it is.
fn column_widths(ui: &egui::Ui, id: egui::Id) -> [f32; 9] {
let stored: Option<[f32; 9]> = ui.memory(|m| m.data.get_temp(id));
stored.unwrap_or_else(|| {
let font = egui::TextStyle::Body.resolve(ui.style());
let mut widths = [0.0; 9];
for (width, sample) in widths.iter_mut().zip(COLUMN_SAMPLES) {
if !sample.is_empty() {
*width = ui.fonts(|fonts| {
fonts
.layout_no_wrap(sample.to_owned(), font.clone(), egui::Color32::WHITE)
.size()
.x
});
}
}
widths
})
}
/// One cell, at least as wide as its column has ever been.
fn cell(ui: &mut egui::Ui, column: usize, widths: &mut [f32; 9], add: impl FnOnce(&mut egui::Ui)) {
let floor = widths[column];
let response = ui.scope(|ui| {
ui.set_min_width(floor);
add(ui);
});
widths[column] = widths[column].max(response.response.rect.width());
}
/// The exit-node mark of a device: shown when it offers to be one, bright
/// for the one in use, and clickable (to confirm) only while that can be done.
///
/// The one in use stays clickable even when it has gone away, since stopping
/// is the only way out of the traffic being blocked.
fn exit_cell(
ui: &mut egui::Ui,
device: &Device<'_>,
network_id: &str,
confirm: &mut Option<ExitConfirm>,
) {
if !device.exit_node && !device.in_use {
ui.label("");
return;
}
let (color, hint) = if device.in_use {
let color = if device.online {
exit::GOOD
} else {
exit::LOUD
};
(color, "in use as your exit node — click to stop")
} else {
(
ui.visuals().text_color(),
"offers to be an exit node — click to send all your internet traffic through it",
)
};
let response = ui
.add(
egui::Label::new(
egui::RichText::new(egui_phosphor::regular::SIGN_OUT)
.size(16.0)
.color(color),
)
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(hint);
if response.clicked() {
*confirm = Some(ExitConfirm {
network_id: network_id.to_string(),
peer_id: device.id.to_string(),
name: device.name(),
stop: device.in_use,
});
}
}
/// One table of all devices: online rows carry live counters and a plot,
/// offline rows show what is known (name and address) and dashes.
fn device_table(
ui: &mut egui::Ui,
unit: Unit,
network: &NetworkReport,
devices: &[Device<'_>],
traffic: &Traffic,
confirm: &mut Option<ExitConfirm>,
) {
let network_id = network.network_id.as_str();
if devices.is_empty() {
ui.weak("none");
return;
}
let widths_id = egui::Id::new(("devices column widths", network_id));
let mut widths = column_widths(ui, widths_id);
egui::Grid::new("devices")
.num_columns(9)
.striped(true)
.spacing([10.0, 4.0])
.show(ui, |ui| {
for (column, header) in [
"device",
"exit",
"proto",
"address",
"handshake",
"pkts",
"bytes",
"rate",
"plot",
]
.into_iter()
.enumerate()
{
cell(ui, column, &mut widths, |ui| {
ui.weak(header);
});
}
ui.end_row();
for device in devices {
cell(ui, 0, &mut widths, |ui| name_cell(ui, device));
cell(ui, 1, &mut widths, |ui| {
exit_cell(ui, device, network_id, confirm);
});
cell(ui, 2, &mut widths, |ui| {
ui.label(device.proto);
});
cell(ui, 3, &mut widths, |ui| match &device.address {
Some(address) => format::copy_field(ui, address, address),
None => {
ui.weak("—");
}
});
cell(ui, 4, &mut widths, |ui| {
ui.label(
device
.handshake_secs
.map_or_else(|| "offline".to_string(), format::age),
);
});
match device.overlay.filter(|_| device.online) {
Some(peer) => {
let series = traffic.peer(network_id, &peer.public_key);
cell(ui, 5, &mut widths, |ui| {
ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets));
});
cell(ui, 6, &mut widths, |ui| {
ui.label(format!(
"{} / {}",
format::bytes(peer.tx_bytes as f64),
format::bytes(peer.rx_bytes as f64)
));
});
cell(ui, 7, &mut widths, |ui| {
ui.add(
egui::Label::new(format::series_rate(series, unit))
.wrap_mode(egui::TextWrapMode::Extend),
);
});
ui.allocate_ui(egui::vec2(100.0, 20.0), |ui| {
format::sparkline(ui, series, unit, 18.0);
});
}
None => {
for column in 5..8 {
cell(ui, column, &mut widths, |ui| {
ui.weak("—");
});
}
ui.weak("—");
}
}
ui.end_row();
}
// Everything this agent has moved through the overlay, including
// devices that have since gone away.
let (mut tx_packets, mut rx_packets, mut tx_bytes, mut rx_bytes) = (0u64, 0, 0, 0);
for peer in devices.iter().filter_map(|d| d.overlay) {
tx_packets += peer.tx_packets;
rx_packets += peer.rx_packets;
tx_bytes += peer.tx_bytes;
rx_bytes += peer.rx_bytes;
}
cell(ui, 0, &mut widths, |ui| {
ui.strong("total");
});
for column in 1..5 {
cell(ui, column, &mut widths, |ui| {
ui.label("");
});
}
cell(ui, 5, &mut widths, |ui| {
ui.strong(format!("{tx_packets}/{rx_packets}"));
});
cell(ui, 6, &mut widths, |ui| {
ui.strong(format!(
"{} / {}",
format::bytes(tx_bytes as f64),
format::bytes(rx_bytes as f64)
));
});
cell(ui, 7, &mut widths, |ui| {
ui.add(
egui::Label::new(
egui::RichText::new(format::series_rate(traffic.network(network_id), unit))
.strong(),
)
.wrap_mode(egui::TextWrapMode::Extend),
);
});
ui.end_row();
});
ui.memory_mut(|m| m.data.insert_temp(widths_id, widths));
}
fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}
+164
View File
@@ -0,0 +1,164 @@
//! Exit nodes as the windows show them: what to call one, and what is wrong
//! with the ones in play.
use eframe::egui;
use tsunagi::ipc::NetworkReport;
use crate::format;
/// An exit node that is working.
pub(crate) const GOOD: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a);
/// Something about exit nodes the user has to look at.
pub(crate) const LOUD: egui::Color32 = egui::Color32::from_rgb(0xe0, 0x4a, 0x3c);
/// What to run to turn kernel forwarding on, which the agent never does.
pub(crate) const FORWARDING_COMMAND: &str = "sudo sysctl -w net.ipv4.ip_forward=1";
/// One thing wrong, loud enough to read at a glance.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Warning {
pub(crate) text: String,
/// A command the user will want, copied by clicking the warning.
pub(crate) copy: Option<&'static str>,
}
/// What to call a device in a network, even while it is away.
pub(crate) fn name_of(network: &NetworkReport, id: &str) -> String {
network
.peers
.iter()
.find(|peer| peer.endpoint_id == id)
.and_then(|peer| peer.hostname.clone())
.or_else(|| {
network
.members
.iter()
.find(|member| member.endpoint_id == id)
.and_then(|member| member.hostname.clone())
})
.unwrap_or_else(|| format::short(id))
}
/// Everything wrong with a network's exit-node settings, most urgent first.
pub(crate) fn warnings(network: &NetworkReport) -> Vec<Warning> {
let exit = &network.exit;
let mut out = Vec::new();
if let Some(via) = &exit.via {
let name = name_of(network, via);
if !exit.via_online {
out.push(Warning {
text: format!(
"exit node {name} is offline — your internet traffic is blocked until it \
returns; stop using it from the devices window"
),
copy: None,
});
} else {
match &exit.client_rules {
Some(rules) if rules.ok => {}
Some(rules) => out.push(Warning {
text: format!(
"the routes to exit node {name} are incomplete: {}",
rules.detail
),
copy: None,
}),
None => out.push(Warning {
text: format!("the routes to exit node {name} are not installed"),
copy: None,
}),
}
}
}
if exit.offering {
match &exit.offer_rules {
Some(rules) if !rules.ok => out.push(Warning {
text: format!("exit node: its rules are incomplete: {}", rules.detail),
copy: None,
}),
None => out.push(Warning {
text: "exit node: its rules are not installed yet".to_string(),
copy: None,
}),
Some(_) => {}
}
if exit.forwarding == Some(false) {
out.push(Warning {
text: format!("exit node: kernel forwarding is OFF — run: {FORWARDING_COMMAND}"),
copy: Some(FORWARDING_COMMAND),
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use tsunagi::ipc::{ExitReport, PeerReport, RuleSetReport};
fn network(exit: ExitReport) -> NetworkReport {
NetworkReport {
peers: vec![PeerReport {
endpoint_id: "abcdef0123456789".into(),
hostname: Some("music".into()),
exit_node: true,
..Default::default()
}],
exit,
..Default::default()
}
}
fn ok() -> Option<RuleSetReport> {
Some(RuleSetReport {
ok: true,
detail: "ok".into(),
})
}
#[test]
fn a_network_with_no_exit_settings_has_nothing_to_warn_about() {
assert!(warnings(&network(ExitReport::default())).is_empty());
}
#[test]
fn a_lost_exit_node_says_the_traffic_is_blocked() {
let found = warnings(&network(ExitReport {
via: Some("abcdef0123456789".into()),
via_online: false,
client_rules: ok(),
..Default::default()
}));
assert_eq!(found.len(), 1);
assert!(
found[0].text.contains("music is offline"),
"{}",
found[0].text
);
assert!(found[0].text.contains("blocked"), "{}", found[0].text);
}
#[test]
fn a_working_exit_node_is_quiet_and_forwarding_off_gives_the_command() {
let working = ExitReport {
via: Some("abcdef0123456789".into()),
via_online: true,
client_rules: ok(),
..Default::default()
};
assert!(warnings(&network(working)).is_empty());
let offering = warnings(&network(ExitReport {
offering: true,
offer_rules: ok(),
forwarding: Some(false),
..Default::default()
}));
assert_eq!(offering.len(), 1);
assert_eq!(offering[0].copy, Some(FORWARDING_COMMAND));
}
}
+170
View File
@@ -0,0 +1,170 @@
//! Formatting and small shared widgets: humanized ages, byte sizes and rates,
//! a fixed-size sparkline, and a click-to-copy field with a green-flash feedback.
use std::time::{Duration, Instant};
use eframe::egui;
use crate::stats::{Series, Unit};
/// Sparkline accent.
const ACCENT: egui::Color32 = egui::Color32::from_rgb(0x2f, 0x80, 0xd8);
/// The green a field flashes to when its value is copied.
const COPIED_GREEN: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a);
/// How long the copy flash takes to fade back to normal.
const FLASH: Duration = Duration::from_secs(1);
/// A short, readable prefix of a long identifier (char-safe, no panic).
pub(crate) fn short(id: &str) -> String {
const KEEP: usize = 12;
if id.chars().count() <= KEEP {
id.to_string()
} else {
format!("{}…", id.chars().take(KEEP).collect::<String>())
}
}
/// A duration in seconds as a compact age (`5s`, `12m`, `3h 20m`, `2d 4h`).
pub(crate) fn age(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3_600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h {}m", secs / 3_600, (secs % 3_600) / 60)
} else {
format!("{}d {}h", secs / 86_400, (secs % 86_400) / 3_600)
}
}
/// A byte count in binary units.
pub(crate) fn bytes(n: f64) -> String {
const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"];
let mut value = n.max(0.0);
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{} B", value as u64)
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
/// A rate in the chosen unit, per second.
pub(crate) fn rate(value: f32, unit: Unit) -> String {
match unit {
Unit::Packets => {
if value >= 1000.0 {
format!("{:.1}k pkt/s", value / 1000.0)
} else {
format!("{value:.0} pkt/s")
}
}
Unit::Bytes => format!("{}/s", bytes(f64::from(value))),
}
}
/// The current rate of a series as a string, or a dash when unsampled.
pub(crate) fn series_rate(series: Option<&Series>, unit: Unit) -> String {
series.map_or_else(|| "—".to_string(), |s| rate(s.rate(unit), unit))
}
/// A label whose text is `display` and which copies `value` to the clipboard
/// when clicked. Feedback is a green flash that fades back over a second — the
/// text itself never changes, so a dense table does not reflow on a copy.
pub(crate) fn copy_field(ui: &mut egui::Ui, display: &str, value: &str) {
let id = ui.make_persistent_id(("copy", value, display));
let copied_at: Option<Instant> = ui.memory(|memory| memory.data.get_temp(id));
// 1.0 right after the click, fading to 0.0 a second later.
let flash = copied_at.and_then(|at| {
let elapsed = at.elapsed().as_secs_f32();
let span = FLASH.as_secs_f32();
(elapsed < span).then(|| 1.0 - elapsed / span)
});
let text = match flash {
Some(factor) => {
let base = ui.visuals().text_color();
egui::RichText::new(display).color(lerp_color(base, COPIED_GREEN, factor))
}
None => egui::RichText::new(display),
};
let response = ui
.add(egui::Label::new(text).sense(egui::Sense::click()))
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("click to copy");
if response.clicked() {
ui.ctx().copy_text(value.to_owned());
ui.memory_mut(|memory| memory.data.insert_temp(id, Instant::now()));
}
if flash.is_some() {
// Animate the fade smoothly until it completes.
ui.ctx().request_repaint();
}
}
/// Linear blend between two colours, `t` in `0.0..=1.0` toward `b`.
fn lerp_color(a: egui::Color32, b: egui::Color32, t: f32) -> egui::Color32 {
let t = t.clamp(0.0, 1.0);
let mix = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round() as u8;
egui::Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b()))
}
/// A fixed-height sparkline of a series' recent rate, with the peak (top-left)
/// and current value (bottom-right) overlaid. The space is always reserved —
/// even with no data — so the layout never jumps when data arrives.
pub(crate) fn sparkline(ui: &mut egui::Ui, series: Option<&Series>, unit: Unit, height: f32) {
let width = ui.available_width();
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
let painter = ui.painter_at(rect);
let visuals = ui.visuals();
painter.rect_filled(rect, 2.0, visuals.extreme_bg_color);
let Some(series) = series else {
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
"no data yet",
egui::FontId::proportional(10.0),
visuals.weak_text_color(),
);
return;
};
let history = series.history(unit);
let peak = history.iter().copied().fold(1.0_f32, f32::max);
if history.len() >= 2 {
let count = history.len();
let points: Vec<egui::Pos2> = history
.iter()
.enumerate()
.map(|(i, value)| {
let x = rect.left() + rect.width() * (i as f32 / (count - 1) as f32);
let y = rect.bottom() - 2.0 - (rect.height() - 12.0) * (value / peak);
egui::pos2(x, y)
})
.collect();
painter.add(egui::Shape::line(
points,
egui::Stroke::new(1.5_f32, ACCENT),
));
}
painter.text(
rect.left_top() + egui::vec2(4.0, 2.0),
egui::Align2::LEFT_TOP,
format!("peak {}", rate(peak, unit)),
egui::FontId::proportional(9.0),
visuals.weak_text_color(),
);
painter.text(
rect.right_bottom() + egui::vec2(-4.0, -2.0),
egui::Align2::RIGHT_BOTTOM,
rate(series.rate(unit), unit),
egui::FontId::proportional(10.0),
visuals.strong_text_color(),
);
}
+75
View File
@@ -0,0 +1,75 @@
//! Tray application for managing a running tsunagi agent.
//!
//! A tray icon and menu (Open, Quit) that opens a small window (networks, their
//! peers and addresses, join/leave, per-network active and broadcast switches).
//! There is no window until Open, and closing it leaves the icon. The window
//! is a thin client over the agent's local control socket — the same interface
//! the CLI uses.
// Nothing here is a library.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod about;
mod agent;
mod app;
mod devices;
mod exit;
mod format;
mod stats;
mod tray;
mod ui;
use eframe::egui;
use crate::app::App;
/// The argument that makes this binary the window rather than the tray.
const WINDOW_ARG: &str = "--window";
fn main() -> Result<(), Box<dyn std::error::Error>> {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
// Two processes from one binary. The tray is what stays; the window is
// started by it and ends when it is closed. A window that merely hides is
// not something every platform can do — Wayland has no such thing — and a
// hidden one that stays is worse than none.
if std::env::args().any(|arg| arg == WINDOW_ARG) {
window()
} else {
tray::run()
}
}
/// Runs the window until it is closed.
fn window() -> Result<(), Box<dyn std::error::Error>> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let handle = runtime.handle().clone();
let options = eframe::NativeOptions {
// A fixed, small window; the whole layout targets this size.
viewport: egui::ViewportBuilder::default()
.with_title("tsunagi")
.with_app_id("tsunagi")
.with_inner_size([360.0, 600.0])
.with_resizable(false),
..Default::default()
};
eframe::run_native(
"tsunagi",
options,
Box::new(move |cc| {
App::new(cc, handle.clone())
.map(|app| Box::new(app) as Box<dyn eframe::App>)
.map_err(std::convert::Into::into)
}),
)?;
// The window loop has ended; let the runtime wind down.
drop(runtime);
Ok(())
}
+122
View File
@@ -0,0 +1,122 @@
//! Traffic rates derived from successive status snapshots.
//!
//! The agent reports cumulative packet and byte counters, not rates. The GUI
//! samples them each time a fresh snapshot arrives and turns the deltas into
//! per-second rates plus short rolling histories for sparklines. One series per
//! key: a network total, and each peer within it. Both packets and bytes are
//! tracked so the UI can switch between them.
use std::collections::{HashMap, VecDeque};
use std::time::Instant;
use tsunagi::ipc::StatusReport;
/// How many rate samples to keep for a sparkline.
const HISTORY: usize = 60;
/// Which quantity a rate or history is of.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Unit {
/// Packets per second.
Packets,
/// Bytes per second.
Bytes,
}
/// One series' rolling packet and byte rates.
#[derive(Default)]
pub(crate) struct Series {
last: Option<(Instant, u64, u64)>,
packets_rate: f32,
bytes_rate: f32,
packets_history: VecDeque<f32>,
bytes_history: VecDeque<f32>,
}
impl Series {
fn sample(&mut self, now: Instant, packets: u64, bytes: u64) {
if let Some((then, prev_packets, prev_bytes)) = self.last {
let dt = now.saturating_duration_since(then).as_secs_f32();
if dt > 0.0 {
self.packets_rate = packets.saturating_sub(prev_packets) as f32 / dt;
self.bytes_rate = bytes.saturating_sub(prev_bytes) as f32 / dt;
push(&mut self.packets_history, self.packets_rate);
push(&mut self.bytes_history, self.bytes_rate);
}
}
self.last = Some((now, packets, bytes));
}
/// The current rate in the chosen unit.
pub(crate) fn rate(&self, unit: Unit) -> f32 {
match unit {
Unit::Packets => self.packets_rate,
Unit::Bytes => self.bytes_rate,
}
}
/// The rolling history in the chosen unit.
pub(crate) fn history(&self, unit: Unit) -> &VecDeque<f32> {
match unit {
Unit::Packets => &self.packets_history,
Unit::Bytes => &self.bytes_history,
}
}
}
fn push(history: &mut VecDeque<f32>, value: f32) {
history.push_back(value);
while history.len() > HISTORY {
history.pop_front();
}
}
/// All traffic series, keyed by a stable string.
#[derive(Default)]
pub(crate) struct Traffic {
series: HashMap<String, Series>,
}
impl Traffic {
/// Folds a fresh snapshot into the rates. Call once per new generation.
pub(crate) fn observe(&mut self, report: &StatusReport, now: Instant) {
for network in &report.networks {
let Some(overlay) = &network.overlay else {
continue;
};
let (mut net_packets, mut net_bytes) = (0u64, 0u64);
for peer in &overlay.peers {
let packets = peer.tx_packets.saturating_add(peer.rx_packets);
let bytes = peer.tx_bytes.saturating_add(peer.rx_bytes);
net_packets = net_packets.saturating_add(packets);
net_bytes = net_bytes.saturating_add(bytes);
self.series
.entry(peer_key(&network.network_id, &peer.public_key))
.or_default()
.sample(now, packets, bytes);
}
self.series
.entry(net_key(&network.network_id))
.or_default()
.sample(now, net_packets, net_bytes);
}
}
/// The network-total series, if sampled.
pub(crate) fn network(&self, network_id: &str) -> Option<&Series> {
self.series.get(&net_key(network_id))
}
/// One peer's series, if sampled.
pub(crate) fn peer(&self, network_id: &str, public_key: &str) -> Option<&Series> {
self.series.get(&peer_key(network_id, public_key))
}
}
fn net_key(network_id: &str) -> String {
format!("net:{network_id}")
}
fn peer_key(network_id: &str, public_key: &str) -> String {
format!("peer:{network_id}:{public_key}")
}
+210
View File
@@ -0,0 +1,210 @@
//! The tray: the icon, its menu, and starting the window.
//!
//! This is the process that stays. It holds no window and no GUI toolkit of
//! its own; choosing Open starts this same binary again as the window, which
//! is a perfectly ordinary program that ends when it is closed.
use std::process::{Child, Command};
use std::sync::{Arc, Mutex};
use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
/// The window process, when there is one.
struct Window {
child: Mutex<Option<Child>>,
}
impl Window {
/// Starts the window unless one is already open.
///
/// Raising an existing one is not something a process can ask for on
/// every platform, so choosing Open while it is open does nothing.
fn open(&self) {
let mut slot = self.child.lock().unwrap_or_else(|e| e.into_inner());
if let Some(child) = slot.as_mut() {
match child.try_wait() {
Ok(None) => return,
_ => *slot = None,
}
}
let started = std::env::current_exe()
.map_err(|err| err.to_string())
.and_then(|exe| {
Command::new(exe)
.arg(crate::WINDOW_ARG)
.spawn()
.map_err(|err| err.to_string())
});
match started {
Ok(child) => *slot = Some(child),
Err(err) => eprintln!("cannot open the window: {err}"),
}
}
/// Closes the window if it is open.
fn close(&self) {
let mut slot = self.child.lock().unwrap_or_else(|e| e.into_inner());
if let Some(mut child) = slot.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
/// A built tray icon and the ids of its menu items.
struct Built {
icon: TrayIcon,
open: MenuId,
quit: MenuId,
}
fn build() -> Result<Built, String> {
let open = MenuItem::new("Open tsunagi", true, None);
let quit = MenuItem::new("Quit", true, None);
let menu = Menu::new();
menu.append(&open).map_err(|err| err.to_string())?;
menu.append(&PredefinedMenuItem::separator())
.map_err(|err| err.to_string())?;
menu.append(&quit).map_err(|err| err.to_string())?;
let icon = TrayIconBuilder::new()
.with_tooltip("tsunagi")
.with_menu(Box::new(menu))
.with_icon(make_icon()?)
.build()
.map_err(|err| err.to_string())?;
Ok(Built {
icon,
open: open.id().clone(),
quit: quit.id().clone(),
})
}
/// [`build`], with a missing indicator library reported instead of panicking.
///
/// The library is loaded on first use, and its binding panics when it is not
/// installed, which reads as a bug in this program rather than a package that
/// is not there.
#[cfg(target_os = "linux")]
fn build_checked() -> Result<Built, String> {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(build));
std::panic::set_hook(hook);
built.unwrap_or_else(|_| {
Err(
"the tray needs libayatana-appindicator (Arch: libayatana-appindicator, \
Debian/Ubuntu: libayatana-appindicator3-1), which is not installed"
.to_string(),
)
})
}
/// Acts on menu choices as the platform's loop delivers them.
fn handle_menu(built: &Built, window: Arc<Window>) {
let (open, quit) = (built.open.clone(), built.quit.clone());
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
if event.id == open {
window.open();
} else if event.id == quit {
window.close();
std::process::exit(0);
}
}));
}
/// Shows the icon and runs until Quit.
///
/// Linux: the tray needs GTK, which nothing else here initialises, so this
/// thread does and runs its main loop.
#[cfg(target_os = "linux")]
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
gtk::init().map_err(|err| format!("cannot initialise GTK: {err}"))?;
let built = build_checked()?;
handle_menu(
&built,
Arc::new(Window {
child: Mutex::new(None),
}),
);
gtk::main();
drop(built.icon);
Ok(())
}
/// Shows the icon and runs until Quit.
///
/// Windows and macOS: the icon needs an event loop on the main thread to be
/// served, and macOS wants it created once that loop is running. There are no
/// windows in it; it exists to be pumped.
#[cfg(not(target_os = "linux"))]
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::window::WindowId;
struct Pump {
window: Arc<Window>,
icon: Option<TrayIcon>,
}
impl ApplicationHandler for Pump {
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {
if self.icon.is_some() {
return;
}
match build() {
Ok(built) => {
handle_menu(&built, Arc::clone(&self.window));
self.icon = Some(built.icon);
}
Err(err) => {
eprintln!("cannot create the tray icon: {err}");
std::process::exit(1);
}
}
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
let mut builder = EventLoop::builder();
// macOS: live in the menu bar with no Dock icon.
#[cfg(target_os = "macos")]
{
use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS};
builder.with_activation_policy(ActivationPolicy::Accessory);
}
let event_loop = builder.build()?;
let mut pump = Pump {
window: Arc::new(Window {
child: Mutex::new(None),
}),
icon: None,
};
event_loop.run_app(&mut pump)?;
Ok(())
}
/// A small round icon, drawn in code so there is no asset to ship yet.
fn make_icon() -> Result<Icon, String> {
const SIZE: u32 = 32;
let centre = (SIZE as f32 - 1.0) / 2.0;
let radius = SIZE as f32 * 0.46;
let mut rgba = vec![0u8; (SIZE * SIZE * 4) as usize];
for y in 0..SIZE {
for x in 0..SIZE {
let (dx, dy) = (x as f32 - centre, y as f32 - centre);
if dx * dx + dy * dy <= radius * radius {
let i = ((y * SIZE + x) * 4) as usize;
rgba[i] = 0x2f;
rgba[i + 1] = 0x80;
rgba[i + 2] = 0xd8;
rgba[i + 3] = 0xff;
}
}
}
Icon::from_rgba(rgba, SIZE, SIZE).map_err(|err| err.to_string())
}
+386
View File
@@ -0,0 +1,386 @@
//! The main window: a compact overview. Per-network detail (the full peer
//! table with inline plots) lives in a separate devices window (see
//! [`crate::devices`]), opened from each tile.
//!
//! Pure rendering: state comes from the snapshot and the traffic sampler, a
//! change to a switch or button sends a [`Command`] to the worker, and local UI
//! state (the join form, the unit toggle, which devices windows are open, an
//! in-progress hostname edit) lives in [`UiState`].
use std::collections::BTreeSet;
use eframe::egui;
use egui_phosphor::regular as icon;
use tsunagi::ipc::{NetworkReport, StatusReport};
use crate::agent::{AgentClient, Command, Snapshot};
use crate::devices;
use crate::exit;
use crate::format;
use crate::stats::{Traffic, Unit};
const OK: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a);
const BAD: egui::Color32 = egui::Color32::from_rgb(0xd0, 0x4a, 0x3c);
/// A neutral, not-garish red for the Leave button.
const LEAVE_RED: egui::Color32 = egui::Color32::from_rgb(0xa8, 0x3a, 0x3a);
/// Join form fields.
#[derive(Default)]
pub(crate) struct JoinForm {
pub(crate) name: String,
pub(crate) secret: String,
}
/// Local UI state kept across frames.
pub(crate) struct UiState {
pub(crate) join: JoinForm,
/// Whether rates and sparklines are shown in packets or bytes.
pub(crate) unit: Unit,
/// An in-progress hostname edit (`None` when not editing).
pub(crate) host_edit: Option<String>,
/// Network ids whose devices window is open.
pub(crate) open_devices: BTreeSet<String>,
/// Whether the About window is open.
pub(crate) about_open: bool,
/// An exit-node choice waiting for the user to confirm it.
pub(crate) exit_confirm: Option<devices::ExitConfirm>,
}
impl Default for UiState {
fn default() -> Self {
Self {
join: JoinForm::default(),
unit: Unit::Packets,
host_edit: None,
open_devices: BTreeSet::new(),
about_open: false,
exit_confirm: None,
}
}
}
/// Draws the main window.
pub(crate) fn draw(
ctx: &egui::Context,
agent: &AgentClient,
state: &mut UiState,
snapshot: &Snapshot,
traffic: &Traffic,
) {
egui::TopBottomPanel::top("top").show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.heading("tsunagi");
ui.label(
egui::RichText::new(env!("CARGO_PKG_VERSION"))
.small()
.weak(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(icon::INFO).on_hover_text("About").clicked() {
state.about_open = true;
}
if snapshot.busy {
ui.add(egui::Spinner::new());
}
ui.separator();
ui.selectable_value(&mut state.unit, Unit::Bytes, "bytes");
ui.selectable_value(&mut state.unit, Unit::Packets, "pkts");
ui.label("show:");
});
});
ui.add_space(4.0);
});
egui::TopBottomPanel::bottom("bottom").show(ctx, |ui| {
if let Some(action) = &snapshot.last_action {
ui.add_space(4.0);
match action {
Ok(message) => ui.colored_label(OK, message),
Err(error) => ui.colored_label(BAD, error),
};
}
ui.add_space(4.0);
draw_join(ui, agent, &mut state.join);
ui.add_space(4.0);
});
egui::CentralPanel::default().show(ctx, |ui| match &snapshot.status {
None => {
ui.add_space(8.0);
ui.label("Connecting to the agent…");
}
Some(Err(error)) => draw_unreachable(ui, agent, error),
Some(Ok(report)) => {
draw_header(ui, agent, state, report);
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if report.networks.is_empty() {
ui.add_space(6.0);
ui.weak("No networks yet — join one below.");
}
for network in &report.networks {
draw_network(ui, agent, state, traffic, network, &report.endpoint_id);
}
});
}
});
}
/// The "agent cannot be reached" state.
fn draw_unreachable(ui: &mut egui::Ui, agent: &AgentClient, error: &str) {
ui.add_space(8.0);
ui.colored_label(BAD, "The agent is not reachable.");
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.weak("socket");
ui.monospace(agent.socket().display().to_string());
});
ui.add_space(4.0);
ui.weak(error);
ui.add_space(6.0);
ui.weak(
"Start the agent, or check that this user may open its control socket \
(its group, mode 0660).",
);
}
/// The device header: id (with a copy button) and an editable hostname.
fn draw_header(ui: &mut egui::Ui, agent: &AgentClient, state: &mut UiState, report: &StatusReport) {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.weak("device");
// Clicking the shortened id copies the full one.
format::copy_field(ui, &format::short(&report.endpoint_id), &report.endpoint_id);
});
ui.horizontal(|ui| {
ui.weak("host");
match &mut state.host_edit {
None => {
ui.label(&report.hostname);
if ui
.small_button(icon::PENCIL_SIMPLE)
.on_hover_text("rename")
.clicked()
{
state.host_edit = Some(report.hostname.clone());
}
}
Some(buffer) => {
let response = ui.add(egui::TextEdit::singleline(buffer).desired_width(150.0));
let submit = (response.lost_focus()
&& ui.input(|i| i.key_pressed(egui::Key::Enter)))
|| ui.small_button(icon::CHECK).on_hover_text("save").clicked();
if submit {
agent.send(Command::SetHostname(buffer.trim().to_string()));
state.host_edit = None;
} else if ui.small_button(icon::X).on_hover_text("cancel").clicked() {
state.host_edit = None;
}
}
}
});
if !report.cache_healthy {
ui.colored_label(
egui::Color32::from_rgb(0xd9, 0x9a, 0x00),
"cache unavailable",
);
}
}
/// One compact network tile.
fn draw_network(
ui: &mut egui::Ui,
agent: &AgentClient,
state: &mut UiState,
traffic: &Traffic,
network: &NetworkReport,
own_id: &str,
) {
ui.add_space(6.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
format::copy_field(ui, network.name.as_str(), &network.name);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let leave =
egui::Button::new(egui::RichText::new("Leave").color(egui::Color32::WHITE))
.fill(LEAVE_RED);
if ui.add(leave).clicked() {
agent.send(Command::Leave {
network_id: network.network_id.clone(),
});
}
});
});
let address = network.overlay.as_ref().and_then(|o| o.address.as_ref());
let series = traffic.network(&network.network_id);
egui::Grid::new(("net", &network.network_id))
.num_columns(2)
.spacing([10.0, 3.0])
.show(ui, |ui| {
ui.weak("id");
format::copy_field(ui, &format::short(&network.network_id), &network.network_id);
ui.end_row();
if let Some(range) = &network.range {
ui.weak("range");
format::copy_field(ui, range, range);
ui.end_row();
}
if let Some(address) = address {
ui.weak("addr");
format::copy_field(ui, address, address);
ui.end_row();
}
ui.weak("peers");
ui.label(format!(
"{} connected · {} known",
network.peers.len(),
devices::known_count(network, own_id)
));
ui.end_row();
});
format::sparkline(ui, series, state.unit, 34.0);
ui.horizontal(|ui| {
let mut active = network.active;
if ui.checkbox(&mut active, "active").changed() {
agent.send(Command::SetActive {
network_id: network.network_id.clone(),
active,
});
}
let mut broadcast = network.broadcast;
if ui.checkbox(&mut broadcast, "broadcast").changed() {
agent.send(Command::SetBroadcast {
network_id: network.network_id.clone(),
enabled: broadcast,
});
}
let mut offering = network.exit.offering;
if ui
.checkbox(&mut offering, "exit node")
.on_hover_text(
"let other members send all their internet traffic through this \
device, which can see it",
)
.changed()
{
agent.send(Command::SetExitOffer {
network_id: network.network_id.clone(),
enabled: offering,
});
}
});
ui.horizontal(|ui| {
if let Some(via) = &network.exit.via {
ui.weak("exit");
ui.label(exit::name_of(network, via));
if ui
.small_button("off")
.on_hover_text("send internet traffic the ordinary way again")
.clicked()
{
agent.send(Command::SetExitNode {
network_id: network.network_id.clone(),
peer: None,
});
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Show devices").clicked() {
state.open_devices.insert(network.network_id.clone());
}
if ui
.button(format!("{} Copy secret", icon::COPY))
.on_hover_text("copy this network's secret to the clipboard")
.clicked()
{
agent.send(Command::CopySecret {
network_id: network.network_id.clone(),
});
}
});
});
for warning in exit::warnings(network) {
draw_exit_warning(ui, &warning);
}
});
}
/// A prominent line about an exit node. One that carries a command copies it
/// when clicked.
fn draw_exit_warning(ui: &mut egui::Ui, warning: &exit::Warning) {
let text = egui::RichText::new(format!("{} {}", icon::WARNING, warning.text))
.color(exit::LOUD)
.strong();
let label = egui::Label::new(text).wrap();
match warning.copy {
Some(command) => {
let response = ui
.add(label.sense(egui::Sense::click()))
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("click to copy the command");
if response.clicked() {
ui.ctx().copy_text(command.to_owned());
}
}
None => {
ui.add(label);
}
}
}
/// The join form: aligned name and secret, a generate button, and Join.
fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) {
const INPUT_WIDTH: f32 = 208.0;
ui.label("Join or create a network");
egui::Grid::new("join")
.num_columns(2)
.spacing([8.0, 6.0])
.show(ui, |ui| {
ui.label("name");
ui.add(egui::TextEdit::singleline(&mut join.name).desired_width(INPUT_WIDTH));
ui.end_row();
ui.label("secret");
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut join.secret)
.desired_width(INPUT_WIDTH - 26.0)
.password(true),
);
if ui
.button(icon::SHUFFLE)
.on_hover_text("Generate a new random secret")
.clicked()
{
join.secret = tsunagi::identity::NetworkSecret::generate()
.encode()
.as_str()
.to_owned();
}
});
ui.end_row();
});
let ready = !join.name.trim().is_empty() && !join.secret.trim().is_empty();
if ui
.add_enabled(ready, egui::Button::new("Join / create"))
.clicked()
{
agent.send(Command::Join {
name: join.name.trim().to_string(),
secret: join.secret.trim().to_string(),
});
join.secret.clear();
}
}
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "tsunagi-wg-quic"
# Its own version, and deliberately separate from the wire version it
# negotiates: a release here does not stop it talking to a peer on an older
# build, because what peers compare is `ANNOUNCEMENT_VERSION`.
version = "0.1.0"
description = "The wg-quic protocol for tsunagi: WireGuard's cryptography carried in iroh's QUIC datagrams."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0" }
iroh.workspace = true
tokio.workspace = true
serde.workspace = true
postcard.workspace = true
bytes.workspace = true
thiserror.workspace = true
tracing.workspace = true
data-encoding.workspace = true
hex.workspace = true
rusqlite = { version = "0.40", features = ["bundled"] }
sha2 = "0.11"
hkdf = "0.13"
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
rand = "0.10"
boringtun = { version = "0.7.1", default-features = false }
[dev-dependencies]
tsunagi = { path = "../tsunagi", features = ["testing"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
[lints]
workspace = true
@@ -1,45 +1,49 @@
//! What a WireGuard peer tells the network about itself.
//!
//! This is the opaque payload the control plane carries in a
//! [`crate::dataplane::PluginCapability`]. The agent core never parses it —
//! [`tsunagi::dataplane::PluginCapability`]. The agent core never parses it —
//! only this module does, and only after bounding every field.
//!
//! The announcement is deliberately tiny: a participant says **who it is**,
//! not **where it is**. Reachability is the data plane transport's job, and
//! the transport already solves it — see
//! [`crate::dataplane::transport`]. A plugin that also tried to advertise
//! [`tsunagi::dataplane::transport`]. A plugin that also tried to advertise
//! addresses would be reimplementing NAT traversal badly.
use std::net::Ipv6Addr;
use serde::{Deserialize, Serialize};
use crate::dataplane::PluginError;
use crate::identity::NetworkId;
use tsunagi::dataplane::PluginError;
use tsunagi::identity::NetworkId;
use super::keys::WgPublicKey;
use super::overlay::overlay_address;
use crate::keys::WgPublicKey;
/// Version of the announcement format.
///
/// Version 3 dropped the IPv4 range again: overlay addressing moved to the
/// signed records in [`crate::state`], which carry the range and survive a
/// signed records in [`tsunagi::state`], which carry the range and survive a
/// participant being away. postcard is not self-describing, so an older peer
/// cannot read a newer announcement; the mismatch is reported, not misparsed.
pub const ANNOUNCEMENT_VERSION: u16 = 3;
pub const ANNOUNCEMENT_VERSION: u16 = 4;
/// What one participant advertises for the WireGuard data plane.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WgAnnouncement {
/// Announcement format version.
pub version: u16,
/// The peer's WireGuard public key. Its overlay address is derived from it.
pub public_key: [u8; 32],
/// The overlay address the peer believes it has.
/// The peer's WireGuard public key.
///
/// Carried for diagnostics and cross-checking only. Addresses are always
/// derived locally, never taken from this field.
pub overlay_address: Ipv6Addr,
/// The whole announcement, now that addresses belong to the system
/// level: this says *who* is at the other end of a tunnel, and nothing
/// about where.
pub public_key: [u8; 32],
/// The network this key is for.
///
/// Strictly redundant — a capability arrives on a session that already
/// proved membership of one network — and kept anyway, because the
/// binding used to be a side effect of checking a derived address and
/// losing it silently when that check went would be the wrong way to
/// lose it.
pub network: [u8; 32],
}
/// A peer announcement that has been validated against a specific network.
@@ -47,8 +51,6 @@ pub struct WgAnnouncement {
pub struct ValidatedAnnouncement {
/// The peer's WireGuard public key.
pub public_key: WgPublicKey,
/// The overlay address derived locally for this key. Authoritative.
pub overlay_address: Ipv6Addr,
}
impl WgAnnouncement {
@@ -57,7 +59,7 @@ impl WgAnnouncement {
Self {
version: ANNOUNCEMENT_VERSION,
public_key: *public_key.as_bytes(),
overlay_address: overlay_address(network, public_key),
network: *network.as_bytes(),
}
}
@@ -105,20 +107,13 @@ impl WgAnnouncement {
"peer announced this agent's own WireGuard key".into(),
));
}
// AllowedIPs are derived, never trusted. A mismatch means the peer is
// confused or lying, and either way its own claim is discarded.
let derived = overlay_address(network, &public_key);
if self.overlay_address != derived {
if self.network != *network.as_bytes() {
return Err(PluginError::Rejected(
"announced overlay address does not match the one derived from the peer's key"
.into(),
"announcement is for a different network".into(),
));
}
Ok(ValidatedAnnouncement {
public_key,
overlay_address: derived,
})
Ok(ValidatedAnnouncement { public_key })
}
}
@@ -127,7 +122,7 @@ mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
use super::super::keys::WgSecretKey;
@@ -149,7 +144,6 @@ mod tests {
let validated = WgAnnouncement::decode_and_validate(&payload, id, &local).unwrap();
assert_eq!(validated.public_key, peer);
assert_eq!(validated.overlay_address, overlay_address(id, &peer));
}
#[test]
@@ -167,20 +161,27 @@ mod tests {
}
#[test]
fn allowed_ips_are_derived_not_taken_from_the_peer() {
fn there_is_nothing_address_like_to_forge() {
// Addresses belong to the system level, are allocated there and are
// signed by the member that holds one. A protocol announcement
// carries no address at all, so this is not a thing a peer can lie
// about here — and a peer sending traffic from an address it does
// not hold is rejected by the agreed address, not by anything it
// said in this message.
let id = network("no-hijack");
let victim = WgSecretKey::generate().public();
let attacker = WgSecretKey::generate().public();
let peer = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
// An attacker claims the victim's overlay address with its own key.
let mut forged = WgAnnouncement::new(id, &attacker);
forged.overlay_address = overlay_address(id, &victim);
let announcement = WgAnnouncement::new(id, &peer);
let validated =
WgAnnouncement::decode_and_validate(&announcement.encode().unwrap(), id, &local)
.unwrap();
assert_eq!(validated.public_key, peer);
let result = WgAnnouncement::decode_and_validate(&forged.encode().unwrap(), id, &local);
assert!(
matches!(result, Err(PluginError::Rejected(ref reason)) if reason.contains("does not match")),
"claiming another member's overlay address must be rejected: {result:?}"
// The validated form has one field, and it is an identity.
assert_eq!(
std::mem::size_of_val(&validated),
std::mem::size_of::<WgPublicKey>()
);
}
@@ -236,7 +237,7 @@ mod tests {
let peer = WgSecretKey::generate().public();
let payload = WgAnnouncement::new(id, &peer).encode().unwrap();
assert!(
payload.len() < crate::config::Limits::default().max_capability_data_len,
payload.len() < tsunagi::config::Limits::default().max_capability_data_len,
"announcement is {} bytes",
payload.len()
);
@@ -18,16 +18,18 @@
//! ourselves, and we do:
//!
//! * outbound, a packet is routed to the peer that **owns** its destination
//! address, where ownership is the derivation in [`super::overlay`];
//! address, where ownership is the signed claim the system level agreed;
//! * inbound, a decrypted packet is dropped unless its **source** is exactly
//! the address derived for the peer whose tunnel decrypted it.
//! the address that peer holds.
//!
//! So a participant cannot receive traffic addressed to someone else, and
//! cannot forge traffic that appears to come from someone else, no matter
//! what it announced.
//! cannot forge traffic that appears to come from someone else. Neither
//! check consults anything the peer said here: an address is claimed at the
//! system level and signed by its holder, and that is what is compared
//! against.
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::collections::{HashMap, VecDeque};
use std::net::Ipv4Addr;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
@@ -37,15 +39,12 @@ use bytes::Bytes;
use iroh::EndpointId;
use tokio::task::JoinHandle;
use crate::dataplane::PluginError;
use crate::dataplane::transport::{SharedLink, TransportError};
use crate::identity::NetworkId;
use tsunagi::dataplane::routing::{FlowId, flow::ip_flow};
use tsunagi::dataplane::transport::{SharedLink, TransportError};
use tsunagi::dataplane::{PacketSink, PluginError};
use tsunagi::identity::NetworkId;
use super::keys::{WgPublicKey, WgSecretKey};
use super::overlay::overlay_address;
use super::packet::IpHeader;
use super::tun::TunDevice;
use crate::state::Ipv4Range;
use crate::keys::{WgPublicKey, WgSecretKey};
/// How often WireGuard's own timers are driven.
///
@@ -56,6 +55,66 @@ const TIMER_INTERVAL: Duration = Duration::from_millis(250);
/// Scratch space for one encapsulate or decapsulate call.
const SCRATCH: usize = 4096;
/// Mirrors boringtun 0.7's bounded pending-packet FIFO with opaque flow tags.
/// All three operations share the same mutex as the cryptographic state.
struct FlowTunnel {
tunn: Tunn,
queued: VecDeque<FlowId>,
}
fn is_data(result: &TunnResult<'_>) -> bool {
matches!(result, TunnResult::WriteToNetwork(bytes) if bytes.starts_with(&[4, 0, 0, 0]))
}
impl FlowTunnel {
fn encapsulate<'a>(
&mut self,
packet: &[u8],
scratch: &'a mut [u8],
flow: FlowId,
) -> (TunnResult<'a>, FlowId) {
let result = self.tunn.encapsulate(packet, scratch);
if is_data(&result) {
return (result, flow);
}
// MAX_QUEUE_DEPTH in boringtun 0.7: the newest packet is dropped at 256.
if self.queued.len() < 256 {
self.queued.push_back(flow);
}
(result, 0)
}
fn decapsulate<'a>(
&mut self,
packet: &[u8],
scratch: &'a mut [u8],
) -> (TunnResult<'a>, FlowId) {
let result = self.tunn.decapsulate(None, packet, scratch);
let mut flow = 0;
if packet.is_empty() {
if is_data(&result) {
flow = self.queued.pop_front().unwrap_or(0);
} else if let Some(pending) = self.queued.pop_front() {
// Without a session, draining pops the oldest IP packet and
// encapsulate queues it again at the back.
self.queued.push_back(pending);
}
}
(result, flow)
}
fn update_timers<'a>(&mut self, scratch: &'a mut [u8]) -> TunnResult<'a> {
let result = self.tunn.update_timers(scratch);
if matches!(
result,
TunnResult::Err(boringtun::noise::errors::WireGuardError::ConnectionExpired)
) {
self.queued.clear();
}
result
}
}
/// Counters for one peer's tunnel.
#[derive(Debug, Default)]
struct PeerCounters {
@@ -65,6 +124,8 @@ struct PeerCounters {
rx_bytes: AtomicU64,
dropped_wrong_source: AtomicU64,
dropped_oversize: AtomicU64,
/// Packets there was no session to encrypt with yet.
dropped_no_session: AtomicU64,
protocol_errors: AtomicU64,
}
@@ -85,6 +146,11 @@ pub struct PeerStats {
pub dropped_wrong_source: u64,
/// Packets dropped because they did not fit in one link datagram.
pub dropped_oversize: u64,
/// Packets dropped because there was no session to encrypt with yet.
///
/// A handful while a tunnel comes up is normal; a number that keeps
/// climbing means the handshake is not completing.
pub dropped_no_session: u64,
/// WireGuard protocol errors, including packets that failed to decrypt.
pub protocol_errors: u64,
}
@@ -109,11 +175,13 @@ impl PeerHealth {
struct Peer {
endpoint_id: EndpointId,
public_key: WgPublicKey,
overlay: Ipv6Addr,
/// The IPv4 address this peer owns, when the overlay is dual stack and
/// nobody else derived the same one.
/// The overlay address this peer holds, as the control plane agreed it.
///
/// Not derived here and not taken from the peer: the system level
/// allocates it, the peer signs the claim, and every protocol carries
/// traffic for the same address.
overlay_v4: Mutex<Option<Ipv4Addr>>,
tunn: Mutex<Tunn>,
tunn: Mutex<FlowTunnel>,
link: SharedLink,
counters: Arc<PeerCounters>,
task: Mutex<Option<JoinHandle<()>>>,
@@ -124,7 +192,6 @@ impl std::fmt::Debug for Peer {
f.debug_struct("Peer")
.field("peer", &self.endpoint_id.fmt_short().to_string())
.field("public_key", &self.public_key)
.field("overlay", &self.overlay)
.finish()
}
}
@@ -148,6 +215,7 @@ impl Peer {
rx_bytes: self.counters.rx_bytes.load(Ordering::Relaxed),
dropped_wrong_source: self.counters.dropped_wrong_source.load(Ordering::Relaxed),
dropped_oversize: self.counters.dropped_oversize.load(Ordering::Relaxed),
dropped_no_session: self.counters.dropped_no_session.load(Ordering::Relaxed),
protocol_errors: self.counters.protocol_errors.load(Ordering::Relaxed),
}
}
@@ -158,7 +226,7 @@ impl Peer {
Err(poisoned) => poisoned.into_inner(),
};
PeerHealth {
since_handshake: guard.time_since_last_handshake(),
since_handshake: guard.tunn.time_since_last_handshake(),
}
}
}
@@ -170,15 +238,8 @@ pub struct PeerSummary {
pub endpoint_id: EndpointId,
/// The peer's WireGuard public key.
pub public_key: WgPublicKey,
/// The overlay address this agent derived for it.
pub overlay_address: Ipv6Addr,
/// Its IPv4 overlay address, when the overlay is dual stack.
///
/// `None` with `ipv4_conflict` set means another member derived the same
/// address and won it; that peer is still fully reachable over IPv6.
/// The overlay address it holds, once the control plane has agreed one.
pub overlay_address_v4: Option<Ipv4Addr>,
/// Whether this peer lost an IPv4 address to a derivation collision.
pub ipv4_conflict: bool,
/// Whether the tunnel has handshaken.
pub health: PeerHealth,
/// Traffic counters.
@@ -192,30 +253,25 @@ pub struct PeerSummary {
struct Inner {
network: NetworkId,
private_key: WgSecretKey,
tun: Arc<dyn TunDevice>,
/// The IPv4 overlay range, when the overlay is dual stack.
ipv4_range: Option<Ipv4Range>,
peers: RwLock<HashMap<WgPublicKey, Arc<Peer>>>,
/// Both families, so one lookup routes any packet.
routes: RwLock<HashMap<IpAddr, WgPublicKey>>,
/// Where decrypted packets go.
///
/// The interface belongs to the system level, so this hands a packet up
/// rather than writing it out: only that level knows which addresses the
/// sending member is entitled to use.
sink: Arc<dyn PacketSink>,
next_index: AtomicU32,
unroutable: AtomicU64,
/// One destination nobody owned, kept so the counter can be acted on.
unroutable_sample: Mutex<Option<IpAddr>>,
multicast: AtomicU64,
ipv4_conflicts: AtomicU64,
}
impl std::fmt::Debug for Inner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Inner")
.field("network", &self.network.fmt_short())
.field("tun", &self.tun.name())
.finish()
}
}
/// A userspace WireGuard interface for one network.
/// The userspace WireGuard tunnels of one network.
#[derive(Debug)]
pub struct WireguardDevice {
inner: Arc<Inner>,
@@ -223,44 +279,75 @@ pub struct WireguardDevice {
}
impl WireguardDevice {
/// Starts a device on top of `tun`.
pub fn start(
network: NetworkId,
private_key: WgSecretKey,
tun: Arc<dyn TunDevice>,
ipv4_range: Option<Ipv4Range>,
) -> Self {
/// Starts the tunnels for one network.
///
/// No interface is involved: packets arrive through [`Self::carry`] and
/// leave through the sink. Which address belongs to whom, and therefore
/// where a packet should go, is decided above this.
pub fn start(network: NetworkId, private_key: WgSecretKey, sink: Arc<dyn PacketSink>) -> Self {
let inner = Arc::new(Inner {
network,
private_key,
tun,
ipv4_range,
peers: RwLock::new(HashMap::new()),
routes: RwLock::new(HashMap::new()),
sink,
next_index: AtomicU32::new(1),
unroutable: AtomicU64::new(0),
unroutable_sample: Mutex::new(None),
multicast: AtomicU64::new(0),
ipv4_conflicts: AtomicU64::new(0),
});
let reader = tokio::spawn(read_from_os(Arc::clone(&inner)));
let timers = tokio::spawn(drive_timers(Arc::clone(&inner)));
Self {
inner,
tasks: vec![reader, timers],
tasks: vec![timers],
}
}
/// The interface name in use.
pub fn interface(&self) -> &str {
self.inner.tun.name()
}
/// Encrypts a packet and sends it to a peer.
///
/// `false` when there is no tunnel for that peer, which is a state the
/// caller reports rather than an error: a peer whose link has not come
/// up yet is normal.
pub fn carry(&self, peer: iroh::EndpointId, packet: &[u8]) -> bool {
let found = read_lock(&self.inner.peers)
.values()
.find(|candidate| candidate.endpoint_id == peer)
.map(Arc::clone);
let Some(peer) = found else {
return false;
};
/// The interface MTU.
pub fn mtu(&self) -> u32 {
self.inner.tun.mtu()
let mut scratch = [0u8; SCRATCH];
let flow = ip_flow(packet);
// The encryption is the whole of what this protocol contributes, so
// it happens here rather than anywhere the packet passes through.
// The lock is released before the send: a slow link must not hold up
// the tunnel's timers.
let result = {
let mut tunn = match peer.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let (result, selected_flow) = tunn.encapsulate(packet, &mut scratch, flow);
match result {
TunnResult::WriteToNetwork(out) => Some((out.len(), selected_flow)),
// No session yet, so nothing to send. Counted as dropped
// rather than reported: the handshake is in flight and the
// next packet will go.
_ => None,
}
};
let Some((len, selected_flow)) = result else {
peer.counters
.dropped_no_session
.fetch_add(1, Ordering::Relaxed);
return false;
};
send_to_peer_flow(&peer, &scratch[..len], selected_flow);
peer.counters.tx_packets.fetch_add(1, Ordering::Relaxed);
peer.counters
.tx_bytes
.fetch_add(packet.len() as u64, Ordering::Relaxed);
true
}
/// Adds or replaces a peer and starts its tunnel.
@@ -292,14 +379,14 @@ impl WireguardDevice {
None,
);
let overlay = overlay_address(self.inner.network, &public_key);
let overlay_v4 = self.claim_ipv4(&public_key, overlay_v4);
let peer = Arc::new(Peer {
endpoint_id,
public_key,
overlay,
overlay_v4: Mutex::new(overlay_v4),
tunn: Mutex::new(tunn),
tunn: Mutex::new(FlowTunnel {
tunn,
queued: VecDeque::new(),
}),
link,
counters: Arc::new(PeerCounters::default()),
task: Mutex::new(None),
@@ -311,10 +398,6 @@ impl WireguardDevice {
}
write_lock(&self.inner.peers).insert(public_key, Arc::clone(&peer));
write_lock(&self.inner.routes).insert(IpAddr::V6(overlay), public_key);
if let Some(v4) = overlay_v4 {
write_lock(&self.inner.routes).insert(IpAddr::V4(v4), public_key);
}
// Start the handshake now instead of waiting for the next timer tick,
// so the tunnel is usable as soon as the link exists.
@@ -322,61 +405,11 @@ impl WireguardDevice {
Ok(())
}
/// Decides which IPv4 address a new peer gets, if any.
///
/// IPv4 has far too little room for a derived address to be collision
/// free. When two members derive the same one, the member whose public
/// key sorts lower keeps it — a rule every member computes identically,
/// so they all agree on the outcome without talking about it. The other
/// member simply has no IPv4 address; it is still fully reachable over
/// IPv6, which never collides.
fn claim_ipv4(&self, public_key: &WgPublicKey, wanted: Option<Ipv4Addr>) -> Option<Ipv4Addr> {
let wanted = wanted?;
let holder = read_lock(&self.inner.routes)
.get(&IpAddr::V4(wanted))
.copied();
let Some(holder) = holder else {
return Some(wanted);
};
if holder == *public_key {
return Some(wanted);
}
self.inner.ipv4_conflicts.fetch_add(1, Ordering::Relaxed);
if holder.as_bytes() <= public_key.as_bytes() {
// The peer already holding it wins.
return None;
}
// The newcomer wins; take the address away from the other peer.
if let Some(loser) = read_lock(&self.inner.peers).get(&holder).cloned() {
let mut slot = match loser.overlay_v4.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
*slot = None;
}
Some(wanted)
}
/// Removes a peer and stops its tunnel.
pub fn remove_peer(&self, public_key: &WgPublicKey) {
if let Some(peer) = write_lock(&self.inner.peers).remove(public_key) {
let mut routes = write_lock(&self.inner.routes);
routes.remove(&IpAddr::V6(peer.overlay));
let v4 = match peer.overlay_v4.lock() {
Ok(guard) => *guard,
Err(poisoned) => *poisoned.into_inner(),
};
if let Some(v4) = v4 {
routes.remove(&IpAddr::V4(v4));
}
}
}
/// How many IPv4 derivation collisions have been resolved.
pub fn ipv4_conflicts(&self) -> u64 {
self.inner.ipv4_conflicts.load(Ordering::Relaxed)
// Dropping it stops the task and closes the link; there is no route
// to withdraw, because routes are not kept here.
write_lock(&self.inner.peers).remove(public_key);
}
/// Removes every peer whose key is not in `keep`.
@@ -408,9 +441,7 @@ impl WireguardDevice {
PeerSummary {
endpoint_id: peer.endpoint_id,
public_key: peer.public_key,
overlay_address: peer.overlay,
overlay_address_v4: overlay_v4,
ipv4_conflict: overlay_v4.is_none() && self.inner.ipv4_range.is_some(),
health: peer.health(),
stats: peer.stats(),
path: peer.link.path_description(),
@@ -421,34 +452,6 @@ impl WireguardDevice {
peers.sort_by_key(|peer| peer.public_key);
peers
}
/// Unicast packets the operating system sent to an address no peer owns.
///
/// A non-zero value means something tried to reach a host that is not in
/// the overlay.
pub fn unroutable_packets(&self) -> u64 {
self.inner.unroutable.load(Ordering::Relaxed)
}
/// One destination that nobody owned, if there was one.
///
/// A bare count says something is wrong but not what; the address usually
/// says it outright.
pub fn unroutable_sample(&self) -> Option<IpAddr> {
match self.inner.unroutable_sample.lock() {
Ok(guard) => *guard,
Err(poisoned) => *poisoned.into_inner(),
}
}
/// Multicast packets dropped.
///
/// Expected and harmless: Linux emits multicast listener and router
/// solicitation traffic on any IPv6 interface, and this overlay is
/// unicast only. Counted separately so it does not look like a fault.
pub fn multicast_packets(&self) -> u64 {
self.inner.multicast.load(Ordering::Relaxed)
}
}
impl Drop for WireguardDevice {
@@ -475,9 +478,7 @@ fn write_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
/// Asks boringtun for a handshake initiation and sends it.
///
/// Encapsulating an empty packet is how the protocol state machine is told
/// "there is something to say"; with no session yet it answers with the
/// handshake initiation.
/// This does not enqueue an empty IP packet in boringtun's pending queue.
fn kick_handshake(peer: &Peer) {
let mut scratch = vec![0u8; SCRATCH];
let len = {
@@ -485,7 +486,7 @@ fn kick_handshake(peer: &Peer) {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
match tunn.encapsulate(&[], &mut scratch) {
match tunn.tunn.format_handshake_initiation(&mut scratch, false) {
TunnResult::WriteToNetwork(out) => Some(out.len()),
_ => None,
}
@@ -497,7 +498,11 @@ fn kick_handshake(peer: &Peer) {
/// Sends whatever boringtun produced, without holding the tunnel lock.
fn send_to_peer(peer: &Peer, payload: &[u8]) {
match peer.link.send(Bytes::copy_from_slice(payload)) {
send_to_peer_flow(peer, payload, 0);
}
fn send_to_peer_flow(peer: &Peer, payload: &[u8], flow: FlowId) {
match peer.link.send_flow(Bytes::copy_from_slice(payload), flow) {
Ok(()) => {}
Err(TransportError::TooLarge { .. }) => {
peer.counters
@@ -511,84 +516,13 @@ fn send_to_peer(peer: &Peer, payload: &[u8]) {
}
}
/// Operating system -> peer.
async fn read_from_os(inner: Arc<Inner>) {
loop {
let Some(packet) = inner.tun.recv().await else {
return;
};
// Route by destination: only the peer that owns that overlay address
// may receive it. Both families go through the same table.
let Some(destination) = IpHeader::parse(&packet).map(|header| header.destination()) else {
inner.unroutable.fetch_add(1, Ordering::Relaxed);
continue;
};
// The kernel emits multicast on every IPv6 interface. The overlay is
// unicast only, so this is dropped, but it is not a fault.
if destination.is_multicast() {
inner.multicast.fetch_add(1, Ordering::Relaxed);
continue;
}
let target = read_lock(&inner.routes).get(&destination).copied();
let Some(target) = target else {
note_unroutable(&inner, destination);
continue;
};
let peer = read_lock(&inner.peers).get(&target).cloned();
let Some(peer) = peer else {
note_unroutable(&inner, destination);
continue;
};
let mut scratch = vec![0u8; SCRATCH];
let outcome = {
let mut tunn = match peer.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
match tunn.encapsulate(&packet, &mut scratch) {
TunnResult::WriteToNetwork(out) => Some(out.len()),
TunnResult::Done => None,
TunnResult::Err(err) => {
tracing::trace!(?err, "wireguard encapsulation failed");
peer.counters
.protocol_errors
.fetch_add(1, Ordering::Relaxed);
None
}
_ => None,
}
};
if let Some(len) = outcome {
send_to_peer(&peer, &scratch[..len]);
peer.counters.tx_packets.fetch_add(1, Ordering::Relaxed);
peer.counters
.tx_bytes
.fetch_add(packet.len() as u64, Ordering::Relaxed);
}
}
}
/// Counts a packet nobody owned the destination of, keeping one example.
fn note_unroutable(inner: &Inner, destination: IpAddr) {
inner.unroutable.fetch_add(1, Ordering::Relaxed);
let mut sample = match inner.unroutable_sample.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
*sample = Some(destination);
}
/// Peer -> operating system.
async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
let mut scratch = vec![0u8; SCRATCH];
loop {
let Some(datagram) = peer.link.recv().await else {
return;
};
let mut scratch = vec![0u8; SCRATCH];
// boringtun may need several passes: a handshake reply first, then
// any packets that were queued while the session was coming up.
let mut input: Option<&[u8]> = Some(&datagram);
@@ -598,14 +532,14 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
match tunn.decapsulate(None, input.unwrap_or(&[]), &mut scratch) {
TunnResult::WriteToNetwork(out) => Outcome::ToNetwork(out.len()),
TunnResult::WriteToTunnelV6(out, source) => {
Outcome::ToTunnel(out.len(), IpAddr::V6(source))
}
TunnResult::WriteToTunnelV4(out, source) => {
Outcome::ToTunnel(out.len(), IpAddr::V4(source))
}
let (result, flow) = tunn.decapsulate(input.unwrap_or(&[]), &mut scratch);
match result {
TunnResult::WriteToNetwork(out) => Outcome::ToNetwork(out.len(), flow),
// The source boringtun reports is not consulted here:
// whether the peer may use it is checked where the
// claims live.
TunnResult::WriteToTunnelV6(out, _) => Outcome::ToTunnel(out.len()),
TunnResult::WriteToTunnelV4(out, _) => Outcome::ToTunnel(out.len()),
TunnResult::Done => Outcome::Done,
TunnResult::Err(err) => {
tracing::trace!(?err, "wireguard decapsulation failed");
@@ -615,38 +549,26 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
};
match outcome {
Outcome::ToNetwork(len) => {
send_to_peer(&peer, &scratch[..len]);
Outcome::ToNetwork(len, flow) => {
send_to_peer_flow(&peer, &scratch[..len], flow);
// Keep draining with an empty datagram, as boringtun asks.
input = None;
continue;
}
Outcome::ToTunnel(len, source) => {
Outcome::ToTunnel(len) => {
// Handed up, not written out. This end has proved *who*
// sent the packet; whether that member may use the source
// address it chose is a question about a signed claim,
// and only the system level holds those.
let payload = Bytes::copy_from_slice(&scratch[..len]);
// Enforce address ownership: a peer may only send from an
// address derived for its own key, in either family.
let owned = match source {
IpAddr::V6(addr) => addr == peer.overlay,
IpAddr::V4(addr) => {
let held = match peer.overlay_v4.lock() {
Ok(guard) => *guard,
Err(poisoned) => *poisoned.into_inner(),
};
held == Some(addr)
}
};
if !owned {
peer.counters
.dropped_wrong_source
.fetch_add(1, Ordering::Relaxed);
break;
}
if inner.tun.send(payload).await.is_ok() {
peer.counters.rx_packets.fetch_add(1, Ordering::Relaxed);
peer.counters
.rx_bytes
.fetch_add(len as u64, Ordering::Relaxed);
}
inner
.sink
.deliver(inner.network, peer.endpoint_id, payload)
.await;
peer.counters.rx_packets.fetch_add(1, Ordering::Relaxed);
peer.counters
.rx_bytes
.fetch_add(len as u64, Ordering::Relaxed);
break;
}
Outcome::Failed => {
@@ -662,8 +584,8 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
}
enum Outcome {
ToNetwork(usize),
ToTunnel(usize, IpAddr),
ToNetwork(usize, FlowId),
ToTunnel(usize),
Done,
Failed,
}
@@ -697,3 +619,73 @@ async fn drive_timers(inner: Arc<Inner>) {
}
}
}
#[cfg(test)]
mod flow_tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn tunnel(ours: &WgSecretKey, theirs: &WgSecretKey) -> FlowTunnel {
FlowTunnel {
tunn: Tunn::new(
ours.to_static_secret(),
theirs.public().into_x25519(),
None,
None,
1,
None,
),
queued: VecDeque::new(),
}
}
fn wire(result: TunnResult<'_>) -> Vec<u8> {
match result {
TunnResult::WriteToNetwork(bytes) => bytes.to_vec(),
_ => panic!("expected WireGuard frame"),
}
}
#[test]
fn flow_tags_survive_pending_queue_overflow_handshake_and_encryption() {
let ak = WgSecretKey::from_bytes(&[1; 32]);
let bk = WgSecretKey::from_bytes(&[2; 32]);
let mut a = tunnel(&ak, &bk);
let mut b = tunnel(&bk, &ak);
let mut scratch = [0u8; SCRATCH];
let mut packet = [0u8; 40];
packet[0] = 0x45;
packet[2..4].copy_from_slice(&40u16.to_be_bytes());
packet[9] = 6;
packet[12..20].copy_from_slice(&[10, 0, 0, 1, 10, 0, 0, 2]);
let (result, flow) = a.encapsulate(&packet, &mut scratch, 100);
assert_eq!(
flow, 0,
"handshake does not masquerade as application traffic"
);
let hello = wire(result);
for flow in 101..400 {
let _ = a.encapsulate(&packet, &mut scratch, flow);
}
assert_eq!(a.queued.len(), 256);
let response = wire(b.decapsulate(&hello, &mut scratch).0);
let keepalive = wire(a.decapsulate(&response, &mut scratch).0);
let _ = b.decapsulate(&keepalive, &mut scratch);
for expected in 100..356 {
let (result, flow) = a.decapsulate(&[], &mut scratch);
assert_eq!(flow, expected);
let ciphertext = wire(result);
match b.decapsulate(&ciphertext, &mut scratch).0 {
TunnResult::WriteToTunnelV4(bytes, _) => assert_eq!(bytes, packet),
_ => panic!("queued IP packet must decrypt"),
}
}
assert!(a.queued.is_empty());
assert!(matches!(
a.decapsulate(&[], &mut scratch).0,
TunnResult::Done
));
let (result, flow) = a.encapsulate(&packet, &mut scratch, 12345);
assert!(is_data(&result));
assert_eq!(flow, 12345);
}
}
@@ -11,7 +11,7 @@ use boringtun::x25519;
use data_encoding::BASE64;
use zeroize::{Zeroize, Zeroizing};
use crate::dataplane::PluginError;
use tsunagi::dataplane::PluginError;
/// Length of a raw WireGuard key, in bytes.
pub const KEY_LEN: usize = 32;
+50
View File
@@ -0,0 +1,50 @@
//! The `wg-quic` protocol: WireGuard's cryptography in iroh's QUIC datagrams.
//!
//! A crate of its own, so the line between a protocol and the system level
//! is drawn by the compiler rather than by discipline: nothing here can
//! reach into `tsunagi` beyond what it makes public. It also carries its own
//! version, which is **not** the version peers compare — that is
//! [`ANNOUNCEMENT_VERSION`], and it moves only when the bytes on the wire
//! do, so two peers on different releases keep working.
//!
//! That it uses iroh is a convenience, not a requirement of the design: the
//! system level already has an iroh endpoint that crosses NAT, and plain
//! WireGuard is blocked on some networks where this is not.
//!
//! Where the line falls:
//!
//! * **It knows nothing about reachability.** It is handed a
//! [`PacketLink`](tsunagi::dataplane::transport::PacketLink) per peer and
//! moves datagrams over it. Hole punching and relaying are the transport's.
//! * **It knows nothing about addresses, and owns no interface.** One agent
//! has one interface at the system level, and every protocol carries
//! traffic for the same addresses on it. A packet arrives here already
//! routed and leaves here already decrypted, to be checked against the
//! signed claim by the level that holds those.
//! * **Its announcement says who, not where.** A public key and the network
//! it is for, so there is nothing about reachability to lie about.
//! * **The core never parses that announcement.** It moves a bounded opaque
//! blob; only [`announcement`] reads it.
//! * **Its keys are its own.** One per network, in its own store, unrelated
//! to the iroh device key and to the network secret — which it never sees.
//! * **WireGuard's own crypto is untouched.** The handshake and encryption
//! run end to end between the two ends of a tunnel, via [`boringtun`]'s
//! state machine in this process: no kernel module, no `wg` tool, the same
//! code on every platform.
//!
//! See `docs/wireguard.md` for the full picture.
pub mod announcement;
pub mod device;
pub mod keys;
pub mod plugin;
pub mod store;
pub use announcement::{ANNOUNCEMENT_VERSION, ValidatedAnnouncement, WgAnnouncement};
pub use device::{PeerHealth, PeerStats, PeerSummary, WireguardDevice};
pub use keys::{WgPublicKey, WgSecretKey};
pub use plugin::{
DEFAULT_MTU, MIN_MTU, NetworkOverview, PeerOverview, WIREGUARD_OVERHEAD, WIREGUARD_PROTOCOL,
WireguardConfig, WireguardPlugin,
};
pub use store::WgKeyStore;
@@ -1,4 +1,4 @@
//! The WireGuard IP plugin.
//! The WireGuard protocol plugin.
//!
//! Each agent builds its own view of the overlay from the set of participants
//! the control plane agreed on. For a full mesh of `N` members that is `N - 1`
@@ -8,24 +8,24 @@
//! # What this plugin does and does not know
//!
//! * It does **not** know where a peer is. It is handed a
//! [`PacketLink`](crate::dataplane::transport::PacketLink) per peer and runs
//! [`PacketLink`](tsunagi::dataplane::transport::PacketLink) per peer and runs
//! a WireGuard tunnel over it. Reachability, hole punching and relaying are
//! the transport's problem.
//! * It does **not** know which addresses anybody holds, and owns no
//! interface. One agent has one interface, at the system level, and every
//! protocol carries traffic for the same addresses on it. A packet arrives
//! here already routed and leaves here already decrypted.
//! * It owns one WireGuard key per network, in its own store, unrelated to the
//! iroh device key and to the network secret.
//! * It owns one packet interface per network, named deterministically.
//! * It never touches an interface it did not create, and never changes
//! routing, DNS or firewall settings beyond its own device.
//!
//! WireGuard runs in userspace via [`boringtun`], so there is no kernel module
//! and no `wg` tool to depend on. The only privileged step is creating the
//! packet interface, and even that is behind [`TunFactory`] so the whole data
//! plane can run unprivileged in tests.
//! and no `wg` tool to depend on, and nothing this plugin does needs
//! privileges: creating the interface is somebody else's job now.
//!
//! A failure here is reported and retried. It never stops the control plane.
use std::collections::{BTreeSet, HashMap};
use std::net::{IpAddr, Ipv4Addr};
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
@@ -34,43 +34,46 @@ use iroh::EndpointId;
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;
use crate::BoxFuture;
use crate::dataplane::transport::SharedLink;
use crate::dataplane::{IpPlugin, PluginCapability, PluginContext, PluginError};
use crate::identity::NetworkId;
use tsunagi::BoxFuture;
use tsunagi::dataplane::PacketSink;
use tsunagi::dataplane::transport::SharedLink;
use tsunagi::dataplane::{IpPlugin, PluginCapability, PluginContext, PluginError};
use tsunagi::identity::NetworkId;
use super::announcement::{ValidatedAnnouncement, WgAnnouncement};
use super::config::{DEFAULT_INTERFACE_PREFIX, interface_name};
use super::device::{PeerSummary, WireguardDevice};
use super::keys::{WgPublicKey, WgSecretKey};
use super::overlay::{OVERLAY_PREFIX_LEN, overlay_address, overlay_prefix};
use super::store::WgKeyStore;
use super::tun::{TunFactory, TunRequest};
use crate::state::Ipv4Range;
use crate::announcement::{ValidatedAnnouncement, WgAnnouncement};
use crate::device::{PeerSummary, WireguardDevice};
use crate::keys::{WgPublicKey, WgSecretKey};
use crate::store::WgKeyStore;
use tsunagi::state::Ipv4Range;
use tsunagi::task::{TASK_GRACE, wind_down};
/// The protocol identifier this plugin announces.
pub const WIREGUARD_PROTOCOL: &str = "wireguard";
/// Smallest interface MTU IPv6 permits, from RFC 8200.
/// The protocol id of this plugin.
///
/// This is not advice, it is a hard limit. Linux tears IPv6 down entirely on
/// an interface whose MTU is below it — the per-device `/proc/sys/net/ipv6`
/// entries disappear and `ip -6 address add` fails with `Invalid argument` —
/// so the overlay address could never be assigned. Anything smaller is
/// rejected up front instead of failing obscurely later.
pub const MIN_MTU: u32 = 1280;
/// `wg-quic`, because that is what it is: WireGuard's cryptography carried
/// in QUIC datagrams. The name is on the wire, so it is a protocol name and
/// not a description of the implementation.
pub const WIREGUARD_PROTOCOL: &str = "wg-quic";
/// Smallest interface MTU the overlay accepts.
///
/// 576 bytes is what IPv4 guarantees every host can reassemble (RFC 1122),
/// so nothing below it is worth offering. The current overlay is IPv4.
/// Lowering this is not needed to accommodate a narrow QUIC path: the default
/// transport fragments opaque payloads below the plugin.
pub const MIN_MTU: u32 = 576;
/// Default interface MTU.
///
/// Equal to [`MIN_MTU`], because the overlay is IPv6 and there is no room
/// below it.
pub const DEFAULT_MTU: u32 = MIN_MTU;
/// Kept stable across path changes. The default transport splits encrypted
/// packets when the current QUIC path cannot carry them in one datagram.
pub const DEFAULT_MTU: u32 = 1280;
/// Bytes WireGuard adds to a packet: type and reserved, receiver index,
/// counter and the Poly1305 tag.
///
/// A link therefore has to carry `mtu + WIREGUARD_OVERHEAD` bytes in one
/// datagram for a full-size packet to get through.
/// A link must carry `mtu + WIREGUARD_OVERHEAD` logical payload bytes.
/// Transport fragmentation is independent of WireGuard and the inner IP flags.
pub const WIREGUARD_OVERHEAD: u32 = 32;
/// Configuration of the WireGuard plugin.
@@ -78,14 +81,13 @@ pub const WIREGUARD_OVERHEAD: u32 = 32;
pub struct WireguardConfig {
/// Directory for the plugin's own key store. Separate from agent state.
pub state_dir: PathBuf,
/// Prefix of the interface names this plugin creates.
///
/// Two agents on one host in the same network need different prefixes,
/// because the rest of the name is derived from the network id.
pub interface_prefix: String,
/// WireGuard keepalive, which keeps tunnels and their links warm.
pub keepalive: Option<u16>,
/// Interface MTU. See [`DEFAULT_MTU`].
/// The largest packet a tunnel will carry.
///
/// Not the interface MTU, which belongs to the agent: this is what this
/// protocol is configured to carry. The transport may split ciphertext
/// into smaller datagrams without changing the original IP packet.
pub mtu: u32,
/// How long to coalesce changes before reconciling.
pub reconcile_debounce: Duration,
@@ -99,7 +101,6 @@ impl WireguardConfig {
pub fn new(state_dir: impl Into<PathBuf>) -> Self {
Self {
state_dir: state_dir.into(),
interface_prefix: DEFAULT_INTERFACE_PREFIX.to_string(),
keepalive: Some(25),
mtu: DEFAULT_MTU,
reconcile_debounce: Duration::from_millis(200),
@@ -107,12 +108,6 @@ impl WireguardConfig {
}
}
/// Sets the interface name prefix.
pub fn with_interface_prefix(mut self, prefix: impl Into<String>) -> Self {
self.interface_prefix = prefix.into();
self
}
/// Sets the interface MTU.
///
/// Validated when the plugin is opened; see [`MIN_MTU`].
@@ -139,30 +134,16 @@ impl WireguardConfig {
pub struct NetworkOverview {
/// The network.
pub network: NetworkId,
/// Packet interface this plugin created for it.
pub interface: String,
/// Interface MTU.
/// The largest packet a tunnel in this network will carry.
pub mtu: u32,
/// This agent's WireGuard public key in this network.
pub public_key: WgPublicKey,
/// This agent's overlay address.
pub overlay_address: IpAddr,
/// The overlay subnet every member shares.
pub overlay_prefix: IpAddr,
/// Prefix length of the overlay subnet.
pub overlay_prefix_len: u8,
/// This agent's IPv4 overlay address, when the overlay is dual stack.
/// This agent's overlay address, once the network has agreed one.
pub overlay_address_v4: Option<Ipv4Addr>,
/// The IPv4 overlay range in use.
pub ipv4_range: Option<Ipv4Range>,
/// Peers this agent knows about.
pub peers: Vec<PeerOverview>,
/// Unicast packets the operating system sent to an address no peer owns.
pub unroutable_packets: u64,
/// Multicast packets dropped. Expected, not a fault.
pub multicast_packets: u64,
/// One destination nobody owned, if there was one.
pub unroutable_sample: Option<IpAddr>,
}
impl NetworkOverview {
@@ -179,9 +160,7 @@ pub struct PeerOverview {
pub endpoint_id: EndpointId,
/// The peer's WireGuard public key.
pub public_key: WgPublicKey,
/// The overlay address derived for it locally.
pub overlay_address: IpAddr,
/// Its IPv4 overlay address, once a tunnel exists and it won the address.
/// The overlay address the network agreed it holds.
pub overlay_address_v4: Option<Ipv4Addr>,
/// Whether a data plane link to it exists.
pub has_link: bool,
@@ -201,7 +180,6 @@ impl PeerOverview {
#[derive(Debug)]
struct NetworkState {
key: WgSecretKey,
interface: String,
device: Option<Arc<WireguardDevice>>,
announcements: HashMap<EndpointId, ValidatedAnnouncement>,
links: HashMap<EndpointId, SharedLink>,
@@ -209,14 +187,6 @@ struct NetworkState {
allocations: HashMap<EndpointId, Ipv4Addr>,
/// The range those allocations came from.
ipv4_range: Option<Ipv4Range>,
/// The address last reported as missing, so it is said once, not forever.
reported_missing_v4: Option<Ipv4Addr>,
/// What was last applied to the host interface.
///
/// The overlay IPv4 address is allocated at run time and can change while
/// the agent runs, so the interface has to be brought back in line
/// without being recreated — recreating it would drop every tunnel.
applied: Option<TunRequest>,
}
#[derive(Debug, Default)]
@@ -241,16 +211,16 @@ struct Worker {
config: WireguardConfig,
/// This agent's endpoint id, learned when the plugin is attached.
local_id: OnceLock<EndpointId>,
tun_factory: Arc<dyn TunFactory>,
store: WgKeyStore,
shared: Mutex<Shared>,
context: OnceLock<PluginContext>,
/// Where decrypted packets go, once the agent has attached one.
sink: OnceLock<Arc<dyn PacketSink>>,
}
impl std::fmt::Debug for Worker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Worker")
.field("tun", &self.tun_factory.name())
.field("store", &self.store.path())
.finish()
}
@@ -265,22 +235,65 @@ pub struct WireguardPlugin {
}
impl WireguardPlugin {
/// The settings this protocol accepts.
pub const OPTIONS: &'static [tsunagi::dataplane::ProtocolOption] = &[
tsunagi::dataplane::ProtocolOption {
key: "keepalive",
value: "SECONDS",
help: "keeps a tunnel and its link warm through a NAT; 0 turns it off",
default: Some("25"),
},
tsunagi::dataplane::ProtocolOption {
key: "mtu",
value: "BYTES",
help: "largest packet a tunnel will carry, at least 576",
default: Some("1280"),
},
];
/// Applies `key=value` settings to a configuration.
///
/// An unknown key is refused rather than ignored: a setting that was
/// silently dropped looks exactly like one that did not work.
pub fn configure(
mut config: WireguardConfig,
options: &[(String, String)],
) -> Result<WireguardConfig, PluginError> {
for (key, value) in options {
match key.as_str() {
"keepalive" => {
let seconds: u16 = value.parse().map_err(|_| {
PluginError::Other(format!("keepalive={value} is not a number of seconds"))
})?;
config.keepalive = (seconds > 0).then_some(seconds);
}
"mtu" => {
let mtu: u32 = value.parse().map_err(|_| {
PluginError::Other(format!("mtu={value} is not a number of bytes"))
})?;
config = config.with_mtu(mtu);
}
other => {
let known: Vec<&str> = Self::OPTIONS.iter().map(|spec| spec.key).collect();
return Err(PluginError::Other(format!(
"`{other}` is not a setting of {WIREGUARD_PROTOCOL}; it takes {}",
known.join(", ")
)));
}
}
}
Ok(config)
}
/// Opens the plugin's key store and starts its reconciliation task.
///
/// Must be called from inside a tokio runtime; the plugin starts no
/// runtime of its own.
pub async fn open(
config: WireguardConfig,
tun_factory: Arc<dyn TunFactory>,
) -> Result<Arc<Self>, PluginError> {
// Validate the prefix once, here, rather than failing per network.
interface_name(&config.interface_prefix, NetworkId::from_bytes([0u8; 32]))?;
pub async fn open(config: WireguardConfig) -> Result<Arc<Self>, PluginError> {
if config.mtu < MIN_MTU {
return Err(PluginError::Other(format!(
"an MTU of {} is below the {MIN_MTU} bytes IPv6 requires (RFC 8200). \
Linux disables IPv6 on an interface below that, so the overlay address \
could never be assigned.",
"an MTU of {} is below the {MIN_MTU} bytes every IPv4 host must be able \
to reassemble (RFC 1122)",
config.mtu
)));
}
@@ -293,10 +306,10 @@ impl WireguardPlugin {
let worker = Arc::new(Worker {
config,
local_id: OnceLock::new(),
tun_factory,
store,
shared: Mutex::new(Shared::default()),
context: OnceLock::new(),
sink: OnceLock::new(),
});
let (commands, receiver) = mpsc::channel(64);
@@ -332,7 +345,6 @@ impl WireguardPlugin {
.map(|(endpoint_id, announcement)| PeerOverview {
endpoint_id: *endpoint_id,
public_key: announcement.public_key,
overlay_address: IpAddr::V6(announcement.overlay_address),
overlay_address_v4: state.allocations.get(endpoint_id).copied(),
has_link: state.links.contains_key(endpoint_id),
tunnel: tunnels.get(&announcement.public_key).cloned(),
@@ -342,29 +354,11 @@ impl WireguardPlugin {
Some(NetworkOverview {
network,
interface: state.interface.clone(),
mtu: self.worker.config.mtu,
public_key: state.key.public(),
overlay_address: IpAddr::V6(overlay_address(network, &state.key.public())),
overlay_prefix: IpAddr::V6(overlay_prefix(network)),
overlay_prefix_len: OVERLAY_PREFIX_LEN,
overlay_address_v4: state.allocations.get(&self.worker.local_id()).copied(),
ipv4_range: state.ipv4_range,
peers,
unroutable_packets: state
.device
.as_ref()
.map(|device| device.unroutable_packets())
.unwrap_or(0),
multicast_packets: state
.device
.as_ref()
.map(|device| device.multicast_packets())
.unwrap_or(0),
unroutable_sample: state
.device
.as_ref()
.and_then(|device| device.unroutable_sample()),
})
}
@@ -433,7 +427,6 @@ impl Worker {
return Ok(false);
}
let name = interface_name(&self.config.interface_prefix, network)?;
let worker = Arc::clone(self);
let key = tokio::task::spawn_blocking(move || worker.store.load_or_create(network))
.await
@@ -443,14 +436,11 @@ impl Worker {
let mut shared = self.lock_shared();
shared.networks.entry(network).or_insert(NetworkState {
key,
interface: name,
device: None,
announcements: HashMap::new(),
links: HashMap::new(),
allocations: HashMap::new(),
ipv4_range: None,
reported_missing_v4: None,
applied: None,
});
}
@@ -463,79 +453,35 @@ impl Worker {
Ok(true)
}
/// What the host interface for a network should look like.
fn desired_request(&self, state: &NetworkState, network: NetworkId) -> TunRequest {
let own_range = state.ipv4_range;
TunRequest {
name: state.interface.clone(),
address: overlay_address(network, &state.key.public()),
prefix_len: OVERLAY_PREFIX_LEN,
address_v4: state.allocations.get(&self.local_id()).copied(),
prefix_len_v4: own_range.map_or(0, |range| range.prefix_len),
mtu: self.config.mtu,
}
}
/// Creates the packet interface and starts the WireGuard device.
/// Starts this network's tunnels.
///
/// No interface is created: one agent has one, it belongs to the system
/// level, and decrypted packets are handed there rather than written
/// out.
async fn ensure_device(&self, network: NetworkId) -> Result<(), PluginError> {
let (request, key, own_range) = {
let key = {
let shared = self.lock_shared();
match shared.networks.get(&network) {
Some(state) if state.device.is_none() => (
self.desired_request(state, network),
state.key.clone(),
state.ipv4_range,
),
Some(state) if state.device.is_none() => state.key.clone(),
_ => return Ok(()),
}
};
let applied = request.clone();
let tun = self.tun_factory.create(request).await?;
let device = Arc::new(WireguardDevice::start(network, key, tun, own_range));
let sink = match self.sink.get() {
Some(sink) => Arc::clone(sink),
// Not attached to an agent: the protocol still runs, and its
// packets have nowhere to go.
None => Arc::new(tsunagi::dataplane::DiscardPackets) as Arc<dyn PacketSink>,
};
let device = Arc::new(WireguardDevice::start(network, key, sink));
let mut shared = self.lock_shared();
if let Some(state) = shared.networks.get_mut(&network) {
state.interface = device.interface().to_string();
state.device = Some(device);
state.applied = Some(applied);
}
Ok(())
}
/// Brings a live interface back in line after the overlay changed its
/// mind about this agent's address.
async fn ensure_addresses(&self, network: NetworkId) {
let wanted = {
let shared = self.lock_shared();
match shared.networks.get(&network) {
Some(state) if state.device.is_some() => {
let wanted = self.desired_request(state, network);
if state.applied.as_ref() == Some(&wanted) {
return;
}
wanted
}
_ => return,
}
};
match self.tun_factory.reconfigure(wanted.clone()).await {
Ok(()) => {
let mut shared = self.lock_shared();
if let Some(state) = shared.networks.get_mut(&network) {
state.applied = Some(wanted);
}
}
Err(err) => {
// Not fatal: the tunnels keep running on the addresses that
// are there, and the next reconciliation tries again.
tracing::warn!(%err, "cannot update the overlay interface addresses");
self.report(network, err);
}
}
}
/// Brings the running tunnels in line with what is known.
///
/// A peer gets a tunnel once both halves have arrived: its announcement,
@@ -550,10 +496,6 @@ impl Worker {
};
let allocations = state.allocations.clone();
let own_v4 = state.allocations.get(&self.local_id()).copied();
let interface = state.device.as_ref().map(|_| state.interface.clone());
let range = state.ipv4_range;
let state_reported = state.reported_missing_v4;
let mut wanted: Vec<WgPublicKey> = Vec::new();
let mut too_small: Vec<(usize, usize)> = Vec::new();
for (endpoint_id, announcement) in &state.announcements {
@@ -594,53 +536,15 @@ impl Worker {
}
device.retain_peers(&wanted);
// The agent assigns this address itself, so finding it absent means
// the assignment did not take — something outside removed it, or the
// provisioner reported a success it did not achieve. Left unsaid it
// looks like a broken network: the kernel would send packets with the
// wrong source address and every peer would drop them. So it is
// checked rather than assumed, because the assumption is exactly the
// kind that has been wrong here before.
let missing_v4 = match (own_v4, interface.as_deref(), range) {
(Some(address), Some(interface), Some(range))
if !super::tun::address_is_local(IpAddr::V4(address)) =>
{
let already = state_reported == Some(address);
if let Some(state) = shared.networks.get_mut(&network) {
state.reported_missing_v4 = Some(address);
}
(!already).then_some((address, interface.to_string(), range))
}
_ => {
if let Some(state) = shared.networks.get_mut(&network) {
state.reported_missing_v4 = None;
}
None
}
};
drop(shared);
if let Some((address, interface, range)) = missing_v4 {
self.report(
network,
format!(
"this agent was allocated {address}/{} but the address is not on any \
interface, so IPv4 cannot work: packets would leave with the wrong \
source and every peer would drop them. It should have been assigned \
to `{interface}` automatically; check whether something else removed \
it.",
range.prefix_len
),
);
}
for (available, needed) in too_small {
self.report(
network,
format!(
"this path carries only {available} byte datagrams but a {} byte MTU needs \
{needed}; packets larger than {} bytes will be dropped. Lower the MTU only \
if you can stay at or above {MIN_MTU}, which IPv6 requires.",
{needed}; packets larger than {} bytes will be dropped. The floor is \
{MIN_MTU} bytes, what every IPv4 host must be able to reassemble.",
self.config.mtu,
available.saturating_sub(WIREGUARD_OVERHEAD as usize)
),
@@ -655,11 +559,10 @@ impl Worker {
// kernel to remove an interface this agent created; the explicit
// destroy makes that immediate and definite rather than dependent on
// the last reader letting go.
let removed = self.lock_shared().networks.remove(&network);
if let Some(state) = removed {
drop(state.device);
self.tun_factory.destroy(&state.interface).await;
}
// Dropping the state drops the tunnels, which stops their tasks and
// closes their links. There is no interface to remove: the agent owns
// it, and it outlives any one network.
self.lock_shared().networks.remove(&network);
}
fn known_networks(&self) -> Vec<NetworkId> {
@@ -725,7 +628,6 @@ async fn run(worker: Arc<Worker>, mut commands: mpsc::Receiver<Command>) {
}, if wait_until.is_some() => {
deadline = None;
for network in std::mem::take(&mut pending) {
worker.ensure_addresses(network).await;
worker.sync(network);
}
}
@@ -736,7 +638,6 @@ async fn run(worker: Arc<Worker>, mut commands: mpsc::Receiver<Command>) {
if let Err(err) = worker.ensure_device(network).await {
tracing::debug!(%err, "packet interface still unavailable");
}
worker.ensure_addresses(network).await;
worker.sync(network);
}
}
@@ -749,10 +650,19 @@ impl IpPlugin for WireguardPlugin {
WIREGUARD_PROTOCOL
}
fn protocol_version(&self) -> u16 {
crate::announcement::ANNOUNCEMENT_VERSION
}
fn options(&self) -> &'static [tsunagi::dataplane::ProtocolOption] {
Self::OPTIONS
}
fn attach(&self, context: PluginContext) {
if let Some(local) = context.local_endpoint_id() {
let _ = self.worker.local_id.set(local);
}
let _ = self.worker.sink.set(context.packet_sink());
let _ = self.worker.context.set(context);
}
@@ -777,7 +687,7 @@ impl IpPlugin for WireguardPlugin {
let announcement = WgAnnouncement::new(network, &state.key.public());
Ok(Some(PluginCapability {
protocol: WIREGUARD_PROTOCOL.to_string(),
version: super::announcement::ANNOUNCEMENT_VERSION,
version: crate::announcement::ANNOUNCEMENT_VERSION,
enabled: true,
data: announcement.encode()?,
}))
@@ -845,6 +755,15 @@ impl IpPlugin for WireguardPlugin {
}
}
fn carry(&self, network: NetworkId, peer: EndpointId, packet: bytes::Bytes) -> bool {
let shared = self.worker.lock_shared();
shared
.networks
.get(&network)
.and_then(|state| state.device.as_ref())
.is_some_and(|device| device.carry(peer, &packet))
}
fn on_peer_link(&self, network: NetworkId, peer: EndpointId, link: SharedLink) {
self.nudge(Command::Link {
network,
@@ -873,15 +792,28 @@ impl IpPlugin for WireguardPlugin {
self.nudge(Command::Teardown(network));
}
fn on_network_forgotten(&self, network: NetworkId) {
// The key is this protocol's identity in that network and nothing
// else's. Keeping it after leaving would keep a secret for a
// network this agent is no longer in — and hand back the same
// overlay address on a rejoin that everyone else has moved past.
if let Err(err) = self.worker.store.forget(network) {
tracing::warn!(%err, "cannot remove the WireGuard key of a network we left");
}
}
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
Box::pin(async move {
let (reply_tx, reply_rx) = oneshot::channel();
if self.commands.send(Command::Stop(reply_tx)).await.is_ok() {
let _ = reply_rx.await;
// Bounded here as well as by the agent's grace: that grace
// abandons this future, which would leave the runtime task
// running behind it.
let _ = tokio::time::timeout(TASK_GRACE, reply_rx).await;
}
let task = self.task.lock().ok().and_then(|mut guard| guard.take());
if let Some(task) = task {
let _ = task.await;
wind_down(task, TASK_GRACE, "wg-quic runtime").await;
}
})
}
@@ -17,10 +17,10 @@ use std::sync::Mutex;
use rusqlite::{Connection, OptionalExtension, params};
use crate::dataplane::PluginError;
use crate::identity::NetworkId;
use tsunagi::dataplane::PluginError;
use tsunagi::identity::NetworkId;
use super::keys::{KEY_LEN, WgSecretKey};
use crate::keys::{KEY_LEN, WgSecretKey};
/// Schema version written by this build.
pub const SCHEMA_VERSION: i64 = 1;
@@ -37,14 +37,14 @@ impl WgKeyStore {
pub fn open(path: impl AsRef<Path>) -> Result<Self, PluginError> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
crate::storage::create_dir(parent)
tsunagi::storage::create_dir(parent)
.map_err(|err| PluginError::Other(format!("cannot create {parent:?}: {err}")))?;
}
let existed = path.exists();
let conn = Connection::open(&path).map_err(|err| {
PluginError::Other(format!("cannot open the WireGuard key store: {err}"))
})?;
crate::storage::restrict_path_permissions(&path)
tsunagi::storage::restrict_path_permissions(&path)
.map_err(|err| PluginError::Other(format!("cannot secure the key store: {err}")))?;
conn.busy_timeout(std::time::Duration::from_secs(5))
@@ -169,7 +169,7 @@ mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
fn network(name: &str) -> NetworkId {
NetworkKeys::derive(
+313
View File
@@ -0,0 +1,313 @@
//! Exit nodes, end to end: real agents, a real control plane, real iroh data
//! links and real WireGuard, with an in-memory packet interface and a mock
//! host, so no privileges are needed and the machine's network is untouched.
//!
//! One agent offers itself as an exit node; another chooses it, and an
//! ordinary internet packet leaves the second agent's interface, arrives on
//! the first one's, and the answer comes back with the internet's own source
//! address.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::Ipv4Addr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use iroh::EndpointId;
use tempfile::TempDir;
use tsunagi::dataplane::IpPlugin;
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::NetworkId;
use tsunagi::overlay::{
BroadcastHostRules, MemoryTun, MemoryTunFactory, MockHostRules, OverlayError, TunDevice,
TunFactory, TunRequest,
};
use tsunagi::testing::{network, wait_until};
use tsunagi::{Agent, NetworkStatus};
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
const INTERNET: Ipv4Addr = Ipv4Addr::new(93, 184, 216, 34);
/// An in-memory interface on a host whose rules are a mock.
///
/// Not "on the host": the agent must not go looking for an address on a real
/// link. It only has host *rules*, which is what an exit node needs from it.
#[derive(Debug, Clone)]
struct RuledTuns {
tuns: MemoryTunFactory,
rules: MockHostRules,
}
impl TunFactory for RuledTuns {
fn name(&self) -> &str {
"memory-with-rules"
}
fn on_host(&self) -> bool {
false
}
fn host_rules(&self) -> Option<Arc<dyn BroadcastHostRules>> {
Some(Arc::new(self.rules.clone()))
}
fn create<'a>(
&'a self,
request: TunRequest,
) -> tsunagi::BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>> {
self.tuns.create(request)
}
fn reconfigure<'a>(
&'a self,
request: TunRequest,
) -> tsunagi::BoxFuture<'a, Result<(), OverlayError>> {
self.tuns.reconfigure(request)
}
fn destroy<'a>(&'a self, name: &'a str) -> tsunagi::BoxFuture<'a, ()> {
self.tuns.destroy(name)
}
}
struct Node {
_dir: TempDir,
agent: Agent,
plugin: Arc<WireguardPlugin>,
tuns: MemoryTunFactory,
rules: MockHostRules,
}
impl Node {
async fn spawn(discovery: &SharedMemoryDiscovery, tag: &str) -> Self {
let dir = TempDir::new().unwrap();
let tuns = MemoryTunFactory::new();
let rules = MockHostRules::new();
let plugin = WireguardPlugin::open(
WireguardConfig::new(dir.path().join("wireguard"))
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
)
.await
.unwrap();
let agent = Agent::spawn(
tsunagi::testing::config_with(dir.path(), discovery)
.with_overlay_ipv4_range(Some(tsunagi::state::DEFAULT_IPV4_RANGE))
.with_interface(
Arc::new(RuledTuns {
tuns: tuns.clone(),
rules: rules.clone(),
}),
tag,
1280,
)
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
)
.await
.unwrap();
Self {
_dir: dir,
agent,
plugin,
tuns,
rules,
}
}
fn id(&self) -> EndpointId {
self.agent.endpoint_id()
}
async fn overlay(&self, network: NetworkId) -> Ipv4Addr {
wait_until("the network agreed an overlay address", || async {
self.plugin.overview(network)?.overlay_address_v4
})
.await
}
async fn tun(&self) -> Arc<MemoryTun> {
let name = wait_until("the packet interface exists", || async {
self.agent.overlay().map(|overlay| overlay.interface)
})
.await;
self.tuns.device(&name).expect("the device was created")
}
async fn wait_for_tunnels(&self, network: NetworkId, count: usize) {
wait_until("established WireGuard tunnels", || async {
let view = self.plugin.overview(network)?;
(view.established_peers() == count).then_some(())
})
.await;
}
async fn status(&self, network: NetworkId) -> NetworkStatus {
self.agent.network_status(network).await.unwrap()
}
}
fn ipv4_packet(source: Ipv4Addr, destination: Ipv4Addr, payload: &[u8]) -> Bytes {
let total = 20 + payload.len();
let mut packet = Vec::with_capacity(total);
packet.push((4 << 4) | 5);
packet.push(0);
packet.extend_from_slice(&(total as u16).to_be_bytes());
packet.extend_from_slice(&[0, 0, 0, 0]);
packet.push(64);
packet.push(253);
packet.extend_from_slice(&[0, 0]);
packet.extend_from_slice(&source.octets());
packet.extend_from_slice(&destination.octets());
packet.extend_from_slice(payload);
Bytes::from(packet)
}
async fn next(tun: &MemoryTun) -> Bytes {
tokio::time::timeout(Duration::from_secs(10), tun.pop_to_os())
.await
.expect("a packet reaches the operating system")
.unwrap()
}
#[tokio::test]
async fn a_member_sends_its_internet_traffic_through_an_exit_node_and_gets_the_answers() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("exit-node");
let server = Node::spawn(&discovery, "texita").await;
let client = Node::spawn(&discovery, "texitb").await;
let id = server.agent.join_network(&name, &secret).await.unwrap();
client.agent.join_network(&name, &secret).await.unwrap();
server.wait_for_tunnels(id, 1).await;
client.wait_for_tunnels(id, 1).await;
let server_addr = server.overlay(id).await;
let client_addr = client.overlay(id).await;
let server_tun = server.tun().await;
let client_tun = client.tun().await;
// Off until its owner turns it on: nothing is announced, and choosing it
// is refused rather than quietly accepted.
assert!(!server.status(id).await.exit_offer);
assert!(
client
.status(id)
.await
.peers
.iter()
.all(|peer| !peer.exit_node)
);
let refused = client.agent.set_exit_via(id, Some(server.id())).await;
assert!(refused.is_err(), "{refused:?}");
assert!(client.status(id).await.exit_via.is_none());
// The server turns it on. Its host rules are applied, and the client
// learns of it over the control channel.
server.agent.set_exit_offer(id, true).await.unwrap();
let plan = server.rules.exit().installed().expect("rules were applied");
assert_eq!(plan.offer, vec![tsunagi::state::DEFAULT_IPV4_RANGE]);
assert!(!plan.client);
let status = server.status(id).await;
assert!(status.exit_offer);
assert!(status.exit_rules.offer.as_ref().unwrap().ok);
wait_until("the client sees an exit node", || async {
client
.status(id)
.await
.peers
.iter()
.any(|peer| peer.endpoint_id == server.id() && peer.exit_node)
.then_some(())
})
.await;
// The client chooses it; its routes are installed.
client
.agent
.set_exit_via(id, Some(server.id()))
.await
.unwrap();
let status = client.status(id).await;
assert_eq!(status.exit_via, Some(server.id()));
assert!(status.exit_via_online);
assert!(
client
.rules
.exit()
.installed()
.expect("client rules")
.client
);
// An ordinary packet for the internet leaves the client's interface and
// arrives on the server's, from the client's overlay address.
client_tun.push_from_os(ipv4_packet(client_addr, INTERNET, b"GET /"));
let request = next(&server_tun).await;
assert_eq!(&request[12..16], &client_addr.octets());
assert_eq!(&request[16..20], &INTERNET.octets());
assert_eq!(&request[20..], b"GET /");
// The host's answer, from the internet's own address, comes back.
server_tun.push_from_os(ipv4_packet(INTERNET, client_addr, b"200 OK"));
let reply = next(&client_tun).await;
assert_eq!(&reply[12..16], &INTERNET.octets());
assert_eq!(&reply[16..20], &client_addr.octets());
assert_eq!(&reply[20..], b"200 OK");
// Members still reach each other directly, exit node or not.
client_tun.push_from_os(ipv4_packet(client_addr, server_addr, b"ping"));
let direct = next(&server_tun).await;
assert_eq!(&direct[12..16], &client_addr.octets());
assert_eq!(&direct[16..20], &server_addr.octets());
// Stopping puts everything back, and the choice is gone.
client.agent.set_exit_via(id, None).await.unwrap();
assert!(client.status(id).await.exit_via.is_none());
assert!(client.rules.exit().installed().is_none());
server.agent.set_exit_offer(id, false).await.unwrap();
assert!(server.rules.exit().installed().is_none());
wait_until("the client sees it withdrawn", || async {
client
.status(id)
.await
.peers
.iter()
.all(|peer| !peer.exit_node)
.then_some(())
})
.await;
client.agent.shutdown().await;
server.agent.shutdown().await;
}
#[tokio::test]
async fn an_agent_that_is_not_offering_drops_a_members_internet_packets() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("exit-node-off");
let server = Node::spawn(&discovery, "texitc").await;
let client = Node::spawn(&discovery, "texitd").await;
let id = server.agent.join_network(&name, &secret).await.unwrap();
client.agent.join_network(&name, &secret).await.unwrap();
server.wait_for_tunnels(id, 1).await;
client.wait_for_tunnels(id, 1).await;
let client_addr = client.overlay(id).await;
let server_tun = server.tun().await;
let client_tun = client.tun().await;
// With no exit node chosen, the client's own interface has nowhere to
// send the packet, and nothing reaches the server.
client_tun.push_from_os(ipv4_packet(client_addr, INTERNET, b"nowhere"));
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(
tokio::time::timeout(Duration::from_millis(300), server_tun.pop_to_os())
.await
.is_err(),
"nothing was sent"
);
assert!(
server.rules.exit().installed().is_none(),
"no rules without the switch"
);
client.agent.shutdown().await;
server.agent.shutdown().await;
}
@@ -11,29 +11,27 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
use std::time::Duration;
use common::{config_with, network, wait_until};
use tempfile::TempDir;
use tsunagi::dataplane::IpPlugin;
use tsunagi::dataplane::wireguard::{
Cidr, InterfaceState, LinkKind, ManagedTunFactory, MockHost, MockProvisioner, WireguardConfig,
WireguardPlugin,
};
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::NetworkId;
use tsunagi::overlay::{
Cidr, InterfaceState, LinkKind, ManagedTunFactory, MockHost, MockProvisioner,
};
use tsunagi::state::DEFAULT_IPV4_RANGE;
use tsunagi::testing::{config_with, network, wait_until};
use tsunagi::{Agent, NetworkStatus};
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
/// An agent whose overlay interface is applied to a pretend host.
struct HostedAgent {
_dir: TempDir,
agent: Agent,
plugin: Arc<WireguardPlugin>,
_plugin: Arc<WireguardPlugin>,
host: MockHost,
}
@@ -43,12 +41,12 @@ impl HostedAgent {
let provisioner = Arc::new(MockProvisioner::new(host.clone()));
let factory = Arc::new(ManagedTunFactory::new(provisioner));
let config = WireguardConfig::new(dir.path().join("wireguard"))
.with_interface_prefix(tag)
.with_reconcile(Duration::from_millis(20), Duration::from_millis(100));
let plugin = WireguardPlugin::open(config, factory).await.unwrap();
let plugin = WireguardPlugin::open(config).await.unwrap();
let agent = Agent::spawn(
config_with(dir.path(), discovery)
.with_overlay_ipv4_range(Some(DEFAULT_IPV4_RANGE))
.with_interface(factory, tag, 1280)
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
)
.await
@@ -56,17 +54,18 @@ impl HostedAgent {
Self {
_dir: dir,
agent,
plugin,
_plugin: plugin,
host,
}
}
/// The interface name the plugin settled on for a network.
async fn interface(&self, network: NetworkId) -> String {
wait_until("the plugin named its interface", || async {
self.plugin
.overview(network)
.map(|view| view.interface)
/// The one interface this agent owns. Not per network.
async fn interface(&self, _network: NetworkId) -> String {
wait_until("the agent named its interface", || async {
self.agent
.overlay()
.map(|overlay| overlay.interface)
.filter(|name| !name.is_empty())
})
.await
@@ -94,11 +93,12 @@ fn v4(state: &InterfaceState) -> Vec<Ipv4Addr> {
.collect()
}
fn has_v6(state: &InterfaceState) -> bool {
state
.addresses
.iter()
.any(|cidr| matches!(cidr.addr, IpAddr::V6(_)))
/// Whether the overlay address has been put on the interface yet.
///
/// It is allocated and signed at the system level, so it arrives on a later
/// reconciliation than the interface itself rather than with it.
fn addressed(state: &InterfaceState) -> bool {
!state.addresses.is_empty()
}
#[tokio::test]
@@ -111,18 +111,13 @@ async fn an_agent_creates_and_configures_its_own_overlay_interface() {
let interface = agent.interface(network_id).await;
let state = agent
.wait_for_host("the interface to be created", &interface, |state| {
state.filter(|state| !state.addresses.is_empty())
})
.wait_for_host("the interface to be created", &interface, |state| state)
.await;
assert_eq!(state.kind, LinkKind::Tun);
assert!(state.up, "the agent brought the link up itself");
assert_eq!(state.mtu, 1280);
assert!(has_v6(&state), "the derived overlay address is assigned");
// The IPv4 address is allocated at run time, so it arrives on a later
// reconciliation than the interface itself.
let addresses = agent
.wait_for_host("the allocated IPv4 address", &interface, |state| {
state.map(|state| v4(&state)).filter(|v4| !v4.is_empty())
@@ -160,9 +155,14 @@ async fn an_interface_left_by_a_crashed_run_is_replaced_rather_than_tripped_over
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
assert_eq!(agent.interface(network_id).await, interface);
// Waited on directly rather than on "it has some address": the address
// this agent was allocated arrives a reconciliation after the interface
// does, and in between the leftover is still the only one there.
let state = agent
.wait_for_host("the interface to be rebuilt", &interface, |state| {
state.filter(|state| state.attached && has_v6(state))
.wait_for_host("the stale address to be replaced", &interface, |state| {
state.filter(|state| {
state.attached && addressed(state) && !state.addresses.contains(&stale)
})
})
.await;
assert!(
@@ -216,7 +216,10 @@ async fn an_interface_belonging_to_something_else_is_left_alone() {
}
#[tokio::test]
async fn leaving_a_network_removes_the_interface_from_the_host() {
async fn leaving_a_network_takes_its_address_off_the_interface_but_not_the_interface() {
// The interface belongs to the agent, so it outlives any one network:
// another may still be using it. What a network takes with it is its own
// address.
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("provision-cleanup");
let agent = HostedAgent::spawn(&discovery, "tsunx", MockHost::new()).await;
@@ -224,21 +227,25 @@ async fn leaving_a_network_removes_the_interface_from_the_host() {
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
let interface = agent.interface(network_id).await;
agent
.wait_for_host("the interface to exist", &interface, |state| state)
.wait_for_host("the address to be assigned", &interface, |state| {
state.filter(addressed)
})
.await;
agent.agent.deactivate_network(network_id).await.unwrap();
agent
.wait_for_host("the interface to be removed", &interface, |state| {
state.is_none().then_some(())
let state = agent
.wait_for_host("the address to be withdrawn", &interface, |state| {
state.filter(|state| !addressed(state))
})
.await;
assert_eq!(state.kind, LinkKind::Tun, "the interface is still there");
// And it goes when the agent does.
agent.agent.shutdown().await;
assert!(
agent.host.names().is_empty(),
"nothing is left behind: {:?}",
agent.host.names()
);
agent.agent.shutdown().await;
}
@@ -6,22 +6,21 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::sync::Arc;
use std::time::Duration;
use common::{config_with, network, wait_for_peers, wait_until};
use tempfile::TempDir;
use tsunagi::dataplane::IpPlugin;
use tsunagi::dataplane::wireguard::{MemoryTunFactory, WireguardConfig, WireguardPlugin};
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::ipc::unix::{ControlSocket, request_status};
use tsunagi::ipc::{ControlSocket, ControlSocketAccess, request_status};
use tsunagi::ipc::{StatusReport, control_socket_path};
use tsunagi::overlay::MemoryTunFactory;
use tsunagi::testing::{config_with, network, wait_for_peers, wait_until};
use tsunagi::{Agent, BoxFuture};
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
/// Builds the report the way the binary does, from the agent plus the plugin.
fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::unix::ReportSource> {
fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::ReportSource> {
Arc::new(move || -> BoxFuture<'static, StatusReport> {
let agent = agent.clone();
let plugin = Arc::clone(&plugin);
@@ -42,21 +41,24 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::u
hostname: peer.hostname.clone(),
transport: format!("{:?}", peer.transport),
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
exit_node: peer.exit_node,
})
.collect(),
overlay: plugin.overview(net.network_id).map(|view| {
tsunagi::ipc::OverlayReport {
interface: view.interface.clone(),
mtu: view.mtu,
address: view.overlay_address.to_string(),
prefix: view.overlay_prefix.to_string(),
prefix_len: view.overlay_prefix_len,
// The interface belongs to the agent now.
interface: agent
.overlay()
.map_or_else(String::new, |overlay| overlay.interface),
mtu: agent.overlay().map_or(0, |overlay| overlay.mtu),
address: view.overlay_address_v4.map(|a| a.to_string()),
prefix_len: view.ipv4_range.map_or(0, |range| range.prefix_len),
peers: view
.peers
.iter()
.map(|peer| tsunagi::ipc::OverlayPeerReport {
public_key: peer.public_key.to_string(),
address: peer.overlay_address.to_string(),
address: peer.overlay_address_v4.map(|a| a.to_string()),
handshake_secs_ago: peer
.tunnel
.as_ref()
@@ -82,6 +84,7 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::u
cache_healthy: status.cache_healthy,
networks,
dns: None,
..Default::default()
}
})
})
@@ -96,14 +99,14 @@ async fn a_client_sees_the_agent_and_its_overlay() {
let tuns = MemoryTunFactory::new();
let plugin = WireguardPlugin::open(
WireguardConfig::new(dir_a.path().join("wg"))
.with_interface_prefix("tca")
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
Arc::new(tuns),
)
.await
.unwrap();
let agent = Agent::spawn(
config_with(dir_a.path(), &discovery).with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
config_with(dir_a.path(), &discovery)
.with_interface(Arc::new(tuns), "tca0", 1280)
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
)
.await
.unwrap();
@@ -111,14 +114,14 @@ async fn a_client_sees_the_agent_and_its_overlay() {
let dir_b = TempDir::new().unwrap();
let plugin_b = WireguardPlugin::open(
WireguardConfig::new(dir_b.path().join("wg"))
.with_interface_prefix("tcb")
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
Arc::new(MemoryTunFactory::new()),
)
.await
.unwrap();
let agent_b = Agent::spawn(
config_with(dir_b.path(), &discovery).with_plugin(plugin_b.clone() as Arc<dyn IpPlugin>),
config_with(dir_b.path(), &discovery)
.with_interface(Arc::new(MemoryTunFactory::new()), "tcb0", 1280)
.with_plugin(plugin_b.clone() as Arc<dyn IpPlugin>),
)
.await
.unwrap();
@@ -129,9 +132,13 @@ async fn a_client_sees_the_agent_and_its_overlay() {
// A short path: a Unix socket address is limited to about 100 bytes.
let socket_path = dir_a.path().join("agent.sock");
let control = ControlSocket::bind(&socket_path, source(agent.clone(), plugin.clone()))
.await
.unwrap();
let control = ControlSocket::bind(
&socket_path,
source(agent.clone(), plugin.clone()),
ControlSocketAccess::Private,
)
.await
.unwrap();
let report = wait_until("the overlay is reported as up", || {
let socket_path = socket_path.clone();
@@ -168,7 +175,7 @@ async fn a_client_sees_the_agent_and_its_overlay() {
"the tunnel is up: {:?}",
overlay.peers
);
assert!(!overlay.address.is_empty());
assert!(overlay.address.is_some());
control.shutdown().await;
assert!(!socket_path.exists(), "the socket is removed on shutdown");
@@ -185,20 +192,20 @@ async fn a_leftover_socket_file_is_replaced_but_a_live_one_is_not() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("agent.sock");
let empty: Arc<dyn tsunagi::ipc::unix::ReportSource> =
let empty: Arc<dyn tsunagi::ipc::ReportSource> =
Arc::new(|| -> BoxFuture<'static, StatusReport> {
Box::pin(async { StatusReport::default() })
});
// A file with nobody listening is a leftover from a crash.
std::fs::write(&path, b"stale").unwrap();
let first = ControlSocket::bind(&path, Arc::clone(&empty))
let first = ControlSocket::bind(&path, Arc::clone(&empty), ControlSocketAccess::Private)
.await
.unwrap();
assert!(request_status(&path).await.is_ok());
// A live socket is not stolen from the agent that owns it.
let second = ControlSocket::bind(&path, Arc::clone(&empty)).await;
let second = ControlSocket::bind(&path, Arc::clone(&empty), ControlSocketAccess::Private).await;
assert!(
matches!(second, Err(tsunagi::Error::StateLocked { .. })),
"a second agent must not take over a live control socket"
@@ -233,45 +240,180 @@ fn the_socket_path_is_derived_and_short_enough() {
);
}
/// Asking who this device is must not need the directory lock.
///
/// The lock belongs to the one agent allowed to *write* the state. `id` only
/// reads, so making it take the lock would mean the question could never be
/// answered while an agent was running — which is exactly when you want to
/// ask it.
#[cfg(feature = "cli")]
#[tokio::test]
async fn identity_can_be_read_while_an_agent_holds_the_directory() {
let dir = TempDir::new().unwrap();
let discovery = SharedMemoryDiscovery::new();
/// A source that can also leave, the way the binary's one does.
#[derive(Debug)]
struct Control(Agent);
// Hold the directory the way a running agent does.
impl tsunagi::ipc::ReportSource for Control {
fn report(&self) -> BoxFuture<'_, StatusReport> {
Box::pin(async move { StatusReport::default() })
}
fn join(
&self,
name: String,
secret: String,
_broadcast: Option<bool>,
) -> BoxFuture<'_, Result<tsunagi::ipc::JoinedReport, String>> {
Box::pin(async move {
let name = tsunagi::identity::NetworkName::new(&name).map_err(|e| e.to_string())?;
let secret =
tsunagi::identity::NetworkSecret::decode(&secret).map_err(|e| e.to_string())?;
let already = self
.0
.list_networks()
.await
.map_err(|err| err.to_string())?
.iter()
.any(|other| other.name == name);
let network_id = self
.0
.join_network(&name, &secret)
.await
.map_err(|err| err.to_string())?;
Ok(tsunagi::ipc::JoinedReport {
name: name.as_str().to_string(),
network_id: network_id.to_string(),
already_configured: already,
name_shared_with: None,
})
})
}
fn leave(&self, network_id: String) -> BoxFuture<'_, Result<tsunagi::ipc::LeftReport, String>> {
Box::pin(async move {
let wanted: tsunagi::NetworkId = network_id.parse().map_err(|_| "not an id")?;
let name = self
.0
.list_networks()
.await
.map_err(|err| err.to_string())?
.into_iter()
.find(|network| network.network_id == wanted)
.map(|network| network.name.as_str().to_string())
.ok_or("not a network this agent is in")?;
let outcome = self
.0
.leave_network(wanted)
.await
.map_err(|err| err.to_string())?;
Ok(tsunagi::ipc::LeftReport {
name,
announced: outcome.announced,
peers_told: outcome.peers_told as u32,
})
})
}
}
#[tokio::test]
async fn a_client_can_leave_a_network_through_the_running_agent() {
// The release can only be published by the agent that is running, and
// only while its sessions are up, so leaving goes over this socket
// rather than being done behind its back in the state store.
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("control-leave");
let dir = TempDir::new().unwrap();
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
let expected = agent.endpoint_id().to_string();
let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.arg("id")
// The same layout `StoragePaths::under` gives the agent above.
.arg("--state-dir")
.arg(dir.path().join("state"))
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.output()
let other = TempDir::new().unwrap();
let peer = Agent::spawn(config_with(other.path(), &discovery))
.await
.unwrap();
let network_id = agent.join_network(&name, &secret).await.unwrap();
peer.join_network(&name, &secret).await.unwrap();
wait_for_peers(&agent, network_id, 1).await;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"`id` failed while an agent was running:\n{stderr}"
);
assert!(
stdout.contains(&expected),
"expected {expected} in:\n{stdout}"
);
let socket_path = dir.path().join("control.sock");
let control = ControlSocket::bind(
&socket_path,
Arc::new(Control(agent.clone())),
ControlSocketAccess::Private,
)
.await
.unwrap();
let report = tsunagi::ipc::leave_network(&socket_path, &network_id.to_string())
.await
.unwrap();
assert_eq!(report.name, name.as_str());
assert!(report.announced);
assert_eq!(report.peers_told, 1);
assert!(agent.list_networks().await.unwrap().is_empty());
// Asking again names the state it is in rather than failing obscurely.
let err = tsunagi::ipc::leave_network(&socket_path, &network_id.to_string())
.await
.unwrap_err();
assert!(err.to_string().contains("not a network"), "{err}");
control.shutdown().await;
agent.shutdown().await;
peer.shutdown().await;
}
#[tokio::test]
async fn a_client_can_add_a_network_to_a_running_agent() {
// One agent per identity, and it may be in several networks at once —
// but the state directory belongs to that one live agent, so a second
// `up` cannot add a network to it. Without this there was no way at
// all: you could leave a network while running, but not join one.
let discovery = SharedMemoryDiscovery::new();
let (first, first_secret) = network("control-join-one");
let (second, second_secret) = network("control-join-two");
let dir = TempDir::new().unwrap();
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
agent.join_network(&first, &first_secret).await.unwrap();
let socket_path = dir.path().join("control.sock");
let control = ControlSocket::bind(
&socket_path,
Arc::new(Control(agent.clone())),
ControlSocketAccess::Private,
)
.await
.unwrap();
let report = tsunagi::ipc::join_network(
&socket_path,
second.as_str(),
second_secret.encode().as_str(),
)
.await
.unwrap();
assert_eq!(report.name, second.as_str());
assert!(!report.already_configured);
// Running, not merely written down: it is in the agent's own list and
// answering for status straight away, on the same identity.
let joined: tsunagi::NetworkId = report.network_id.parse().unwrap();
assert!(agent.is_active(joined).await);
assert_eq!(agent.list_networks().await.unwrap().len(), 2);
assert!(agent.network_status(joined).await.is_ok());
// Joining the same one again is not an error, and says which it was.
let again = tsunagi::ipc::join_network(
&socket_path,
second.as_str(),
second_secret.encode().as_str(),
)
.await
.unwrap();
assert!(again.already_configured);
assert_eq!(again.network_id, report.network_id);
// A secret that is not one is refused rather than stored.
let err = tsunagi::ipc::join_network(&socket_path, "rubbish", "not-a-secret")
.await
.unwrap_err();
assert!(!err.to_string().is_empty());
assert_eq!(agent.list_networks().await.unwrap().len(), 2);
control.shutdown().await;
agent.shutdown().await;
}
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "tsunagi-wireguard"
version = "0.1.0"
description = "Pure WireGuard direct UDP transport and protocol plugin for tsunagi mesh networks."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0" }
iroh.workspace = true
tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macros"] }
serde.workspace = true
postcard.workspace = true
bytes.workspace = true
thiserror.workspace = true
tracing.workspace = true
data-encoding.workspace = true
hex.workspace = true
rusqlite = { version = "0.40", features = ["bundled"] }
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
rand = "0.10"
boringtun = { version = "0.7.1", default-features = false }
netdev = "0.46"
[dev-dependencies]
tsunagi = { path = "../tsunagi", features = ["testing"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
[lints]
workspace = true
@@ -0,0 +1,142 @@
//! What a pure WireGuard peer tells the network about itself.
//!
//! Exchanged via [`tsunagi::dataplane::PluginCapability`]. The announcement
//! carries the WireGuard public key, the listening UDP port, and candidate IP addresses
//! for direct UDP packet transport.
use serde::{Deserialize, Serialize};
use tsunagi::dataplane::PluginError;
use tsunagi::identity::NetworkId;
use crate::keys::WgPublicKey;
/// The wire protocol identifier for pure WireGuard.
pub const WG_PROTOCOL: &str = "wg";
/// Wire version of the `wg` announcement format.
pub const ANNOUNCEMENT_VERSION: u16 = 1;
/// Wire version peers compare during capability exchange.
///
/// This is the same as [`ANNOUNCEMENT_VERSION`]; keeping a separate name
/// makes the intent explicit in the CLI protocol registry.
pub const WG_WIRE_VERSION: u16 = ANNOUNCEMENT_VERSION;
/// Maximum number of IP candidates allowed in an announcement.
pub const MAX_CANDIDATE_ADDRS: usize = 32;
/// What one participant advertises for the pure WireGuard UDP data plane.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WgAnnouncement {
/// Announcement format version.
pub version: u16,
/// The peer's WireGuard public key.
pub public_key: [u8; 32],
/// The network this key is for.
pub network: [u8; 32],
/// The UDP port the peer is listening on for WireGuard packets.
pub port: u16,
/// Candidate IP addresses where this peer can be reached on `port`.
#[serde(default)]
pub addrs: Vec<std::net::IpAddr>,
}
/// A peer announcement that has been validated against a specific network.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidatedAnnouncement {
/// The peer's WireGuard public key.
pub public_key: WgPublicKey,
/// The UDP port the peer listens on.
pub port: u16,
/// Validated candidate IP addresses.
pub addrs: Vec<std::net::IpAddr>,
}
impl WgAnnouncement {
/// Builds this agent's announcement.
pub fn new(
network: NetworkId,
public_key: &WgPublicKey,
port: u16,
addrs: Vec<std::net::IpAddr>,
) -> Self {
Self {
version: ANNOUNCEMENT_VERSION,
public_key: *public_key.as_bytes(),
network: *network.as_bytes(),
port,
addrs,
}
}
/// Encodes the announcement into the opaque capability payload.
pub fn encode(&self) -> Result<Vec<u8>, PluginError> {
postcard::to_allocvec(self)
.map_err(|err| PluginError::Other(format!("cannot encode announcement: {err}")))
}
/// Decodes and validates a payload received from a peer.
pub fn decode_and_validate(
payload: &[u8],
network: NetworkId,
local_key: &WgPublicKey,
) -> Result<ValidatedAnnouncement, PluginError> {
let announcement: Self = postcard::from_bytes(payload)
.map_err(|_| PluginError::Rejected("malformed WireGuard announcement".into()))?;
announcement.validate(network, local_key)
}
fn validate(
self,
network: NetworkId,
local_key: &WgPublicKey,
) -> Result<ValidatedAnnouncement, PluginError> {
if self.version != ANNOUNCEMENT_VERSION {
return Err(PluginError::Rejected(format!(
"peer speaks WireGuard announcement version {} but this build speaks \
{ANNOUNCEMENT_VERSION}; one of the two needs updating",
self.version
)));
}
let public_key = WgPublicKey::from_bytes(self.public_key);
if public_key.is_zero() {
return Err(PluginError::Rejected(
"WireGuard public key is all zeroes".into(),
));
}
if &public_key == local_key {
return Err(PluginError::Rejected(
"peer announced this agent's own WireGuard key".into(),
));
}
if self.network != *network.as_bytes() {
return Err(PluginError::Rejected(
"announcement is for a different network".into(),
));
}
if self.port == 0 {
return Err(PluginError::Rejected("WireGuard port cannot be 0".into()));
}
if self.addrs.len() > MAX_CANDIDATE_ADDRS {
return Err(PluginError::Rejected(format!(
"too many candidate addresses: {} > {MAX_CANDIDATE_ADDRS}",
self.addrs.len()
)));
}
let mut valid_addrs = Vec::new();
for addr in self.addrs {
if !addr.is_unspecified() && !valid_addrs.contains(&addr) {
valid_addrs.push(addr);
}
}
Ok(ValidatedAnnouncement {
public_key,
port: self.port,
addrs: valid_addrs,
})
}
}
+686
View File
@@ -0,0 +1,686 @@
//! Userspace WireGuard device implementation over direct UDP links.
//!
//! Uses [`boringtun::noise::Tunn`] state machine to encrypt and decrypt
//! standard WireGuard protocol packets directly over a [`SharedLink`].
use std::collections::{HashMap, VecDeque};
use std::net::Ipv4Addr;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
use boringtun::noise::{Tunn, TunnResult};
use bytes::Bytes;
use iroh::EndpointId;
use tokio::task::JoinHandle;
use tsunagi::dataplane::routing::{FlowId, flow::ip_flow};
use tsunagi::dataplane::transport::{SharedLink, TransportError};
use tsunagi::dataplane::{PacketSink, PluginContext, PluginError};
use tsunagi::identity::NetworkId;
use crate::keys::{WgPublicKey, WgSecretKey};
use crate::transport::WireguardTransport;
const TIMER_INTERVAL: Duration = Duration::from_millis(250);
const SCRATCH: usize = 4096;
/// Maximum consecutive rekey timeout retransmissions before declaring a tunnel dead.
pub const MAX_REKEY_TIMEOUTS: u32 = 3;
/// Overall maximum duration trying to establish or rekey a handshake before declaring it dead.
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15);
struct FlowTunnel {
tunn: Tunn,
queued: VecDeque<FlowId>,
handshake_started_at: Option<std::time::Instant>,
rekey_timeouts: u32,
failed: bool,
}
fn is_data(result: &TunnResult<'_>) -> bool {
matches!(result, TunnResult::WriteToNetwork(bytes) if bytes.starts_with(&[4, 0, 0, 0]))
}
impl FlowTunnel {
fn encapsulate<'a>(
&mut self,
packet: &[u8],
scratch: &'a mut [u8],
flow: FlowId,
) -> (TunnResult<'a>, FlowId) {
let result = self.tunn.encapsulate(packet, scratch);
if is_data(&result) {
return (result, flow);
}
if matches!(result, TunnResult::WriteToNetwork(ref b) if !b.is_empty() && b[0] == 1)
&& self.handshake_started_at.is_none()
{
self.handshake_started_at = Some(std::time::Instant::now());
self.rekey_timeouts = 0;
self.failed = false;
}
if self.queued.len() < 256 {
self.queued.push_back(flow);
}
(result, 0)
}
fn decapsulate<'a>(
&mut self,
packet: &[u8],
scratch: &'a mut [u8],
) -> (TunnResult<'a>, FlowId) {
let result = self.tunn.decapsulate(None, packet, scratch);
if self.tunn.time_since_last_handshake().is_some() {
self.handshake_started_at = None;
self.rekey_timeouts = 0;
self.failed = false;
}
let mut flow = 0;
if packet.is_empty() {
if is_data(&result) {
flow = self.queued.pop_front().unwrap_or(0);
} else if let Some(pending) = self.queued.pop_front() {
self.queued.push_back(pending);
}
}
(result, flow)
}
fn update_timers<'a>(&mut self, scratch: &'a mut [u8]) -> (TunnResult<'a>, bool) {
if self.failed {
return (TunnResult::Done, false);
}
let result = self.tunn.update_timers(scratch);
if matches!(
result,
TunnResult::Err(boringtun::noise::errors::WireGuardError::ConnectionExpired)
) || self.tunn.is_expired()
{
self.failed = true;
self.queued.clear();
self.handshake_started_at = None;
self.rekey_timeouts = 0;
return (result, true);
}
if let TunnResult::WriteToNetwork(ref bytes) = result
&& !bytes.is_empty()
&& bytes[0] == 1
&& self.handshake_started_at.is_none()
{
self.handshake_started_at = Some(std::time::Instant::now());
}
if let Some(started) = self.handshake_started_at
&& started.elapsed() >= HANDSHAKE_TIMEOUT
{
self.failed = true;
self.queued.clear();
self.handshake_started_at = None;
self.rekey_timeouts = 0;
return (result, true);
}
(result, false)
}
}
#[derive(Debug, Default)]
struct PeerCounters {
tx_packets: AtomicU64,
tx_bytes: AtomicU64,
rx_packets: AtomicU64,
rx_bytes: AtomicU64,
dropped_wrong_source: AtomicU64,
dropped_oversize: AtomicU64,
dropped_no_session: AtomicU64,
protocol_errors: AtomicU64,
}
/// A snapshot of one peer's tunnel counters.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PeerStats {
/// Plaintext packets encrypted and sent to this peer.
pub tx_packets: u64,
/// Plaintext bytes encrypted and sent to this peer.
pub tx_bytes: u64,
/// Plaintext packets decrypted from this peer and given to the OS.
pub rx_packets: u64,
/// Plaintext bytes decrypted from this peer and given to the OS.
pub rx_bytes: u64,
/// Packets dropped because their source was not this peer's address.
pub dropped_wrong_source: u64,
/// Packets dropped because they did not fit in one link datagram.
pub dropped_oversize: u64,
/// Packets dropped because there was no session to encrypt with yet.
pub dropped_no_session: u64,
/// WireGuard protocol errors, including packets that failed to decrypt.
pub protocol_errors: u64,
}
/// Whether a peer's tunnel has completed a handshake.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PeerHealth {
/// Time since the last successful WireGuard handshake.
pub since_handshake: Option<Duration>,
}
impl PeerHealth {
/// Whether the tunnel has ever completed a handshake.
pub fn is_up(&self) -> bool {
self.since_handshake.is_some()
}
}
struct Peer {
endpoint_id: EndpointId,
public_key: WgPublicKey,
overlay_v4: Mutex<Option<Ipv4Addr>>,
tunn: Mutex<FlowTunnel>,
link: SharedLink,
counters: Arc<PeerCounters>,
task: Mutex<Option<JoinHandle<()>>>,
}
impl std::fmt::Debug for Peer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Peer")
.field("peer", &self.endpoint_id.fmt_short().to_string())
.field("public_key", &self.public_key)
.finish()
}
}
impl Drop for Peer {
fn drop(&mut self) {
if let Ok(mut guard) = self.task.lock()
&& let Some(task) = guard.take()
{
task.abort();
}
}
}
impl Peer {
fn stats(&self) -> PeerStats {
PeerStats {
tx_packets: self.counters.tx_packets.load(Ordering::Relaxed),
tx_bytes: self.counters.tx_bytes.load(Ordering::Relaxed),
rx_packets: self.counters.rx_packets.load(Ordering::Relaxed),
rx_bytes: self.counters.rx_bytes.load(Ordering::Relaxed),
dropped_wrong_source: self.counters.dropped_wrong_source.load(Ordering::Relaxed),
dropped_oversize: self.counters.dropped_oversize.load(Ordering::Relaxed),
dropped_no_session: self.counters.dropped_no_session.load(Ordering::Relaxed),
protocol_errors: self.counters.protocol_errors.load(Ordering::Relaxed),
}
}
fn health(&self) -> PeerHealth {
let guard = match self.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
PeerHealth {
since_handshake: guard.tunn.time_since_last_handshake(),
}
}
}
/// What a peer's tunnel looks like from outside.
#[derive(Debug, Clone)]
pub struct PeerSummary {
/// The peer's control plane identity.
pub endpoint_id: EndpointId,
/// The peer's WireGuard public key.
pub public_key: WgPublicKey,
/// The overlay address it holds, once agreed.
pub overlay_address_v4: Option<Ipv4Addr>,
/// Whether the tunnel has handshaken.
pub health: PeerHealth,
/// Traffic counters.
pub stats: PeerStats,
/// What the transport reports about the path carrying this tunnel.
pub path: String,
/// Largest datagram the link accepts.
pub max_datagram: usize,
}
struct Inner {
network: NetworkId,
private_key: WgSecretKey,
peers: RwLock<HashMap<WgPublicKey, Arc<Peer>>>,
sink: Arc<dyn PacketSink>,
transport: Arc<WireguardTransport>,
context: PluginContext,
next_index: AtomicU32,
}
impl Inner {
fn on_tunnel_failed(&self, peer: EndpointId, public_key: WgPublicKey) {
self.transport.close_link(self.network, peer);
write_lock(&self.peers).remove(&public_key);
self.context.report_peer_protocol_failure(
self.network,
peer,
crate::announcement::WG_PROTOCOL,
"WireGuard rekey timeout",
);
}
}
impl std::fmt::Debug for Inner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Inner")
.field("network", &self.network.fmt_short())
.finish()
}
}
/// The userspace WireGuard tunnels of one network.
#[derive(Debug)]
pub struct WireguardDevice {
inner: Arc<Inner>,
tasks: Vec<JoinHandle<()>>,
}
impl WireguardDevice {
/// Starts the WireGuard tunnels for one network.
pub fn start(
network: NetworkId,
private_key: WgSecretKey,
sink: Arc<dyn PacketSink>,
transport: Arc<WireguardTransport>,
context: PluginContext,
) -> Self {
let inner = Arc::new(Inner {
network,
private_key,
peers: RwLock::new(HashMap::new()),
sink,
transport,
context,
next_index: AtomicU32::new(1),
});
let timers = tokio::spawn(drive_timers(Arc::clone(&inner)));
Self {
inner,
tasks: vec![timers],
}
}
/// Encrypts an IP packet and sends it to a peer over its direct link.
pub fn carry(&self, peer: EndpointId, packet: &[u8]) -> bool {
let found = read_lock(&self.inner.peers)
.values()
.find(|candidate| candidate.endpoint_id == peer)
.cloned();
let Some(peer) = found else {
return false;
};
let mut scratch = [0u8; SCRATCH];
let flow = ip_flow(packet);
let result = {
let mut tunn = match peer.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let (result, selected_flow) = tunn.encapsulate(packet, &mut scratch, flow);
match result {
TunnResult::WriteToNetwork(out) => Some((out.len(), selected_flow)),
_ => None,
}
};
let Some((len, selected_flow)) = result else {
peer.counters
.dropped_no_session
.fetch_add(1, Ordering::Relaxed);
return false;
};
send_to_peer_flow(&peer, &scratch[..len], selected_flow);
peer.counters.tx_packets.fetch_add(1, Ordering::Relaxed);
peer.counters
.tx_bytes
.fetch_add(packet.len() as u64, Ordering::Relaxed);
true
}
/// Adds or replaces a peer and starts its tunnel.
pub fn add_peer(
&self,
endpoint_id: EndpointId,
public_key: WgPublicKey,
overlay_v4: Option<Ipv4Addr>,
link: SharedLink,
keepalive: Option<u16>,
) -> Result<(), PluginError> {
if public_key == self.inner.private_key.public() {
return Err(PluginError::Rejected(
"refusing to add ourselves as a WireGuard peer".into(),
));
}
let index = self.inner.next_index.fetch_add(1, Ordering::Relaxed);
let tunn = Tunn::new(
self.inner.private_key.to_static_secret(),
public_key.into_x25519(),
None,
keepalive,
index,
None,
);
let peer = Arc::new(Peer {
endpoint_id,
public_key,
overlay_v4: Mutex::new(overlay_v4),
tunn: Mutex::new(FlowTunnel {
tunn,
queued: VecDeque::new(),
handshake_started_at: None,
rekey_timeouts: 0,
failed: false,
}),
link,
counters: Arc::new(PeerCounters::default()),
task: Mutex::new(None),
});
let task = tokio::spawn(read_from_link(Arc::clone(&self.inner), Arc::clone(&peer)));
if let Ok(mut guard) = peer.task.lock() {
*guard = Some(task);
}
write_lock(&self.inner.peers).insert(public_key, Arc::clone(&peer));
kick_handshake(&peer);
Ok(())
}
/// Removes a peer and stops its tunnel.
pub fn remove_peer(&self, public_key: &WgPublicKey) {
write_lock(&self.inner.peers).remove(public_key);
}
/// Removes every peer whose key is not in `keep`.
pub fn retain_peers(&self, keep: &[WgPublicKey]) {
let stale: Vec<WgPublicKey> = read_lock(&self.inner.peers)
.keys()
.filter(|key| !keep.contains(key))
.copied()
.collect();
for key in stale {
self.remove_peer(&key);
}
}
/// Whether a peer's tunnel exists.
pub fn has_peer(&self, public_key: &WgPublicKey) -> bool {
read_lock(&self.inner.peers).contains_key(public_key)
}
/// A snapshot of every peer.
pub fn peers(&self) -> Vec<PeerSummary> {
let mut peers: Vec<PeerSummary> = read_lock(&self.inner.peers)
.values()
.map(|peer| {
let overlay_v4 = match peer.overlay_v4.lock() {
Ok(guard) => *guard,
Err(poisoned) => *poisoned.into_inner(),
};
PeerSummary {
endpoint_id: peer.endpoint_id,
public_key: peer.public_key,
overlay_address_v4: overlay_v4,
health: peer.health(),
stats: peer.stats(),
path: peer.link.path_description(),
max_datagram: peer.link.max_datagram_size(),
}
})
.collect();
peers.sort_by_key(|peer| peer.public_key);
peers
}
}
impl Drop for WireguardDevice {
fn drop(&mut self) {
for task in &self.tasks {
task.abort();
}
}
}
fn read_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
match lock.read() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn write_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
match lock.write() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn kick_handshake(peer: &Peer) {
let mut scratch = vec![0u8; SCRATCH];
let len = {
let mut tunn = match peer.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
match tunn.tunn.format_handshake_initiation(&mut scratch, false) {
TunnResult::WriteToNetwork(out) => {
tunn.handshake_started_at = Some(std::time::Instant::now());
tunn.rekey_timeouts = 0;
tunn.failed = false;
Some(out.len())
}
_ => None,
}
};
if let Some(len) = len {
send_to_peer(peer, &scratch[..len]);
}
}
fn send_to_peer(peer: &Peer, payload: &[u8]) {
send_to_peer_flow(peer, payload, 0);
}
fn send_to_peer_flow(peer: &Peer, payload: &[u8], flow: FlowId) {
match peer.link.send_flow(Bytes::copy_from_slice(payload), flow) {
Ok(()) => {}
Err(TransportError::TooLarge { .. }) => {
peer.counters
.dropped_oversize
.fetch_add(1, Ordering::Relaxed);
}
Err(TransportError::Closed) => {}
Err(err) => {
tracing::trace!(%err, "dropping a WireGuard packet the link refused");
}
}
}
async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
let mut scratch = vec![0u8; SCRATCH];
loop {
let Some(datagram) = peer.link.recv().await else {
return;
};
let mut input: Option<&[u8]> = Some(&datagram);
loop {
let outcome = {
let mut tunn = match peer.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let (result, flow) = tunn.decapsulate(input.unwrap_or(&[]), &mut scratch);
match result {
TunnResult::WriteToNetwork(out) => Outcome::ToNetwork(out.len(), flow),
TunnResult::WriteToTunnelV6(out, _) => Outcome::ToTunnel(out.len()),
TunnResult::WriteToTunnelV4(out, _) => Outcome::ToTunnel(out.len()),
TunnResult::Done => Outcome::Done,
TunnResult::Err(err) => {
tracing::trace!(?err, "WireGuard decapsulation failed");
Outcome::Failed
}
}
};
match outcome {
Outcome::ToNetwork(len, flow) => {
send_to_peer_flow(&peer, &scratch[..len], flow);
input = None;
continue;
}
Outcome::ToTunnel(len) => {
let payload = Bytes::copy_from_slice(&scratch[..len]);
inner
.sink
.deliver(inner.network, peer.endpoint_id, payload)
.await;
peer.counters.rx_packets.fetch_add(1, Ordering::Relaxed);
peer.counters
.rx_bytes
.fetch_add(len as u64, Ordering::Relaxed);
break;
}
Outcome::Failed => {
peer.counters
.protocol_errors
.fetch_add(1, Ordering::Relaxed);
break;
}
Outcome::Done => break,
}
}
}
}
enum Outcome {
ToNetwork(usize, FlowId),
ToTunnel(usize),
Done,
Failed,
}
async fn drive_timers(inner: Arc<Inner>) {
let mut ticker = tokio::time::interval(TIMER_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let peers: Vec<Arc<Peer>> = read_lock(&inner.peers).values().cloned().collect();
for peer in peers {
let mut scratch = vec![0u8; SCRATCH];
let (len, failed) = {
let mut tunn = match peer.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let (result, failed) = tunn.update_timers(&mut scratch);
let len = match result {
TunnResult::WriteToNetwork(out) => Some(out.len()),
TunnResult::Err(err) => {
tracing::trace!(?err, "WireGuard timer error");
None
}
_ => None,
};
(len, failed)
};
if let Some(len) = len {
send_to_peer(&peer, &scratch[..len]);
}
if failed {
tracing::warn!(
peer = %peer.endpoint_id.fmt_short(),
"WireGuard tunnel failed due to rekey timeout, triggering fallback"
);
inner.on_tunnel_failed(peer.endpoint_id, peer.public_key);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_flow_tunnel_rekey_timeout_triggers_failure() {
let my_secret = WgSecretKey::generate();
let their_secret = WgSecretKey::generate();
let their_public = their_secret.public();
let tunn = Tunn::new(
my_secret.to_static_secret(),
their_public.into_x25519(),
None,
None,
1,
None,
);
let mut tunnel = FlowTunnel {
tunn,
queued: VecDeque::new(),
handshake_started_at: Some(std::time::Instant::now() - Duration::from_secs(16)),
rekey_timeouts: 0,
failed: false,
};
let mut scratch = vec![0u8; SCRATCH];
// When handshake timeout exceeds HANDSHAKE_TIMEOUT (15s), update_timers returns true (failed)
let (_, failed) = tunnel.update_timers(&mut scratch);
assert!(failed, "handshake timeout should report failure");
assert!(tunnel.handshake_started_at.is_none());
assert!(tunnel.failed, "tunnel should be marked as failed");
// Subsequent timer tick must not report failure again
let (_, failed_again) = tunnel.update_timers(&mut scratch);
assert!(
!failed_again,
"subsequent timer tick should not re-trigger failure"
);
}
#[test]
fn test_flow_tunnel_reset_on_handshake() {
let my_secret = WgSecretKey::generate();
let their_secret = WgSecretKey::generate();
let their_public = their_secret.public();
let tunn = Tunn::new(
my_secret.to_static_secret(),
their_public.into_x25519(),
None,
None,
1,
None,
);
let mut tunnel = FlowTunnel {
tunn,
queued: VecDeque::new(),
handshake_started_at: Some(std::time::Instant::now()),
rekey_timeouts: 2,
failed: true,
};
let mut scratch = vec![0u8; SCRATCH];
let _ = tunnel.decapsulate(&[], &mut scratch);
// decapsulate without handshake response doesn't establish session
assert_eq!(tunnel.rekey_timeouts, 2);
}
}
+160
View File
@@ -0,0 +1,160 @@
//! WireGuard key material for pure WireGuard transport.
//!
//! Keys belong to this protocol plugin alone. They are not derived from
//! the iroh device key and not derived from the network secret, so compromising
//! or rotating one does not affect the others.
//!
//! Keys are X25519, encoded the way WireGuard encodes them: standard base64
//! with padding, 44 characters.
use boringtun::x25519;
use data_encoding::BASE64;
use zeroize::{Zeroize, Zeroizing};
use tsunagi::dataplane::PluginError;
/// Length of a raw WireGuard key in bytes.
pub const KEY_LEN: usize = 32;
/// Length of the base64 text form of a key.
pub const KEY_TEXT_LEN: usize = 44;
/// Applies the X25519 clamping WireGuard applies to private keys.
fn clamp(bytes: &mut [u8; KEY_LEN]) {
bytes[0] &= 248;
bytes[31] &= 127;
bytes[31] |= 64;
}
/// A WireGuard public key.
///
/// Public, safe to log, and the identity a peer is known by inside the overlay.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WgPublicKey([u8; KEY_LEN]);
impl WgPublicKey {
/// Wraps raw key bytes.
pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
Self(bytes)
}
/// The raw key bytes.
pub fn as_bytes(&self) -> &[u8; KEY_LEN] {
&self.0
}
/// The key in the form the WireGuard implementation expects.
pub(crate) fn into_x25519(self) -> x25519::PublicKey {
x25519::PublicKey::from(self.0)
}
/// Whether this is the all-zero key, which is never a valid peer.
pub fn is_zero(&self) -> bool {
self.0 == [0u8; KEY_LEN]
}
/// The base64 form WireGuard uses.
pub fn encode(&self) -> String {
BASE64.encode(&self.0)
}
/// Parses the base64 form WireGuard uses.
pub fn decode(text: &str) -> Result<Self, PluginError> {
if text.len() != KEY_TEXT_LEN {
return Err(PluginError::Rejected(format!(
"a WireGuard key is {KEY_TEXT_LEN} base64 characters, got {}",
text.len()
)));
}
let raw = BASE64
.decode(text.as_bytes())
.map_err(|_| PluginError::Rejected("key is not valid base64".into()))?;
let bytes = <[u8; KEY_LEN]>::try_from(raw.as_slice())
.map_err(|_| PluginError::Rejected("key is not 32 bytes".into()))?;
Ok(Self(bytes))
}
/// A short prefix for logs and diagnostics.
pub fn fmt_short(&self) -> String {
self.encode().chars().take(8).collect()
}
}
impl std::fmt::Display for WgPublicKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.encode())
}
}
impl std::fmt::Debug for WgPublicKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "WgPublicKey({})", self.fmt_short())
}
}
/// A WireGuard private key.
///
/// Clamped on creation so it matches what `wg genkey` produces. Zeroized on
/// drop, and prints as `[redacted]` in Debug so it cannot leak into logs.
#[derive(Clone)]
pub struct WgSecretKey {
bytes: Zeroizing<[u8; KEY_LEN]>,
public: WgPublicKey,
}
impl WgSecretKey {
/// Generates a fresh clamped random private key.
pub fn generate() -> Self {
let mut bytes = [0u8; KEY_LEN];
rand::fill(&mut bytes);
clamp(&mut bytes);
let secret = x25519::StaticSecret::from(bytes);
let public = WgPublicKey(*x25519::PublicKey::from(&secret).as_bytes());
Self {
bytes: Zeroizing::new(bytes),
public,
}
}
/// Wraps existing key bytes, clamping them to a valid X25519 private key.
pub fn from_bytes(raw: &[u8; KEY_LEN]) -> Self {
let mut bytes = *raw;
clamp(&mut bytes);
let secret = x25519::StaticSecret::from(bytes);
let public = WgPublicKey(*x25519::PublicKey::from(&secret).as_bytes());
Self {
bytes: Zeroizing::new(bytes),
public,
}
}
/// The matching public key.
pub fn public(&self) -> WgPublicKey {
self.public
}
/// The key in the form the WireGuard implementation expects.
pub(crate) fn to_static_secret(&self) -> x25519::StaticSecret {
x25519::StaticSecret::from(*self.bytes)
}
/// The raw key bytes.
pub fn as_bytes(&self) -> &[u8; KEY_LEN] {
&self.bytes
}
}
impl Drop for WgSecretKey {
fn drop(&mut self) {
self.bytes.zeroize();
}
}
impl std::fmt::Debug for WgSecretKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WgSecretKey")
.field("bytes", &"[redacted]")
.field("public", &self.public)
.finish()
}
}
+28
View File
@@ -0,0 +1,28 @@
//! The pure WireGuard protocol plugin for Tsunagi: WireGuard cryptography over direct UDP.
//!
//! Deliberately a separate crate from the core, ensuring clear boundaries:
//! - Direct UDP datagram transport without QUIC encapsulation or double-encryption.
//! - Standard WireGuard cryptography via `boringtun`.
//! - Peer discovery and key exchange managed through the Tsunagi control plane.
#![deny(unsafe_code)]
#![warn(missing_docs)]
pub mod announcement;
pub mod device;
pub mod keys;
pub mod plugin;
pub mod store;
pub mod transport;
pub use announcement::{
ANNOUNCEMENT_VERSION, ValidatedAnnouncement, WG_PROTOCOL, WG_WIRE_VERSION, WgAnnouncement,
};
pub use device::{PeerHealth, PeerStats, PeerSummary, WireguardDevice};
pub use keys::{WgPublicKey, WgSecretKey};
pub use plugin::{
DEFAULT_MTU, MIN_MTU, NetworkOverview, PeerOverview, WIREGUARD_OVERHEAD, WireguardConfig,
WireguardPlugin, local_ip_candidates,
};
pub use store::WgKeyStore;
pub use transport::{DEFAULT_PORT, MAX_UDP_DATAGRAM_SIZE, WireguardLink, WireguardTransport};
File diff suppressed because it is too large. Load diff
+160
View File
@@ -0,0 +1,160 @@
//! The plugin's own key store.
//!
//! Deliberately a separate SQLite file from the agent's `state.sqlite`: plugin
//! keys are not the iroh identity and not the network secret, and their
//! lifecycle is the plugin's business alone.
//!
//! One key per network, so a participant presents a different WireGuard
//! identity in each network it belongs to.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use rusqlite::{Connection, OptionalExtension, params};
use tsunagi::dataplane::PluginError;
use tsunagi::identity::NetworkId;
use crate::keys::{KEY_LEN, WgSecretKey};
/// Schema version written by this build.
pub const SCHEMA_VERSION: i64 = 1;
/// Per-network WireGuard private keys.
#[derive(Debug)]
pub struct WgKeyStore {
conn: Mutex<Connection>,
path: PathBuf,
}
impl WgKeyStore {
/// Opens, creating the file and its directory if needed.
pub fn open(path: impl AsRef<Path>) -> Result<Self, PluginError> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
tsunagi::storage::create_dir(parent)
.map_err(|err| PluginError::Other(format!("cannot create {parent:?}: {err}")))?;
}
let existed = path.exists();
let conn = Connection::open(&path).map_err(|err| {
PluginError::Other(format!("cannot open the WireGuard key store: {err}"))
})?;
tsunagi::storage::restrict_path_permissions(&path)
.map_err(|err| PluginError::Other(format!("cannot secure the key store: {err}")))?;
conn.busy_timeout(std::time::Duration::from_secs(5))
.and_then(|()| conn.pragma_update(None, "journal_mode", "WAL"))
.and_then(|()| conn.pragma_update(None, "synchronous", "NORMAL"))
.map_err(|err| PluginError::Other(format!("cannot configure the key store: {err}")))?;
if existed {
let integrity: String = conn
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
.map_err(|err| {
PluginError::Other(format!("WireGuard key store is unusable: {err}"))
})?;
if integrity != "ok" {
return Err(PluginError::Other(format!(
"WireGuard key store at {} is corrupt and will not be recreated: {integrity}",
path.display()
)));
}
}
let found: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.map_err(|err| PluginError::Other(format!("cannot read the schema version: {err}")))?;
if found > SCHEMA_VERSION {
return Err(PluginError::Other(format!(
"WireGuard key store schema {found} is newer than {SCHEMA_VERSION}"
)));
}
if found < SCHEMA_VERSION {
conn.execute_batch(
"BEGIN;
CREATE TABLE IF NOT EXISTS network_keys (
network_id BLOB PRIMARY KEY,
secret BLOB NOT NULL,
created_at INTEGER NOT NULL
);
PRAGMA user_version = 1;
COMMIT;",
)
.map_err(|err| PluginError::Other(format!("cannot create the schema: {err}")))?;
}
Ok(Self {
conn: Mutex::new(conn),
path,
})
}
/// Path of the underlying file.
pub fn path(&self) -> &Path {
&self.path
}
/// Loads the stored key for `network`, or generates, stores and returns a fresh one.
pub fn load_or_create(&self, network: NetworkId) -> Result<WgSecretKey, PluginError> {
let guard = self
.conn
.lock()
.map_err(|_| PluginError::Other("key store lock is poisoned".into()))?;
let existing: Option<Vec<u8>> = guard
.query_row(
"SELECT secret FROM network_keys WHERE network_id = ?1",
params![network.as_bytes().as_slice()],
|row| row.get(0),
)
.optional()
.map_err(|err| PluginError::Other(format!("cannot read the key store: {err}")))?;
if let Some(secret) = existing {
let bytes = <[u8; KEY_LEN]>::try_from(secret.as_slice()).map_err(|_| {
PluginError::Other(format!(
"stored WireGuard key for {} is {} bytes, expected {KEY_LEN}",
network.fmt_short(),
secret.len()
))
})?;
return Ok(WgSecretKey::from_bytes(&bytes));
}
let fresh = WgSecretKey::generate();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|err| PluginError::Other(format!("system clock is broken: {err}")))?
.as_secs() as i64;
guard
.execute(
"INSERT INTO network_keys (network_id, secret, created_at) VALUES (?1, ?2, ?3)",
params![
network.as_bytes().as_slice(),
fresh.as_bytes().as_slice(),
now
],
)
.map_err(|err| PluginError::Other(format!("cannot write the key store: {err}")))?;
Ok(fresh)
}
/// Removes the stored key for `network`.
pub fn forget(&self, network: NetworkId) -> Result<(), PluginError> {
let guard = self
.conn
.lock()
.map_err(|_| PluginError::Other("key store lock is poisoned".into()))?;
guard
.execute(
"DELETE FROM network_keys WHERE network_id = ?1",
params![network.as_bytes().as_slice()],
)
.map_err(|err| {
PluginError::Other(format!("cannot delete from the key store: {err}"))
})?;
Ok(())
}
}
+796
View File
@@ -0,0 +1,796 @@
//! Pure WireGuard UDP packet transport and link implementation.
//!
//! Provides [`WireguardTransport`] implementing [`PacketTransport`]
//! and [`WireguardLink`] implementing [`PacketLink`].
use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use bytes::Bytes;
use iroh::EndpointId;
use tokio::net::UdpSocket;
use tokio::sync::{Notify, mpsc, oneshot};
use tsunagi::BoxFuture;
use tsunagi::dataplane::transport::{
InboundLink, PacketLink, PacketTransport, SharedLink, TransportError,
};
use tsunagi::identity::{DeviceIdentity, NetworkId};
/// Default UDP port to bind and listen on for pure WireGuard.
pub const DEFAULT_PORT: u16 = 51820;
/// Maximum payload length for a single framed datagram over UDP.
pub const MAX_UDP_DATAGRAM_SIZE: usize = 65507;
const PROBE_MAGIC: &[u8; 19] = b"TSUNAGI_WG_PROBE_V1";
const PROBE_TYPE_PING: u8 = 1;
const PROBE_TYPE_PONG: u8 = 2;
const PROBE_LEN: usize = 19 + 1 + 32 + 32 + 32 + 8; // 124 bytes
fn encode_probe(
msg_type: u8,
sender: EndpointId,
receiver: EndpointId,
network: NetworkId,
cookie: u64,
) -> Vec<u8> {
let mut buf = Vec::with_capacity(PROBE_LEN);
buf.extend_from_slice(PROBE_MAGIC);
buf.push(msg_type);
buf.extend_from_slice(sender.as_bytes());
buf.extend_from_slice(receiver.as_bytes());
buf.extend_from_slice(network.as_bytes());
buf.extend_from_slice(&cookie.to_be_bytes());
buf
}
struct DecodedProbe {
msg_type: u8,
sender: EndpointId,
receiver: EndpointId,
network: NetworkId,
cookie: u64,
}
fn decode_probe(bytes: &[u8]) -> Option<DecodedProbe> {
if bytes.len() < PROBE_LEN || &bytes[..19] != PROBE_MAGIC {
return None;
}
let msg_type = bytes[19];
if msg_type != PROBE_TYPE_PING && msg_type != PROBE_TYPE_PONG {
return None;
}
let sender = EndpointId::from_bytes(&bytes[20..52].try_into().ok()?).ok()?;
let receiver = EndpointId::from_bytes(&bytes[52..84].try_into().ok()?).ok()?;
let network = NetworkId::from_bytes(bytes[84..116].try_into().ok()?);
let cookie = u64::from_be_bytes(bytes[116..124].try_into().ok()?);
Some(DecodedProbe {
msg_type,
sender,
receiver,
network,
cookie,
})
}
/// An authenticated datagram link carried over direct UDP.
#[derive(Debug)]
pub struct WireguardLink {
network: NetworkId,
peer: EndpointId,
target_addr: Arc<RwLock<SocketAddr>>,
candidates: Arc<RwLock<Vec<SocketAddr>>>,
confirmed: Arc<AtomicBool>,
tx: mpsc::Sender<Bytes>,
inbound_tx: mpsc::Sender<Bytes>,
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
closed_notify: Arc<Notify>,
is_closed: Arc<AtomicBool>,
}
impl WireguardLink {
/// Creates a new `WireguardLink` targeting `target_addr` over `socket`.
pub fn new(
network: NetworkId,
peer: EndpointId,
target_addr: SocketAddr,
candidates: Vec<SocketAddr>,
socket: Arc<UdpSocket>,
) -> Self {
let (tx_out, mut rx_out) = mpsc::channel::<Bytes>(1024);
let (tx_in, rx_in) = mpsc::channel::<Bytes>(1024);
let is_closed = Arc::new(AtomicBool::new(false));
let confirmed = Arc::new(AtomicBool::new(false));
let closed_notify = Arc::new(Notify::new());
let target_holder = Arc::new(RwLock::new(target_addr));
let candidates_holder = Arc::new(RwLock::new(candidates));
let socket_w = Arc::clone(&socket);
let target_r = Arc::clone(&target_holder);
let candidates_r = Arc::clone(&candidates_holder);
let confirmed_r = Arc::clone(&confirmed);
let is_closed_w = Arc::clone(&is_closed);
let closed_notify_w = Arc::clone(&closed_notify);
tokio::spawn(async move {
while let Some(msg) = rx_out.recv().await {
if msg.len() > MAX_UDP_DATAGRAM_SIZE {
continue;
}
// In WireGuard protocol, type 1 is Handshake Initiation (148 bytes).
let is_handshake_init = !msg.is_empty() && msg[0] == 1;
if confirmed_r.load(Ordering::Acquire) && !is_handshake_init {
let target = match target_r.read() {
Ok(g) => *g,
Err(p) => *p.into_inner(),
};
if let Err(err) = socket_w.send_to(&msg, target).await {
tracing::trace!(%err, %target, "failed to send WireGuard UDP datagram");
}
} else {
let targets = match candidates_r.read() {
Ok(g) => g.clone(),
Err(p) => p.into_inner().clone(),
};
if targets.is_empty() {
let target = match target_r.read() {
Ok(g) => *g,
Err(p) => *p.into_inner(),
};
let _ = socket_w.send_to(&msg, target).await;
} else {
for target in targets {
let _ = socket_w.send_to(&msg, target).await;
}
}
}
}
is_closed_w.store(true, Ordering::Release);
closed_notify_w.notify_waiters();
});
Self {
network,
peer,
target_addr: target_holder,
candidates: candidates_holder,
confirmed,
tx: tx_out,
inbound_tx: tx_in,
rx: tokio::sync::Mutex::new(rx_in),
closed_notify,
is_closed,
}
}
/// Delivers an inbound packet received from UDP into this link's receive queue.
pub fn deliver_inbound(&self, payload: Bytes) -> bool {
if self.is_closed.load(Ordering::Acquire) {
return false;
}
self.confirmed.store(true, Ordering::Release);
self.inbound_tx.try_send(payload).is_ok()
}
/// Marks this link as confirmed by a probe response or verified packet.
pub fn confirm(&self) {
self.confirmed.store(true, Ordering::Release);
}
/// Whether this link has had verified two-way direct connectivity.
pub fn is_confirmed(&self) -> bool {
self.confirmed.load(Ordering::Acquire)
}
/// Updates candidate addresses for this peer.
pub fn update_candidates(&self, addrs: impl IntoIterator<Item = SocketAddr>) {
if let Ok(mut guard) = self.candidates.write() {
let new_candidates: Vec<SocketAddr> = addrs
.into_iter()
.filter(|a| !a.ip().is_unspecified())
.collect();
if !new_candidates.is_empty() {
*guard = new_candidates;
}
}
}
/// Updates the target socket address (roaming / NAT hole punch update).
pub fn update_target(&self, new_target: SocketAddr) {
if new_target.ip().is_unspecified() {
return;
}
self.confirmed.store(true, Ordering::Release);
if let Ok(mut guard) = self.target_addr.write()
&& *guard != new_target
{
tracing::debug!(peer = %self.peer.fmt_short(), old = %*guard, new = %new_target, "WireGuard endpoint roamed");
*guard = new_target;
}
}
/// Explicitly closes this link, notifying any pending readers or waiters.
pub fn close(&self) {
if !self.is_closed.swap(true, Ordering::AcqRel) {
self.closed_notify.notify_waiters();
}
}
/// Returns the current target address.
pub fn target_addr(&self) -> SocketAddr {
match self.target_addr.read() {
Ok(g) => *g,
Err(p) => *p.into_inner(),
}
}
}
impl PacketLink for WireguardLink {
fn network(&self) -> NetworkId {
self.network
}
fn peer(&self) -> EndpointId {
self.peer
}
fn max_datagram_size(&self) -> usize {
MAX_UDP_DATAGRAM_SIZE
}
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
if payload.len() > MAX_UDP_DATAGRAM_SIZE {
return Err(TransportError::TooLarge {
size: payload.len(),
limit: MAX_UDP_DATAGRAM_SIZE,
});
}
if self.is_closed.load(Ordering::Acquire) {
return Err(TransportError::Closed);
}
self.tx
.try_send(payload)
.map_err(|_| TransportError::Closed)
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move {
if self.is_closed.load(Ordering::Acquire) {
return None;
}
tokio::select! {
biased;
_ = self.closed_notify.notified() => None,
msg = async {
let mut guard = self.rx.lock().await;
guard.recv().await
} => msg,
}
})
}
fn closed(&self) -> BoxFuture<'_, ()> {
Box::pin(async move {
if self.is_closed.load(Ordering::Acquire) {
return;
}
self.closed_notify.notified().await;
})
}
fn is_closed(&self) -> bool {
self.is_closed.load(Ordering::Acquire)
}
fn path_description(&self) -> String {
format!("wireguard via {}", self.target_addr())
}
}
type InboundCallback = Box<dyn Fn(InboundLink) + Send + Sync + 'static>;
type PendingProbes = Arc<Mutex<HashMap<(NetworkId, EndpointId, u64), oneshot::Sender<SocketAddr>>>>;
/// Direct UDP WireGuard packet transport.
pub struct WireguardTransport {
local_id: EndpointId,
listen_port: u16,
socket: Arc<UdpSocket>,
peer_addresses: RwLock<HashMap<EndpointId, Vec<SocketAddr>>>,
peer_networks: RwLock<HashMap<EndpointId, HashSet<NetworkId>>>,
links: RwLock<HashMap<(NetworkId, EndpointId), Arc<WireguardLink>>>,
inbound_cb: Arc<Mutex<Option<InboundCallback>>>,
pending_probes: PendingProbes,
}
impl std::fmt::Debug for WireguardTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WireguardTransport")
.field("local_id", &self.local_id.fmt_short().to_string())
.field("listen_port", &self.listen_port)
.finish()
}
}
impl WireguardTransport {
/// Binds a UDP socket for pure WireGuard transport.
///
/// Attempts `requested_port` (default 51820). If that fails, falls back to an ephemeral port.
pub async fn bind(
identity: &DeviceIdentity,
requested_port: Option<u16>,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let target_port = requested_port.unwrap_or(DEFAULT_PORT);
let bind_addr = SocketAddr::from(([0, 0, 0, 0], target_port));
let socket = match UdpSocket::bind(bind_addr).await {
Ok(s) => s,
Err(err) if target_port > 0 => {
tracing::warn!(%err, port = target_port, "failed to bind WireGuard UDP port, falling back to ephemeral port");
UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], 0))).await?
}
Err(err) => return Err(Box::new(err)),
};
let bound_port = socket.local_addr()?.port();
tracing::info!(port = bound_port, "WireGuard UDP transport bound");
Ok(Self {
local_id: identity.endpoint_id(),
listen_port: bound_port,
socket: Arc::new(socket),
peer_addresses: RwLock::new(HashMap::new()),
peer_networks: RwLock::new(HashMap::new()),
links: RwLock::new(HashMap::new()),
inbound_cb: Arc::new(Mutex::new(None)),
pending_probes: Arc::new(Mutex::new(HashMap::new())),
})
}
/// The local port this transport is listening on.
pub fn bound_port(&self) -> u16 {
self.listen_port
}
/// Associates a peer with a network ID.
pub fn set_peer_network(&self, peer: EndpointId, network: NetworkId) {
if let Ok(mut guard) = self.peer_networks.write() {
guard.entry(peer).or_default().insert(network);
}
}
/// Records an observed UDP target address for a peer.
pub fn set_peer_addr(&self, peer: EndpointId, addr: SocketAddr) {
if !addr.is_ipv4() || addr.ip().is_unspecified() {
return;
}
if let Ok(mut guard) = self.peer_addresses.write() {
let addrs = guard.entry(peer).or_default();
addrs.retain(|a| *a != addr);
addrs.insert(0, addr);
}
}
/// Records multiple observed UDP target addresses for a peer.
pub fn set_peer_addrs(&self, peer: EndpointId, addrs: impl IntoIterator<Item = SocketAddr>) {
if let Ok(mut guard) = self.peer_addresses.write() {
let entry = guard.entry(peer).or_default();
for addr in addrs {
if addr.is_ipv4() && !addr.ip().is_unspecified() && !entry.contains(&addr) {
entry.push(addr);
}
}
}
}
/// Returns candidate socket addresses known for a peer.
pub fn peer_addrs(&self, peer: EndpointId) -> Vec<SocketAddr> {
let guard = match self.peer_addresses.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.get(&peer).cloned().unwrap_or_default()
}
/// Explicitly closes any active direct link for the given network and peer.
pub fn close_link(&self, network: NetworkId, peer: EndpointId) {
let link = {
let guard = match self.links.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.get(&(network, peer)).cloned()
};
if let Some(link) = link {
link.close();
}
}
/// Notifies the transport that a peer's capability was announced.
///
/// When `we_accept` is true, sends a hole-punch probe to candidate addresses
/// to open stateful NAT firewalls, but does NOT create an unverified direct link
/// in the hub before packets actually flow.
pub fn notify_peer_capability(
&self,
network: NetworkId,
peer: EndpointId,
addrs: &[SocketAddr],
we_accept: bool,
) {
self.set_peer_network(peer, network);
let ipv4_addrs: Vec<SocketAddr> = addrs.iter().copied().filter(|a| a.is_ipv4()).collect();
self.set_peer_addrs(peer, ipv4_addrs.iter().copied());
// Update candidate addresses on any existing link for this peer
if let Ok(guard) = self.links.read()
&& let Some(link) = guard.get(&(network, peer))
{
link.update_candidates(ipv4_addrs.iter().copied());
}
// Send hole punching probe if we accept and have candidate addresses.
if we_accept && !ipv4_addrs.is_empty() {
let socket = Arc::clone(&self.socket);
let local_id = self.local_id;
tokio::spawn(async move {
let cookie: u64 = rand::random();
let probe = encode_probe(PROBE_TYPE_PING, local_id, peer, network, cookie);
for addr in ipv4_addrs {
let _ = socket.send_to(&probe, addr).await;
}
});
}
}
/// Runs the receive loop for incoming UDP datagrams.
pub async fn accept_loop<F>(&self, on_inbound: F)
where
F: Fn(InboundLink) + Send + Sync + 'static,
{
{
let mut guard = match self.inbound_cb.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
*guard = Some(Box::new(on_inbound));
}
let socket = Arc::clone(&self.socket);
let mut buf = [0u8; MAX_UDP_DATAGRAM_SIZE];
while let Ok((len, src_addr)) = socket.recv_from(&mut buf).await {
if src_addr.ip().is_unspecified() {
continue;
}
// 1. Direct UDP probe packet handling
if len >= PROBE_LEN
&& &buf[..19] == PROBE_MAGIC
&& let Some(probe) = decode_probe(&buf[..len])
{
if probe.receiver != self.local_id {
continue;
}
if probe.msg_type == PROBE_TYPE_PING {
let is_known = {
let guard = match self.peer_networks.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard
.get(&probe.sender)
.is_some_and(|nets| nets.contains(&probe.network))
};
if is_known {
let pong = encode_probe(
PROBE_TYPE_PONG,
self.local_id,
probe.sender,
probe.network,
probe.cookie,
);
let _ = socket.send_to(&pong, src_addr).await;
self.set_peer_addr(probe.sender, src_addr);
let link = {
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(existing) = guard.get(&(probe.network, probe.sender))
&& !existing.is_closed()
{
existing.update_target(src_addr);
existing.confirm();
None
} else {
let candidates = self.peer_addrs(probe.sender);
let link = Arc::new(WireguardLink::new(
probe.network,
probe.sender,
src_addr,
candidates,
Arc::clone(&self.socket),
));
link.confirm();
guard.insert((probe.network, probe.sender), Arc::clone(&link));
Some(link)
}
};
if let Some(link) = link {
let cb_guard = match self.inbound_cb.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(ref cb) = *cb_guard {
cb(InboundLink {
network: probe.network,
peer: probe.sender,
protocol: crate::announcement::WG_PROTOCOL.to_string(),
link,
});
}
}
}
continue;
} else if probe.msg_type == PROBE_TYPE_PONG {
self.set_peer_addr(probe.sender, src_addr);
let waiter = {
let mut probes = match self.pending_probes.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
probes.remove(&(probe.network, probe.sender, probe.cookie))
};
if let Some(tx) = waiter {
let _ = tx.send(src_addr);
}
continue;
}
}
// 2. Data / handshake packet handling
let datagram = Bytes::copy_from_slice(&buf[..len]);
// Identify which peer this packet comes from
let matched_peer = {
let links_guard = match self.links.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
links_guard
.values()
.find(|link| link.target_addr() == src_addr)
.map(|link| link.peer())
.or_else(|| {
let addrs_guard = match self.peer_addresses.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
addrs_guard
.iter()
.find(|(_, addrs)| addrs.contains(&src_addr))
.map(|(peer, _)| *peer)
.or_else(|| {
links_guard
.values()
.find(|link| link.target_addr().ip() == src_addr.ip())
.map(|link| link.peer())
.or_else(|| {
addrs_guard
.iter()
.find(|(_, addrs)| {
addrs.iter().any(|a| a.ip() == src_addr.ip())
})
.map(|(peer, _)| *peer)
})
})
})
};
let Some(peer) = matched_peer else {
continue;
};
self.set_peer_addr(peer, src_addr);
// Deliver to ALL active links for this peer (across all networks this peer shares with us)
let peer_links: Vec<Arc<WireguardLink>> = {
let guard = match self.links.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard
.values()
.filter(|l| l.peer() == peer && !l.is_closed())
.cloned()
.collect()
};
if !peer_links.is_empty() {
for link in peer_links {
link.update_target(src_addr);
link.deliver_inbound(datagram.clone());
}
continue;
}
// If no active link exists for this peer yet, create inbound links for all networks known for this peer
let networks: Vec<NetworkId> = {
let guard = match self.peer_networks.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard
.get(&peer)
.map(|s| s.iter().copied().collect())
.unwrap_or_default()
};
for network in networks {
let link = {
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(existing) = guard.get(&(network, peer))
&& !existing.is_closed()
{
existing.update_target(src_addr);
existing.deliver_inbound(datagram.clone());
continue;
}
let candidates = self.peer_addrs(peer);
let link = Arc::new(WireguardLink::new(
network,
peer,
src_addr,
candidates,
Arc::clone(&self.socket),
));
link.update_target(src_addr);
link.deliver_inbound(datagram.clone());
guard.insert((network, peer), Arc::clone(&link));
link
};
let cb_guard = match self.inbound_cb.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(ref cb) = *cb_guard {
cb(InboundLink {
network,
peer,
protocol: crate::announcement::WG_PROTOCOL.to_string(),
link,
});
}
}
}
}
}
impl PacketTransport for WireguardTransport {
fn name(&self) -> &str {
"wireguard"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn open<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
_protocol: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
Box::pin(async move {
let mut addrs = self.peer_addrs(peer);
if addrs.is_empty() {
return Err(TransportError::Unreachable(format!(
"no candidate UDP address known for peer {}",
peer.fmt_short()
)));
}
crate::plugin::prioritize_local_subnet(&mut addrs);
// If an existing confirmed link is alive, reuse it
{
let guard = match self.links.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(existing) = guard.get(&(network, peer))
&& !existing.is_closed()
&& existing.is_confirmed()
{
existing.update_candidates(addrs);
return Ok(Arc::clone(existing) as SharedLink);
}
}
// Probe candidate addresses with PING
let cookie: u64 = rand::random();
let (tx, rx) = oneshot::channel::<SocketAddr>();
{
let mut probes = match self.pending_probes.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
probes.insert((network, peer, cookie), tx);
}
let probe_ping = encode_probe(PROBE_TYPE_PING, self.local_id, peer, network, cookie);
let socket = Arc::clone(&self.socket);
let candidates = addrs.clone();
let probe_task = tokio::spawn(async move {
for i in 0..3 {
for target in &candidates {
let _ = socket.send_to(&probe_ping, *target).await;
}
if i < 2 {
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
}
}
});
let confirmed_addr =
match tokio::time::timeout(std::time::Duration::from_millis(1200), rx).await {
Ok(Ok(addr)) => {
probe_task.abort();
addr
}
_ => {
probe_task.abort();
let mut probes = match self.pending_probes.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
probes.remove(&(network, peer, cookie));
return Err(TransportError::Unreachable(format!(
"direct UDP probe to peer {} timed out",
peer.fmt_short()
)));
}
};
let link = {
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(existing) = guard.get(&(network, peer))
&& !existing.is_closed()
{
existing.update_target(confirmed_addr);
existing.update_candidates(addrs);
existing.confirm();
return Ok(Arc::clone(existing) as SharedLink);
}
let link = Arc::new(WireguardLink::new(
network,
peer,
confirmed_addr,
addrs,
Arc::clone(&self.socket),
));
link.confirm();
guard.insert((network, peer), Arc::clone(&link));
link
};
Ok(link as SharedLink)
})
}
}
@@ -0,0 +1,86 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::IpAddr;
use tsunagi::identity::NetworkId;
use tsunagi_wireguard::{ANNOUNCEMENT_VERSION, WgAnnouncement, WgPublicKey, WgSecretKey};
#[test]
fn announcement_round_trip() {
let network = NetworkId::from_bytes([42u8; 32]);
let key = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
let addrs: Vec<IpAddr> = vec![
"192.168.1.100".parse().unwrap(),
"10.0.0.5".parse().unwrap(),
];
let announcement = WgAnnouncement::new(network, &key, 51820, addrs.clone());
let encoded = announcement.encode().expect("encode");
let validated = WgAnnouncement::decode_and_validate(&encoded, network, &local).expect("decode");
assert_eq!(validated.public_key, key);
assert_eq!(validated.port, 51820);
assert_eq!(validated.addrs, addrs);
}
#[test]
fn announcement_rejects_zero_key() {
let network = NetworkId::from_bytes([42u8; 32]);
let local = WgSecretKey::generate().public();
let zero = WgPublicKey::from_bytes([0u8; 32]);
let announcement = WgAnnouncement::new(network, &zero, 51820, vec![]);
let encoded = announcement.encode().unwrap();
let err = WgAnnouncement::decode_and_validate(&encoded, network, &local).unwrap_err();
assert!(err.to_string().contains("all zeroes"));
}
#[test]
fn announcement_rejects_own_key() {
let network = NetworkId::from_bytes([42u8; 32]);
let local = WgSecretKey::generate().public();
let announcement = WgAnnouncement::new(network, &local, 51820, vec![]);
let encoded = announcement.encode().unwrap();
let err = WgAnnouncement::decode_and_validate(&encoded, network, &local).unwrap_err();
assert!(err.to_string().contains("own WireGuard key"));
}
#[test]
fn announcement_rejects_wrong_network() {
let network_a = NetworkId::from_bytes([1u8; 32]);
let network_b = NetworkId::from_bytes([2u8; 32]);
let key = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
let announcement = WgAnnouncement::new(network_a, &key, 51820, vec![]);
let encoded = announcement.encode().unwrap();
let err = WgAnnouncement::decode_and_validate(&encoded, network_b, &local).unwrap_err();
assert!(err.to_string().contains("different network"));
}
#[test]
fn announcement_rejects_zero_port() {
let network = NetworkId::from_bytes([42u8; 32]);
let key = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
let announcement = WgAnnouncement {
version: ANNOUNCEMENT_VERSION,
public_key: *key.as_bytes(),
network: *network.as_bytes(),
port: 0,
addrs: vec![],
};
let encoded = announcement.encode().unwrap();
let err = WgAnnouncement::decode_and_validate(&encoded, network, &local).unwrap_err();
assert!(err.to_string().contains("cannot be 0"));
}
@@ -0,0 +1,378 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use tokio::sync::mpsc;
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
use tsunagi::dataplane::{IpPlugin, PacketSink, PluginContext};
use tsunagi::identity::{DeviceIdentity, NetworkId};
use tsunagi_wireguard::{WireguardConfig, WireguardPlugin, WireguardTransport};
struct TestSink {
delivered: mpsc::Sender<(NetworkId, iroh::EndpointId, Bytes)>,
}
impl PacketSink for TestSink {
fn deliver(
&self,
network: NetworkId,
from: iroh::EndpointId,
packet: Bytes,
) -> tsunagi::BoxFuture<'_, ()> {
let delivered = self.delivered.clone();
Box::pin(async move {
let _ = delivered.send((network, from, packet)).await;
})
}
}
fn sample_ipv4_packet() -> Bytes {
let mut packet = vec![0u8; 40];
packet[0] = 0x45; // IPv4, IHL 5
packet[2..4].copy_from_slice(&40u16.to_be_bytes()); // total len 40
packet[8] = 64; // TTL
packet[9] = 17; // UDP
packet[12..16].copy_from_slice(&[10, 0, 0, 1]); // src 10.0.0.1
packet[16..20].copy_from_slice(&[10, 0, 0, 2]); // dst 10.0.0.2
Bytes::from(packet)
}
#[tokio::test]
async fn test_wireguard_plugin_end_to_end() {
let _ = tracing_subscriber::fmt()
.with_env_filter("trace")
.with_test_writer()
.try_init();
let identity_a = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([77u8; 32]);
let tmp_a = tempfile::tempdir().expect("tmp A");
let tmp_b = tempfile::tempdir().expect("tmp B");
// Transports
let transport_b = Arc::new(
WireguardTransport::bind(&identity_b, Some(0))
.await
.expect("bind B"),
);
let port_b = transport_b.bound_port();
let transport_a = Arc::new(
WireguardTransport::bind(&identity_a, Some(0))
.await
.expect("bind A"),
);
let port_a = transport_a.bound_port();
// Plugins
let cfg_a = WireguardConfig::new(tmp_a.path());
let plugin_a = WireguardPlugin::open(cfg_a, Arc::clone(&transport_a))
.await
.expect("open plugin A");
let cfg_b = WireguardConfig::new(tmp_b.path());
let plugin_b = WireguardPlugin::open(cfg_b, Arc::clone(&transport_b))
.await
.expect("open plugin B");
// Attach sinks
let (sink_a_tx, mut sink_a_rx) = mpsc::channel(10);
plugin_a.attach(PluginContext::with_sink(Arc::new(TestSink {
delivered: sink_a_tx,
})));
let (sink_b_tx, mut sink_b_rx) = mpsc::channel(10);
plugin_b.attach(PluginContext::with_sink(Arc::new(TestSink {
delivered: sink_b_tx,
})));
// Capability exchange
plugin_a.on_network_activated(network);
plugin_b.on_network_activated(network);
let cap_a = loop {
if let Some(cap) = plugin_a.local_capability(network).expect("cap A") {
break cap;
}
tokio::time::sleep(Duration::from_millis(50)).await;
};
let cap_b = loop {
if let Some(cap) = plugin_b.local_capability(network).expect("cap B") {
break cap;
}
tokio::time::sleep(Duration::from_millis(50)).await;
};
plugin_a
.on_peer_capability(network, peer_b, &cap_b)
.expect("A on cap B");
plugin_b
.on_peer_capability(network, peer_a, &cap_a)
.expect("B on cap A");
// Register loopback address candidates
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
transport_a.set_peer_addr(peer_b, addr_b);
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
transport_b.set_peer_addr(peer_a, addr_a);
// Spawn accept loop on A
let transport_a_clone = Arc::clone(&transport_a);
tokio::spawn(async move {
transport_a_clone.accept_loop(|_| {}).await;
});
// Spawn accept loop on B
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone
.accept_loop(move |inbound| {
let _ = inbound_tx.try_send(inbound);
})
.await;
});
// Open link from A to B
let link_a = tokio::time::timeout(
Duration::from_secs(5),
transport_a.open(network, peer_b, "wg"),
)
.await
.expect("open should not timeout")
.expect("open should succeed");
plugin_a.on_peer_link(network, peer_b, Arc::clone(&link_a));
// Wait for inbound link on B and hand to plugin B
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
.await
.expect("accept should not timeout")
.expect("inbound link should arrive");
plugin_b.on_peer_link(network, peer_a, inbound_b.link);
// Wait briefly for WireGuard handshake to complete
let mut up = false;
for _ in 0..50 {
if let Some(overview_a) = plugin_a.overview(network)
&& overview_a.peers.iter().any(|p| p.is_up())
{
up = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(up, "WireGuard handshake should complete between peers");
// Send IP packet from A to B
let packet_a_to_b = sample_ipv4_packet();
let sent = plugin_a.carry(network, peer_b, packet_a_to_b.clone());
assert!(sent, "plugin A carry should succeed");
let received_b = tokio::time::timeout(Duration::from_secs(5), sink_b_rx.recv())
.await
.expect("recv B timeout")
.expect("recv B option");
assert_eq!(received_b.0, network);
assert_eq!(received_b.1, peer_a);
assert_eq!(received_b.2, packet_a_to_b);
// Send IP packet from B to A
let packet_b_to_a = sample_ipv4_packet();
let sent_reply = plugin_b.carry(network, peer_a, packet_b_to_a.clone());
assert!(sent_reply, "plugin B carry should succeed");
let received_a = tokio::time::timeout(Duration::from_secs(5), sink_a_rx.recv())
.await
.expect("recv A timeout")
.expect("recv A option");
assert_eq!(received_a.0, network);
assert_eq!(received_a.1, peer_b);
assert_eq!(received_a.2, packet_b_to_a);
}
#[tokio::test]
async fn test_wireguard_two_networks_same_peers() {
let identity_a = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let net1 = NetworkId::from_bytes([11u8; 32]);
let net2 = NetworkId::from_bytes([22u8; 32]);
let tmp_a = tempfile::tempdir().expect("tmp A");
let tmp_b = tempfile::tempdir().expect("tmp B");
let transport_b = Arc::new(
WireguardTransport::bind(&identity_b, Some(0))
.await
.expect("bind B"),
);
let port_b = transport_b.bound_port();
let transport_a = Arc::new(
WireguardTransport::bind(&identity_a, Some(0))
.await
.expect("bind A"),
);
let port_a = transport_a.bound_port();
let plugin_a =
WireguardPlugin::open(WireguardConfig::new(tmp_a.path()), Arc::clone(&transport_a))
.await
.expect("open plugin A");
let plugin_b =
WireguardPlugin::open(WireguardConfig::new(tmp_b.path()), Arc::clone(&transport_b))
.await
.expect("open plugin B");
let (sink_a_tx, mut sink_a_rx) = mpsc::channel(20);
plugin_a.attach(PluginContext::with_sink(Arc::new(TestSink {
delivered: sink_a_tx,
})));
let (sink_b_tx, mut sink_b_rx) = mpsc::channel(20);
plugin_b.attach(PluginContext::with_sink(Arc::new(TestSink {
delivered: sink_b_tx,
})));
// Activate both networks
for net in [net1, net2] {
plugin_a.on_network_activated(net);
plugin_b.on_network_activated(net);
}
// Exchange capabilities for both networks
for net in [net1, net2] {
let cap_a = loop {
if let Some(cap) = plugin_a.local_capability(net).expect("cap A") {
break cap;
}
tokio::time::sleep(Duration::from_millis(20)).await;
};
let cap_b = loop {
if let Some(cap) = plugin_b.local_capability(net).expect("cap B") {
break cap;
}
tokio::time::sleep(Duration::from_millis(20)).await;
};
plugin_a
.on_peer_capability(net, peer_b, &cap_b)
.expect("A on cap B");
plugin_b
.on_peer_capability(net, peer_a, &cap_a)
.expect("B on cap A");
}
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
transport_a.set_peer_addr(peer_b, addr_b);
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
transport_b.set_peer_addr(peer_a, addr_a);
// Accept loops
let transport_a_clone = Arc::clone(&transport_a);
tokio::spawn(async move {
transport_a_clone.accept_loop(|_| {}).await;
});
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(10);
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone
.accept_loop(move |inbound| {
let _ = inbound_tx.try_send(inbound);
})
.await;
});
// Open links for both networks from A to B
for net in [net1, net2] {
let link_a = transport_a
.open(net, peer_b, "wg")
.await
.expect("open A link");
plugin_a.on_peer_link(net, peer_b, link_a);
}
// Deliver inbound links to B
for _ in 0..2 {
let inbound = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
.await
.expect("inbound timeout")
.expect("inbound option");
plugin_b.on_peer_link(inbound.network, peer_a, inbound.link);
}
// Wait for both handshakes to complete on both networks
for net in [net1, net2] {
let mut up = false;
for _ in 0..50 {
if let Some(overview) = plugin_a.overview(net)
&& overview.peers.iter().any(|p| p.is_up())
{
up = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(up, "handshake on {net:?} should complete");
}
// Send on net1
let pkt1 = sample_ipv4_packet();
assert!(plugin_a.carry(net1, peer_b, pkt1.clone()));
let recv1 = tokio::time::timeout(Duration::from_secs(5), sink_b_rx.recv())
.await
.expect("recv1 timeout")
.expect("recv1 option");
assert_eq!(recv1.0, net1);
assert_eq!(recv1.2, pkt1);
// Send on net2 from A to B
let mut pkt2_bytes = sample_ipv4_packet().to_vec();
pkt2_bytes[15] = 99; // different byte
let pkt2 = Bytes::from(pkt2_bytes);
assert!(plugin_a.carry(net2, peer_b, pkt2.clone()));
let recv2 = tokio::time::timeout(Duration::from_secs(5), sink_b_rx.recv())
.await
.expect("recv2 timeout")
.expect("recv2 option");
assert_eq!(recv2.0, net2);
assert_eq!(recv2.2, pkt2);
// Send reply on net1 from B to A
assert!(plugin_b.carry(net1, peer_a, pkt1.clone()));
let reply1 = tokio::time::timeout(Duration::from_secs(5), sink_a_rx.recv())
.await
.expect("reply1 timeout")
.expect("reply1 option");
assert_eq!(reply1.0, net1);
assert_eq!(reply1.2, pkt1);
// Send reply on net2 from B to A
assert!(plugin_b.carry(net2, peer_a, pkt2.clone()));
let reply2 = tokio::time::timeout(Duration::from_secs(5), sink_a_rx.recv())
.await
.expect("reply2 timeout")
.expect("reply2 option");
assert_eq!(reply2.0, net2);
assert_eq!(reply2.2, pkt2);
}
@@ -0,0 +1,220 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use tokio::sync::mpsc;
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
use tsunagi::identity::{DeviceIdentity, NetworkId};
use tsunagi_wireguard::WireguardTransport;
#[tokio::test]
async fn test_wireguard_transport_loopback() {
let identity_a = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([42u8; 32]);
// Bind transport B on port 0 (ephemeral)
let transport_b = Arc::new(
WireguardTransport::bind(&identity_b, Some(0))
.await
.expect("bind server should succeed"),
);
let port_b = transport_b.bound_port();
assert!(port_b > 0);
// Bind transport A on port 0 (ephemeral)
let transport_a = Arc::new(
WireguardTransport::bind(&identity_a, Some(0))
.await
.expect("bind client should succeed"),
);
let port_a = transport_a.bound_port();
assert!(port_a > 0);
// Register B's address and network on transport A
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
transport_a.set_peer_addr(peer_b, addr_b);
transport_a.set_peer_network(peer_b, network);
// Register A's address and network on transport B
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
transport_b.set_peer_addr(peer_a, addr_a);
transport_b.set_peer_network(peer_a, network);
// Spawn accept loop on A
let transport_a_clone = Arc::clone(&transport_a);
tokio::spawn(async move {
transport_a_clone.accept_loop(|_| {}).await;
});
// Spawn accept loop on B
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone
.accept_loop(move |inbound| {
let _ = inbound_tx.try_send(inbound);
})
.await;
});
// Open link from A to B
let link_a = tokio::time::timeout(
Duration::from_secs(5),
transport_a.open(network, peer_b, "wg"),
)
.await
.expect("open should not timeout")
.expect("open should succeed");
// Send from A to B to trigger inbound link discovery on B
let data_a_to_b = Bytes::from_static(b"wireguard packet from A to B");
link_a.send(data_a_to_b.clone()).expect("send A->B");
// Receive inbound link on B
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
.await
.expect("accept should not timeout")
.expect("inbound link should arrive");
assert_eq!(inbound_b.network, network);
assert_eq!(inbound_b.peer, peer_a);
assert_eq!(inbound_b.protocol, "wg");
let link_b = inbound_b.link;
// Check packet received on B
let received_on_b = tokio::time::timeout(Duration::from_secs(2), link_b.recv())
.await
.expect("recv B timeout")
.expect("recv B option");
assert_eq!(received_on_b, data_a_to_b);
// Send from B to A
let data_b_to_a = Bytes::from_static(b"wireguard packet from B to A");
link_b.send(data_b_to_a.clone()).expect("send B->A");
let received_on_a = tokio::time::timeout(Duration::from_secs(2), link_a.recv())
.await
.expect("recv A timeout")
.expect("recv A option");
assert_eq!(received_on_a, data_b_to_a);
// Path descriptions
assert!(link_a.path_description().contains("wireguard via"));
assert!(link_b.path_description().contains("wireguard via"));
}
#[tokio::test]
async fn test_wireguard_transport_unreachable_probe_times_out() {
let identity_a = DeviceIdentity::generate();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([99u8; 32]);
let transport_a = Arc::new(
WireguardTransport::bind(&identity_a, Some(0))
.await
.expect("bind client should succeed"),
);
// Register a non-responsive address for peer B (TEST-NET-1 documentation IP which drops packets)
let unreachable_addr: SocketAddr = "192.0.2.1:51820".parse().unwrap();
transport_a.set_peer_addr(peer_b, unreachable_addr);
transport_a.set_peer_network(peer_b, network);
// Spawn accept loop on A
let transport_a_clone = Arc::clone(&transport_a);
tokio::spawn(async move {
transport_a_clone.accept_loop(|_| {}).await;
});
// Opening to unreachable candidate must fail with Unreachable rather than returning a dead direct link
let result = transport_a.open(network, peer_b, "wg").await;
match result {
Err(tsunagi::dataplane::transport::TransportError::Unreachable(msg)) => {
assert!(msg.contains("timed out"));
}
other => panic!("expected Unreachable error, got {other:?}"),
}
}
#[tokio::test]
async fn test_wireguard_link_close_and_transport_close_link() {
let identity_a = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([55u8; 32]);
let transport_b = Arc::new(
WireguardTransport::bind(&identity_b, Some(0))
.await
.unwrap(),
);
let port_b = transport_b.bound_port();
let transport_a = Arc::new(
WireguardTransport::bind(&identity_a, Some(0))
.await
.unwrap(),
);
let port_a = transport_a.bound_port();
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
transport_a.set_peer_addr(peer_b, addr_b);
transport_a.set_peer_network(peer_b, network);
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
transport_b.set_peer_addr(peer_a, addr_a);
transport_b.set_peer_network(peer_a, network);
let transport_a_clone = Arc::clone(&transport_a);
tokio::spawn(async move {
transport_a_clone.accept_loop(|_| {}).await;
});
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone.accept_loop(|_| {}).await;
});
let link_a = transport_a
.open(network, peer_b, "wg")
.await
.expect("open link A");
assert!(!link_a.is_closed());
// Spawn a reader task waiting on recv()
let link_a_clone = Arc::clone(&link_a);
let recv_task = tokio::spawn(async move { link_a_clone.recv().await });
// Explicitly closing via transport.close_link()
transport_a.close_link(network, peer_b);
assert!(link_a.is_closed());
// Wait on closed() notification
tokio::time::timeout(Duration::from_secs(1), link_a.closed())
.await
.expect("link.closed() should resolve promptly");
// recv() must have returned None
let recv_res = tokio::time::timeout(Duration::from_secs(1), recv_task)
.await
.expect("recv task should complete")
.expect("task join");
assert_eq!(recv_res, None);
// Send after close must return Closed error
let send_res = link_a.send(Bytes::from_static(b"after close"));
assert!(matches!(
send_res,
Err(tsunagi::dataplane::transport::TransportError::Closed)
));
}
+110
View File
@@ -0,0 +1,110 @@
[package]
name = "tsunagi"
version = "0.1.0"
description = "Small private mesh networks: persistent agent identity, deterministic network spaces, an iroh control plane, signed state and a local DNS view."
readme = "../../README.md"
keywords = ["mesh", "p2p", "iroh", "networking"]
categories = ["network-programming"]
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[features]
default = []
# A real TUN device, so a plugin can carry actual IP traffic. On Linux it
# needs CAP_NET_ADMIN; on Windows the process must run elevated and
# `wintun.dll` must be reachable. Without either the in-memory device serves
# tests. The platform-specific dependencies are declared per target, so
# enabling this only pulls what the target it is built for actually uses.
tun-device = [
"dep:tun",
"dep:rtnetlink",
"dep:caps",
"dep:futures-util",
]
# Telling the operating system where to send its DNS questions. systemd-resolved
# on Linux, the Name Resolution Policy Table on Windows; the zone and the
# server work without it either way.
dns-publish = ["dep:zbus"]
# The test harness in `testing`, for this crate's own tests and for a
# protocol crate's. Not on by default: it is only of use to a test.
testing = ["dep:tempfile", "dep:tracing-subscriber"]
[dependencies]
arc-swap = "1.9"
iroh.workspace = true
# `net` for the local control interface: a Unix socket on Unix, a named pipe
# on Windows. Both live behind the same `net` feature.
tokio = { workspace = true, features = ["net"] }
tokio-stream = "0.1"
serde.workspace = true
postcard.workspace = true
bytes.workspace = true
thiserror.workspace = true
tracing.workspace = true
hex.workspace = true
data-encoding.workspace = true
rusqlite = { version = "0.40", features = ["bundled"] }
hkdf = "0.13"
hmac = "0.13"
sha2 = "0.11"
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
rand = "0.10"
# Already in the tree through iroh. A packet codec, not a DNS server: the
# zone logic is ours and a full server framework would be a large dependency
# for answering A records from memory.
simple-dns = "0.12"
# The IANA top-level domain list, compiled in: one function, no
# dependencies, no network. Used only to warn that a zone name shadows a
# real public domain, never to refuse one.
tld = "2.40"
fs4 = { version = "1.1", features = ["sync"] }
directories = "6.0"
# The real system hostname, without a libc call of our own: this crate is
# forbidden `unsafe` and will not make one.
gethostname = "1.1"
netwatch = "0.19.3"
netdev = "0.46"
mainline = "8.0"
futures-lite = "2.6"
tun = { version = "0.8", features = ["async"], optional = true }
# Only for the `testing` harness, which a protocol crate's tests use too.
tempfile = { version = "3.24", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
# Linux-only interface provisioning. `rtnetlink` configures the interface in
# process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN
# out of the effective set except during the moments it is used.
[target.'cfg(target_os = "linux")'.dependencies]
rtnetlink = { version = "0.23", optional = true }
# Pure Rust D-Bus, no libdbus to link against. `tokio` rather than the
# default reactor, because the agent brings its own.
zbus = { version = "5.19", default-features = false, features = ["tokio"], optional = true }
caps = { version = "0.5", optional = true }
futures-util = { version = "0.3", default-features = false, optional = true }
# Windows-only interface provisioning. The Wintun adapter itself is created
# and driven through the `tun` dependency, whose safe wrapper is the only way
# in — loading `wintun.dll` is an `unsafe` call this crate forbids. `netdev`
# (already in the tree through `netwatch`) reads the interface table through a
# safe wrapper, which is how the provisioner observes an adapter by name
# before deciding what to change. Creating an adapter needs the process to be
# elevated and `wintun.dll` to be reachable at run time.
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Foundation",
] }
[dev-dependencies]
# Its own tests use the harness it publishes.
tsunagi = { path = ".", features = ["testing"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process", "test-util"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
[lints]
workspace = true
File renamed without changes.
@@ -27,7 +27,7 @@ mod status;
pub use events::Event;
pub use status::{
AgentStatus, CandidateStatus, MemberStatus, NetworkMetrics, NetworkState, NetworkStatus,
PeerStatus,
OverlayStatus, PeerStatus,
};
use std::collections::HashMap;
@@ -37,23 +37,29 @@ use iroh::{EndpointAddr, EndpointId};
use tokio::sync::{RwLock, broadcast, mpsc, oneshot};
use tokio::task::JoinHandle;
use crate::BoxFuture;
use crate::config::{AgentConfig, Limits};
use crate::dataplane::transport::PacketTransport;
use crate::dataplane::transport::iroh_link::{IrohTransport, TransportContext};
use crate::dataplane::{PluginContext, PluginRequest};
use crate::dataplane::{PacketSink, PluginContext, PluginRequest};
use crate::error::{Error, Result};
use crate::identity::{DeviceIdentity, NetworkId, NetworkKeys, NetworkName, NetworkSecret};
use crate::net::EndpointAdapter;
use crate::overlay::PacketCarrier;
use crate::proto::handshake;
use crate::proto::message::ControlMessage;
use crate::state::Ipv4Range;
use crate::storage::{CacheOutcome, Storage};
use crate::task::{TASK_GRACE, wind_down};
use network::{InboundSession, NetCommand, NetworkHandle, RuntimeParams};
use shutdown::Shutdown;
/// Summary of a configured network, whether or not it is running.
#[derive(Debug, Clone)]
pub struct ConfiguredNetwork {
/// Saved local broadcast participation.
pub broadcast: bool,
/// Public network identifier.
pub network_id: NetworkId,
/// Network name.
@@ -87,6 +93,10 @@ struct Inner {
accept_task: std::sync::Mutex<Option<JoinHandle<()>>>,
plugin_task: std::sync::Mutex<Option<JoinHandle<()>>>,
transport: std::sync::OnceLock<Arc<dyn PacketTransport>>,
/// Who holds which overlay address, across every network.
routes: Arc<crate::overlay::RoutingTable>,
/// The one interface, when the agent was given a way to make one.
interface: std::sync::OnceLock<Arc<crate::overlay::Interface>>,
}
impl Inner {
@@ -107,11 +117,69 @@ impl Inner {
}
}
/// Routes an outbound packet to whichever protocol can carry it.
///
/// Holds a weak reference: the interface belongs to the agent, and a strong
/// one here would keep the agent alive for as long as its own interface.
struct Carrier(Weak<Inner>);
impl std::fmt::Debug for Carrier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Carrier")
}
}
impl std::fmt::Debug for Sink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Sink")
}
}
impl PacketCarrier for Carrier {
fn carry(&self, route: crate::overlay::Route, packet: bytes::Bytes) -> bool {
let Some(inner) = self.0.upgrade() else {
return false;
};
// Offered to each protocol in turn. With one configured this is a
// single call; the shape is what allows several to be live at once,
// each carrying the peers it has a link to.
inner
.config
.plugins
.iter()
.any(|plugin| plugin.carry(route.network, route.peer, packet.clone()))
}
}
/// Writes a packet a protocol decrypted to the interface.
struct Sink(Weak<Inner>);
impl PacketSink for Sink {
fn deliver<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
packet: bytes::Bytes,
) -> BoxFuture<'a, ()> {
Box::pin(async move {
let Some(inner) = self.0.upgrade() else {
return;
};
let Some(interface) = inner.interface.get() else {
return;
};
// The rejection is already counted on the interface; there is
// nothing useful to do with it here.
let _ = interface.deliver(network, peer, packet).await;
})
}
}
/// Answers the data plane transport's questions about the agent.
///
/// Holds a weak reference on purpose: the transport lives inside the agent, so
/// a strong one would be a cycle and the agent — with its open databases and
/// its directory lock — would never be released.
/// Holds a weak reference for the same reason as [`Carrier`]: the transport
/// lives inside the agent, so a strong one would be a cycle and the agent —
/// with its open databases and its directory lock — would never be released.
#[derive(Debug)]
struct TransportCtx(Weak<Inner>);
@@ -178,17 +246,54 @@ impl Agent {
accept_task: std::sync::Mutex::new(None),
plugin_task: std::sync::Mutex::new(None),
transport: std::sync::OnceLock::new(),
routes: Arc::new(crate::overlay::RoutingTable::new()),
interface: std::sync::OnceLock::new(),
config,
});
// One interface for the agent, if it was given a way to make one.
// Failing to is reported and not fatal: the control plane works, and
// so do the protocols, they just have nowhere to put packets.
if let Some(factory) = inner.config.tun_factory.clone() {
let carrier = Arc::new(Carrier(Arc::downgrade(&inner))) as Arc<dyn PacketCarrier>;
match crate::overlay::Interface::start(
factory,
inner.config.interface_name.clone(),
inner.config.interface_mtu,
Arc::clone(&inner.routes),
carrier,
)
.await
{
Ok(interface) => {
let _ = inner.interface.set(Arc::new(interface));
}
Err(err) => {
let _ = inner.events.send(Event::PluginError {
network: NetworkId::from_bytes([0u8; 32]),
protocol: "overlay".into(),
reason: err.to_string(),
});
}
}
}
// The data plane rides on iroh too, which is where it gets hole
// punching and relay fallback from. It is a separate ALPN and a
// separate connection, so the two planes stay independent.
let transport: Arc<dyn PacketTransport> = Arc::new(IrohTransport::new(
let iroh_transport: Arc<dyn PacketTransport> = Arc::new(IrohTransport::new(
inner.adapter.clone(),
Arc::clone(&inner.limits),
Arc::new(TransportCtx(Arc::downgrade(&inner))) as Arc<dyn TransportContext>,
));
let transport: Arc<dyn PacketTransport> = if inner.config.custom_transports.is_empty() {
iroh_transport
} else {
Arc::new(crate::dataplane::MultiPacketTransport::new(
iroh_transport,
inner.config.custom_transports.clone(),
))
};
let _ = inner.transport.set(transport);
if let CacheOutcome::Reset(reason) = inner.storage.cache_outcome().clone() {
@@ -205,7 +310,11 @@ impl Agent {
// bound; overflow drops the request rather than stalling the plugin.
if !inner.config.plugins.is_empty() {
let (plugin_tx, plugin_rx) = mpsc::channel(64);
let context = PluginContext::new(plugin_tx, inner.identity.endpoint_id());
let sink = inner
.interface
.get()
.map(|_| Arc::new(Sink(Arc::downgrade(&inner))) as Arc<dyn PacketSink>);
let context = PluginContext::new(plugin_tx, inner.identity.endpoint_id(), sink);
for plugin in &inner.config.plugins {
plugin.attach(context.clone());
}
@@ -245,6 +354,138 @@ impl Agent {
self.inner.adapter.loopback_addr()
}
/// Delivers an inbound data plane link to the matching network runtime.
pub async fn install_inbound_link(
&self,
inbound: crate::dataplane::transport::InboundLink,
) -> Result<()> {
let network = inbound.network;
let sender = {
let networks = self.inner.networks.read().await;
networks.get(&network).map(|handle| handle.commands.clone())
};
let Some(sender) = sender else {
return Err(Error::NetworkNotActive(network));
};
sender
.send(NetCommand::InboundLink(Box::new(inbound)))
.await
.map_err(|_| Error::NetworkNotActive(network))?;
Ok(())
}
/// Reserves the configured overlay range for a network, if it can.
///
/// Done here, where activations are serialised, rather than inside the
/// runtime: whether a range is free depends on what the other networks
/// took, and deciding that in a task would make the answer depend on
/// which task ran first.
///
/// `None` means this agent will not propose a range for that network and
/// waits to adopt whatever it settles on. One agent has one interface, so
/// proposing a range it could not route would be worse than having none:
/// the lowest author's range wins, and the collision would spread.
async fn reserve_range(&self, network: NetworkId) -> RangePlan {
let reserve = |wanted: Ipv4Range| {
let reservation = crate::overlay::NetworkRoutes {
exit: Default::default(),
broadcast: Default::default(),
range: Some(wanted),
local: None,
peers: Vec::new(),
};
self.inner.routes.set_network(network, reservation).is_ok()
};
// If this network already has signed records in storage, check if its
// members have already agreed on a range. If so, preserve and reserve
// THAT range rather than preemptively seizing the default configured range.
let stored_records = self
.inner
.storage
.signed_records(network)
.await
.unwrap_or_default();
let mut state_set = crate::state::StateSet::new();
state_set.merge_all(network, stored_records);
if let Some(agreed) = state_set.agreed_range() {
if reserve(agreed) {
return RangePlan {
propose: if Some(agreed) == self.inner.config.overlay_ipv4_range {
Some(agreed)
} else {
None
},
..RangePlan::default()
};
} else {
tracing::warn!(
%network,
range = %agreed,
"agreed range for this network overlaps another network already active on this interface"
);
return RangePlan {
conflict: Some(agreed),
..RangePlan::default()
};
}
}
let Some(configured) = self.inner.config.overlay_ipv4_range else {
return RangePlan::default();
};
// The configured range first, so a network a device has always had
// keeps the addresses it has always had.
if reserve(configured) {
return RangePlan {
propose: Some(configured),
..RangePlan::default()
};
}
// A second network on the same agent cannot have it — one agent,
// one interface — so it falls back to the range derived from its
// own id, which every one of its members derives identically
// without being told.
let derived = crate::state::derived_ipv4_range(network);
if reserve(derived) {
tracing::info!(
%network,
range = %derived,
"another network here holds the configured range; this one uses the \
range derived from its id unless its members settled another"
);
return RangePlan {
fallback: Some(derived),
conflict: Some(configured),
..RangePlan::default()
};
}
tracing::info!(%network, "not proposing a range for this network");
RangePlan {
conflict: Some(configured),
..RangePlan::default()
}
}
/// The overlay interface this agent owns, when it has one.
///
/// One agent, one interface, so this is not asked per network: a packet
/// on it may belong to any of them.
pub fn overlay(&self) -> Option<OverlayStatus> {
let interface = self.inner.interface.get()?;
Some(OverlayStatus {
interface: interface.name().to_string(),
on_host: interface.on_host(),
mtu: interface.mtu(),
addresses: interface.wanted_addresses(),
counters: interface.counters(),
broadcast_rules: interface.broadcast_rules(),
})
}
/// The hostname announced to peers.
pub fn hostname(&self) -> String {
self.inner.read_hostname()
@@ -315,13 +556,47 @@ impl Agent {
&self,
name: &NetworkName,
secret: &NetworkSecret,
) -> Result<NetworkId> {
self.join_network_with_broadcast(name, secret, None).await
}
/// Joins with an explicit broadcast choice; absent preserves saved policy.
pub async fn join_network_with_broadcast(
&self,
name: &NetworkName,
secret: &NetworkSecret,
broadcast: Option<bool>,
) -> Result<NetworkId> {
let keys = NetworkKeys::derive(name, secret);
let network_id = keys.network_id();
// A network's identity is its name *and* its secret, so the same
// name with a different secret is a different network — and one that
// looks identical in anything that shows a name. Almost always a
// mistyped secret, so it is said out loud rather than left to be
// discovered as an empty network sitting beside a working one.
if let Ok(configured) = self.inner.storage.list_networks().await {
for other in configured {
if other.name == *name && other.network_id != network_id {
tracing::warn!(
"`{name}` is already configured with a different secret, as {}. \
Joining with this one adds a second network under the same name; \
they share nothing. Check the secret, or use \
`tsunagi network secret` \
to see which is which.",
other.network_id
);
}
}
}
self.inner
.storage
.upsert_network(network_id, name.clone(), secret.clone(), true)
.await?;
if let Some(enabled) = broadcast {
self.set_broadcast(network_id, enabled).await?;
}
match self.activate_with_keys(keys).await {
// Already a member of exactly this network space: nothing to do.
Ok(()) | Err(Error::NetworkAlreadyActive(_)) => Ok(network_id),
@@ -329,6 +604,87 @@ impl Agent {
}
}
/// Offers this agent as an exit node in a network, or stops, now and
/// after restart. Off unless somebody turns it on.
pub async fn set_exit_offer(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
self.inner
.storage
.set_exit_node(network_id, enabled)
.await?;
if self.is_active(network_id).await {
let (reply, receive) = oneshot::channel();
self.command(network_id, NetCommand::SetExitOffer { enabled, reply })
.await?;
receive.await.map_err(|_| Error::Stopped)?;
}
Ok(())
}
/// Sends this device's internet traffic through a member of a network,
/// or back the ordinary way with `None`.
///
/// A device has one default route, so choosing in one network clears the
/// choice in the others. The member has to be connected and offering
/// to be an exit node. Remembered across restarts.
pub async fn set_exit_via(&self, network_id: NetworkId, via: Option<EndpointId>) -> Result<()> {
let active = self.is_active(network_id).await;
if via.is_some() && !active {
return Err(Error::NetworkNotActive(network_id));
}
if active {
let (reply, receive) = oneshot::channel();
self.command(network_id, NetCommand::SetExitVia { via, reply })
.await?;
receive
.await
.map_err(|_| Error::Stopped)?
.map_err(|reason| Error::Rejected { reason })?;
}
self.inner
.storage
.set_exit_via(network_id, via.map(|peer| *peer.as_bytes()))
.await?;
if via.is_some() {
// One default route: whatever the other networks were doing, they
// are not any more.
let others: Vec<NetworkId> = self
.inner
.networks
.read()
.await
.keys()
.copied()
.filter(|other| *other != network_id)
.collect();
for other in others {
let (reply, receive) = oneshot::channel();
if self
.command(other, NetCommand::SetExitVia { via: None, reply })
.await
.is_ok()
{
let _ = receive.await;
}
}
}
Ok(())
}
/// Changes a network's local broadcast policy now and after restart.
pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
self.inner
.storage
.set_broadcast(network_id, enabled)
.await?;
if self.is_active(network_id).await {
let (reply, receive) = oneshot::channel();
self.command(network_id, NetCommand::SetBroadcast { enabled, reply })
.await?;
receive.await.map_err(|_| Error::Stopped)?;
}
Ok(())
}
/// Activates a configured network that is currently inactive.
///
/// Fails with [`Error::NetworkAlreadyActive`] if it is already running.
@@ -357,20 +713,59 @@ impl Agent {
return Err(Error::NetworkAlreadyActive(network_id));
}
let range = self.reserve_range(network_id).await;
let mut backends = Vec::new();
if let Some(discovery) = &self.inner.config.discovery {
backends.push(discovery.clone());
}
if let Some(dht) = &self.inner.config.dht {
backends.push(dht.for_network(&keys));
}
let discovery = if backends.is_empty() {
None
} else {
Some(
Arc::new(crate::discovery::CompositeDiscovery::new(backends))
as Arc<dyn crate::discovery::NetworkDiscovery>,
)
};
let stored = self
.inner
.storage
.list_networks()
.await?
.into_iter()
.find(|stored| stored.network_id == network_id);
let broadcast = stored.as_ref().is_none_or(|stored| stored.broadcast);
let exit_offer = stored.as_ref().is_some_and(|stored| stored.exit_node);
let exit_via = stored
.as_ref()
.and_then(|stored| stored.exit_via)
.and_then(|bytes| EndpointId::from_bytes(&bytes).ok());
let handle = network::spawn(RuntimeParams {
broadcast,
exit_offer,
exit_via,
keys,
adapter: self.inner.adapter.clone(),
storage: self.inner.storage.clone(),
events: self.inner.events.clone(),
limits: Arc::clone(&self.inner.limits),
reconnect: self.inner.config.reconnect.clone(),
discovery: self.inner.config.discovery.clone(),
discovery,
discovery_interval: self.inner.config.discovery_interval,
discovery_policy: self.inner.config.discovery_policy.clone(),
plugins: self.inner.config.plugins.clone(),
routes: Arc::clone(&self.inner.routes),
interface: self.inner.interface.get().cloned(),
hostname: self.inner.read_hostname(),
transport: self.inner.transport.get().cloned(),
device_secret: self.inner.identity.signing_key(),
ipv4_range: self.inner.config.overlay_ipv4_range,
#[cfg(feature = "testing")]
unreachable_data_peers: Arc::clone(&self.inner.config.unreachable_data_peers),
ipv4_range: range.propose,
ipv4_fallback: range.fallback,
range_conflict: range.conflict,
});
networks.insert(network_id, handle);
drop(networks);
@@ -401,17 +796,78 @@ impl Agent {
for plugin in &self.inner.config.plugins {
plugin.on_network_deactivated(network_id);
}
// The network's addresses come off the interface; the interface
// itself stays, because the agent owns it and other networks may
// still be using it.
self.inner.routes.remove_network(network_id);
if let Some(interface) = self.inner.interface.get()
&& let Err(err) = interface.sync_addresses().await
{
tracing::warn!(%err, "cannot update the overlay addresses");
}
if let Some(interface) = self.inner.interface.get() {
interface.sync_broadcast_rules().await;
interface.sync_exit_rules().await;
}
self.inner.storage.set_auto_start(network_id, false).await?;
Ok(())
}
/// Deactivates a network if it is running and removes it from the state
/// store together with its cached hints.
///
/// Local and silent: nobody is told. [`Agent::leave_network`] is the one
/// that says goodbye first, and is what a person means by leaving.
pub async fn forget_network(&self, network_id: NetworkId) -> Result<()> {
if self.is_active(network_id).await {
self.deactivate_network(network_id).await?;
}
self.inner.storage.remove_network(network_id).await
self.inner.storage.remove_network(network_id).await?;
// Whatever a protocol kept for this network goes with it. The
// agent cannot know what that is, only that there is no longer any
// reason to hold it.
for plugin in &self.inner.config.plugins {
plugin.on_network_forgotten(network_id);
}
Ok(())
}
/// Leaves a network: gives up what was claimed, then forgets it.
///
/// The order matters and cannot be improved on. A signed `Release` goes
/// out first, while there are still sessions to carry it, so the address
/// and name this agent held are freed for somebody else rather than
/// staying reserved to a member that has gone. Only then is the network
/// deactivated and removed.
///
/// Reaching every member is not on offer and never could be: a member
/// that is away hears the tombstone from the ones that were here, the
/// same way it hears everything else. Leaving with nobody connected
/// tells nobody, and says so in the outcome rather than pretending.
///
/// The author's version counter is deliberately **kept**. Rejoining the
/// same network with the same device key must continue from a higher
/// version than the release, or every replica would treat the new claim
/// as stale and ignore it.
pub async fn leave_network(&self, network_id: NetworkId) -> Result<LeaveOutcome> {
let mut outcome = LeaveOutcome::default();
if self.is_active(network_id).await {
let (reply_tx, reply_rx) = oneshot::channel();
self.command(network_id, NetCommand::Release { reply: reply_tx })
.await?;
if let Ok(peers) = reply_rx.await {
outcome.announced = true;
outcome.peers_told = peers;
}
// The tombstone is queued on each session, not yet written to
// the wire. A short pause is the difference between peers
// hearing it now and hearing it from somebody else much later.
if outcome.peers_told > 0 {
tokio::time::sleep(RELEASE_FLUSH).await;
}
}
self.forget_network(network_id).await?;
Ok(outcome)
}
/// Whether a network is currently running.
@@ -429,6 +885,7 @@ impl Agent {
.await?
.into_iter()
.map(|stored| ConfiguredNetwork {
broadcast: stored.broadcast,
network_id: stored.network_id,
name: stored.name,
auto_start: stored.auto_start,
@@ -503,16 +960,26 @@ impl Agent {
}
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
networks.push(NetworkStatus {
broadcast: stored.broadcast,
exit_offer: stored.exit_node,
exit_via: stored
.exit_via
.and_then(|bytes| EndpointId::from_bytes(&bytes).ok()),
exit_via_online: false,
exit_rules: Default::default(),
descriptor: keys.descriptor(),
name: stored.name,
network_id: stored.network_id,
state: NetworkState::Inactive,
peers: Vec::new(),
candidates: Vec::new(),
// An inactive network has no runtime to ask; the roster comes
// from one. Empty, not invented.
// An inactive network has no runtime to ask; the roster and
// the range come from one. Empty, not invented.
members: Vec::new(),
range: None,
range_conflict: None,
metrics: NetworkMetrics::default(),
relay: Default::default(),
});
}
@@ -537,7 +1004,8 @@ impl Agent {
self.command(network_id, NetCommand::Reannounce).await
}
/// Asks one network to re-run discovery and re-evaluate dials right now.
/// Asks one network to re-evaluate known peers and dials right now.
/// External lookup still follows its connected/isolation policy.
///
/// Call this when the host's network environment changed. Platform wake-up
/// notifications can be wired to it later.
@@ -545,7 +1013,7 @@ impl Agent {
self.command(network_id, NetCommand::Recheck).await
}
/// Asks every running network to re-run discovery right now.
/// Asks every running network to re-evaluate known peers right now.
pub async fn recheck(&self) {
let senders: Vec<mpsc::Sender<NetCommand>> = self
.inner
@@ -574,15 +1042,35 @@ impl Agent {
handle.stop().await;
}
self.inner.adapter.close().await;
if let Some(dht) = &self.inner.config.dht {
dht.shutdown().await;
}
for handle in [&self.inner.accept_task, &self.inner.plugin_task] {
// iroh's own close waits for peers to acknowledge; a peer that has
// gone silent must not decide how long that takes.
if tokio::time::timeout(TASK_GRACE, self.inner.adapter.close())
.await
.is_err()
{
tracing::warn!("the endpoint did not close in time; abandoning it");
}
for (what, handle) in [
("inbound connections", &self.inner.accept_task),
("plugin requests", &self.inner.plugin_task),
] {
let task = handle.lock().ok().and_then(|mut guard| guard.take());
if let Some(task) = task {
let _ = task.await;
wind_down(task, TASK_GRACE, what).await;
}
}
// The interface goes with the agent that owns it. Before the
// plugins, so nothing is still trying to write to it.
if let Some(interface) = self.inner.interface.get() {
interface.remove().await;
}
// Plugins remove whatever system objects they created. A plugin that
// misbehaves here must not hold up the agent, so this is bounded.
for plugin in &self.inner.config.plugins {
@@ -613,6 +1101,41 @@ impl Agent {
}
}
/// What a network may propose as its overlay range, and what it cannot have.
#[derive(Debug, Clone, Copy, Default)]
struct RangePlan {
/// Proposed straight away: the configured range, when it is free here.
propose: Option<Ipv4Range>,
/// Proposed after a moment, when the configured range is another
/// network's: the range derived from this network's own id.
fallback: Option<Ipv4Range>,
/// The configured range, when this network cannot have it.
conflict: Option<Ipv4Range>,
}
/// How long a release is given to reach the sessions it was queued on.
///
/// Short: it is one small message on a connection that is already open, and
/// the alternative to waiting at all is tearing the sessions down underneath
/// it.
const RELEASE_FLUSH: std::time::Duration = std::time::Duration::from_millis(300);
/// What happened when an agent left a network.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LeaveOutcome {
/// Whether a signed release was published at all.
///
/// `false` when the network was not running: there was nothing to
/// publish it from, so this was a local removal only.
pub announced: bool,
/// How many connected peers it was sent to.
///
/// Zero with `announced` true means the tombstone is in this agent's
/// own state and nowhere else, and it is leaving with it — so nobody
/// will learn of it.
pub peers_told: usize,
}
/// How long each plugin gets to tear itself down during agent shutdown.
const PLUGIN_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
@@ -680,6 +1203,26 @@ async fn plugin_request_loop(weak: Weak<Inner>, mut requests: mpsc::Receiver<Plu
}
}
}
PluginRequest::ProtocolFailure {
network,
peer,
protocol,
reason,
} => {
let sender = {
let networks = inner.networks.read().await;
networks.get(&network).map(|handle| handle.commands.clone())
};
if let Some(sender) = sender {
let _ = sender
.send(NetCommand::ProtocolFailure {
peer,
protocol,
reason,
})
.await;
}
}
}
}
}
@@ -825,8 +1368,23 @@ async fn handle_inbound_data(inner: Arc<Inner>, conn: iroh::endpoint::Connection
conn.close(5u32.into(), b"data plane not ready");
return;
};
// Downcasting is avoided by keeping the accept side on the concrete type.
let Some(iroh_transport) = transport.as_ref().as_any().downcast_ref::<IrohTransport>() else {
let iroh_transport =
if let Some(iroh) = transport.as_ref().as_any().downcast_ref::<IrohTransport>() {
Some(iroh)
} else if let Some(multi) = transport
.as_ref()
.as_any()
.downcast_ref::<crate::dataplane::MultiPacketTransport>()
{
multi
.default_transport()
.as_ref()
.as_any()
.downcast_ref::<IrohTransport>()
} else {
None
};
let Some(iroh_transport) = iroh_transport else {
conn.close(5u32.into(), b"unsupported data transport");
return;
};
File diff suppressed because it is too large. Load diff
@@ -73,17 +73,21 @@ pub(crate) struct Session {
pub(crate) outbound: mpsc::Sender<Vec<u8>>,
pub(crate) hostname: Option<String>,
pub(crate) capabilities: Vec<PluginCapability>,
pub(crate) broadcast: bool,
/// 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,12 +229,15 @@ 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,
}
}
@@ -51,3 +51,25 @@ impl Shutdown {
// The sender is gone, which for our purposes means "stop".
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[tokio::test]
async fn waiting_resolves_once_triggered_and_stays_resolved() {
let shutdown = Shutdown::new();
assert!(!shutdown.is_triggered());
let waiter = shutdown.clone();
let waiting = tokio::spawn(async move { waiter.wait().await });
shutdown.trigger();
waiting.await.unwrap();
assert!(shutdown.is_triggered());
// Triggering twice is not an error, and a token cloned afterwards
// sees the trigger that already happened.
shutdown.trigger();
shutdown.clone().wait().await;
}
}
@@ -36,6 +36,29 @@ pub struct CandidateStatus {
pub consecutive_failures: u32,
}
/// The overlay interface, as the agent sees it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OverlayStatus {
/// The interface name the operating system gave.
pub interface: String,
/// Whether that interface exists on the host.
///
/// `false` with an in-memory device: the name is real to the agent and
/// to nothing else. Reported, because everything that would configure
/// the operating system for this interface must not when it is this.
pub on_host: bool,
/// Its MTU.
pub mtu: u32,
/// The addresses it should be carrying.
pub addresses: Vec<crate::overlay::Cidr>,
/// What has happened on it.
pub counters: crate::overlay::Counters,
/// The route and firewall allowance installed for LAN broadcast, when
/// some network has broadcast on and the interface is on the host.
/// `None` is "none installed", not "unknown".
pub broadcast_rules: Option<crate::overlay::BroadcastRulesReport>,
}
/// A member the signed state knows about, connected or not.
///
/// This is the durable roster: it comes from signed records, so a member that
@@ -57,11 +80,20 @@ pub struct MemberStatus {
/// 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>,
}
/// 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.
@@ -70,6 +102,12 @@ pub struct PeerStatus {
///
/// A mutable binding, not an identity.
pub hostname: Option<String>,
/// Protocols agreed with this peer: both sides have them at the same
/// version.
///
/// Empty means no data plane with that peer, which leaves the control
/// plane working — messages and state still flow.
pub protocols: Vec<String>,
/// Capabilities the peer announced. Payloads stay opaque.
pub capabilities: Vec<PluginCapability>,
/// How long the session has been up.
@@ -126,6 +164,16 @@ pub struct NetworkMetrics {
/// Status of one network.
#[derive(Debug, Clone)]
pub struct NetworkStatus {
/// 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.
@@ -140,8 +188,19 @@ pub struct NetworkStatus {
pub candidates: Vec<CandidateStatus>,
/// Members the signed state knows about, whether connected or not.
pub members: Vec<MemberStatus>,
/// The overlay range this network uses, once it has one.
pub range: Option<crate::state::Ipv4Range>,
/// The range it could not have, because another network on this agent
/// already holds it.
///
/// One agent has one interface, so an address belongs to one network.
/// This network waits to adopt whatever its members settle on instead of
/// proposing something it could not route.
pub range_conflict: Option<crate::state::Ipv4Range>,
/// Per-network counters.
pub metrics: NetworkMetrics,
/// What has gone through a peer in the middle, in both directions.
pub relay: crate::dataplane::relay::RelayCounters,
}
impl NetworkStatus {
+183 -15
View File
@@ -11,12 +11,42 @@ use std::sync::Arc;
use std::time::Duration;
use crate::dataplane::SharedPlugin;
use crate::discovery::NetworkDiscovery;
use crate::error::{Error, Result};
use crate::discovery::{MainlineDiscovery, NetworkDiscovery};
use crate::error::Result;
/// Qualifier/organisation/application triple used for platform directories.
///
/// Unused on macOS, where the default paths are fixed system locations rather
/// than per-user directories derived from this.
#[cfg_attr(target_os = "macos", allow(dead_code))]
const APP_NAME: &str = "tsunagi";
/// Rendezvous slots per network; this bounds a discovery sample, not membership.
pub const DHT_SLOTS: u8 = 16;
/// BEP44 permits 1000 bencoded bytes; a 996-byte string has a four-byte prefix.
pub const DHT_MAX_VALUE: usize = 996;
/// Maximum direct addresses in one rendezvous record.
pub const DHT_MAX_ADDRS: usize = 8;
/// Maximum relay URL bytes in a rendezvous record.
pub const DHT_MAX_RELAY_LEN: usize = 512;
/// Application freshness, independent of storage nodes' own expiration.
pub const DHT_RECORD_TTL: Duration = Duration::from_secs(900);
/// Clock skew tolerated when reading a recently published record.
pub const DHT_CLOCK_SKEW: Duration = Duration::from_secs(120);
/// Maximum reassembled data-transport payload, independent of the path MTU.
pub const MAX_DATA_DATAGRAM: usize = 64 * 1024;
/// Maximum incomplete datagrams held by one authenticated data link.
pub const MAX_DATA_ASSEMBLIES: usize = 64;
/// Maximum allocated payload bytes for incomplete datagrams on one data link.
pub const MAX_DATA_REASSEMBLY_BYTES: usize = 256 * 1024;
/// Maximum fragments of one logical datagram, including after a path MTU change.
pub const MAX_DATA_FRAGMENTS: usize = 128;
/// Time allowed to assemble a datagram; loss never stalls later datagrams.
pub const DATA_REASSEMBLY_TTL: Duration = Duration::from_secs(5);
/// Completed or discarded packet IDs retained to ignore late duplicate fragments.
pub const DATA_RECENT_IDS: usize = 128;
/// Where the two stores live.
///
/// The mandatory state and the disposable cache are separate both logically and
@@ -48,17 +78,34 @@ impl StoragePaths {
}
}
/// The per-user platform directories.
/// The default platform directories.
///
/// A future system service can supply its own paths instead.
/// Per user on Linux and Windows. On macOS the agent runs as a root
/// LaunchDaemon (creating a utun interface needs root, and `$HOME` under
/// `sudo` points at the wrong place), so the stores live in the system
/// locations instead — `/var/db/tsunagi` for the mandatory state and a
/// disposable cache beside it. A caller that wants somewhere else passes
/// explicit paths to [`StoragePaths::new`] or [`StoragePaths::under`].
pub fn user_default() -> Result<Self> {
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
Error::Storage("no valid home directory for platform config paths".into())
})?;
Ok(Self {
state_dir: dirs.data_dir().to_path_buf(),
cache_dir: dirs.cache_dir().to_path_buf(),
})
#[cfg(target_os = "macos")]
{
Ok(Self {
state_dir: PathBuf::from("/var/db/tsunagi"),
cache_dir: PathBuf::from("/var/db/tsunagi/cache"),
})
}
#[cfg(not(target_os = "macos"))]
{
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
crate::error::Error::Storage(
"no valid home directory for platform config paths".into(),
)
})?;
Ok(Self {
state_dir: dirs.data_dir().to_path_buf(),
cache_dir: dirs.cache_dir().to_path_buf(),
})
}
}
/// Path of the mandatory state database.
@@ -137,6 +184,8 @@ pub struct Limits {
pub write_timeout: Duration,
/// Maximum simultaneous outbound dials per network.
pub max_concurrent_dials: usize,
/// Maximum unverified discovery candidates retained per network.
pub max_discovery_candidates: usize,
/// Maximum simultaneous authenticated sessions per network.
pub max_sessions_per_network: usize,
/// Maximum simultaneous inbound connections being handshaken.
@@ -163,6 +212,7 @@ impl Default for Limits {
dial_timeout: Duration::from_secs(10),
write_timeout: Duration::from_secs(30),
max_concurrent_dials: 8,
max_discovery_candidates: 16,
max_sessions_per_network: 64,
max_inbound_handshakes: 32,
session_send_queue: 64,
@@ -212,6 +262,33 @@ impl ReconnectPolicy {
}
}
/// Scheduling of candidate lookup and independent self-publication.
#[derive(Debug, Clone)]
pub struct DiscoveryPolicy {
/// Mean interval between successful self-publications (20% jitter).
pub publish_interval: Duration,
/// Initial delay between unsuccessful bootstrap lookups.
pub lookup_interval: Duration,
/// Maximum delay between bootstrap lookups.
pub max_lookup_interval: Duration,
/// Continuous isolation before a previously connected network searches again.
pub reconnect_delay: Duration,
/// Deadline for one backend operation.
pub request_timeout: Duration,
}
impl Default for DiscoveryPolicy {
fn default() -> Self {
Self {
publish_interval: Duration::from_secs(300),
lookup_interval: Duration::from_secs(5),
max_lookup_interval: Duration::from_secs(30),
reconnect_delay: Duration::from_secs(60),
request_timeout: Duration::from_secs(60),
}
}
}
/// Everything needed to start an [`crate::Agent`].
#[derive(Clone)]
pub struct AgentConfig {
@@ -223,13 +300,24 @@ pub struct AgentConfig {
pub bind_addrs: Vec<SocketAddr>,
/// How much external connectivity machinery the endpoint may use.
pub transport: TransportPolicy,
/// Real QUIC transport settings for constrained-path integration tests.
#[cfg(feature = "testing")]
pub test_quic_transport: Option<iroh::endpoint::QuicTransportConfig>,
/// Peers with no direct data path, for tests. See
/// [`AgentConfig::with_unreachable_data_peers`].
#[cfg(feature = "testing")]
pub unreachable_data_peers: Arc<std::sync::Mutex<std::collections::HashSet<iroh::EndpointId>>>,
/// Hostname announced to peers. `None` keeps whatever the state store holds,
/// falling back to the OS hostname and finally to a short endpoint id.
pub hostname: Option<String>,
/// Discovery backend. `None` disables discovery-driven dialling; static
/// bootstrap candidates still work.
/// Additional discovery backend, composed with Mainline when it is enabled.
pub discovery: Option<Arc<dyn NetworkDiscovery>>,
/// How often each active network re-runs discovery and re-evaluates dials.
/// Optional Mainline client, shared by this agent's networks. The CLI enables
/// it by default except in local-only mode; library callers opt in explicitly.
pub dht: Option<MainlineDiscovery>,
/// Lookup, publication and recovery scheduling.
pub discovery_policy: DiscoveryPolicy,
/// How often each active network maintains known peers and observes address changes.
pub discovery_interval: Duration,
/// Bounds applied to network input.
pub limits: Limits,
@@ -237,6 +325,20 @@ pub struct AgentConfig {
pub reconnect: ReconnectPolicy,
/// IP plugins whose capabilities are announced and dispatched.
pub plugins: Vec<SharedPlugin>,
/// Custom packet transports registered per protocol.
pub custom_transports:
std::collections::HashMap<String, Arc<dyn crate::dataplane::PacketTransport>>,
/// Where the overlay interface comes from, when the agent should have one.
///
/// One agent, one interface: it belongs here rather than to a protocol,
/// because every protocol carries traffic for the same addresses on it.
/// `None` means no interface — the protocols still run and their packets
/// are discarded.
pub tun_factory: Option<Arc<dyn crate::overlay::TunFactory>>,
/// The name to ask the operating system for.
pub interface_name: String,
/// The interface MTU.
pub interface_mtu: u32,
/// The IPv4 overlay range this agent proposes.
///
/// Addresses are allocated from it and recorded in signed state, so a
@@ -253,12 +355,24 @@ impl AgentConfig {
paths,
bind_addrs: Vec::new(),
transport: TransportPolicy::default(),
#[cfg(feature = "testing")]
test_quic_transport: None,
#[cfg(feature = "testing")]
unreachable_data_peers: Arc::new(std::sync::Mutex::new(
std::collections::HashSet::new(),
)),
hostname: None,
discovery: None,
dht: None,
discovery_policy: DiscoveryPolicy::default(),
discovery_interval: Duration::from_secs(5),
limits: Limits::default(),
reconnect: ReconnectPolicy::default(),
plugins: Vec::new(),
custom_transports: std::collections::HashMap::new(),
tun_factory: None,
interface_name: "tsun0".to_string(),
interface_mtu: 1280,
overlay_ipv4_range: Some(crate::state::DEFAULT_IPV4_RANGE),
}
}
@@ -278,6 +392,21 @@ impl AgentConfig {
self
}
/// Peers this agent will not open or accept a **direct** data link with.
///
/// Test-only, and behind the `testing` feature so a release build has
/// no such switch. The one arrangement that cannot be made on a single
/// host is two agents that both reach a third and not each other,
/// which is exactly the case a relay exists for.
#[cfg(feature = "testing")]
pub fn with_unreachable_data_peers(
mut self,
peers: Arc<std::sync::Mutex<std::collections::HashSet<iroh::EndpointId>>>,
) -> Self {
self.unreachable_data_peers = peers;
self
}
/// Sets the transport policy.
pub fn with_transport(mut self, transport: TransportPolicy) -> Self {
self.transport = transport;
@@ -290,7 +419,19 @@ impl AgentConfig {
self
}
/// Sets how often discovery runs.
/// Adds Mainline discovery alongside the configured candidate backend.
pub fn with_dht(mut self, dht: MainlineDiscovery) -> Self {
self.dht = Some(dht);
self
}
/// Sets lookup, publication and isolation recovery timing.
pub fn with_discovery_policy(mut self, policy: DiscoveryPolicy) -> Self {
self.discovery_policy = policy;
self
}
/// Sets the known-peer maintenance and endpoint-address observation interval.
pub fn with_discovery_interval(mut self, interval: Duration) -> Self {
self.discovery_interval = interval;
self
@@ -314,6 +455,29 @@ impl AgentConfig {
self
}
/// Registers a custom packet transport for a specific protocol.
pub fn with_custom_transport(
mut self,
protocol: impl Into<String>,
transport: Arc<dyn crate::dataplane::PacketTransport>,
) -> Self {
self.custom_transports.insert(protocol.into(), transport);
self
}
/// Gives the agent an overlay interface, from this factory.
pub fn with_interface(
mut self,
factory: Arc<dyn crate::overlay::TunFactory>,
name: impl Into<String>,
mtu: u32,
) -> Self {
self.tun_factory = Some(factory);
self.interface_name = name.into();
self.interface_mtu = mtu;
self
}
/// Replaces the limits.
pub fn with_limits(mut self, limits: Limits) -> Self {
self.limits = limits;
@@ -335,6 +499,8 @@ impl std::fmt::Debug for AgentConfig {
.field("transport", &self.transport)
.field("hostname", &self.hostname)
.field("discovery", &self.discovery.as_ref().map(|d| d.name()))
.field("dht", &self.dht.is_some())
.field("discovery_policy", &self.discovery_policy)
.field("discovery_interval", &self.discovery_interval)
.field("limits", &self.limits)
.field("reconnect", &self.reconnect)
@@ -349,3 +515,5 @@ impl std::fmt::Debug for AgentConfig {
.finish()
}
}
/// Maximum number of transport links a routed datagram may traverse.
pub const ROUTING_HOP_LIMIT: u8 = 16;
@@ -19,8 +19,10 @@
//! A data plane failure never stops the daemon: errors returned here are
//! recorded and surfaced, the control plane keeps running.
pub mod relay;
pub mod routing;
pub mod transport;
pub mod wireguard;
pub mod tunnel;
use std::sync::Arc;
@@ -30,7 +32,10 @@ use tokio::sync::mpsc;
use crate::BoxFuture;
use crate::identity::NetworkId;
pub use transport::{PacketLink, PacketTransport, SharedLink, TransportError};
pub use transport::{
MultiPacketTransport, PacketLink, PacketTransport, SharedLink, TransportError,
};
pub use tunnel::{GenericTunnelPlugin, TunnelCodec};
/// Maximum length of a plugin protocol identifier.
pub const MAX_PROTOCOL_ID_LEN: usize = 32;
@@ -42,7 +47,7 @@ pub const MAX_PROTOCOL_ID_LEN: usize = 32;
/// chooses to do so must validate it itself.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PluginCapability {
/// Protocol identifier, e.g. `wireguard`. Bounded by [`MAX_PROTOCOL_ID_LEN`].
/// Protocol identifier, e.g. `wg-quic`. Bounded by [`MAX_PROTOCOL_ID_LEN`].
pub protocol: String,
/// Version of the plugin's announcement format.
pub version: u16,
@@ -52,6 +57,51 @@ pub struct PluginCapability {
pub data: Vec<u8>,
}
/// Where a protocol hands the packets it has decrypted.
///
/// A protocol proves *who* sent a packet; it does not know what that member
/// is entitled to say, because entitlement is an address claim the system
/// level holds. So a decrypted packet goes here rather than straight to an
/// interface, and is checked on the way.
pub trait PacketSink: Send + Sync + 'static {
/// Hands over one packet, attributed to the peer whose tunnel decrypted
/// it.
fn deliver<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
packet: bytes::Bytes,
) -> BoxFuture<'a, ()>;
}
/// A sink that drops everything, for a protocol running without an interface.
#[derive(Debug, Clone, Copy, Default)]
pub struct DiscardPackets;
impl PacketSink for DiscardPackets {
fn deliver<'a>(
&'a self,
_network: NetworkId,
_peer: EndpointId,
_packet: bytes::Bytes,
) -> BoxFuture<'a, ()> {
Box::pin(async move {})
}
}
/// One setting a protocol accepts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProtocolOption {
/// The key, as written in `key=value`.
pub key: &'static str,
/// A word for the value, for the help line: `BYTES`, `NAME`.
pub value: &'static str,
/// What it does.
pub help: &'static str,
/// What happens when it is not given.
pub default: Option<&'static str>,
}
/// Errors a plugin may return. They are recorded, never fatal for the agent.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
@@ -69,7 +119,7 @@ pub enum PluginError {
/// A request a plugin makes of the agent that owns it.
#[derive(Debug)]
pub(crate) enum PluginRequest {
pub enum PluginRequest {
/// Re-send this agent's announcement to every peer of a network.
Reannounce(NetworkId),
/// Surface a plugin error on the agent's event stream.
@@ -81,6 +131,17 @@ pub(crate) enum PluginRequest {
/// Human readable reason, free of secrets.
reason: String,
},
/// A plugin reported that a data link or tunnel for a peer failed.
ProtocolFailure {
/// Network the failure occurred in.
network: NetworkId,
/// Peer whose protocol failed.
peer: EndpointId,
/// Protocol that failed.
protocol: String,
/// Human readable reason.
reason: String,
},
}
/// The agent-side handle a plugin is given when it is attached.
@@ -95,13 +156,32 @@ pub(crate) enum PluginRequest {
pub struct PluginContext {
sender: Option<mpsc::Sender<PluginRequest>>,
local: Option<EndpointId>,
/// Where decrypted packets go. Absent when nothing is carrying traffic,
/// in which case a protocol still runs and its packets are discarded.
sink: Option<Arc<dyn PacketSink>>,
}
impl PluginContext {
pub(crate) fn new(sender: mpsc::Sender<PluginRequest>, local: EndpointId) -> Self {
pub(crate) fn new(
sender: mpsc::Sender<PluginRequest>,
local: EndpointId,
sink: Option<Arc<dyn PacketSink>>,
) -> Self {
Self {
sender: Some(sender),
local: Some(local),
sink,
}
}
/// Where to hand a decrypted packet.
///
/// Always answers: with no interface to write to, the packets are
/// discarded, which is what `--no-tun` means and is not an error.
pub fn packet_sink(&self) -> Arc<dyn PacketSink> {
match &self.sink {
Some(sink) => Arc::clone(sink),
None => Arc::new(DiscardPackets),
}
}
@@ -110,6 +190,30 @@ impl PluginContext {
Self {
sender: None,
local: None,
sink: None,
}
}
/// A context configured with a custom packet sink.
pub fn with_sink(sink: Arc<dyn PacketSink>) -> Self {
Self {
sender: None,
local: None,
sink: Some(sink),
}
}
/// A context configured with a channel for plugin requests and custom sink, for tests.
#[cfg(feature = "testing")]
pub fn for_test(
sender: mpsc::Sender<PluginRequest>,
local: EndpointId,
sink: Option<Arc<dyn PacketSink>>,
) -> Self {
Self {
sender: Some(sender),
local: Some(local),
sink,
}
}
@@ -154,6 +258,22 @@ impl PluginContext {
reason: reason.into(),
});
}
/// Reports that data plane connectivity or a tunnel for a peer has failed.
pub fn report_peer_protocol_failure(
&self,
network: NetworkId,
peer: EndpointId,
protocol: impl Into<String>,
reason: impl Into<String>,
) {
self.send(PluginRequest::ProtocolFailure {
network,
peer,
protocol: protocol.into(),
reason: reason.into(),
});
}
}
impl std::fmt::Debug for PluginContext {
@@ -170,11 +290,34 @@ impl std::fmt::Debug for PluginContext {
/// Implementations must be cheap and non-blocking: the agent calls them from
/// its runtime tasks. Anything slow belongs in the plugin's own tasks.
pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
/// Stable protocol identifier, e.g. `wireguard`.
/// Stable protocol identifier, e.g. `wg-quic`.
///
/// Must be non-empty and at most [`MAX_PROTOCOL_ID_LEN`] bytes.
fn protocol_id(&self) -> &str;
/// The wire version of this protocol.
///
/// Two peers carry traffic for each other only when they have the same
/// protocol at the same version. There is no negotiating a middle
/// ground: a protocol either speaks the same words at both ends or it
/// does not, and pretending otherwise produces a link that fails later
/// and less clearly.
///
/// **Not the software version.** A plugin crate has its own version and
/// it is nobody else's business: two peers on different builds work
/// together for as long as the wire between them has not changed. This
/// number moves only when the bytes do, so it must never be derived from
/// `CARGO_PKG_VERSION` or anything else that moves with a release.
fn protocol_version(&self) -> u16;
/// The settings this protocol accepts, and what they mean.
///
/// Declared rather than documented elsewhere, so the agent can list them
/// without knowing anything about the protocol.
fn options(&self) -> &'static [ProtocolOption] {
&[]
}
/// Called once, when the agent starts, before any network is activated.
///
/// The plugin keeps the context to ask for re-announcements and to report
@@ -224,6 +367,20 @@ pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
let _ = (network, range, allocations);
}
/// Carries one packet to a peer, encrypting it however this protocol
/// does.
///
/// `false` when it cannot right now — no link, no tunnel, not this
/// protocol's peer — which the caller reports rather than treats as an
/// error. The packet came off the one interface the agent owns, and
/// which protocol takes it is settled by asking.
///
/// The default carries nothing, which is right for a plugin that only
/// announces something.
fn carry(&self, _network: NetworkId, _peer: EndpointId, _packet: bytes::Bytes) -> bool {
false
}
/// A data plane link to a peer is available for this plugin's protocol.
///
/// The plugin moves its packets over this link and never learns how the
@@ -243,6 +400,19 @@ pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
/// The plugin is expected to remove whatever it created for that network.
fn on_network_deactivated(&self, network: NetworkId);
/// Called when this agent has left a network for good.
///
/// Deactivation is temporary and keeps everything ready for next time;
/// this is the other one. Whatever the plugin holds *durably* for that
/// network — a key of its own, a file, a record — goes now, because the
/// agent is no longer a member and keeping it is keeping a secret for a
/// network it cannot rejoin without being told the secret again.
///
/// Always preceded by [`IpPlugin::on_network_deactivated`], so this is
/// only about what outlives a session. Defaulted to nothing, for a
/// plugin that stores nothing.
fn on_network_forgotten(&self, _network: NetworkId) {}
/// Called once when the agent shuts down.
///
/// The plugin removes the system objects it created and stops its tasks.
@@ -290,6 +460,10 @@ impl IpPlugin for TestCapabilityPlugin {
&self.protocol
}
fn protocol_version(&self) -> u16 {
1
}
fn local_capability(
&self,
_network: NetworkId,
+506
View File
@@ -0,0 +1,506 @@
//! Userspace forwarding. Topology updates publish immutable tables; the packet
//! path takes no routing mutex, walks no graph and never inspects plugin bytes.
//! Each transport reader forwards transit immediately, independently of the
//! destination protocol's receive task. Only local delivery enters its inbox.
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use arc_swap::ArcSwap;
use bytes::Bytes;
use iroh::EndpointId;
use tokio::sync::{Notify, mpsc, watch};
use tokio::task::JoinHandle;
use super::routing::{FlowId, PeerId, RoutingTable, envelope, mix64};
use super::transport::{PacketLink, SharedLink, TransportError};
use crate::BoxFuture;
use crate::config::{MAX_DATA_DATAGRAM, ROUTING_HOP_LIMIT};
use crate::identity::NetworkId;
/// Fixed routing header, subtracted from the logical transport MTU.
pub const RELAY_OVERHEAD: usize = envelope::HEADER;
const INBOX: usize = 256;
/// Forwarding counters, shared by all protocols of one network.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RelayCounters {
/// Datagrams originated here and sent through another member.
pub sent_via: u64,
/// Transit datagrams handed to the next transport link.
pub forwarded: u64,
/// Local datagrams received through another member.
pub received_via: u64,
/// Missing or failed next hop.
pub dropped_no_link: u64,
/// Malformed envelope or unknown member.
pub dropped_unknown: u64,
/// Exhausted hop limit, including loops during topology convergence.
pub dropped_hop_limit: u64,
/// Full local protocol inbox. Transit does not use this queue.
pub dropped_congested: u64,
}
#[derive(Debug, Default)]
struct Tally {
sent_via: AtomicU64,
forwarded: AtomicU64,
received_via: AtomicU64,
dropped_no_link: AtomicU64,
dropped_unknown: AtomicU64,
dropped_hop_limit: AtomicU64,
dropped_congested: AtomicU64,
}
impl Tally {
fn snapshot(&self) -> RelayCounters {
RelayCounters {
sent_via: self.sent_via.load(Ordering::Relaxed),
forwarded: self.forwarded.load(Ordering::Relaxed),
received_via: self.received_via.load(Ordering::Relaxed),
dropped_no_link: self.dropped_no_link.load(Ordering::Relaxed),
dropped_unknown: self.dropped_unknown.load(Ordering::Relaxed),
dropped_hop_limit: self.dropped_hop_limit.load(Ordering::Relaxed),
dropped_congested: self.dropped_congested.load(Ordering::Relaxed),
}
}
}
#[derive(Debug)]
struct NextHop {
peer: PeerId,
link: SharedLink,
}
#[derive(Debug)]
struct ForwardRoute {
hops: u8,
next: Box<[NextHop]>,
}
impl ForwardRoute {
fn select(&self, source: &PeerId, flow: FlowId) -> Option<&NextHop> {
if self.next.len() == 1 {
return self.next.first().filter(|hop| !hop.link.is_closed());
}
let seed = u64::from_le_bytes(source[..8].try_into().ok()?);
let start = mix64(flow ^ seed) as usize % self.next.len();
// Stable fallback if a link closed just before its table was replaced.
(0..self.next.len())
.map(|i| &self.next[(start + i) % self.next.len()])
.find(|hop| !hop.link.is_closed())
}
}
#[derive(Debug, Default)]
struct ForwardingTable {
routes: HashMap<PeerId, ForwardRoute>,
inboxes: HashMap<PeerId, mpsc::Sender<Bytes>>,
members: HashSet<PeerId>,
}
#[derive(Debug)]
struct Plane {
local: PeerId,
table: ArcSwap<ForwardingTable>,
tally: Arc<Tally>,
}
impl Plane {
/// Synchronous hot path: one snapshot, bounded header decode, table lookup,
/// and transport send. An exclusively owned buffer is modified in place.
fn receive(&self, incoming: PeerId, frame: Bytes) {
let Some(header) = envelope::decode(&frame) else {
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
return;
};
let table = self.table.load();
if !table.members.contains(&header.source) || header.source == self.local {
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
return;
}
if header.destination == self.local {
let Some(inbox) = table.inboxes.get(&header.source) else {
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
return;
};
if inbox.try_send(frame.slice(RELAY_OVERHEAD..)).is_err() {
self.tally.dropped_congested.fetch_add(1, Ordering::Relaxed);
} else if incoming != header.source {
self.tally.received_via.fetch_add(1, Ordering::Relaxed);
}
return;
}
if header.remaining <= 1 {
self.tally.dropped_hop_limit.fetch_add(1, Ordering::Relaxed);
return;
}
let hop = table
.routes
.get(&header.destination)
.and_then(|route| route.select(&header.source, header.flow));
let Some(hop) = hop else {
self.tally.dropped_no_link.fetch_add(1, Ordering::Relaxed);
return;
};
if hop.link.send(envelope::decrement(frame)).is_ok() {
self.tally.forwarded.fetch_add(1, Ordering::Relaxed);
} else {
self.tally.dropped_no_link.fetch_add(1, Ordering::Relaxed);
}
}
}
#[derive(Debug)]
struct Protocol {
plane: Arc<Plane>,
graph: HashMap<PeerId, Vec<PeerId>>,
members: HashSet<PeerId>,
raw: HashMap<PeerId, SharedLink>,
readers: HashMap<PeerId, JoinHandle<()>>,
links: HashMap<PeerId, Arc<PeerLink>>,
}
impl Protocol {
fn publish(&self) {
let mut graph = self.graph.clone();
graph.insert(
self.plane.local,
self.raw
.iter()
.filter(|(_, link)| !link.is_closed())
.map(|(peer, _)| *peer)
.collect(),
);
let routing = RoutingTable::build(self.plane.local, &graph, ROUTING_HOP_LIMIT);
let routes = routing
.iter()
.filter_map(|(peer, route)| {
let next: Box<[_]> = route
.next_hops
.iter()
.filter_map(|id| {
self.raw.get(id).map(|link| NextHop {
peer: *id,
link: link.clone(),
})
})
.collect();
(!next.is_empty()).then_some((
*peer,
ForwardRoute {
hops: route.hops,
next,
},
))
})
.collect();
let mut members = self.members.clone();
members.extend(self.raw.keys().copied());
self.plane.table.store(Arc::new(ForwardingTable {
routes,
members,
inboxes: self
.links
.iter()
.map(|(peer, link)| (*peer, link.inbox_tx.clone()))
.collect(),
}));
}
}
impl Drop for Protocol {
fn drop(&mut self) {
for task in self.readers.values() {
task.abort();
}
for link in self.links.values() {
link.close();
}
self.plane.table.store(Arc::default());
}
}
/// One network's transport links and independently published protocol tables.
#[derive(Debug)]
pub struct RelayHub {
network: NetworkId,
local: PeerId,
protocols: Mutex<HashMap<String, Protocol>>,
tally: Arc<Tally>,
changed: Arc<Notify>,
}
impl RelayHub {
/// Creates the router at this network's local endpoint.
pub fn new(network: NetworkId, local: EndpointId) -> Arc<Self> {
Arc::new(Self {
network,
local: *local.as_bytes(),
protocols: Mutex::default(),
tally: Arc::default(),
changed: Arc::default(),
})
}
fn protocols(&self) -> std::sync::MutexGuard<'_, HashMap<String, Protocol>> {
self.protocols
.lock()
.unwrap_or_else(|error| error.into_inner())
}
fn protocol<'a>(
&self,
protocols: &'a mut HashMap<String, Protocol>,
name: &str,
) -> &'a mut Protocol {
protocols
.entry(name.to_owned())
.or_insert_with(|| Protocol {
plane: Arc::new(Plane {
local: self.local,
table: ArcSwap::from_pointee(ForwardingTable::default()),
tally: self.tally.clone(),
}),
graph: HashMap::new(),
members: HashSet::new(),
raw: HashMap::new(),
readers: HashMap::new(),
links: HashMap::new(),
})
}
/// Wakes the control loop promptly when a transport reader ends.
pub async fn changed(&self) {
self.changed.notified().await;
}
/// Current counters.
pub fn counters(&self) -> RelayCounters {
self.tally.snapshot()
}
/// Publishes authenticated topology. Refreshing identical state is cheap.
pub fn set_topology(
&self,
protocol: &str,
graph: HashMap<PeerId, Vec<PeerId>>,
members: HashSet<PeerId>,
) {
let mut protocols = self.protocols();
let state = self.protocol(&mut protocols, protocol);
if state.graph != graph || state.members != members {
state.graph = graph;
state.members = members;
state.publish();
}
}
/// Whether the current table has a path to this destination.
pub fn reachable(&self, peer: EndpointId, protocol: &str) -> bool {
self.protocols().get(protocol).is_some_and(|state| {
state
.plane
.table
.load()
.routes
.contains_key(peer.as_bytes())
})
}
/// Stable logical link, preserved across topology changes.
pub fn link(&self, peer: EndpointId, protocol: &str) -> Arc<PeerLink> {
let mut protocols = self.protocols();
let state = self.protocol(&mut protocols, protocol);
if let Some(link) = state.links.get(peer.as_bytes()) {
return link.clone();
}
let (inbox_tx, inbox_rx) = mpsc::channel(INBOX);
let (closed, _) = watch::channel(false);
let link = Arc::new(PeerLink {
network: self.network,
peer,
plane: state.plane.clone(),
inbox_tx,
inbox_rx: tokio::sync::Mutex::new(inbox_rx),
closed,
});
state.links.insert(*peer.as_bytes(), link.clone());
state.publish();
link
}
/// Whether the protocol already owns its logical link.
pub fn has_link(&self, peer: EndpointId, protocol: &str) -> bool {
self.protocols()
.get(protocol)
.is_some_and(|state| state.links.contains_key(peer.as_bytes()))
}
/// Installs a raw transport link and starts its independent ingress reader.
pub fn set_direct(&self, peer: EndpointId, protocol: &str, raw: SharedLink) {
if raw.network() != self.network || raw.peer() != peer {
return;
}
let mut protocols = self.protocols();
let state = self.protocol(&mut protocols, protocol);
let id = *peer.as_bytes();
if let Some(old) = state.readers.remove(&id) {
old.abort();
}
state.raw.insert(id, raw.clone());
state.publish();
let plane = state.plane.clone();
let changed = self.changed.clone();
state.readers.insert(
id,
tokio::spawn(async move {
let mut batch = 0;
while let Some(frame) = raw.recv().await {
plane.receive(id, frame);
batch += 1;
if batch == 64 {
tokio::task::yield_now().await;
batch = 0;
}
}
changed.notify_one();
}),
);
}
/// Removes a physical link without tearing down end-to-end protocol state.
pub fn clear_direct(&self, peer: EndpointId, protocol: &str) {
if let Some(state) = self.protocols().get_mut(protocol) {
state.raw.remove(peer.as_bytes());
if let Some(task) = state.readers.remove(peer.as_bytes()) {
task.abort();
}
state.publish();
}
}
/// Revokes all links and topology involving a departed authenticated peer.
pub fn remove_peer(&self, peer: EndpointId) {
for state in self.protocols().values_mut() {
state.raw.remove(peer.as_bytes());
if let Some(task) = state.readers.remove(peer.as_bytes()) {
task.abort();
}
if let Some(link) = state.links.remove(peer.as_bytes()) {
link.close();
}
state.members.remove(peer.as_bytes());
state.graph.remove(peer.as_bytes());
for neighbors in state.graph.values_mut() {
neighbors.retain(|id| id != peer.as_bytes());
}
state.publish();
}
}
/// Stops every reader and releases all transport handles.
pub fn close(&self) {
self.protocols().clear();
}
}
/// A protocol's end-to-end channel, independent of its current next hop.
#[derive(Debug)]
pub struct PeerLink {
network: NetworkId,
peer: EndpointId,
plane: Arc<Plane>,
inbox_tx: mpsc::Sender<Bytes>,
inbox_rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
closed: watch::Sender<bool>,
}
impl PeerLink {
fn close(&self) {
self.closed.send_replace(true);
}
}
impl PacketLink for PeerLink {
fn network(&self) -> NetworkId {
self.network
}
fn peer(&self) -> EndpointId {
self.peer
}
fn max_datagram_size(&self) -> usize {
MAX_DATA_DATAGRAM - RELAY_OVERHEAD
}
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
self.send_flow(payload, 0)
}
fn send_flow(&self, payload: Bytes, flow: FlowId) -> Result<(), TransportError> {
if self.is_closed() {
return Err(TransportError::Closed);
}
if payload.len() > self.max_datagram_size() {
return Err(TransportError::TooLarge {
size: payload.len(),
limit: self.max_datagram_size(),
});
}
let table = self.plane.table.load();
let route = table
.routes
.get(self.peer.as_bytes())
.ok_or(TransportError::Closed)?;
let hop = route
.select(&self.plane.local, flow)
.ok_or(TransportError::Closed)?;
hop.link.send(envelope::encode(
self.plane.local,
*self.peer.as_bytes(),
flow,
&payload,
))?;
if route.hops > 1 {
self.plane.tally.sent_via.fetch_add(1, Ordering::Relaxed);
}
Ok(())
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move {
let mut closed = self.closed.subscribe();
if *closed.borrow_and_update() {
return None;
}
let mut inbox = self.inbox_rx.lock().await;
tokio::select! { biased; _ = closed.changed() => None, frame = inbox.recv() => frame }
})
}
fn closed(&self) -> BoxFuture<'_, ()> {
Box::pin(async move {
let mut closed = self.closed.subscribe();
if !*closed.borrow_and_update() {
let _ = closed.changed().await;
}
})
}
fn is_closed(&self) -> bool {
*self.closed.borrow()
}
fn path_description(&self) -> String {
let table = self.plane.table.load();
match table.routes.get(self.peer.as_bytes()) {
Some(route) if route.hops == 1 => route.next[0].link.path_description(),
Some(route) => format!(
"relay {} hops via {} ({} equal paths)",
route.hops,
hex::encode(route.next[0].peer),
route.next.len()
),
None => "unreachable".into(),
}
}
}
#[cfg(test)]
#[path = "relay_tests.rs"]
mod tests;
+287
View File
@@ -0,0 +1,287 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
use std::sync::atomic::AtomicBool;
fn network() -> NetworkId {
NetworkKeys::derive(
&NetworkName::new("routing-tests").unwrap(),
&NetworkSecret::from_bytes([3; 32]).unwrap(),
)
.network_id()
}
fn peer(seed: u8) -> EndpointId {
iroh::SecretKey::from_bytes(&[seed; 32]).public()
}
fn id(seed: u8) -> PeerId {
*peer(seed).as_bytes()
}
#[derive(Debug)]
struct Wire {
peer: EndpointId,
sent: Mutex<Vec<Bytes>>,
feed: mpsc::Sender<Bytes>,
inbound: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
dead: AtomicBool,
count: AtomicU64,
record: bool,
}
impl Wire {
fn new(seed: u8, record: bool) -> Arc<Self> {
let (feed, inbound) = mpsc::channel(32);
Arc::new(Self {
peer: peer(seed),
sent: Mutex::default(),
feed,
inbound: tokio::sync::Mutex::new(inbound),
dead: AtomicBool::new(false),
count: AtomicU64::new(0),
record,
})
}
fn take(&self) -> Vec<Bytes> {
std::mem::take(&mut self.sent.lock().unwrap())
}
}
impl PacketLink for Wire {
fn network(&self) -> NetworkId {
network()
}
fn peer(&self) -> EndpointId {
self.peer
}
fn max_datagram_size(&self) -> usize {
MAX_DATA_DATAGRAM
}
fn send(&self, frame: Bytes) -> Result<(), TransportError> {
if self.is_closed() {
return Err(TransportError::Closed);
}
self.count.fetch_add(1, Ordering::Relaxed);
if self.record {
self.sent.lock().unwrap().push(frame);
}
Ok(())
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move { self.inbound.lock().await.recv().await })
}
fn closed(&self) -> BoxFuture<'_, ()> {
Box::pin(std::future::pending())
}
fn is_closed(&self) -> bool {
self.dead.load(Ordering::Relaxed)
}
fn path_description(&self) -> String {
"test-direct".into()
}
}
fn topology(hub: &RelayHub, protocol: &str, edges: &[(u8, &[u8])], members: &[u8]) {
hub.set_topology(
protocol,
edges
.iter()
.map(|(a, bs)| (id(*a), bs.iter().map(|b| id(*b)).collect()))
.collect(),
members.iter().map(|p| id(*p)).collect(),
);
}
#[tokio::test]
async fn ecmp_is_per_flow_direct_wins_and_existing_tunnel_survives_changes() {
let hub = RelayHub::new(network(), peer(1));
let b = Wire::new(2, true);
let c = Wire::new(3, true);
hub.set_direct(peer(2), "ip", b.clone());
hub.set_direct(peer(3), "ip", c.clone());
topology(&hub, "ip", &[(2, &[4]), (3, &[4])], &[1, 2, 3, 4]);
let link = hub.link(peer(4), "ip");
let mut chosen = HashMap::new();
for _ in 0..8 {
for flow in 0..100 {
link.send_flow(Bytes::from_static(b"ciphertext"), flow)
.unwrap();
}
for (hop, wire) in [(2, &b), (3, &c)] {
for frame in wire.take() {
let header = envelope::decode(&frame).unwrap();
assert_eq!(header.source, id(1));
assert_eq!(header.destination, id(4));
if let Some(old) = chosen.insert(header.flow, hop) {
assert_eq!(old, hop);
}
}
}
}
assert_eq!(
chosen.values().copied().collect::<HashSet<_>>(),
HashSet::from([2, 3])
);
let direct = Wire::new(4, true);
hub.set_direct(peer(4), "ip", direct.clone());
link.send_flow(Bytes::from_static(b"direct"), 8).unwrap();
assert_eq!(direct.take().len(), 1);
assert!(b.take().is_empty() && c.take().is_empty());
hub.clear_direct(peer(4), "ip");
b.dead.store(true, Ordering::Relaxed);
for flow in 0..100 {
link.send_flow(Bytes::from_static(b"fallback"), flow)
.unwrap();
}
assert_eq!(c.take().len(), 100);
hub.clear_direct(peer(2), "ip");
topology(&hub, "ip", &[(3, &[])], &[1, 2, 3, 4]);
assert!(link.send(Bytes::new()).is_err());
assert!(!link.is_closed());
assert!(Arc::ptr_eq(&link, &hub.link(peer(4), "ip")));
hub.remove_peer(peer(4));
assert!(link.is_closed());
tokio::time::timeout(std::time::Duration::from_secs(1), link.closed())
.await
.unwrap();
assert!(link.recv().await.is_none());
hub.close();
}
#[tokio::test]
async fn transport_reader_forwards_without_any_plugin_reader_and_isolates_protocols() {
let hub = RelayHub::new(network(), peer(2));
let a = Wire::new(1, true);
let c = Wire::new(3, true);
hub.set_direct(peer(1), "ip", a.clone());
hub.set_direct(peer(3), "ip", c.clone());
topology(&hub, "ip", &[(3, &[4])], &[1, 2, 3, 4]);
let frame = envelope::encode(id(1), id(4), 91, b"end-to-end encrypted");
let pointer = frame.as_ptr();
a.feed.send(frame).await.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(1), async {
while hub.counters().forwarded == 0 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let sent = c.take();
assert_eq!(sent[0].as_ptr(), pointer);
assert_eq!(
envelope::decode(&sent[0]).unwrap().remaining,
ROUTING_HOP_LIMIT - 1
);
assert_eq!(&sent[0][RELAY_OVERHEAD..], b"end-to-end encrypted");
let isolated = hub.link(peer(4), "other-ip");
assert!(isolated.send(Bytes::new()).is_err());
assert_eq!(hub.counters().forwarded, 1);
hub.close();
}
#[tokio::test]
async fn local_delivery_is_bounded_and_malformed_or_unknown_sources_are_dropped() {
let hub = RelayHub::new(network(), peer(2));
let a = Wire::new(1, true);
hub.set_direct(peer(1), "ip", a);
let link = hub.link(peer(1), "ip");
let plane = link.plane.clone();
for _ in 0..INBOX + 1 {
plane.receive(id(1), envelope::encode(id(1), id(2), 0, b"ip"));
}
assert_eq!(hub.counters().dropped_congested, 1);
for _ in 0..INBOX {
assert_eq!(link.recv().await.unwrap(), Bytes::from_static(b"ip"));
}
plane.receive(id(1), Bytes::from_static(b"broken"));
plane.receive(id(1), envelope::encode(id(9), id(2), 0, b"unknown member"));
assert_eq!(hub.counters().dropped_unknown, 2);
plane.receive(
id(1),
envelope::encode(id(1), id(9), 0, b"unknown destination"),
);
assert_eq!(hub.counters().dropped_no_link, 1);
hub.close();
}
#[tokio::test]
async fn inconsistent_tables_cannot_loop_beyond_the_hop_limit() {
let a = RelayHub::new(network(), peer(1));
let b = RelayHub::new(network(), peer(2));
let ab = Wire::new(2, true);
let ba = Wire::new(1, true);
a.set_direct(peer(2), "ip", ab.clone());
b.set_direct(peer(1), "ip", ba.clone());
// Deliberately inconsistent snapshots while updates are in flight.
topology(&a, "ip", &[(2, &[4])], &[1, 2, 3, 4]);
topology(&b, "ip", &[(1, &[4])], &[1, 2, 3, 4]);
let ap = a.link(peer(4), "ip").plane.clone();
let bp = b.link(peer(4), "ip").plane.clone();
let mut frame = envelope::encode(id(3), id(4), 7, b"transit");
for turn in 0..ROUTING_HOP_LIMIT {
let (plane, incoming, wire) = if turn % 2 == 0 {
(&ap, id(2), &ab)
} else {
(&bp, id(1), &ba)
};
plane.receive(incoming, frame);
let sent = wire.take();
if turn == ROUTING_HOP_LIMIT - 1 {
assert!(sent.is_empty());
break;
}
assert_eq!(sent.len(), 1);
frame = sent.into_iter().next().unwrap();
}
assert_eq!(
a.counters().forwarded + b.counters().forwarded,
u64::from(ROUTING_HOP_LIMIT - 1)
);
assert_eq!(
a.counters().dropped_hop_limit + b.counters().dropped_hop_limit,
1
);
a.close();
b.close();
}
/// Measures the actual synchronous transit routine, including header checks,
/// snapshot lookup, ECMP, in-place TTL update and mock transport submission.
/// Packet construction, encryption and socket I/O are outside this measurement.
#[tokio::test]
#[ignore = "manual release-mode forwarding microbenchmark"]
async fn forwarding_benchmark() {
let hub = RelayHub::new(network(), peer(2));
let wire = Wire::new(3, false);
hub.set_direct(peer(3), "ip", wire.clone());
let alternate = Wire::new(5, false);
hub.set_direct(peer(5), "ip", alternate.clone());
topology(&hub, "ip", &[(3, &[4]), (5, &[4])], &[1, 2, 3, 4, 5]);
let plane = hub.link(peer(4), "ip").plane.clone();
let source = id(1);
let destination = id(4);
let incoming = id(1);
for size in [64, 1280] {
let mut elapsed = std::time::Duration::ZERO;
const COUNT: usize = 10000;
const BATCHES: usize = 100;
for _ in 0..BATCHES {
let frames: Vec<_> = (0..COUNT)
.map(|flow| envelope::encode(source, destination, flow as u64, &vec![42; size]))
.collect();
let start = std::time::Instant::now();
for frame in frames {
plane.receive(incoming, std::hint::black_box(frame));
}
elapsed += start.elapsed();
}
let count = (COUNT * BATCHES) as f64;
println!(
"transit {size} B: {:.1} ns/packet, {:.2} Mpps (1M packets, two equal paths)",
elapsed.as_nanos() as f64 / count,
count / elapsed.as_secs_f64() / 1e6
);
}
assert_eq!(
wire.count.load(Ordering::Relaxed) + alternate.count.load(Ordering::Relaxed),
2000000
);
hub.close();
}
@@ -0,0 +1,84 @@
//! Fixed header. Transit changes one byte in an owned receive buffer.
use super::{FlowId, PeerId};
use crate::config::{MAX_DATA_DATAGRAM, ROUTING_HOP_LIMIT};
use bytes::{BufMut, Bytes, BytesMut};
pub(crate) const HEADER: usize = 2 + 32 + 32 + 8;
const VERSION: u8 = 1;
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Header {
pub source: PeerId,
pub destination: PeerId,
pub remaining: u8,
pub flow: FlowId,
}
pub(crate) fn decode(bytes: &[u8]) -> Option<Header> {
if bytes.len() < HEADER
|| bytes.len() > MAX_DATA_DATAGRAM
|| bytes[0] != VERSION
|| bytes[1] == 0
|| bytes[1] > ROUTING_HOP_LIMIT
{
return None;
}
Some(Header {
source: bytes[2..34].try_into().ok()?,
destination: bytes[34..66].try_into().ok()?,
remaining: bytes[1],
flow: u64::from_be_bytes(bytes[66..74].try_into().ok()?),
})
}
pub(crate) fn encode(source: PeerId, destination: PeerId, flow: FlowId, payload: &[u8]) -> Bytes {
let mut bytes = BytesMut::with_capacity(HEADER + payload.len());
bytes.put_u8(VERSION);
bytes.put_u8(ROUTING_HOP_LIMIT);
bytes.extend_from_slice(&source);
bytes.extend_from_slice(&destination);
bytes.put_u64(flow);
bytes.extend_from_slice(payload);
bytes.freeze()
}
pub(crate) fn decrement(bytes: Bytes) -> Bytes {
let mut bytes = match bytes.try_into_mut() {
Ok(bytes) => bytes,
Err(bytes) => BytesMut::from(bytes.as_ref()),
};
// Called only after decode and after excluding remaining <= 1.
bytes[1] -= 1;
bytes.freeze()
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn transit_reuses_the_buffer_and_rejects_invalid_headers() {
let frame = encode([1; 32], [2; 32], 17, &[42; 1280]);
let address = frame.as_ptr();
let frame = decrement(frame);
assert_eq!(
frame.as_ptr(),
address,
"no payload copy for an owned buffer"
);
assert_eq!(decode(&frame).unwrap().remaining, ROUTING_HOP_LIMIT - 1);
assert_eq!(&frame[HEADER..], &[42; 1280]);
for length in 0..HEADER {
assert!(decode(&frame[..length]).is_none());
}
for ttl in [0, ROUTING_HOP_LIMIT + 1] {
let mut bad = frame.to_vec();
bad[1] = ttl;
assert!(decode(&bad).is_none());
}
let mut bad = frame.to_vec();
bad[0] = 99;
assert!(decode(&bad).is_none());
assert!(decode(&vec![1; MAX_DATA_DATAGRAM + 1]).is_none());
}
}
@@ -0,0 +1,125 @@
//! Optional IP flow classifier for IP plugins, called before encryption.
//! The router itself treats this identifier and the payload as opaque.
use super::FlowId;
fn hash(parts: &[&[u8]]) -> FlowId {
let mut value = 0xcbf29ce484222325u64;
for part in parts {
for byte in *part {
value = (value ^ u64::from(*byte)).wrapping_mul(0x100000001b3);
}
}
value
}
/// Stable directional IP flow hash, independent of TCP sequence numbers,
/// checksums and packet contents. Fragmented traffic uses addresses/protocol
/// instead of ports: even non-initial fragments and subsequent fragmented
/// datagrams stay together. This deliberately coalesces fragmented flows.
/// Malformed packets get a coarse hash; parsing is bounded and never allocates.
pub fn ip_flow(packet: &[u8]) -> FlowId {
match packet.first().map(|b| b >> 4) {
Some(4) if packet.len() >= 20 => {
let addresses = &packet[12..20];
let protocol = &packet[9..10];
let offset = usize::from(packet[0] & 15) * 4;
if u16::from_be_bytes([packet[6], packet[7]]) & 0x3fff != 0 {
return hash(&[addresses, protocol]);
}
if offset >= 20
&& matches!(packet[9], 6 | 17)
&& let Some(ports) = packet.get(offset..offset + 4)
{
return hash(&[addresses, protocol, ports]);
}
hash(&[addresses, protocol])
}
Some(6) if packet.len() >= 40 => {
let addresses = &packet[8..40];
let mut protocol = packet[6];
let mut offset = 40;
for _ in 0..8 {
match protocol {
44 => {
if let Some(fragment) = packet.get(offset..offset + 8) {
return hash(&[addresses, &fragment[..1]]);
}
break;
}
0 | 43 | 60 | 51 => {
let Some(header) = packet.get(offset..offset + 2) else {
break;
};
let size = if protocol == 51 {
(usize::from(header[1]) + 2) * 4
} else {
(usize::from(header[1]) + 1) * 8
};
protocol = header[0];
offset += size;
}
6 | 17 => {
if let Some(ports) = packet.get(offset..offset + 4) {
return hash(&[addresses, &[protocol], ports]);
}
break;
}
_ => break,
}
}
hash(&[addresses, &[protocol]])
}
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tcp_flow_ignores_packet_contents_but_distinguishes_ports() {
let mut packet = [0u8; 80];
packet[0] = 0x45;
packet[9] = 6;
packet[12..24].copy_from_slice(&[10, 0, 0, 1, 10, 0, 0, 2, 0, 22, 1, 2]);
let flow = ip_flow(&packet);
packet[4] = 123; // IP id changes for each unfragmented packet.
packet[24..].fill(47);
assert_eq!(flow, ip_flow(&packet));
packet[23] = 3;
assert_ne!(flow, ip_flow(&packet));
packet[6] = 0x20;
let fragment_flow = ip_flow(&packet);
packet[6] = 0;
packet[7] = 1;
packet[20..].fill(99);
assert_eq!(fragment_flow, ip_flow(&packet));
packet[4] = 97;
assert_eq!(fragment_flow, ip_flow(&packet));
}
#[test]
fn ipv6_extensions_and_fragments_are_bounded_and_stable() {
let mut packet = [0u8; 80];
packet[0] = 0x60;
packet[6] = 0;
packet[40] = 17;
packet[48..52].copy_from_slice(&[1, 2, 3, 4]);
let flow = ip_flow(&packet);
packet[52..].fill(5);
assert_eq!(flow, ip_flow(&packet));
packet[50] = 9;
assert_ne!(flow, ip_flow(&packet));
packet[6] = 44;
packet[40] = 6;
let flow = ip_flow(&packet);
packet[42] = 32;
packet[48..].fill(3);
assert_eq!(flow, ip_flow(&packet));
for len in 0..packet.len() {
let _ = ip_flow(&packet[..len]);
}
}
}
+131
View File
@@ -0,0 +1,131 @@
//! Transport-independent routing over opaque identities. Tables are built on
//! topology changes, never on the packet path. No WireGuard or iroh APIs here.
pub(crate) mod envelope;
pub mod flow;
use std::collections::{BTreeSet, HashMap, VecDeque};
/// Opaque authenticated identity; the transport adapter validates its keys.
pub type PeerId = [u8; 32];
/// Stable flow identifier, carried unchanged between transit routers.
pub type FlowId = u64;
/// A shortest route with all equal-cost first hops in stable order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Route {
/// Number of transport links to the destination.
pub hops: u8,
/// Equal-cost first hops. Direct destinations have one entry.
pub next_hops: Box<[PeerId]>,
}
/// Immutable shortest-path table, replaced when connectivity changes.
#[derive(Debug, Clone, Default)]
pub struct RoutingTable {
routes: HashMap<PeerId, Route>,
}
impl RoutingTable {
/// Computes shortest directed paths bounded by `hop_limit`.
/// The caller supplies only authenticated, fresh, compatible edges.
pub fn build(local: PeerId, graph: &HashMap<PeerId, Vec<PeerId>>, hop_limit: u8) -> Self {
let mut distances = HashMap::from([(local, 0u8)]);
let mut first_hops: HashMap<PeerId, BTreeSet<PeerId>> = HashMap::new();
let mut pending = VecDeque::from([local]);
while let Some(node) = pending.pop_front() {
let distance = distances[&node];
if distance >= hop_limit {
continue;
}
let inherited = first_hops.get(&node).cloned().unwrap_or_default();
for &next in graph.get(&node).into_iter().flatten() {
let next_distance = distance + 1;
match distances.get(&next) {
Some(&known) if known < next_distance => continue,
None => {
distances.insert(next, next_distance);
pending.push_back(next);
}
_ => {}
}
let hops = first_hops.entry(next).or_default();
if node == local {
hops.insert(next);
} else {
hops.extend(inherited.iter().copied());
}
}
}
Self {
routes: first_hops
.into_iter()
.map(|(peer, hops)| {
(
peer,
Route {
hops: distances[&peer],
next_hops: hops.into_iter().collect(),
},
)
})
.collect(),
}
}
/// Precomputed route to a destination.
pub fn get(&self, destination: &PeerId) -> Option<&Route> {
self.routes.get(destination)
}
/// All reachable destinations, excluding ourselves.
pub fn iter(&self) -> impl Iterator<Item = (&PeerId, &Route)> {
self.routes.iter()
}
}
/// Stable ECMP mixing, without random state or hashing the payload per hop.
pub(crate) fn mix64(mut value: u64) -> u64 {
value ^= value >> 30;
value = value.wrapping_mul(0xbf58476d1ce4e5b9);
value ^= value >> 27;
value = value.wrapping_mul(0x94d049bb133111eb);
value ^ (value >> 31)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn p(n: u8) -> PeerId {
[n; 32]
}
#[test]
fn shortest_paths_keep_equal_cost_hops_and_prefer_direct_links() {
let mut graph = HashMap::from([
(p(1), vec![p(3), p(2)]),
(p(2), vec![p(4)]),
(p(3), vec![p(4)]),
(p(4), vec![p(5)]),
(p(5), vec![p(1)]),
]);
let routes = RoutingTable::build(p(1), &graph, 16);
assert_eq!(routes.get(&p(5)).unwrap().hops, 3);
assert_eq!(&*routes.get(&p(5)).unwrap().next_hops, &[p(2), p(3)]);
assert!(routes.get(&p(1)).is_none());
assert!(routes.get(&p(9)).is_none());
graph.get_mut(&p(1)).unwrap().push(p(5));
assert_eq!(
RoutingTable::build(p(1), &graph, 16).get(&p(5)).unwrap(),
&Route {
hops: 1,
next_hops: Box::new([p(5)])
}
);
}
#[test]
fn topology_removal_and_hop_budget_remove_impossible_routes() {
let mut graph = HashMap::from([(p(1), vec![p(2)]), (p(2), vec![p(3)]), (p(3), vec![p(4)])]);
assert!(RoutingTable::build(p(1), &graph, 2).get(&p(4)).is_none());
assert!(RoutingTable::build(p(1), &graph, 3).get(&p(4)).is_some());
graph.remove(&p(2));
assert!(RoutingTable::build(p(1), &graph, 16).get(&p(4)).is_none());
}
}
@@ -0,0 +1,234 @@
//! Bounded fragmentation of opaque transport payloads, below peer relaying.
//!
//! Data ALPN v3: u64 packet ID, u32 total length, u32 byte offset (big endian),
//! followed by payload. Each QUIC connection owns its IDs and reassembly state.
//! There is no retransmission: one missing fragment loses one datagram only.
use std::collections::{HashMap, VecDeque};
use bytes::{BufMut, Bytes, BytesMut};
use tokio::time::Instant;
use crate::config::{
DATA_REASSEMBLY_TTL, DATA_RECENT_IDS, MAX_DATA_ASSEMBLIES, MAX_DATA_DATAGRAM,
MAX_DATA_FRAGMENTS, MAX_DATA_REASSEMBLY_BYTES,
};
pub(super) const HEADER: usize = 16;
pub(super) fn encode(id: u64, total: usize, offset: usize, payload: &[u8]) -> Bytes {
let mut frame = BytesMut::with_capacity(HEADER + payload.len());
frame.put_u64(id);
frame.put_u32(total as u32);
frame.put_u32(offset as u32);
frame.extend_from_slice(payload);
frame.freeze()
}
#[derive(Debug)]
struct Assembly {
started: Instant,
bytes: Vec<u8>,
ranges: Vec<(usize, usize)>,
received: usize,
}
#[derive(Debug, Default)]
pub(super) struct Reassembler {
packets: HashMap<u64, Assembly>,
buffered: usize,
recent: VecDeque<u64>,
}
impl Reassembler {
fn remember(&mut self, id: u64) {
if self.recent.len() == DATA_RECENT_IDS {
self.recent.pop_front();
}
self.recent.push_back(id);
}
fn remove(&mut self, id: u64) -> Option<Assembly> {
let packet = self.packets.remove(&id)?;
self.buffered -= packet.bytes.len();
self.remember(id);
Some(packet)
}
pub(super) fn expire(&mut self, now: Instant) {
let expired: Vec<_> = self
.packets
.iter()
.filter(|(_, p)| now.duration_since(p.started) >= DATA_REASSEMBLY_TTL)
.map(|(id, _)| *id)
.collect();
for id in expired {
self.remove(id);
}
}
pub(super) fn push(&mut self, frame: Bytes, now: Instant) -> Option<Bytes> {
self.expire(now);
let id = u64::from_be_bytes(frame.get(..8)?.try_into().ok()?);
let total = u32::from_be_bytes(frame.get(8..12)?.try_into().ok()?) as usize;
let offset = u32::from_be_bytes(frame.get(12..HEADER)?.try_into().ok()?) as usize;
let payload = frame.get(HEADER..)?;
let end = offset.checked_add(payload.len())?;
if total > MAX_DATA_DATAGRAM
|| end > total
|| (payload.is_empty() && total != 0)
|| self.recent.contains(&id)
{
return None;
}
if !self.packets.contains_key(&id) {
if offset == 0 && end == total {
self.remember(id);
return Some(frame.slice(HEADER..));
}
// The length is validated before allocating. Drop the oldest
// incomplete packet when the per-link byte or entry budget is full.
while self.packets.len() >= MAX_DATA_ASSEMBLIES
|| self.buffered + total > MAX_DATA_REASSEMBLY_BYTES
{
let oldest = self
.packets
.iter()
.min_by_key(|(_, p)| p.started)
.map(|(id, _)| *id)?;
self.remove(oldest);
}
self.packets.insert(
id,
Assembly {
started: now,
bytes: vec![0; total],
ranges: Vec::new(),
received: 0,
},
);
self.buffered += total;
}
let packet = self.packets.get_mut(&id)?;
if packet.bytes.len() != total {
self.remove(id);
return None;
}
for &(start, stop) in &packet.ranges {
if start == offset && stop == end && packet.bytes[offset..end] == *payload {
return None; // a duplicate does not reset the expiry time
}
if offset < stop && end > start {
self.remove(id); // conflicting or overlapping fragments
return None;
}
}
if packet.ranges.len() == MAX_DATA_FRAGMENTS {
self.remove(id);
return None;
}
packet.bytes[offset..end].copy_from_slice(payload);
packet.ranges.push((offset, end));
packet.received += payload.len();
if packet.received == total {
return self.remove(id).map(|p| Bytes::from(p.bytes));
}
None
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn reordered_duplicates_and_changing_fragment_sizes_reassemble_once() {
let mut rx = Reassembler::default();
let now = Instant::now();
let packet: Vec<u8> = (0..9000).map(|i| (i % 251) as u8).collect();
let mut parts = Vec::new();
let mut offset = 0;
for size in [1100, 700, 1100, 1100, 700, 1100, 1100, 1100, 1000] {
parts.push(encode(
7,
packet.len(),
offset,
&packet[offset..offset + size],
));
offset += size;
}
assert_eq!(offset, packet.len());
for part in parts[1..].iter().rev() {
assert!(rx.push(part.clone(), now).is_none());
assert!(rx.push(part.clone(), now).is_none());
}
assert_eq!(rx.push(parts[0].clone(), now).unwrap().as_ref(), packet);
assert!(rx.push(parts[0].clone(), now).is_none());
assert_eq!(rx.buffered, 0);
assert!(rx.packets.is_empty());
}
#[test]
fn a_missing_fragment_never_blocks_the_next_packet_and_expires() {
let mut rx = Reassembler::default();
let now = Instant::now();
assert!(rx.push(encode(1, 1280, 0, &[1; 800]), now).is_none());
assert_eq!(
rx.push(encode(2, 4, 0, b"next"), now).unwrap(),
&b"next"[..]
);
rx.expire(now + DATA_REASSEMBLY_TTL);
assert!(rx.packets.is_empty());
assert_eq!(rx.buffered, 0);
assert!(
rx.push(encode(1, 1280, 800, &[1; 480]), now + DATA_REASSEMBLY_TTL)
.is_none()
);
}
#[test]
fn malformed_conflicting_and_oversized_fragments_are_bounded() {
let now = Instant::now();
let mut rx = Reassembler::default();
for len in 0..HEADER {
assert!(rx.push(Bytes::from(vec![0xff; len]), now).is_none());
}
assert!(
rx.push(encode(1, MAX_DATA_DATAGRAM + 1, 0, b"x"), now)
.is_none()
);
assert!(rx.push(encode(2, 100, 100, b"x"), now).is_none());
assert!(rx.push(encode(3, 100, 0, b""), now).is_none());
assert_eq!(rx.buffered, 0);
for (id, bad) in [
(4, encode(4, 100, 0, b"different")),
(5, encode(5, 100, 3, b"overlap")),
(6, encode(6, 101, 9, b"changed total")),
] {
assert!(rx.push(encode(id, 100, 0, b"123456789"), now).is_none());
assert!(rx.push(bad, now).is_none());
assert_eq!(rx.buffered, 0);
}
for id in 100..400 {
assert!(
rx.push(encode(id, MAX_DATA_DATAGRAM, 0, b"x"), now)
.is_none()
);
assert!(rx.buffered <= MAX_DATA_REASSEMBLY_BYTES);
assert!(rx.packets.len() <= MAX_DATA_ASSEMBLIES);
assert!(rx.recent.len() <= DATA_RECENT_IDS);
}
}
#[test]
fn excessive_fragment_counts_are_discarded() {
let mut rx = Reassembler::default();
let now = Instant::now();
for offset in 0..=MAX_DATA_FRAGMENTS {
assert!(rx.push(encode(1, 1000, offset, b"x"), now).is_none());
}
assert!(rx.packets.is_empty());
assert_eq!(rx.buffered, 0);
}
}
@@ -20,13 +20,14 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use bytes::Bytes;
use iroh::EndpointId;
use iroh::endpoint::{Connection, RecvStream, SendStream};
use crate::BoxFuture;
use crate::config::Limits;
use crate::config::{DATA_REASSEMBLY_TTL, Limits, MAX_DATA_DATAGRAM, MAX_DATA_FRAGMENTS};
use crate::error::ProtocolError;
use crate::identity::{NetworkId, NetworkKeys};
use crate::net::EndpointAdapter;
@@ -36,6 +37,7 @@ use crate::proto::message::{
};
use crate::proto::{read_frame, write_frame};
use super::fragments::{self, Reassembler};
use super::{InboundLink, PacketLink, PacketTransport, SharedLink, TransportError};
/// What the iroh transport needs from the agent.
@@ -61,6 +63,8 @@ pub struct IrohLink {
peer: EndpointId,
conn: Connection,
max_datagram: usize,
next_packet: AtomicU64,
reassembly: tokio::sync::Mutex<Reassembler>,
// Kept alive so the peer sees the channel as open; the connection closes
// when the link is dropped.
_send: tokio::sync::Mutex<SendStream>,
@@ -76,14 +80,16 @@ impl IrohLink {
send: SendStream,
recv: RecvStream,
) -> Self {
let local_limit = conn.max_datagram_size().unwrap_or(0);
// Both ends must agree, so the smaller limit wins.
let max_datagram = local_limit.min(peer_limit);
// The negotiated limit is for reassembled payloads, never a snapshot
// of the initial path (which may still be a relay or another VPN).
let max_datagram = MAX_DATA_DATAGRAM.min(peer_limit);
Self {
network,
peer,
conn,
max_datagram,
next_packet: AtomicU64::new(0),
reassembly: tokio::sync::Mutex::new(Reassembler::default()),
_send: tokio::sync::Mutex::new(send),
_recv: tokio::sync::Mutex::new(recv),
}
@@ -110,17 +116,65 @@ impl PacketLink for IrohLink {
limit: self.max_datagram,
});
}
self.conn.send_datagram(payload).map_err(|err| {
use iroh::endpoint::SendDatagramError;
match err {
SendDatagramError::ConnectionLost(_) => TransportError::Closed,
other => TransportError::Other(other.to_string()),
let id = self.next_packet.fetch_add(1, Ordering::Relaxed);
let mut offset: usize = 0;
let mut attempts = 0;
// Re-read the current path's capacity for every fragment. A migration
// can shrink it even between two send_datagram calls.
for _ in 0..MAX_DATA_FRAGMENTS {
loop {
let capacity = self
.conn
.max_datagram_size()
.unwrap_or(0)
.checked_sub(fragments::HEADER)
.filter(|n| *n > 0)
.ok_or_else(|| {
TransportError::Other("QUIC path cannot carry data fragments".into())
})?;
let end = offset.saturating_add(capacity).min(payload.len());
let frame = fragments::encode(id, payload.len(), offset, &payload[offset..end]);
match self.conn.send_datagram(frame) {
Ok(()) => {
offset = end;
break;
}
Err(iroh::endpoint::SendDatagramError::TooLarge) if attempts < 3 => {
attempts += 1;
}
Err(iroh::endpoint::SendDatagramError::ConnectionLost(_)) => {
return Err(TransportError::Closed);
}
Err(err) => return Err(TransportError::Other(err.to_string())),
}
}
})
if offset == payload.len() {
return Ok(());
}
}
Err(TransportError::Other(
"QUIC path needs too many fragments".into(),
))
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move { self.conn.read_datagram().await.ok() })
Box::pin(async move {
let mut reassembly = self.reassembly.lock().await;
loop {
match tokio::time::timeout(DATA_REASSEMBLY_TTL, self.conn.read_datagram()).await {
Ok(Ok(frame)) => {
if let Some(packet) = reassembly.push(frame, tokio::time::Instant::now()) {
return Some(packet);
}
}
Ok(Err(_)) => {
*reassembly = Reassembler::default();
return None;
}
Err(_) => reassembly.expire(tokio::time::Instant::now()),
}
}
})
}
fn closed(&self) -> BoxFuture<'_, ()> {
@@ -208,10 +262,14 @@ impl IrohTransport {
}
let serves = self.lookup.serves(outcome.network_id, &open.protocol).await;
let max_datagram = conn.max_datagram_size().unwrap_or(0);
if open.max_datagram == 0 || conn.max_datagram_size().is_none() {
return Err(TransportError::Other(
"data channel has no datagram support".into(),
));
}
let ack = DataOpenAck {
accepted: serves,
max_datagram: max_datagram as u32,
max_datagram: MAX_DATA_DATAGRAM as u32,
};
write_frame(
&mut send,
@@ -226,7 +284,14 @@ impl IrohTransport {
return Err(TransportError::Declined(open.protocol));
}
let link = IrohLink::new(outcome.network_id, peer, conn, usize::MAX, send, recv);
let link = IrohLink::new(
outcome.network_id,
peer,
conn,
open.max_datagram as usize,
send,
recv,
);
Ok(InboundLink {
network: outcome.network_id,
peer,
@@ -289,6 +354,7 @@ impl PacketTransport for IrohTransport {
let open = DataOpen {
protocol: protocol.to_string(),
max_datagram: MAX_DATA_DATAGRAM as u32,
};
write_frame(
&mut send,
@@ -310,6 +376,12 @@ impl PacketTransport for IrohTransport {
return Err(TransportError::Declined(protocol.to_string()));
}
if ack.max_datagram == 0 || conn.max_datagram_size().is_none() {
return Err(TransportError::Other(
"data channel has no datagram support".into(),
));
}
let link = IrohLink::new(network, peer, conn, ack.max_datagram as usize, send, recv);
Ok(Arc::new(link) as SharedLink)
})
@@ -29,8 +29,11 @@
//! encrypted by the transport, and scoped to exactly one network, one peer and
//! one plugin protocol.
mod fragments;
pub mod iroh_link;
use std::sync::Arc;
use bytes::Bytes;
use iroh::EndpointId;
@@ -77,8 +80,8 @@ pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
/// The largest datagram this link can carry, in bytes.
///
/// A plugin must size its own packets to fit, because there is no
/// fragmentation here.
/// This is the logical payload limit. A transport can fragment underneath
/// it so path MTU changes do not force a plugin to resize the host interface.
fn max_datagram_size(&self) -> usize;
/// Sends one datagram.
@@ -87,6 +90,16 @@ pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
/// handed to the transport, nothing more.
fn send(&self, payload: Bytes) -> Result<(), TransportError>;
/// Sends a datagram with an opaque stable flow identifier. Plugins derive
/// this before encryption; transports that do not route can ignore it.
fn send_flow(
&self,
payload: Bytes,
_flow: super::routing::FlowId,
) -> Result<(), TransportError> {
self.send(payload)
}
/// Receives the next datagram, or `None` once the link is finished.
fn recv(&self) -> BoxFuture<'_, Option<Bytes>>;
@@ -144,3 +157,51 @@ pub trait PacketTransport: Send + Sync + std::fmt::Debug + 'static {
protocol: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>>;
}
/// A composite packet transport that dispatches requests by protocol id.
#[derive(Debug)]
pub struct MultiPacketTransport {
default: Arc<dyn PacketTransport>,
by_protocol: std::collections::HashMap<String, Arc<dyn PacketTransport>>,
}
impl MultiPacketTransport {
/// Creates a new composite transport with a default fallback transport.
pub fn new(
default: Arc<dyn PacketTransport>,
by_protocol: std::collections::HashMap<String, Arc<dyn PacketTransport>>,
) -> Self {
Self {
default,
by_protocol,
}
}
/// Access the default underlying transport.
pub fn default_transport(&self) -> &Arc<dyn PacketTransport> {
&self.default
}
}
impl PacketTransport for MultiPacketTransport {
fn name(&self) -> &str {
"multi"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn open<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
protocol: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
if let Some(transport) = self.by_protocol.get(protocol) {
transport.open(network, peer, protocol)
} else {
self.default.open(network, peer, protocol)
}
}
}
+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(())
})
}
}
+516
View File
@@ -0,0 +1,516 @@
//! Mainline BEP44 rendezvous. Slots are a changing sample, not a membership list.
use ::mainline::{Dht, MutableItem, SigningKey, async_dht::AsyncDht};
use futures_lite::StreamExt;
use iroh::{EndpointAddr, EndpointId};
use sha2::{Digest, Sha256};
use std::collections::{HashSet, VecDeque};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::{Mutex as AsyncMutex, mpsc};
use tokio::task::JoinSet;
use tokio::time::timeout;
use zeroize::Zeroizing;
use super::{BoxFuture, Candidate, CandidateSource, NetworkDiscovery};
use crate::config::{
DHT_CLOCK_SKEW, DHT_MAX_ADDRS, DHT_MAX_RELAY_LEN, DHT_MAX_VALUE, DHT_RECORD_TTL, DHT_SLOTS,
};
use crate::error::{Error, Result};
use crate::identity::{DiscoveryKey, NetworkKeys};
const QUERY_TIMEOUT: Duration = Duration::from_secs(8);
const MAGIC: &[u8; 5] = b"TSND\x01";
/// One lazily started Mainline client, shared by all networks on one agent.
/// No socket is opened until an active network publishes or looks up candidates.
/// Agent shutdown closes this client and all its clones; a new agent needs a new client.
#[derive(Debug, Clone, Default)]
pub struct MainlineDiscovery {
client: Arc<AsyncMutex<ClientState>>,
allow_loopback: bool,
}
#[derive(Debug, Default)]
enum ClientState {
#[default]
Pending,
Ready(AsyncDht),
Stopped,
}
impl MainlineDiscovery {
/// Uses a caller-supplied Mainline node instead of public bootstrap defaults.
pub fn from_dht(dht: Dht) -> Self {
Self {
client: Arc::new(AsyncMutex::new(ClientState::Ready(dht.as_async()))),
allow_loopback: false,
}
}
/// Creates a loopback-only client for a local Mainline Testnet.
#[cfg(feature = "testing")]
pub fn local_testnet(bootstrap: &[String]) -> Result<Self> {
// Reject anything that could resolve or send outside loopback.
if bootstrap.is_empty()
|| bootstrap.iter().any(|s| {
s.parse::<SocketAddr>()
.map_or(true, |a| !a.ip().is_loopback())
})
{
return Err(Error::Discovery(
"test bootstrap must contain loopback sockets".into(),
));
}
let dht = Dht::builder()
.bootstrap(bootstrap)
.bind_address(Ipv4Addr::LOCALHOST)
.port(0)
.request_timeout(Duration::from_millis(200))
.build()
.map_err(|_| Error::Discovery("cannot bind local DHT".into()))?;
Ok(Self {
allow_loopback: true,
..Self::from_dht(dht)
})
}
/// Binds this client's rendezvous backend to one network's independent keys.
pub fn for_network(&self, keys: &NetworkKeys) -> Arc<dyn NetworkDiscovery> {
Arc::new(NetworkDht {
client: self.clone(),
key: keys.discovery_key(),
seed: Zeroizing::new(*keys.dht_write_key()),
observed: Mutex::new(VecDeque::new()),
})
}
async fn client(&self) -> Result<AsyncDht> {
let mut client = self.client.lock().await;
match &*client {
ClientState::Ready(dht) => return Ok(dht.clone()),
ClientState::Stopped => return Err(Error::Discovery("DHT client is stopped".into())),
ClientState::Pending => {}
}
// Construction may resolve bootstrap hostnames. Keep it off Tokio's
// executor; a construction failure is retried on the next operation.
let dht = tokio::task::spawn_blocking(|| Dht::builder().port(0).build())
.await
.map_err(|_| Error::Discovery("DHT startup task failed".into()))?
.map(Dht::as_async)
.map_err(|_| Error::Discovery("cannot start DHT client".into()))?;
*client = ClientState::Ready(dht.clone());
Ok(dht)
}
pub(crate) async fn shutdown(&self) {
*self.client.lock().await = ClientState::Stopped;
}
}
struct NetworkDht {
client: MainlineDiscovery,
key: DiscoveryKey,
seed: Zeroizing<[u8; 32]>,
// Records encountered while publishing. Only an explicit lookup consumes
// these hints: publication never causes dials on a connected network.
observed: Mutex<VecDeque<Vec<u8>>>,
}
impl std::fmt::Debug for NetworkDht {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("NetworkDht(<redacted>)")
}
}
fn salt(slot: u8) -> Vec<u8> {
let mut salt = b"tsunagi-rendezvous-v1".to_vec();
salt.push(slot);
salt
}
fn slots_for(id: EndpointId) -> [u8; 2] {
let hash = Sha256::digest(id.as_bytes());
let first = hash[0] % DHT_SLOTS;
[first, (first + 1 + hash[1] % (DHT_SLOTS - 1)) % DHT_SLOTS]
}
fn now() -> Result<u64> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.map_err(|_| Error::Discovery("system clock predates Unix epoch".into()))
}
impl NetworkDht {
async fn publish_slot(&self, dht: &AsyncDht, slot: u8, value: &[u8]) -> Result<()> {
let salt = salt(slot);
let signer = SigningKey::from_bytes(&self.seed);
let public = signer.verifying_key().to_bytes();
for attempt in 0..3 {
let recent = timeout(
QUERY_TIMEOUT,
dht.get_mutable_most_recent(&public, Some(&salt)),
)
.await
.map_err(|_| Error::Discovery("DHT read timed out".into()))?;
if let Some(item) = &recent
&& item.value().len() <= DHT_MAX_VALUE
&& let Ok(mut observed) = self.observed.lock()
{
if observed.len() == DHT_SLOTS as usize {
observed.pop_front();
}
observed.push_back(item.value().to_vec());
}
let seq = recent
.as_ref()
.map_or(0, MutableItem::seq)
.checked_add(1)
.ok_or_else(|| Error::Discovery("DHT sequence exhausted".into()))?;
let item = MutableItem::new(signer.clone(), value, seq, Some(&salt));
match timeout(
QUERY_TIMEOUT,
dht.put_mutable(item, recent.as_ref().map(MutableItem::seq)),
)
.await
{
Ok(Ok(outcome)) if outcome.stored_at > 0 => return Ok(()),
_ if attempt < 2 => {
// CAS is per storage node, not a global lock. Read again on
// conflicts; another writer's entry is an acceptable sample.
tokio::time::sleep(Duration::from_millis(rand::random_range(30..150))).await;
}
_ => break,
}
}
Err(Error::Discovery(
"DHT publication was not acknowledged".into(),
))
}
}
impl NetworkDiscovery for NetworkDht {
fn name(&self) -> &str {
"mainline"
}
fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
if key != self.key {
return Err(Error::Discovery("wrong rendezvous network".into()));
}
let value = encode_record(&addr, now()?, self.client.allow_loopback)?;
let dht = self.client.client().await?;
let [first, second] = slots_for(addr.id);
let (a, b) = tokio::join!(
self.publish_slot(&dht, first, &value),
self.publish_slot(&dht, second, &value)
);
a.and(b)
})
}
fn unpublish<'a>(&'a self, _: DiscoveryKey, _: EndpointId) -> BoxFuture<'a, Result<()>> {
// Stopping publication lets freshness expire. Deleting a shared slot
// could erase a different writer, and BEP44 has no delete operation.
Box::pin(async { Ok(()) })
}
fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
Box::pin(async move {
let (tx, mut rx) = mpsc::channel(DHT_SLOTS as usize);
let collect = async move {
let mut found = Vec::new();
while let Some(candidate) = rx.recv().await {
found.push(candidate);
}
found
};
let (result, found) = tokio::join!(self.resolve_into(key, tx), collect);
result?;
Ok(found)
})
}
fn resolve_into<'a>(
&'a self,
key: DiscoveryKey,
candidates: mpsc::Sender<Candidate>,
) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
if key != self.key {
return Err(Error::Discovery("wrong rendezvous network".into()));
}
let dht = self.client.client().await?;
let public = SigningKey::from_bytes(&self.seed)
.verifying_key()
.to_bytes();
let observed = self
.observed
.lock()
.map(|mut values| std::mem::take(&mut *values))
.unwrap_or_default();
let mut seen = HashSet::new();
for value in observed {
if let Some(addr) = decode_record(&value, now()?, self.client.allow_loopback)
&& seen.insert(addr.id)
&& candidates
.send(Candidate::new(addr, CandidateSource::Discovery))
.await
.is_err()
{
return Ok(());
}
}
let mut slots: Vec<_> = (0..DHT_SLOTS).collect();
for i in (1..slots.len()).rev() {
slots.swap(i, rand::random_range(0..=i));
}
let mut queries = JoinSet::new();
loop {
if seen.len() >= DHT_SLOTS as usize {
break;
}
while queries.len() < 4 {
let Some(slot) = slots.pop() else {
break;
};
let dht = dht.clone();
let allow_loopback = self.client.allow_loopback;
queries.spawn(async move {
timeout(QUERY_TIMEOUT, async move {
let salt = salt(slot);
let target = MutableItem::target_from_key(&public, Some(&salt));
let mut items = dht.get_mutable(&public, Some(&salt), None);
while let Some(item) = items.next().await {
// Mainline verifies the signature; also bind the
// returned item to the key and slot we requested.
if item.key() != &public || item.target() != &target {
continue;
}
if let Some(addr) =
decode_record(item.value(), now().ok()?, allow_loopback)
{
return Some(addr);
}
}
None
})
.await
.ok()
.flatten()
});
}
let Some(result) = queries.join_next().await else {
break;
};
if let Ok(Some(addr)) = result
&& seen.insert(addr.id)
&& candidates
.send(Candidate::new(addr, CandidateSource::Discovery))
.await
.is_err()
{
break;
}
}
Ok(())
})
}
}
fn usable(addr: &SocketAddr, allow_loopback: bool) -> bool {
addr.port() != 0
&& !addr.ip().is_unspecified()
&& !addr.ip().is_multicast()
&& (allow_loopback || !addr.ip().is_loopback())
&& match addr.ip() {
IpAddr::V4(ip) => !ip.is_broadcast() && !ip.is_link_local(),
IpAddr::V6(ip) => !ip.is_unicast_link_local(),
}
}
fn encode_record(addr: &EndpointAddr, timestamp: u64, allow_loopback: bool) -> Result<Vec<u8>> {
let ips: Vec<_> = addr
.ip_addrs()
.filter(|ip| usable(ip, allow_loopback))
.take(DHT_MAX_ADDRS)
.collect();
let relay = addr
.relay_urls()
.next()
.map(ToString::to_string)
.unwrap_or_default();
if relay.len() > DHT_MAX_RELAY_LEN || (ips.is_empty() && relay.is_empty()) {
return Err(Error::Discovery("no encodable endpoint address yet".into()));
}
let mut bytes = Vec::with_capacity(DHT_MAX_VALUE);
bytes.extend_from_slice(MAGIC);
bytes.extend_from_slice(&timestamp.to_be_bytes());
bytes.extend_from_slice(addr.id.as_bytes());
bytes.extend_from_slice(&(relay.len() as u16).to_be_bytes());
bytes.extend_from_slice(relay.as_bytes());
bytes.push(ips.len() as u8);
for ip in ips {
match ip.ip() {
IpAddr::V4(v) => {
bytes.push(4);
bytes.extend_from_slice(&v.octets());
}
IpAddr::V6(v) => {
bytes.push(6);
bytes.extend_from_slice(&v.octets());
}
}
bytes.extend_from_slice(&ip.port().to_be_bytes());
}
if bytes.len() > DHT_MAX_VALUE {
return Err(Error::Discovery("DHT record too large".into()));
}
Ok(bytes)
}
fn take<'a>(input: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
let (head, rest) = input.split_at_checked(n)?;
*input = rest;
Some(head)
}
fn decode_record(mut bytes: &[u8], now: u64, allow_loopback: bool) -> Option<EndpointAddr> {
if bytes.len() > DHT_MAX_VALUE || take(&mut bytes, MAGIC.len())? != MAGIC {
return None;
}
let timestamp = u64::from_be_bytes(take(&mut bytes, 8)?.try_into().ok()?);
if timestamp > now.saturating_add(DHT_CLOCK_SKEW.as_secs())
|| now.saturating_sub(timestamp) >= DHT_RECORD_TTL.as_secs()
{
return None;
}
let id = EndpointId::from_bytes(take(&mut bytes, 32)?.try_into().ok()?).ok()?;
let mut addr = EndpointAddr::new(id);
let len = u16::from_be_bytes(take(&mut bytes, 2)?.try_into().ok()?) as usize;
if len > DHT_MAX_RELAY_LEN {
return None;
}
let relay = std::str::from_utf8(take(&mut bytes, len)?).ok()?;
if !relay.is_empty() {
addr = addr.with_relay_url(relay.parse().ok()?);
}
let count = *take(&mut bytes, 1)?.first()? as usize;
if count > DHT_MAX_ADDRS {
return None;
}
for _ in 0..count {
let ip = match *take(&mut bytes, 1)?.first()? {
4 => IpAddr::V4(Ipv4Addr::from(
<[u8; 4]>::try_from(take(&mut bytes, 4)?).ok()?,
)),
6 => IpAddr::V6(Ipv6Addr::from(
<[u8; 16]>::try_from(take(&mut bytes, 16)?).ok()?,
)),
_ => return None,
};
let port = u16::from_be_bytes(take(&mut bytes, 2)?.try_into().ok()?);
let socket = SocketAddr::new(ip, port);
if usable(&socket, allow_loopback) {
addr = addr.with_ip_addr(socket);
}
}
(bytes.is_empty() && !addr.addrs.is_empty()).then_some(addr)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::testing::network;
fn address() -> EndpointAddr {
EndpointAddr::new(iroh::SecretKey::generate().public())
.with_ip_addr("127.0.0.1:12345".parse().unwrap())
}
#[test]
fn records_reject_expiration_future_dates_truncation_and_oversize() {
let addr = address();
let bytes = encode_record(&addr, 1_000, true).unwrap();
assert_eq!(decode_record(&bytes, 1_100, true).unwrap(), addr);
assert!(decode_record(&bytes, 1_901, true).is_none());
assert!(decode_record(&bytes, 100, true).is_none());
for n in 0..bytes.len() {
assert!(decode_record(&bytes[..n], 1_100, true).is_none());
}
assert!(decode_record(&vec![0; DHT_MAX_VALUE + 1], 1_100, true).is_none());
assert!(encode_record(&addr, 1_000, false).is_err());
assert!(decode_record(&bytes, 1_100, false).is_none());
}
#[tokio::test]
async fn a_stopped_custom_client_never_falls_back_to_public_bootstrap() {
let net = mainline::Testnet::builder(3).build().unwrap();
let client = MainlineDiscovery::local_testnet(&net.bootstrap).unwrap();
let (name, secret) = network("mainline-shutdown");
let keys = NetworkKeys::derive(&name, &secret);
let backend = client.for_network(&keys);
client.shutdown().await;
assert!(backend.resolve(keys.discovery_key()).await.is_err());
}
#[tokio::test]
async fn concurrent_writers_share_slots_and_other_secrets_find_nothing() {
let net = mainline::Testnet::builder(5).build().unwrap();
let client_a = MainlineDiscovery::local_testnet(&net.bootstrap).unwrap();
let client_b = MainlineDiscovery::local_testnet(&net.bootstrap).unwrap();
let (name, secret) = network("mainline-slots");
let keys = NetworkKeys::derive(&name, &secret);
let a = client_a.for_network(&keys);
let b = client_b.for_network(&keys);
let first = address();
let second = loop {
let next = address();
if slots_for(next.id) == slots_for(first.id) {
break next;
}
};
let key = keys.discovery_key();
let (ra, rb) = tokio::join!(
a.publish(key, first.clone()),
b.publish(key, second.clone())
);
assert!(ra.is_ok() || rb.is_ok());
let (found_a, found_b) = tokio::join!(a.resolve(key), b.resolve(key));
let found_a = found_a.unwrap();
let found_b = found_b.unwrap();
assert!(
found_a.iter().any(|c| c.addr == second) || found_b.iter().any(|c| c.addr == first),
"at least one writer must discover the other despite colliding in both slots"
);
assert!(
found_a
.iter()
.chain(&found_b)
.all(|c| c.addr == first || c.addr == second)
);
let other = NetworkKeys::derive(&name, &crate::identity::NetworkSecret::generate());
assert!(
client_a
.for_network(&other)
.resolve(other.discovery_key())
.await
.unwrap()
.is_empty()
);
// Restarting the publisher must read seq from DHT instead of starting at 1.
let restarted = client_a.for_network(&keys);
restarted.publish(key, first.clone()).await.unwrap();
assert!(
b.resolve(key)
.await
.unwrap()
.iter()
.any(|c| c.addr == first)
);
}
}
+251
View File
@@ -0,0 +1,251 @@
//! Per-network discovery tasks. Dropping the owner cancels all backend futures.
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tokio::task::JoinSet;
use tokio::time::{Instant, timeout};
use super::{Candidate, NetworkDiscovery};
use crate::config::DiscoveryPolicy;
use crate::identity::DiscoveryKey;
use crate::net::EndpointAdapter;
pub(crate) struct DiscoveryWorker {
connected: watch::Sender<Connectivity>,
tasks: JoinSet<()>,
backend: Arc<dyn NetworkDiscovery>,
key: DiscoveryKey,
endpoint: iroh::EndpointId,
}
#[derive(Clone, Copy)]
enum Connectivity {
Initial,
Connected,
Isolated(Instant),
}
impl DiscoveryWorker {
pub(crate) fn spawn(
backend: Arc<dyn NetworkDiscovery>,
key: DiscoveryKey,
adapter: EndpointAdapter,
policy: DiscoveryPolicy,
address_check: Duration,
candidates: mpsc::Sender<Candidate>,
) -> Self {
let (connected, receiver) = watch::channel(Connectivity::Initial);
let mut tasks = JoinSet::new();
tasks.spawn(publish_loop(
backend.clone(),
key,
adapter.clone(),
policy.clone(),
address_check,
));
tasks.spawn(lookup_loop(
backend.clone(),
key,
policy,
receiver,
candidates,
));
Self {
connected,
tasks,
backend,
key,
endpoint: adapter.endpoint_id(),
}
}
pub(crate) fn set_connected(&self, connected: bool) {
self.connected.send_if_modified(|current| {
if matches!(*current, Connectivity::Connected) == connected {
return false;
}
*current = if connected {
Connectivity::Connected
} else {
Connectivity::Isolated(Instant::now())
};
true
});
}
pub(crate) async fn stop(mut self) {
self.tasks.abort_all();
while self.tasks.join_next().await.is_some() {}
// BEP44 has no deletion. Memory/static backends can withdraw promptly;
// an unresponsive backend must not extend agent shutdown.
let _ = timeout(
Duration::from_millis(250),
self.backend.unpublish(self.key, self.endpoint),
)
.await;
}
}
fn jitter(delay: Duration) -> Duration {
delay
.mul_f64(0.8 + 0.4 * rand::random::<f64>())
.max(Duration::from_millis(10))
}
async fn publish_loop(
backend: Arc<dyn NetworkDiscovery>,
key: DiscoveryKey,
adapter: EndpointAdapter,
policy: DiscoveryPolicy,
address_check: Duration,
) {
let mut previous = None;
let mut due = Instant::now();
let mut ticker = tokio::time::interval(address_check.max(Duration::from_millis(10)));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let mut addr = adapter.addr();
addr.addrs.extend(adapter.loopback_addr().addrs);
if previous.as_ref() != Some(&addr) {
previous = Some(addr.clone());
due = Instant::now();
}
if Instant::now() < due {
continue;
}
let result = timeout(policy.request_timeout, backend.publish(key, addr.clone())).await;
match result {
Ok(Ok(())) => {
due = Instant::now() + jitter(policy.publish_interval);
}
result => {
tracing::debug!(?result, "discovery publication failed; will retry");
due = Instant::now() + jitter(policy.lookup_interval);
}
}
}
}
async fn lookup_loop(
backend: Arc<dyn NetworkDiscovery>,
key: DiscoveryKey,
policy: DiscoveryPolicy,
mut connected: watch::Receiver<Connectivity>,
candidates: mpsc::Sender<Candidate>,
) {
let mut last_isolation = None;
let mut due = Instant::now();
let mut delay = policy.lookup_interval;
loop {
let state = *connected.borrow_and_update();
if matches!(state, Connectivity::Connected) {
if connected.changed().await.is_err() {
break;
}
continue;
}
if let Connectivity::Isolated(since) = state
&& last_isolation != Some(since)
{
// Preserve the transition time even if a rapid reconnect/disconnect
// overwrote a watch value before this task had a chance to run.
due = since + policy.reconnect_delay;
delay = policy.lookup_interval;
last_isolation = Some(since);
}
tokio::select! {
biased;
changed = connected.changed() => {
if changed.is_err() { break; }
continue;
}
_ = tokio::time::sleep_until(due) => {}
}
tokio::select! {
biased;
changed = connected.changed() => {
if changed.is_err() { break; }
continue;
}
result = timeout(policy.request_timeout, backend.resolve_into(key, candidates.clone())) => {
if !matches!(result, Ok(Ok(()))) {
tracing::debug!(?result, "discovery lookup failed; will retry");
}
}
}
due = Instant::now() + jitter(delay);
delay = delay.saturating_mul(2).min(policy.max_lookup_interval);
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::Result;
use crate::discovery::BoxFuture;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug, Default)]
struct Reads(AtomicUsize);
impl NetworkDiscovery for Reads {
fn name(&self) -> &str {
"reads"
}
fn publish<'a>(
&'a self,
_: DiscoveryKey,
_: iroh::EndpointAddr,
) -> BoxFuture<'a, Result<()>> {
Box::pin(async { Ok(()) })
}
fn unpublish<'a>(
&'a self,
_: DiscoveryKey,
_: iroh::EndpointId,
) -> BoxFuture<'a, Result<()>> {
Box::pin(async { Ok(()) })
}
fn resolve<'a>(&'a self, _: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
self.0.fetch_add(1, Ordering::SeqCst);
Box::pin(async { Ok(Vec::new()) })
}
}
#[tokio::test(start_paused = true)]
async fn recovery_requires_a_full_minute_and_reconnection_restarts_that_minute() {
let reads = Arc::new(Reads::default());
let (tx, rx) = watch::channel(Connectivity::Connected);
let (candidates, _receiver) = mpsc::channel(16);
let mut tasks = JoinSet::new();
tasks.spawn(lookup_loop(
reads.clone(),
DiscoveryKey::from_bytes([0; 32]),
DiscoveryPolicy::default(),
rx,
candidates,
));
tokio::task::yield_now().await;
tx.send(Connectivity::Isolated(Instant::now())).unwrap();
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(59)).await;
tokio::task::yield_now().await;
assert_eq!(reads.0.load(Ordering::SeqCst), 0);
tx.send(Connectivity::Connected).unwrap();
tx.send(Connectivity::Isolated(Instant::now())).unwrap();
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(59)).await;
tokio::task::yield_now().await;
assert_eq!(reads.0.load(Ordering::SeqCst), 0);
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
assert_eq!(reads.0.load(Ordering::SeqCst), 1);
tx.send(Connectivity::Connected).unwrap();
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(600)).await;
tokio::task::yield_now().await;
assert_eq!(reads.0.load(Ordering::SeqCst), 1);
}
}
+84
View File
@@ -0,0 +1,84 @@
//! A DNS view of the overlay.
//!
//! The names and addresses of a network, served to the host that runs the
//! agent, so members can be reached by name. Answered from signed state, so a
//! member that is switched off still resolves.
//!
//! Three parts, kept apart on purpose:
//!
//! * [`zone`] decides what the answer is. Pure, and knows nothing about
//! packets or sockets.
//! * [`server`] puts that on the wire.
//! * `publish` tells the operating system where to send its questions,
//! which is the only part that differs between platforms.
pub mod publish;
pub mod server;
pub mod zone;
pub use publish::{DnsPublisher, PublishError, Published};
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
/// Where the server listens, one list per address family.
///
/// Loopback, and only loopback. The zones are a view *for the host running
/// the agent*: it is that host's resolver that is pointed at them. Binding
/// an overlay address instead would put them in front of the whole mesh —
/// and an agent in several networks would then answer one network's
/// questions about another's names, which is precisely what separate
/// networks are for.
///
/// Both families, and independently: a question arrives over whichever one
/// the resolver happens to use, and on a host with one of them switched off
/// the other must still answer. So neither list failing says anything about
/// the other, and a listener is opened from each.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListenPlan {
/// IPv4 candidates, best first.
pub v4: Vec<SocketAddr>,
/// IPv6 candidates, best first.
pub v6: Vec<SocketAddr>,
}
impl ListenPlan {
/// The two lists, so a caller can bind one listener from each.
pub fn families(&self) -> [&[SocketAddr]; 2] {
[&self.v4, &self.v6]
}
}
/// Builds the listen plan for one port.
pub fn listen_plan(port: u16) -> ListenPlan {
ListenPlan {
v4: vec![SocketAddr::from((Ipv4Addr::LOCALHOST, port))],
v6: vec![SocketAddr::from((Ipv6Addr::LOCALHOST, port))],
}
}
pub use server::{DnsServer, SharedZone};
pub use zone::{Answer, Query, Zone, ZoneError, ZoneName, Zones};
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn both_families_are_planned_for_and_neither_is_reachable_off_this_host() {
let plan = listen_plan(5354);
assert_eq!(
plan.v4,
vec!["127.0.0.1:5354".parse::<SocketAddr>().unwrap()]
);
assert_eq!(plan.v6, vec!["[::1]:5354".parse::<SocketAddr>().unwrap()]);
assert_eq!(plan.families().len(), 2);
// Nothing here is reachable from another member: the zones are a
// view for this host, and an agent in two networks must not answer
// one network's questions about the other's names.
for address in plan.families().concat() {
assert!(address.ip().is_loopback(), "{address} is not loopback");
}
}
}
+333
View File
@@ -0,0 +1,333 @@
//! Telling the macOS resolver to send some questions here, through
//! `/etc/resolver`.
//!
//! macOS reads one file per domain out of `/etc/resolver`: a file named after
//! a domain, containing the `nameserver` and `port` to send that domain's
//! questions to, makes the system resolver a *scoped* resolver for exactly
//! that suffix and nothing else (see `resolver(5)`). That is the same contract
//! the systemd-resolved and NRPT publishers arrange on the other platforms:
//! route these suffixes to this server, and claim nothing else.
//!
//! # Why a port, and so no privileged socket
//!
//! A resolver file carries a `port`, so the server does not have to sit on 53
//! and the agent needs nothing like `CAP_NET_BIND_SERVICE`. It can serve on a
//! high port and still be reached for its suffixes.
//!
//! # Tagged, reversible, and never clobbering
//!
//! Every file this publisher writes begins with a marker line, so it only ever
//! replaces or removes files it wrote itself: a pre-existing `/etc/resolver`
//! entry a user set up by hand is left untouched. The files are removed when
//! the resolver is turned off and on shutdown; a crash leaves them behind, and
//! the next apply for the same domains overwrites them.
//!
//! # Privilege
//!
//! Writing under `/etc/resolver` needs root. A permission error is reported as
//! a refusal — the kind of failure a person has to act on — rather than a
//! transient one, so the agent does not retry it at the ordinary pace.
use std::collections::BTreeSet;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::BoxFuture;
use super::{DnsPublisher, PublishError, Published};
/// The directory macOS reads scoped resolvers from.
const RESOLVER_DIR: &str = "/etc/resolver";
/// The first line of every file this publisher writes, so it recognises its
/// own and never touches a file a user created.
const MARKER: &str = "# managed by tsunagi — do not edit";
/// Writes and removes `/etc/resolver` files for the overlay zones.
#[derive(Debug)]
pub struct ResolverDirPublisher {
/// The resolver directory. A field rather than a constant so the tests can
/// point it at a temporary directory and never touch the real one.
dir: PathBuf,
/// The domain files written by the last apply, so a later apply can remove
/// the ones no longer wanted and `revert` can remove them all.
written: Mutex<BTreeSet<String>>,
}
impl Default for ResolverDirPublisher {
fn default() -> Self {
Self::new()
}
}
impl ResolverDirPublisher {
/// A publisher writing to the real `/etc/resolver`.
pub fn new() -> Self {
Self::in_dir(RESOLVER_DIR)
}
/// A publisher writing to a specific directory. For the tests.
fn in_dir(dir: impl Into<PathBuf>) -> Self {
Self {
dir: dir.into(),
written: Mutex::new(BTreeSet::new()),
}
}
fn apply_inner(&self, published: &Published) -> Result<(), PublishError> {
let desired = desired_files(published)?;
// The directory may not exist yet; creating it needs root.
std::fs::create_dir_all(&self.dir).map_err(|err| classify(&self.dir, err))?;
// Remove the files from a previous apply that are no longer wanted.
let previous = lock(&self.written).clone();
for name in previous.difference(&keys(&desired)) {
remove_if_ours(&self.dir.join(name));
}
for (name, body) in &desired {
let path = self.dir.join(name);
// Only ever overwrite a file that is already ours, or a name that
// is free. A resolver a user set up by hand is left in place and
// reported, never clobbered.
if path.exists() && !is_ours(&path) {
return Err(PublishError::Failed(format!(
"{} already exists and was not created by tsunagi; \
remove it or rename the network to publish its resolver",
path.display()
)));
}
std::fs::write(&path, body).map_err(|err| classify(&path, err))?;
}
*lock(&self.written) = keys(&desired);
Ok(())
}
fn revert_inner(&self) {
let written = std::mem::take(&mut *lock(&self.written));
for name in &written {
remove_if_ours(&self.dir.join(name));
}
}
}
impl DnsPublisher for ResolverDirPublisher {
fn name(&self) -> &str {
"resolver-dir"
}
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
// A handful of small file writes, short enough to run in place rather
// than hop to the blocking pool.
Box::pin(async move { self.apply_inner(published) })
}
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
Box::pin(async move {
self.revert_inner();
Ok(())
})
}
}
/// The resolver files a publication implies: a map of file name to contents.
fn desired_files(published: &Published) -> Result<Vec<(String, String)>, PublishError> {
if published.servers.is_empty() {
return Err(PublishError::Failed(
"no server address to point the resolver at".to_string(),
));
}
let body = file_contents(published);
let mut files = Vec::new();
for domain in &published.domains {
let name = resolver_file_name(domain)?;
files.push((name, body.clone()));
}
Ok(files)
}
/// The contents of one resolver file: the marker, every server, one port.
fn file_contents(published: &Published) -> String {
let mut out = String::from(MARKER);
out.push('\n');
for server in &published.servers {
let ip = match server.ip() {
IpAddr::V4(v4) => v4.to_string(),
IpAddr::V6(v6) => v6.to_string(),
};
out.push_str("nameserver ");
out.push_str(&ip);
out.push('\n');
}
// One port for the file; every server listens on the same one. The `port`
// directive is what lets the server stay off 53.
if let Some(first) = published.servers.first() {
out.push_str("port ");
out.push_str(&first.port().to_string());
out.push('\n');
}
out
}
/// The file name for a domain, rejecting anything that is not a plain label
/// path component: a resolver file name must never escape the directory.
fn resolver_file_name(domain: &str) -> Result<String, PublishError> {
let trimmed = domain.trim_end_matches('.');
if trimmed.is_empty()
|| trimmed.contains('/')
|| trimmed.contains('\\')
|| trimmed.contains(std::path::MAIN_SEPARATOR)
|| trimmed == "."
|| trimmed == ".."
|| trimmed.starts_with('.')
{
return Err(PublishError::Failed(format!(
"cannot make a resolver file name for the domain {domain:?}"
)));
}
Ok(trimmed.to_string())
}
/// Whether a file is one this publisher wrote, by its marker first line.
fn is_ours(path: &Path) -> bool {
match std::fs::read_to_string(path) {
Ok(text) => text.lines().next() == Some(MARKER),
Err(_) => false,
}
}
/// Removes a file, but only if it is one of ours. Best effort.
fn remove_if_ours(path: &Path) {
if is_ours(path)
&& let Err(err) = std::fs::remove_file(path)
&& err.kind() != std::io::ErrorKind::NotFound
{
tracing::debug!(path = %path.display(), %err, "cannot remove the resolver file");
}
}
/// The set of file names in a desired-files list.
fn keys(files: &[(String, String)]) -> BTreeSet<String> {
files.iter().map(|(name, _)| name.clone()).collect()
}
/// Turns a filesystem error into the right kind of publish error: a permission
/// problem is a refusal a person must act on, anything else is a plain failure.
fn classify(path: &Path, err: std::io::Error) -> PublishError {
if err.kind() == std::io::ErrorKind::PermissionDenied {
PublishError::Refused(format!(
"writing {} needs root; start the agent with `sudo` or as a root LaunchDaemon",
path.display()
))
} else {
PublishError::Failed(format!("cannot write {}: {err}", path.display()))
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::SocketAddr;
use super::*;
fn published(domains: &[&str]) -> Published {
Published {
interface: "utun5".into(),
servers: vec![
"10.13.37.1:5353".parse::<SocketAddr>().unwrap(),
"[fd00::1]:5353".parse::<SocketAddr>().unwrap(),
],
domains: domains.iter().map(|d| d.to_string()).collect(),
}
}
#[test]
fn a_file_lists_every_server_and_one_port_under_the_marker() {
let body = file_contents(&published(&["mynet."]));
let mut lines = body.lines();
assert_eq!(lines.next(), Some(MARKER));
assert_eq!(lines.next(), Some("nameserver 10.13.37.1"));
assert_eq!(lines.next(), Some("nameserver fd00::1"));
assert_eq!(lines.next(), Some("port 5353"));
assert_eq!(lines.next(), None);
}
#[test]
fn a_domain_becomes_a_plain_file_name_without_its_trailing_dot() {
assert_eq!(resolver_file_name("mynet.").unwrap(), "mynet");
assert_eq!(resolver_file_name("my.net").unwrap(), "my.net");
}
#[test]
fn a_domain_that_would_escape_the_directory_is_refused() {
assert!(resolver_file_name("../etc/passwd").is_err());
assert!(resolver_file_name("a/b").is_err());
assert!(resolver_file_name("").is_err());
assert!(resolver_file_name(".").is_err());
assert!(resolver_file_name(".hidden").is_err());
}
#[tokio::test]
async fn apply_writes_a_file_per_domain_and_revert_removes_them() {
let dir = tempfile::tempdir().unwrap();
let publisher = ResolverDirPublisher::in_dir(dir.path());
publisher
.apply(&published(&["mynet.", "other."]))
.await
.unwrap();
assert!(dir.path().join("mynet").is_file());
assert!(dir.path().join("other").is_file());
publisher.revert().await.unwrap();
assert!(!dir.path().join("mynet").exists());
assert!(!dir.path().join("other").exists());
}
#[tokio::test]
async fn re_applying_removes_a_domain_that_is_no_longer_wanted() {
let dir = tempfile::tempdir().unwrap();
let publisher = ResolverDirPublisher::in_dir(dir.path());
publisher.apply(&published(&["a.", "b."])).await.unwrap();
publisher.apply(&published(&["a."])).await.unwrap();
assert!(dir.path().join("a").is_file());
assert!(
!dir.path().join("b").exists(),
"the dropped domain's file is removed"
);
}
#[tokio::test]
async fn a_file_a_user_created_is_never_clobbered_or_removed() {
let dir = tempfile::tempdir().unwrap();
let foreign = dir.path().join("mynet");
std::fs::write(&foreign, "nameserver 9.9.9.9\n").unwrap();
let publisher = ResolverDirPublisher::in_dir(dir.path());
let err = publisher.apply(&published(&["mynet."])).await.unwrap_err();
assert!(matches!(err, PublishError::Failed(_)), "{err:?}");
// Untouched.
assert_eq!(
std::fs::read_to_string(&foreign).unwrap(),
"nameserver 9.9.9.9\n"
);
// And revert leaves a foreign file alone even if it is in our set.
publisher.revert().await.unwrap();
assert!(foreign.is_file());
}
}
@@ -74,7 +74,10 @@ mod tests {
fn published() -> Published {
Published {
interface: "tsundemo".into(),
server: "10.13.37.69:5354".parse().unwrap(),
servers: vec![
"10.13.37.69:5354".parse().unwrap(),
"[::1]:5354".parse().unwrap(),
],
domains: vec!["lab".into()],
}
}
@@ -23,6 +23,16 @@ 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;
@@ -32,10 +42,16 @@ 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.
/// 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,
/// Where the server is listening.
pub server: SocketAddr,
/// 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
@@ -62,20 +78,54 @@ pub enum PublishError {
}
impl PublishError {
/// What the user can do about it, when there is something.
/// 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(
"systemd-resolved asks polkit before accepting this, and polkit \
decides by user. Run the agent as a system service, or install a \
polkit rule allowing this user the `org.freedesktop.resolve1.set-*` \
actions.",
"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.
@@ -103,6 +153,10 @@ pub fn interface_index(name: &str) -> Option<u32> {
.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
@@ -122,6 +176,36 @@ mod tests {
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() {
@@ -135,36 +135,53 @@ impl DnsPublisher for ResolvedPublisher {
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;
let (family, octets) = wire_address(published.server.ip());
let port = published.server.port();
// 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, &[(family, octets.clone(), port, String::new())])
.await
{
match proxy.set_link_dns_ex(index, &servers).await {
Ok(()) => {}
Err(err) => {
let classified = classify(err, "setting the link's DNS server");
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 port != 53 {
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 \
{port}. Run the server on port 53, or point your resolver at \
{} yourself.",
published.server
{}. 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, &[(family, octets)])
.set_link_dns(index, &plain)
.await
.map_err(|err| classify(err, "setting the link's DNS server"))?;
.map_err(|err| classify(err, "setting the link's DNS servers"))?;
}
}
@@ -243,7 +260,7 @@ mod tests {
let publisher = ResolvedPublisher::new();
let published = Published {
interface: "tsunagi-no-such-interface".into(),
server: SocketAddr::from(([10, 13, 37, 69], 5354)),
servers: vec![SocketAddr::from(([10, 13, 37, 69], 5354))],
domains: vec!["lab".into()],
};
let err = publisher.apply(&published).await.unwrap_err();
@@ -62,7 +62,7 @@ mod tests {
let publisher = UnsupportedPublisher::new();
let published = Published {
interface: "tsundemo".into(),
server: "10.0.0.1:5354".parse().unwrap(),
servers: vec!["10.0.0.1:5354".parse().unwrap()],
domains: vec!["lab".into()],
};
let err = publisher.apply(&published).await.unwrap_err();
+301
View File
@@ -0,0 +1,301 @@
//! Telling the Windows DNS client to send some questions here, through the
//! Name Resolution Policy Table.
//!
//! The NRPT is how Windows does split DNS: a rule says "names under this
//! suffix are resolved by these servers", and names outside every rule are
//! resolved the ordinary way. That is exactly the contract of [`Published`] —
//! route these suffixes here and claim nothing else — so one NRPT rule per
//! suffix is the whole of it. The rules are keyed by namespace, not by
//! interface, so the interface name in [`Published`] is not needed here.
//!
//! # Why PowerShell
//!
//! The NRPT lives in the registry, but a rule only takes effect once the DNS
//! client is told to reload its policy, and that notification is an RPC with
//! no safe wrapper this crate may call — it forbids `unsafe`. The
//! `DnsClient` PowerShell module does the write *and* the reload, so it is
//! used by absolute path from `%SystemRoot%`. Every value passed to it is one
//! this agent produced: the suffixes are validated zone names (letters,
//! digits, `-`, `_`, `.` only) and the servers are addresses it is listening
//! on. They are still single-quoted and escaped when built into the script,
//! so nothing could be read as PowerShell rather than as data.
//!
//! # The port
//!
//! The Windows DNS client always asks on port 53; it cannot be told another.
//! So a server on a different port cannot be reached this way, and rather than
//! configure a rule that points at nothing, this says so — the same honest
//! failure the systemd-resolved path gives for an old resolver with no port.
//!
//! # Privilege
//!
//! Writing an NRPT rule needs an elevated process. An ordinary user is
//! refused, which is reported as its own kind of error because the answer to
//! it — run elevated, or as a service — differs from "this is not Windows".
use std::collections::BTreeSet;
use std::net::IpAddr;
use std::sync::Mutex;
use crate::BoxFuture;
use super::{DnsPublisher, PublishError, Published};
/// The port the Windows DNS client is fixed to.
const DNS_PORT: u16 = 53;
/// Configures the Name Resolution Policy Table through PowerShell.
#[derive(Debug, Default)]
pub struct NrptPublisher {
/// The namespaces last configured, so shutdown knows what to undo.
applied: Mutex<Vec<String>>,
}
impl NrptPublisher {
/// Creates the publisher. Nothing is contacted until [`Self::apply`].
pub fn new() -> Self {
Self::default()
}
}
/// A suffix as the NRPT wants it: a leading dot means "this and everything
/// under it".
fn namespace(domain: &str) -> String {
let trimmed = domain.trim_matches('.');
format!(".{trimmed}")
}
/// A PowerShell single-quoted literal, with any embedded quote doubled.
fn ps_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
/// A PowerShell array literal, `@('a','b')`, or `@()` when empty.
fn ps_array(values: &[String]) -> String {
let items: Vec<String> = values.iter().map(|value| ps_literal(value)).collect();
format!("@({})", items.join(","))
}
/// The script that clears our rules for these namespaces and then adds them.
fn apply_script(namespaces: &[String], servers: &[String]) -> String {
format!(
"$ErrorActionPreference = 'Stop'\n\
$ns = {ns}\n\
$srv = {srv}\n\
foreach ($n in $ns) {{ Get-DnsClientNrptRule | Where-Object {{ $_.Namespace -eq $n }} | \
ForEach-Object {{ Remove-DnsClientNrptRule -Name $_.Name -Force }} }}\n\
foreach ($n in $ns) {{ Add-DnsClientNrptRule -Namespace $n -NameServers $srv }}\n",
ns = ps_array(namespaces),
srv = ps_array(servers),
)
}
/// The script that clears our rules for these namespaces.
fn revert_script(namespaces: &[String]) -> String {
format!(
"$ErrorActionPreference = 'Stop'\n\
$ns = {ns}\n\
foreach ($n in $ns) {{ Get-DnsClientNrptRule | Where-Object {{ $_.Namespace -eq $n }} | \
ForEach-Object {{ Remove-DnsClientNrptRule -Name $_.Name -Force }} }}\n",
ns = ps_array(namespaces),
)
}
/// Turns what PowerShell said on failure into the kind of failure it is.
fn classify(text: &str) -> PublishError {
let lower = text.to_ascii_lowercase();
if lower.contains("access is denied")
|| lower.contains("requires elevation")
|| lower.contains("run as administrator")
|| lower.contains("administrator privilege")
|| lower.contains("permissiondenied")
{
PublishError::Refused(format!("the Windows DNS client refused the change: {text}"))
} else if lower.contains("is not recognized")
|| lower.contains("commandnotfoundexception")
|| lower.contains("not recognized as the name of a cmdlet")
{
PublishError::Unavailable(format!(
"the DnsClient PowerShell module is not available: {text}"
))
} else {
PublishError::Failed(format!("the Windows DNS client failed the change: {text}"))
}
}
/// Runs a PowerShell script from `%SystemRoot%` and maps a failure to a
/// [`PublishError`].
async fn powershell(script: String) -> Result<(), PublishError> {
let program = powershell_path();
let output = tokio::task::spawn_blocking(move || {
std::process::Command::new(program)
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
&script,
])
.output()
})
.await
.map_err(|err| PublishError::Failed(format!("could not run PowerShell: {err}")))?
.map_err(|err| PublishError::Unavailable(format!("could not run PowerShell: {err}")))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let text = if stderr.trim().is_empty() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
stderr.trim().to_string()
};
Err(classify(if text.is_empty() { "no output" } else { &text }))
}
/// The absolute path to Windows PowerShell, so nothing on `PATH` can stand in
/// for it.
fn powershell_path() -> std::path::PathBuf {
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into());
std::path::Path::new(&root)
.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe")
}
impl DnsPublisher for NrptPublisher {
fn name(&self) -> &str {
"windows-nrpt"
}
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
Box::pin(async move {
if published.domains.is_empty() {
return Err(PublishError::Unavailable(
"there are no suffixes to route, so there is no rule to write".to_string(),
));
}
// Windows can only ask on port 53, so a server anywhere else is
// unreachable this way. Keep the ones it can use.
let servers: BTreeSet<IpAddr> = published
.servers
.iter()
.filter(|server| server.port() == DNS_PORT)
.map(|server| server.ip())
.collect();
if servers.is_empty() {
let elsewhere = published
.servers
.iter()
.map(|server| server.to_string())
.collect::<Vec<_>>()
.join(", ");
return Err(PublishError::Unavailable(format!(
"the Windows DNS client only asks on port {DNS_PORT}, and the server is on \
{elsewhere}. Run the server on port {DNS_PORT}, or point your resolver at it \
yourself."
)));
}
let namespaces: Vec<String> = published.domains.iter().map(|d| namespace(d)).collect();
let servers: Vec<String> = servers.iter().map(|ip| ip.to_string()).collect();
powershell(apply_script(&namespaces, &servers)).await?;
match self.applied.lock() {
Ok(mut guard) => *guard = namespaces,
Err(poisoned) => *poisoned.into_inner() = namespaces,
}
Ok(())
})
}
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
Box::pin(async move {
let namespaces = match self.applied.lock() {
Ok(mut guard) => std::mem::take(&mut *guard),
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
};
if namespaces.is_empty() {
return Ok(());
}
powershell(revert_script(&namespaces)).await
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::net::SocketAddr;
#[test]
fn a_suffix_gets_a_leading_dot() {
assert_eq!(namespace("lab"), ".lab");
assert_eq!(namespace(".lab"), ".lab");
assert_eq!(namespace("a.b"), ".a.b");
}
#[test]
fn a_quote_in_a_value_cannot_escape_the_literal() {
// Zone names never contain a quote, but the escaping is what makes
// that a guarantee rather than a hope.
assert_eq!(ps_literal("a'b"), "'a''b'");
assert_eq!(ps_array(&["x".into(), "y".into()]), "@('x','y')");
assert_eq!(ps_array(&[]), "@()");
}
#[test]
fn the_apply_script_removes_before_it_adds() {
let script = apply_script(&[".lab".into()], &["10.13.37.69".into()]);
let remove = script.find("Remove-DnsClientNrptRule").unwrap();
let add = script.find("Add-DnsClientNrptRule").unwrap();
assert!(
remove < add,
"a stale rule must go before the new one:\n{script}"
);
assert!(script.contains("@('.lab')"));
assert!(script.contains("@('10.13.37.69')"));
}
#[test]
fn an_access_denied_is_a_refusal_a_missing_cmdlet_is_unavailable() {
assert!(matches!(
classify("Access is denied"),
PublishError::Refused(_)
));
assert!(matches!(
classify("The term 'Add-DnsClientNrptRule' is not recognized"),
PublishError::Unavailable(_)
));
assert!(matches!(
classify("something else"),
PublishError::Failed(_)
));
}
#[tokio::test]
async fn a_server_only_on_a_nonstandard_port_is_unavailable_not_a_failure() {
// Nothing is run: Windows cannot ask there, and saying so is the
// honest answer without touching the DNS client.
let publisher = NrptPublisher::new();
let published = Published {
interface: "tsun0".into(),
servers: vec![SocketAddr::from(([10, 13, 37, 69], 5354))],
domains: vec!["lab".into()],
};
let err = publisher.apply(&published).await.unwrap_err();
assert!(matches!(err, PublishError::Unavailable(_)), "{err}");
}
#[tokio::test]
async fn reverting_without_having_applied_does_nothing_and_succeeds() {
NrptPublisher::new().revert().await.unwrap();
}
}
@@ -16,7 +16,7 @@ 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};
use super::zone::{Answer, Query, Zone, Zones};
/// How long an answer may be cached.
///
@@ -39,30 +39,35 @@ 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 zone the server answers from, swapped as the roster changes.
/// The zones the server answers from, swapped as the rosters change.
///
/// Shared rather than copied into the server so that a roster change is one
/// write, not a restart: rebinding the socket would drop questions in flight
/// for no reason.
#[derive(Debug, Clone)]
pub struct SharedZone(Arc<RwLock<Arc<Zone>>>);
/// 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 zone.
pub fn new(zone: Zone) -> Self {
Self(Arc::new(RwLock::new(Arc::new(zone))))
/// Wraps a set of zones.
pub fn new(zones: Zones) -> Self {
Self(Arc::new(RwLock::new(Arc::new(zones))))
}
/// Replaces it.
pub fn set(&self, zone: Zone) {
/// 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(zone),
Err(poisoned) => *poisoned.into_inner() = Arc::new(zone),
Ok(mut guard) => *guard = Arc::new(zones),
Err(poisoned) => *poisoned.into_inner() = Arc::new(zones),
}
}
/// The zone as it is now.
pub fn get(&self) -> Arc<Zone> {
/// 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()),
@@ -75,7 +80,7 @@ impl SharedZone {
/// `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(zone: &Zone, query: &[u8]) -> Option<Vec<u8>> {
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;
@@ -105,7 +110,16 @@ pub fn respond(zone: &Zone, query: &[u8]) -> Option<Vec<u8>> {
}
let qname = question.qname.to_string();
let answer = zone.lookup(&qname, query_kind(question.qtype));
// 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()?;
@@ -338,7 +352,11 @@ mod tests {
use crate::dns::zone::ZoneName;
use std::net::Ipv4Addr;
fn zone() -> Zone {
fn zone() -> Zones {
Zones::new([one_zone()])
}
fn one_zone() -> Zone {
Zone::new(
ZoneName::new("lab").unwrap(),
[
@@ -468,7 +486,7 @@ mod tests {
let many: Vec<(String, Ipv4Addr)> = (0..200)
.map(|i| ("host".to_string(), Ipv4Addr::new(10, 13, 37, i as u8)))
.collect();
let wide = Zone::new(ZoneName::new("lab").unwrap(), many);
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!(
@@ -518,9 +536,64 @@ mod tests {
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::new(Zone::new(ZoneName::new("lab").unwrap(), []));
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();
@@ -128,17 +128,14 @@ impl ZoneName {
.to_string(),
);
}
if tld::exist_case_insensitive(top) {
return Some(format!(
// 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"
));
}
// Not delegated today is not a promise about tomorrow.
(!self.0.contains('.')).then(|| {
format!(
"`.{top}` is not a delegated top-level domain today, but it could \
become one; `.internal` is reserved for private use and never will"
)
})
}
@@ -336,6 +333,86 @@ impl Zone {
}
}
/// 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();
@@ -436,11 +513,13 @@ mod tests {
let local = ZoneName::new("local").unwrap().collision().unwrap();
assert!(local.contains("multicast DNS"), "{local}");
// An undelegated single label is a maybe, not a yes.
let lab = ZoneName::new("lab").unwrap().collision().unwrap();
assert!(lab.contains("could"), "{lab}");
// A multi-label name under something undelegated is not worth a word.
assert_eq!(ZoneName::new("a.lab").unwrap().collision(), None);
// 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]
@@ -577,4 +656,94 @@ mod tests {
);
assert_ne!(a.serial(), changed.serial());
}
#[test]
fn each_network_answers_only_for_its_own_zone() {
// One agent, several networks, one socket. A question carries a
// name and not the network it belongs to, so the suffix is what
// decides — and a member of one network is not a name in another.
let zones = Zones::new([
Zone::new(
ZoneName::new("lab").unwrap(),
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))],
),
Zone::new(
ZoneName::new("home").unwrap(),
[("music".to_string(), Ipv4Addr::new(10, 20, 0, 2))],
),
]);
assert_eq!(zones.len(), 2);
assert_eq!(zones.names(), 2);
let (zone, answer) = zones.lookup("music.lab", Query::A).unwrap();
assert_eq!(zone.origin().as_str(), "lab");
assert_eq!(
answer,
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 2)])
);
let (zone, answer) = zones.lookup("music.home", Query::A).unwrap();
assert_eq!(zone.origin().as_str(), "home");
assert_eq!(answer, Answer::Addresses(vec![Ipv4Addr::new(10, 20, 0, 2)]));
// A name under neither is nobody's business here.
assert!(zones.lookup("music.example.com", Query::A).is_none());
assert!(Zones::default().lookup("music.lab", Query::A).is_none());
}
#[test]
fn a_denial_comes_from_the_zone_that_owns_the_name() {
// The SOA that bounds a negative answer belongs to the zone that
// denied it; with several served that is no longer a given.
let zones = Zones::new([
Zone::new(ZoneName::new("lab").unwrap(), []),
Zone::new(
ZoneName::new("home").unwrap(),
[("music".to_string(), Ipv4Addr::new(10, 20, 0, 2))],
),
]);
let (zone, answer) = zones.lookup("nobody.lab", Query::A).unwrap();
assert_eq!(zone.origin().as_str(), "lab");
assert_eq!(answer, Answer::NoSuchName);
}
#[test]
fn a_reverse_question_is_answered_by_whichever_zone_holds_the_address() {
// It carries no suffix to match on, so every zone is asked.
let zones = Zones::new([
Zone::new(
ZoneName::new("lab").unwrap(),
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))],
),
Zone::new(
ZoneName::new("home").unwrap(),
[("kitchen".to_string(), Ipv4Addr::new(10, 20, 0, 2))],
),
]);
let (_, answer) = zones.lookup("2.0.20.10.in-addr.arpa", Query::Ptr).unwrap();
assert_eq!(answer, Answer::Name("kitchen.home".to_string()));
let (_, answer) = zones.lookup("2.37.13.10.in-addr.arpa", Query::Ptr).unwrap();
assert_eq!(answer, Answer::Name("music.lab".to_string()));
}
#[test]
fn two_networks_of_one_name_do_not_shadow_each_other_at_random() {
// Exactly the two-`LAB` case. Only one can own the suffix; which
// one must not depend on the order they happened to start in.
let first = Zone::new(
ZoneName::new("lab").unwrap(),
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))],
);
let second = Zone::new(
ZoneName::new("lab").unwrap(),
[("music".to_string(), Ipv4Addr::new(10, 99, 0, 2))],
);
let zones = Zones::new([first, second]);
assert_eq!(zones.len(), 1, "one suffix, one zone");
let (_, answer) = zones.lookup("music.lab", Query::A).unwrap();
assert_eq!(
answer,
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 2)])
);
}
}
@@ -110,6 +110,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,11 +49,16 @@ impl DeviceIdentity {
self.secret.public()
}
/// The raw secret key bytes, for persistence only.
pub(crate) fn secret_bytes(&self) -> [u8; 32] {
/// The raw secret key bytes, for persistence and transport certificate generation.
pub fn secret_bytes(&self) -> [u8; 32] {
self.secret.to_bytes()
}
/// Signs a message with the device's persistent secret key.
pub fn sign(&self, message: &[u8]) -> iroh::Signature {
self.secret.sign(message)
}
/// A clone of the iroh secret key, for endpoint construction only.
pub(crate) fn secret_key(&self) -> SecretKey {
self.secret.clone()
@@ -340,6 +340,8 @@ pub struct NetworkKeys {
network_id: NetworkId,
discovery_key: DiscoveryKey,
auth_key: Zeroizing<[u8; 32]>,
// Separate from the semi-public DiscoveryKey. Existing derivations stay frozen.
dht_write_key: Zeroizing<[u8; 32]>,
name: NetworkName,
}
@@ -373,6 +375,7 @@ impl NetworkKeys {
network_id: NetworkId(expand("network-id")),
discovery_key: DiscoveryKey(expand("discovery-key")),
auth_key: Zeroizing::new(expand("handshake-auth")),
dht_write_key: Zeroizing::new(expand("mainline-rendezvous-write-v1")),
name: name.clone(),
}
}
@@ -405,6 +408,11 @@ impl NetworkKeys {
pub(crate) fn auth_key(&self) -> &[u8; 32] {
&self.auth_key
}
/// Secret signing material for network rendezvous; never exported or logged.
pub(crate) fn dht_write_key(&self) -> &[u8; 32] {
&self.dht_write_key
}
}
impl std::fmt::Debug for NetworkKeys {
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 `tsunagi status` while
/// the first is mid-flight would find nothing listening.
async fn serve(
name: String,
first: tokio::net::windows::named_pipe::NamedPipeServer,
source: Arc<dyn ReportSource>,
) {
let mut server = first;
loop {
if server.connect().await.is_err() {
match next_instance(&name) {
Some(next) => {
server = next;
continue;
}
None => return,
}
}
let connected = server;
match next_instance(&name) {
Some(next) => server = next,
None => {
// Nothing left to accept the next client on, but the one in
// hand is still answered before the loop ends.
let source = Arc::clone(&source);
tokio::spawn(async move {
let _ = super::serve_connection(connected, source).await;
});
return;
}
}
let source = Arc::clone(&source);
tokio::spawn(async move {
if let Err(err) = super::serve_connection(connected, source).await {
tracing::debug!(%err, "local control request failed");
}
});
}
}
/// Creates the next pipe instance, or `None` if the name can no longer be
/// served.
fn next_instance(name: &str) -> Option<tokio::net::windows::named_pipe::NamedPipeServer> {
let mut sec_attrs = match PipeSecurityAttributes::new() {
Ok(attrs) => attrs,
Err(err) => {
tracing::debug!(%err, "cannot build pipe security attributes for next instance");
return None;
}
};
// SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES.
match unsafe {
ServerOptions::new()
.reject_remote_clients(true)
.create_with_security_attributes_raw(name, sec_attrs.as_raw_mut())
} {
Ok(server) => Some(server),
Err(err) => {
tracing::debug!(%err, "cannot create the next control pipe instance");
None
}
}
}
/// Connects a client to the pipe for `path`, waiting out a busy pipe.
///
/// The wait is bounded by the caller's exchange timeout, so a pipe that is
/// busy forever is given up on rather than spun on.
pub(crate) async fn connect(path: &Path) -> Result<NamedPipeClient> {
let name = pipe_name(path);
loop {
match ClientOptions::new().open(&name) {
Ok(client) => return Ok(client),
Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY) => {
tokio::time::sleep(Duration::from_millis(50)).await;
}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
return Err(Error::Io {
path: path.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"the agent's control pipe exists, but Windows denied access. \
Run this command as the same Windows user and with the same \
elevation as `tsunagi up` (use an administrator terminal if \
the agent is elevated). Windows error: {err}"
),
),
});
}
Err(source) => {
return Err(Error::Io {
path: path.to_path_buf(),
source,
});
}
}
}
}
/// Whether an agent is serving the pipe for `path`.
///
/// A busy or inaccessible pipe still means a server is there. In particular,
/// access denied must not send a mutating command down the offline path,
/// where it would only produce a misleading state-directory lock error.
pub(crate) async fn probe(path: &Path) -> bool {
let name = pipe_name(path);
match ClientOptions::new().open(&name) {
Ok(_) => true,
Err(err) => {
err.raw_os_error() == Some(ERROR_PIPE_BUSY)
|| err.kind() == std::io::ErrorKind::PermissionDenied
}
}
}
/// The pipe name for a control-socket path.
///
/// A hash rather than the path itself, because a pipe name may not contain the
/// separators and drive letters a path does, and because two agents with
/// different state directories must never collide.
fn pipe_name(path: &Path) -> String {
let digest = Sha256::digest(path.as_os_str().as_encoded_bytes());
format!(r"\\.\pipe\tsunagi-{}", hex::encode(&digest[..16]))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::time::Duration;
use super::super::{Request, Response, StatusReport, exchange};
use super::*;
use crate::BoxFuture;
#[test]
fn one_path_maps_to_one_name_and_two_paths_do_not_collide() {
let a = pipe_name(Path::new(r"C:\a\agent.sock"));
let b = pipe_name(Path::new(r"C:\b\agent.sock"));
assert!(a.starts_with(r"\\.\pipe\tsunagi-"), "{a}");
assert_eq!(a, pipe_name(Path::new(r"C:\a\agent.sock")));
assert_ne!(a, b);
}
#[tokio::test]
async fn a_request_makes_the_round_trip_over_a_real_pipe() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("agent.sock");
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
Box::pin(async { StatusReport::default() })
});
assert!(!is_serving(&path).await, "nothing serves the pipe yet");
let control = ControlSocket::bind(&path, source).await.unwrap();
assert!(is_serving(&path).await, "the agent serves it now");
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
.await
.unwrap();
assert!(matches!(answer, Response::Status(_)));
control.shutdown().await;
}
#[tokio::test]
async fn an_inaccessible_agent_is_not_mistaken_for_an_absent_one() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("agent.sock");
// An outbound-only pipe denies the client's duplex open even when
// both processes have the same privileges. This exercises the same
// Windows error as a client unable to access an elevated agent,
// without requiring elevation or changing any ACLs.
let _server = ServerOptions::new()
.first_pipe_instance(true)
.access_inbound(false)
.create(pipe_name(&path))
.unwrap();
assert!(is_serving(&path).await, "access denied is not absence");
for error in [
request_status(&path).await.unwrap_err(),
join_network(&path, "test", "local-ipc-test-secret")
.await
.unwrap_err(),
set_dns(&path, false, None).await.unwrap_err(),
] {
let Error::Io { source, .. } = error else {
panic!("expected an access error, got {error}");
};
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
let message = source.to_string();
assert!(message.contains("same Windows user"), "{message}");
assert!(message.contains("administrator"), "{message}");
assert!(!message.contains("local-ipc-test-secret"), "{message}");
}
}
#[tokio::test]
async fn a_silent_agent_is_reported_rather_than_waited_out() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("agent.sock");
// A server instance that accepts and then never answers, like a
// wedged agent. Unbounded, the exchange below would never return.
let name = pipe_name(&path);
let server = ServerOptions::new()
.first_pipe_instance(true)
.create(&name)
.unwrap();
let _accept = tokio::spawn(async move {
let _ = server.connect().await;
// Hold the connection open, answering nothing.
tokio::time::sleep(Duration::from_secs(30)).await;
});
let error = exchange(&path, &Request::Status, Duration::from_millis(200))
.await
.expect_err("a silent agent cannot be reported as healthy");
assert!(matches!(error, Error::Timeout { .. }), "{error:?}");
}
#[tokio::test]
async fn a_second_agent_on_the_same_pipe_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("agent.sock");
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
Box::pin(async { StatusReport::default() })
});
let first = ControlSocket::bind(&path, Arc::clone(&source))
.await
.unwrap();
let second = ControlSocket::bind(&path, source).await;
assert!(
matches!(second, Err(Error::StateLocked { .. })),
"{second:?}"
);
first.shutdown().await;
}
}
+4
View File
@@ -44,9 +44,13 @@ pub mod error;
pub mod identity;
pub mod ipc;
pub mod net;
pub mod overlay;
pub mod proto;
pub mod state;
pub mod storage;
pub mod task;
#[cfg(feature = "testing")]
pub mod testing;
pub use agent::{Agent, AgentStatus, Event, NetworkStatus, PeerStatus};
pub use config::{AgentConfig, Limits, ReconnectPolicy, StoragePaths, TransportPolicy};
+141
View File
@@ -240,6 +240,62 @@ impl EndpointAdapter {
TransportPolicy::N0Defaults => builder.preset(presets::N0),
};
if matches!(
config.transport,
TransportPolicy::N0Defaults | TransportPolicy::DirectOnly
) {
let filter = iroh::address_lookup::AddrFilter::new(|addrs| {
let mut overlay_ips = std::collections::HashSet::new();
for iface in netdev::get_interfaces() {
let name = iface.name.to_lowercase();
let friendly = iface
.friendly_name
.as_deref()
.map(str::to_lowercase)
.unwrap_or_default();
let desc = iface
.description
.as_deref()
.map(str::to_lowercase)
.unwrap_or_default();
if name.starts_with("tsun")
|| name.contains("tsunagi")
|| name.contains("wintun")
|| friendly.starts_with("tsun")
|| friendly.contains("tsunagi")
|| friendly.contains("wintun")
|| desc.contains("wintun")
|| desc.contains("tsunagi")
{
for ip in iface.ipv4 {
overlay_ips.insert(std::net::IpAddr::V4(ip.addr()));
}
for ip in iface.ipv6 {
overlay_ips.insert(std::net::IpAddr::V6(ip.addr()));
}
}
}
if overlay_ips.is_empty() {
return std::borrow::Cow::Borrowed(addrs);
}
let filtered: Vec<iroh::TransportAddr> = addrs
.iter()
.filter(|addr| match addr {
iroh::TransportAddr::Ip(sock) => !overlay_ips.contains(&sock.ip()),
_ => true,
})
.cloned()
.collect();
std::borrow::Cow::Owned(filtered)
});
builder = builder.addr_filter(filter);
}
#[cfg(feature = "testing")]
if let Some(transport) = &config.test_quic_transport {
builder = builder.transport_config(transport.clone());
}
if !config.bind_addrs.is_empty() {
builder = builder.clear_ip_transports();
for addr in &config.bind_addrs {
@@ -254,6 +310,91 @@ impl EndpointAdapter {
.await
.map_err(|err| Error::Endpoint(format!("cannot bind endpoint: {err}")))?;
tracing::debug!(
endpoint = %endpoint.id().fmt_short(),
bound_sockets = ?endpoint.bound_sockets(),
"iroh endpoint bound locally"
);
let ep_id = endpoint.id();
let mut addr_watcher = endpoint.watch_addr();
let mut relay_watcher = endpoint.home_relay_status();
let close_token = endpoint.clone();
tokio::spawn(async move {
use iroh::Watcher;
let mut last_addrs: Vec<SocketAddr> = Vec::new();
let mut last_relays: Vec<String> = Vec::new();
let warn_timer = tokio::time::sleep(Duration::from_secs(12));
tokio::pin!(warn_timer);
let mut warned = false;
loop {
tokio::select! {
biased;
_ = close_token.closed() => {
break;
}
() = &mut warn_timer, if !warned => {
warned = true;
if last_addrs.is_empty() && !last_relays.is_empty() {
tracing::warn!(
endpoint = %ep_id.fmt_short(),
relays = ?last_relays,
"No direct reflexive or UPnP addresses discovered after probe; communications will rely on relay servers"
);
} else if last_addrs.is_empty() && last_relays.is_empty() {
tracing::warn!(
endpoint = %ep_id.fmt_short(),
"No reachability discovered (neither direct reflexive/UPnP addresses nor relay servers available)"
);
}
}
res = addr_watcher.updated() => {
let addr = match res {
Ok(a) => a,
Err(_) => break,
};
let addrs: Vec<SocketAddr> = addr.ip_addrs().copied().collect();
let relays: Vec<String> = addr.relay_urls().map(|u| u.to_string()).collect();
if addrs != last_addrs || relays != last_relays {
tracing::debug!(
endpoint = %ep_id.fmt_short(),
direct_addrs = ?addrs,
relays = ?relays,
"Discovered endpoint reachability (STUN/UPnP/local/relay)"
);
last_addrs = addrs;
last_relays = relays;
}
}
res = relay_watcher.updated() => {
let relays = match res {
Ok(r) => r,
Err(_) => break,
};
for relay in &relays {
if relay.is_connected() {
tracing::debug!(
endpoint = %ep_id.fmt_short(),
relay = %relay.url(),
"Connected to home relay server"
);
} else if let Some(err) = relay.last_error() {
tracing::debug!(
endpoint = %ep_id.fmt_short(),
relay = %relay.url(),
error = %err,
"Relay connection attempt failed"
);
}
}
}
}
}
});
Ok(Self { endpoint })
}
+231
View File
@@ -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,231 @@
//! 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,
}
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, ()>;
}
/// 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>>>,
}
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;
}
/// 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 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,
};
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,
})
.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,570 @@
//! 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.
fn rule_spec(plan: &BroadcastRulesPlan) -> Vec<String> {
[
"-i",
plan.interface.as_str(),
"-p",
"udp",
"-s",
&plan.range.to_string(),
"-m",
"comment",
"--comment",
&plan.tag(),
"-j",
"ACCEPT",
]
.into_iter()
.map(str::to_string)
.collect()
}
/// Waits briefly for the xtables lock instead of failing when another tool
/// holds it.
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) -> Vec<String> {
locked(
["-C".to_string(), CHAIN.to_string()]
.into_iter()
.chain(rule_spec(plan)),
)
}
fn insert_args(plan: &BroadcastRulesPlan) -> Vec<String> {
locked(
["-I".to_string(), CHAIN.to_string(), "1".to_string()]
.into_iter()
.chain(rule_spec(plan)),
)
}
fn list_args() -> Vec<String> {
locked(["-S".to_string(), CHAIN.to_string()])
}
/// Splits one `iptables -S` line into words, honouring double quotes, which
/// `iptables` puts around comments that need them.
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
}
/// The delete commands for every rule in an `iptables -S INPUT` listing that
/// carries exactly this tag. Anything else in the chain is left alone.
fn tagged_deletions(listing: &str, tag: &str) -> Vec<Vec<String>> {
listing
.lines()
.filter_map(|line| {
let words = split_rule_line(line);
let [append, chain, rest @ ..] = words.as_slice() else {
return None;
};
if append != "-A" || chain != CHAIN {
return None;
}
let tagged = rest
.windows(2)
.any(|pair| pair[0] == "--comment" && pair[1] == tag);
tagged.then(|| {
locked(
["-D".to_string(), CHAIN.to_string()]
.into_iter()
.chain(rest.iter().cloned()),
)
})
})
.collect()
}
/// A failure message with the likely fix for the likely cause.
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 present = match run(&check_args(plan)) {
Ran::Missing => return RuleOutcome::Failed(missing(plan)),
Ran::Exited { success, .. } => success,
};
let listing = match run(&list_args()) {
Ran::Missing => return RuleOutcome::Failed(missing(plan)),
Ran::Exited {
success: true,
stdout,
..
} => stdout,
Ran::Exited { stderr, .. } => return RuleOutcome::Failed(failure(&stderr)),
};
let stale = tagged_deletions(&listing, &plan.tag());
// `-C` matched an identical rule and nothing else carries our tag, so
// there is nothing to do. Otherwise start from a clean slate for this
// interface: a rule from an earlier run may name another range.
if present && stale.len() == 1 {
return RuleOutcome::Applied;
}
for delete in &stale {
if let Ran::Exited {
success: false,
stderr,
..
} = run(delete)
{
return RuleOutcome::Failed(failure(&stderr));
}
}
match run(&insert_args(plan)) {
Ran::Missing => RuleOutcome::Failed(missing(plan)),
Ran::Exited { success: true, .. } => RuleOutcome::Applied,
Ran::Exited { stderr, .. } => RuleOutcome::Failed(failure(&stderr)),
}
}
/// Removes every rule tagged for this interface. Best effort.
fn clear_firewall(run: Run<'_>, interface: &str) {
let Ran::Exited {
success: true,
stdout,
..
} = run(&list_args())
else {
return;
};
for delete in tagged_deletions(&stdout, &tag_for(interface)) {
if let Ran::Exited {
success: false,
stderr,
..
} = run(&delete)
{
tracing::warn!(
interface,
"cannot remove the broadcast firewall rule: {}",
failure(&stderr)
);
}
}
}
/// Runs the real `iptables`.
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()));
assert_eq!(
spec,
"-i tsun0 -p udp -s 10.13.37.0/24 -m comment --comment tsunagi:tsun0 -j ACCEPT"
);
// Nothing here may widen to other protocols or every source.
assert!(spec.contains("-p udp"));
assert!(!spec.contains("0.0.0.0/0"));
assert!(words(&insert_args(&plan())).starts_with("-w 5 -I INPUT 1 -i tsun0"));
assert!(words(&check_args(&plan())).starts_with("-w 5 -C INPUT -i tsun0"));
}
#[test]
fn iptables_is_found_in_fixed_directories_never_on_path() {
assert_eq!(pick_iptables(|_| false), None);
assert_eq!(
pick_iptables(|p| p == "/sbin/iptables"),
Some("/sbin/iptables")
);
assert_eq!(pick_iptables(|_| true), Some("/usr/sbin/iptables"));
}
#[test]
fn only_rules_with_our_exact_tag_are_selected_for_deletion() {
let listing = "\
-P INPUT DROP
-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -m comment --comment \"tsunagi:tsun0\" -j ACCEPT
-A INPUT -i tsun0 -m comment --comment tsunagi:tsun0 -j ACCEPT
-A INPUT -i tsun1 -p udp -m comment --comment tsunagi:tsun1 -j ACCEPT
-A INPUT -p tcp --dport 22 -m comment --comment \"allow ssh tsunagi:tsun0\" -j ACCEPT
-A FORWARD -i tsun0 -m comment --comment tsunagi:tsun0 -j ACCEPT
-A INPUT -p tcp --dport 22 -j ACCEPT
";
let deletions = tagged_deletions(listing, "tsunagi:tsun0");
assert_eq!(deletions.len(), 2, "{deletions:?}");
assert_eq!(
words(&deletions[0]),
"-w 5 -D INPUT -s 10.13.37.0/24 -i tsun0 -p udp -m comment --comment tsunagi:tsun0 -j ACCEPT"
);
assert!(words(&deletions[1]).contains("-D INPUT -i tsun0 -m comment"));
assert!(tagged_deletions("", "tsunagi:tsun0").is_empty());
}
/// A scripted `iptables`: answers by the leading verb, records every call.
struct Fake {
calls: RefCell<Vec<String>>,
check_ok: bool,
listing: String,
insert_stderr: Option<&'static str>,
missing: bool,
}
impl Fake {
fn new(check_ok: bool, listing: &str) -> Self {
Self {
calls: RefCell::new(Vec::new()),
check_ok,
listing: listing.to_string(),
insert_stderr: None,
missing: false,
}
}
fn run(&self, args: &[String]) -> Ran {
self.calls.borrow_mut().push(words(args));
if self.missing {
return Ran::Missing;
}
let ok = |stdout: &str| Ran::Exited {
success: true,
stdout: stdout.to_string(),
stderr: String::new(),
};
match args.get(2).map(String::as_str) {
Some("-C") if self.check_ok => ok(""),
Some("-C") => Ran::Exited {
success: false,
stdout: String::new(),
stderr: "Bad rule".into(),
},
Some("-S") => ok(&self.listing),
Some("-I") => match self.insert_stderr {
Some(stderr) => Ran::Exited {
success: false,
stdout: String::new(),
stderr: stderr.into(),
},
None => ok(""),
},
_ => ok(""),
}
}
fn verbs(&self) -> Vec<String> {
self.calls
.borrow()
.iter()
.map(|call| call.split(' ').nth(2).unwrap().to_string())
.collect()
}
}
const ONE_OURS: &str =
"-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -m comment --comment tsunagi:tsun0 -j ACCEPT\n";
#[test]
fn an_identical_rule_already_in_place_is_left_alone() {
let fake = Fake::new(true, ONE_OURS);
assert_eq!(
apply_firewall(&|a| fake.run(a), &plan()),
RuleOutcome::Applied
);
assert_eq!(fake.verbs(), ["-C", "-S"], "no insert, no delete");
}
#[test]
fn a_missing_rule_is_inserted_once() {
let fake = Fake::new(false, "-P INPUT ACCEPT\n");
assert_eq!(
apply_firewall(&|a| fake.run(a), &plan()),
RuleOutcome::Applied
);
assert_eq!(fake.verbs(), ["-C", "-S", "-I"]);
}
#[test]
fn a_stale_rule_for_another_range_is_replaced_not_stacked() {
// A crashed run left a rule for an old range under our tag.
let stale = "-A INPUT -s 10.99.0.0/16 -i tsun0 -p udp -m comment --comment tsunagi:tsun0 -j ACCEPT\n";
let fake = Fake::new(false, stale);
assert_eq!(
apply_firewall(&|a| fake.run(a), &plan()),
RuleOutcome::Applied
);
assert_eq!(fake.verbs(), ["-C", "-S", "-D", "-I"]);
}
#[test]
fn duplicates_of_the_rule_collapse_to_one() {
let doubled = format!("{ONE_OURS}{ONE_OURS}");
let fake = Fake::new(true, &doubled);
assert_eq!(
apply_firewall(&|a| fake.run(a), &plan()),
RuleOutcome::Applied
);
assert_eq!(fake.verbs(), ["-C", "-S", "-D", "-D", "-I"]);
}
#[test]
fn a_missing_iptables_says_what_to_install_and_what_to_allow() {
let mut fake = Fake::new(false, "");
fake.missing = true;
let RuleOutcome::Failed(reason) = apply_firewall(&|a| fake.run(a), &plan()) else {
panic!("expected a failure");
};
assert!(reason.contains("iptables not found"), "{reason}");
assert!(reason.contains("10.13.37.0/24"), "{reason}");
assert!(reason.contains("tsun0"), "{reason}");
}
#[test]
fn a_refused_insert_is_reported_with_the_cause() {
let mut fake = Fake::new(false, "");
fake.insert_stderr = Some(
"iptables v1.8.10: can't initialize iptables table `filter': Permission denied (you must be root)",
);
let RuleOutcome::Failed(reason) = apply_firewall(&|a| fake.run(a), &plan()) else {
panic!("expected a failure");
};
assert!(reason.starts_with("iptables needs root"), "{reason}");
}
#[test]
fn clearing_removes_only_tagged_rules_and_ignores_a_missing_iptables() {
let listing = format!(
"-A INPUT -p tcp --dport 22 -j ACCEPT\n{ONE_OURS}-A INPUT -i tsun1 -m comment --comment tsunagi:tsun1 -j ACCEPT\n"
);
let fake = Fake::new(true, &listing);
clear_firewall(&|a| fake.run(a), "tsun0");
assert_eq!(fake.verbs(), ["-S", "-D"]);
let mut gone = Fake::new(true, "");
gone.missing = true;
clear_firewall(&|a| gone.run(a), "tsun0");
assert_eq!(gone.verbs(), ["-S"]);
}
#[test]
fn rule_listing_words_honour_quotes() {
assert_eq!(
split_rule_line("-A INPUT -m comment --comment \"a b\" -j ACCEPT"),
[
"-A",
"INPUT",
"-m",
"comment",
"--comment",
"a b",
"-j",
"ACCEPT"
]
);
assert!(split_rule_line(" ").is_empty());
}
}
@@ -0,0 +1,728 @@
//! 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, failure, locked, run_iptables, 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.
fn offer_rules(interface: &str, range: &Ipv4Range) -> Vec<Rule> {
let range_text = range.to_string();
let tag = exit_tag(interface, range);
let comment = ["-m", "comment", "--comment", tag.as_str()];
let spec = |head: &[&str], tail: &[&str]| -> Vec<String> {
owned(head)
.into_iter()
.chain(owned(&comment))
.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 carrying a tag that starts
/// with `prefix`. Anything else in the chain is left alone.
fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec<Found> {
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())?;
Some(Found {
tag,
rest: rest.to_vec(),
})
})
.collect()
}
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();
// 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);
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());
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,
}
impl Fake {
fn new(check_ok: bool) -> Self {
Self {
calls: RefCell::new(Vec::new()),
listing: RefCell::new(Vec::new()),
missing: false,
check_ok,
}
}
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 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_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,189 @@
//! Broadcast host rules on macOS: a `route` entry; the firewall is deferred.
//!
//! The limited-broadcast route is installed with `/sbin/route`, so a game's
//! LAN discovery packet leaves through the overlay interface. It belongs to
//! the interface and is removed when broadcast is turned off, the network
//! leaves, or the agent stops — and, because a utun is torn down with the
//! process, it also goes away on its own if the agent is killed.
//!
//! The inbound-UDP firewall allowance is **not** implemented here. On macOS
//! that means a `pf` anchor, which is a good deal more machinery; until it is
//! written the firewall half reports itself as missing with what to allow by
//! hand, and the route is still installed. Failure is contained: neither half
//! can stop the agent, and `status` shows exactly which is in place.
//!
//! `route` is a program, not a system call, so it is run by absolute path from
//! `/sbin` rather than off `PATH`, and its arguments are built from values
//! this agent computed — the interface name the kernel assigned, nothing a
//! peer said.
use crate::BoxFuture;
use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome};
/// The limited-broadcast destination routed through the overlay interface.
const LIMITED_BROADCAST: &str = "255.255.255.255";
/// The `route` arguments that direct the limited broadcast out an interface.
fn route_add_args(interface: &str) -> Vec<String> {
vec![
"-q".into(),
"-n".into(),
"add".into(),
"-host".into(),
LIMITED_BROADCAST.into(),
"-interface".into(),
interface.into(),
]
}
/// The `route` arguments that remove it again.
fn route_del_args(interface: &str) -> Vec<String> {
vec![
"-q".into(),
"-n".into(),
"delete".into(),
"-host".into(),
LIMITED_BROADCAST.into(),
"-interface".into(),
interface.into(),
]
}
/// What to do by hand while the `pf` allowance is unimplemented.
fn firewall_todo(plan: &BroadcastRulesPlan) -> String {
format!(
"the inbound-UDP firewall allowance is not implemented on macOS yet; if the host \
firewall drops discovery replies, allow inbound UDP from {} on `{}` with a pf rule \
yourself",
plan.range, plan.interface
)
}
/// The macOS implementation: `route` for the route, no firewall yet.
#[derive(Debug, Default)]
pub struct MacosHostRules;
impl MacosHostRules {
/// Creates the host rules.
pub fn new() -> Self {
Self
}
/// Installs the route, replacing one this agent added earlier so the call
/// can be repeated. The delete is best effort: a route that is not there
/// is the state the add then reaches anyway.
async fn set_route(&self, interface: String) -> RuleOutcome {
let _ = run(route_del_args(&interface)).await;
match run(route_add_args(&interface)).await {
Ok(()) => RuleOutcome::Applied,
Err(reason) => RuleOutcome::Failed(reason),
}
}
}
impl BroadcastHostRules for MacosHostRules {
fn name(&self) -> &str {
"route"
}
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
Box::pin(async move {
let route = self.set_route(plan.interface.clone()).await;
BroadcastRulesReport {
plan: plan.clone(),
route,
firewall: RuleOutcome::Failed(firewall_todo(plan)),
}
})
}
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
if let Err(reason) = run(route_del_args(interface)).await {
tracing::debug!(interface = %interface, %reason, "cannot remove the broadcast route");
}
})
}
}
/// Runs `/sbin/route`, turning a non-zero exit into the message it printed.
async fn run(args: Vec<String>) -> Result<(), String> {
let display = format!("route {}", args.join(" "));
let output = tokio::task::spawn_blocking(move || {
std::process::Command::new("/sbin/route")
.args(&args)
.output()
})
.await
.map_err(|err| format!("could not run {display}: {err}"))?
.map_err(|err| format!("could not run {display}: {err}"))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let text = stderr.trim();
if text.is_empty() {
Err(format!("{display} failed with {}", output.status))
} else {
Err(format!("{display} failed: {text}"))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::Ipv4Addr;
use super::*;
fn plan() -> BroadcastRulesPlan {
BroadcastRulesPlan {
interface: "utun5".into(),
source: Ipv4Addr::new(10, 13, 37, 142),
range: "10.13.37.0/24".parse().unwrap(),
}
}
#[test]
fn the_route_is_the_limited_broadcast_out_the_interface() {
assert_eq!(
route_add_args("utun5"),
vec![
"-q",
"-n",
"add",
"-host",
"255.255.255.255",
"-interface",
"utun5"
]
);
assert_eq!(
route_del_args("utun5"),
vec![
"-q",
"-n",
"delete",
"-host",
"255.255.255.255",
"-interface",
"utun5"
]
);
}
#[test]
fn the_firewall_todo_names_the_range_and_interface() {
let todo = firewall_todo(&plan());
assert!(todo.contains("10.13.37.0/24"), "{todo}");
assert!(todo.contains("utun5"), "{todo}");
assert!(todo.contains("pf"), "{todo}");
// Deferred, so the firewall half is always reported as not in place;
// the route runs for real and so is left to an on-host check. Running
// `route` here would need root and would change the host routing
// table, which the default test suite must never do.
}
}
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff. Show more