Rename the CLI package to tsunagi and its executable to tsng

The command line package is now `tsunagi` and builds the `tsng` executable,
which the packages, the unit, the installers and the workflows install and
run. The core library package becomes `tsunagi-core`, keeping the library name
`tsunagi`, so no code changes `use tsunagi::...`. Directories follow
(crates/tsunagi-core, crates/tsunagi). Every command shown to a person is now
`tsng ...`.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Aleksandr BogomiakovandClaude committed 2026-10-03 02:14:43 +01:00
1 parent bb38562b0a
commit 9966b50407
120 files changed
+631 -635

No files matched your search

+8 -8
View File
@@ -98,9 +98,9 @@ jobs:
# one lint to keep the musl build log clean. A real link error still
# fails the build.
export RUSTFLAGS="${RUSTFLAGS:-} -A linker_messages"
cargo zigbuild --release --locked --target "${{ matrix.target }}" --bin tsunagi
cargo zigbuild --release --locked --target "${{ matrix.target }}" --bin tsng
else
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
cargo build --release --locked --target "${{ matrix.target }}" --bin tsng
fi
# The tray GUI. On Linux it is built against glibc (an old baseline for
@@ -136,8 +136,8 @@ jobs:
stage="dist/pkg/${{ matrix.asset }}"
mkdir -p "$stage"
bin="tsunagi"
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
bin="tsng"
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsng.exe"
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
# The tray GUI alongside it, where it was built (Linux: x86_64 only).
@@ -186,7 +186,7 @@ jobs:
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
bash dist/deb/build-deb.sh \
"target/${{ matrix.target }}/release/tsunagi" \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.deb_arch }}" \
"$version" \
"tsunagi_${version}_${{ matrix.deb_arch }}.deb"
@@ -202,7 +202,7 @@ jobs:
version="${GITHUB_REF_NAME#v}"
pkgver="${version//-/_}"
bash dist/arch/build-pkg.sh \
"target/${{ matrix.target }}/release/tsunagi" \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.pkg_arch }}" \
"$version" \
"tsunagi-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst"
@@ -218,11 +218,11 @@ jobs:
pkgver="${version//-/_}"
tray="target/${{ matrix.gnu_target }}/release/tsunagi-tray"
bash dist/deb/build-deb.sh \
"target/${{ matrix.target }}/release/tsunagi" \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.deb_arch }}" "$version" \
"tsunagi-gui_${version}_${{ matrix.deb_arch }}.deb" "$tray"
bash dist/arch/build-pkg.sh \
"target/${{ matrix.target }}/release/tsunagi" \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.pkg_arch }}" "$version" \
"tsunagi-gui-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst" "$tray"
+8 -8
View File
@@ -100,9 +100,9 @@ jobs:
# one lint to keep the musl build log clean. A real link error still
# fails the build.
export RUSTFLAGS="${RUSTFLAGS:-} -A linker_messages"
cargo zigbuild --release --locked --target "${{ matrix.target }}" --bin tsunagi
cargo zigbuild --release --locked --target "${{ matrix.target }}" --bin tsng
else
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
cargo build --release --locked --target "${{ matrix.target }}" --bin tsng
fi
# The tray GUI. On Linux it is built against glibc (an old baseline for
@@ -138,8 +138,8 @@ jobs:
stage="dist/pkg/${{ matrix.asset }}"
mkdir -p "$stage"
bin="tsunagi"
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
bin="tsng"
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsng.exe"
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
# The tray GUI alongside it, where it was built (Linux: x86_64 only).
@@ -188,7 +188,7 @@ jobs:
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
bash dist/deb/build-deb.sh \
"target/${{ matrix.target }}/release/tsunagi" \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.deb_arch }}" \
"$version" \
"tsunagi_${version}_${{ matrix.deb_arch }}.deb"
@@ -204,7 +204,7 @@ jobs:
version="${GITHUB_REF_NAME#v}"
pkgver="${version//-/_}"
bash dist/arch/build-pkg.sh \
"target/${{ matrix.target }}/release/tsunagi" \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.pkg_arch }}" \
"$version" \
"tsunagi-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst"
@@ -220,11 +220,11 @@ jobs:
pkgver="${version//-/_}"
tray="target/${{ matrix.gnu_target }}/release/tsunagi-tray"
bash dist/deb/build-deb.sh \
"target/${{ matrix.target }}/release/tsunagi" \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.deb_arch }}" "$version" \
"tsunagi-gui_${version}_${{ matrix.deb_arch }}.deb" "$tray"
bash dist/arch/build-pkg.sh \
"target/${{ matrix.target }}/release/tsunagi" \
"target/${{ matrix.target }}/release/tsng" \
"${{ matrix.pkg_arch }}" "$version" \
"tsunagi-gui-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst" "$tray"
+17 -17
View File
@@ -30,7 +30,7 @@ scripts when improving the interactive workflow.
Keep these separate. Crossing them is the main thing to review for.
- **Control plane vs data plane.** The separation is **logical, not physical**.
The control protocol in `crates/tsunagi/src/proto/` knows nothing about
The control protocol in `crates/tsunagi-core/src/proto/` knows nothing about
packets, and a protocol crate knows nothing about the control protocol;
either can be replaced on its own. Both may ride on iroh — refusing to
would throw away iroh's NAT traversal and force the data plane to
@@ -70,9 +70,9 @@ Keep these separate. Crossing them is the main thing to review for.
ownership checks or add broadcast flooding to encrypted transit.
- **Plugins never learn reachability.** An `IpPlugin` is handed a `PacketLink`
per peer and moves datagrams over it. Addresses, hole punching and relays
belong to `crates/tsunagi/src/dataplane/transport/`. A plugin announcement says *who*, never
belong to `crates/tsunagi-core/src/dataplane/transport/`. A plugin announcement says *who*, never
*where*.
- **The core never parses a plugin payload.** See `crates/tsunagi/src/dataplane/mod.rs`. Only
- **The core never parses a plugin payload.** See `crates/tsunagi-core/src/dataplane/mod.rs`. Only
`crates/tsunagi-wg-quic/src/announcement.rs` interprets `wg-quic` payloads, and
only after bounding every field. A data plane failure must never stop the
control plane.
@@ -81,7 +81,7 @@ Keep these separate. Crossing them is the main thing to review for.
inbound packets are dropped unless their source is the address derived for
the peer that sent them. Never trust an address a peer announces.
- **Signed state is the only durable agreement.** A fact that must survive a
participant being away goes in `crates/tsunagi/src/state/` as a record signed by its
participant being away goes in `crates/tsunagi-core/src/state/` as a record signed by its
author, never in a session. Merging is deterministic, an older version never
rolls back a newer one, and absence from a snapshot is not deletion. Never
add a vote or a quorum: a majority is not a trust root here, and it would
@@ -115,7 +115,7 @@ Keep these separate. Crossing them is the main thing to review for.
normal path *and* the important failures. See [docs/testing.md](docs/testing.md).
Do not add tests for getters or to move a coverage number.
2. **Never change `IDENTITY_SCHEME`, the derivation labels, or the transcript
encoding** in `crates/tsunagi/src/identity/network.rs` and `crates/tsunagi/src/proto/handshake.rs` without
encoding** in `crates/tsunagi-core/src/identity/network.rs` and `crates/tsunagi-core/src/proto/handshake.rs` without
treating it as an incompatible protocol change. Bumping the crate version or
the control protocol version must not change an existing `NetworkId`.
3. **Secrets never leak.** Not into logs, not into `Debug`, not into status
@@ -126,7 +126,7 @@ Keep these separate. Crossing them is the main thing to review for.
the library; tests opt out explicitly at the top of each file.
5. **Bounds before allocation.** Frame lengths are checked against
`Limits::max_frame_len` before a buffer is allocated. Every string, list and
queue has a limit in `crates/tsunagi/src/config.rs`.
queue has a limit in `crates/tsunagi-core/src/config.rs`.
6. **Failure is contained.** A bad signature, wrong secret, malformed packet or
unknown version rejects one message or one session. It never stops another
network and never stops the agent. There is no irreversible global error
@@ -150,18 +150,18 @@ Keep these separate. Crossing them is the main thing to review for.
| path | responsibility |
|---------------------|----------------|
| `crates/tsunagi/src/identity/` | device identity; deterministic network space identity and derived keys |
| `crates/tsunagi/src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
| `crates/tsunagi/src/discovery.rs` | candidate sources; test and static backends |
| `crates/tsunagi/src/proto/` | framing, message formats, membership handshake |
| `crates/tsunagi/src/net.rs` | iroh endpoint adapter and observability snapshots |
| `crates/tsunagi/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
| `crates/tsunagi/src/state/` | signed records that outlive a session, their merge rules and address allocation |
| `crates/tsunagi/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks, broadcast host rules |
| `crates/tsunagi/src/dataplane/` | the protocol contract and the authenticated packet transport |
| `crates/tsunagi/src/dns/` | the DNS view of a network: zone, server, resolver publication |
| `crates/tsunagi-core/src/identity/` | device identity; deterministic network space identity and derived keys |
| `crates/tsunagi-core/src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
| `crates/tsunagi-core/src/discovery.rs` | candidate sources; test and static backends |
| `crates/tsunagi-core/src/proto/` | framing, message formats, membership handshake |
| `crates/tsunagi-core/src/net.rs` | iroh endpoint adapter and observability snapshots |
| `crates/tsunagi-core/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
| `crates/tsunagi-core/src/state/` | signed records that outlive a session, their merge rules and address allocation |
| `crates/tsunagi-core/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks, broadcast host rules |
| `crates/tsunagi-core/src/dataplane/` | the protocol contract and the authenticated packet transport |
| `crates/tsunagi-core/src/dns/` | the DNS view of a network: zone, server, resolver publication |
| `crates/tsunagi-wg-quic/` | the `wg-quic` protocol: its keys, its announcement, its tunnels |
| `crates/tsunagi-cli/` | the command line agent; the only place that owns a runtime, a logger and signals |
| `crates/tsunagi/` | the command line agent, built as `tsng`; the only place that owns a runtime, a logger and signals |
| `tests/` | integration tests; `tests/common/` is the shared harness |
Add abstractions only at real substitution or testing boundaries. Do not add a
Generated
+26 -26
View File
@@ -6234,6 +6234,27 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tsunagi"
version = "0.1.0"
dependencies = [
"anstream",
"anstyle",
"clap",
"iroh",
"netwatch",
"nix 0.31.3",
"simple-dns",
"tempfile",
"tokio",
"tracing",
"tracing-subscriber",
"tsunagi-core",
"tsunagi-tcp-tls",
"tsunagi-wg-quic",
"tsunagi-wireguard",
]
[[package]]
name = "tsunagi-core"
version = "0.1.0"
dependencies = [
"arc-swap",
"bytes",
@@ -6266,34 +6287,13 @@ dependencies = [
"tokio-stream",
"tracing",
"tracing-subscriber",
"tsunagi",
"tsunagi-core",
"tun",
"windows-sys 0.61.2",
"zbus",
"zeroize",
]
[[package]]
name = "tsunagi-cli"
version = "0.1.0"
dependencies = [
"anstream",
"anstyle",
"clap",
"iroh",
"netwatch",
"nix 0.31.3",
"simple-dns",
"tempfile",
"tokio",
"tracing",
"tracing-subscriber",
"tsunagi",
"tsunagi-tcp-tls",
"tsunagi-wg-quic",
"tsunagi-wireguard",
]
[[package]]
name = "tsunagi-tcp-tls"
version = "0.1.0"
@@ -6318,7 +6318,7 @@ dependencies = [
"tokio-rustls",
"tracing",
"tracing-subscriber",
"tsunagi",
"tsunagi-core",
"x25519-dalek",
"zeroize",
]
@@ -6334,7 +6334,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"tray-icon",
"tsunagi",
"tsunagi-core",
"winit",
]
@@ -6359,7 +6359,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"tsunagi",
"tsunagi-core",
"zeroize",
]
@@ -6383,7 +6383,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"tsunagi",
"tsunagi-core",
"zeroize",
]
+53 -53
View File
@@ -36,7 +36,7 @@ A working library with **real iroh connections** and integration tests:
overlay with deterministically derived addresses and optional IPv4, real
tunnels carried over iroh, and address ownership enforced rather than
believed;
- a **command line agent**, `tsunagi`, with a local control socket.
- a **command line agent**, `tsng`, with a local control socket.
### What it deliberately does **not** do
@@ -71,8 +71,8 @@ On the first machine:
```bash
cargo build --release
./target/release/tsunagi status # this device, the agent, and this host
./target/release/tsunagi network # the networks this device belongs to
./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
@@ -81,19 +81,19 @@ 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/tsunagi up
./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/tsunagi join --network lab
./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/tsunagi up
./target/release/tsunagi join --network lab --secret tsn1...
./target/release/tsng up
./target/release/tsng join --network lab --secret tsn1...
```
**Name and secret are enough for the first meeting.** Mainline DHT discovery
@@ -141,7 +141,7 @@ wg-quic: tsun0 on 10.13.37.69/24 mtu 1280, 1/1 tunnel(s) established
From another shell on either machine:
```bash
tsunagi status
tsng status
```
```text
@@ -170,8 +170,8 @@ ping 100.65.243.53
what they find, so `--ipv4-range` only matters for whoever starts the network:
```bash
tsunagi up --ipv4-range 10.44.0.0/16
tsunagi up --ipv4-range none # no data plane at all
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
@@ -193,7 +193,7 @@ assigns it to the interface itself.
## Levels
The command line is split the way the design is. A bare `tsunagi up` needs
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:
@@ -204,7 +204,7 @@ belongs to, which `--help` shows as two sections:
their own settings (`-o key=value`, or `-o protocol:key=value`).
```bash
tsunagi protocols # what this build can carry packets with
tsng protocols # what this build can carry packets with
```
```text
@@ -223,7 +223,7 @@ A pair of peers uses a protocol they both have **at the same wire version**.
That is not the software version: two peers on different builds carry traffic
for each other for as long as the bytes between them have not changed. A peer
with nothing in common keeps its control plane — messages and signed state
still flow — and simply has no data plane, which `tsunagi status` shows as a
still flow — and simply has no data plane, which `tsng status` shows as a
session with no agreed protocol.
## Names
@@ -233,12 +233,12 @@ each named after the network, so members can be reached by name instead of
by address:
```bash
tsunagi up # DNS is already enabled
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 `tsunagi dns off`
disables it; `--dns` or `tsunagi dns on` enables it again. Existing saved opt-outs
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.
@@ -299,7 +299,7 @@ polkit.addRule(function(action, subject) {
RULE
```
**Without it the server still runs** — `tsunagi status` prints where it is
**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.
@@ -318,7 +318,7 @@ remote peer could influence.
That needs `CAP_NET_ADMIN`, granted once:
```bash
sudo setcap cap_net_admin+p /usr/local/bin/tsunagi
sudo setcap cap_net_admin+p /usr/local/bin/tsng
```
`+p` rather than `+ep`: the capability is then *permitted* but not
@@ -328,7 +328,7 @@ 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.
`tsunagi status` says which of these applies on the host it runs on, along
`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
@@ -353,28 +353,28 @@ Every item takes the same shape, so there is nothing to remember: name it to
see it, name it with a value to change it.
```
tsunagi id everything about this device
tsunagi id hostname the name it answers to
tsunagi id hostname mango change it
tsunagi id key the key it signs with
tsunagi id key rotate replace that key
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
tsunagi network the networks this device belongs to
tsunagi network join -n lab -s tsn1… join one; adds it to a running agent
tsunagi network join -n lab resume one this device has, or make it on the spot
tsunagi network stop lab stop serving it, keeping everything
tsunagi network start lab serve it again, from where it left off
tsunagi network leave lab give up the address and name, then forget it
tsunagi network secret the secret of each joined network
tsunagi network secret lab just that one, for copying
tsunagi network secret generate a fresh secret for a network that does not exist yet
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
tsunagi dns whether the local resolver is serving, and what
tsunagi dns on start it, now and after every restart
tsunagi dns off stop it, now and after every restart
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.** `tsunagi join --network lab`
**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
@@ -386,11 +386,11 @@ joined `lab` (k2on43wadbi5x267vp6z3ogkm7nbjedfdoxyauxhtxhwqrprylba)
Run this on the other machine:
tsunagi join --network lab --secret tsn1u7c…
tsng join --network lab --secret tsn1u7c…
Its agent has to be running. If it is not:
tsunagi up --peer 91e83a6e2b7a…
tsng up --peer 91e83a6e2b7a…
```
Two networks of one name is a thing that happens — a mistyped secret makes
@@ -410,11 +410,11 @@ Asking for it is deliberate, because these reports get pasted into chats.
**`up` takes no network at all** if you would rather decide later: it
brings up the agent and whatever it is already configured for, and waits.
That is the shape of a daemon in one terminal and `tsunagi network join`
That is the shape of a daemon in one terminal and `tsng network join`
in another.
`network join` is also the answer to a question `up` cannot: a state
directory belongs to one live agent, so a second `tsunagi up` cannot add a
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`.
@@ -477,11 +477,11 @@ The original IP packet, including its DF bit, is preserved. See
|---|---|---|
| `setcap cap_net_admin+p` | ordinary user, one capability | recommended: nothing to prepare, nothing left behind. Lost on every rebuild or copy of the binary. |
| systemd service | `User=`, `AmbientCapabilities=CAP_NET_ADMIN` | the same, for an installed service |
| `sudo tsunagi up` | root | everything works, nothing is isolated |
| `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 `tsunagi up`
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
@@ -498,7 +498,7 @@ grant like Linux, and a `utun` plus `/etc/resolver` are root-only. The kernel
assigns the interface its `utunN` name, so the name derived from the network id
is only a request and the real one is adopted once the interface is up.
Run it as root — `sudo tsunagi up` for a quick start, or install it as a root
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`
@@ -613,10 +613,10 @@ paths. It supports `255.255.255.255` and the overlay subnet's broadcast address;
received broadcasts never trigger another fanout.
```sh
tsunagi join -n games --no-broadcast
tsunagi join -n games --broadcast
tsunagi network broadcast games off
tsunagi network broadcast games on
tsng join -n games --no-broadcast
tsng join -n games --broadcast
tsng network broadcast games off
tsng network broadcast games on
```
The choice persists across restarts and a plain `join`. It can be changed while
@@ -681,8 +681,8 @@ 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
— `tsunagi network join` adds them to the agent that is already running,
which is why a second `tsunagi up` on the same directory is refused rather
— `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
@@ -693,9 +693,9 @@ several of them run side by side in one process as readily as in one host.
Membership outlives a session, so it also has to be possible to end it.
```bash
tsunagi network # what this device belongs to
tsunagi network leave lab # give up the address and the name, then forget it
tsunagi wipe --yes # remove everything and be a stranger again
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
-47
View File
@@ -1,47 +0,0 @@
[package]
name = "tsunagi-cli"
version = "0.1.0"
description = "The tsunagi command line agent."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[[bin]]
name = "tsunagi"
path = "src/main.rs"
# The binary and the library share a name, which `cargo doc` cannot map to
# two directories. The command line is documented by `--help`, not rustdoc,
# so the library keeps the name and this opts out.
doc = false
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0", features = ["tun-device", "dns-publish"] }
tsunagi-wireguard = { path = "../tsunagi-wireguard", version = "0.1.0" }
tsunagi-wg-quic = { path = "../tsunagi-wg-quic", version = "0.1.0" }
tsunagi-tcp-tls = { path = "../tsunagi-tcp-tls", version = "0.1.0" }
iroh.workspace = true
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "signal"] }
tracing.workspace = true
tracing-subscriber.workspace = true
clap = { version = "4.5", features = ["derive", "env"] }
# Already in the tree through clap. `anstream` strips the escapes when stdout
# is not a terminal and turns on virtual terminal processing on Windows, so
# colour is never written where it would show up as rubbish.
anstream = "1.0"
anstyle = "1.0"
# Local interface addresses, for the diagnostics in `status`.
netwatch = "0.19.3"
# Resolving a group name to its gid for `up --control-group`, through a safe
# wrapper (no unsafe of our own). Unix only.
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", features = ["user"] }
[dev-dependencies]
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
tempfile.workspace = true
simple-dns = "0.12"
[lints]
workspace = true
-56
View File
@@ -1,56 +0,0 @@
//! What the command line says about this device, against a running agent.
//!
//! These drive the real binary, which is why they live beside it rather
//! than with the library's own tests: the library cannot depend on a binary
//! built from a crate that depends on it.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tempfile::TempDir;
use tsunagi::Agent;
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
/// Asking who this device is must not need the directory lock.
///
/// The lock belongs to the one agent allowed to *write* the state. `id` only
/// reads, so making it take the lock would mean the question could never be
/// answered while an agent was running — which is exactly when you want to
/// ask it.
#[tokio::test]
async fn identity_can_be_read_while_an_agent_holds_the_directory() {
let dir = TempDir::new().unwrap();
// Hold the directory the way a running agent does.
let agent = Agent::spawn(
AgentConfig::new(StoragePaths::under(dir.path()))
.with_transport(TransportPolicy::LocalOnly)
.with_loopback_bind(),
)
.await
.unwrap();
let expected = agent.endpoint_id().to_string();
let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.arg("id")
// The same layout `StoragePaths::under` gives the agent above.
.arg("--state-dir")
.arg(dir.path().join("state"))
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.output()
.await
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"`id` failed while an agent was running:\n{stderr}"
);
assert!(
stdout.contains(&expected),
"expected {expected} in:\n{stdout}"
);
agent.shutdown().await;
}
+113
View File
@@ -0,0 +1,113 @@
[package]
name = "tsunagi-core"
version = "0.1.0"
description = "Small private mesh networks: persistent agent identity, deterministic network spaces, an iroh control plane, signed state and a local DNS view."
readme = "../../README.md"
keywords = ["mesh", "p2p", "iroh", "networking"]
categories = ["network-programming"]
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[lib]
name = "tsunagi"
[features]
default = []
# A real TUN device, so a plugin can carry actual IP traffic. On Linux it
# needs CAP_NET_ADMIN; on Windows the process must run elevated and
# `wintun.dll` must be reachable. Without either the in-memory device serves
# tests. The platform-specific dependencies are declared per target, so
# enabling this only pulls what the target it is built for actually uses.
tun-device = [
"dep:tun",
"dep:rtnetlink",
"dep:caps",
"dep:futures-util",
]
# Telling the operating system where to send its DNS questions. systemd-resolved
# on Linux, the Name Resolution Policy Table on Windows; the zone and the
# server work without it either way.
dns-publish = ["dep:zbus"]
# The test harness in `testing`, for this crate's own tests and for a
# protocol crate's. Not on by default: it is only of use to a test.
testing = ["dep:tempfile", "dep:tracing-subscriber"]
[dependencies]
arc-swap = "1.9"
iroh.workspace = true
# `net` for the local control interface: a Unix socket on Unix, a named pipe
# on Windows. Both live behind the same `net` feature.
tokio = { workspace = true, features = ["net"] }
tokio-stream = "0.1"
serde.workspace = true
postcard.workspace = true
bytes.workspace = true
thiserror.workspace = true
tracing.workspace = true
hex.workspace = true
data-encoding.workspace = true
rusqlite = { version = "0.40", features = ["bundled"] }
hkdf = "0.13"
hmac = "0.13"
sha2 = "0.11"
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
rand = "0.10"
# Already in the tree through iroh. A packet codec, not a DNS server: the
# zone logic is ours and a full server framework would be a large dependency
# for answering A records from memory.
simple-dns = "0.12"
# The IANA top-level domain list, compiled in: one function, no
# dependencies, no network. Used only to warn that a zone name shadows a
# real public domain, never to refuse one.
tld = "2.40"
fs4 = { version = "1.1", features = ["sync"] }
directories = "6.0"
# The real system hostname, without a libc call of our own: this crate is
# forbidden `unsafe` and will not make one.
gethostname = "1.1"
netwatch = "0.19.3"
netdev = "0.46"
mainline = "8.0"
futures-lite = "2.6"
tun = { version = "0.8", features = ["async"], optional = true }
# Only for the `testing` harness, which a protocol crate's tests use too.
tempfile = { version = "3.24", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
# Linux-only interface provisioning. `rtnetlink` configures the interface in
# process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN
# out of the effective set except during the moments it is used.
[target.'cfg(target_os = "linux")'.dependencies]
rtnetlink = { version = "0.23", optional = true }
# Pure Rust D-Bus, no libdbus to link against. `tokio` rather than the
# default reactor, because the agent brings its own.
zbus = { version = "5.19", default-features = false, features = ["tokio"], optional = true }
caps = { version = "0.5", optional = true }
futures-util = { version = "0.3", default-features = false, optional = true }
# Windows-only interface provisioning. The Wintun adapter itself is created
# and driven through the `tun` dependency, whose safe wrapper is the only way
# in — loading `wintun.dll` is an `unsafe` call this crate forbids. `netdev`
# (already in the tree through `netwatch`) reads the interface table through a
# safe wrapper, which is how the provisioner observes an adapter by name
# before deciding what to change. Creating an adapter needs the process to be
# elevated and `wintun.dll` to be reachable at run time.
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Foundation",
] }
[dev-dependencies]
# Its own tests use the harness it publishes.
tsunagi-core = { path = ".", features = ["testing"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process", "test-util"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
[lints]
workspace = true
File renamed without changes.
@@ -582,7 +582,7 @@ impl Agent {
"`{name}` is already configured with a different secret, as {}. \
Joining with this one adds a second network under the same name; \
they share nothing. Check the secret, or use \
`tsunagi network secret` \
`tsng network secret` \
to see which is which.",
other.network_id
);
@@ -1218,7 +1218,7 @@ impl Runtime {
network = %self.network_id,
exit_node = %via.fmt_short(),
"the exit node is gone or no longer offers to be one; internet traffic stays \
routed through it and is dropped until it returns or `tsunagi exit-node off`"
routed through it and is dropped until it returns or `tsng exit-node off`"
);
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -214,7 +214,7 @@ pub enum Request {
///
/// Asked of the running agent because that is the only way to add a
/// network to an agent that is already up: the state directory belongs
/// to one live agent, so a second `tsunagi up` cannot.
/// to one live agent, so a second `tsng up` cannot.
Join {
/// The network name.
name: String,
@@ -764,7 +764,7 @@ where
///
/// A local answer comes from memory, so anything this slow means the agent is
/// wedged rather than busy. Saying so beats waiting: unbounded, one wedged
/// runtime leaves `tsunagi status` hanging with nothing on screen and no way
/// runtime leaves `tsng status` hanging with nothing on screen and no way
/// out but Ctrl-C.
pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
File renamed without changes.
@@ -210,7 +210,7 @@ impl Drop for ControlSocket {
/// Accepts one client at a time, keeping a fresh instance ready for the next.
///
/// A named pipe server instance serves a single client, so a new instance is
/// created as soon as one is taken — otherwise a second `tsunagi status` while
/// created as soon as one is taken — otherwise a second `tsng status` while
/// the first is mid-flight would find nothing listening.
async fn serve(
name: String,
@@ -294,7 +294,7 @@ pub(crate) async fn connect(path: &Path) -> Result<NamedPipeClient> {
format!(
"the agent's control pipe exists, but Windows denied access. \
Run this command as the same Windows user and with the same \
elevation as `tsunagi up` (use an administrator terminal if \
elevation as `tsng up` (use an administrator terminal if \
the agent is elevated). Windows error: {err}"
),
),
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -90,7 +90,7 @@ fn classify(what: &str, text: &str) -> String {
|| lower.contains("run as administrator")
|| lower.contains("permissiondenied")
{
format!("{what}: Windows refused; run tsunagi from an elevated (administrator) process")
format!("{what}: Windows refused; run tsng from an elevated (administrator) process")
} else if lower.contains("is not recognized") || lower.contains("commandnotfound") {
format!("{what}: the NetTCPIP/NetSecurity PowerShell modules are not available: {text}")
} else if text.is_empty() {
File renamed without changes.
@@ -15,7 +15,7 @@
//! Grant it with:
//!
//! ```text
//! sudo setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsunagi
//! sudo setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsng
//! ```
//!
//! `+p` rather than `+ep`: with `+p` the capability is permitted but not
@@ -171,7 +171,7 @@ mod linux_impl {
caps::raise(None, CapSet::Effective, Capability::CAP_NET_ADMIN).map_err(|err| {
OverlayError::Unavailable(format!(
"cannot raise CAP_NET_ADMIN: {err}. {}",
Privilege::how_to_grant("tsunagi")
Privilege::how_to_grant("tsng")
))
})?;
Ok(Self { raised: true })
@@ -204,10 +204,8 @@ mod tests {
#[test]
fn the_grant_instructions_name_the_program() {
let text = Privilege::how_to_grant("/usr/local/bin/tsunagi");
assert!(
text.contains("setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsunagi")
);
let text = Privilege::how_to_grant("/usr/local/bin/tsng");
assert!(text.contains("setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsng"));
assert!(text.contains("--no-tun"), "the fallback is offered too");
}
@@ -483,15 +483,15 @@ mod system {
if denied {
let hint = if cfg!(target_os = "windows") {
"Open PowerShell or Command Prompt with 'Run as administrator' and \
start `tsunagi up` there."
start `tsng up` there."
} else if cfg!(target_os = "linux") {
"Start `tsunagi up` as root or grant CAP_NET_ADMIN with \
"Start `tsng up` as root or grant CAP_NET_ADMIN with \
`sudo setcap cap_net_admin,cap_net_bind_service+p /path/to/tsunagi`."
} else if cfg!(target_os = "macos") {
"Creating a utun interface needs root on macOS; start `tsunagi up` \
"Creating a utun interface needs root on macOS; start `tsng up` \
with `sudo`, or install it as a root LaunchDaemon."
} else {
"Start `tsunagi up` with the privileges required to create a TUN interface."
"Start `tsng up` with the privileges required to create a TUN interface."
};
return OverlayError::Unavailable(format!(
"permission denied while creating the TUN interface `{name}`. \
@@ -539,7 +539,7 @@ mod system {
let message = open_error("tsun0", error).to_string();
assert!(message.contains("permission denied"), "{message}");
assert!(message.contains("tsun0"), "{message}");
assert!(message.contains("tsunagi up"), "{message}");
assert!(message.contains("tsng up"), "{message}");
#[cfg(target_os = "windows")]
assert!(message.contains("Run as administrator"), "{message}");
#[cfg(target_os = "linux")]
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+173
View File
@@ -0,0 +1,173 @@
//! Scenario 1: deterministic network identity.
//!
//! The same name and secret must yield the same network space on different
//! devices, and nothing else — hostname, device key, restart — may change it.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tsunagi::Agent;
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
use tsunagi::testing::{TestAgent, config_with, network};
#[test]
fn derivation_is_a_pure_function_of_name_and_secret() {
let name = NetworkName::new("home").unwrap();
let other = NetworkName::new("Home").unwrap();
let secret = NetworkSecret::generate();
let other_secret = NetworkSecret::generate();
let a = NetworkKeys::derive(&name, &secret);
let b = NetworkKeys::derive(&name, &secret);
assert_eq!(a.network_id(), b.network_id());
assert_eq!(a.discovery_key(), b.discovery_key());
assert_eq!(a.descriptor(), b.descriptor());
// A different name is a different space. Names are used verbatim, so case
// matters.
assert_ne!(
a.network_id(),
NetworkKeys::derive(&other, &secret).network_id()
);
// A different secret is a different space.
assert_ne!(
a.network_id(),
NetworkKeys::derive(&name, &other_secret).network_id()
);
// Separated key material: the discovery key is not the network id.
assert_ne!(a.network_id().as_bytes(), a.discovery_key().as_bytes());
}
#[test]
fn descriptor_carries_no_creator_time_or_secret() {
let name = NetworkName::new("shared").unwrap();
let secret = NetworkSecret::generate();
let first = NetworkKeys::derive(&name, &secret).descriptor();
let second = NetworkKeys::derive(&name, &secret).descriptor();
// Two independently built descriptors are byte identical: no random
// creator id, no creation timestamp, no owner signature.
assert_eq!(first.to_canonical_bytes(), second.to_canonical_bytes());
let encoded = first.to_canonical_bytes();
let secret_bytes = secret.encode();
assert!(
!encoded
.windows(secret_bytes.len())
.any(|window| window == secret_bytes.as_bytes()),
"the secret must never appear in the public descriptor"
);
}
#[test]
fn names_are_validated_not_silently_normalised() {
assert!(NetworkName::new("").is_err());
assert!(NetworkName::new(" home").is_err(), "must not be trimmed");
assert!(NetworkName::new("home ").is_err(), "must not be trimmed");
assert!(NetworkName::new("ho\nme").is_err());
assert!(NetworkName::new("a".repeat(65)).is_err());
assert_eq!(NetworkName::new("home").unwrap().as_str(), "home");
}
#[test]
fn secrets_are_not_truncated_or_normalised() {
let secret = NetworkSecret::generate();
let text = secret.encode();
let round_tripped = NetworkSecret::decode(&text).unwrap();
assert_eq!(secret, round_tripped);
// Short secrets are rejected rather than stretched.
assert!(NetworkSecret::from_bytes(vec![7u8; 15]).is_err());
assert!(NetworkSecret::from_bytes(vec![7u8; 16]).is_ok());
// Debug output must not leak the secret.
let rendered = format!("{secret:?}");
assert_eq!(rendered, "NetworkSecret(<redacted>)");
}
#[tokio::test]
async fn different_devices_and_hostnames_agree_on_the_network_id() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("agreement");
let a = TestAgent::spawn_with(|cfg| cfg.with_hostname("alpha"), &discovery)
.await
.unwrap();
let b = TestAgent::spawn_with(|cfg| cfg.with_hostname("beta"), &discovery)
.await
.unwrap();
assert_ne!(
a.agent.endpoint_id(),
b.agent.endpoint_id(),
"different devices must have different endpoint ids"
);
assert_eq!(a.agent.hostname(), "alpha");
assert_eq!(b.agent.hostname(), "beta");
let id_a = a.agent.join_network(&name, &secret).await.unwrap();
let id_b = b.agent.join_network(&name, &secret).await.unwrap();
assert_eq!(id_a, id_b);
a.agent.shutdown().await;
b.agent.shutdown().await;
}
#[tokio::test]
async fn restart_keeps_the_device_and_network_identity() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("stable");
let first = TestAgent::spawn(&discovery).await.unwrap();
let device_id = first.agent.endpoint_id();
let network_id = first.agent.join_network(&name, &secret).await.unwrap();
let dir = first.stop().await;
let reopened = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
assert_eq!(
reopened.endpoint_id(),
device_id,
"restarting must not mint a new peer"
);
let networks = reopened.list_networks().await.unwrap();
assert_eq!(networks.len(), 1);
assert_eq!(networks[0].network_id, network_id);
assert!(networks[0].active, "auto-start networks come back up");
reopened.shutdown().await;
drop(reopened);
drop(dir);
}
#[tokio::test]
async fn changing_the_secret_keeps_the_device_identity() {
let discovery = SharedMemoryDiscovery::new();
let dir = tempfile::TempDir::new().unwrap();
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
let device_id = agent.endpoint_id();
let name = NetworkName::new("rotating").unwrap();
let old = agent
.join_network(&name, &NetworkSecret::generate())
.await
.unwrap();
let new = agent
.join_network(&name, &NetworkSecret::generate())
.await
.unwrap();
assert_ne!(old, new, "a new secret is a new network space");
assert_eq!(
agent.endpoint_id(),
device_id,
"rotating the network secret must not change the persistent iroh id"
);
agent.shutdown().await;
drop(agent);
drop(dir);
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2 -2
View File
@@ -8,7 +8,7 @@ license.workspace = true
repository.workspace = true
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0" }
tsunagi-core = { path = "../tsunagi-core", version = "0.1.0" }
iroh.workspace = true
tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macros"] }
rustls = "0.23"
@@ -33,7 +33,7 @@ netdev = "0.46"
caps = "0.5"
[dev-dependencies]
tsunagi = { path = "../tsunagi", features = ["testing"] }
tsunagi-core = { path = "../tsunagi-core", features = ["testing"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
+1 -1
View File
@@ -15,7 +15,7 @@ path = "src/main.rs"
# The agent control client and the report types: the GUI is a thin client over
# the same local control interface the CLI uses, so it reuses these directly
# and never re-implements the protocol.
tsunagi = { path = "../tsunagi", version = "0.1.0" }
tsunagi-core = { path = "../tsunagi-core", version = "0.1.0" }
# Native, lightweight GUI — no web engine. eframe re-exports egui.
eframe = { version = "0.31", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
# Cross-platform tray icon + menu (Windows tray, macOS menu bar, Linux SNI).
+2 -2
View File
@@ -11,7 +11,7 @@ license.workspace = true
repository.workspace = true
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0" }
tsunagi-core = { path = "../tsunagi-core", version = "0.1.0" }
iroh.workspace = true
tokio.workspace = true
serde.workspace = true
@@ -30,7 +30,7 @@ rand = "0.10"
boringtun = { version = "0.7.1", default-features = false }
[dev-dependencies]
tsunagi = { path = "../tsunagi", features = ["testing"] }
tsunagi-core = { path = "../tsunagi-core", features = ["testing"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
@@ -1,7 +1,7 @@
//! The local control interface: a client asking a running agent for status.
//!
//! Uses a real Unix socket on a temporary path, the real agent and the real
//! WireGuard data plane, so what a `tsunagi status` client would see is what
//! WireGuard data plane, so what a `tsng status` client would see is what
//! is checked here.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+2 -2
View File
@@ -8,7 +8,7 @@ license.workspace = true
repository.workspace = true
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0" }
tsunagi-core = { path = "../tsunagi-core", version = "0.1.0" }
iroh.workspace = true
tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macros"] }
serde.workspace = true
@@ -26,7 +26,7 @@ boringtun = { version = "0.7.1", default-features = false }
netdev = "0.46"
[dev-dependencies]
tsunagi = { path = "../tsunagi", features = ["testing"] }
tsunagi-core = { path = "../tsunagi-core", features = ["testing"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
+23 -90
View File
@@ -1,110 +1,43 @@
[package]
name = "tsunagi"
version = "0.1.0"
description = "Small private mesh networks: persistent agent identity, deterministic network spaces, an iroh control plane, signed state and a local DNS view."
readme = "../../README.md"
keywords = ["mesh", "p2p", "iroh", "networking"]
categories = ["network-programming"]
description = "The tsunagi command line agent."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[features]
default = []
# A real TUN device, so a plugin can carry actual IP traffic. On Linux it
# needs CAP_NET_ADMIN; on Windows the process must run elevated and
# `wintun.dll` must be reachable. Without either the in-memory device serves
# tests. The platform-specific dependencies are declared per target, so
# enabling this only pulls what the target it is built for actually uses.
tun-device = [
"dep:tun",
"dep:rtnetlink",
"dep:caps",
"dep:futures-util",
]
# Telling the operating system where to send its DNS questions. systemd-resolved
# on Linux, the Name Resolution Policy Table on Windows; the zone and the
# server work without it either way.
dns-publish = ["dep:zbus"]
# The test harness in `testing`, for this crate's own tests and for a
# protocol crate's. Not on by default: it is only of use to a test.
testing = ["dep:tempfile", "dep:tracing-subscriber"]
[[bin]]
name = "tsng"
path = "src/main.rs"
[dependencies]
arc-swap = "1.9"
tsunagi-core = { path = "../tsunagi-core", version = "0.1.0", features = ["tun-device", "dns-publish"] }
tsunagi-wireguard = { path = "../tsunagi-wireguard", version = "0.1.0" }
tsunagi-wg-quic = { path = "../tsunagi-wg-quic", version = "0.1.0" }
tsunagi-tcp-tls = { path = "../tsunagi-tcp-tls", version = "0.1.0" }
iroh.workspace = true
# `net` for the local control interface: a Unix socket on Unix, a named pipe
# on Windows. Both live behind the same `net` feature.
tokio = { workspace = true, features = ["net"] }
tokio-stream = "0.1"
serde.workspace = true
postcard.workspace = true
bytes.workspace = true
thiserror.workspace = true
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "signal"] }
tracing.workspace = true
hex.workspace = true
data-encoding.workspace = true
rusqlite = { version = "0.40", features = ["bundled"] }
hkdf = "0.13"
hmac = "0.13"
sha2 = "0.11"
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
rand = "0.10"
# Already in the tree through iroh. A packet codec, not a DNS server: the
# zone logic is ours and a full server framework would be a large dependency
# for answering A records from memory.
simple-dns = "0.12"
# The IANA top-level domain list, compiled in: one function, no
# dependencies, no network. Used only to warn that a zone name shadows a
# real public domain, never to refuse one.
tld = "2.40"
fs4 = { version = "1.1", features = ["sync"] }
directories = "6.0"
# The real system hostname, without a libc call of our own: this crate is
# forbidden `unsafe` and will not make one.
gethostname = "1.1"
tracing-subscriber.workspace = true
clap = { version = "4.5", features = ["derive", "env"] }
# Already in the tree through clap. `anstream` strips the escapes when stdout
# is not a terminal and turns on virtual terminal processing on Windows, so
# colour is never written where it would show up as rubbish.
anstream = "1.0"
anstyle = "1.0"
# Local interface addresses, for the diagnostics in `status`.
netwatch = "0.19.3"
netdev = "0.46"
mainline = "8.0"
futures-lite = "2.6"
tun = { version = "0.8", features = ["async"], optional = true }
# Only for the `testing` harness, which a protocol crate's tests use too.
tempfile = { version = "3.24", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
# Linux-only interface provisioning. `rtnetlink` configures the interface in
# process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN
# out of the effective set except during the moments it is used.
[target.'cfg(target_os = "linux")'.dependencies]
rtnetlink = { version = "0.23", optional = true }
# Pure Rust D-Bus, no libdbus to link against. `tokio` rather than the
# default reactor, because the agent brings its own.
zbus = { version = "5.19", default-features = false, features = ["tokio"], optional = true }
caps = { version = "0.5", optional = true }
futures-util = { version = "0.3", default-features = false, optional = true }
# Windows-only interface provisioning. The Wintun adapter itself is created
# and driven through the `tun` dependency, whose safe wrapper is the only way
# in — loading `wintun.dll` is an `unsafe` call this crate forbids. `netdev`
# (already in the tree through `netwatch`) reads the interface table through a
# safe wrapper, which is how the provisioner observes an adapter by name
# before deciding what to change. Creating an adapter needs the process to be
# elevated and `wintun.dll` to be reachable at run time.
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Foundation",
] }
# Resolving a group name to its gid for `up --control-group`, through a safe
# wrapper (no unsafe of our own). Unix only.
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", features = ["user"] }
[dev-dependencies]
# Its own tests use the harness it publishes.
tsunagi = { path = ".", features = ["testing"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process", "test-util"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
simple-dns = "0.12"
[lints]
workspace = true
@@ -1,4 +1,4 @@
//! The `tsunagi` command line agent.
//! The `tsng` command line agent.
//!
//! This binary owns everything the library deliberately refuses to do: it
//! starts the tokio runtime, installs a logging subscriber and handles
@@ -34,7 +34,7 @@ use tsunagi_wireguard::{
/// A small agent for private mesh networks.
#[derive(Debug, Parser)]
#[command(name = "tsunagi", version, about, long_about = None)]
#[command(name = "tsng", version, about, long_about = None)]
struct Cli {
/// Log filter, for example `info` or `tsunagi=debug`.
///
@@ -69,7 +69,7 @@ enum Command {
Protocols,
/// Makes a network, or joins one — the command that does the work.
///
/// The same as `tsunagi network join`, at the top level because it is
/// The same as `tsng network join`, at the top level because it is
/// what gets typed: `up` runs the agent, this decides what it is in.
Join(JoinArgs),
/// Shows the networks this device belongs to, and leaves them.
@@ -79,7 +79,7 @@ enum Command {
/// Lists devices offering to be an exit node, and sends all this
/// device's internet traffic through one.
///
/// The other half of `tsunagi network exit-node`, which is what makes a
/// The other half of `tsng network exit-node`, which is what makes a
/// device offer itself. A device uses one exit node at a time.
ExitNode(ExitNodeArgs),
/// Removes everything this device has stored and starts over.
@@ -205,8 +205,8 @@ enum NetworkAction {
///
/// The state directory belongs to one live agent, so this is how a
/// network is added to it, and it takes effect at once. With no agent
/// running it is configured and starts with the next `tsunagi up`.
/// `tsunagi join` is the same command, spelled shorter.
/// running it is configured and starts with the next `tsng up`.
/// `tsng join` is the same command, spelled shorter.
Join(JoinArgs),
/// Stops serving a network, keeping everything so it can be resumed.
///
@@ -214,7 +214,7 @@ enum NetworkAction {
/// signed state all stay. Sessions close and the address comes off the
/// interface, and nothing is announced — to the others this device is
/// simply away, as if it had been switched off. It stays stopped
/// across restarts until `tsunagi network start`.
/// across restarts until `tsng network start`.
Stop {
/// Exact network name, full id or unique id prefix.
network: String,
@@ -471,7 +471,7 @@ struct UpArgs {
/// A protocol setting, as `key=value` or `protocol:key=value`.
///
/// Repeat for several. `tsunagi protocols` lists what each one takes.
/// Repeat for several. `tsng protocols` lists what each one takes.
#[arg(
short = 'o',
long = "protocol-option",
@@ -498,13 +498,13 @@ struct UpArgs {
/// addresses; questions are taken on loopback of both families, over
/// UDP and TCP.
///
/// Overrides a remembered opt-out. Use --no-dns or `tsunagi dns off` to disable.
/// Overrides a remembered opt-out. Use --no-dns or `tsng dns off` to disable.
#[arg(long, conflicts_with = "no_dns", help_heading = "System")]
dns: bool,
/// Disable local DNS and remember this choice for future starts.
///
/// Use --dns or `tsunagi dns on` to enable it again.
/// Use --dns or `tsng dns on` to enable it again.
#[arg(long, conflicts_with = "dns", help_heading = "System")]
no_dns: bool,
@@ -715,7 +715,7 @@ fn resolve_secret(
[one] => Ok((one.secret.clone(), SecretOrigin::Stored)),
several => Err(format!(
"this device is in {} networks called `{name}`, so the name alone does not say \
which. Give --secret, or `tsunagi network` lists them with their ids.",
which. Give --secret, or `tsng network` lists them with their ids.",
several.len()
)
.into()),
@@ -841,7 +841,7 @@ fn control_socket(
return tsunagi::ipc::default_control_socket_path();
}
// When the user did not choose a state directory, prefer a running system
// service's socket, so `sudo tsunagi status` reaches the packaged daemon —
// service's socket, so `sudo tsng status` reaches the packaged daemon —
// which binds there via TSUNAGI_CONTROL_SOCKET — with no flags. Only when it
// is actually present, so a per-user agent still uses its own socket.
#[cfg(all(unix, not(target_os = "macos")))]
@@ -979,7 +979,7 @@ fn device_section(paths: &StoragePaths, observed: &Observed) -> report::Section
/// The networks this device belongs to, named but not described.
///
/// No secrets: this is part of `status`, and a status report is somewhere a
/// secret must never appear. `tsunagi network secret` is the place that shows one,
/// secret must never appear. `tsng network secret` is the place that shows one,
/// because asking for it there is deliberate.
fn configured_networks_section(paths: &StoragePaths) -> report::Section {
use report::{Health, Row, Section};
@@ -1299,7 +1299,7 @@ fn update(state: &Arc<std::sync::Mutex<DnsState>>, edit: impl FnOnce(&mut DnsSta
/// What a protocol is called, what it speaks, and what it takes.
///
/// A registry rather than a lookup on the plugins themselves, because
/// `tsunagi protocols` has to answer before anything is constructed, and
/// `tsng protocols` has to answer before anything is constructed, and
/// because this is the list `--protocol` resolves against.
struct ProtocolSpec {
/// The name on the wire, which is what peers compare.
@@ -1411,7 +1411,7 @@ fn show_protocols() -> Result<(), Box<dyn std::error::Error>> {
}
out.push(section);
}
print_report("tsunagi protocols", &out)
print_report("tsng protocols", &out)
}
/// Serves the local control socket from the running agent.
@@ -1734,7 +1734,7 @@ fn resolve_network<'a>(
) -> Result<&'a tsunagi::storage::StoredNetwork, String> {
let wanted = wanted.trim_matches(|c: char| c.is_ascii_whitespace());
if wanted.is_empty() {
return Err("select a network by name or id; `tsunagi network` lists them".to_string());
return Err("select a network by name or id; `tsng network` lists them".to_string());
}
if let Some(network) = networks
.iter()
@@ -1755,7 +1755,7 @@ fn resolve_network<'a>(
[one] => Ok(one),
[] => Err(format!(
"no configured network matches name or id `{wanted}`; \
`tsunagi network` lists them"
`tsng network` lists them"
)),
several => {
let candidates = several
@@ -1771,7 +1771,7 @@ fn resolve_network<'a>(
}
}
/// `tsunagi network`: what this device belongs to, and leaving it.
/// `tsng network`: what this device belongs to, and leaving it.
async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
@@ -1810,7 +1810,7 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Er
}
}
/// `tsunagi join`: make a network or join one.
/// `tsng join`: make a network or join one.
async fn join_command(args: JoinArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
@@ -1883,7 +1883,7 @@ async fn join_network(
eprintln!(
"\nwarning: `{}` is also configured with a different secret, as {}.\n\
A network is its name *and* its secret, so these two share nothing.\n\
If that was a mistyped secret, `tsunagi network leave` removes one.",
If that was a mistyped secret, `tsng network leave` removes one.",
report.name,
short(other, 10)
);
@@ -1935,7 +1935,7 @@ async fn join_network(
if origin == SecretOrigin::Generated {
println!(" secret {}", secret.encode().as_str());
}
eprintln!("\nNo agent is running here, so it starts with the next `tsunagi up`.");
eprintln!("\nNo agent is running here, so it starts with the next `tsng up`.");
Ok(())
}
@@ -1955,15 +1955,15 @@ async fn invite(socket: &std::path::Path, name: &NetworkName, secret: &NetworkSe
println!(" secret {}", secret.encode().as_str());
println!(
"\nRun this on the other machine:\n\n \
tsunagi join --network {name} --secret {}",
tsng join --network {name} --secret {}",
secret.encode().as_str()
);
match endpoint {
Some(endpoint) => println!(
"\nStart its agent with `tsunagi up`; DHT discovery is enabled by default.\n\n \
Optional manual bootstrap: tsunagi up --peer {endpoint}"
"\nStart its agent with `tsng up`; DHT discovery is enabled by default.\n\n \
Optional manual bootstrap: tsng up --peer {endpoint}"
),
None => println!("\nIts agent has to be running: `tsunagi up`."),
None => println!("\nIts agent has to be running: `tsng up`."),
}
}
@@ -1999,28 +1999,28 @@ async fn show_networks(
None => "running · no address agreed yet".to_string(),
},
format!(
"`tsunagi network stop {}` pauses it, `leave` gives it up",
"`tsng network stop {}` pauses it, `leave` gives it up",
short(&id, 10)
),
),
Some(_) => (
"stopped".to_string(),
format!(
"kept as it was; `tsunagi network start {}` resumes it",
"kept as it was; `tsng network start {}` resumes it",
short(&id, 10)
),
),
None if network.auto_start => (
"configured · starts with the agent".to_string(),
format!(
"`tsunagi network stop {}` keeps it from starting",
"`tsng network stop {}` keeps it from starting",
short(&id, 10)
),
),
None => (
"stopped".to_string(),
format!(
"kept as it was; `tsunagi network start {}` resumes it",
"kept as it was; `tsng network start {}` resumes it",
short(&id, 10)
),
),
@@ -2049,7 +2049,7 @@ async fn show_networks(
));
}
out.push(section);
print_report("tsunagi networks", &out)
print_report("tsng networks", &out)
}
/// Stops serving a network, or starts serving it again.
@@ -2114,7 +2114,7 @@ async fn exit_offer_command(
storage.set_exit_node(network.network_id, enabled).await?;
storage.release_ownership_lock();
println!(
"exit node {} for `{}` ({}) with the next `tsunagi up`",
"exit node {} for `{}` ({}) with the next `tsng up`",
if enabled {
"will be offered"
} else {
@@ -2153,7 +2153,7 @@ async fn exit_offer_command(
}
println!(
"\nMembers can now send all their internet traffic through this device, and it \
can see it. `tsunagi network exit-node {wanted} off` stops."
can see it. `tsng network exit-node {wanted} off` stops."
);
Ok(())
}
@@ -2235,12 +2235,12 @@ fn resolve_exit_node<'a>(
[] => match named.first() {
Some((network, peer)) => Err(format!(
"`{}` is connected but does not offer to be an exit node. On that device: \
tsunagi network exit-node {} on",
tsng network exit-node {} on",
peer_label(peer),
network.name
)),
None => Err(format!(
"no connected device matches `{wanted}`. `tsunagi exit-node` lists the \
"no connected device matches `{wanted}`. `tsng exit-node` lists the \
ones that offer to be an exit node"
)),
},
@@ -2262,7 +2262,7 @@ fn resolve_exit_node<'a>(
}
}
/// `tsunagi exit-node`: list, use or stop using an exit node.
/// `tsng exit-node`: list, use or stop using an exit node.
async fn exit_node_command(args: ExitNodeArgs) -> Result<(), Box<dyn std::error::Error>> {
use report::{Health, Report, Row, Section};
@@ -2272,7 +2272,7 @@ async fn exit_node_command(args: ExitNodeArgs) -> Result<(), Box<dyn std::error:
if !tsunagi::ipc::is_serving(&socket).await {
return Err(
"no agent is running: an exit node is used through the running agent, which \
changes the routes of this device. Start it with `tsunagi up`."
changes the routes of this device. Start it with `tsng up`."
.into(),
);
}
@@ -2308,16 +2308,14 @@ async fn exit_node_command(args: ExitNodeArgs) -> Result<(), Box<dyn std::error:
"none",
"no connected device offers to be an exit node",
)
.with_note("on that device: tsunagi network exit-node <network> on"),
.with_note("on that device: tsng network exit-node <network> on"),
);
}
let mut out = Report::new();
out.push(section);
print_report("tsunagi exit-node", &out)?;
print_report("tsng exit-node", &out)?;
if any {
println!(
"\n`tsunagi exit-node <name|id>` uses one; `tsunagi exit-node off` stops."
);
println!("\n`tsng exit-node <name|id>` uses one; `tsng exit-node off` stops.");
}
Ok(())
}
@@ -2364,7 +2362,7 @@ async fn exit_node_command(args: ExitNodeArgs) -> Result<(), Box<dyn std::error:
}
eprintln!(
"\nThat device can see everything that is not encrypted. If it goes away the \
traffic is dropped, not sent the old way; `tsunagi exit-node off` stops."
traffic is dropped, not sent the old way; `tsng exit-node off` stops."
);
Ok(())
}
@@ -2399,8 +2397,8 @@ async fn set_active(
if !report.active {
eprintln!(
"\nNothing was announced: to the others this device is away, and the address \
and name it holds stay reserved for it. `tsunagi network start {}` resumes \
it; `tsunagi network leave` is the one that gives them up.",
and name it holds stay reserved for it. `tsng network start {}` resumes \
it; `tsng network leave` is the one that gives them up.",
short(&id, 10)
);
}
@@ -2412,7 +2410,7 @@ async fn set_active(
storage.set_auto_start(network.network_id, active).await?;
storage.release_ownership_lock();
println!(
"`{name}` ({}) will {} with the next `tsunagi up`",
"`{name}` ({}) will {} with the next `tsng up`",
short(&id, 10),
if active { "start" } else { "stay stopped" }
);
@@ -2511,7 +2509,7 @@ fn forget_protocol_state(paths: &StoragePaths, network: tsunagi::NetworkId) {
}
}
/// `tsunagi wipe`: back to a device that has never joined anything.
/// `tsng wipe`: back to a device that has never joined anything.
async fn wipe(args: WipeArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
@@ -2532,7 +2530,7 @@ async fn wipe(args: WipeArgs) -> Result<(), Box<dyn std::error::Error>> {
let networks = stored_networks(&paths);
if !args.yes {
println!("`tsunagi wipe --yes` would remove:\n");
println!("`tsng wipe --yes` would remove:\n");
for entry in plan.entries() {
println!(" {}", entry.display());
}
@@ -2543,7 +2541,7 @@ async fn wipe(args: WipeArgs) -> Result<(), Box<dyn std::error::Error>> {
}
println!(
"\nNobody is told. Leave each network first — start the agent and run\n\
`tsunagi network leave <id>` — to free the address and name it holds\n\
`tsng network leave <id>` — to free the address and name it holds\n\
for the others. Afterwards this device is a stranger: a new identity,\n\
no networks, and no way to sign anything for the old ones."
);
@@ -2572,7 +2570,7 @@ async fn wipe(args: WipeArgs) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
/// `tsunagi dns`: the local resolver, and turning it on or off.
/// `tsng dns`: the local resolver, and turning it on or off.
async fn dns_command(args: DnsArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
@@ -2628,7 +2626,7 @@ async fn dns_command(args: DnsArgs) -> Result<(), Box<dyn std::error::Error>> {
},
)?;
println!("{} for future starts", if enable { "on" } else { "off" });
eprintln!("\nNo agent is running here, so it takes effect with the next `tsunagi up`.");
eprintln!("\nNo agent is running here, so it takes effect with the next `tsng up`.");
Ok(())
}
@@ -2663,10 +2661,10 @@ async fn show_dns(
out.push(section);
}
}
print_report("tsunagi dns", &out)
print_report("tsng dns", &out)
}
/// `tsunagi id`: what this device is, and what changes it.
/// `tsng id`: what this device is, and what changes it.
async fn id(args: IdArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
@@ -2703,13 +2701,13 @@ async fn show_identity(
out.push(device);
// What this device *is*, not what it belongs to. The networks are
// `tsunagi network`, and their secrets are asked for by name there:
// `tsng network`, and their secrets are asked for by name there:
// printing them in an overview put them in every pasted report.
let networks = stored_networks(paths);
let mut section = Section::new("networks");
section.push(match networks.len() {
0 => Row::new(Health::Info, "none", "no network has been joined")
.with_note("`tsunagi join --network <name>` makes or joins one"),
.with_note("`tsng join --network <name>` makes or joins one"),
count => Row::new(
Health::Info,
"joined",
@@ -2722,11 +2720,11 @@ async fn show_identity(
.join(", ")
),
)
.with_note("`tsunagi network` lists them with their ids and addresses"),
.with_note("`tsng network` lists them with their ids and addresses"),
});
out.push(section);
print_report("tsunagi id", &out)
print_report("tsng id", &out)
}
/// The name this device answers to.
@@ -2938,7 +2936,7 @@ async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
out.push(host_section(&observed));
out.push(addresses_section().await);
print_report("tsunagi status", &out)
print_report("tsng status", &out)
}
/// The local resolver: whether it answers, and whether the system asks it.
@@ -3017,7 +3015,7 @@ fn dns_absent_section() -> report::Section {
"no local resolver for any network",
)
.with_note(
"members resolve by address only. `tsunagi up --dns` serves \
"members resolve by address only. `tsng up --dns` serves \
`<hostname>.<network>` from signed state, so a member that is switched \
off still resolves.",
),
@@ -3160,7 +3158,7 @@ fn network_section(
)
.with_note(
"a network is its name *and* its secret, so these two share nothing. \
Usually a mistyped secret; `tsunagi network secret` shows which is which.",
Usually a mistyped secret; `tsng network secret` shows which is which.",
),
);
}
@@ -3377,7 +3375,7 @@ fn exit_rows(network: &tsunagi::ipc::NetworkReport) -> Vec<report::Row> {
let row = if !exit.via_online {
Row::new(Health::Broken, "using exit", format!("{name} · OFFLINE")).with_note(
"internet traffic is dropped until it returns or you run \
`tsunagi exit-node off`",
`tsng exit-node off`",
)
} else {
match &exit.client_rules {
@@ -3535,7 +3533,7 @@ fn host_section(observed: &Observed) -> report::Section {
.collect();
host.push(
Row::new(Health::Info, "protocols", protocols.join(", "))
.with_note("run `tsunagi protocols` to see what each one takes"),
.with_note("run `tsng protocols` to see what each one takes"),
);
host.push(Row::new(
Health::Info,
@@ -3727,7 +3725,7 @@ fn print_report(title: &str, out: &report::Report) -> Result<(), Box<dyn std::er
Ok(())
}
/// The shape of what `tsunagi status` reports.
/// The shape of what `tsng status` reports.
///
/// Findings are built first and rendered second, so what is reported is
/// decided separately from how it looks and can be tested without a
@@ -3869,7 +3867,7 @@ mod report {
/// Whether anything in the report was graded at all.
///
/// A report of plain facts — `tsunagi id` — has nothing to summarise,
/// A report of plain facts — `tsng id` — has nothing to summarise,
/// and "everything checked out" under a list of identifiers would be
/// claiming something that was never checked.
fn has_checks(&self) -> bool {
@@ -4075,7 +4073,7 @@ mod report {
#[test]
fn a_report_of_plain_facts_claims_nothing_at_the_end() {
// `tsunagi id` reports identifiers, not checks. Summarising them
// `tsng id` reports identifiers, not checks. Summarising them
// as fine would assert something that was never tested.
let mut report = Report::new();
let mut section = Section::new("device");
@@ -4213,7 +4211,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
let known: Vec<&str> = PROTOCOLS.iter().map(|spec| spec.name).collect();
return Err(format!(
"this build has no protocol called `{name}`; it has {}. \
Run `tsunagi protocols` to see what each one takes.",
Run `tsng protocols` to see what each one takes.",
known.join(", ")
)
.into());
@@ -4232,7 +4230,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
.any(|spec| !settings_for(spec, std::slice::from_ref(setting)).is_empty())
{
return Err(format!(
"no selected protocol takes `{}`; run `tsunagi protocols` to see what they do",
"no selected protocol takes `{}`; run `tsng protocols` to see what they do",
setting.key
)
.into());
@@ -4343,13 +4341,13 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
"an agent is already running for {}, and one state directory is one \
agent — it is the device, not a network.\n\n\
To add a network to it:\n\n \
tsunagi join --network <name>\n\n\
tsng join --network <name>\n\n\
To run a second, separate agent instead, give it everything of its \
own:\n\n \
tsunagi up --state-dir <dir> --cache-dir <dir> --interface tsun1 \
tsng up --state-dir <dir> --cache-dir <dir> --interface tsun1 \
--ipv4-range <cidr>\n\n\
That is a different identity with its own interface, not this one \
with another network. `tsunagi network` lists what this one has.",
with another network. `tsng network` lists what this one has.",
path.display()
)
.into());
@@ -4403,14 +4401,14 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
protocol_names.join(", ")
);
// What this device belongs to is a separate question from whether its
// agent is running, and `tsunagi join` answers it at any time.
// agent is running, and `tsng join` answers it at any time.
let configured = agent.list_networks().await.unwrap_or_default();
let running = configured.iter().filter(|network| network.active).count();
println!(
" networks {}",
match configured.len() {
0 => "none yet · `tsunagi join --network <name>` makes or joins one".to_string(),
total => format!("{running} of {total} running · `tsunagi network` lists them"),
0 => "none yet · `tsng join --network <name>` makes or joins one".to_string(),
total => format!("{running} of {total} running · `tsng network` lists them"),
}
);
println!(" state {}", paths.state_dir.display());
@@ -4424,7 +4422,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
None
}));
// Serve `tsunagi status` for as long as this agent runs. Failing to bind
// Serve `tsng status` for as long as this agent runs. Failing to bind
// is not fatal: the agent itself works fine without it.
let control = {
let agent = agent.clone();
@@ -4458,7 +4456,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
Some(socket)
}
Err(err) => {
eprintln!("warning: `tsunagi status` will not work: {err}");
eprintln!("warning: `tsng status` will not work: {err}");
None
}
}
@@ -5476,7 +5474,7 @@ mod status_tests {
assert_eq!(out.worst(), Health::Broken, "{text}");
assert!(text.contains("OFFLINE"), "{text}");
assert!(text.contains("dropped until it returns"), "{text}");
assert!(text.contains("tsunagi exit-node off"), "{text}");
assert!(text.contains("tsng exit-node off"), "{text}");
}
#[test]
@@ -5630,14 +5628,13 @@ mod network_tests {
(vec!["--reach", "local", "--dht"], false),
(vec!["--reach", "direct"], true),
] {
let cli =
super::Cli::try_parse_from(["tsunagi", "up"].into_iter().chain(flags)).unwrap();
let cli = super::Cli::try_parse_from(["tsng", "up"].into_iter().chain(flags)).unwrap();
let super::Command::Up(args) = cli.command else {
panic!("expected up");
};
assert_eq!(super::dht_enabled(&args), expected);
}
assert!(super::Cli::try_parse_from(["tsunagi", "up", "--dht", "--no-dht"]).is_err());
assert!(super::Cli::try_parse_from(["tsng", "up", "--dht", "--no-dht"]).is_err());
}
use super::*;
@@ -5814,7 +5811,7 @@ mod secret_tests {
#[test]
fn a_name_this_device_already_has_resumes_it() {
// Otherwise `tsunagi join --network lab` would invent a stranger with
// Otherwise `tsng join --network lab` would invent a stranger with
// the same name every time, which is the confusion this whole
// report format exists to prevent.
let dir = tempfile::tempdir().unwrap();
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more