Compare commits

...
Author SHA1 Message Date
Aleksandr BogomiakovandClaude c184ef0914 Rewrite README as a short project front page; add a Nix flake
The README is now a landing page: the idea (a mesh VPN with no server, joined
with a network name and a secret), install and platform tables, exit nodes,
and a short build section. Implementation detail stays in docs/.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 12:29:40 +01:00
32 changed files with 3089 additions and 747 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Windows: link the C runtime statically, so the agent and the tray start on a
# machine that has no Visual C++ redistributable installed. The installer ships
# nothing but our own files and Wintun.
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
+25 -1
View File
@@ -190,6 +190,29 @@ jobs:
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}" tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
fi fi
# The Windows installer: the agent as a service, Wintun, the tray and the
# PATH entry in one setup program. Wintun is the signed library from
# wintun.net, pinned by hash so a changed download fails the build.
- name: Build the Windows installer
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$version = $env:GITHUB_REF_NAME.TrimStart('v')
Invoke-WebRequest -Uri https://www.wintun.net/builds/wintun-0.14.1.zip -OutFile wintun.zip
$hash = (Get-FileHash wintun.zip -Algorithm SHA256).Hash
if ($hash -ne '07C256185D6EE3652E09FA55C0B673E2624B565E02C4B9091C79CA7D2F24EF51') {
throw "wintun.zip has an unexpected hash: $hash"
}
Expand-Archive wintun.zip -DestinationPath wintun-dl
choco install innosetup -y --no-progress
$iscc = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'
$stage = Join-Path $PWD 'dist/pkg/${{ matrix.asset }}'
& $iscc "/DAppVersion=$version" "/DSourceDir=$stage" `
"/DWintunDir=$(Join-Path $PWD 'wintun-dl/wintun/bin/amd64')" `
"/O$PWD" dist/windows/tsunagi.iss
if ($LASTEXITCODE -ne 0) { throw "ISCC failed with $LASTEXITCODE" }
# A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops # 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 # the static binary, the systemd service and the polkit rule. Built with
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh. # dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
@@ -251,6 +274,7 @@ jobs:
${{ matrix.asset }}-*.* ${{ matrix.asset }}-*.*
*.deb *.deb
*.pkg.tar.zst *.pkg.tar.zst
tsunagi-setup-*.exe
if-no-files-found: error if-no-files-found: error
publish: publish:
@@ -322,5 +346,5 @@ jobs:
cat up.json >&2 || true cat up.json >&2 || true
rc=1 rc=1
fi fi
done < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' \) -print0) done < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' -o -name 'tsunagi-setup-*.exe' \) -print0)
exit "$rc" exit "$rc"
+25 -1
View File
@@ -192,6 +192,29 @@ jobs:
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}" tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
fi fi
# The Windows installer: the agent as a service, Wintun, the tray and the
# PATH entry in one setup program. Wintun is the signed library from
# wintun.net, pinned by hash so a changed download fails the build.
- name: Build the Windows installer
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$version = $env:GITHUB_REF_NAME.TrimStart('v')
Invoke-WebRequest -Uri https://www.wintun.net/builds/wintun-0.14.1.zip -OutFile wintun.zip
$hash = (Get-FileHash wintun.zip -Algorithm SHA256).Hash
if ($hash -ne '07C256185D6EE3652E09FA55C0B673E2624B565E02C4B9091C79CA7D2F24EF51') {
throw "wintun.zip has an unexpected hash: $hash"
}
Expand-Archive wintun.zip -DestinationPath wintun-dl
choco install innosetup -y --no-progress
$iscc = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'
$stage = Join-Path $PWD 'dist/pkg/${{ matrix.asset }}'
& $iscc "/DAppVersion=$version" "/DSourceDir=$stage" `
"/DWintunDir=$(Join-Path $PWD 'wintun-dl/wintun/bin/amd64')" `
"/O$PWD" dist/windows/tsunagi.iss
if ($LASTEXITCODE -ne 0) { throw "ISCC failed with $LASTEXITCODE" }
# A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops # 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 # the static binary, the systemd service and the polkit rule. Built with
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh. # dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
@@ -250,6 +273,7 @@ jobs:
${{ matrix.asset }}-*.* ${{ matrix.asset }}-*.*
*.deb *.deb
*.pkg.tar.zst *.pkg.tar.zst
tsunagi-setup-*.exe
if-no-files-found: error if-no-files-found: error
publish: publish:
@@ -278,7 +302,7 @@ jobs:
# download-artifact nests each artifact under its own directory, so # download-artifact nests each artifact under its own directory, so
# the archives are artifacts/<asset>/<file>. # 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' \)) mapfile -t files < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' -o -name 'tsunagi-setup-*.exe' \))
printf 'asset: %s\n' "${files[@]}" printf 'asset: %s\n' "${files[@]}"
prerelease=() prerelease=()
+1 -1
View File
@@ -8,7 +8,7 @@ before touching the code. Do not restate the full design here; follow the links.
A proof-of-concept agent library for small private mesh networks. A user A proof-of-concept agent library for small private mesh networks. A user
supplies a network name and one shared secret; agents derive the same network supplies a network name and one shared secret; agents derive the same network
space independently, find each other, prove membership and exchange control space independently, find each other, prove membership and exchange control
messages. Scope and non-scope are in [README.md](README.md). messages. What it does and does not do is in [docs/architecture.md](docs/architecture.md) and [docs/threat-model.md](docs/threat-model.md).
There is no central server, no owner, no registration and no majority vote. There is no central server, no owner, no registration and no majority vote.
Design accordingly: a majority is not a root of trust. Design accordingly: a majority is not a root of trust.
Generated
+12
View File
@@ -6250,6 +6250,7 @@ dependencies = [
"tsunagi-tcp-tls", "tsunagi-tcp-tls",
"tsunagi-wg-quic", "tsunagi-wg-quic",
"tsunagi-wireguard", "tsunagi-wireguard",
"windows-service",
] ]
[[package]] [[package]]
@@ -7135,6 +7136,17 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-service"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2"
dependencies = [
"bitflags 2.13.2",
"widestring",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "windows-strings" name = "windows-strings"
version = "0.1.0" version = "0.1.0"
+135 -718
View File
@@ -1,766 +1,183 @@
# tsunagi <p align="center">
<img src="dist/linux/tsunagi.svg" width="96" alt="">
A proof-of-concept Rust library for **small private mesh networks** — a handful </p>
of friends, home machines, a few servers. Units to dozens of participants, not
thousands. <h1 align="center">tsunagi</h1>
The end user configures exactly two things: <p align="center">
<b>A mesh VPN with no server.</b><br>
```text Pick a network name, share a secret, and your machines are on one private network.
network_name </p>
secret # one shared secret; "password" and "secret" mean the same value
``` <p align="center">
<a href="https://github.com/house-of-vanity/tsunagi/releases/latest"><img alt="release" src="https://img.shields.io/github/v/release/house-of-vanity/tsunagi?include_prereleases"></a>
From those, every agent independently derives the same network space. There is <a href="https://github.com/house-of-vanity/tsunagi/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/house-of-vanity/tsunagi/actions/workflows/ci.yml/badge.svg"></a>
no central server, no network owner with special powers, no registration and no <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-WTFPL-blue"></a>
majority vote. Anyone who knows the parameters can join; nobody has to trust </p>
anybody else.
```console
## What this proof of concept actually does laptop$ tsng join -n home
joined `home`
A working library with **real iroh connections** and integration tests:
- persistent device identity stored in SQLite, stable across restarts;
- several independent networks at once in one agent;
- deterministic network identity derived from name + secret;
- candidates supplied by a replaceable discovery component;
- 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;
- automatic reconnect with bounded exponential backoff and jitter;
- status snapshots, an event stream and honest diagnostics;
- configuration restored after a restart;
- correct behaviour when the disposable cache is missing or corrupt;
- a **WireGuard data plane**, in userspace: its own key per network, an IPv6
overlay with deterministically derived addresses and optional IPv4, real
tunnels carried over iroh, and address ownership enforced rather than
believed;
- a **command line agent**, `tsng`, with a local control socket.
### What it deliberately does **not** do
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
[docs/sync-model.md](docs/sync-model.md). The WireGuard plugin's own limits,
including that its system backend is Linux-only, are in
[docs/wireguard.md](docs/wireguard.md#limits-and-future-work).
**Control and data are separated logically, not physically.** Both ride on
iroh, on different ALPNs and different connections, so the data plane inherits
iroh's hole punching and relay fallback instead of reimplementing them — while
the control protocol still knows nothing about packets and can keep a different
transport underneath it later. Filtering user traffic remains the operating
system's and the user's responsibility, not this library's.
## Requirements
- Rust 1.91 or newer (iroh 1.2 requires it), edition 2024. Pinned dependencies
in `Cargo.lock`.
- 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
`--no-tun` skips even that.
## Trying it on two machines
On the first machine:
```bash
cargo build --release
./target/release/tsng status # this device, the agent, and this host
./target/release/tsng network # the networks this device belongs to
```
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
# Terminal one: the agent. It prints its endpoint id and then serves.
./target/release/tsng 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/tsng join --network lab
```
On the second machine, start its agent and join the same network with the
secret that was printed:
```bash
./target/release/tsng up
./target/release/tsng 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 wg-quic: direct via 192.0.2.7:41234, datagram 1382
--- status ---
control: 1 peer(s), 0 dial failure(s), 0 handshake failure(s)
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.
## Checking that it works
From another shell on either machine:
```bash
tsng status
```
```text
endpoint 7d76ccbbc21bf30767e14422c0494740a2cecc02aa9f82d5b8d57bdae350e7fc
hostname tsunagi-7d76ccbbc2
bound 0.0.0.0:41641
network lab (z2o4qwrvnj3zb6st2aoqg4abf342j662q2ujttqrsmz22argk2ba) active
peer b345d5271b tsunagi-b345d5271b Direct rtt 24ms
overlay tsunz2o4qwrvnj3 fd09:…:c1c6/64 and 100.110.49.177 mtu 1280 1/1 tunnel(s) up
SDsEb/WF fd09:…:c4b / 100.65.243.53 handshake 4s ago tx 0 rx 0 Direct via Ip(…)
```
`1/1 tunnel(s) up` and a recent handshake mean the tunnel is live. Then send
real traffic to the peer's overlay address:
```bash
ping6 fd09:…:c4b # or
ping 100.65.243.53
```
`tx` and `rx` in the status should start moving.
**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
tsng up --ipv4-range 10.44.0.0/16
tsng 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.
No vote is involved — see
[docs/wireguard.md](docs/wireguard.md#ipv4-allocated-signed-and-kept) and
[docs/sync-model.md](docs/sync-model.md).
Because the address is allocated at run time rather than derived, it is not
known until the agent has started and agreed with its peers. The agent then
assigns it to the interface itself.
## Levels
The command line is split the way the design is. A bare `tsng 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
tsng protocols # what this build can carry packets with
```
```text
wg-quic (wire version 4)
what WireGuard's cryptography carried in iroh's QUIC datagrams
-o keepalive=SECONDS keeps a tunnel and its link warm through a NAT
-o mtu=BYTES largest packet a tunnel will carry, at least 576
```
Each protocol declares its own settings, so the agent can list them without
knowing anything about the protocol, and a setting no selected protocol takes
is refused rather than ignored. `--protocol none` runs the control plane by
itself.
A pair of peers uses a protocol they both have **at the same wire version**.
That is not the software version: two peers on different builds carry traffic
for each other for as long as the bytes between them have not changed. A peer
with nothing in common keeps its control plane — messages and signed state
still flow — and simply has no data plane, which `tsng 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
tsng up # DNS is already enabled
dig @127.0.0.1 -p 5354 music.lab
```
An explicit choice is remembered across restarts. `--no-dns` or `tsng dns off`
disables it; `--dns` or `tsng dns on` enables it again. Existing saved opt-outs
remain respected. The `dns on` and `dns off` commands also take effect immediately
on a running agent. `--dns` and `--no-dns` cannot be combined.
The zone of a network is its name — `--dns-zone` is gone, because with
several networks there is no single zone to name. A network name may contain
dots, so `--network lab.internal` is how you get `music.lab.internal`.
Names come from signed state, which is the point: **a member that is
switched off still resolves**, because its claim outlived the session.
The answers are IPv4 addresses, because that is what the overlay is. The
*questions* are taken on `127.0.0.1` and `[::1]`, over UDP and TCP, so a
resolver reaches it over whichever family it uses; both are published to the
system resolver together. Loopback and nothing else: the zones are a view
for the host running the agent, and binding an overlay address would put
them in front of the whole mesh — an agent in two networks would then answer
one network's questions about the other's names.
The zone is the network name, and it is yours to choose. A name that
shadows a real public domain is reported and then used: a network called
`ru` warns that every public `.ru` name becomes unreachable from this host,
and then does it. Anything that collides with nothing — which is most names
— is said nothing about, because a warning that fires on every private name
anybody picks is how people learn to ignore warnings.
On Linux the agent tells systemd-resolved to send questions for that suffix
here, over D-Bus, scoped to the overlay interface and as a *routing* domain
so it never becomes the resolver for anything else. resolved drops the whole
setting when the interface goes, and the interface goes with the agent.
Only an interface the agent created, though. Under `--no-tun` there is no
host interface at all, and the agent says so and serves the zone on
loopback rather than configuring whatever else on the host happens to share
the name.
That last step needs permission that `CAP_NET_ADMIN` does not give:
systemd-resolved asks polkit, and polkit decides by **user**, not by
capability, so there is no way for the agent to arrange it from inside. On
a desktop the refusal reads `Interactive authentication required`.
Running as a system service is enough. Otherwise the agent prints the rule
that grants it — the four actions it calls and nothing else — ready to
paste:
```bash
sudo tee /etc/polkit-1/rules.d/50-tsunagi-resolved.rules > /dev/null <<'RULE'
polkit.addRule(function(action, subject) {
var allowed = [
"org.freedesktop.resolve1.set-dns-servers",
"org.freedesktop.resolve1.set-domains",
"org.freedesktop.resolve1.set-default-route",
"org.freedesktop.resolve1.revert"
];
if (allowed.indexOf(action.id) >= 0 && subject.user == "YOUR-USER") {
return polkit.Result.YES;
}
});
RULE
```
**Without it the server still runs** — `tsng status` prints where it is
listening and the exact `dig` line — so the automatic part is missing, not
the feature. The refusal is said once rather than on every pass, and retried
slowly, because nothing but a person will change it.
The server is authoritative for its zone and nothing else. No recursion, no
forwarding, no cache: pointing a resolver at it can never make it a route to
the outside.
## Privileges
On Linux the agent **manages its own overlay interface**. It creates the TUN
interface, sets the MTU, brings it up and assigns both overlay addresses, all
over netlink in process — no `ip` invocation, no shell, nothing that a
remote peer could influence.
That needs `CAP_NET_ADMIN`, granted once:
```bash
sudo setcap cap_net_admin+p /usr/local/bin/tsng
```
`+p` rather than `+ep`: the capability is then *permitted* but not
*effective*, and the agent raises it only around the handful of netlink calls
that need it — a few milliseconds at startup, and again if its address
allocation changes. Everything else, including every byte from the network,
is handled with it lowered. `+ep` works too; the agent lowers it on the way
in.
`tsng status` says which of these applies on the host it runs on, along
with what the agent is doing. It grades each finding: **ok** for what works,
**warn** for what the agent runs without and you can fix from the line it
prints, **FAIL** for what it cannot work around. The words carry the grade as
well as the colour, so the report reads the same piped to a file or on a
terminal without colour, and it honours `NO_COLOR`.
Members are listed online first, then the ones that are away. A member that
is away is reported plainly rather than flagged: in a mesh of laptops it is
the ordinary condition, not a fault. The signed state is what makes that
sayable — it remembers who belongs while they are gone, so the report can say
"offline, 10.13.37.99 still reserved for it" instead of leaving a
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.
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.
```
tsng id everything about this device
tsng id hostname the name it answers to
tsng id hostname mango change it
tsng id key the key it signs with
tsng id key rotate replace that key
tsng network the networks this device belongs to
tsng network join -n lab -s tsn1… join one; adds it to a running agent
tsng network join -n lab resume one this device has, or make it on the spot
tsng network stop lab stop serving it, keeping everything
tsng network start lab serve it again, from where it left off
tsng network leave lab give up the address and name, then forget it
tsng network secret the secret of each joined network
tsng network secret lab just that one, for copying
tsng network secret generate a fresh secret for a network that does not exist yet
tsng dns whether the local resolver is serving, and what
tsng dns on start it, now and after every restart
tsng dns off stop it, now and after every restart
```
**A network without a secret makes one.** `tsng 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… secret tsn1u7c…
Run this on the other machine: server$ tsng join -n home -s tsn1u7c…
server$ tsng id hostname server
tsng join --network lab --secret tsn1u7c… laptop$ ssh server.home
Its agent has to be running. If it is not:
tsng up --peer 91e83a6e2b7a…
``` ```
Two networks of one name is a thing that happens — a mistyped secret makes No account, no coordination server, no admin console, no keys to approve.
one — so joining says whether the network was already here, and warns when Anyone who knows the **name** and the **secret** is in. Nobody else can find
another configured network answers to the same name. A name is a label and the network, let alone enter it.
the id is the identity.
That is the ad-hoc case: one person makes a network and sends the command ## Why
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 Tailscale, Headscale and ZeroTier are great, but each needs a control server:
by `status`, not in a log, a `Debug` rendering or anything sent to a peer. theirs, or one you have to run and keep alive. tsunagi doesn't. Machines find
Asking for it is deliberate, because these reports get pasted into chats. each other through the public BitTorrent DHT, connect directly (hole punching,
with relay fallback), and keep the network state among themselves.
**`up` takes no network at all** if you would rather decide later: it | | Tailscale | Headscale | ZeroTier | **tsunagi** |
brings up the agent and whatever it is already configured for, and waits. | ---------------------- | ------------------ | ---------------- | ------------------- | ------------------- |
That is the shape of a daemon in one terminal and `tsng network join` | Control server | hosted by vendor | you run it | hosted or self-run | **none** |
in another. | Account / sign-in | yes | yes | yes | **no** |
| To join a network | sign in, get approved | pre-auth key | network ID, approval | **name + secret** |
| Direct connections | yes | yes | yes | yes |
| Exit nodes | yes | yes | via routes | yes |
`network join` is also the answer to a question `up` cannot: a state ## Features
directory belongs to one live agent, so a second `tsng 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 - **Two things to know:** a network name and a secret. A secret is generated for you when you make a network.
one: there is one record per author, a new version replaces the whole claim, - **Direct, encrypted links** between members (WireGuard cryptography), with relay fallback behind strict NATs.
and no replica can keep the old name standing. Changing it while the agent - **Names for machines:** `server.home` just works, even while `server` is switched off.
runs goes through the agent, which republishes and tells its peers straight - **Exit nodes:** send all your internet traffic through any member that offers one.
away. - **Multi-hop:** members that can't reach each other directly are routed through a member that can.
- **LAN games:** UDP broadcasts are relayed, so "LAN" lobbies show up across the mesh.
- **Several networks at once** on one device.
- **Tray app** for Windows, macOS and Linux; a command line for everything.
Replacing the signing key makes this device a different member, and it loses ## Install
the address and name the old key held — nothing can sign on a retired key's
behalf, and by design there is no authority that could overrule an author. So
the outgoing key signs a release for every network on its way out, which
frees them for whoever wants them next, and the whole thing commits at once.
`status` and `id` both prefer a running agent, which is live and Grab a build from the [latest release](https://github.com/house-of-vanity/tsunagi/releases/latest).
authoritative, and fall back to reading the state store when there is none.
Reading takes no directory lock, so neither has to wait for the agent it is
asking about — nor does either need one to be running.
### It cleans up after itself | Platform | How |
| --------------------- | -------------------------------------------------------------------------------- |
| **Windows** | Run `tsunagi-setup-<version>-x86_64.exe`. Installs the service, the tray and `tsng`. |
| **macOS** | `brew tap house-of-vanity/tap && brew install --cask tsunagi-gui`<br>CLI only: `brew install tsunagi` |
| **Debian / Ubuntu** | `sudo apt install ./tsunagi-gui_<version>_amd64.deb` (`tsunagi_…` for no tray) |
| **Arch** | `sudo pacman -U tsunagi-gui-<version>-1-x86_64.pkg.tar.zst` |
| **NixOS** | [Flake and module](#nixos) below |
| **Any other Linux** | Unpack the tarball, then `sudo ./install.sh && sudo systemctl enable --now tsunagi` |
The interface is tied to an open file descriptor and is deliberately **not** Linux packages run the agent as an unprivileged service user. Add yourself to
made persistent, so the kernel removes it when the agent exits — on a clean the `tsunagi` group (`sudo usermod -aG tsunagi $USER`, then log in again) to
shutdown, on a panic, on `SIGKILL`, on power loss alike. Keeping it is what use `tsng` and the tray without `sudo`.
would take an action; removing it is the default.
If something is left behind anyway — an interface made by an older version's ## Quick start
manual recipe, or one from a run killed in the instant between creating it and
recording it — the next start **replaces it**, along with any stale addresses
it carried. Two things are never touched:
* an interface that is not a TUN, because the name colliding with somebody's ```sh
bridge is not a reason to destroy the bridge; tsng join -n home # first machine: makes the network and prints a secret
* a TUN that another process is holding open, because that is a working tsng join -n home -s tsn1… # every other machine
overlay belonging to somebody else — most likely a second agent on this tsng status # who is online, over which path
host, which should be given a different `--wg-prefix`.
Both of those refuse with an explanation rather than guessing.
Two settings the manual recipe used to need are gone with it.
`keep_addr_on_down` existed only because an interface nobody held open lost
carrier and had its IPv6 addresses flushed, and `nodad` only because duplicate
address detection can never finish without carrier. An interface held open for
its whole life has carrier for its whole life.
### The MTU is 1280
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
| approach | agent runs as | notes |
|---|---|---|
| `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 tsng 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 |
On Windows, put `wintun.dll` beside the executable and start `tsng 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 tsng 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
```bash
cargo fmt --all -- --check
cargo clippy --locked --workspace --all-targets -- -D warnings
cargo test --locked --workspace --all-targets
``` ```
The whole suite runs offline on loopback. Set `TSUNAGI_TEST_LOG=tsunagi=debug` Every member gets an address in `10.13.37.0/24` and a name like `<host>.home`.
to see agent logs while a test runs. Machines are called `tsunagi-<id>` until you rename them: `tsng id hostname laptop`.
There are also two runnable demos, which are demos and not substitutes for the ### Exit nodes
tests:
```bash ```sh
cargo run --example two_agents # control plane only tsng network exit-node home on # on the machine that should be the exit
tsng exit-node # on another one: who offers an exit
tsng exit-node laptop # send everything through it
tsng exit-node off
``` ```
Both run with no privileges and change nothing on the host. The tray does the same with a checkbox. An exit node sees everything you send
through it, so use only members you trust.
[Details and per-OS notes](docs/exit-node.md).
## Usage ## Platforms
```rust,no_run | Platform | Build | CLI `tsng` | Tray | Exit node: offer / use | Packages |
use std::sync::Arc; | ------------------------- | ------------ | :--------: | :--: | :--------------------: | ----------------------------------- |
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy}; | Linux x86_64 | release | ✔ | ✔ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball, Nix |
use tsunagi::discovery::SharedMemoryDiscovery; | Linux aarch64 | release | ✔ | –¹ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball, Nix |
use tsunagi::identity::{NetworkName, NetworkSecret}; | Linux armv7 | release | ✔ | –¹ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball |
use tsunagi::proto::ControlMessage; | macOS, Apple silicon | release | ✔ | ✔ | ✔ / ✔² | Homebrew, tarball |
use tsunagi::{Agent, Result}; | macOS, Intel | from source | ✔³ | ✔³ | ✔ / ✔²³ | – |
| Windows x86_64 | release | ✔ | ✔ | ✔ / ✔² | installer, zip |
// The library never starts a runtime, installs a logger, handles signals, <sup>¹ Not in the release; builds from source (and through Nix).
// forks, or calls process::exit. The binary owns all of that. ² Newer and less battle-tested than on Linux.
#[tokio::main] ³ Builds from source, not covered by CI.</sup>
async fn main() -> Result<()> {
let config = AgentConfig::new(StoragePaths::user_default()?)
.with_transport(TransportPolicy::N0Defaults)
.with_discovery(Arc::new(SharedMemoryDiscovery::new()));
let agent = Agent::spawn(config).await?; The Linux CLI is a single static binary: it runs on any distribution.
let name = NetworkName::new("kitchen-table")?; ## NixOS
let secret = NetworkSecret::generate(); // 32 random bytes
println!("share this: {}", secret.encode().as_str());
let network = agent.join_network(&name, &secret).await?; The repository is a flake with a package and a module.
let mut events = agent.subscribe(); ```nix
tokio::spawn(async move { # flake.nix
while let Ok(event) = events.recv().await { {
println!("{event:?}"); inputs.tsunagi.url = "github:house-of-vanity/tsunagi";
outputs = { nixpkgs, tsunagi, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
tsunagi.nixosModules.default
{
services.tsunagi = {
enable = true;
tray.enable = true; # optional, for desktops
users = [ "alice" ]; # may use tsng and the tray without sudo
};
} }
}); ];
};
for peer in agent.network_status(network).await?.connected_peers() { };
agent
.send(network, peer, ControlMessage::Ping { seq: 1, payload: vec![] })
.await?;
}
agent.shutdown().await;
Ok(())
} }
``` ```
`TransportPolicy::LocalOnly` is the default, so a plain `AgentConfig::new` never Just trying it: `nix run github:house-of-vanity/tsunagi#tsunagi-cli -- --help`.
reaches the internet by accident. Opt into `DirectOnly` or `N0Defaults` `packages.<system>.tsunagi` (with the tray) and `tsunagi-cli` are also exported,
explicitly. along with `overlays.default`.
### Through somebody in the middle ## Build from source
Tsunagi routes the overlay in userspace. A direct data link always wins; Rust 1.91 or newer.
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 ```sh
tsng join -n games --no-broadcast cargo build --release --bin tsng # the agent and CLI
tsng join -n games --broadcast cargo build --release --bin tsunagi-tray # the tray (Linux needs GTK 3 and libayatana-appindicator)
tsng network broadcast games off
tsng network broadcast games on
``` ```
The choice persists across restarts and a plain `join`. It can be changed while Windows needs [Wintun](https://www.wintun.net) next to `tsng.exe`.
the agent runs; `status` shows it for each network. While broadcast is on, the Per-platform notes: [Linux](dist/linux/README.md), [Arch](dist/arch/README.md),
agent routes `255.255.255.255` through the Tsunagi interface and allows inbound [macOS](dist/macos/README.md), [Windows](dist/windows/README.md).
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 ## How it works
Two different lookups are involved, and only one of them is this project's: - Your name and secret derive a network identity. Members publish signed
contact records to the public Mainline DHT under it, and find each other there.
- Every connection proves knowledge of the secret before it's accepted.
Traffic between members is end-to-end encrypted, relays included.
- Addresses and names are claimed with each device's own key and replicated
between members. There is no one in charge, so nothing to be taken down.
**1. Resolving one endpoint's address — iroh's, and it works today.** The secret is the only key, so make it a good one (the generated ones are).
With `--reach relay` or `--reach direct`, iroh publishes a signed The [threat model](docs/threat-model.md) says what this does and doesn't protect against.
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 — 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. `--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.
## Storage
Two physically separate SQLite files, placed wherever the library's
configuration says (`StoragePaths`). A future system service supplies its own
paths; tests always use temporary directories.
| file | holds | when damaged |
|----------------|--------------------------------------------------|-------------------------|
| `state.sqlite` | device identity, network configuration, hostname | clear error, never reset |
| `cache.sqlite` | address hints and other recoverable data | discarded and recreated |
The `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. That directory *is* the identity: one
agent, one device key, one interface, and as many networks as you like on it
— `tsng network join` adds them to the agent that is already running,
which is why a second `tsng 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
tsng network # what this device belongs to
tsng network leave lab # give up the address and the name, then forget it
tsng 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 ## Documentation
- [docs/architecture.md](docs/architecture.md) — module boundaries and runtime. [Architecture](docs/architecture.md) ·
- [docs/wireguard.md](docs/wireguard.md) — the WireGuard plugin: overlay [Protocol](docs/protocol.md) ·
addressing, announcements, backends, reconciliation. [WireGuard data plane](docs/wireguard.md) ·
- [docs/protocol.md](docs/protocol.md) — identity derivation, framing, handshake. [Routing](docs/routing.md) ·
- [docs/sync-model.md](docs/sync-model.md) — the planned signed-state model and [Exit nodes](docs/exit-node.md) ·
what is deliberately not built yet. [Threat model](docs/threat-model.md) ·
- [docs/exit-node.md](docs/exit-node.md) — exit nodes: sending all traffic through a member. [Contributing](AGENTS.md)
- [docs/threat-model.md](docs/threat-model.md) — threat model and known limits.
- [docs/testing.md](docs/testing.md) — what the suite covers and what it does not.
- [AGENTS.md](AGENTS.md) — rules for anyone (human or agent) changing this repo.
## Security in one paragraph
Membership is proved by an HMAC over a transcript keyed by a value derived from
the shared secret, bound to the specific iroh connection through the TLS
exporter, to the network id, to both endpoint identities and to distinct role
labels. This targets high-entropy secrets: there is no PAKE here, so a short
human passphrase is guessable offline by anyone who can reach the handshake.
Anyone who knows the secret is a full participant and can create many
identities. Read [docs/threat-model.md](docs/threat-model.md) before relying on
any of this.
## Licence ## Licence
+5
View File
@@ -800,6 +800,11 @@ impl Agent {
// itself stays, because the agent owns it and other networks may // itself stays, because the agent owns it and other networks may
// still be using it. // still be using it.
self.inner.routes.remove_network(network_id); self.inner.routes.remove_network(network_id);
if let Some(interface) = self.inner.interface.get() {
interface
.set_underlay(network_id, Vec::new(), Vec::new())
.await;
}
if let Some(interface) = self.inner.interface.get() if let Some(interface) = self.inner.interface.get()
&& let Err(err) = interface.sync_addresses().await && let Err(err) = interface.sync_addresses().await
{ {
+73
View File
@@ -712,6 +712,34 @@ impl Runtime {
self.start_dials(); self.start_dials();
// Last, so it carries what this round learned. // Last, so it carries what this round learned.
self.introduce_peers(); self.introduce_peers();
self.refresh_underlay().await;
}
/// Hands the interface the underlay destinations this network depends
/// on: the verified direct IPv4 paths of every control connection, and
/// the relays in use. Only hosts that cannot exempt the agent from an
/// exit node's default route read it.
async fn refresh_underlay(&self) {
let Some(interface) = self.params.interface.clone() else {
return;
};
let mut addresses = Vec::new();
let mut hosts = Vec::new();
for session in self.sessions.values() {
for path in snapshot_connection(&session.conn).paths {
match path.remote {
PathAddr::Ip(std::net::SocketAddr::V4(socket)) => addresses.push(*socket.ip()),
PathAddr::Relay(url) => hosts.extend(relay_host(&url)),
_ => {}
}
}
}
for url in self.params.adapter.snapshot().relay_urls {
hosts.extend(relay_host(&url));
}
interface
.set_underlay(self.network_id, addresses, hosts)
.await;
} }
fn add_candidate(&mut self, candidate: Candidate) { fn add_candidate(&mut self, candidate: Candidate) {
@@ -2448,3 +2476,48 @@ mod tests {
assert_eq!(chosen_restored.copied(), Some("wg")); assert_eq!(chosen_restored.copied(), Some("wg"));
} }
} }
/// The host name of a relay URL such as `https://relay.example:443/`.
fn relay_host(url: &str) -> Option<String> {
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
let authority = rest.split(['/', '?', '#']).next()?;
let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
let host = if host.starts_with('[') {
host.split_once(']')?.0.trim_start_matches('[')
} else {
host.split(':').next()?
};
(!host.is_empty()
&& host.len() <= 253
&& host
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == ':'))
.then(|| host.to_ascii_lowercase())
}
#[cfg(test)]
mod relay_host_tests {
use super::relay_host;
#[test]
fn the_host_is_taken_out_of_a_relay_url() {
assert_eq!(
relay_host("https://relay.example.com./").as_deref(),
Some("relay.example.com.")
);
assert_eq!(
relay_host("https://Euw1-1.relay.iroh.network:443/x").as_deref(),
Some("euw1-1.relay.iroh.network")
);
assert_eq!(
relay_host("http://192.0.2.7:3340").as_deref(),
Some("192.0.2.7")
);
assert_eq!(
relay_host("https://[2001:db8::1]:443/").as_deref(),
Some("2001:db8::1")
);
assert_eq!(relay_host("https:///").as_deref(), None);
assert_eq!(relay_host("https://bad host/").as_deref(), None);
}
}
@@ -35,6 +35,17 @@ pub struct ExitHostPlan {
pub offer: Vec<Ipv4Range>, pub offer: Vec<Ipv4Range>,
/// Whether this device sends its internet traffic through a member. /// Whether this device sends its internet traffic through a member.
pub client: bool, pub client: bool,
/// The range of every network on the interface. Only the using side
/// reads it, to keep this agent's own traffic *to* the overlay inside it
/// where the host has no per-user routing (macOS).
pub overlay: Vec<Ipv4Range>,
/// Underlay addresses this agent talks to directly and must keep reaching
/// outside the overlay's default route. Only filled while `client` is set
/// and only for hosts that report [`ExitHostRules::needs_bypass`].
pub bypass: Vec<std::net::Ipv4Addr>,
/// Host names (relay servers) that must stay reachable the same way; the
/// host rules resolve them.
pub bypass_hosts: Vec<String>,
} }
impl ExitHostPlan { impl ExitHostPlan {
@@ -72,6 +83,13 @@ pub trait ExitHostRules: Send + Sync + std::fmt::Debug + 'static {
/// when nothing is there: it runs on the shutdown path, and once at /// when nothing is there: it runs on the shutdown path, and once at
/// startup to take away what a crashed run left. /// startup to take away what a crashed run left.
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>; fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>;
/// Whether using an exit node on this host needs the underlay addresses
/// the agent talks to, because the host cannot exempt the agent's own
/// traffic from the default route by user id (Windows). The default is no.
fn needs_bypass(&self) -> bool {
false
}
} }
/// The tag every firewall object of one offered range carries. /// The tag every firewall object of one offered range carries.
@@ -99,6 +117,7 @@ pub struct MockExitRules {
calls: Arc<Mutex<Vec<String>>>, calls: Arc<Mutex<Vec<String>>>,
forwarding: Arc<Mutex<Option<bool>>>, forwarding: Arc<Mutex<Option<bool>>>,
failure: Arc<Mutex<Option<String>>>, failure: Arc<Mutex<Option<String>>>,
needs_bypass: Arc<Mutex<bool>>,
} }
impl MockExitRules { impl MockExitRules {
@@ -114,6 +133,11 @@ impl MockExitRules {
*lock(&self.forwarding) = forwarding; *lock(&self.forwarding) = forwarding;
} }
/// Whether this host asks for the underlay addresses, like Windows.
pub fn set_needs_bypass(&self, needs: bool) {
*lock(&self.needs_bypass) = needs;
}
/// Makes every step fail with this reason from now on. /// Makes every step fail with this reason from now on.
pub fn fail_with(&self, reason: Option<String>) { pub fn fail_with(&self, reason: Option<String>) {
*lock(&self.failure) = reason; *lock(&self.failure) = reason;
@@ -138,6 +162,10 @@ fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
} }
impl ExitHostRules for MockExitRules { impl ExitHostRules for MockExitRules {
fn needs_bypass(&self) -> bool {
*lock(&self.needs_bypass)
}
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> { fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
Box::pin(async move { Box::pin(async move {
lock(&self.calls).push(format!("apply:{}", plan.interface)); lock(&self.calls).push(format!("apply:{}", plan.interface));
@@ -204,6 +232,8 @@ mod tests {
interface: "tsun0".into(), interface: "tsun0".into(),
offer: vec![range()], offer: vec![range()],
client: true, client: true,
overlay: vec![range()],
..Default::default()
}; };
let report = rules.apply(&plan).await; let report = rules.apply(&plan).await;
assert_eq!(report.offer[0].1, RuleOutcome::Applied); assert_eq!(report.offer[0].1, RuleOutcome::Applied);
@@ -218,6 +248,8 @@ mod tests {
interface: "tsun0".into(), interface: "tsun0".into(),
offer: vec![range()], offer: vec![range()],
client: false, client: false,
overlay: Vec::new(),
..Default::default()
}) })
.await; .await;
assert_eq!(report.forwarding, Some(false)); assert_eq!(report.forwarding, Some(false));
@@ -19,6 +19,10 @@
use crate::BoxFuture; use crate::BoxFuture;
use std::sync::{Arc, Mutex};
use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules};
use super::macos_exit::{apply_plan, clear_all, run_tool};
use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome}; use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome};
/// The limited-broadcast destination routed through the overlay interface. /// The limited-broadcast destination routed through the overlay interface.
@@ -60,14 +64,19 @@ fn firewall_todo(plan: &BroadcastRulesPlan) -> String {
) )
} }
/// The macOS implementation: `route` for the route, no firewall yet. /// The macOS implementation: `route` for the route, no firewall yet, and
/// `pf` plus `route` for exit nodes (see [`super::macos_exit`]).
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct MacosHostRules; pub struct MacosHostRules {
/// The reference `pfctl -E` handed out, held while exit-node rules need
/// `pf` on and released when they are removed.
pub(super) pf_token: Arc<Mutex<Option<String>>>,
}
impl MacosHostRules { impl MacosHostRules {
/// Creates the host rules. /// Creates the host rules.
pub fn new() -> Self { pub fn new() -> Self {
Self Self::default()
} }
/// Installs the route, replacing one this agent added earlier so the call /// Installs the route, replacing one this agent added earlier so the call
@@ -82,11 +91,46 @@ impl MacosHostRules {
} }
} }
impl ExitHostRules for MacosHostRules {
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
Box::pin(async move {
let job_plan = plan.clone();
let token = Arc::clone(&self.pf_token);
tokio::task::spawn_blocking(move || apply_plan(&run_tool, &token, &job_plan))
.await
.unwrap_or_else(|err| {
let failed = RuleOutcome::Failed(format!("the task failed: {err}"));
ExitHostReport {
offer: plan
.offer
.iter()
.map(|range| (*range, failed.clone()))
.collect(),
forwarding: None,
client: plan.client.then_some(failed),
}
})
})
}
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
let name = interface.to_string();
let token = Arc::clone(&self.pf_token);
let _ = tokio::task::spawn_blocking(move || clear_all(&run_tool, &token, &name)).await;
})
}
}
impl BroadcastHostRules for MacosHostRules { impl BroadcastHostRules for MacosHostRules {
fn name(&self) -> &str { fn name(&self) -> &str {
"route" "route"
} }
fn exit_rules(&self) -> Option<&dyn ExitHostRules> {
Some(self)
}
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> { fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
Box::pin(async move { Box::pin(async move {
let route = self.set_route(plan.interface.clone()).await; let route = self.set_route(plan.interface.clone()).await;
@@ -0,0 +1,915 @@
//! Exit-node host rules on macOS: a `pf` anchor and `route` entries.
//!
//! Compiled on every platform so the rule text and the parsers are tested
//! everywhere; only `MacosHostRules` runs them, and only on macOS.
//!
//! # Offering
//!
//! Per offered range, in the anchor `com.apple/tsunagi-exit-<if>`:
//!
//! ```text
//! nat on <egress> inet from <range> to any -> (<egress>)
//! pass in quick on <if> inet from <range> to ! <range>
//! ```
//!
//! The anchor lives under `com.apple/` on purpose: the stock `/etc/pf.conf`
//! evaluates `nat-anchor "com.apple/*"` and `anchor "com.apple/*"`, so the
//! rules take effect without editing the user's ruleset. `pf` cannot say
//! "every interface but one" in a translation address, so the egress is the
//! interface of the default route **when the rules are applied**; after the
//! default route moves (Wi-Fi to Ethernet) the rules are re-applied by
//! switching the exit node off and on, or restarting the agent.
//!
//! The kernel's `net.inet.ip.forwarding` is only read, never written: it
//! changes how the whole host behaves, which is the owner's decision.
//!
//! # Using
//!
//! macOS has no `uidrange` policy routing, and the agent is root, so the
//! Linux scheme does not carry over. Instead:
//!
//! ```text
//! route add -net 0.0.0.0/1 -interface <if>
//! route add -net 128.0.0.0/1 -interface <if>
//! pass out quick on <if> route-to (<egress> <gateway>) inet proto { tcp udp }
//! from any to ! <overlay ranges> user <agent uid>
//! nat on <egress> inet from (<if>) to any -> (<egress>)
//! ```
//!
//! The two halves outrank the default route without replacing it, and every
//! more specific route (LAN, another VPN) keeps working. The `route-to` rule
//! sends the *agent's own* traffic — QUIC, relay, DHT, which would otherwise
//! loop into the tunnel it carries — back out the physical interface, and the
//! translation gives it that interface's address, since an unbound socket
//! picked the overlay address when the route pointed at the overlay. Like the
//! Linux exemption this is by user id, so anything else running as root also
//! bypasses the tunnel.
//!
//! IPv6 is blocked while an exit node is in use, as on Linux: `::/1` and
//! `8000::/1` are rejecting routes, so applications fall back to IPv4 at once
//! instead of leaking.
//!
//! Every object is derived from local state and the interface the agent owns;
//! none of it comes from a peer.
use std::net::Ipv4Addr;
use std::sync::Mutex;
use crate::state::Ipv4Range;
use super::RuleOutcome;
use super::exit::{ExitHostPlan, ExitHostReport};
/// The programs this module runs, always by absolute path.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Tool {
Route,
Pfctl,
Sysctl,
Id,
}
impl Tool {
fn path(self) -> &'static str {
match self {
Self::Route => "/sbin/route",
Self::Pfctl => "/sbin/pfctl",
Self::Sysctl => "/usr/sbin/sysctl",
Self::Id => "/usr/bin/id",
}
}
}
/// What running a tool came to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Ran {
/// The program is not there.
Missing,
/// It ran.
Exited {
success: bool,
stdout: String,
stderr: String,
},
}
/// Runs a tool with optional standard input; injectable so the logic is
/// tested without touching the host.
pub(super) type Run<'a> = &'a dyn Fn(Tool, &[String], Option<&str>) -> Ran;
/// Runs a real tool. Blocking: callers use `spawn_blocking`.
pub(super) fn run_tool(tool: Tool, args: &[String], stdin: Option<&str>) -> Ran {
use std::io::Write;
use std::process::{Command, Stdio};
let mut command = Command::new(tool.path());
command
.args(args)
.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ran::Missing,
Err(err) => {
return Ran::Exited {
success: false,
stdout: String::new(),
stderr: format!("could not run {}: {err}", tool.path()),
};
}
};
if let (Some(text), Some(mut pipe)) = (stdin, child.stdin.take()) {
// A write error shows up as the tool's own failure below.
let _ = pipe.write_all(text.as_bytes());
}
match child.wait_with_output() {
Ok(output) => Ran::Exited {
success: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
},
Err(err) => Ran::Exited {
success: false,
stdout: String::new(),
stderr: format!("could not run {}: {err}", tool.path()),
},
}
}
fn owned(words: &[&str]) -> Vec<String> {
words.iter().map(|word| word.to_string()).collect()
}
/// A name that is safe in a `pf` rule, an anchor path and a table name.
fn safe_name(name: &str) -> bool {
!name.is_empty() && name.len() <= 32 && name.chars().all(|c| c.is_ascii_alphanumeric())
}
/// The anchor holding every rule of one interface.
fn anchor_name(interface: &str) -> Result<String, String> {
if safe_name(interface) {
Ok(format!("com.apple/tsunagi-exit-{interface}"))
} else {
Err(format!(
"`{interface}` is not an interface name this agent can write a pf rule for"
))
}
}
/// The default route as `route -n get default` reports it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct DefaultRoute {
pub interface: String,
pub gateway: Option<Ipv4Addr>,
}
/// Reads `interface:` and `gateway:` out of `route -n get default`.
fn parse_default_route(text: &str) -> Option<DefaultRoute> {
let field = |name: &str| {
text.lines().find_map(|line| {
let (key, value) = line.split_once(':')?;
(key.trim() == name).then(|| value.trim().to_string())
})
};
let interface = field("interface").filter(|name| safe_name(name))?;
let gateway = field("gateway").and_then(|value| value.parse().ok());
Some(DefaultRoute { interface, gateway })
}
fn failure(stderr: &str, stdout: &str) -> String {
let text = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
if text.is_empty() {
"the command failed without saying why".to_string()
} else {
text.to_string()
}
}
/// Runs a tool and turns a failure into a sentence a person can act on.
fn run_ok(
run: Run<'_>,
tool: Tool,
args: &[String],
stdin: Option<&str>,
) -> Result<String, String> {
match run(tool, args, stdin) {
Ran::Missing => Err(format!("{} not found", tool.path())),
Ran::Exited {
success: true,
stdout,
..
} => Ok(stdout),
Ran::Exited { stderr, stdout, .. } => Err(format!(
"{} {} failed: {}",
tool.path(),
args.join(" "),
failure(&stderr, &stdout)
)),
}
}
/// The route traffic leaves by now, which is the egress for translation and
/// for the agent's own sockets. Read *before* the overlay routes are added.
fn default_route(run: Run<'_>, overlay: &str) -> Result<DefaultRoute, String> {
let out = run_ok(run, Tool::Route, &owned(&["-n", "get", "default"]), None)
.map_err(|why| format!("cannot find the default route: {why}"))?;
let route = parse_default_route(&out)
.ok_or_else(|| "the host has no default route to send traffic out of".to_string())?;
if route.interface == overlay {
return Err(format!(
"the default route already goes through `{overlay}`"
));
}
Ok(route)
}
/// Whether the kernel forwards IPv4. Read only.
fn forwarding_state(run: Run<'_>) -> Option<bool> {
let out = run_ok(
run,
Tool::Sysctl,
&owned(&["-n", "net.inet.ip.forwarding"]),
None,
)
.ok()?;
match out.trim() {
"1" => Some(true),
"0" => Some(false),
_ => None,
}
}
/// The effective user id of this process, from `id -u`.
fn agent_uid(run: Run<'_>) -> Result<u32, String> {
run_ok(run, Tool::Id, &owned(&["-u"]), None)
.ok()
.and_then(|out| out.trim().parse().ok())
.ok_or_else(|| "cannot tell which user this agent runs as".to_string())
}
/// What the using side adds to the rules.
#[derive(Debug, Clone, PartialEq, Eq)]
struct ClientRules<'a> {
uid: u32,
/// Every overlay range: the agent's own traffic *to* those still goes
/// through the interface.
overlay: &'a [Ipv4Range],
}
/// The text of the anchor: translation first, filtering after, which is the
/// order `pf` insists on.
fn pf_rules(
interface: &str,
egress: &DefaultRoute,
offer: &[Ipv4Range],
client: Option<&ClientRules<'_>>,
) -> String {
let out = &egress.interface;
let mut nat = Vec::new();
let mut filter = Vec::new();
for range in offer {
nat.push(format!("nat on {out} inet from {range} to any -> ({out})"));
filter.push(format!(
"pass in quick on {interface} inet from {range} to ! {range}"
));
}
let mut tables = Vec::new();
if let Some(client) = client {
nat.push(format!(
"nat on {out} inet from ({interface}) to any -> ({out})"
));
let target = if client.overlay.is_empty() {
"any".to_string()
} else {
let ranges: Vec<String> = client.overlay.iter().map(|r| r.to_string()).collect();
// A table, because `! { a, b }` expands to one rule per element
// in older `pf`, which matches everything.
tables.push(format!(
"table <tsunagi_overlay_{interface}> const {{ {} }}",
ranges.join(", ")
));
format!("! <tsunagi_overlay_{interface}>")
};
let via = match egress.gateway {
Some(gateway) => format!("({out} {gateway})"),
None => out.clone(),
};
filter.push(format!(
"pass out quick on {interface} route-to {via} inet proto {{ tcp udp }} \
from any to {target} user {}",
client.uid
));
}
tables
.into_iter()
.chain(nat)
.chain(filter)
.collect::<Vec<_>>()
.join("\n")
+ "\n"
}
/// Turns `pf` on if nobody has, holding the reference `pfctl` hands out so it
/// can be released when the agent is done. One reference at most.
fn acquire_pf(run: Run<'_>, slot: &Mutex<Option<String>>) -> Result<(), String> {
if lock(slot).is_some() {
return Ok(());
}
match run(Tool::Pfctl, &owned(&["-E"]), None) {
Ran::Missing => Err(format!("{} not found", Tool::Pfctl.path())),
Ran::Exited {
success,
stdout,
stderr,
} => {
// `pfctl -E` reports `Token : <n>` on stderr, and also says so
// when pf is already enabled.
let token = stdout.lines().chain(stderr.lines()).find_map(parse_token);
if !success && token.is_none() {
return Err(format!("cannot enable pf: {}", failure(&stderr, &stdout)));
}
*lock(slot) = token;
Ok(())
}
}
}
fn parse_token(line: &str) -> Option<String> {
let (key, value) = line.split_once(':')?;
let value = value.trim();
(key.trim().eq_ignore_ascii_case("token")
&& !value.is_empty()
&& value.chars().all(|c| c.is_ascii_digit()))
.then(|| value.to_string())
}
fn release_pf(run: Run<'_>, slot: &Mutex<Option<String>>) {
if let Some(token) = lock(slot).take()
&& let Err(why) = run_ok(run, Tool::Pfctl, &["-X".to_string(), token], None)
{
tracing::debug!(%why, "cannot release the pf reference");
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn flush_args(anchor: &str) -> Vec<String> {
owned(&["-q", "-a", anchor, "-F", "all"])
}
fn load_args(anchor: &str) -> Vec<String> {
owned(&["-q", "-a", anchor, "-f", "-"])
}
/// The two halves that out-rank the default route.
const HALVES: [&str; 2] = ["0.0.0.0/1", "128.0.0.0/1"];
/// The IPv6 halves, made unreachable.
const HALVES_V6: [&str; 2] = ["::/1", "8000::/1"];
fn route_add_args(half: &str, interface: &str) -> Vec<String> {
owned(&["-q", "-n", "add", "-net", half, "-interface", interface])
}
fn route_del_args(half: &str, interface: &str) -> Vec<String> {
owned(&["-q", "-n", "delete", "-net", half, "-interface", interface])
}
fn route_add_v6_args(half: &str) -> Vec<String> {
owned(&["-q", "-n", "add", "-inet6", "-net", half, "::1", "-reject"])
}
fn route_del_v6_args(half: &str) -> Vec<String> {
owned(&["-q", "-n", "delete", "-inet6", "-net", half, "::1"])
}
/// Takes away the overlay default routes. Best effort: what is not there is
/// the state wanted.
fn clear_routes(run: Run<'_>, interface: &str) {
for half in HALVES {
let _ = run(Tool::Route, &route_del_args(half, interface), None);
}
for half in HALVES_V6 {
let _ = run(Tool::Route, &route_del_v6_args(half), None);
}
}
fn set_routes(run: Run<'_>, interface: &str) -> Result<(), String> {
clear_routes(run, interface);
for half in HALVES {
if let Err(why) = run_ok(run, Tool::Route, &route_add_args(half, interface), None) {
clear_routes(run, interface);
return Err(why);
}
}
for half in HALVES_V6 {
// A host without IPv6 has nothing to block; not a failure.
if let Err(why) = run_ok(run, Tool::Route, &route_add_v6_args(half), None) {
tracing::debug!(%why, "cannot block IPv6 for the exit node");
}
}
Ok(())
}
/// Makes the host match the plan.
pub(super) fn apply_plan(
run: Run<'_>,
token: &Mutex<Option<String>>,
plan: &ExitHostPlan,
) -> ExitHostReport {
let forwarding = if plan.offer.is_empty() {
None
} else {
forwarding_state(run)
};
let outcome = apply_rules(run, token, plan);
let verdict = |ok: bool, why: &str| {
if ok {
RuleOutcome::Applied
} else {
RuleOutcome::Failed(why.to_string())
}
};
match outcome {
Ok(()) => ExitHostReport {
offer: plan
.offer
.iter()
.map(|range| (*range, RuleOutcome::Applied))
.collect(),
forwarding,
client: plan.client.then_some(RuleOutcome::Applied),
},
Err(why) => ExitHostReport {
offer: plan
.offer
.iter()
.map(|range| (*range, verdict(false, &why)))
.collect(),
forwarding,
client: plan.client.then(|| verdict(false, &why)),
},
}
}
fn apply_rules(
run: Run<'_>,
token: &Mutex<Option<String>>,
plan: &ExitHostPlan,
) -> Result<(), String> {
let interface = plan.interface.as_str();
let anchor = anchor_name(interface)?;
let egress = default_route(run, interface)?;
let uid = if plan.client {
Some(agent_uid(run)?)
} else {
None
};
let client = uid.map(|uid| ClientRules {
uid,
overlay: &plan.overlay,
});
let rules = pf_rules(interface, &egress, &plan.offer, client.as_ref());
acquire_pf(run, token)?;
run_ok(run, Tool::Pfctl, &load_args(&anchor), Some(&rules))
.map_err(|why| format!("cannot load the pf rules: {why}"))?;
// The routes come after the rules that keep the agent's own traffic off
// them: the other order would loop for as long as it takes.
if plan.client {
set_routes(run, interface)?;
} else {
clear_routes(run, interface);
}
Ok(())
}
/// Removes everything this agent put there for the interface.
pub(super) fn clear_all(run: Run<'_>, token: &Mutex<Option<String>>, interface: &str) {
clear_routes(run, interface);
if let Ok(anchor) = anchor_name(interface) {
// Flushing an anchor nothing was ever loaded into is not an error
// worth reporting.
if let Err(why) = run_ok(run, Tool::Pfctl, &flush_args(&anchor), None) {
tracing::debug!(%why, "cannot flush the exit node pf anchor");
}
}
release_pf(run, token);
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::cell::RefCell;
use super::*;
fn range() -> Ipv4Range {
"10.13.37.0/24".parse().unwrap()
}
const DEFAULT_ROUTE: &str = " route to: default\n\
destination: default\n\
mask: default\n\
gateway: 192.168.1.1\n\
interface: en0\n\
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>\n";
fn egress() -> DefaultRoute {
DefaultRoute {
interface: "en0".into(),
gateway: Some(Ipv4Addr::new(192, 168, 1, 1)),
}
}
/// A scripted host that records every call.
struct Fake {
calls: RefCell<Vec<String>>,
stdins: RefCell<Vec<String>>,
default_route: Option<String>,
forwarding: &'static str,
pf_missing: bool,
pf_load_fails: bool,
route_add_fails: bool,
pf_enable_output: &'static str,
}
impl Fake {
fn new() -> Self {
Self {
calls: RefCell::new(Vec::new()),
stdins: RefCell::new(Vec::new()),
default_route: Some(DEFAULT_ROUTE.to_string()),
forwarding: "1\n",
pf_missing: false,
pf_load_fails: false,
route_add_fails: false,
pf_enable_output: "pf enabled\nToken : 4242\n",
}
}
fn run(&self, tool: Tool, args: &[String], stdin: Option<&str>) -> Ran {
let line = format!("{:?} {}", tool, args.join(" "));
self.calls.borrow_mut().push(line);
if let Some(text) = stdin {
self.stdins.borrow_mut().push(text.to_string());
}
let ok = |stdout: &str, stderr: &str| Ran::Exited {
success: true,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
};
let fail = |why: &str| Ran::Exited {
success: false,
stdout: String::new(),
stderr: why.to_string(),
};
match tool {
Tool::Id => ok("0\n", ""),
Tool::Sysctl => ok(self.forwarding, ""),
Tool::Route if args.first().map(String::as_str) == Some("-n") => {
match &self.default_route {
Some(text) => ok(text, ""),
None => fail("route: writing to routing socket: not in table"),
}
}
Tool::Route if args.contains(&"add".to_string()) && self.route_add_fails => {
fail("route: writing to routing socket: File exists")
}
Tool::Route => ok("", ""),
Tool::Pfctl if self.pf_missing => Ran::Missing,
Tool::Pfctl if args == ["-E"] => ok("", self.pf_enable_output),
Tool::Pfctl if args.contains(&"-f".to_string()) && self.pf_load_fails => {
fail("pfctl: Syntax error")
}
Tool::Pfctl => ok("", ""),
}
}
fn calls(&self) -> Vec<String> {
self.calls.borrow().clone()
}
fn position(&self, needle: &str) -> Option<usize> {
self.calls.borrow().iter().position(|c| c.contains(needle))
}
fn count(&self, needle: &str) -> usize {
self.calls
.borrow()
.iter()
.filter(|c| c.contains(needle))
.count()
}
}
fn plan(offer: bool, client: bool) -> ExitHostPlan {
ExitHostPlan {
interface: "utun5".into(),
offer: if offer { vec![range()] } else { Vec::new() },
client,
overlay: vec![range()],
..Default::default()
}
}
#[test]
fn the_default_route_is_read_from_route_output() {
assert_eq!(parse_default_route(DEFAULT_ROUTE), Some(egress()));
// A point-to-point default has no gateway line to use.
let ppp = "destination: default\n interface: ppp0\n";
assert_eq!(
parse_default_route(ppp),
Some(DefaultRoute {
interface: "ppp0".into(),
gateway: None
})
);
assert_eq!(parse_default_route("route: not in table"), None);
// An interface name that is not plain letters and digits never ends
// up in a rule.
assert_eq!(parse_default_route("interface: en0; pass all\n"), None);
}
#[test]
fn the_anchor_is_under_com_apple_and_only_for_plain_names() {
assert_eq!(
anchor_name("utun5").unwrap(),
"com.apple/tsunagi-exit-utun5"
);
assert!(anchor_name("").is_err());
assert!(anchor_name("utun5 pass all").is_err());
assert!(anchor_name("../x").is_err());
}
#[test]
fn offering_translates_the_range_and_admits_it_in() {
let text = pf_rules("utun5", &egress(), &[range()], None);
assert_eq!(
text,
"nat on en0 inet from 10.13.37.0/24 to any -> (en0)\n\
pass in quick on utun5 inet from 10.13.37.0/24 to ! 10.13.37.0/24\n"
);
// Translation precedes filtering, as pf requires.
assert!(text.find("nat on").unwrap() < text.find("pass in").unwrap());
}
#[test]
fn using_sends_the_agents_own_traffic_back_out_the_physical_interface() {
let other: Ipv4Range = "10.99.0.0/16".parse().unwrap();
let overlay = [range(), other];
let client = ClientRules {
uid: 0,
overlay: &overlay,
};
let text = pf_rules("utun5", &egress(), &[], Some(&client));
assert!(
text.starts_with(
"table <tsunagi_overlay_utun5> const { 10.13.37.0/24, 10.99.0.0/16 }\n"
),
"{text}"
);
assert!(
text.contains("nat on en0 inet from (utun5) to any -> (en0)\n"),
"{text}"
);
assert!(
text.contains(
"pass out quick on utun5 route-to (en0 192.168.1.1) inet proto { tcp udp } \
from any to ! <tsunagi_overlay_utun5> user 0\n"
),
"{text}"
);
// Only the agent's user, and only what is not the overlay.
assert!(!text.contains("from any to any"));
let direct = DefaultRoute {
interface: "ppp0".into(),
gateway: None,
};
let text = pf_rules(
"utun5",
&direct,
&[],
Some(&ClientRules {
uid: 501,
overlay: &[],
}),
);
assert!(text.contains("route-to ppp0 inet"), "{text}");
assert!(text.contains("to any user 501"), "{text}");
assert!(!text.contains("table"), "{text}");
}
#[test]
fn the_overlay_default_routes_are_halves_and_ipv6_is_rejected() {
assert_eq!(
route_add_args("0.0.0.0/1", "utun5").join(" "),
"-q -n add -net 0.0.0.0/1 -interface utun5"
);
assert_eq!(
route_del_args("128.0.0.0/1", "utun5").join(" "),
"-q -n delete -net 128.0.0.0/1 -interface utun5"
);
assert_eq!(
route_add_v6_args("8000::/1").join(" "),
"-q -n add -inet6 -net 8000::/1 ::1 -reject"
);
assert_eq!(
route_del_v6_args("::/1").join(" "),
"-q -n delete -inet6 -net ::/1 ::1"
);
}
#[test]
fn the_pf_token_is_found_in_either_stream() {
assert_eq!(parse_token("Token : 1234"), Some("1234".into()));
assert_eq!(parse_token("token: 9"), Some("9".into()));
assert_eq!(parse_token("pf enabled"), None);
assert_eq!(parse_token("Token : x; rm"), None);
}
#[test]
fn offering_loads_the_anchor_and_reads_but_never_writes_forwarding() {
let fake = Fake::new();
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
assert_eq!(report.offer, vec![(range(), RuleOutcome::Applied)]);
assert_eq!(report.forwarding, Some(true));
assert_eq!(report.client, None);
let calls = fake.calls();
assert!(
calls
.iter()
.any(|c| c == "Pfctl -q -a com.apple/tsunagi-exit-utun5 -f -"),
"{calls:?}"
);
assert_eq!(
fake.stdins.borrow()[0],
"nat on en0 inet from 10.13.37.0/24 to any -> (en0)\n\
pass in quick on utun5 inet from 10.13.37.0/24 to ! 10.13.37.0/24\n"
);
// sysctl is only ever asked, never told.
assert!(
calls
.iter()
.filter(|c| c.starts_with("Sysctl"))
.all(|c| c == "Sysctl -n net.inet.ip.forwarding"),
"{calls:?}"
);
// No client routes were added for an offer alone.
assert_eq!(fake.count(" add "), 0);
assert_eq!(token.lock().unwrap().as_deref(), Some("4242"));
}
#[test]
fn forwarding_off_is_reported_and_unreadable_is_none() {
let mut fake = Fake::new();
fake.forwarding = "0\n";
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
assert_eq!(report.forwarding, Some(false));
fake.forwarding = "junk";
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
assert_eq!(report.forwarding, None);
}
#[test]
fn the_client_rules_are_loaded_before_the_routes_that_need_them() {
let fake = Fake::new();
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(false, true));
assert_eq!(report.client, Some(RuleOutcome::Applied));
assert!(report.offer.is_empty());
let load = fake.position("-f -").unwrap();
let add = fake.position(" add -net 0.0.0.0/1").unwrap();
assert!(load < add, "{:?}", fake.calls());
assert!(fake.position("add -net 128.0.0.0/1").is_some());
assert!(fake.position("-inet6 -net ::/1 ::1 -reject").is_some());
assert!(fake.stdins.borrow()[0].contains("user 0"));
// The default route was read before any overlay route existed.
assert!(fake.position("-n get default").unwrap() < add);
}
#[test]
fn no_routes_are_added_when_the_rules_did_not_load() {
let mut fake = Fake::new();
fake.pf_load_fails = true;
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, true));
let Some(RuleOutcome::Failed(why)) = report.client else {
panic!("{report:?}");
};
assert!(why.contains("cannot load the pf rules"), "{why}");
assert!(why.contains("Syntax error"), "{why}");
assert!(matches!(report.offer[0].1, RuleOutcome::Failed(_)));
// Nothing sent the host's traffic into a tunnel it could not exempt
// the agent from.
assert_eq!(fake.count(" add "), 0);
}
#[test]
fn a_failing_route_is_a_failure_and_leaves_nothing_behind() {
let mut fake = Fake::new();
fake.route_add_fails = true;
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(false, true));
let Some(RuleOutcome::Failed(why)) = report.client else {
panic!("{report:?}");
};
assert!(why.contains("File exists"), "{why}");
// The cleanup after the failed add removed whatever did land.
let last_add = fake
.calls()
.iter()
.rposition(|c| c.contains(" add "))
.unwrap();
assert!(
fake.calls()[last_add..]
.iter()
.any(|c| c.contains("delete"))
);
}
#[test]
fn a_missing_pfctl_or_default_route_says_what_is_wrong() {
let mut fake = Fake::new();
fake.pf_missing = true;
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
panic!("{report:?}");
};
assert!(why.contains("/sbin/pfctl not found"), "{why}");
let mut fake = Fake::new();
fake.default_route = None;
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
panic!("{report:?}");
};
assert!(why.contains("default route"), "{why}");
assert_eq!(fake.count("-f -"), 0);
}
#[test]
fn a_default_route_through_the_overlay_is_refused() {
let mut fake = Fake::new();
fake.default_route = Some("gateway: 10.13.37.1\ninterface: utun5\n".into());
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
panic!("{report:?}");
};
assert!(why.contains("already goes through"), "{why}");
}
#[test]
fn pf_is_enabled_once_and_released_once_on_clear() {
let fake = Fake::new();
let token = Mutex::new(None);
let run = |t, a: &[String], s: Option<&str>| fake.run(t, a, s);
apply_plan(&run, &token, &plan(true, false));
apply_plan(&run, &token, &plan(true, true));
assert_eq!(fake.count("Pfctl -E"), 1, "{:?}", fake.calls());
clear_all(&run, &token, "utun5");
assert_eq!(fake.count("Pfctl -X 4242"), 1);
assert!(
fake.position("-a com.apple/tsunagi-exit-utun5 -F all")
.is_some()
);
assert!(
fake.position("delete -net 0.0.0.0/1 -interface utun5")
.is_some()
);
assert_eq!(*token.lock().unwrap(), None);
// A second clear has nothing to release and still succeeds.
clear_all(&run, &token, "utun5");
assert_eq!(fake.count("Pfctl -X"), 1);
}
#[test]
fn dropping_the_client_removes_its_routes() {
let fake = Fake::new();
let token = Mutex::new(None);
let run = |t, a: &[String], s: Option<&str>| fake.run(t, a, s);
apply_plan(&run, &token, &plan(true, false));
assert!(fake.position("delete -net 0.0.0.0/1").is_some());
assert_eq!(fake.count(" add "), 0);
}
}
@@ -61,11 +61,16 @@ pub use linux::LinuxHostRules;
// Compiled everywhere so the scripts it builds are tested everywhere; only // Compiled everywhere so the scripts it builds are tested everywhere; only
// the type that runs them is exported on Windows. // the type that runs them is exported on Windows.
mod windows; mod windows;
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
mod windows_exit;
#[cfg(all(feature = "tun-device", target_os = "windows"))] #[cfg(all(feature = "tun-device", target_os = "windows"))]
pub use windows::WindowsHostRules; pub use windows::WindowsHostRules;
// Compiled everywhere so its rule text and parsers are tested everywhere.
#[cfg(all(feature = "tun-device", target_os = "macos"))] #[cfg(all(feature = "tun-device", target_os = "macos"))]
mod macos; mod macos;
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
mod macos_exit;
#[cfg(all(feature = "tun-device", target_os = "macos"))] #[cfg(all(feature = "tun-device", target_os = "macos"))]
pub use macos::MacosHostRules; pub use macos::MacosHostRules;
@@ -29,6 +29,8 @@
use crate::BoxFuture; use crate::BoxFuture;
use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules};
use super::windows_exit;
use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome, tag_for}; use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome, tag_for};
/// A PowerShell single-quoted literal, with any embedded quote doubled. /// A PowerShell single-quoted literal, with any embedded quote doubled.
@@ -113,6 +115,11 @@ fn powershell_path() -> std::path::PathBuf {
/// Runs a script and returns what it said on failure. /// Runs a script and returns what it said on failure.
fn run_powershell(script: &str) -> Result<(), String> { fn run_powershell(script: &str) -> Result<(), String> {
run_powershell_output(script).map(|_| ())
}
/// Runs a script and returns what it printed, or what it said on failure.
fn run_powershell_output(script: &str) -> Result<String, String> {
let output = std::process::Command::new(powershell_path()) let output = std::process::Command::new(powershell_path())
.args([ .args([
"-NoProfile", "-NoProfile",
@@ -125,7 +132,7 @@ fn run_powershell(script: &str) -> Result<(), String> {
.output() .output()
.map_err(|err| format!("could not run PowerShell: {err}"))?; .map_err(|err| format!("could not run PowerShell: {err}"))?;
if output.status.success() { if output.status.success() {
return Ok(()); return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
} }
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
let text = if stderr.trim().is_empty() { let text = if stderr.trim().is_empty() {
@@ -155,11 +162,73 @@ impl WindowsHostRules {
} }
} }
/// The IPv4 addresses a host name resolves to; empty when it does not.
fn resolve_host(host: &str) -> Vec<std::net::Ipv4Addr> {
use std::net::{IpAddr, ToSocketAddrs};
(host, 443)
.to_socket_addrs()
.map(|addrs| {
addrs
.filter_map(|addr| match addr.ip() {
IpAddr::V4(ip) => Some(ip),
IpAddr::V6(_) => None,
})
.collect()
})
.unwrap_or_default()
}
impl ExitHostRules for WindowsHostRules {
fn needs_bypass(&self) -> bool {
true
}
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
Box::pin(async move {
let job = plan.clone();
tokio::task::spawn_blocking(move || {
windows_exit::apply_plan(
&|script| run_powershell_output(script),
&resolve_host,
&job,
)
})
.await
.unwrap_or_else(|err| {
let failed = RuleOutcome::Failed(format!("the task failed: {err}"));
ExitHostReport {
offer: plan
.offer
.iter()
.map(|range| (*range, failed.clone()))
.collect(),
forwarding: None,
client: plan.client.then_some(failed),
}
})
})
}
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
let name = interface.to_string();
let _ = tokio::task::spawn_blocking(move || {
windows_exit::clear_all(&|script| run_powershell_output(script), &name)
})
.await;
})
}
}
impl BroadcastHostRules for WindowsHostRules { impl BroadcastHostRules for WindowsHostRules {
fn name(&self) -> &str { fn name(&self) -> &str {
"windows-netsecurity" "windows-netsecurity"
} }
fn exit_rules(&self) -> Option<&dyn ExitHostRules> {
Some(self)
}
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> { fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
Box::pin(async move { Box::pin(async move {
let route = step("route", route_script(plan)).await; let route = step("route", route_script(plan)).await;
@@ -0,0 +1,603 @@
//! Exit-node host rules on Windows: WinNAT through PowerShell.
//!
//! Compiled on every platform so the scripts and the plan logic are tested
//! everywhere; only `WindowsHostRules` runs them, and only on Windows. It
//! could not be run against a real Windows host while it was written.
//!
//! # Offering
//!
//! One NAT object per offered range, named with the usual tag
//! `tsunagi-exit:<if>:<range>`:
//!
//! ```text
//! New-NetNat -Name <tag> -InternalIPInterfaceAddressPrefix <range>
//! ```
//!
//! WinNAT translates that range out of whichever interface the traffic leaves
//! by, which is the Windows counterpart of the Linux masquerade. Windows
//! forwards per interface, and the egress is not known here, so forwarding is
//! only *read* for the overlay interface; turning it on for the interfaces
//! involved is the owner's decision (`Set-NetIPInterface -Forwarding Enabled`).
//! Some Windows versions allow a single NAT per host: a second offered range
//! then reports its own failure.
//!
//! # Using
//!
//! Windows has no per-user or per-process routing, so the Linux scheme (an
//! exemption for the agent's user) does not carry over. Instead the agent's
//! own underlay traffic is exempted by *destination*:
//!
//! ```text
//! New-NetRoute 0.0.0.0/1 on the overlay interface, metric 1
//! New-NetRoute 128.0.0.0/1 on the overlay interface, metric 1
//! New-NetRoute <ip>/32 via the physical default gateway, metric 7373
//! ```
//!
//! The halves out-rank the default route without replacing it, so every more
//! specific route (LAN, another VPN) keeps working. A `/32` per address the
//! agent talks to directly (the verified direct paths of its connections, and
//! the relays it uses, resolved when the rules are applied) keeps that traffic
//! on the physical network instead of looping into the tunnel it carries. The
//! metric 7373 marks those routes as ours, which is how stale ones are found
//! and removed. Everything is written to the active store, so a reboot clears
//! it. IPv6 is blackholed with `::/1` and `8000::/1` on the overlay interface,
//! which has no IPv6: applications fall back to IPv4 after a delay, where
//! Linux and macOS answer at once. The bypass follows the agent's paths as
//! they change; a path that appears after the last apply is reached through
//! the tunnel until the next one.
//!
//! The bypass routes are written before the halves, and removed after them.
use super::RuleOutcome;
use std::net::Ipv4Addr;
use super::exit::{ExitHostPlan, ExitHostReport, exit_tag, exit_tag_prefix};
use crate::state::Ipv4Range;
/// The route metric that marks a bypass route as this agent's.
const BYPASS_METRIC: u32 = 7373;
/// Runs a PowerShell script and returns what it printed.
pub(super) type RunScript<'a> = &'a dyn Fn(&str) -> Result<String, String>;
/// Resolves a host name to its IPv4 addresses.
pub(super) type Resolve<'a> = &'a dyn Fn(&str) -> Vec<Ipv4Addr>;
/// A PowerShell single-quoted literal, with any embedded quote doubled.
fn ps_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
/// Creates the NAT of one range, replacing one this agent made before.
fn nat_script(interface: &str, range: &Ipv4Range) -> String {
let name = ps_literal(&exit_tag(interface, range));
let prefix = ps_literal(&range.to_string());
format!(
"$ErrorActionPreference = 'Stop'\n\
$name = {name}\n\
Get-NetNat -Name $name -ErrorAction SilentlyContinue | \
Remove-NetNat -Confirm:$false -ErrorAction SilentlyContinue\n\
New-NetNat -Name $name -InternalIPInterfaceAddressPrefix {prefix} | Out-Null\n"
)
}
/// Removes this interface's NAT objects except those named in `keep`.
fn prune_script(interface: &str, keep: &[Ipv4Range]) -> String {
let prefix = ps_literal(&exit_tag_prefix(interface));
let keep: Vec<String> = keep
.iter()
.map(|range| ps_literal(&exit_tag(interface, range)))
.collect();
format!(
"$ErrorActionPreference = 'SilentlyContinue'\n\
$prefix = {prefix}\n\
$keep = @({})\n\
Get-NetNat | Where-Object {{ $_.Name.StartsWith($prefix) -and \
($keep -notcontains $_.Name) }} | Remove-NetNat -Confirm:$false\n\
exit 0\n",
keep.join(", ")
)
}
/// Prints `Enabled` or `Disabled` for the interface's IPv4 forwarding.
fn forwarding_script(interface: &str) -> String {
let alias = ps_literal(interface);
format!(
"$ErrorActionPreference = 'Stop'\n\
(Get-NetIPInterface -InterfaceAlias {alias} -AddressFamily IPv4).Forwarding\n"
)
}
/// Prints `<interface index> <next hop>` of the best default route that is
/// not through the overlay interface.
fn gateway_script(interface: &str) -> String {
let alias = ps_literal(interface);
format!(
"$ErrorActionPreference = 'Stop'\n\
$alias = {alias}\n\
$best = Get-NetRoute -DestinationPrefix '0.0.0.0/0' -AddressFamily IPv4 \
-PolicyStore ActiveStore | Where-Object {{ $_.InterfaceAlias -ne $alias -and \
$_.NextHop -ne '0.0.0.0' }} | Sort-Object {{ $_.RouteMetric + \
(Get-NetIPInterface -InterfaceIndex $_.InterfaceIndex -AddressFamily IPv4).InterfaceMetric }} | \
Select-Object -First 1\n\
if (-not $best) {{ throw 'the host has no default gateway outside the overlay' }}\n\
\"$($best.InterfaceIndex) $($best.NextHop)\"\n"
)
}
/// The default gateway as the script above printed it.
fn parse_gateway(text: &str) -> Option<(u32, Ipv4Addr)> {
let mut words = text.split_whitespace();
let index = words.next()?.parse().ok()?;
let hop = words.next()?.parse().ok()?;
words.next().is_none().then_some((index, hop))
}
/// Replaces the bypass routes with one per address, through the gateway.
fn bypass_script(index: u32, gateway: Ipv4Addr, addresses: &[Ipv4Addr]) -> String {
let list: Vec<String> = addresses
.iter()
.map(|address| ps_literal(&address.to_string()))
.collect();
format!(
"$ErrorActionPreference = 'Stop'\n\
Get-NetRoute -AddressFamily IPv4 -PolicyStore ActiveStore -ErrorAction SilentlyContinue | \
Where-Object {{ $_.RouteMetric -eq {BYPASS_METRIC} -and $_.DestinationPrefix.EndsWith('/32') }} | \
Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
foreach ($ip in @({})) {{\n\
New-NetRoute -DestinationPrefix \"$ip/32\" -InterfaceIndex {index} -NextHop '{gateway}' \
-RouteMetric {BYPASS_METRIC} -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Out-Null\n\
}}\n",
list.join(", ")
)
}
/// Sends everything else through the overlay interface.
fn halves_script(interface: &str) -> String {
let alias = ps_literal(interface);
format!(
"$ErrorActionPreference = 'Stop'\n\
$alias = {alias}\n\
foreach ($p in '0.0.0.0/1', '128.0.0.0/1') {{\n\
Get-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -PolicyStore ActiveStore \
-ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
New-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -NextHop '0.0.0.0' \
-RouteMetric 1 -PolicyStore ActiveStore | Out-Null\n\
}}\n\
foreach ($p in '::/1', '8000::/1') {{\n\
Get-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -PolicyStore ActiveStore \
-ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
New-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -NextHop '::' \
-RouteMetric 1 -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Out-Null\n\
}}\n"
)
}
/// Removes the halves and every bypass route. What is not there is the state
/// wanted.
fn remove_client_script(interface: &str) -> String {
let alias = ps_literal(interface);
format!(
"$ErrorActionPreference = 'SilentlyContinue'\n\
$alias = {alias}\n\
foreach ($p in '0.0.0.0/1', '128.0.0.0/1', '::/1', '8000::/1') {{\n\
Get-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -PolicyStore ActiveStore | \
Remove-NetRoute -Confirm:$false\n\
}}\n\
Get-NetRoute -AddressFamily IPv4 -PolicyStore ActiveStore | \
Where-Object {{ $_.RouteMetric -eq {BYPASS_METRIC} -and $_.DestinationPrefix.EndsWith('/32') }} | \
Remove-NetRoute -Confirm:$false\n\
exit 0\n"
)
}
/// Every address to keep direct: the ones given and what the names resolve
/// to, sorted and without duplicates.
fn bypass_addresses(
resolve: Resolve<'_>,
addresses: &[Ipv4Addr],
hosts: &[String],
) -> Vec<Ipv4Addr> {
let mut all: std::collections::BTreeSet<Ipv4Addr> = addresses.iter().copied().collect();
for host in hosts {
all.extend(resolve(host));
}
all.into_iter()
.filter(|a| !a.is_loopback() && !a.is_unspecified() && !a.is_multicast())
.collect()
}
fn apply_client(
run: RunScript<'_>,
resolve: Resolve<'_>,
plan: &ExitHostPlan,
) -> Result<(), String> {
let interface = plan.interface.as_str();
let gateway = run(&gateway_script(interface))?;
let (index, hop) = parse_gateway(&gateway)
.ok_or_else(|| format!("cannot read the default gateway from `{}`", gateway.trim()))?;
let addresses = bypass_addresses(resolve, &plan.bypass, &plan.bypass_hosts);
run(&bypass_script(index, hop, &addresses))?;
if let Err(why) = run(&halves_script(interface)) {
// Do not leave a half-installed set: bypass routes alone are harmless
// but the next apply starts clean.
let _ = run(&remove_client_script(interface));
return Err(why);
}
Ok(())
}
fn parse_forwarding(text: &str) -> Option<bool> {
match text.trim().to_ascii_lowercase().as_str() {
"enabled" | "1" => Some(true),
"disabled" | "0" => Some(false),
_ => None,
}
}
/// Makes the host match the plan.
pub(super) fn apply_plan(
run: RunScript<'_>,
resolve: Resolve<'_>,
plan: &ExitHostPlan,
) -> ExitHostReport {
let interface = plan.interface.as_str();
// What an earlier plan left and this one no longer wants.
let pruned = run(&prune_script(interface, &plan.offer));
let offer = plan
.offer
.iter()
.map(|range| {
let outcome = match &pruned {
Err(why) => RuleOutcome::Failed(why.clone()),
Ok(_) => match run(&nat_script(interface, range)) {
Ok(_) => RuleOutcome::Applied,
Err(why) => RuleOutcome::Failed(why),
},
};
(*range, outcome)
})
.collect();
let client = if plan.client {
Some(match apply_client(run, resolve, plan) {
Ok(()) => RuleOutcome::Applied,
Err(why) => RuleOutcome::Failed(why),
})
} else {
// Whatever an earlier plan left must not stay in force.
let _ = run(&remove_client_script(interface));
None
};
let forwarding = if plan.offer.is_empty() {
None
} else {
run(&forwarding_script(interface))
.ok()
.and_then(|text| parse_forwarding(&text))
};
ExitHostReport {
offer,
forwarding,
client,
}
}
/// Removes every NAT object of this agent's for the interface.
pub(super) fn clear_all(run: RunScript<'_>, interface: &str) {
if let Err(why) = run(&prune_script(interface, &[])) {
tracing::warn!(interface, "cannot remove the exit node NAT: {why}");
}
if let Err(why) = run(&remove_client_script(interface)) {
tracing::warn!(interface, "cannot remove the exit node routes: {why}");
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::cell::RefCell;
use super::*;
fn range() -> Ipv4Range {
"10.13.37.0/24".parse().unwrap()
}
fn plan(client: bool) -> ExitHostPlan {
ExitHostPlan {
interface: "tsun0".into(),
offer: vec![range()],
client,
overlay: vec![range()],
..Default::default()
}
}
#[test]
fn the_nat_is_named_with_the_tag_and_replaces_its_own() {
let script = nat_script("tsun0", &range());
assert!(
script.contains("$name = 'tsunagi-exit:tsun0:10.13.37.0/24'"),
"{script}"
);
assert!(script.contains("-InternalIPInterfaceAddressPrefix '10.13.37.0/24'"));
assert!(
script.find("Remove-NetNat").unwrap() < script.find("New-NetNat").unwrap(),
"{script}"
);
}
#[test]
fn a_quote_in_a_value_cannot_escape_the_literal() {
let script = nat_script("x'; Remove-Item C:\\ #", &range());
assert!(script.contains("'tsunagi-exit:x''; Remove-Item C:\\ #:10.13.37.0/24'"));
assert!(prune_script("x'y", &[]).contains("'tsunagi-exit:x''y:'"));
assert!(forwarding_script("x'y").contains("'x''y'"));
}
#[test]
fn pruning_keeps_wanted_ranges_and_only_selects_this_interfaces_nats() {
let script = prune_script("tsun0", &[range()]);
assert!(script.contains("$prefix = 'tsunagi-exit:tsun0:'"));
assert!(script.contains("$keep = @('tsunagi-exit:tsun0:10.13.37.0/24')"));
assert!(script.contains("StartsWith($prefix)"));
assert!(script.contains("exit 0"));
assert!(prune_script("tsun0", &[]).contains("$keep = @()"));
}
#[test]
fn forwarding_text_is_read_never_written() {
assert_eq!(parse_forwarding("Enabled\r\n"), Some(true));
assert_eq!(parse_forwarding("Disabled"), Some(false));
assert_eq!(parse_forwarding("what"), None);
let script = forwarding_script("tsun0");
assert!(script.contains("Get-NetIPInterface"));
assert!(!script.contains("Set-NetIPInterface"));
}
#[test]
fn offering_prunes_then_creates_and_reads_forwarding() {
let calls = RefCell::new(Vec::new());
let run = |script: &str| -> Result<String, String> {
calls.borrow_mut().push(script.to_string());
Ok(if script.contains("Get-NetIPInterface") {
"Disabled\n".to_string()
} else {
String::new()
})
};
let report = apply_plan(&run, &no_names, &plan(false));
assert_eq!(report.offer, vec![(range(), RuleOutcome::Applied)]);
assert_eq!(report.forwarding, Some(false));
assert_eq!(report.client, None);
let calls = calls.borrow();
assert!(calls[0].contains("StartsWith"), "{calls:?}");
assert!(calls[1].contains("New-NetNat"), "{calls:?}");
// Without a client, the routes of an earlier plan are taken away.
assert!(calls[2].contains("RouteMetric -eq 7373"), "{calls:?}");
assert!(calls[3].contains("Get-NetIPInterface"), "{calls:?}");
}
#[test]
fn a_failing_nat_is_that_ranges_failure() {
let run = |script: &str| -> Result<String, String> {
if script.contains("New-NetNat") {
Err("A NAT with this prefix already exists".into())
} else {
Ok(String::new())
}
};
let report = apply_plan(&run, &no_names, &plan(false));
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
panic!("{report:?}");
};
assert!(why.contains("already exists"), "{why}");
}
fn no_names(_: &str) -> Vec<Ipv4Addr> {
Vec::new()
}
fn client_plan() -> ExitHostPlan {
ExitHostPlan {
interface: "tsun0".into(),
offer: Vec::new(),
client: true,
overlay: vec![range()],
bypass: vec![Ipv4Addr::new(138, 201, 61, 182)],
bypass_hosts: vec!["relay.example".into()],
}
}
#[test]
fn the_gateway_is_read_from_the_script_output() {
assert_eq!(
parse_gateway("12 192.168.1.1\r\n"),
Some((12, Ipv4Addr::new(192, 168, 1, 1)))
);
assert_eq!(parse_gateway(""), None);
assert_eq!(parse_gateway("12"), None);
assert_eq!(parse_gateway("12 gw"), None);
assert_eq!(parse_gateway("12 1.1.1.1 extra"), None);
let script = gateway_script("tsun0");
assert!(script.contains("-ne $alias"));
assert!(script.contains("-ne '0.0.0.0'"));
}
#[test]
fn bypass_routes_are_marked_host_routes_through_the_physical_gateway() {
let script = bypass_script(
12,
Ipv4Addr::new(192, 168, 1, 1),
&[Ipv4Addr::new(1, 2, 3, 4), Ipv4Addr::new(5, 6, 7, 8)],
);
assert!(script.contains("@('1.2.3.4', '5.6.7.8')"), "{script}");
assert!(script.contains("-InterfaceIndex 12 -NextHop '192.168.1.1'"));
assert!(script.contains("-RouteMetric 7373"));
assert!(script.contains("-PolicyStore ActiveStore"));
// Stale marked routes go first, and only /32 ones with our metric.
assert!(script.find("Remove-NetRoute").unwrap() < script.find("New-NetRoute").unwrap());
assert!(script.contains("RouteMetric -eq 7373"));
assert!(script.contains("EndsWith('/32')"));
}
#[test]
fn the_halves_go_through_the_overlay_and_ipv6_is_blackholed() {
let script = halves_script("tsun0");
assert!(script.contains("'0.0.0.0/1', '128.0.0.0/1'"));
assert!(script.contains("-InterfaceAlias $alias -NextHop '0.0.0.0'"));
assert!(script.contains("'::/1', '8000::/1'"));
assert!(script.contains("-PolicyStore ActiveStore"));
assert!(
!script.contains("0.0.0.0/0"),
"the default route is left alone"
);
assert!(halves_script("x'y").contains("'x''y'"));
}
#[test]
fn addresses_are_resolved_merged_and_cleaned() {
let resolve = |host: &str| -> Vec<Ipv4Addr> {
match host {
"relay.example" => vec![Ipv4Addr::new(9, 9, 9, 9), Ipv4Addr::new(1, 2, 3, 4)],
_ => Vec::new(),
}
};
let all = bypass_addresses(
&resolve,
&[
Ipv4Addr::new(1, 2, 3, 4),
Ipv4Addr::LOCALHOST,
Ipv4Addr::UNSPECIFIED,
],
&["relay.example".into(), "gone.example".into()],
);
assert_eq!(
all,
vec![Ipv4Addr::new(1, 2, 3, 4), Ipv4Addr::new(9, 9, 9, 9)]
);
}
#[test]
fn the_bypass_is_written_before_the_halves_that_need_it() {
let calls = RefCell::new(Vec::new());
let run = |script: &str| -> Result<String, String> {
calls.borrow_mut().push(script.to_string());
Ok(if script.contains("Select-Object -First 1") {
"12 192.168.1.1\n".to_string()
} else {
String::new()
})
};
let resolve = |_: &str| vec![Ipv4Addr::new(9, 9, 9, 9)];
let report = apply_plan(&run, &resolve, &client_plan());
assert_eq!(report.client, Some(RuleOutcome::Applied));
let calls = calls.borrow();
let at = |needle: &str| calls.iter().position(|c| c.contains(needle)).unwrap();
assert!(at("Select-Object -First 1") < at("-RouteMetric 7373"));
assert!(at("-RouteMetric 7373") < at("'0.0.0.0/1', '128.0.0.0/1'"));
let bypass = &calls[at("-RouteMetric 7373")];
assert!(
bypass.contains("'9.9.9.9'") && bypass.contains("'138.201.61.182'"),
"{bypass}"
);
}
#[test]
fn nothing_goes_through_the_overlay_when_the_bypass_cannot_be_written() {
let run = |script: &str| -> Result<String, String> {
if script.contains("Select-Object -First 1") {
Ok("12 192.168.1.1".to_string())
} else if script.contains("-RouteMetric 7373") && script.contains("foreach ($ip") {
Err("Access is denied".to_string())
} else {
assert!(!script.contains("-NextHop '0.0.0.0'"), "{script}");
Ok(String::new())
}
};
let report = apply_plan(&run, &no_names, &client_plan());
assert_eq!(
report.client,
Some(RuleOutcome::Failed("Access is denied".into()))
);
}
#[test]
fn a_missing_gateway_is_a_failure_that_installs_no_route() {
let run = |script: &str| -> Result<String, String> {
assert!(!script.contains("New-NetRoute"), "{script}");
if script.contains("Select-Object -First 1") {
Err("the host has no default gateway outside the overlay".into())
} else {
Ok(String::new())
}
};
let report = apply_plan(&run, &no_names, &client_plan());
let Some(RuleOutcome::Failed(why)) = report.client else {
panic!("{report:?}");
};
assert!(why.contains("no default gateway"), "{why}");
}
#[test]
fn a_failed_halves_step_removes_what_was_written() {
let calls = RefCell::new(Vec::new());
let run = |script: &str| -> Result<String, String> {
calls.borrow_mut().push(script.to_string());
if script.contains("Select-Object -First 1") {
Ok("12 192.168.1.1".to_string())
} else if script.contains("'128.0.0.0/1'") && script.contains("New-NetRoute") {
Err("boom".to_string())
} else {
Ok(String::new())
}
};
let report = apply_plan(&run, &no_names, &client_plan());
assert!(matches!(report.client, Some(RuleOutcome::Failed(_))));
assert!(calls.borrow().last().unwrap().contains("Remove-NetRoute"));
}
#[test]
fn dropping_the_client_removes_its_routes_and_clearing_does_too() {
let calls = RefCell::new(Vec::new());
let run = |script: &str| -> Result<String, String> {
calls.borrow_mut().push(script.to_string());
Ok(String::new())
};
let report = apply_plan(&run, &no_names, &plan(false));
assert_eq!(report.client, None);
assert!(
calls
.borrow()
.iter()
.any(|c| c.contains("RouteMetric -eq 7373"))
);
let script = remove_client_script("tsun0");
assert!(script.contains("'0.0.0.0/1', '128.0.0.0/1', '::/1', '8000::/1'"));
assert!(script.contains("exit 0"));
// Only routes on our interface, or marked ours, are touched.
assert!(script.contains("-InterfaceAlias $alias"));
calls.borrow_mut().clear();
clear_all(&run, "tsun0");
assert!(
calls
.borrow()
.iter()
.any(|c| c.contains("RouteMetric -eq 7373"))
);
}
#[test]
fn clearing_prunes_everything_for_the_interface() {
let seen = RefCell::new(Vec::<String>::new());
let run = |script: &str| -> Result<String, String> {
seen.borrow_mut().push(script.to_string());
Ok(String::new())
};
clear_all(&run, "tsun0");
let seen = seen.borrow();
assert!(seen[0].contains("$keep = @()"));
assert!(seen[0].contains("'tsunagi-exit:tsun0:'"));
}
}
+154 -5
View File
@@ -164,6 +164,17 @@ pub struct Interface {
rules_report: std::sync::Mutex<Option<BroadcastRulesReport>>, rules_report: std::sync::Mutex<Option<BroadcastRulesReport>>,
/// What the exit-node rules were last asked for, and what came of it. /// What the exit-node rules were last asked for, and what came of it.
exit_state: std::sync::Mutex<ExitState>, exit_state: std::sync::Mutex<ExitState>,
/// Where each network's runtime reaches its peers and relays outside the
/// overlay; read only by hosts that cannot exempt the agent from the
/// exit node's default route.
underlay: std::sync::Mutex<std::collections::HashMap<NetworkId, Underlay>>,
}
/// Underlay destinations one network's runtime currently depends on.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct Underlay {
addresses: std::collections::BTreeSet<std::net::Ipv4Addr>,
hosts: std::collections::BTreeSet<String>,
} }
/// The exit-node host rules as this interface last left them. /// The exit-node host rules as this interface last left them.
@@ -275,6 +286,7 @@ impl Interface {
host_rules, host_rules,
rules_report: std::sync::Mutex::new(None), rules_report: std::sync::Mutex::new(None),
exit_state: std::sync::Mutex::new(ExitState::default()), exit_state: std::sync::Mutex::new(ExitState::default()),
underlay: std::sync::Mutex::new(std::collections::HashMap::new()),
}) })
} }
@@ -350,10 +362,20 @@ impl Interface {
.collect(); .collect();
ranges.sort_by_key(|range| (range.base, range.prefix_len)); ranges.sort_by_key(|range| (range.base, range.prefix_len));
ranges.dedup(); ranges.dedup();
let client = self.routes.exit_via().is_some();
let overlay = self.routes.overlay_ranges();
let (bypass, bypass_hosts) = if client && exit.needs_bypass() {
self.underlay_targets(&overlay)
} else {
(Vec::new(), Vec::new())
};
let wanted = ExitHostPlan { let wanted = ExitHostPlan {
interface: self.name.clone(), interface: self.name.clone(),
offer: ranges, offer: ranges,
client: self.routes.exit_via().is_some(), client,
overlay,
bypass,
bypass_hosts,
}; };
let first = !std::mem::replace(&mut self.exit_state().cleaned, true); let first = !std::mem::replace(&mut self.exit_state().cleaned, true);
@@ -398,9 +420,15 @@ impl Interface {
tracing::warn!( tracing::warn!(
interface = %self.name, interface = %self.name,
"kernel forwarding is off for this interface, so nothing is forwarded for the \ "kernel forwarding is off for this interface, so nothing is forwarded for the \
exit node yet: `sysctl -w net.ipv4.conf.{0}.forwarding=1` (or \ exit node yet: {}",
`net.ipv4.ip_forward=1`), and set it in sysctl.d to keep it", if cfg!(target_os = "windows") {
self.name "`Set-NetIPInterface -Forwarding Enabled` on the interfaces involved"
} else if cfg!(target_os = "macos") {
"`sysctl -w net.inet.ip.forwarding=1`, set again after every reboot"
} else {
"`sysctl -w net.ipv4.ip_forward=1` (or `net.ipv4.conf.<interface>.forwarding=1`), \
and set it in sysctl.d to keep it"
}
); );
} }
match &report.client { match &report.client {
@@ -419,6 +447,68 @@ impl Interface {
state.report = Some(report); state.report = Some(report);
} }
/// Tells the interface where a network's runtime reaches peers and
/// relays outside the overlay. When the host needs those kept out of the
/// exit node's default route and the set changed, the rules follow.
pub async fn set_underlay(
&self,
network: NetworkId,
addresses: impl IntoIterator<Item = std::net::Ipv4Addr>,
hosts: impl IntoIterator<Item = String>,
) {
let new = Underlay {
addresses: addresses.into_iter().collect(),
hosts: hosts.into_iter().collect(),
};
let changed = {
let mut map = match self.underlay.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if new == Underlay::default() {
map.remove(&network).is_some()
} else {
map.insert(network, new.clone()) != Some(new)
}
};
let needed = changed
&& self.routes.exit_via().is_some()
&& self
.host_rules
.as_ref()
.and_then(|rules| rules.exit_rules())
.is_some_and(|exit| exit.needs_bypass());
if needed {
self.sync_exit_rules().await;
}
}
/// Every underlay destination worth keeping direct, minus anything that
/// is the overlay itself, loopback or link-local.
fn underlay_targets(
&self,
overlay: &[crate::state::Ipv4Range],
) -> (Vec<std::net::Ipv4Addr>, Vec<String>) {
let map = match self.underlay.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let mut addresses = std::collections::BTreeSet::new();
let mut hosts = std::collections::BTreeSet::new();
for underlay in map.values() {
addresses.extend(underlay.addresses.iter().copied().filter(|address| {
!address.is_loopback()
&& !address.is_link_local()
&& !address.is_multicast()
&& !address.is_unspecified()
&& !address.is_broadcast()
&& !overlay.iter().any(|range| range.contains(*address))
}));
hosts.extend(underlay.hosts.iter().cloned());
}
(addresses.into_iter().collect(), hosts.into_iter().collect())
}
fn exit_state(&self) -> std::sync::MutexGuard<'_, ExitState> { fn exit_state(&self) -> std::sync::MutexGuard<'_, ExitState> {
match self.exit_state.lock() { match self.exit_state.lock() {
Ok(guard) => guard, Ok(guard) => guard,
@@ -443,7 +533,7 @@ impl Interface {
"this overlay interface is not on the host (no TUN device), so there is \ "this overlay interface is not on the host (no TUN device), so there is \
nothing to forward to", nothing to forward to",
), ),
Some(false) => Some("exit nodes are only supported on Linux yet"), Some(false) => Some("exit nodes are not supported on this platform yet"),
Some(true) => None, Some(true) => None,
}; };
let failed = |detail: &str| RuleSetReport { let failed = |detail: &str| RuleSetReport {
@@ -1336,6 +1426,65 @@ mod tests {
assert_eq!(interface.counters().received, 1); assert_eq!(interface.counters().received, 1);
} }
#[tokio::test]
async fn the_underlay_reaches_the_rules_only_for_hosts_that_need_it_and_only_while_using_one() {
let rules = MockHostRules::new();
let (interface, routes, id) = managed_interface(&rules, false).await;
let relay = || vec!["relay.example".to_string()];
let direct = || {
vec![
Ipv4Addr::new(138, 201, 61, 182),
Ipv4Addr::new(10, 13, 37, 9), // the overlay itself
Ipv4Addr::LOCALHOST,
Ipv4Addr::new(169, 254, 1, 1),
]
};
// A host that does not ask never gets them, so Linux and macOS do not
// re-apply their rules whenever a path moves.
routes.set_exit(id, using(peer(2)));
interface.set_underlay(id, direct(), relay()).await;
interface.sync_exit_rules().await;
let plan = rules.exit().installed().unwrap();
assert!(plan.client);
assert!(plan.bypass.is_empty() && plan.bypass_hosts.is_empty());
// A host that does gets the usable ones, and follows changes.
rules.exit().set_needs_bypass(true);
interface.set_underlay(id, direct(), relay()).await;
interface.sync_exit_rules().await;
let plan = rules.exit().installed().unwrap();
assert_eq!(plan.bypass, vec![Ipv4Addr::new(138, 201, 61, 182)]);
assert_eq!(plan.bypass_hosts, relay());
let applies = || {
rules
.exit()
.calls()
.iter()
.filter(|c| c.starts_with("apply"))
.count()
};
let before = applies();
interface.set_underlay(id, direct(), relay()).await;
assert_eq!(applies(), before, "an unchanged set is not applied again");
interface
.set_underlay(id, vec![Ipv4Addr::new(5, 6, 7, 8)], relay())
.await;
assert_eq!(applies(), before + 1);
assert_eq!(
rules.exit().installed().unwrap().bypass,
vec![Ipv4Addr::new(5, 6, 7, 8)]
);
// Not using an exit node: nothing to keep direct.
routes.set_exit(id, Default::default());
interface.sync_exit_rules().await;
interface
.set_underlay(id, vec![Ipv4Addr::new(1, 1, 1, 1)], relay())
.await;
assert!(rules.exit().installed().is_none());
}
#[tokio::test] #[tokio::test]
async fn the_exit_rules_follow_the_routing_table_and_leave_with_the_interface() { async fn the_exit_rules_follow_the_routing_table_and_leave_with_the_interface() {
let rules = MockHostRules::new(); let rules = MockHostRules::new();
+12
View File
@@ -293,6 +293,18 @@ impl RoutingTable {
offers offers
} }
/// The range of every network in the table that has agreed one, sorted.
pub fn overlay_ranges(&self) -> Vec<Ipv4Range> {
let mut ranges: Vec<Ipv4Range> = self
.read()
.values()
.filter_map(|routes| routes.range)
.collect();
ranges.sort_by_key(|range| (range.base, range.prefix_len));
ranges.dedup();
ranges
}
/// One network's exit-node settings and its range, if it is in the table. /// One network's exit-node settings and its range, if it is in the table.
pub fn exit_settings(&self, network: NetworkId) -> Option<(ExitPolicy, Option<Ipv4Range>)> { pub fn exit_settings(&self, network: NetworkId) -> Option<(ExitPolicy, Option<Ipv4Range>)> {
self.read() self.read()
+6
View File
@@ -13,6 +13,12 @@ pub(crate) const GOOD: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a)
pub(crate) const LOUD: egui::Color32 = egui::Color32::from_rgb(0xe0, 0x4a, 0x3c); 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. /// What to run to turn kernel forwarding on, which the agent never does.
#[cfg(target_os = "macos")]
pub(crate) const FORWARDING_COMMAND: &str = "sudo sysctl -w net.inet.ip.forwarding=1";
#[cfg(target_os = "windows")]
pub(crate) const FORWARDING_COMMAND: &str =
"Get-NetIPInterface | Set-NetIPInterface -Forwarding Enabled";
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub(crate) const FORWARDING_COMMAND: &str = "sudo sysctl -w net.ipv4.ip_forward=1"; pub(crate) const FORWARDING_COMMAND: &str = "sudo sysctl -w net.ipv4.ip_forward=1";
/// One thing wrong, loud enough to read at a glance. /// One thing wrong, loud enough to read at a glance.
+3
View File
@@ -34,6 +34,9 @@ netwatch = "0.19.3"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
nix = { version = "0.31", features = ["user"] } nix = { version = "0.31", features = ["user"] }
[target."cfg(windows)".dependencies]
windows-service = "0.8"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] } tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
tempfile.workspace = true tempfile.workspace = true
+228 -11
View File
@@ -11,6 +11,9 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
#[cfg(windows)]
mod winservice;
use clap::{Args, Parser, Subcommand, ValueEnum}; use clap::{Args, Parser, Subcommand, ValueEnum};
use tsunagi::agent::Event; use tsunagi::agent::Event;
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy}; use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
@@ -84,6 +87,10 @@ enum Command {
ExitNode(ExitNodeArgs), ExitNode(ExitNodeArgs),
/// Removes everything this device has stored and starts over. /// Removes everything this device has stored and starts over.
Wipe(WipeArgs), Wipe(WipeArgs),
/// Runs the agent under the Windows service manager. Registered by the
/// installer, not typed.
#[command(hide = true)]
Service(Box<UpArgs>),
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
@@ -757,12 +764,26 @@ fn main() -> std::process::ExitCode {
let is_debug = log_filter.contains("debug") || log_filter.contains("trace"); let is_debug = log_filter.contains("debug") || log_filter.contains("trace");
tracing_subscriber::fmt() let builder = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::new(&log_filter)) .with_env_filter(tracing_subscriber::EnvFilter::new(&log_filter))
.with_target(is_debug) .with_target(is_debug)
.compact() .compact();
.with_writer(std::io::stderr) // A service has no console: it writes to a file.
.init(); match matches!(cli.command, Command::Service(_))
.then(open_service_log)
.flatten()
{
Some(file) => builder
.with_ansi(false)
.with_writer(std::sync::Mutex::new(file))
.init(),
None => builder.with_writer(std::io::stderr).init(),
}
#[cfg(windows)]
if let Command::Service(args) = cli.command {
return run_service(*args);
}
// The library never starts a runtime; this binary owns it. // The library never starts a runtime; this binary owns it.
let runtime = match tokio::runtime::Builder::new_multi_thread() let runtime = match tokio::runtime::Builder::new_multi_thread()
@@ -785,6 +806,89 @@ fn main() -> std::process::ExitCode {
} }
} }
/// The log file of the Windows service, `%ProgramData%\Tsunagi\logs\tsng.log`.
///
/// The previous one is kept as `tsng.log.old` once it passes a few megabytes,
/// so a service that logs for months does not fill the disk. `None` anywhere
/// else, and when the file cannot be opened.
fn open_service_log() -> Option<std::fs::File> {
if !cfg!(windows) {
return None;
}
let root = std::env::var_os("ProgramData")?;
open_log_in(&PathBuf::from(root).join("Tsunagi").join("logs"))
}
/// Opens `tsng.log` in a directory for appending, moving a large one aside.
fn open_log_in(dir: &std::path::Path) -> Option<std::fs::File> {
std::fs::create_dir_all(dir).ok()?;
let log = dir.join("tsng.log");
if std::fs::metadata(&log).is_ok_and(|meta| meta.len() > SERVICE_LOG_LIMIT) {
let _ = std::fs::rename(&log, dir.join("tsng.log.old"));
}
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log)
.ok()
}
/// Past this size the service log is moved aside, keeping one older file.
const SERVICE_LOG_LIMIT: u64 = 8 * 1024 * 1024;
/// The directories and control socket of the Windows service, unless the
/// service command line names its own.
///
/// A service runs as the system account, whose profile is the wrong home for
/// a device's identity, so the state is kept under `%ProgramData%\Tsunagi`,
/// where the installer locks it to administrators and the system. The control
/// socket stays at the system-wide default every client already looks at,
/// which is what lets a tray and an unelevated `tsng status` find the service.
#[cfg_attr(not(windows), allow(dead_code))]
fn service_defaults(mut args: UpArgs, program_data: &std::path::Path) -> UpArgs {
let root = program_data.join("Tsunagi");
args.paths
.state_dir
.get_or_insert_with(|| root.join("data"));
args.paths
.cache_dir
.get_or_insert_with(|| root.join("cache"));
args.control_socket
.get_or_insert_with(tsunagi::ipc::default_control_socket_path);
args
}
/// Hands the process to the Windows service manager, with `up` as the body.
#[cfg(windows)]
fn run_service(args: UpArgs) -> std::process::ExitCode {
let program_data = std::env::var_os("ProgramData")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"));
let args = service_defaults(args, &program_data);
let args = std::sync::Mutex::new(Some(args));
let body = move || -> Result<(), String> {
let Some(args) = args.lock().ok().and_then(|mut slot| slot.take()) else {
return Err("the service was started twice".to_string());
};
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|err| format!("cannot start the async runtime: {err}"))?;
runtime.block_on(up(args)).map_err(|err| err.to_string())
};
match winservice::run(Box::new(body)) {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(err) => {
eprintln!(
"error: cannot run as a service ({err}); `tsng service` is started by the \
Windows service manager, use `tsng up` to run the agent yourself"
);
std::process::ExitCode::FAILURE
}
}
}
async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> { async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
match command { match command {
Command::Id(args) => id(args).await, Command::Id(args) => id(args).await,
@@ -796,6 +900,11 @@ async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
Command::Dns(args) => dns_command(args).await, Command::Dns(args) => dns_command(args).await,
Command::ExitNode(args) => exit_node_command(args).await, Command::ExitNode(args) => exit_node_command(args).await,
Command::Wipe(args) => wipe(args).await, Command::Wipe(args) => wipe(args).await,
// Taken in `main`, before there is a runtime: the service manager
// wants the process's thread, not an async task.
Command::Service(_) => Err("`tsng service` is started by the Windows service \
manager; use `tsng up` to run the agent yourself"
.into()),
} }
} }
@@ -2085,15 +2194,29 @@ async fn broadcast_command(
} }
/// What to run to turn kernel forwarding on, which the agent never does. /// What to run to turn kernel forwarding on, which the agent never does.
#[cfg(target_os = "macos")]
const FORWARDING_COMMAND: &str = "sudo sysctl -w net.inet.ip.forwarding=1";
#[cfg(target_os = "windows")]
const FORWARDING_COMMAND: &str = "Get-NetIPInterface | Set-NetIPInterface -Forwarding Enabled # in an administrator PowerShell";
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
const FORWARDING_COMMAND: &str = "sudo sysctl -w net.ipv4.ip_forward=1"; const FORWARDING_COMMAND: &str = "sudo sysctl -w net.ipv4.ip_forward=1";
/// How to keep forwarding on across reboots.
#[cfg(target_os = "macos")]
const FORWARDING_PERSIST: &str = "macOS forgets it at reboot; set it again from a boot script \
such as a LaunchDaemon";
#[cfg(target_os = "windows")]
const FORWARDING_PERSIST: &str = "the setting is kept across reboots";
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
const FORWARDING_PERSIST: &str =
"keep it across reboots with `net.ipv4.ip_forward = 1` in /etc/sysctl.d/99-tsunagi.conf";
/// The warning for an exit node whose rules are in place while the kernel is /// The warning for an exit node whose rules are in place while the kernel is
/// not forwarding, so that nothing passes. /// not forwarding, so that nothing passes.
fn forwarding_off_note() -> String { fn forwarding_off_note() -> String {
format!( format!(
"kernel forwarding is OFF, so nothing passes yet: run `{FORWARDING_COMMAND}` \ "kernel forwarding is OFF, so nothing passes yet: run `{FORWARDING_COMMAND}` \
(and keep it across reboots with `net.ipv4.ip_forward = 1` in \ ({FORWARDING_PERSIST})"
/etc/sysctl.d/99-tsunagi.conf)"
) )
} }
@@ -4886,7 +5009,14 @@ async fn stop_signal() -> &'static str {
_ = terminate.recv() => "terminated", _ = terminate.recv() => "terminated",
} }
} }
#[cfg(not(unix))] #[cfg(windows)]
{
tokio::select! {
_ = tokio::signal::ctrl_c() => "interrupted",
_ = winservice::stop_requested() => "stopped by the service manager",
}
}
#[cfg(not(any(unix, windows)))]
{ {
let _ = tokio::signal::ctrl_c().await; let _ = tokio::signal::ctrl_c().await;
"interrupted" "interrupted"
@@ -5435,10 +5565,7 @@ mod status_tests {
out.push(network_section(&network, OWN, false)); out.push(network_section(&network, OWN, false));
let text = out.render(false); let text = out.render(false);
assert_eq!(out.worst(), Health::Degraded, "{text}"); assert_eq!(out.worst(), Health::Degraded, "{text}");
assert!( assert!(text.contains(FORWARDING_COMMAND), "{text}");
text.contains("sudo sysctl -w net.ipv4.ip_forward=1"),
"{text}"
);
assert!(text.contains("ok (nat + forward)"), "{text}"); assert!(text.contains("ok (nat + forward)"), "{text}");
network.exit.forwarding = Some(true); network.exit.forwarding = Some(true);
@@ -5932,3 +6059,93 @@ mod network_context_tests {
assert_eq!(standing, NetworkStanding::Stopped); assert_eq!(standing, NetworkStanding::Stopped);
} }
} }
#[cfg(test)]
mod service_log_tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use super::*;
#[test]
fn the_log_appends_and_a_large_one_is_moved_aside() {
let dir = tempfile::tempdir().unwrap();
let logs = dir.path().join("logs");
let mut file = open_log_in(&logs).unwrap();
writeln!(file, "one").unwrap();
drop(file);
let mut file = open_log_in(&logs).unwrap();
writeln!(file, "two").unwrap();
drop(file);
assert_eq!(
std::fs::read_to_string(logs.join("tsng.log")).unwrap(),
"one\ntwo\n"
);
let big = std::fs::File::options()
.append(true)
.open(logs.join("tsng.log"))
.unwrap();
big.set_len(SERVICE_LOG_LIMIT + 1).unwrap();
drop(big);
let file = open_log_in(&logs).unwrap();
assert_eq!(file.metadata().unwrap().len(), 0);
assert!(logs.join("tsng.log.old").exists());
}
#[test]
fn a_log_that_cannot_be_opened_is_none() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("file");
std::fs::write(&file, b"x").unwrap();
// A directory cannot be made under a regular file.
assert!(open_log_in(&file.join("logs")).is_none());
}
#[test]
fn the_service_keeps_its_state_under_program_data_unless_told_otherwise() {
use clap::Parser;
let parse = |extra: &[&str]| {
let mut words = vec!["tsng", "service"];
words.extend_from_slice(extra);
match Cli::try_parse_from(words).unwrap().command {
Command::Service(args) => *args,
other => panic!("{other:?}"),
}
};
let root = std::path::Path::new("/pd");
let args = service_defaults(parse(&[]), root);
assert_eq!(
args.paths.state_dir,
Some(root.join("Tsunagi").join("data"))
);
assert_eq!(
args.paths.cache_dir,
Some(root.join("Tsunagi").join("cache"))
);
assert_eq!(
args.control_socket,
Some(tsunagi::ipc::default_control_socket_path())
);
let args = service_defaults(
parse(&["--state-dir", "/mine", "--control-socket", "/mine/sock"]),
root,
);
assert_eq!(args.paths.state_dir, Some(PathBuf::from("/mine")));
assert_eq!(args.control_socket, Some(PathBuf::from("/mine/sock")));
}
#[test]
fn the_service_command_is_refused_off_the_service_manager() {
use clap::Parser;
let cli = Cli::try_parse_from(["tsng", "service"]).unwrap();
assert!(matches!(cli.command, Command::Service(_)));
let runtime = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let err = runtime.block_on(run(cli.command)).unwrap_err();
assert!(err.to_string().contains("service manager"), "{err}");
}
}
+121
View File
@@ -0,0 +1,121 @@
//! The agent as a Windows service.
//!
//! `tsng service` is what the installer registers with the service control
//! manager (`sc create`); nobody types it. The manager starts the process,
//! which must call into the dispatcher quickly and then report its state, so
//! this is the only place the Windows service API is touched. Everything the
//! agent does is the same `up` that runs in a console; the only differences
//! are that it stops when the manager says so rather than on Ctrl-C, and that
//! it logs to a file because a service has no console.
//!
//! The one `unsafe` here is not ours: `define_windows_service!` expands to the
//! `extern "system"` entry point the manager calls, which has to be unsafe.
#![allow(unsafe_code)]
use std::ffi::OsString;
use std::sync::OnceLock;
use std::time::Duration;
use windows_service::service::{
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType,
};
use windows_service::service_control_handler::{
self, ServiceControlHandlerResult, ServiceStatusHandle,
};
use windows_service::{define_windows_service, service_dispatcher};
/// The name the installer registers the service under.
pub const SERVICE_NAME: &str = "Tsunagi";
/// How long the manager waits for a stop before it gives up on us. The agent's
/// own shutdown is bounded and shorter.
const STOP_WAIT: Duration = Duration::from_secs(25);
/// Set when the manager asks the service to stop.
static STOP: tokio::sync::Notify = tokio::sync::Notify::const_new();
/// What `up` does, handed over by `main` because the manager's entry point
/// cannot take arguments.
type Body = Box<dyn Fn() -> Result<(), String> + Send + Sync>;
static BODY: OnceLock<Body> = OnceLock::new();
/// Where the manager's status handle is kept, so the stop handler can report.
static STATUS: OnceLock<ServiceStatusHandle> = OnceLock::new();
/// Resolves when the manager has asked the service to stop.
pub async fn stop_requested() {
STOP.notified().await;
}
define_windows_service!(ffi_service_main, service_main);
/// Hands the process to the service control manager and returns when the
/// service has stopped. Fails at once when not started by the manager, which
/// is how running `tsng service` in a console is reported.
pub fn run(body: Body) -> Result<(), windows_service::Error> {
let _ = BODY.set(body);
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
}
fn service_main(_arguments: Vec<OsString>) {
let result = serve();
let code = match &result {
Ok(()) => ServiceExitCode::Win32(0),
Err(err) => {
tracing::error!("the service failed: {err}");
// A non-zero exit lets the recovery actions restart it.
ServiceExitCode::ServiceSpecific(1)
}
};
if let Some(handle) = STATUS.get() {
let _ = handle.set_service_status(status(ServiceState::Stopped, code, Duration::ZERO));
}
}
fn serve() -> Result<(), String> {
let handle = service_control_handler::register(SERVICE_NAME, |control| match control {
ServiceControl::Stop | ServiceControl::Shutdown => {
if let Some(handle) = STATUS.get() {
let _ = handle.set_service_status(status(
ServiceState::StopPending,
ServiceExitCode::Win32(0),
STOP_WAIT,
));
}
STOP.notify_one();
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
})
.map_err(|err| format!("cannot register with the service control manager: {err}"))?;
let _ = STATUS.set(handle);
handle
.set_service_status(status(
ServiceState::Running,
ServiceExitCode::Win32(0),
Duration::ZERO,
))
.map_err(|err| format!("cannot report the service as running: {err}"))?;
match BODY.get() {
Some(body) => body(),
None => Err("the service has nothing to run".to_string()),
}
}
fn status(state: ServiceState, exit_code: ServiceExitCode, wait_hint: Duration) -> ServiceStatus {
ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: state,
controls_accepted: if state == ServiceState::Running {
ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN
} else {
ServiceControlAccept::empty()
},
exit_code,
checkpoint: 0,
wait_hint,
process_id: None,
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ service's socket at `/run/tsunagi/agent.sock`):
```sh ```sh
sudo tsng status sudo tsng status
sudo tsng join <network-name> <tsn1…secret> sudo tsng join -n <network-name> -s <tsn1…secret>
``` ```
## How it runs ## How it runs
+1 -1
View File
@@ -28,7 +28,7 @@ group, so after `sudo usermod -aG tsunagi "$USER"` (and logging in again) no
```sh ```sh
sudo tsng status sudo tsng status
sudo tsng join <network-name> <tsn1…secret> sudo tsng join -n <network-name> -s <tsn1…secret>
``` ```
## As your own user — one capability, no service ## As your own user — one capability, no service
+1 -1
View File
@@ -48,7 +48,7 @@ Done. Start the agent with:
Control it with sudo (the CLI finds the service's socket automatically): Control it with sudo (the CLI finds the service's socket automatically):
sudo tsng status sudo tsng status
sudo tsng join <network-name> <tsn1…secret> sudo tsng join -n <network-name> -s <tsn1…secret>
EOF EOF
else else
cat <<EOF cat <<EOF
+1 -1
View File
@@ -15,7 +15,7 @@ socket and do **not** need `sudo`:
```sh ```sh
tsng status tsng status
tsng join <network-name> <tsn1…secret> tsng join -n <network-name> -s <tsn1…secret>
``` ```
The agent uses fixed system paths, so the commands find it regardless of whose The agent uses fixed system paths, so the commands find it regardless of whose
+29
View File
@@ -1,5 +1,34 @@
# Running tsunagi on Windows # Running tsunagi on Windows
## The installer (recommended)
`tsunagi-setup-<version>-x86_64.exe` does everything in one go. Run it as an
administrator (it asks):
- installs `tsng.exe`, the tray app and **Wintun** to `C:\Program Files\Tsunagi`;
- registers and starts the **Tsunagi** Windows service (it starts at boot and
restarts after a crash), running as the system account;
- adds `tsng` to the PATH and an inbound Windows Firewall rule for the agent
(both are tasks you can untick);
- starts the tray icon at sign-in.
Where things live: the device's identity and networks in
`%ProgramData%\Tsunagi\data` (readable only by administrators and the system),
the log in `%ProgramData%\Tsunagi\logs\tsng.log`. The service keeps its own
state: networks joined by an agent you started by hand (`tsng up`, which keeps
its data in your user profile) are not carried over; join them again with
`tsng join`. The installer stops such an agent, because it cannot run beside the
service.
`tsng status`, `tsng join` and the tray work from an ordinary, non-elevated
terminal. Manage the service with the Services app or `sc stop Tsunagi` /
`sc start Tsunagi`. Silent install: `/VERYSILENT /SUPPRESSMSGBOXES`; choose
tasks with `/TASKS="!autostart,addtopath,firewall"`. Uninstalling removes the
service, the firewall rule and the PATH entry, and asks before deleting the
data.
## By hand (the zip)
This archive holds `tsng.exe`. The overlay interface is a Wintun adapter, so This archive holds `tsng.exe`. The overlay interface is a Wintun adapter, so
the agent needs two things to carry real traffic: the agent needs two things to carry real traffic:
+242
View File
@@ -0,0 +1,242 @@
; Inno Setup script for the Tsunagi Windows installer.
;
; One installer that leaves a working machine:
; * the agent (tsng.exe), as a Windows service that starts at boot
; * Wintun, the driver library behind the overlay interface
; * the tray application, started at sign-in
; * `tsng` on the PATH, and an inbound Defender Firewall rule for the agent
;
; Built in CI (see .github/workflows/release.yml); locally:
; ISCC.exe /DAppVersion=0.1.0 /DSourceDir=<dir with tsng.exe and tsunagi-tray.exe>
; /DWintunDir=<wintun\bin\amd64> /O<output dir> dist\windows\tsunagi.iss
; Silent install: tsunagi-setup-<version>-x86_64.exe /VERYSILENT /SUPPRESSMSGBOXES
; Pick tasks with /TASKS="!autostart,addtopath,firewall".
#ifndef AppVersion
#define AppVersion "0.0.0"
#endif
#ifndef SourceDir
#error SourceDir must point at the directory holding tsng.exe and tsunagi-tray.exe
#endif
#ifndef WintunDir
#error WintunDir must point at the directory holding wintun.dll
#endif
#define ServiceName "Tsunagi"
[Setup]
AppId={{F635E5DD-250D-499F-B0B3-AFC7CC64A15D}
AppName=Tsunagi
AppVersion={#AppVersion}
AppPublisher=Tsunagi
DefaultDirName={autopf}\Tsunagi
DefaultGroupName=Tsunagi
DisableProgramGroupPage=yes
PrivilegesRequired=admin
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
OutputBaseFilename=tsunagi-setup-{#AppVersion}-x86_64
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
ChangesEnvironment=yes
UninstallDisplayName=Tsunagi
; The service and the tray are stopped by hand below, so the installer does
; not need to ask Windows to close applications.
CloseApplications=no
[Tasks]
Name: "autostart"; Description: "Start the tray icon when I sign in"; GroupDescription: "Tsunagi:"
Name: "addtopath"; Description: "Add tsng to the PATH"; GroupDescription: "Tsunagi:"
Name: "firewall"; Description: "Allow the agent through Windows Firewall (direct connections between devices)"; GroupDescription: "Tsunagi:"
[Files]
Source: "{#SourceDir}\tsng.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#SourceDir}\tsunagi-tray.exe"; DestDir: "{app}"; Flags: ignoreversion
; Wintun is the unmodified, signed library from wintun.net; its licence allows
; shipping it with an application and asks for the licence text beside it.
; In the archive the text sits at its root, two levels above bin\amd64.
Source: "{#WintunDir}\wintun.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#WintunDir}\..\..\LICENSE.txt"; DestDir: "{app}\licenses"; DestName: "wintun-LICENSE.txt"; Flags: ignoreversion
[Dirs]
; Identity and network secrets live here; locked down below.
Name: "{commonappdata}\Tsunagi\data"
Name: "{commonappdata}\Tsunagi\cache"
Name: "{commonappdata}\Tsunagi\logs"
[Icons]
Name: "{group}\Tsunagi"; Filename: "{app}\tsunagi-tray.exe"
Name: "{commonstartup}\Tsunagi"; Filename: "{app}\tsunagi-tray.exe"; Tasks: autostart
[Registry]
Root: HKLM; Subkey: "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; \
ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \
Check: NeedsAddPath('{app}'); Tasks: addtopath
[Run]
; Only the system account and administrators may read the state: it holds the
; device's key and every network's secret. SIDs, so it works in any language.
Filename: "{sys}\icacls.exe"; \
Parameters: """{commonappdata}\Tsunagi\data"" /inheritance:r /grant:r *S-1-5-18:(OI)(CI)F *S-1-5-32-544:(OI)(CI)F"; \
Flags: runhidden; StatusMsg: "Protecting the agent's data..."
Filename: "{sys}\icacls.exe"; \
Parameters: """{commonappdata}\Tsunagi\cache"" /inheritance:r /grant:r *S-1-5-18:(OI)(CI)F *S-1-5-32-544:(OI)(CI)F"; \
Flags: runhidden
Filename: "{sys}\icacls.exe"; \
Parameters: """{commonappdata}\Tsunagi\logs"" /inheritance:r /grant:r *S-1-5-18:(OI)(CI)F *S-1-5-32-544:(OI)(CI)F"; \
Flags: runhidden
; The tray runs as the person who installed, not elevated, like it does at sign-in.
Filename: "{app}\tsunagi-tray.exe"; Description: "Start the Tsunagi tray"; \
Flags: nowait postinstall skipifsilent runasoriginaluser
[UninstallRun]
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=""Tsunagi"""; \
Flags: runhidden; RunOnceId: "RemoveFirewallRule"
[Code]
const
ServiceName = '{#ServiceName}';
EnvKey = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment';
function Sc(const Params: String): Integer;
var
Code: Integer;
begin
if Exec(ExpandConstant('{sys}\sc.exe'), Params, '', SW_HIDE, ewWaitUntilTerminated, Code) then
Result := Code
else
Result := -1;
end;
function ServiceExists: Boolean;
begin
// 1060 is ERROR_SERVICE_DOES_NOT_EXIST.
Result := Sc('query ' + ServiceName) <> 1060;
end;
procedure StopService;
var
Code: Integer;
begin
// `net stop` waits until the service has stopped, or says it was not running.
Exec(ExpandConstant('{sys}\net.exe'), 'stop ' + ServiceName, '', SW_HIDE, ewWaitUntilTerminated, Code);
end;
procedure StopTray;
var
Code: Integer;
begin
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM tsunagi-tray.exe', '', SW_HIDE, ewWaitUntilTerminated, Code);
end;
// An agent started by hand (`tsng up`) holds the control pipe and the Wintun
// adapter, and the file about to be replaced: it cannot run beside the service.
procedure StopStrayAgents;
var
Code: Integer;
begin
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM tsng.exe', '', SW_HIDE, ewWaitUntilTerminated, Code);
end;
function NeedsAddPath(Param: String): Boolean;
var
Current: String;
begin
if not RegQueryStringValue(HKLM, EnvKey, 'Path', Current) then
begin
Result := True;
exit;
end;
Result := Pos(';' + Uppercase(ExpandConstant(Param)) + ';', ';' + Uppercase(Current) + ';') = 0;
end;
procedure RemoveFromPath;
var
Current, Entry: String;
At: Integer;
begin
if not RegQueryStringValue(HKLM, EnvKey, 'Path', Current) then
exit;
Entry := ';' + ExpandConstant('{app}');
At := Pos(Uppercase(Entry), Uppercase(Current));
if At > 0 then
begin
Delete(Current, At, Length(Entry));
RegWriteExpandStringValue(HKLM, EnvKey, 'Path', Current);
end;
end;
// Stops what is about to be replaced: the files are in use while it runs.
function PrepareToInstall(var NeedsRestart: Boolean): String;
begin
StopTray;
if ServiceExists then
StopService;
StopStrayAgents;
Result := '';
end;
procedure InstallService;
var
App: String;
begin
App := ExpandConstant('{app}\tsng.exe');
// The command line is `tsng service`: it keeps its state under
// %ProgramData%\Tsunagi and serves the system-wide control socket.
if ServiceExists then
Sc('config ' + ServiceName + ' binPath= "\"' + App + '\" service" start= auto obj= LocalSystem')
else
Sc('create ' + ServiceName + ' binPath= "\"' + App + '\" service" start= auto obj= LocalSystem DisplayName= "Tsunagi agent"');
Sc('description ' + ServiceName + ' "Tsunagi: private mesh network agent"');
// Restart after a crash, backing off, and forget the failures after a day.
Sc('failure ' + ServiceName + ' reset= 86400 actions= restart/5000/restart/5000/restart/30000');
Sc('start ' + ServiceName);
end;
procedure InstallFirewallRule;
var
Code: Integer;
Netsh: String;
begin
Netsh := ExpandConstant('{sys}\netsh.exe');
Exec(Netsh, 'advfirewall firewall delete rule name="Tsunagi"', '', SW_HIDE, ewWaitUntilTerminated, Code);
if WizardIsTaskSelected('firewall') then
Exec(Netsh,
'advfirewall firewall add rule name="Tsunagi" dir=in action=allow enable=yes profile=any program="' +
ExpandConstant('{app}\tsng.exe') + '"',
'', SW_HIDE, ewWaitUntilTerminated, Code);
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssPostInstall then
begin
InstallFirewallRule;
InstallService;
end;
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usUninstall then
begin
StopTray;
if ServiceExists then
begin
StopService;
Sc('delete ' + ServiceName);
end;
StopStrayAgents;
RemoveFromPath;
end;
if CurUninstallStep = usPostUninstall then
begin
// The data is the device's identity and its networks: kept unless asked,
// and never removed by a silent uninstall.
if (not UninstallSilent) and
(MsgBox('Also delete this device''s identity, networks and logs?' + #13#10 +
'Choose No to keep them for a later reinstall.', mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES) then
DelTree(ExpandConstant('{commonappdata}\Tsunagi'), True, True, True);
end;
end;
+43
View File
@@ -59,6 +59,49 @@ be an exit node in one network and not in another.
specific IPv6 routes keep working, and so does the agent's own IPv6. A host specific IPv6 routes keep working, and so does the agent's own IPv6. A host
with no IPv6 has nothing to block and is left alone. with no IPv6 has nothing to block and is left alone.
## macOS
macOS has no `iptables` and no per-user policy routing, so the same behaviour
is built from `pf` and `route`. The agent runs as root there.
- **Offering:** a `pf` anchor `com.apple/tsunagi-exit-<interface>` (the stock
`/etc/pf.conf` already evaluates `com.apple/*`) holds
`nat on <egress> from <range> to any -> (<egress>)` and a `pass in` rule.
`pf` is enabled if it was off, and that reference is released when the rules
go. Forwarding is only read: `sudo sysctl -w net.inet.ip.forwarding=1`, which
macOS forgets at reboot. The egress is the default route's interface **at the
time the rules are applied**; after it changes (Wi-Fi to Ethernet) switch the
offer off and on.
- **Using:** `0.0.0.0/1` and `128.0.0.0/1` routes through the overlay interface,
and a `pf` `route-to` rule that sends the agent's own traffic (matched by its
user id, root) back out the physical interface, with translation to that
interface's address. Anything else you run as root also bypasses the tunnel.
IPv6 is blocked with rejecting `::/1` and `8000::/1` routes.
- Not verified on a live host when this was written: run `tsng exit-node` and
check `sudo pfctl -a com.apple/tsunagi-exit-utunN -s rules -s nat`.
## Windows
- **Offering:** each offered range gets a WinNAT object
`tsunagi-exit:<interface>:<range>` (`New-NetNat`), removed with the offer, the
network and the agent. Forwarding is read for the overlay interface and never
changed; Windows forwards per interface, so enable it on the ones involved
(`Get-NetIPInterface | Set-NetIPInterface -Forwarding Enabled`, administrator
PowerShell). Some Windows versions allow one NAT per host; a second range then
reports its own failure.
- **Using:** Windows has no per-user routing, so the agent's own traffic is kept
off the tunnel *by destination*. `0.0.0.0/1` and `128.0.0.0/1` go through the
overlay interface; a `/32` route with metric 7373 (the marker for "ours")
through the physical default gateway is added for every address the agent
talks to directly: verified direct paths of its connections, plus the relays
it uses, resolved when the rules are applied. The set follows path changes. A
path that appears after the last apply goes through the tunnel until the next
one. IPv6 is blackholed with `::/1` and `8000::/1` on the overlay interface,
so applications fall back to IPv4 after a delay rather than at once.
Everything is in the active store: a reboot clears it.
- Not verified on a live host when this was written. To inspect:
`Get-NetRoute -PolicyStore ActiveStore | ? { $_.RouteMetric -eq 7373 -or $_.InterfaceAlias -like 'tsun*' }`.
## What it does not do ## What it does not do
- **The agent must run as a system service** (its own user). The uid exemption - **The agent must run as a system service** (its own user). The uid exemption
+2 -2
View File
@@ -26,8 +26,8 @@ system can hand us IP packets, and even that is behind a trait
| `ManagedTunFactory` | `CAP_NET_ADMIN` | traffic actually reaches the OS | | `ManagedTunFactory` | `CAP_NET_ADMIN` | traffic actually reaches the OS |
`ManagedTunFactory` creates the interface and configures it; see `ManagedTunFactory` creates the interface and configures it; see
*Provisioning the interface* below and *Privileges* in *Provisioning the interface* below and Privileges in
[../README.md](../README.md#privileges). With no capability the agent runs [../dist/linux/README.md](../dist/linux/README.md). With no capability the agent runs
with `--no-tun`: everything but the last hop into the kernel still works. with `--no-tun`: everything but the last hop into the kernel still works.
[boringtun]: https://docs.rs/boringtun [boringtun]: https://docs.rs/boringtun
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1790769526,
"narHash": "sha256-5AIVFLRcx8YAwbFTVPTs7YhwJH7JyHfAQsvXmpovcXI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c9fe7d12cd78d1adcd12dd15e24432dde5b155a0",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+39
View File
@@ -0,0 +1,39 @@
{
description = "tsunagi: serverless mesh VPN. A network name and a secret are all it takes.";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
outputs =
{ self, nixpkgs }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
packages = forAllSystems (pkgs: rec {
tsunagi = pkgs.callPackage ./nix/package.nix { };
tsunagi-cli = pkgs.callPackage ./nix/package.nix { withTray = false; };
default = tsunagi;
});
overlays.default = final: _prev: {
tsunagi = final.callPackage ./nix/package.nix { };
tsunagi-cli = final.callPackage ./nix/package.nix { withTray = false; };
};
nixosModules.default = import ./nix/module.nix self;
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
inputsFrom = [ self.packages.${pkgs.stdenv.hostPlatform.system}.tsunagi ];
packages = [
pkgs.rustfmt
pkgs.clippy
];
};
});
};
}
+132
View File
@@ -0,0 +1,132 @@
flake:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.tsunagi;
socket = "/run/tsunagi/agent.sock";
in
{
options.services.tsunagi = {
enable = lib.mkEnableOption "the tsunagi mesh agent";
package = lib.mkOption {
type = lib.types.package;
default =
if cfg.tray.enable then
flake.packages.${pkgs.stdenv.hostPlatform.system}.tsunagi
else
flake.packages.${pkgs.stdenv.hostPlatform.system}.tsunagi-cli;
defaultText = lib.literalExpression "tsunagi.packages.\${system}.tsunagi (tsunagi-cli without the tray)";
description = "The tsunagi package to run.";
};
tray.enable = lib.mkEnableOption "the tray client, started with every graphical session";
users = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "alice" ];
description = ''
Users allowed to talk to the agent without sudo (`tsng status`, `tsng join`,
the tray). They are added to the `tsunagi` group.
'';
};
protocols = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = null;
example = [
"wg-quic"
"wg"
];
description = "Packet protocols for `tsng up --protocol`, best first. Null keeps the agent's default.";
};
};
config = lib.mkIf cfg.enable {
users.groups.tsunagi = { };
users.users = {
tsunagi = {
isSystemUser = true;
group = "tsunagi";
home = "/var/lib/tsunagi";
description = "tsunagi agent";
};
}
// lib.genAttrs cfg.users (_: {
extraGroups = [ "tsunagi" ];
});
systemd.services.tsunagi = {
description = "tsunagi mesh agent";
documentation = [ "https://github.com/house-of-vanity/tsunagi" ];
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
environment = {
TSUNAGI_STATE_DIR = "/var/lib/tsunagi";
TSUNAGI_CACHE_DIR = "/var/cache/tsunagi";
TSUNAGI_CONTROL_SOCKET = socket;
TSUNAGI_CONTROL_GROUP = "tsunagi";
};
serviceConfig = {
ExecStart = lib.concatStringsSep " " (
[ "${cfg.package}/bin/tsng up" ]
++ lib.optional (cfg.protocols != null) "--protocol ${lib.concatStringsSep "," cfg.protocols}"
);
User = "tsunagi";
Group = "tsunagi";
AmbientCapabilities = [
"CAP_NET_ADMIN"
"CAP_NET_BIND_SERVICE"
];
CapabilityBoundingSet = [
"CAP_NET_ADMIN"
"CAP_NET_BIND_SERVICE"
];
StateDirectory = "tsunagi";
StateDirectoryMode = "0700";
CacheDirectory = "tsunagi";
RuntimeDirectory = "tsunagi";
RuntimeDirectoryMode = "0755";
Restart = "on-failure";
RestartSec = 5;
};
};
# Exit nodes: the agent looks for iptables only in fixed FHS directories.
systemd.tmpfiles.rules = [
"L+ /usr/bin/iptables - - - - ${pkgs.iptables}/bin/iptables"
];
# Names of mesh members are served locally and handed to the system resolver.
services.resolved.enable = lib.mkDefault true;
# polkit decides by group, not capability, who may configure systemd-resolved.
security.polkit.extraConfig = ''
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.isInGroup("tsunagi")) {
return polkit.Result.YES;
}
});
'';
environment.systemPackages = [ cfg.package ];
environment.sessionVariables.TSUNAGI_CONTROL_SOCKET = socket;
environment.etc."xdg/autostart/tsunagi-tray.desktop" = lib.mkIf cfg.tray.enable {
source = "${cfg.package}/share/applications/tsunagi-tray.desktop";
};
};
}
+94
View File
@@ -0,0 +1,94 @@
{
withTray ? true,
lib,
rustPlatform,
pkg-config,
copyDesktopItems,
wrapGAppsHook3,
gtk3,
libayatana-appindicator,
libglvnd,
libxkbcommon,
vulkan-loader,
wayland,
xdotool,
libx11,
libxcursor,
libxi,
libxrandr,
}:
let
# The tray is an egui app that loads graphics, windowing and appindicator
# libraries at run time, so they have to be put on its library path by hand.
trayRuntimeLibs = [
libayatana-appindicator
libglvnd
libxkbcommon
vulkan-loader
wayland
libx11
libxcursor
libxi
libxrandr
];
in
rustPlatform.buildRustPackage {
pname = if withTray then "tsunagi" else "tsunagi-cli";
version = "0.1.0";
src = lib.cleanSource ../.;
cargoLock.lockFile = ../Cargo.lock;
cargoBuildFlags = [
"--bin"
"tsng"
]
++ lib.optionals withTray [
"--bin"
"tsunagi-tray"
];
# The suite opens real sockets and interfaces; it runs in CI instead.
doCheck = false;
nativeBuildInputs = [ pkg-config ] ++ lib.optionals withTray [
copyDesktopItems
wrapGAppsHook3
];
buildInputs = lib.optionals withTray [
gtk3
libayatana-appindicator
xdotool
];
dontWrapGApps = true;
postInstall = ''
install -Dm644 dist/linux/50-tsunagi-resolved.rules \
"$out/share/doc/tsunagi/50-tsunagi-resolved.rules"
''
+ lib.optionalString withTray ''
install -Dm644 dist/linux/tsunagi.svg \
"$out/share/icons/hicolor/scalable/apps/tsunagi.svg"
install -Dm644 dist/linux/tsunagi-tray.desktop \
"$out/share/applications/tsunagi-tray.desktop"
'';
postFixup = lib.optionalString withTray ''
wrapProgram "$out/bin/tsunagi-tray" \
"''${gappsWrapperArgs[@]}" \
--prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath trayRuntimeLibs}:/run/opengl-driver/lib
'';
meta = {
description = "Serverless mesh VPN: join a network with a name and a secret";
homepage = "https://github.com/house-of-vanity/tsunagi";
license = lib.licenses.wtfpl;
mainProgram = "tsng";
platforms = [
"x86_64-linux"
"aarch64-linux"
];
};
}