Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9966b50407 | ||
|
|
bb38562b0a |
No files matched your search
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
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.
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.
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.
+1
-1
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+142
-7
@@ -702,14 +702,37 @@ fn exit_default_route(index: Option<u32>) -> rtnetlink::packet_route::route::Rou
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// Removes the exit node's routing rules and its table. Nothing there is
|
||||
/// The exit table's IPv6 default: nothing is reachable through it.
|
||||
///
|
||||
/// There is no IPv6 through an exit node, and what the ordinary route would do
|
||||
/// is send it out in the clear. This turns it into an immediate refusal, which
|
||||
/// is what makes applications fall back to IPv4.
|
||||
fn exit_blocking_route_v6() -> rtnetlink::packet_route::route::RouteMessage {
|
||||
RouteMessageBuilder::<std::net::Ipv6Addr>::new()
|
||||
.destination_prefix(std::net::Ipv6Addr::UNSPECIFIED, 0)
|
||||
.table_id(EXIT_TABLE)
|
||||
.kind(rtnetlink::packet_route::route::RouteType::Unreachable)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// A host with no IPv6 at all has nothing to block, and says so this way.
|
||||
fn family_unsupported(err: &impl std::fmt::Display) -> bool {
|
||||
let text = err.to_string();
|
||||
text.contains("not supported") || text.contains("Address family")
|
||||
}
|
||||
|
||||
/// Removes the exit node's routing rules and its tables. Nothing there is
|
||||
/// success: it also runs once at startup, for what a crashed run left.
|
||||
async fn clear_exit_client() -> Result<(), OverlayError> {
|
||||
with_netlink(|handle| async move { remove_exit_client(&handle).await }).await
|
||||
}
|
||||
|
||||
async fn remove_exit_client(handle: &rtnetlink::Handle) -> Result<(), OverlayError> {
|
||||
let mut rules = handle.rule().get(IpVersion::V4).execute();
|
||||
/// Deletes this agent's exit rules of one address family.
|
||||
async fn remove_exit_rules(
|
||||
handle: &rtnetlink::Handle,
|
||||
version: IpVersion,
|
||||
) -> Result<(), OverlayError> {
|
||||
let mut rules = handle.rule().get(version).execute();
|
||||
let mut ours = Vec::new();
|
||||
while let Some(rule) = rules
|
||||
.try_next()
|
||||
@@ -732,12 +755,32 @@ async fn remove_exit_client(handle: &rtnetlink::Handle) -> Result<(), OverlayErr
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_exit_client(handle: &rtnetlink::Handle) -> Result<(), OverlayError> {
|
||||
remove_exit_rules(handle, IpVersion::V4).await?;
|
||||
match handle.route().del(exit_default_route(None)).execute().await {
|
||||
Ok(()) => Ok(()),
|
||||
Ok(()) => {}
|
||||
// "No such process": no such route, which is the goal.
|
||||
Err(err) if err.to_string().contains("No such") => Ok(()),
|
||||
Err(err) if err.to_string().contains("No such") => {}
|
||||
Err(err) => {
|
||||
return Err(OverlayError::Unavailable(format!(
|
||||
"cannot remove the exit node default route: {err}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
match remove_exit_rules(handle, IpVersion::V6).await {
|
||||
Ok(()) => {}
|
||||
Err(OverlayError::Unavailable(reason)) if family_unsupported(&reason) => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
match handle.route().del(exit_blocking_route_v6()).execute().await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.to_string().contains("No such") || family_unsupported(&err) => Ok(()),
|
||||
Err(err) => Err(OverlayError::Unavailable(format!(
|
||||
"cannot remove the exit node default route: {err}"
|
||||
"cannot remove the exit node IPv6 block: {err}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -751,6 +794,9 @@ async fn remove_exit_client(handle: &rtnetlink::Handle) -> Result<(), OverlayErr
|
||||
/// ip rule add priority 5300 lookup 0x7473
|
||||
/// ```
|
||||
///
|
||||
/// and the same for IPv6 with `ip -6 route replace unreachable default table
|
||||
/// 0x7473`, so that IPv6 is refused instead of leaving the ordinary way.
|
||||
///
|
||||
/// What an earlier call left is removed first, so repeating it is safe, and
|
||||
/// the rules go in last, so a failure part way never leaves a rule that
|
||||
/// sends traffic to a table with no route in it.
|
||||
@@ -817,11 +863,81 @@ async fn set_exit_client(name: &str, uid: u32) -> Result<(), OverlayError> {
|
||||
.priority(EXIT_DEFAULT_PRIORITY)
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| fail("rule for the default route", err))
|
||||
.map_err(|err| fail("rule for the default route", err))?;
|
||||
|
||||
set_exit_block_v6(&handle, uid).await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The IPv6 half: an unreachable default for everything but the agent's own
|
||||
/// connections and the specific routes, which keep working.
|
||||
async fn set_exit_block_v6(handle: &rtnetlink::Handle, uid: u32) -> Result<(), OverlayError> {
|
||||
let fail = |what: &str, err: rtnetlink::Error| {
|
||||
OverlayError::Unavailable(format!("cannot add the exit node IPv6 {what}: {err}"))
|
||||
};
|
||||
if let Err(err) = handle
|
||||
.route()
|
||||
.add(exit_blocking_route_v6())
|
||||
.replace()
|
||||
.execute()
|
||||
.await
|
||||
{
|
||||
// Without IPv6 there is nothing to leak.
|
||||
return if family_unsupported(&err) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(fail("block", err))
|
||||
};
|
||||
}
|
||||
|
||||
let mut bypass = handle
|
||||
.rule()
|
||||
.add()
|
||||
.v6()
|
||||
.table_id(MAIN_TABLE)
|
||||
.action(RuleAction::ToTable)
|
||||
.priority(EXIT_BYPASS_PRIORITY);
|
||||
bypass
|
||||
.message_mut()
|
||||
.attributes
|
||||
.push(RuleAttribute::UidRange(RuleUidRange {
|
||||
start: uid,
|
||||
end: uid,
|
||||
}));
|
||||
bypass
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| fail("rule for the agent's own traffic", err))?;
|
||||
|
||||
let mut specific = handle
|
||||
.rule()
|
||||
.add()
|
||||
.v6()
|
||||
.table_id(MAIN_TABLE)
|
||||
.action(RuleAction::ToTable)
|
||||
.priority(EXIT_SPECIFIC_PRIORITY);
|
||||
specific
|
||||
.message_mut()
|
||||
.attributes
|
||||
.push(RuleAttribute::SuppressPrefixLen(0));
|
||||
specific
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| fail("rule for the specific routes", err))?;
|
||||
|
||||
handle
|
||||
.rule()
|
||||
.add()
|
||||
.v6()
|
||||
.table_id(EXIT_TABLE)
|
||||
.action(RuleAction::ToTable)
|
||||
.priority(EXIT_DEFAULT_PRIORITY)
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| fail("rule for the default route", err))
|
||||
}
|
||||
|
||||
async fn configure_link(configure: &Configure) -> Result<(), OverlayError> {
|
||||
let name = configure.name.as_str();
|
||||
with_netlink(|handle| async move {
|
||||
@@ -994,6 +1110,25 @@ mod tests {
|
||||
assert!(!is_exit_rule(&rule(5280, 100, vec![uid])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ipv6_default_of_the_exit_table_refuses_instead_of_routing() {
|
||||
let route = exit_blocking_route_v6();
|
||||
assert_eq!(route.header.destination_prefix_length, 0);
|
||||
let debug = format!("{route:?}");
|
||||
assert!(debug.contains("Unreachable"), "{debug}");
|
||||
assert!(debug.contains(&format!("Table({EXIT_TABLE})")), "{debug}");
|
||||
assert!(!debug.contains("Oif"), "{debug}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_without_ipv6_is_not_a_failure() {
|
||||
assert!(family_unsupported(
|
||||
&"Address family not supported by protocol"
|
||||
));
|
||||
assert!(family_unsupported(&"Operation not supported"));
|
||||
assert!(!family_unsupported(&"Operation not permitted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_route_of_the_exit_table_names_the_table_and_the_interface() {
|
||||
let route = exit_default_route(Some(7));
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+4
-6
@@ -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");
|
||||
}
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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.
File renamed without changes.
File renamed without changes.
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -48,6 +48,21 @@ pub(crate) struct ExitConfirm {
|
||||
name: String,
|
||||
/// Stop using it, rather than start.
|
||||
stop: bool,
|
||||
/// Asked in the main window, not in the devices window of the network.
|
||||
main: bool,
|
||||
}
|
||||
|
||||
impl ExitConfirm {
|
||||
/// A question about stopping the exit node a network's tile shows.
|
||||
pub(crate) fn stop_from_tile(network: &NetworkReport, peer_id: &str) -> Self {
|
||||
Self {
|
||||
network_id: network.network_id.clone(),
|
||||
peer_id: peer_id.to_string(),
|
||||
name: exit::name_of(network, peer_id),
|
||||
stop: true,
|
||||
main: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Device<'_> {
|
||||
@@ -126,18 +141,24 @@ pub(crate) fn show(
|
||||
device_table(ui, unit, network, &devices, traffic, confirm);
|
||||
});
|
||||
});
|
||||
confirm_exit(ctx, agent, confirm, &network.network_id);
|
||||
confirm_exit(ctx, agent, confirm, Some(&network.network_id));
|
||||
}
|
||||
|
||||
/// The confirmation for an exit-node choice, drawn in the window it was
|
||||
/// made in. Everything behind it is blocked until it is answered.
|
||||
fn confirm_exit(
|
||||
///
|
||||
/// `network_id` is the devices window the question is drawn in, or `None` for
|
||||
/// the main window; a question appears in the window it was asked in only.
|
||||
pub(crate) fn confirm_exit(
|
||||
ctx: &egui::Context,
|
||||
agent: &AgentClient,
|
||||
confirm: &mut Option<ExitConfirm>,
|
||||
network_id: &str,
|
||||
network_id: Option<&str>,
|
||||
) {
|
||||
let Some(pending) = confirm.as_ref().filter(|c| c.network_id == network_id) else {
|
||||
let Some(pending) = confirm.as_ref().filter(|c| match network_id {
|
||||
Some(id) => !c.main && c.network_id == id,
|
||||
None => c.main,
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
egui::Area::new(egui::Id::new("exit confirm backdrop"))
|
||||
@@ -151,48 +172,31 @@ fn confirm_exit(
|
||||
});
|
||||
|
||||
let (mut accepted, mut cancelled) = (false, false);
|
||||
egui::Window::new(if pending.stop {
|
||||
"Stop using exit node"
|
||||
} else {
|
||||
"Use as exit node"
|
||||
})
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.set_max_width(380.0);
|
||||
if pending.stop {
|
||||
ui.label(format!("Stop using {} as exit node?", pending.name));
|
||||
ui.label("Your internet traffic will leave this device the ordinary way again.");
|
||||
} else {
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"Send ALL your internet traffic through {}?",
|
||||
pending.name
|
||||
))
|
||||
.strong(),
|
||||
);
|
||||
ui.label(
|
||||
"Everything not on this network will leave through that device, which can \
|
||||
see it.",
|
||||
);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let label = if pending.stop {
|
||||
"Stop"
|
||||
egui::Window::new("Exit node")
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.set_max_width(380.0);
|
||||
ui.label(if pending.stop {
|
||||
format!("Stop using {}?", pending.name)
|
||||
} else {
|
||||
"Use as exit node"
|
||||
};
|
||||
if ui.button(label).clicked() {
|
||||
accepted = true;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
cancelled = true;
|
||||
}
|
||||
format!("Use {} as exit node?", pending.name)
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.button(if pending.stop { "Stop" } else { "Use" })
|
||||
.clicked()
|
||||
{
|
||||
accepted = true;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
cancelled = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if accepted {
|
||||
agent.send(Command::SetExitNode {
|
||||
network_id: pending.network_id.clone(),
|
||||
@@ -431,6 +435,7 @@ fn exit_cell(
|
||||
peer_id: device.id.to_string(),
|
||||
name: device.name(),
|
||||
stop: device.in_use,
|
||||
main: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,8 @@ pub(crate) fn draw(
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
devices::confirm_exit(ctx, agent, &mut state.exit_confirm, None);
|
||||
}
|
||||
|
||||
/// The "agent cannot be reached" state.
|
||||
@@ -281,21 +283,32 @@ fn draw_network(
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(via) = &network.exit.via {
|
||||
ui.weak("exit");
|
||||
ui.label(exit::name_of(network, via));
|
||||
if ui
|
||||
.small_button("off")
|
||||
.on_hover_text("send internet traffic the ordinary way again")
|
||||
.clicked()
|
||||
{
|
||||
agent.send(Command::SetExitNode {
|
||||
network_id: network.network_id.clone(),
|
||||
peer: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// In the corner while this device sends its traffic through
|
||||
// somebody: green while that works, red while it does not.
|
||||
if let Some(via) = &network.exit.via {
|
||||
let color = if network.exit.via_online {
|
||||
exit::GOOD
|
||||
} else {
|
||||
exit::LOUD
|
||||
};
|
||||
let response = ui
|
||||
.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(icon::SIGN_OUT).size(18.0).color(color),
|
||||
)
|
||||
.sense(egui::Sense::click()),
|
||||
)
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text(format!(
|
||||
"exit node: {} — click to stop",
|
||||
exit::name_of(network, via)
|
||||
));
|
||||
if response.clicked() {
|
||||
state.exit_confirm =
|
||||
Some(devices::ExitConfirm::stop_from_tile(network, via));
|
||||
}
|
||||
}
|
||||
if ui.button("Show devices").clicked() {
|
||||
state.open_devices.insert(network.network_id.clone());
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user