AWG-only policy also selected a competing end-to-end plugin, so compatible external links could still have no compatible encrypted data channel. Carrier changes replaced tunnel state and status selected counters from different engines. Use one internal WireGuard plugin and a separate DataCarrier contract for external capabilities/lifecycle. Keep the logical link, keys, handshake, interface and counter epoch across policy, fallback and direct/relay changes. Control/discovery transport selection and membership remain independent. Share the bounded per-neighbour UDP WireGuard engine between ordinary WG and Amnezia masking. Encrypt the relay envelope inside the outer hop channel with fresh pair keys/PSK. Remove duplicate WG key/device code and the TCP/TLS inner cipher. TLS carries opaque internal WireGuard frames. Refuse missing native transports instead of letting default iroh impersonate their protocol names. Probe the actual authenticated carrier, independently of the shared IP tunnel; failed local probe submissions count as loss too. Apply the vendored iroh interface exclusion to actual direct candidates, not just public discovery records. Exclude overlay subnets from introductions and propagate the actual owned interface name, including kernel-assigned utun names, to every carrier. Production logs showed paths through the overlay itself. This removes a recursive transport path; it does not establish the historical cause of the reported 100 GB absolute counter. Exit-node traffic is also real peer IP traffic even when local applications are idle. Hide inner cipher choices/handshake clutter in CLI and tray. Report external routes, readiness and actionable policy/version failures. Count original IP packets only when their ciphertext is accepted by the logical transport, including queued packets' exact unpadded lengths. Exclude physical probes and keepalives. Export counter epochs; sum per-peer rate deltas and discard stale histories so joins/restarts cannot turn old totals into fresh traffic. Status also prints exact IP byte totals for comparison with the tray. Coordinated upgrade: data ALPN 6, internal wireguard-e2e capability v4, wg/awg v2, QUIC carrier v1, TLS v2, local IPC v23. Control ALPN 8, device/network identity derivation, signed records and saved network choices stay unchanged. Keep the historical wg-quic key store; transactionally import missing keys from wg then awg without replacing canonical keys. Forget removes legacy copies too, on the blocking pool, to prevent resurrection after restart. Preserve historic cipher options as shared settings, rejecting contradictory values. Validation on Linux in the Nix development shell: - cargo fmt --all -- --check - cargo clippy --locked --workspace --all-targets -- -D warnings - cargo test --locked --workspace --all-targets (562 passed, 3 ignored) The inherited service socket/secret/group environment overrides were removed for isolated CLI process tests. A new carrier test initially raced the first peer announcement; its deadline polling now waits for a peer row explicitly. Real loopback endpoints, membership, SQLite, WG/AWG/TLS encryption and memory TUNs cover mixed-carrier relaying and one smoltcp TCP connection surviving relay/direct/policy changes and a dropped transmission. Counter tests check exact plaintext lengths, refused sends, epochs, joins and stale histories. Public DHT, privileged kernel and manual benchmark tests remain ignored; macOS/Windows native TUNs, desktop GUI and WAN/DPI behavior were not run.
260 lines
12 KiB
Markdown
260 lines
12 KiB
Markdown
<p align="center">
|
||
<img src="dist/linux/tsunagi.svg" width="96" alt="">
|
||
</p>
|
||
|
||
<h1 align="center">tsunagi</h1>
|
||
|
||
<p align="center">
|
||
<b>A mesh VPN with no server.</b><br>
|
||
Pick a network name, share a secret, and your machines are on one private network.
|
||
</p>
|
||
|
||
<p align="center">
|
||
<a href="https://github.com/house-of-vanity/tsunagi/releases/latest"><img alt="release" src="https://img.shields.io/github/v/release/house-of-vanity/tsunagi?include_prereleases"></a>
|
||
<a href="https://github.com/house-of-vanity/tsunagi/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/house-of-vanity/tsunagi/actions/workflows/ci.yml/badge.svg"></a>
|
||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-WTFPL-blue"></a>
|
||
</p>
|
||
|
||
```console
|
||
laptop$ tsng join -n home
|
||
joined `home`
|
||
secret tsn1u7c…
|
||
|
||
server$ tsng join -n home -s tsn1u7c…
|
||
server$ tsng id hostname server
|
||
|
||
laptop$ ssh server.home
|
||
```
|
||
|
||
No account, no coordination server, no admin console, no keys to approve.
|
||
Anyone who knows the **name** and the **secret** is in. Nobody else can find
|
||
the network, let alone enter it.
|
||
|
||
## Why
|
||
|
||
Tailscale, Headscale and ZeroTier are great, but each needs a control server:
|
||
theirs, or one you have to run and keep alive. tsunagi doesn't. Machines find
|
||
each other through the public BitTorrent DHT, connect directly (hole punching,
|
||
with relay fallback), and keep the network state among themselves.
|
||
|
||
| | Tailscale | Headscale | ZeroTier | **tsunagi** |
|
||
| ---------------------- | ------------------ | ---------------- | ------------------- | ------------------- |
|
||
| Control server | hosted by vendor | you run it | hosted or self-run | **none** |
|
||
| Account / sign-in | yes | yes | yes | **no** |
|
||
| To join a network | sign in, get approved | pre-auth key | network ID, approval | **name + secret** |
|
||
| Direct connections | yes | yes | yes | yes |
|
||
| Exit nodes | yes | yes | via routes | yes |
|
||
|
||
## Features
|
||
|
||
- **Two things to know:** a network name and a secret. A secret is generated for you when you make a network.
|
||
- **Direct, encrypted links** between members (WireGuard cryptography), with relay fallback behind strict NATs.
|
||
- **Names for machines:** `server.home` just works, even while `server` is switched off. A name belongs to whoever claimed it first; a later device with the same name keeps working by address and is told so.
|
||
- **Exit nodes:** send all your internet traffic through any member that offers one.
|
||
- **Multi-hop:** members that can't reach each other directly are routed through a member that can, over whatever kind of link each hop has. A member reached that way is online, and shown as relayed; traffic moves to a direct link by itself the moment one comes up.
|
||
- **LAN games:** UDP broadcasts are relayed, so "LAN" lobbies show up across the mesh.
|
||
- **Several networks at once** on one device.
|
||
- **Tray app** for Windows, macOS and Linux; a command line for everything.
|
||
|
||
## Install
|
||
|
||
Grab a build from the [latest release](https://github.com/house-of-vanity/tsunagi/releases/latest).
|
||
|
||
| 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` |
|
||
|
||
Linux packages run the agent as an unprivileged service user. Add yourself to
|
||
the `tsunagi` group (`sudo usermod -aG tsunagi $USER`, then log in again) to
|
||
use `tsng` and the tray without `sudo`.
|
||
|
||
## Quick start
|
||
|
||
```sh
|
||
tsng join -n home # first machine: makes the network and prints a secret
|
||
tsng join -n home -s tsn1… # every other machine
|
||
tsng status # who is online, over which path
|
||
```
|
||
|
||
Every new network gets a stable pseudorandom `/24` in `10.0.0.0/8`, derived
|
||
from its network id. Its members agree on that range even when starting offline,
|
||
and each gets a name like `<host>.home`. Existing signed ranges are preserved.
|
||
|
||
If two configured networks have overlapping ranges, the agent runs only one.
|
||
`tsng status`, `tsng network` and the tray show which network blocks the
|
||
other. `tsng network start <name-or-id>` or the tray’s “Switch to this network”
|
||
button suspends the conflicting networks. They resume automatically when the
|
||
selected network stops; the selection survives restarting the agent. Explicitly
|
||
stopping a suspended network cancels its automatic resume.
|
||
|
||
Machines are called `tsunagi-<id>` until you rename them: `tsng id hostname laptop`.
|
||
|
||
### Virtual services
|
||
|
||
```sh
|
||
tsng vip create home api
|
||
tsng vip port add home api tcp/443 --backend server1:6443 --backend server2:7443
|
||
tsng vip status home api --watch
|
||
```
|
||
|
||
Use `api.home:443`. Each client selects the available backend with the lowest
|
||
measured data-plane RTT. A VIP can have multiple independent TCP and UDP ports.
|
||
Every member may edit it; the tray's **VIP** button opens a draft editor with
|
||
explicit Save. [Commands, health, metrics and limits](docs/vip.md).
|
||
|
||
### Exit nodes
|
||
|
||
```sh
|
||
tsng network exit-node home on # on the machine that should be the exit
|
||
tsng exit-node # on another one: who offers an exit
|
||
tsng exit-node laptop # send everything through it
|
||
tsng exit-node off
|
||
```
|
||
|
||
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).
|
||
|
||
The default data transport order is `wg,awg,wg-quic,tcp-tls`. AWG negotiates a
|
||
fresh masking profile and outer keys for each pair connection attempt, including
|
||
reentry after fallback. Sustained data loss triggers switching independently of
|
||
the iroh control connection. [Configuration and limits](docs/amnezia.md).
|
||
|
||
Change a network's protocol selection without editing its service or restarting:
|
||
|
||
```sh
|
||
tsng protocols # available protocols and settings
|
||
tsng protocols home # this device's choice for home
|
||
tsng protocols home awg,wg-quic # prefer AWG, retain QUIC as fallback
|
||
tsng protocols home none # control plane only
|
||
```
|
||
|
||
Exact network names, full ids and unique id prefixes work here. The choice is
|
||
local to this device, saved per network and applied immediately; an offline
|
||
change applies on the next start. In the tray, expand **data transports** on a network
|
||
tile to toggle protocols or change their preference order. Adjacent peers need a common external transport at the same wire version.
|
||
Selection applies to this device's incoming and outgoing data links; control and
|
||
discovery are independent. A relay's next hop may use a different transport.
|
||
Every pair uses one internal WireGuard cipher, hidden from these controls.
|
||
Changing transport or moving direct → relay → direct preserves that channel,
|
||
addresses and interface. An existing TCP connection can resume after temporary
|
||
loss, subject to its own timeouts. `tsng up --protocol` supplies the
|
||
initial defaults for networks without a saved choice; it does not restrict
|
||
which built-in protocols can be enabled later.
|
||
|
||
Remove an obsolete offline device from a network's shared history:
|
||
|
||
```sh
|
||
tsng network forget-node home old-laptop
|
||
tsng network forget-node home 10.13.37.143
|
||
```
|
||
|
||
Networks accept exact names, full ids or unique id prefixes; nodes accept those
|
||
id selectors, exact hostnames or overlay IPv4 addresses. Ambiguous selectors
|
||
change nothing. Start the network first if it is stopped. In **Show devices**,
|
||
click **Forget** beside an offline node and confirm its identity.
|
||
|
||
Every member has the same cleanup right. The signed cleanup frees the observed
|
||
claim's name and IPv4 reservation, survives restarts and reaches offline replicas
|
||
when they reconnect. It does not ban the device: a newer signed claim can return.
|
||
Online nodes and the currently selected exit node cannot be forgotten.
|
||
[State and limits](docs/sync-model.md#offline-node-cleanup).
|
||
|
||
## Platforms
|
||
|
||
| Platform | Build | CLI `tsng` | Tray | Exit node: offer / use | Packages |
|
||
| ------------------------- | ------------ | :--------: | :--: | :--------------------: | ----------------------------------- |
|
||
| Linux x86_64 | release | ✔ | ✔ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball, Nix |
|
||
| Linux aarch64 | release | ✔ | –¹ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball, Nix |
|
||
| Linux armv7 | release | ✔ | –¹ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball |
|
||
| macOS, Apple silicon | release | ✔ | ✔ | ✔ / ✔² | Homebrew, tarball |
|
||
| macOS, Intel | from source | ✔³ | ✔³ | ✔ / ✔²³ | – |
|
||
| Windows x86_64 | release | ✔ | ✔ | ✔ / ✔² | installer, zip |
|
||
|
||
<sup>¹ Not in the release; builds from source (and through Nix).
|
||
² Newer and less battle-tested than on Linux.
|
||
³ Builds from source, not covered by CI.</sup>
|
||
|
||
The Linux CLI is a single static binary: it runs on any distribution.
|
||
|
||
## NixOS
|
||
|
||
The repository is a flake with a package and a module.
|
||
|
||
```nix
|
||
# flake.nix
|
||
{
|
||
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
|
||
};
|
||
}
|
||
];
|
||
};
|
||
};
|
||
}
|
||
```
|
||
|
||
Just trying it: `nix run github:house-of-vanity/tsunagi#tsunagi-cli -- --help`.
|
||
`packages.<system>.tsunagi` (with the tray) and `tsunagi-cli` are also exported,
|
||
along with `overlays.default`.
|
||
|
||
For development from a checkout, `nix develop` also exposes the tray's native
|
||
runtime libraries, including AppIndicator and graphics/windowing libraries.
|
||
Cargo-built binaries such as `target/debug/tsunagi-tray` can run directly inside
|
||
this shell, without installing the packaged wrapper.
|
||
|
||
## Build from source
|
||
|
||
Rust 1.91 or newer.
|
||
|
||
```sh
|
||
cargo build --release --bin tsng # the agent and CLI
|
||
cargo build --release --bin tsunagi-tray # the tray (Linux needs GTK 3 and libayatana-appindicator)
|
||
```
|
||
|
||
Windows needs [Wintun](https://www.wintun.net) next to `tsng.exe`.
|
||
Per-platform notes: [Linux](dist/linux/README.md), [Arch](dist/arch/README.md),
|
||
[macOS](dist/macos/README.md), [Windows](dist/windows/README.md).
|
||
|
||
## How it works
|
||
|
||
- 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.
|
||
|
||
The secret is the only key, so make it a good one (the generated ones are).
|
||
The [threat model](docs/threat-model.md) says what this does and doesn't protect against.
|
||
|
||
## Documentation
|
||
|
||
[Architecture](docs/architecture.md) ·
|
||
[Protocol](docs/protocol.md) ·
|
||
[WireGuard data plane](docs/wireguard.md) ·
|
||
[Pairwise Amnezia transport](docs/amnezia.md) ·
|
||
[Routing](docs/routing.md) ·
|
||
[Exit nodes](docs/exit-node.md) ·
|
||
[Virtual services](docs/vip.md) ·
|
||
[Threat model](docs/threat-model.md) ·
|
||
[Contributing](AGENTS.md)
|
||
|
||
## Licence
|
||
|
||
[WTFPL](LICENSE): do what the fuck you want to.
|