Compare commits
25
Commits
No files matched your search
@@ -33,11 +33,18 @@ jobs:
|
||||
asset: tsunagi-linux-x86_64
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
deb: true
|
||||
deb_arch: amd64
|
||||
pkg_arch: x86_64
|
||||
gnu_target: x86_64-unknown-linux-gnu
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
asset: tsunagi-linux-aarch64
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
deb: true
|
||||
deb_arch: arm64
|
||||
pkg_arch: aarch64
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
asset: tsunagi-macos-aarch64
|
||||
@@ -63,9 +70,9 @@ jobs:
|
||||
# aws-lc-sys (iroh's rustls backend) and the bundled sqlite are C, and the
|
||||
# musl targets have no prebuilt bindings, so bindgen needs libclang and the
|
||||
# C build needs cmake. zig is the cross C compiler cargo-zigbuild drives.
|
||||
- name: Install musl build tooling
|
||||
- name: Install musl build and packaging tooling
|
||||
if: matrix.zig
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang libclang-dev
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang libclang-dev libarchive-tools zstd
|
||||
- name: Install zig
|
||||
if: matrix.zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
@@ -96,6 +103,30 @@ jobs:
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
||||
fi
|
||||
|
||||
# The tray GUI. On Linux it is built against glibc (an old baseline for
|
||||
# portability), not musl: a GUI must dlopen libGL/X11/Wayland at runtime,
|
||||
# which a static musl binary cannot. macOS and Windows build it natively.
|
||||
- name: Build the tray GUI
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ runner.os }}" == "Linux" ]]; then
|
||||
if [[ -n "${{ matrix.gnu_target }}" ]]; then
|
||||
# tray-icon links GTK and libappindicator through pkg-config, which
|
||||
# has no cross story under zig, so the Linux GUI is built natively
|
||||
# (x86_64 only) against the runner's glibc.
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libgtk-3-dev libayatana-appindicator3-dev libxdo-dev
|
||||
cargo build --release --locked \
|
||||
--target "${{ matrix.gnu_target }}" --bin tsunagi-tray
|
||||
else
|
||||
echo "no GUI build for ${{ matrix.target }}"
|
||||
fi
|
||||
else
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi-tray
|
||||
fi
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -109,12 +140,24 @@ jobs:
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
|
||||
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
|
||||
|
||||
# The tray GUI alongside it, where it was built (Linux: x86_64 only).
|
||||
tray="tsunagi-tray"
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && tray="tsunagi-tray.exe"
|
||||
if [[ "${{ runner.os }}" == "Linux" ]]; then
|
||||
if [[ -n "${{ matrix.gnu_target }}" ]]; then
|
||||
cp "target/${{ matrix.gnu_target }}/release/${tray}" "$stage/"
|
||||
fi
|
||||
else
|
||||
cp "target/${{ matrix.target }}/release/${tray}" "$stage/"
|
||||
fi
|
||||
|
||||
# The top-level README, plus the per-OS install notes and integration
|
||||
# files, so the archive carries what the platform needs to set up.
|
||||
cp README.md "$stage/"
|
||||
case "${{ runner.os }}" in
|
||||
Linux)
|
||||
cp dist/linux/tsunagi.service dist/linux/install.sh "$stage/"
|
||||
cp dist/linux/tsunagi.service dist/linux/install.sh \
|
||||
dist/linux/50-tsunagi-resolved.rules "$stage/"
|
||||
cp dist/linux/README.md "$stage/INSTALL.md"
|
||||
;;
|
||||
macOS)
|
||||
@@ -133,13 +176,67 @@ jobs:
|
||||
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
|
||||
fi
|
||||
|
||||
# A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops
|
||||
# the static binary, the systemd service and the polkit rule. Built with
|
||||
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
|
||||
- name: Build the Debian package
|
||||
if: matrix.deb
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
bash dist/deb/build-deb.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.deb_arch }}" \
|
||||
"$version" \
|
||||
"tsunagi_${version}_${{ matrix.deb_arch }}.deb"
|
||||
|
||||
# A dependency-free Arch package (.pkg.tar.zst), assembled with bsdtar and
|
||||
# zstd (no makepkg); same binary, service and polkit rule. See
|
||||
# dist/arch/build-pkg.sh.
|
||||
- name: Build the Arch package
|
||||
if: matrix.pkg_arch
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
pkgver="${version//-/_}"
|
||||
bash dist/arch/build-pkg.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.pkg_arch }}" \
|
||||
"$version" \
|
||||
"tsunagi-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst"
|
||||
|
||||
# The GUI variants: the same agent plus the tray binary and a desktop
|
||||
# launcher, named tsunagi-gui (conflicting with the headless packages).
|
||||
- name: Build the GUI packages
|
||||
if: matrix.gnu_target
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
pkgver="${version//-/_}"
|
||||
tray="target/${{ matrix.gnu_target }}/release/tsunagi-tray"
|
||||
bash dist/deb/build-deb.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.deb_arch }}" "$version" \
|
||||
"tsunagi-gui_${version}_${{ matrix.deb_arch }}.deb" "$tray"
|
||||
bash dist/arch/build-pkg.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.pkg_arch }}" "$version" \
|
||||
"tsunagi-gui-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst" "$tray"
|
||||
|
||||
# @v3 on purpose: Gitea does not support the v4 artifact backend. This is
|
||||
# what makes the archive show up under the run and hands it to `publish`.
|
||||
- name: Upload the archive as a run artifact
|
||||
# what makes the archive (and the .deb, where built) show up under the run
|
||||
# and hands it to `publish`.
|
||||
- name: Upload the build outputs as run artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: ${{ matrix.asset }}-*.*
|
||||
path: |
|
||||
${{ matrix.asset }}-*.*
|
||||
*.deb
|
||||
*.pkg.tar.zst
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
@@ -211,5 +308,5 @@ jobs:
|
||||
cat up.json >&2 || true
|
||||
rc=1
|
||||
fi
|
||||
done < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' \) -print0)
|
||||
done < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' \) -print0)
|
||||
exit "$rc"
|
||||
+112
-15
@@ -2,13 +2,14 @@ name: Release
|
||||
|
||||
# GitHub version of the release pipeline. The build half mirrors the Gitea one
|
||||
# in .gitea/workflows/release.yml (static musl Linux via cargo-zigbuild, native
|
||||
# macOS and Windows); only the publish half differs: GitHub needs the v4
|
||||
# artifact backend and fills the release with `gh`.
|
||||
# macOS and Windows); only the publish half differs: it uses the current
|
||||
# artifact actions (Node 24) and fills the release with `gh`.
|
||||
#
|
||||
# Every job is gated on `github.server_url == 'https://github.com'`, so this file
|
||||
# runs only on GitHub. Gitea runs its own copy in .gitea/workflows; should a
|
||||
# Gitea instance also read .github/workflows, the guard skips these jobs there
|
||||
# so they never fail on the @v4 artifact backend or `gh`.
|
||||
# runs only on GitHub. Gitea runs its own copy in .gitea/workflows (which pins
|
||||
# the older @v3 artifact actions it supports); should a Gitea instance also read
|
||||
# .github/workflows, the guard skips these jobs there so they never fail on the
|
||||
# artifact backend or `gh`.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
@@ -34,11 +35,18 @@ jobs:
|
||||
asset: tsunagi-linux-x86_64
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
deb: true
|
||||
deb_arch: amd64
|
||||
pkg_arch: x86_64
|
||||
gnu_target: x86_64-unknown-linux-gnu
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
asset: tsunagi-linux-aarch64
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
deb: true
|
||||
deb_arch: arm64
|
||||
pkg_arch: aarch64
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
asset: tsunagi-macos-aarch64
|
||||
@@ -51,7 +59,7 @@ jobs:
|
||||
zig: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
@@ -64,9 +72,9 @@ jobs:
|
||||
# aws-lc-sys (iroh's rustls backend) and the bundled sqlite are C, and the
|
||||
# musl targets have no prebuilt bindings, so bindgen needs libclang and the
|
||||
# C build needs cmake. zig is the cross C compiler cargo-zigbuild drives.
|
||||
- name: Install musl build tooling
|
||||
- name: Install musl build and packaging tooling
|
||||
if: matrix.zig
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang libclang-dev
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang libclang-dev libarchive-tools zstd
|
||||
- name: Install zig
|
||||
if: matrix.zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
@@ -97,6 +105,30 @@ jobs:
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
||||
fi
|
||||
|
||||
# The tray GUI. On Linux it is built against glibc (an old baseline for
|
||||
# portability), not musl: a GUI must dlopen libGL/X11/Wayland at runtime,
|
||||
# which a static musl binary cannot. macOS and Windows build it natively.
|
||||
- name: Build the tray GUI
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ runner.os }}" == "Linux" ]]; then
|
||||
if [[ -n "${{ matrix.gnu_target }}" ]]; then
|
||||
# tray-icon links GTK and libappindicator through pkg-config, which
|
||||
# has no cross story under zig, so the Linux GUI is built natively
|
||||
# (x86_64 only) against the runner's glibc.
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libgtk-3-dev libayatana-appindicator3-dev libxdo-dev
|
||||
cargo build --release --locked \
|
||||
--target "${{ matrix.gnu_target }}" --bin tsunagi-tray
|
||||
else
|
||||
echo "no GUI build for ${{ matrix.target }}"
|
||||
fi
|
||||
else
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi-tray
|
||||
fi
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -110,12 +142,24 @@ jobs:
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
|
||||
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
|
||||
|
||||
# The tray GUI alongside it, where it was built (Linux: x86_64 only).
|
||||
tray="tsunagi-tray"
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && tray="tsunagi-tray.exe"
|
||||
if [[ "${{ runner.os }}" == "Linux" ]]; then
|
||||
if [[ -n "${{ matrix.gnu_target }}" ]]; then
|
||||
cp "target/${{ matrix.gnu_target }}/release/${tray}" "$stage/"
|
||||
fi
|
||||
else
|
||||
cp "target/${{ matrix.target }}/release/${tray}" "$stage/"
|
||||
fi
|
||||
|
||||
# The top-level README, plus the per-OS install notes and integration
|
||||
# files, so the archive carries what the platform needs to set up.
|
||||
cp README.md "$stage/"
|
||||
case "${{ runner.os }}" in
|
||||
Linux)
|
||||
cp dist/linux/tsunagi.service dist/linux/install.sh "$stage/"
|
||||
cp dist/linux/tsunagi.service dist/linux/install.sh \
|
||||
dist/linux/50-tsunagi-resolved.rules "$stage/"
|
||||
cp dist/linux/README.md "$stage/INSTALL.md"
|
||||
;;
|
||||
macOS)
|
||||
@@ -134,11 +178,64 @@ jobs:
|
||||
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
|
||||
fi
|
||||
|
||||
- name: Upload the archive as a run artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
# A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops
|
||||
# the static binary, the systemd service and the polkit rule. Built with
|
||||
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
|
||||
- name: Build the Debian package
|
||||
if: matrix.deb
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
bash dist/deb/build-deb.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.deb_arch }}" \
|
||||
"$version" \
|
||||
"tsunagi_${version}_${{ matrix.deb_arch }}.deb"
|
||||
|
||||
# A dependency-free Arch package (.pkg.tar.zst), assembled with bsdtar and
|
||||
# zstd (no makepkg); same binary, service and polkit rule. See
|
||||
# dist/arch/build-pkg.sh.
|
||||
- name: Build the Arch package
|
||||
if: matrix.pkg_arch
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
pkgver="${version//-/_}"
|
||||
bash dist/arch/build-pkg.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.pkg_arch }}" \
|
||||
"$version" \
|
||||
"tsunagi-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst"
|
||||
|
||||
# The GUI variants: the same agent plus the tray binary and a desktop
|
||||
# launcher, named tsunagi-gui (conflicting with the headless packages).
|
||||
- name: Build the GUI packages
|
||||
if: matrix.gnu_target
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
pkgver="${version//-/_}"
|
||||
tray="target/${{ matrix.gnu_target }}/release/tsunagi-tray"
|
||||
bash dist/deb/build-deb.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.deb_arch }}" "$version" \
|
||||
"tsunagi-gui_${version}_${{ matrix.deb_arch }}.deb" "$tray"
|
||||
bash dist/arch/build-pkg.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.pkg_arch }}" "$version" \
|
||||
"tsunagi-gui-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst" "$tray"
|
||||
|
||||
- name: Upload the build outputs as run artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: ${{ matrix.asset }}-*.*
|
||||
path: |
|
||||
${{ matrix.asset }}-*.*
|
||||
*.deb
|
||||
*.pkg.tar.zst
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
@@ -150,7 +247,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download every build artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
@@ -165,9 +262,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# download-artifact@v4 nests each artifact under its own directory, so
|
||||
# download-artifact nests each artifact under its own directory, so
|
||||
# the archives are artifacts/<asset>/<file>.
|
||||
mapfile -t files < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' \))
|
||||
mapfile -t files < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' \))
|
||||
printf 'asset: %s\n' "${files[@]}"
|
||||
|
||||
prerelease=()
|
||||
|
||||
Generated
+2740
-89
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -14,8 +14,8 @@ mainline = { path = "vendor/mainline" }
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
rust-version = "1.91"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/tsunagi-net/tsunagi"
|
||||
license = "WTFPL"
|
||||
repository = "https://github.com/house-of-vanity/tsunagi"
|
||||
|
||||
[workspace.lints.rust]
|
||||
missing_docs = "warn"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
Version 2, December 2004
|
||||
|
||||
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim or modified
|
||||
copies of this license document, and changing it is allowed as long
|
||||
as the name is changed.
|
||||
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. You just DO WHAT THE FUCK YOU WANT TO.
|
||||
@@ -746,6 +746,7 @@ networks first if the addresses should be freed.
|
||||
- [docs/protocol.md](docs/protocol.md) — identity derivation, framing, handshake.
|
||||
- [docs/sync-model.md](docs/sync-model.md) — the planned signed-state model and
|
||||
what is deliberately not built yet.
|
||||
- [docs/exit-node.md](docs/exit-node.md) — exit nodes: sending all traffic through a member.
|
||||
- [docs/threat-model.md](docs/threat-model.md) — threat model and known limits.
|
||||
- [docs/testing.md](docs/testing.md) — what the suite covers and what it does not.
|
||||
- [AGENTS.md](AGENTS.md) — rules for anyone (human or agent) changing this repo.
|
||||
@@ -763,4 +764,4 @@ any of this.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT OR Apache-2.0.
|
||||
[WTFPL](LICENSE): do what the fuck you want to.
|
||||
@@ -33,6 +33,11 @@ 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
|
||||
|
||||
+810
-20
File diff suppressed because it is too large.
Load diff
@@ -418,3 +418,46 @@ fn ambiguous_and_unknown_names_leave_every_network_untouched() {
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].network_id, before[1].network_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exit_node_is_offered_per_network_only_when_asked_and_nobody_offers_by_default() {
|
||||
let agent = start_bare(0);
|
||||
assert!(agent.run(&["join", "-n", "exit-offer"]).status.success());
|
||||
let offered = || {
|
||||
read_networks(&agent.dir)
|
||||
.iter()
|
||||
.find(|n| n.name.as_str() == "exit-offer")
|
||||
.unwrap()
|
||||
.exit_node
|
||||
};
|
||||
assert!(!offered(), "off unless somebody turns it on");
|
||||
|
||||
let on = agent.run(&["network", "exit-node", "exit-offer", "on"]);
|
||||
assert!(
|
||||
on.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&on.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&on.stdout).contains("exit node on"));
|
||||
assert!(offered());
|
||||
|
||||
let off = agent.run(&["network", "exit-node", "exit-offer", "off"]);
|
||||
assert!(off.status.success());
|
||||
assert!(!offered());
|
||||
|
||||
let listed = agent.run(&["exit-node"]);
|
||||
assert!(listed.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&listed.stdout).contains("no connected device offers"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&listed.stdout)
|
||||
);
|
||||
|
||||
let unknown = agent.run(&["exit-node", "nobody"]);
|
||||
assert!(!unknown.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&unknown.stderr).contains("no connected device matches"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&unknown.stderr)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "tsunagi-tray"
|
||||
version = "0.1.0"
|
||||
description = "Tray application to manage a running tsunagi agent."
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "tsunagi-tray"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# The agent control client and the report types: the GUI is a thin client over
|
||||
# the same local control interface the CLI uses, so it reuses these directly
|
||||
# and never re-implements the protocol.
|
||||
tsunagi = { path = "../tsunagi", version = "0.1.0" }
|
||||
# Native, lightweight GUI — no web engine. eframe re-exports egui.
|
||||
eframe = { version = "0.31", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
|
||||
# Cross-platform tray icon + menu (Windows tray, macOS menu bar, Linux SNI).
|
||||
tray-icon = "0.21"
|
||||
# Background worker that talks to the agent off the UI thread.
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
egui-phosphor = "0.9"
|
||||
|
||||
# Linux only: tray-icon (already GTK-based there) needs the app to initialise
|
||||
# GTK and run its main loop.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
|
||||
# Windows and macOS: the tray process has no GUI toolkit, only an event loop to
|
||||
# serve the icon (and, on macOS, the Accessory activation policy that keeps it
|
||||
# out of the Dock). Pinned to eframe's winit so the types unify.
|
||||
[target.'cfg(any(windows, target_os = "macos"))'.dependencies]
|
||||
winit = "0.30"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,175 @@
|
||||
//! The About window: what this software is, and what it is talking to.
|
||||
//!
|
||||
//! Compact on purpose — one table for the client, one for the agent. The agent
|
||||
//! half is whatever the agent says about itself, so a client and an agent that
|
||||
//! are not the same build show up as such.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use tsunagi::ipc::{PrivilegeReport, StatusReport};
|
||||
|
||||
use crate::agent::Snapshot;
|
||||
use crate::format;
|
||||
|
||||
const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
|
||||
const AUTHOR: &str = "AB <tsunagi@hexor.cy>";
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Draws the window body into its viewport.
|
||||
pub(crate) fn show(ctx: &egui::Context, socket: &std::path::Path, snapshot: &Snapshot) {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
// Long paths and a taller agent table must scroll rather than be cut.
|
||||
egui::ScrollArea::both()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| body(ui, socket, snapshot));
|
||||
});
|
||||
}
|
||||
|
||||
fn body(ui: &mut egui::Ui, socket: &std::path::Path, snapshot: &Snapshot) {
|
||||
{
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("tsunagi");
|
||||
ui.label(egui::RichText::new(VERSION).small().weak());
|
||||
});
|
||||
ui.label("Peer-to-peer mesh network with no server.");
|
||||
ui.add_space(6.0);
|
||||
|
||||
section(ui, "client");
|
||||
grid(ui, "about client", |ui| {
|
||||
row(ui, "repository", |ui| {
|
||||
ui.hyperlink(REPOSITORY);
|
||||
});
|
||||
row(ui, "author", |ui| {
|
||||
format::copy_field(ui, AUTHOR, AUTHOR);
|
||||
});
|
||||
row(ui, "license", |ui| {
|
||||
ui.label(env!("CARGO_PKG_LICENSE"));
|
||||
});
|
||||
row(ui, "build", |ui| {
|
||||
ui.label(format!(
|
||||
"{} {} · {}",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
if cfg!(debug_assertions) {
|
||||
"debug"
|
||||
} else {
|
||||
"release"
|
||||
}
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
section(ui, "agent");
|
||||
let socket = socket.display().to_string();
|
||||
match &snapshot.status {
|
||||
None => {
|
||||
grid(ui, "about agent", |ui| {
|
||||
row(ui, "socket", |ui| format::copy_field(ui, &socket, &socket));
|
||||
row(ui, "state", |ui| {
|
||||
ui.label("connecting…");
|
||||
});
|
||||
});
|
||||
}
|
||||
Some(Err(error)) => {
|
||||
grid(ui, "about agent", |ui| {
|
||||
row(ui, "socket", |ui| format::copy_field(ui, &socket, &socket));
|
||||
row(ui, "state", |ui| {
|
||||
ui.colored_label(egui::Color32::from_rgb(0xd8, 0x4a, 0x3a), "unreachable");
|
||||
});
|
||||
row(ui, "reason", |ui| {
|
||||
ui.add(egui::Label::new(error).wrap());
|
||||
});
|
||||
});
|
||||
}
|
||||
Some(Ok(report)) => agent_table(ui, &socket, report),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_table(ui: &mut egui::Ui, socket: &str, report: &StatusReport) {
|
||||
grid(ui, "about agent", |ui| {
|
||||
row(ui, "socket", |ui| format::copy_field(ui, socket, socket));
|
||||
row(ui, "state", |ui| {
|
||||
ui.colored_label(egui::Color32::from_rgb(0x3c, 0xb0, 0x4a), "connected");
|
||||
});
|
||||
row(ui, "version", |ui| {
|
||||
if report.version.is_empty() {
|
||||
ui.weak("unknown");
|
||||
} else if report.version == VERSION {
|
||||
ui.label(&report.version);
|
||||
} else {
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(0xd8, 0x9a, 0x2a),
|
||||
format!("{} (client is {VERSION})", report.version),
|
||||
);
|
||||
}
|
||||
});
|
||||
if !report.protocols.is_empty() {
|
||||
row(ui, "protocols", |ui| {
|
||||
ui.label(report.protocols.join(" · "));
|
||||
});
|
||||
}
|
||||
row(ui, "endpoint id", |ui| {
|
||||
format::copy_field(ui, &format::short(&report.endpoint_id), &report.endpoint_id);
|
||||
});
|
||||
row(ui, "hostname", |ui| {
|
||||
ui.label(&report.hostname);
|
||||
});
|
||||
if !report.program.is_empty() {
|
||||
row(ui, "binary", |ui| {
|
||||
format::copy_field(ui, &report.program, &report.program);
|
||||
});
|
||||
}
|
||||
if !report.state_dir.is_empty() {
|
||||
row(ui, "state dir", |ui| {
|
||||
format::copy_field(ui, &report.state_dir, &report.state_dir);
|
||||
});
|
||||
row(ui, "cache dir", |ui| {
|
||||
format::copy_field(ui, &report.cache_dir, &report.cache_dir);
|
||||
});
|
||||
}
|
||||
row(ui, "interface", |ui| match &report.privilege {
|
||||
PrivilegeReport::Unknown => {
|
||||
ui.weak("unknown");
|
||||
}
|
||||
PrivilegeReport::Available => {
|
||||
ui.label("can be managed (CAP_NET_ADMIN)");
|
||||
}
|
||||
PrivilegeReport::Missing(_) => {
|
||||
ui.colored_label(egui::Color32::from_rgb(0xd8, 0x9a, 0x2a), "no privileges");
|
||||
}
|
||||
PrivilegeReport::Unsupported => {
|
||||
ui.label("not supported on this platform");
|
||||
}
|
||||
});
|
||||
row(ui, "cache", |ui| {
|
||||
ui.label(if report.cache_healthy {
|
||||
"usable"
|
||||
} else {
|
||||
"unavailable"
|
||||
});
|
||||
});
|
||||
row(ui, "listening", |ui| {
|
||||
ui.label(report.bound_sockets.join(", "));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn section(ui: &mut egui::Ui, title: &str) {
|
||||
ui.label(egui::RichText::new(title).strong());
|
||||
}
|
||||
|
||||
fn grid(ui: &mut egui::Ui, id: &str, add: impl FnOnce(&mut egui::Ui)) {
|
||||
egui::Grid::new(id)
|
||||
.num_columns(2)
|
||||
.spacing([12.0, 3.0])
|
||||
.show(ui, add);
|
||||
}
|
||||
|
||||
/// One label/value line of a table.
|
||||
fn row(ui: &mut egui::Ui, label: &str, value: impl FnOnce(&mut egui::Ui)) {
|
||||
ui.weak(label);
|
||||
value(ui);
|
||||
ui.end_row();
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! The client side: a background worker that talks to the agent over the local
|
||||
//! control socket, off the UI thread.
|
||||
//!
|
||||
//! The UI never blocks on IO. A worker on a tokio runtime polls the agent for
|
||||
//! status on an interval and runs the commands the UI sends (join, leave,
|
||||
//! activate, broadcast), writing the latest snapshot into shared state and
|
||||
//! asking egui to repaint. The whole thing is a thin layer over
|
||||
//! [`tsunagi::ipc`]; no protocol is re-implemented here.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eframe::egui;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use tsunagi::ipc::{self, StatusReport};
|
||||
|
||||
/// How often the worker refreshes status while idle.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// A request from the UI to the agent.
|
||||
pub(crate) enum Command {
|
||||
/// Stop serving a network, or start serving it again.
|
||||
SetActive { network_id: String, active: bool },
|
||||
/// Turn local broadcast participation on or off for a network.
|
||||
SetBroadcast { network_id: String, enabled: bool },
|
||||
/// Offer this device as an exit node in a network, or stop.
|
||||
SetExitOffer { network_id: String, enabled: bool },
|
||||
/// Send all this device's internet traffic through a member of a
|
||||
/// network, or (`None`) back the ordinary way.
|
||||
SetExitNode {
|
||||
network_id: String,
|
||||
peer: Option<String>,
|
||||
},
|
||||
/// Join a network, or start one already configured.
|
||||
Join { name: String, secret: String },
|
||||
/// Leave a network entirely.
|
||||
Leave { network_id: String },
|
||||
/// Change the hostname this device announces.
|
||||
SetHostname(String),
|
||||
/// Fetch a network's shared secret and place it on the clipboard.
|
||||
CopySecret { network_id: String },
|
||||
}
|
||||
|
||||
/// The latest the worker knows, read by the UI each frame.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Snapshot {
|
||||
/// The last status, or the reason it could not be read (agent down, no
|
||||
/// permission on the socket, …).
|
||||
pub status: Option<Result<StatusReport, String>>,
|
||||
/// The outcome of the most recent command, for a transient banner.
|
||||
pub last_action: Option<Result<String, String>>,
|
||||
/// Whether a command is currently being executed.
|
||||
pub busy: bool,
|
||||
/// Bumped each time `status` is refreshed, so the UI can tell new data from
|
||||
/// a repaint of the same data and sample traffic rates only on change.
|
||||
pub generation: u64,
|
||||
/// When this status was read, for computing rates between refreshes.
|
||||
pub at: Option<Instant>,
|
||||
/// The socket the last request went to.
|
||||
pub socket: PathBuf,
|
||||
}
|
||||
|
||||
/// Handle the UI holds: send commands, read the latest snapshot.
|
||||
pub(crate) struct AgentClient {
|
||||
tx: mpsc::UnboundedSender<Command>,
|
||||
shared: Arc<Mutex<Snapshot>>,
|
||||
/// A secret fetched for the clipboard, taken by the UI thread which owns
|
||||
/// the clipboard; kept out of [`Snapshot`] so it is copied once, not every
|
||||
/// frame, and never travels in the cloned snapshot.
|
||||
pending_copy: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl AgentClient {
|
||||
/// Starts the worker on `runtime`, reporting to `ctx` (for repaints).
|
||||
pub(crate) fn spawn(runtime: &tokio::runtime::Handle, ctx: egui::Context) -> Self {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let shared = Arc::new(Mutex::new(Snapshot {
|
||||
socket: resolve_socket(),
|
||||
..Snapshot::default()
|
||||
}));
|
||||
let pending_copy = Arc::new(Mutex::new(None));
|
||||
runtime.spawn(worker(
|
||||
rx,
|
||||
Arc::clone(&shared),
|
||||
Arc::clone(&pending_copy),
|
||||
ctx,
|
||||
));
|
||||
Self {
|
||||
tx,
|
||||
shared,
|
||||
pending_copy,
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes any secret the worker fetched for the clipboard.
|
||||
pub(crate) fn take_pending_copy(&self) -> Option<String> {
|
||||
lock(&self.pending_copy).take()
|
||||
}
|
||||
|
||||
/// The control socket this client talks to, as last resolved.
|
||||
pub(crate) fn socket(&self) -> PathBuf {
|
||||
lock(&self.shared).socket.clone()
|
||||
}
|
||||
|
||||
/// Sends a command; a dropped worker simply means nothing happens.
|
||||
pub(crate) fn send(&self, command: Command) {
|
||||
let _ = self.tx.send(command);
|
||||
}
|
||||
|
||||
/// A copy of the latest snapshot.
|
||||
pub(crate) fn snapshot(&self) -> Snapshot {
|
||||
lock(&self.shared).clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The worker loop: refresh on an interval and whenever a command runs.
|
||||
async fn worker(
|
||||
mut rx: mpsc::UnboundedReceiver<Command>,
|
||||
shared: Arc<Mutex<Snapshot>>,
|
||||
pending_copy: Arc<Mutex<Option<String>>>,
|
||||
ctx: egui::Context,
|
||||
) {
|
||||
refresh(&shared, &ctx).await;
|
||||
let mut tick = tokio::time::interval(POLL_INTERVAL);
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tick.tick() => {
|
||||
refresh(&shared, &ctx).await;
|
||||
}
|
||||
command = rx.recv() => {
|
||||
let Some(command) = command else { return };
|
||||
run(&resolve_socket(), command, &shared, &pending_copy, &ctx).await;
|
||||
refresh(&shared, &ctx).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads status and stores it, mapping any failure to a human string.
|
||||
///
|
||||
/// The socket is looked for again each time: a window opened before the agent
|
||||
/// was started would otherwise keep asking the per-user path for ever, while
|
||||
/// the service came up at the system one.
|
||||
async fn refresh(shared: &Arc<Mutex<Snapshot>>, ctx: &egui::Context) {
|
||||
let socket = resolve_socket();
|
||||
let status = ipc::request_status(&socket)
|
||||
.await
|
||||
.map_err(|err| err.to_string());
|
||||
{
|
||||
let mut guard = lock(shared);
|
||||
guard.status = Some(status);
|
||||
guard.generation = guard.generation.wrapping_add(1);
|
||||
guard.at = Some(Instant::now());
|
||||
guard.socket = socket;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Runs one command and records its outcome.
|
||||
async fn run(
|
||||
socket: &std::path::Path,
|
||||
command: Command,
|
||||
shared: &Arc<Mutex<Snapshot>>,
|
||||
pending_copy: &Arc<Mutex<Option<String>>>,
|
||||
ctx: &egui::Context,
|
||||
) {
|
||||
lock(shared).busy = true;
|
||||
ctx.request_repaint();
|
||||
|
||||
let outcome: Result<String, String> = match command {
|
||||
Command::SetActive { network_id, active } => ipc::set_active(socket, &network_id, active)
|
||||
.await
|
||||
.map(|report| {
|
||||
let state = if report.active { "started" } else { "stopped" };
|
||||
format!("{} {}", state, report.name)
|
||||
})
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::SetBroadcast {
|
||||
network_id,
|
||||
enabled,
|
||||
} => ipc::set_broadcast(socket, &network_id, enabled)
|
||||
.await
|
||||
.map(|on| format!("broadcast {}", if on { "on" } else { "off" }))
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::SetExitOffer {
|
||||
network_id,
|
||||
enabled,
|
||||
} => ipc::set_exit_offer(socket, &network_id, enabled)
|
||||
.await
|
||||
.map(|on| format!("exit node {}", if on { "on" } else { "off" }))
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::SetExitNode { network_id, peer } => {
|
||||
ipc::set_exit_node(socket, &network_id, peer.as_deref())
|
||||
.await
|
||||
.map(|peer| match peer {
|
||||
Some(_) => "now using the exit node".to_string(),
|
||||
None => "stopped using an exit node".to_string(),
|
||||
})
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
Command::Join { name, secret } => ipc::join_network(socket, &name, &secret)
|
||||
.await
|
||||
.map(|report| format!("joined {}", report.name))
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::Leave { network_id } => ipc::leave_network(socket, &network_id)
|
||||
.await
|
||||
.map(|report| format!("left {}", report.name))
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::SetHostname(name) => ipc::set_hostname(socket, &name)
|
||||
.await
|
||||
.map(|accepted| format!("hostname: {accepted}"))
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::CopySecret { network_id } => {
|
||||
match ipc::network_secret(socket, &network_id).await {
|
||||
// The secret goes only to the clipboard slot, never into the
|
||||
// banner text or any log line.
|
||||
Ok(secret) => {
|
||||
*lock(pending_copy) = Some(secret);
|
||||
Ok("secret copied to clipboard".to_string())
|
||||
}
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut guard = lock(shared);
|
||||
guard.busy = false;
|
||||
guard.last_action = if matches!(&outcome, Ok(message) if message.is_empty()) {
|
||||
None
|
||||
} else {
|
||||
Some(outcome)
|
||||
};
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Where the GUI looks for the agent: `TSUNAGI_CONTROL_SOCKET` if set, else the
|
||||
/// platform default (the system service's path).
|
||||
pub(crate) fn resolve_socket() -> PathBuf {
|
||||
if let Some(path) = std::env::var_os("TSUNAGI_CONTROL_SOCKET") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
ipc::default_control_socket_path()
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! The eframe application behind the window: the main view, the per-network
|
||||
//! devices windows, and the agent worker.
|
||||
//!
|
||||
//! It runs in its own process, started by the tray when the user chooses Open
|
||||
//! and gone when the window is closed. The tray, which has to outlive the
|
||||
//! window, lives in the other process; see [`crate::tray`].
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::about;
|
||||
use crate::agent::AgentClient;
|
||||
use crate::devices;
|
||||
use crate::stats::Traffic;
|
||||
use crate::ui::{self, UiState};
|
||||
|
||||
/// The window's state.
|
||||
pub(crate) struct App {
|
||||
agent: AgentClient,
|
||||
state: UiState,
|
||||
/// Traffic rates derived from successive snapshots.
|
||||
traffic: Traffic,
|
||||
/// The snapshot generation last folded into `traffic`.
|
||||
sampled: u64,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Builds the app: resolves the socket and starts the worker.
|
||||
pub(crate) fn new(
|
||||
cc: &eframe::CreationContext<'_>,
|
||||
runtime: tokio::runtime::Handle,
|
||||
) -> Result<Self, String> {
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
// Add the Phosphor icon glyphs to the font set so the icon buttons
|
||||
// render instead of showing as tofu.
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
||||
ctx.set_fonts(fonts);
|
||||
|
||||
let agent = AgentClient::spawn(&runtime, ctx);
|
||||
Ok(Self {
|
||||
agent,
|
||||
state: UiState::default(),
|
||||
traffic: Traffic::default(),
|
||||
sampled: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let snapshot = self.agent.snapshot();
|
||||
if snapshot.generation != self.sampled
|
||||
&& let (Some(Ok(report)), Some(at)) = (&snapshot.status, snapshot.at)
|
||||
{
|
||||
self.traffic.observe(report, at);
|
||||
self.sampled = snapshot.generation;
|
||||
}
|
||||
|
||||
ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic);
|
||||
self.show_devices_windows(ctx, &snapshot);
|
||||
self.show_about_window(ctx, &snapshot);
|
||||
|
||||
// A secret the worker fetched for the clipboard: copy it here, on the
|
||||
// UI thread that owns the clipboard, exactly once.
|
||||
if let Some(secret) = self.agent.take_pending_copy() {
|
||||
ctx.copy_text(secret);
|
||||
}
|
||||
|
||||
// Keep ticking so fresh status is picked up.
|
||||
ctx.request_repaint_after(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Renders the About window while it is open.
|
||||
fn show_about_window(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) {
|
||||
if !self.state.about_open {
|
||||
return;
|
||||
}
|
||||
let builder = egui::ViewportBuilder::default()
|
||||
.with_title("About tsunagi")
|
||||
.with_inner_size([560.0, 520.0])
|
||||
.with_min_inner_size([380.0, 280.0]);
|
||||
let socket = self.agent.socket();
|
||||
let keep = ctx.show_viewport_immediate(
|
||||
egui::ViewportId::from_hash_of("about"),
|
||||
builder,
|
||||
|vctx, _class| {
|
||||
about::show(vctx, &socket, snapshot);
|
||||
!vctx.input(|i| i.viewport().close_requested())
|
||||
},
|
||||
);
|
||||
if !keep {
|
||||
self.state.about_open = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders one separate window per open network-devices view.
|
||||
fn show_devices_windows(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) {
|
||||
let Some(Ok(report)) = &snapshot.status else {
|
||||
return;
|
||||
};
|
||||
let open: Vec<String> = self.state.open_devices.iter().cloned().collect();
|
||||
for id in open {
|
||||
let Some(network) = report.networks.iter().find(|n| n.network_id == id) else {
|
||||
// The network is gone (left/forgotten); close its window.
|
||||
self.state.open_devices.remove(&id);
|
||||
continue;
|
||||
};
|
||||
let viewport_id = egui::ViewportId::from_hash_of(("devices", &id));
|
||||
let builder = egui::ViewportBuilder::default()
|
||||
.with_title(devices::title(network))
|
||||
.with_inner_size([940.0, 540.0])
|
||||
.with_min_inner_size([480.0, 320.0]);
|
||||
let agent = &self.agent;
|
||||
let traffic = &self.traffic;
|
||||
// The window shares the single unit setting, so its toggle and the
|
||||
// main window's stay in step.
|
||||
let unit = &mut self.state.unit;
|
||||
let confirm = &mut self.state.exit_confirm;
|
||||
let keep = ctx.show_viewport_immediate(viewport_id, builder, |vctx, _class| {
|
||||
devices::show(
|
||||
vctx,
|
||||
agent,
|
||||
unit,
|
||||
confirm,
|
||||
network,
|
||||
&report.endpoint_id,
|
||||
traffic,
|
||||
);
|
||||
!vctx.input(|i| i.viewport().close_requested())
|
||||
});
|
||||
if !keep {
|
||||
self.state.open_devices.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
//! The per-network devices window: full detail about one network — its
|
||||
//! settings and counters, and one table of every device, online first, with
|
||||
//! inline traffic plots for the connected ones.
|
||||
//!
|
||||
//! Rendered in a separate OS window (an egui viewport) opened from a tile in
|
||||
//! the main window.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use tsunagi::ipc::{NetworkReport, OverlayPeerReport};
|
||||
|
||||
use crate::agent::{AgentClient, Command};
|
||||
use crate::exit;
|
||||
use crate::format;
|
||||
use crate::stats::{Traffic, Unit};
|
||||
|
||||
/// A readable title for a network's window.
|
||||
pub(crate) fn title(network: &NetworkReport) -> String {
|
||||
format!("tsunagi · {}", network.name)
|
||||
}
|
||||
|
||||
/// One row of the device table, built from a tunnel and/or a signed member.
|
||||
struct Device<'a> {
|
||||
online: bool,
|
||||
/// The last hostname seen for it, kept while it is offline.
|
||||
hostname: Option<&'a str>,
|
||||
/// What clicking the hostname copies: `hostname.network`, so it resolves.
|
||||
copy: Option<String>,
|
||||
/// The full endpoint id, which clicking the short form copies.
|
||||
id: &'a str,
|
||||
proto: &'a str,
|
||||
address: Option<String>,
|
||||
handshake_secs: Option<u64>,
|
||||
overlay: Option<&'a OverlayPeerReport>,
|
||||
/// It is connected and offers to be an exit node.
|
||||
exit_node: bool,
|
||||
/// It is the exit node this device sends its internet traffic through.
|
||||
in_use: bool,
|
||||
}
|
||||
|
||||
/// An exit-node choice the user has to confirm: all their traffic goes through
|
||||
/// somebody else's device, or stops doing so.
|
||||
pub(crate) struct ExitConfirm {
|
||||
network_id: String,
|
||||
peer_id: String,
|
||||
name: String,
|
||||
/// Stop using it, rather than start.
|
||||
stop: bool,
|
||||
}
|
||||
|
||||
impl Device<'_> {
|
||||
fn name(&self) -> String {
|
||||
self.hostname
|
||||
.map_or_else(|| format::short(self.id), str::to_string)
|
||||
}
|
||||
|
||||
fn sort_key(&self) -> String {
|
||||
self.hostname.unwrap_or(self.id).to_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
/// The first characters of an endpoint id, enough to tell devices apart.
|
||||
fn short_id(id: &str) -> String {
|
||||
id.chars().take(5).collect()
|
||||
}
|
||||
|
||||
/// The device cell: the hostname and, in brackets, the start of the id.
|
||||
/// Clicking either copies its own full value.
|
||||
fn name_cell(ui: &mut egui::Ui, device: &Device<'_>) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
match (device.hostname, &device.copy) {
|
||||
(Some(hostname), Some(copy)) => {
|
||||
format::copy_field(ui, hostname, copy);
|
||||
format::copy_field(ui, &format!("({})", short_id(device.id)), device.id);
|
||||
}
|
||||
_ => format::copy_field(ui, &short_id(device.id), device.id),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Draws the window body into its viewport.
|
||||
pub(crate) fn show(
|
||||
ctx: &egui::Context,
|
||||
agent: &AgentClient,
|
||||
unit: &mut Unit,
|
||||
confirm: &mut Option<ExitConfirm>,
|
||||
network: &NetworkReport,
|
||||
own_id: &str,
|
||||
traffic: &Traffic,
|
||||
) {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&network.name);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.selectable_value(unit, Unit::Bytes, "bytes");
|
||||
ui.selectable_value(unit, Unit::Packets, "pkts");
|
||||
ui.label("show:");
|
||||
});
|
||||
});
|
||||
// A copy of the chosen unit for the read-only rendering below.
|
||||
let unit = *unit;
|
||||
|
||||
summary(ui, network);
|
||||
ui.add_space(6.0);
|
||||
|
||||
ui.label(egui::RichText::new("traffic").strong());
|
||||
format::sparkline(ui, traffic.network(&network.network_id), unit, 48.0);
|
||||
ui.add_space(8.0);
|
||||
|
||||
let devices = devices(network, own_id);
|
||||
let online = devices.iter().filter(|d| d.online).count();
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"devices ({} online / {} known)",
|
||||
online,
|
||||
devices.len()
|
||||
))
|
||||
.strong(),
|
||||
);
|
||||
egui::ScrollArea::both()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
device_table(ui, unit, network, &devices, traffic, confirm);
|
||||
});
|
||||
});
|
||||
confirm_exit(ctx, agent, confirm, &network.network_id);
|
||||
}
|
||||
|
||||
/// The confirmation for an exit-node choice, drawn in the window it was
|
||||
/// made in. Everything behind it is blocked until it is answered.
|
||||
fn confirm_exit(
|
||||
ctx: &egui::Context,
|
||||
agent: &AgentClient,
|
||||
confirm: &mut Option<ExitConfirm>,
|
||||
network_id: &str,
|
||||
) {
|
||||
let Some(pending) = confirm.as_ref().filter(|c| c.network_id == network_id) else {
|
||||
return;
|
||||
};
|
||||
egui::Area::new(egui::Id::new("exit confirm backdrop"))
|
||||
.order(egui::Order::Middle)
|
||||
.fixed_pos(egui::Pos2::ZERO)
|
||||
.show(ctx, |ui| {
|
||||
let screen = ctx.screen_rect();
|
||||
ui.allocate_response(screen.size(), egui::Sense::click_and_drag());
|
||||
ui.painter()
|
||||
.rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140));
|
||||
});
|
||||
|
||||
let (mut accepted, mut cancelled) = (false, false);
|
||||
egui::Window::new(if pending.stop {
|
||||
"Stop using exit node"
|
||||
} else {
|
||||
"Use as exit node"
|
||||
})
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.set_max_width(380.0);
|
||||
if pending.stop {
|
||||
ui.label(format!("Stop using {} as exit node?", pending.name));
|
||||
ui.label("Your internet traffic will leave this device the ordinary way again.");
|
||||
} else {
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"Send ALL your internet traffic through {}?",
|
||||
pending.name
|
||||
))
|
||||
.strong(),
|
||||
);
|
||||
ui.label(
|
||||
"Everything not on this network will leave through that device, which can \
|
||||
see it.",
|
||||
);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let label = if pending.stop {
|
||||
"Stop"
|
||||
} else {
|
||||
"Use as exit node"
|
||||
};
|
||||
if ui.button(label).clicked() {
|
||||
accepted = true;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
cancelled = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if accepted {
|
||||
agent.send(Command::SetExitNode {
|
||||
network_id: pending.network_id.clone(),
|
||||
peer: (!pending.stop).then(|| pending.peer_id.clone()),
|
||||
});
|
||||
}
|
||||
if accepted || cancelled || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
*confirm = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The network's own settings and counters.
|
||||
fn summary(ui: &mut egui::Ui, network: &NetworkReport) {
|
||||
egui::Grid::new("summary")
|
||||
.num_columns(2)
|
||||
.spacing([12.0, 3.0])
|
||||
.show(ui, |ui| {
|
||||
ui.weak("id");
|
||||
format::copy_field(ui, &network.network_id, &network.network_id);
|
||||
ui.end_row();
|
||||
|
||||
ui.weak("active");
|
||||
ui.label(yes_no(network.active));
|
||||
ui.end_row();
|
||||
ui.weak("broadcast");
|
||||
ui.label(yes_no(network.broadcast));
|
||||
ui.end_row();
|
||||
|
||||
if let Some(range) = &network.range {
|
||||
ui.weak("range");
|
||||
format::copy_field(ui, range, range);
|
||||
ui.end_row();
|
||||
}
|
||||
if let Some(address) = network.overlay.as_ref().and_then(|o| o.address.as_ref()) {
|
||||
ui.weak("this device");
|
||||
format::copy_field(ui, address, address);
|
||||
ui.end_row();
|
||||
}
|
||||
ui.weak("candidates");
|
||||
ui.label(network.candidates.to_string());
|
||||
ui.end_row();
|
||||
ui.weak("relay fwd/via/recv");
|
||||
ui.label(format!(
|
||||
"{} / {} / {}",
|
||||
network.relay_forwarded, network.relay_sent_via, network.relay_received_via
|
||||
));
|
||||
ui.end_row();
|
||||
ui.weak("dial/handshake fails");
|
||||
ui.label(format!(
|
||||
"{} / {}",
|
||||
network.dial_failures, network.handshake_failures
|
||||
));
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
|
||||
/// Every other device this network knows of, from a live tunnel or the signed
|
||||
/// membership. This device is in the roster too but is not one of the others.
|
||||
fn known_ids<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<&'a str> {
|
||||
let overlay_peers = network
|
||||
.overlay
|
||||
.as_ref()
|
||||
.map(|o| o.peers.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let mut ids: Vec<&str> = Vec::new();
|
||||
let candidates = overlay_peers
|
||||
.iter()
|
||||
.map(|p| p.endpoint_id.as_str())
|
||||
.chain(network.peers.iter().map(|p| p.endpoint_id.as_str()))
|
||||
.chain(network.members.iter().map(|m| m.endpoint_id.as_str()))
|
||||
.chain(network.exit.via.as_deref());
|
||||
for id in candidates {
|
||||
if id != own_id && !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// How many other devices this network knows of, connected or not.
|
||||
pub(crate) fn known_count(network: &NetworkReport, own_id: &str) -> usize {
|
||||
known_ids(network, own_id).len()
|
||||
}
|
||||
|
||||
/// Builds the device list: every endpoint known from a live tunnel or the
|
||||
/// signed membership, online ones first.
|
||||
fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<Device<'a>> {
|
||||
// The signed members carry the last name seen, so a device that is away
|
||||
// is still called by it; a live announcement is fresher and wins.
|
||||
let mut hostnames: HashMap<&str, &str> = network
|
||||
.members
|
||||
.iter()
|
||||
.filter_map(|m| m.hostname.as_deref().map(|h| (m.endpoint_id.as_str(), h)))
|
||||
.collect();
|
||||
hostnames.extend(
|
||||
network
|
||||
.peers
|
||||
.iter()
|
||||
.filter_map(|p| p.hostname.as_deref().map(|h| (p.endpoint_id.as_str(), h))),
|
||||
);
|
||||
let overlay_peers = network
|
||||
.overlay
|
||||
.as_ref()
|
||||
.map(|o| o.peers.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
let ids = known_ids(network, own_id);
|
||||
|
||||
let mut devices: Vec<Device> = ids
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let overlay = overlay_peers.iter().find(|p| p.endpoint_id == id);
|
||||
let member = network.members.iter().find(|m| m.endpoint_id == id);
|
||||
let online = overlay.is_some_and(|p| p.handshake_secs_ago.is_some());
|
||||
let hostname = hostnames.get(id).copied();
|
||||
Device {
|
||||
online,
|
||||
hostname,
|
||||
copy: hostname.map(|h| format!("{h}.{}", network.name)),
|
||||
id,
|
||||
proto: overlay.map_or("—", |p| p.protocol.as_str()),
|
||||
address: overlay
|
||||
.and_then(|p| p.address.clone())
|
||||
.or_else(|| member.and_then(|m| m.overlay_address_v4.clone())),
|
||||
handshake_secs: overlay.and_then(|p| p.handshake_secs_ago),
|
||||
overlay,
|
||||
exit_node: online
|
||||
&& network
|
||||
.peers
|
||||
.iter()
|
||||
.any(|p| p.endpoint_id == id && p.exit_node),
|
||||
in_use: network.exit.via.as_deref() == Some(id),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
devices.sort_by(|a, b| {
|
||||
b.online
|
||||
.cmp(&a.online)
|
||||
.then_with(|| a.sort_key().cmp(&b.sort_key()))
|
||||
});
|
||||
devices
|
||||
}
|
||||
|
||||
/// The widest value each column is expected to hold, so the table starts out
|
||||
/// wide enough and does not reflow as numbers grow.
|
||||
const COLUMN_SAMPLES: [&str; 9] = [
|
||||
"",
|
||||
"exit",
|
||||
"tcp-tls",
|
||||
"255.255.255.255",
|
||||
"23h 59m",
|
||||
"99999/99999",
|
||||
"1023.9 MB / 1023.9 MB",
|
||||
"1023.9 kB/s",
|
||||
"",
|
||||
];
|
||||
|
||||
/// Column widths that only ever grow, kept across frames.
|
||||
///
|
||||
/// A grid sizes each column to its widest cell *this frame*, so a value that
|
||||
/// gets shorter — `14.4 MB/s` giving way to `382 pkt/s` — would pull the
|
||||
/// column in and push every cell after it. Remembering the widest seen keeps
|
||||
/// the table where it is.
|
||||
fn column_widths(ui: &egui::Ui, id: egui::Id) -> [f32; 9] {
|
||||
let stored: Option<[f32; 9]> = ui.memory(|m| m.data.get_temp(id));
|
||||
stored.unwrap_or_else(|| {
|
||||
let font = egui::TextStyle::Body.resolve(ui.style());
|
||||
let mut widths = [0.0; 9];
|
||||
for (width, sample) in widths.iter_mut().zip(COLUMN_SAMPLES) {
|
||||
if !sample.is_empty() {
|
||||
*width = ui.fonts(|fonts| {
|
||||
fonts
|
||||
.layout_no_wrap(sample.to_owned(), font.clone(), egui::Color32::WHITE)
|
||||
.size()
|
||||
.x
|
||||
});
|
||||
}
|
||||
}
|
||||
widths
|
||||
})
|
||||
}
|
||||
|
||||
/// One cell, at least as wide as its column has ever been.
|
||||
fn cell(ui: &mut egui::Ui, column: usize, widths: &mut [f32; 9], add: impl FnOnce(&mut egui::Ui)) {
|
||||
let floor = widths[column];
|
||||
let response = ui.scope(|ui| {
|
||||
ui.set_min_width(floor);
|
||||
add(ui);
|
||||
});
|
||||
widths[column] = widths[column].max(response.response.rect.width());
|
||||
}
|
||||
|
||||
/// The exit-node mark of a device: shown when it offers to be one, bright
|
||||
/// for the one in use, and clickable (to confirm) only while that can be done.
|
||||
///
|
||||
/// The one in use stays clickable even when it has gone away, since stopping
|
||||
/// is the only way out of the traffic being blocked.
|
||||
fn exit_cell(
|
||||
ui: &mut egui::Ui,
|
||||
device: &Device<'_>,
|
||||
network_id: &str,
|
||||
confirm: &mut Option<ExitConfirm>,
|
||||
) {
|
||||
if !device.exit_node && !device.in_use {
|
||||
ui.label("");
|
||||
return;
|
||||
}
|
||||
let (color, hint) = if device.in_use {
|
||||
let color = if device.online {
|
||||
exit::GOOD
|
||||
} else {
|
||||
exit::LOUD
|
||||
};
|
||||
(color, "in use as your exit node — click to stop")
|
||||
} else {
|
||||
(
|
||||
ui.visuals().text_color(),
|
||||
"offers to be an exit node — click to send all your internet traffic through it",
|
||||
)
|
||||
};
|
||||
let response = ui
|
||||
.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(egui_phosphor::regular::SIGN_OUT)
|
||||
.size(16.0)
|
||||
.color(color),
|
||||
)
|
||||
.sense(egui::Sense::click()),
|
||||
)
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text(hint);
|
||||
if response.clicked() {
|
||||
*confirm = Some(ExitConfirm {
|
||||
network_id: network_id.to_string(),
|
||||
peer_id: device.id.to_string(),
|
||||
name: device.name(),
|
||||
stop: device.in_use,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One table of all devices: online rows carry live counters and a plot,
|
||||
/// offline rows show what is known (name and address) and dashes.
|
||||
fn device_table(
|
||||
ui: &mut egui::Ui,
|
||||
unit: Unit,
|
||||
network: &NetworkReport,
|
||||
devices: &[Device<'_>],
|
||||
traffic: &Traffic,
|
||||
confirm: &mut Option<ExitConfirm>,
|
||||
) {
|
||||
let network_id = network.network_id.as_str();
|
||||
if devices.is_empty() {
|
||||
ui.weak("none");
|
||||
return;
|
||||
}
|
||||
let widths_id = egui::Id::new(("devices column widths", network_id));
|
||||
let mut widths = column_widths(ui, widths_id);
|
||||
egui::Grid::new("devices")
|
||||
.num_columns(9)
|
||||
.striped(true)
|
||||
.spacing([10.0, 4.0])
|
||||
.show(ui, |ui| {
|
||||
for (column, header) in [
|
||||
"device",
|
||||
"exit",
|
||||
"proto",
|
||||
"address",
|
||||
"handshake",
|
||||
"pkts",
|
||||
"bytes",
|
||||
"rate",
|
||||
"plot",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
cell(ui, column, &mut widths, |ui| {
|
||||
ui.weak(header);
|
||||
});
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
for device in devices {
|
||||
cell(ui, 0, &mut widths, |ui| name_cell(ui, device));
|
||||
cell(ui, 1, &mut widths, |ui| {
|
||||
exit_cell(ui, device, network_id, confirm);
|
||||
});
|
||||
cell(ui, 2, &mut widths, |ui| {
|
||||
ui.label(device.proto);
|
||||
});
|
||||
cell(ui, 3, &mut widths, |ui| match &device.address {
|
||||
Some(address) => format::copy_field(ui, address, address),
|
||||
None => {
|
||||
ui.weak("—");
|
||||
}
|
||||
});
|
||||
cell(ui, 4, &mut widths, |ui| {
|
||||
ui.label(
|
||||
device
|
||||
.handshake_secs
|
||||
.map_or_else(|| "offline".to_string(), format::age),
|
||||
);
|
||||
});
|
||||
|
||||
match device.overlay.filter(|_| device.online) {
|
||||
Some(peer) => {
|
||||
let series = traffic.peer(network_id, &peer.public_key);
|
||||
cell(ui, 5, &mut widths, |ui| {
|
||||
ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets));
|
||||
});
|
||||
cell(ui, 6, &mut widths, |ui| {
|
||||
ui.label(format!(
|
||||
"{} / {}",
|
||||
format::bytes(peer.tx_bytes as f64),
|
||||
format::bytes(peer.rx_bytes as f64)
|
||||
));
|
||||
});
|
||||
cell(ui, 7, &mut widths, |ui| {
|
||||
ui.add(
|
||||
egui::Label::new(format::series_rate(series, unit))
|
||||
.wrap_mode(egui::TextWrapMode::Extend),
|
||||
);
|
||||
});
|
||||
ui.allocate_ui(egui::vec2(100.0, 20.0), |ui| {
|
||||
format::sparkline(ui, series, unit, 18.0);
|
||||
});
|
||||
}
|
||||
None => {
|
||||
for column in 5..8 {
|
||||
cell(ui, column, &mut widths, |ui| {
|
||||
ui.weak("—");
|
||||
});
|
||||
}
|
||||
ui.weak("—");
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
// Everything this agent has moved through the overlay, including
|
||||
// devices that have since gone away.
|
||||
let (mut tx_packets, mut rx_packets, mut tx_bytes, mut rx_bytes) = (0u64, 0, 0, 0);
|
||||
for peer in devices.iter().filter_map(|d| d.overlay) {
|
||||
tx_packets += peer.tx_packets;
|
||||
rx_packets += peer.rx_packets;
|
||||
tx_bytes += peer.tx_bytes;
|
||||
rx_bytes += peer.rx_bytes;
|
||||
}
|
||||
cell(ui, 0, &mut widths, |ui| {
|
||||
ui.strong("total");
|
||||
});
|
||||
for column in 1..5 {
|
||||
cell(ui, column, &mut widths, |ui| {
|
||||
ui.label("");
|
||||
});
|
||||
}
|
||||
cell(ui, 5, &mut widths, |ui| {
|
||||
ui.strong(format!("{tx_packets}/{rx_packets}"));
|
||||
});
|
||||
cell(ui, 6, &mut widths, |ui| {
|
||||
ui.strong(format!(
|
||||
"{} / {}",
|
||||
format::bytes(tx_bytes as f64),
|
||||
format::bytes(rx_bytes as f64)
|
||||
));
|
||||
});
|
||||
cell(ui, 7, &mut widths, |ui| {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(format::series_rate(traffic.network(network_id), unit))
|
||||
.strong(),
|
||||
)
|
||||
.wrap_mode(egui::TextWrapMode::Extend),
|
||||
);
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
ui.memory_mut(|m| m.data.insert_temp(widths_id, widths));
|
||||
}
|
||||
|
||||
fn yes_no(value: bool) -> &'static str {
|
||||
if value { "yes" } else { "no" }
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Exit nodes as the windows show them: what to call one, and what is wrong
|
||||
//! with the ones in play.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use tsunagi::ipc::NetworkReport;
|
||||
|
||||
use crate::format;
|
||||
|
||||
/// An exit node that is working.
|
||||
pub(crate) const GOOD: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a);
|
||||
/// Something about exit nodes the user has to look at.
|
||||
pub(crate) const LOUD: egui::Color32 = egui::Color32::from_rgb(0xe0, 0x4a, 0x3c);
|
||||
|
||||
/// What to run to turn kernel forwarding on, which the agent never does.
|
||||
pub(crate) const FORWARDING_COMMAND: &str = "sudo sysctl -w net.ipv4.ip_forward=1";
|
||||
|
||||
/// One thing wrong, loud enough to read at a glance.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Warning {
|
||||
pub(crate) text: String,
|
||||
/// A command the user will want, copied by clicking the warning.
|
||||
pub(crate) copy: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// What to call a device in a network, even while it is away.
|
||||
pub(crate) fn name_of(network: &NetworkReport, id: &str) -> String {
|
||||
network
|
||||
.peers
|
||||
.iter()
|
||||
.find(|peer| peer.endpoint_id == id)
|
||||
.and_then(|peer| peer.hostname.clone())
|
||||
.or_else(|| {
|
||||
network
|
||||
.members
|
||||
.iter()
|
||||
.find(|member| member.endpoint_id == id)
|
||||
.and_then(|member| member.hostname.clone())
|
||||
})
|
||||
.unwrap_or_else(|| format::short(id))
|
||||
}
|
||||
|
||||
/// Everything wrong with a network's exit-node settings, most urgent first.
|
||||
pub(crate) fn warnings(network: &NetworkReport) -> Vec<Warning> {
|
||||
let exit = &network.exit;
|
||||
let mut out = Vec::new();
|
||||
|
||||
if let Some(via) = &exit.via {
|
||||
let name = name_of(network, via);
|
||||
if !exit.via_online {
|
||||
out.push(Warning {
|
||||
text: format!(
|
||||
"exit node {name} is offline — your internet traffic is blocked until it \
|
||||
returns; stop using it from the devices window"
|
||||
),
|
||||
copy: None,
|
||||
});
|
||||
} else {
|
||||
match &exit.client_rules {
|
||||
Some(rules) if rules.ok => {}
|
||||
Some(rules) => out.push(Warning {
|
||||
text: format!(
|
||||
"the routes to exit node {name} are incomplete: {}",
|
||||
rules.detail
|
||||
),
|
||||
copy: None,
|
||||
}),
|
||||
None => out.push(Warning {
|
||||
text: format!("the routes to exit node {name} are not installed"),
|
||||
copy: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if exit.offering {
|
||||
match &exit.offer_rules {
|
||||
Some(rules) if !rules.ok => out.push(Warning {
|
||||
text: format!("exit node: its rules are incomplete: {}", rules.detail),
|
||||
copy: None,
|
||||
}),
|
||||
None => out.push(Warning {
|
||||
text: "exit node: its rules are not installed yet".to_string(),
|
||||
copy: None,
|
||||
}),
|
||||
Some(_) => {}
|
||||
}
|
||||
if exit.forwarding == Some(false) {
|
||||
out.push(Warning {
|
||||
text: format!("exit node: kernel forwarding is OFF — run: {FORWARDING_COMMAND}"),
|
||||
copy: Some(FORWARDING_COMMAND),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tsunagi::ipc::{ExitReport, PeerReport, RuleSetReport};
|
||||
|
||||
fn network(exit: ExitReport) -> NetworkReport {
|
||||
NetworkReport {
|
||||
peers: vec![PeerReport {
|
||||
endpoint_id: "abcdef0123456789".into(),
|
||||
hostname: Some("music".into()),
|
||||
exit_node: true,
|
||||
..Default::default()
|
||||
}],
|
||||
exit,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn ok() -> Option<RuleSetReport> {
|
||||
Some(RuleSetReport {
|
||||
ok: true,
|
||||
detail: "ok".into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_with_no_exit_settings_has_nothing_to_warn_about() {
|
||||
assert!(warnings(&network(ExitReport::default())).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lost_exit_node_says_the_traffic_is_blocked() {
|
||||
let found = warnings(&network(ExitReport {
|
||||
via: Some("abcdef0123456789".into()),
|
||||
via_online: false,
|
||||
client_rules: ok(),
|
||||
..Default::default()
|
||||
}));
|
||||
assert_eq!(found.len(), 1);
|
||||
assert!(
|
||||
found[0].text.contains("music is offline"),
|
||||
"{}",
|
||||
found[0].text
|
||||
);
|
||||
assert!(found[0].text.contains("blocked"), "{}", found[0].text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_working_exit_node_is_quiet_and_forwarding_off_gives_the_command() {
|
||||
let working = ExitReport {
|
||||
via: Some("abcdef0123456789".into()),
|
||||
via_online: true,
|
||||
client_rules: ok(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(warnings(&network(working)).is_empty());
|
||||
|
||||
let offering = warnings(&network(ExitReport {
|
||||
offering: true,
|
||||
offer_rules: ok(),
|
||||
forwarding: Some(false),
|
||||
..Default::default()
|
||||
}));
|
||||
assert_eq!(offering.len(), 1);
|
||||
assert_eq!(offering[0].copy, Some(FORWARDING_COMMAND));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Formatting and small shared widgets: humanized ages, byte sizes and rates,
|
||||
//! a fixed-size sparkline, and a click-to-copy field with a green-flash feedback.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::stats::{Series, Unit};
|
||||
|
||||
/// Sparkline accent.
|
||||
const ACCENT: egui::Color32 = egui::Color32::from_rgb(0x2f, 0x80, 0xd8);
|
||||
/// The green a field flashes to when its value is copied.
|
||||
const COPIED_GREEN: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a);
|
||||
/// How long the copy flash takes to fade back to normal.
|
||||
const FLASH: Duration = Duration::from_secs(1);
|
||||
|
||||
/// A short, readable prefix of a long identifier (char-safe, no panic).
|
||||
pub(crate) fn short(id: &str) -> String {
|
||||
const KEEP: usize = 12;
|
||||
if id.chars().count() <= KEEP {
|
||||
id.to_string()
|
||||
} else {
|
||||
format!("{}…", id.chars().take(KEEP).collect::<String>())
|
||||
}
|
||||
}
|
||||
|
||||
/// A duration in seconds as a compact age (`5s`, `12m`, `3h 20m`, `2d 4h`).
|
||||
pub(crate) fn age(secs: u64) -> String {
|
||||
if secs < 60 {
|
||||
format!("{secs}s")
|
||||
} else if secs < 3_600 {
|
||||
format!("{}m", secs / 60)
|
||||
} else if secs < 86_400 {
|
||||
format!("{}h {}m", secs / 3_600, (secs % 3_600) / 60)
|
||||
} else {
|
||||
format!("{}d {}h", secs / 86_400, (secs % 86_400) / 3_600)
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte count in binary units.
|
||||
pub(crate) fn bytes(n: f64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"];
|
||||
let mut value = n.max(0.0);
|
||||
let mut unit = 0;
|
||||
while value >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
value /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{} B", value as u64)
|
||||
} else {
|
||||
format!("{value:.1} {}", UNITS[unit])
|
||||
}
|
||||
}
|
||||
|
||||
/// A rate in the chosen unit, per second.
|
||||
pub(crate) fn rate(value: f32, unit: Unit) -> String {
|
||||
match unit {
|
||||
Unit::Packets => {
|
||||
if value >= 1000.0 {
|
||||
format!("{:.1}k pkt/s", value / 1000.0)
|
||||
} else {
|
||||
format!("{value:.0} pkt/s")
|
||||
}
|
||||
}
|
||||
Unit::Bytes => format!("{}/s", bytes(f64::from(value))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current rate of a series as a string, or a dash when unsampled.
|
||||
pub(crate) fn series_rate(series: Option<&Series>, unit: Unit) -> String {
|
||||
series.map_or_else(|| "—".to_string(), |s| rate(s.rate(unit), unit))
|
||||
}
|
||||
|
||||
/// A label whose text is `display` and which copies `value` to the clipboard
|
||||
/// when clicked. Feedback is a green flash that fades back over a second — the
|
||||
/// text itself never changes, so a dense table does not reflow on a copy.
|
||||
pub(crate) fn copy_field(ui: &mut egui::Ui, display: &str, value: &str) {
|
||||
let id = ui.make_persistent_id(("copy", value, display));
|
||||
let copied_at: Option<Instant> = ui.memory(|memory| memory.data.get_temp(id));
|
||||
// 1.0 right after the click, fading to 0.0 a second later.
|
||||
let flash = copied_at.and_then(|at| {
|
||||
let elapsed = at.elapsed().as_secs_f32();
|
||||
let span = FLASH.as_secs_f32();
|
||||
(elapsed < span).then(|| 1.0 - elapsed / span)
|
||||
});
|
||||
|
||||
let text = match flash {
|
||||
Some(factor) => {
|
||||
let base = ui.visuals().text_color();
|
||||
egui::RichText::new(display).color(lerp_color(base, COPIED_GREEN, factor))
|
||||
}
|
||||
None => egui::RichText::new(display),
|
||||
};
|
||||
let response = ui
|
||||
.add(egui::Label::new(text).sense(egui::Sense::click()))
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text("click to copy");
|
||||
if response.clicked() {
|
||||
ui.ctx().copy_text(value.to_owned());
|
||||
ui.memory_mut(|memory| memory.data.insert_temp(id, Instant::now()));
|
||||
}
|
||||
if flash.is_some() {
|
||||
// Animate the fade smoothly until it completes.
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear blend between two colours, `t` in `0.0..=1.0` toward `b`.
|
||||
fn lerp_color(a: egui::Color32, b: egui::Color32, t: f32) -> egui::Color32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let mix = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round() as u8;
|
||||
egui::Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b()))
|
||||
}
|
||||
|
||||
/// A fixed-height sparkline of a series' recent rate, with the peak (top-left)
|
||||
/// and current value (bottom-right) overlaid. The space is always reserved —
|
||||
/// even with no data — so the layout never jumps when data arrives.
|
||||
pub(crate) fn sparkline(ui: &mut egui::Ui, series: Option<&Series>, unit: Unit, height: f32) {
|
||||
let width = ui.available_width();
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
|
||||
let painter = ui.painter_at(rect);
|
||||
let visuals = ui.visuals();
|
||||
painter.rect_filled(rect, 2.0, visuals.extreme_bg_color);
|
||||
|
||||
let Some(series) = series else {
|
||||
painter.text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"no data yet",
|
||||
egui::FontId::proportional(10.0),
|
||||
visuals.weak_text_color(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let history = series.history(unit);
|
||||
let peak = history.iter().copied().fold(1.0_f32, f32::max);
|
||||
if history.len() >= 2 {
|
||||
let count = history.len();
|
||||
let points: Vec<egui::Pos2> = history
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, value)| {
|
||||
let x = rect.left() + rect.width() * (i as f32 / (count - 1) as f32);
|
||||
let y = rect.bottom() - 2.0 - (rect.height() - 12.0) * (value / peak);
|
||||
egui::pos2(x, y)
|
||||
})
|
||||
.collect();
|
||||
painter.add(egui::Shape::line(
|
||||
points,
|
||||
egui::Stroke::new(1.5_f32, ACCENT),
|
||||
));
|
||||
}
|
||||
|
||||
painter.text(
|
||||
rect.left_top() + egui::vec2(4.0, 2.0),
|
||||
egui::Align2::LEFT_TOP,
|
||||
format!("peak {}", rate(peak, unit)),
|
||||
egui::FontId::proportional(9.0),
|
||||
visuals.weak_text_color(),
|
||||
);
|
||||
painter.text(
|
||||
rect.right_bottom() + egui::vec2(-4.0, -2.0),
|
||||
egui::Align2::RIGHT_BOTTOM,
|
||||
rate(series.rate(unit), unit),
|
||||
egui::FontId::proportional(10.0),
|
||||
visuals.strong_text_color(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Tray application for managing a running tsunagi agent.
|
||||
//!
|
||||
//! A tray icon and menu (Open, Quit) that opens a small window (networks, their
|
||||
//! peers and addresses, join/leave, per-network active and broadcast switches).
|
||||
//! There is no window until Open, and closing it leaves the icon. The window
|
||||
//! is a thin client over the agent's local control socket — the same interface
|
||||
//! the CLI uses.
|
||||
|
||||
// Nothing here is a library.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod about;
|
||||
mod agent;
|
||||
mod app;
|
||||
mod devices;
|
||||
mod exit;
|
||||
mod format;
|
||||
mod stats;
|
||||
mod tray;
|
||||
mod ui;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
/// The argument that makes this binary the window rather than the tray.
|
||||
const WINDOW_ARG: &str = "--window";
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
|
||||
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
|
||||
|
||||
// Two processes from one binary. The tray is what stays; the window is
|
||||
// started by it and ends when it is closed. A window that merely hides is
|
||||
// not something every platform can do — Wayland has no such thing — and a
|
||||
// hidden one that stays is worse than none.
|
||||
if std::env::args().any(|arg| arg == WINDOW_ARG) {
|
||||
window()
|
||||
} else {
|
||||
tray::run()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the window until it is closed.
|
||||
fn window() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let handle = runtime.handle().clone();
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
// A fixed, small window; the whole layout targets this size.
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("tsunagi")
|
||||
.with_app_id("tsunagi")
|
||||
.with_inner_size([360.0, 600.0])
|
||||
.with_resizable(false),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"tsunagi",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
App::new(cc, handle.clone())
|
||||
.map(|app| Box::new(app) as Box<dyn eframe::App>)
|
||||
.map_err(std::convert::Into::into)
|
||||
}),
|
||||
)?;
|
||||
|
||||
// The window loop has ended; let the runtime wind down.
|
||||
drop(runtime);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Traffic rates derived from successive status snapshots.
|
||||
//!
|
||||
//! The agent reports cumulative packet and byte counters, not rates. The GUI
|
||||
//! samples them each time a fresh snapshot arrives and turns the deltas into
|
||||
//! per-second rates plus short rolling histories for sparklines. One series per
|
||||
//! key: a network total, and each peer within it. Both packets and bytes are
|
||||
//! tracked so the UI can switch between them.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::time::Instant;
|
||||
|
||||
use tsunagi::ipc::StatusReport;
|
||||
|
||||
/// How many rate samples to keep for a sparkline.
|
||||
const HISTORY: usize = 60;
|
||||
|
||||
/// Which quantity a rate or history is of.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Unit {
|
||||
/// Packets per second.
|
||||
Packets,
|
||||
/// Bytes per second.
|
||||
Bytes,
|
||||
}
|
||||
|
||||
/// One series' rolling packet and byte rates.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Series {
|
||||
last: Option<(Instant, u64, u64)>,
|
||||
packets_rate: f32,
|
||||
bytes_rate: f32,
|
||||
packets_history: VecDeque<f32>,
|
||||
bytes_history: VecDeque<f32>,
|
||||
}
|
||||
|
||||
impl Series {
|
||||
fn sample(&mut self, now: Instant, packets: u64, bytes: u64) {
|
||||
if let Some((then, prev_packets, prev_bytes)) = self.last {
|
||||
let dt = now.saturating_duration_since(then).as_secs_f32();
|
||||
if dt > 0.0 {
|
||||
self.packets_rate = packets.saturating_sub(prev_packets) as f32 / dt;
|
||||
self.bytes_rate = bytes.saturating_sub(prev_bytes) as f32 / dt;
|
||||
push(&mut self.packets_history, self.packets_rate);
|
||||
push(&mut self.bytes_history, self.bytes_rate);
|
||||
}
|
||||
}
|
||||
self.last = Some((now, packets, bytes));
|
||||
}
|
||||
|
||||
/// The current rate in the chosen unit.
|
||||
pub(crate) fn rate(&self, unit: Unit) -> f32 {
|
||||
match unit {
|
||||
Unit::Packets => self.packets_rate,
|
||||
Unit::Bytes => self.bytes_rate,
|
||||
}
|
||||
}
|
||||
|
||||
/// The rolling history in the chosen unit.
|
||||
pub(crate) fn history(&self, unit: Unit) -> &VecDeque<f32> {
|
||||
match unit {
|
||||
Unit::Packets => &self.packets_history,
|
||||
Unit::Bytes => &self.bytes_history,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push(history: &mut VecDeque<f32>, value: f32) {
|
||||
history.push_back(value);
|
||||
while history.len() > HISTORY {
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// All traffic series, keyed by a stable string.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Traffic {
|
||||
series: HashMap<String, Series>,
|
||||
}
|
||||
|
||||
impl Traffic {
|
||||
/// Folds a fresh snapshot into the rates. Call once per new generation.
|
||||
pub(crate) fn observe(&mut self, report: &StatusReport, now: Instant) {
|
||||
for network in &report.networks {
|
||||
let Some(overlay) = &network.overlay else {
|
||||
continue;
|
||||
};
|
||||
let (mut net_packets, mut net_bytes) = (0u64, 0u64);
|
||||
for peer in &overlay.peers {
|
||||
let packets = peer.tx_packets.saturating_add(peer.rx_packets);
|
||||
let bytes = peer.tx_bytes.saturating_add(peer.rx_bytes);
|
||||
net_packets = net_packets.saturating_add(packets);
|
||||
net_bytes = net_bytes.saturating_add(bytes);
|
||||
self.series
|
||||
.entry(peer_key(&network.network_id, &peer.public_key))
|
||||
.or_default()
|
||||
.sample(now, packets, bytes);
|
||||
}
|
||||
self.series
|
||||
.entry(net_key(&network.network_id))
|
||||
.or_default()
|
||||
.sample(now, net_packets, net_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// The network-total series, if sampled.
|
||||
pub(crate) fn network(&self, network_id: &str) -> Option<&Series> {
|
||||
self.series.get(&net_key(network_id))
|
||||
}
|
||||
|
||||
/// One peer's series, if sampled.
|
||||
pub(crate) fn peer(&self, network_id: &str, public_key: &str) -> Option<&Series> {
|
||||
self.series.get(&peer_key(network_id, public_key))
|
||||
}
|
||||
}
|
||||
|
||||
fn net_key(network_id: &str) -> String {
|
||||
format!("net:{network_id}")
|
||||
}
|
||||
|
||||
fn peer_key(network_id: &str, public_key: &str) -> String {
|
||||
format!("peer:{network_id}:{public_key}")
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! The tray: the icon, its menu, and starting the window.
|
||||
//!
|
||||
//! This is the process that stays. It holds no window and no GUI toolkit of
|
||||
//! its own; choosing Open starts this same binary again as the window, which
|
||||
//! is a perfectly ordinary program that ends when it is closed.
|
||||
|
||||
use std::process::{Child, Command};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem};
|
||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
||||
|
||||
/// The window process, when there is one.
|
||||
struct Window {
|
||||
child: Mutex<Option<Child>>,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
/// Starts the window unless one is already open.
|
||||
///
|
||||
/// Raising an existing one is not something a process can ask for on
|
||||
/// every platform, so choosing Open while it is open does nothing.
|
||||
fn open(&self) {
|
||||
let mut slot = self.child.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(child) = slot.as_mut() {
|
||||
match child.try_wait() {
|
||||
Ok(None) => return,
|
||||
_ => *slot = None,
|
||||
}
|
||||
}
|
||||
let started = std::env::current_exe()
|
||||
.map_err(|err| err.to_string())
|
||||
.and_then(|exe| {
|
||||
Command::new(exe)
|
||||
.arg(crate::WINDOW_ARG)
|
||||
.spawn()
|
||||
.map_err(|err| err.to_string())
|
||||
});
|
||||
match started {
|
||||
Ok(child) => *slot = Some(child),
|
||||
Err(err) => eprintln!("cannot open the window: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the window if it is open.
|
||||
fn close(&self) {
|
||||
let mut slot = self.child.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(mut child) = slot.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A built tray icon and the ids of its menu items.
|
||||
struct Built {
|
||||
icon: TrayIcon,
|
||||
open: MenuId,
|
||||
quit: MenuId,
|
||||
}
|
||||
|
||||
fn build() -> Result<Built, String> {
|
||||
let open = MenuItem::new("Open tsunagi", true, None);
|
||||
let quit = MenuItem::new("Quit", true, None);
|
||||
let menu = Menu::new();
|
||||
menu.append(&open).map_err(|err| err.to_string())?;
|
||||
menu.append(&PredefinedMenuItem::separator())
|
||||
.map_err(|err| err.to_string())?;
|
||||
menu.append(&quit).map_err(|err| err.to_string())?;
|
||||
|
||||
let icon = TrayIconBuilder::new()
|
||||
.with_tooltip("tsunagi")
|
||||
.with_menu(Box::new(menu))
|
||||
.with_icon(make_icon()?)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(Built {
|
||||
icon,
|
||||
open: open.id().clone(),
|
||||
quit: quit.id().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// [`build`], with a missing indicator library reported instead of panicking.
|
||||
///
|
||||
/// The library is loaded on first use, and its binding panics when it is not
|
||||
/// installed, which reads as a bug in this program rather than a package that
|
||||
/// is not there.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn build_checked() -> Result<Built, String> {
|
||||
let hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(|_| {}));
|
||||
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(build));
|
||||
std::panic::set_hook(hook);
|
||||
built.unwrap_or_else(|_| {
|
||||
Err(
|
||||
"the tray needs libayatana-appindicator (Arch: libayatana-appindicator, \
|
||||
Debian/Ubuntu: libayatana-appindicator3-1), which is not installed"
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Acts on menu choices as the platform's loop delivers them.
|
||||
fn handle_menu(built: &Built, window: Arc<Window>) {
|
||||
let (open, quit) = (built.open.clone(), built.quit.clone());
|
||||
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
|
||||
if event.id == open {
|
||||
window.open();
|
||||
} else if event.id == quit {
|
||||
window.close();
|
||||
std::process::exit(0);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Shows the icon and runs until Quit.
|
||||
///
|
||||
/// Linux: the tray needs GTK, which nothing else here initialises, so this
|
||||
/// thread does and runs its main loop.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
gtk::init().map_err(|err| format!("cannot initialise GTK: {err}"))?;
|
||||
let built = build_checked()?;
|
||||
handle_menu(
|
||||
&built,
|
||||
Arc::new(Window {
|
||||
child: Mutex::new(None),
|
||||
}),
|
||||
);
|
||||
gtk::main();
|
||||
drop(built.icon);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows the icon and runs until Quit.
|
||||
///
|
||||
/// Windows and macOS: the icon needs an event loop on the main thread to be
|
||||
/// served, and macOS wants it created once that loop is running. There are no
|
||||
/// windows in it; it exists to be pumped.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::WindowEvent;
|
||||
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
||||
use winit::window::WindowId;
|
||||
|
||||
struct Pump {
|
||||
window: Arc<Window>,
|
||||
icon: Option<TrayIcon>,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for Pump {
|
||||
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {
|
||||
if self.icon.is_some() {
|
||||
return;
|
||||
}
|
||||
match build() {
|
||||
Ok(built) => {
|
||||
handle_menu(&built, Arc::clone(&self.window));
|
||||
self.icon = Some(built.icon);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("cannot create the tray icon: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
|
||||
}
|
||||
|
||||
let mut builder = EventLoop::builder();
|
||||
// macOS: live in the menu bar with no Dock icon.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS};
|
||||
builder.with_activation_policy(ActivationPolicy::Accessory);
|
||||
}
|
||||
let event_loop = builder.build()?;
|
||||
let mut pump = Pump {
|
||||
window: Arc::new(Window {
|
||||
child: Mutex::new(None),
|
||||
}),
|
||||
icon: None,
|
||||
};
|
||||
event_loop.run_app(&mut pump)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A small round icon, drawn in code so there is no asset to ship yet.
|
||||
fn make_icon() -> Result<Icon, String> {
|
||||
const SIZE: u32 = 32;
|
||||
let centre = (SIZE as f32 - 1.0) / 2.0;
|
||||
let radius = SIZE as f32 * 0.46;
|
||||
let mut rgba = vec![0u8; (SIZE * SIZE * 4) as usize];
|
||||
for y in 0..SIZE {
|
||||
for x in 0..SIZE {
|
||||
let (dx, dy) = (x as f32 - centre, y as f32 - centre);
|
||||
if dx * dx + dy * dy <= radius * radius {
|
||||
let i = ((y * SIZE + x) * 4) as usize;
|
||||
rgba[i] = 0x2f;
|
||||
rgba[i + 1] = 0x80;
|
||||
rgba[i + 2] = 0xd8;
|
||||
rgba[i + 3] = 0xff;
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon::from_rgba(rgba, SIZE, SIZE).map_err(|err| err.to_string())
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! The main window: a compact overview. Per-network detail (the full peer
|
||||
//! table with inline plots) lives in a separate devices window (see
|
||||
//! [`crate::devices`]), opened from each tile.
|
||||
//!
|
||||
//! Pure rendering: state comes from the snapshot and the traffic sampler, a
|
||||
//! change to a switch or button sends a [`Command`] to the worker, and local UI
|
||||
//! state (the join form, the unit toggle, which devices windows are open, an
|
||||
//! in-progress hostname edit) lives in [`UiState`].
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use eframe::egui;
|
||||
use egui_phosphor::regular as icon;
|
||||
|
||||
use tsunagi::ipc::{NetworkReport, StatusReport};
|
||||
|
||||
use crate::agent::{AgentClient, Command, Snapshot};
|
||||
use crate::devices;
|
||||
use crate::exit;
|
||||
use crate::format;
|
||||
use crate::stats::{Traffic, Unit};
|
||||
|
||||
const OK: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a);
|
||||
const BAD: egui::Color32 = egui::Color32::from_rgb(0xd0, 0x4a, 0x3c);
|
||||
/// A neutral, not-garish red for the Leave button.
|
||||
const LEAVE_RED: egui::Color32 = egui::Color32::from_rgb(0xa8, 0x3a, 0x3a);
|
||||
|
||||
/// Join form fields.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct JoinForm {
|
||||
pub(crate) name: String,
|
||||
pub(crate) secret: String,
|
||||
}
|
||||
|
||||
/// Local UI state kept across frames.
|
||||
pub(crate) struct UiState {
|
||||
pub(crate) join: JoinForm,
|
||||
/// Whether rates and sparklines are shown in packets or bytes.
|
||||
pub(crate) unit: Unit,
|
||||
/// An in-progress hostname edit (`None` when not editing).
|
||||
pub(crate) host_edit: Option<String>,
|
||||
/// Network ids whose devices window is open.
|
||||
pub(crate) open_devices: BTreeSet<String>,
|
||||
/// Whether the About window is open.
|
||||
pub(crate) about_open: bool,
|
||||
/// An exit-node choice waiting for the user to confirm it.
|
||||
pub(crate) exit_confirm: Option<devices::ExitConfirm>,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
join: JoinForm::default(),
|
||||
unit: Unit::Packets,
|
||||
host_edit: None,
|
||||
open_devices: BTreeSet::new(),
|
||||
about_open: false,
|
||||
exit_confirm: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the main window.
|
||||
pub(crate) fn draw(
|
||||
ctx: &egui::Context,
|
||||
agent: &AgentClient,
|
||||
state: &mut UiState,
|
||||
snapshot: &Snapshot,
|
||||
traffic: &Traffic,
|
||||
) {
|
||||
egui::TopBottomPanel::top("top").show(ctx, |ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("tsunagi");
|
||||
ui.label(
|
||||
egui::RichText::new(env!("CARGO_PKG_VERSION"))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(icon::INFO).on_hover_text("About").clicked() {
|
||||
state.about_open = true;
|
||||
}
|
||||
if snapshot.busy {
|
||||
ui.add(egui::Spinner::new());
|
||||
}
|
||||
ui.separator();
|
||||
ui.selectable_value(&mut state.unit, Unit::Bytes, "bytes");
|
||||
ui.selectable_value(&mut state.unit, Unit::Packets, "pkts");
|
||||
ui.label("show:");
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
|
||||
egui::TopBottomPanel::bottom("bottom").show(ctx, |ui| {
|
||||
if let Some(action) = &snapshot.last_action {
|
||||
ui.add_space(4.0);
|
||||
match action {
|
||||
Ok(message) => ui.colored_label(OK, message),
|
||||
Err(error) => ui.colored_label(BAD, error),
|
||||
};
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
draw_join(ui, agent, &mut state.join);
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| match &snapshot.status {
|
||||
None => {
|
||||
ui.add_space(8.0);
|
||||
ui.label("Connecting to the agent…");
|
||||
}
|
||||
Some(Err(error)) => draw_unreachable(ui, agent, error),
|
||||
Some(Ok(report)) => {
|
||||
draw_header(ui, agent, state, report);
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
if report.networks.is_empty() {
|
||||
ui.add_space(6.0);
|
||||
ui.weak("No networks yet — join one below.");
|
||||
}
|
||||
for network in &report.networks {
|
||||
draw_network(ui, agent, state, traffic, network, &report.endpoint_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The "agent cannot be reached" state.
|
||||
fn draw_unreachable(ui: &mut egui::Ui, agent: &AgentClient, error: &str) {
|
||||
ui.add_space(8.0);
|
||||
ui.colored_label(BAD, "The agent is not reachable.");
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.weak("socket");
|
||||
ui.monospace(agent.socket().display().to_string());
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.weak(error);
|
||||
ui.add_space(6.0);
|
||||
ui.weak(
|
||||
"Start the agent, or check that this user may open its control socket \
|
||||
(its group, mode 0660).",
|
||||
);
|
||||
}
|
||||
|
||||
/// The device header: id (with a copy button) and an editable hostname.
|
||||
fn draw_header(ui: &mut egui::Ui, agent: &AgentClient, state: &mut UiState, report: &StatusReport) {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.weak("device");
|
||||
// Clicking the shortened id copies the full one.
|
||||
format::copy_field(ui, &format::short(&report.endpoint_id), &report.endpoint_id);
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.weak("host");
|
||||
match &mut state.host_edit {
|
||||
None => {
|
||||
ui.label(&report.hostname);
|
||||
if ui
|
||||
.small_button(icon::PENCIL_SIMPLE)
|
||||
.on_hover_text("rename")
|
||||
.clicked()
|
||||
{
|
||||
state.host_edit = Some(report.hostname.clone());
|
||||
}
|
||||
}
|
||||
Some(buffer) => {
|
||||
let response = ui.add(egui::TextEdit::singleline(buffer).desired_width(150.0));
|
||||
let submit = (response.lost_focus()
|
||||
&& ui.input(|i| i.key_pressed(egui::Key::Enter)))
|
||||
|| ui.small_button(icon::CHECK).on_hover_text("save").clicked();
|
||||
if submit {
|
||||
agent.send(Command::SetHostname(buffer.trim().to_string()));
|
||||
state.host_edit = None;
|
||||
} else if ui.small_button(icon::X).on_hover_text("cancel").clicked() {
|
||||
state.host_edit = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if !report.cache_healthy {
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(0xd9, 0x9a, 0x00),
|
||||
"cache unavailable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One compact network tile.
|
||||
fn draw_network(
|
||||
ui: &mut egui::Ui,
|
||||
agent: &AgentClient,
|
||||
state: &mut UiState,
|
||||
traffic: &Traffic,
|
||||
network: &NetworkReport,
|
||||
own_id: &str,
|
||||
) {
|
||||
ui.add_space(6.0);
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
format::copy_field(ui, network.name.as_str(), &network.name);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let leave =
|
||||
egui::Button::new(egui::RichText::new("Leave").color(egui::Color32::WHITE))
|
||||
.fill(LEAVE_RED);
|
||||
if ui.add(leave).clicked() {
|
||||
agent.send(Command::Leave {
|
||||
network_id: network.network_id.clone(),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let address = network.overlay.as_ref().and_then(|o| o.address.as_ref());
|
||||
let series = traffic.network(&network.network_id);
|
||||
|
||||
egui::Grid::new(("net", &network.network_id))
|
||||
.num_columns(2)
|
||||
.spacing([10.0, 3.0])
|
||||
.show(ui, |ui| {
|
||||
ui.weak("id");
|
||||
format::copy_field(ui, &format::short(&network.network_id), &network.network_id);
|
||||
ui.end_row();
|
||||
|
||||
if let Some(range) = &network.range {
|
||||
ui.weak("range");
|
||||
format::copy_field(ui, range, range);
|
||||
ui.end_row();
|
||||
}
|
||||
if let Some(address) = address {
|
||||
ui.weak("addr");
|
||||
format::copy_field(ui, address, address);
|
||||
ui.end_row();
|
||||
}
|
||||
ui.weak("peers");
|
||||
ui.label(format!(
|
||||
"{} connected · {} known",
|
||||
network.peers.len(),
|
||||
devices::known_count(network, own_id)
|
||||
));
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
format::sparkline(ui, series, state.unit, 34.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let mut active = network.active;
|
||||
if ui.checkbox(&mut active, "active").changed() {
|
||||
agent.send(Command::SetActive {
|
||||
network_id: network.network_id.clone(),
|
||||
active,
|
||||
});
|
||||
}
|
||||
let mut broadcast = network.broadcast;
|
||||
if ui.checkbox(&mut broadcast, "broadcast").changed() {
|
||||
agent.send(Command::SetBroadcast {
|
||||
network_id: network.network_id.clone(),
|
||||
enabled: broadcast,
|
||||
});
|
||||
}
|
||||
let mut offering = network.exit.offering;
|
||||
if ui
|
||||
.checkbox(&mut offering, "exit node")
|
||||
.on_hover_text(
|
||||
"let other members send all their internet traffic through this \
|
||||
device, which can see it",
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
agent.send(Command::SetExitOffer {
|
||||
network_id: network.network_id.clone(),
|
||||
enabled: offering,
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(via) = &network.exit.via {
|
||||
ui.weak("exit");
|
||||
ui.label(exit::name_of(network, via));
|
||||
if ui
|
||||
.small_button("off")
|
||||
.on_hover_text("send internet traffic the ordinary way again")
|
||||
.clicked()
|
||||
{
|
||||
agent.send(Command::SetExitNode {
|
||||
network_id: network.network_id.clone(),
|
||||
peer: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Show devices").clicked() {
|
||||
state.open_devices.insert(network.network_id.clone());
|
||||
}
|
||||
if ui
|
||||
.button(format!("{} Copy secret", icon::COPY))
|
||||
.on_hover_text("copy this network's secret to the clipboard")
|
||||
.clicked()
|
||||
{
|
||||
agent.send(Command::CopySecret {
|
||||
network_id: network.network_id.clone(),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
for warning in exit::warnings(network) {
|
||||
draw_exit_warning(ui, &warning);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A prominent line about an exit node. One that carries a command copies it
|
||||
/// when clicked.
|
||||
fn draw_exit_warning(ui: &mut egui::Ui, warning: &exit::Warning) {
|
||||
let text = egui::RichText::new(format!("{} {}", icon::WARNING, warning.text))
|
||||
.color(exit::LOUD)
|
||||
.strong();
|
||||
let label = egui::Label::new(text).wrap();
|
||||
match warning.copy {
|
||||
Some(command) => {
|
||||
let response = ui
|
||||
.add(label.sense(egui::Sense::click()))
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text("click to copy the command");
|
||||
if response.clicked() {
|
||||
ui.ctx().copy_text(command.to_owned());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.add(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The join form: aligned name and secret, a generate button, and Join.
|
||||
fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) {
|
||||
const INPUT_WIDTH: f32 = 208.0;
|
||||
ui.label("Join or create a network");
|
||||
egui::Grid::new("join")
|
||||
.num_columns(2)
|
||||
.spacing([8.0, 6.0])
|
||||
.show(ui, |ui| {
|
||||
ui.label("name");
|
||||
ui.add(egui::TextEdit::singleline(&mut join.name).desired_width(INPUT_WIDTH));
|
||||
ui.end_row();
|
||||
|
||||
ui.label("secret");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut join.secret)
|
||||
.desired_width(INPUT_WIDTH - 26.0)
|
||||
.password(true),
|
||||
);
|
||||
if ui
|
||||
.button(icon::SHUFFLE)
|
||||
.on_hover_text("Generate a new random secret")
|
||||
.clicked()
|
||||
{
|
||||
join.secret = tsunagi::identity::NetworkSecret::generate()
|
||||
.encode()
|
||||
.as_str()
|
||||
.to_owned();
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
let ready = !join.name.trim().is_empty() && !join.secret.trim().is_empty();
|
||||
if ui
|
||||
.add_enabled(ready, egui::Button::new("Join / create"))
|
||||
.clicked()
|
||||
{
|
||||
agent.send(Command::Join {
|
||||
name: join.name.trim().to_string(),
|
||||
secret: join.secret.trim().to_string(),
|
||||
});
|
||||
join.secret.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Exit nodes, end to end: real agents, a real control plane, real iroh data
|
||||
//! links and real WireGuard, with an in-memory packet interface and a mock
|
||||
//! host, so no privileges are needed and the machine's network is untouched.
|
||||
//!
|
||||
//! One agent offers itself as an exit node; another chooses it, and an
|
||||
//! ordinary internet packet leaves the second agent's interface, arrives on
|
||||
//! the first one's, and the answer comes back with the internet's own source
|
||||
//! address.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use tempfile::TempDir;
|
||||
use tsunagi::dataplane::IpPlugin;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::NetworkId;
|
||||
use tsunagi::overlay::{
|
||||
BroadcastHostRules, MemoryTun, MemoryTunFactory, MockHostRules, OverlayError, TunDevice,
|
||||
TunFactory, TunRequest,
|
||||
};
|
||||
use tsunagi::testing::{network, wait_until};
|
||||
use tsunagi::{Agent, NetworkStatus};
|
||||
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
|
||||
|
||||
const INTERNET: Ipv4Addr = Ipv4Addr::new(93, 184, 216, 34);
|
||||
|
||||
/// An in-memory interface on a host whose rules are a mock.
|
||||
///
|
||||
/// Not "on the host": the agent must not go looking for an address on a real
|
||||
/// link. It only has host *rules*, which is what an exit node needs from it.
|
||||
#[derive(Debug, Clone)]
|
||||
struct RuledTuns {
|
||||
tuns: MemoryTunFactory,
|
||||
rules: MockHostRules,
|
||||
}
|
||||
|
||||
impl TunFactory for RuledTuns {
|
||||
fn name(&self) -> &str {
|
||||
"memory-with-rules"
|
||||
}
|
||||
|
||||
fn on_host(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn host_rules(&self) -> Option<Arc<dyn BroadcastHostRules>> {
|
||||
Some(Arc::new(self.rules.clone()))
|
||||
}
|
||||
|
||||
fn create<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
) -> tsunagi::BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>> {
|
||||
self.tuns.create(request)
|
||||
}
|
||||
|
||||
fn reconfigure<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
) -> tsunagi::BoxFuture<'a, Result<(), OverlayError>> {
|
||||
self.tuns.reconfigure(request)
|
||||
}
|
||||
|
||||
fn destroy<'a>(&'a self, name: &'a str) -> tsunagi::BoxFuture<'a, ()> {
|
||||
self.tuns.destroy(name)
|
||||
}
|
||||
}
|
||||
|
||||
struct Node {
|
||||
_dir: TempDir,
|
||||
agent: Agent,
|
||||
plugin: Arc<WireguardPlugin>,
|
||||
tuns: MemoryTunFactory,
|
||||
rules: MockHostRules,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
async fn spawn(discovery: &SharedMemoryDiscovery, tag: &str) -> Self {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let tuns = MemoryTunFactory::new();
|
||||
let rules = MockHostRules::new();
|
||||
let plugin = WireguardPlugin::open(
|
||||
WireguardConfig::new(dir.path().join("wireguard"))
|
||||
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let agent = Agent::spawn(
|
||||
tsunagi::testing::config_with(dir.path(), discovery)
|
||||
.with_overlay_ipv4_range(Some(tsunagi::state::DEFAULT_IPV4_RANGE))
|
||||
.with_interface(
|
||||
Arc::new(RuledTuns {
|
||||
tuns: tuns.clone(),
|
||||
rules: rules.clone(),
|
||||
}),
|
||||
tag,
|
||||
1280,
|
||||
)
|
||||
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
Self {
|
||||
_dir: dir,
|
||||
agent,
|
||||
plugin,
|
||||
tuns,
|
||||
rules,
|
||||
}
|
||||
}
|
||||
|
||||
fn id(&self) -> EndpointId {
|
||||
self.agent.endpoint_id()
|
||||
}
|
||||
|
||||
async fn overlay(&self, network: NetworkId) -> Ipv4Addr {
|
||||
wait_until("the network agreed an overlay address", || async {
|
||||
self.plugin.overview(network)?.overlay_address_v4
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn tun(&self) -> Arc<MemoryTun> {
|
||||
let name = wait_until("the packet interface exists", || async {
|
||||
self.agent.overlay().map(|overlay| overlay.interface)
|
||||
})
|
||||
.await;
|
||||
self.tuns.device(&name).expect("the device was created")
|
||||
}
|
||||
|
||||
async fn wait_for_tunnels(&self, network: NetworkId, count: usize) {
|
||||
wait_until("established WireGuard tunnels", || async {
|
||||
let view = self.plugin.overview(network)?;
|
||||
(view.established_peers() == count).then_some(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn status(&self, network: NetworkId) -> NetworkStatus {
|
||||
self.agent.network_status(network).await.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn ipv4_packet(source: Ipv4Addr, destination: Ipv4Addr, payload: &[u8]) -> Bytes {
|
||||
let total = 20 + payload.len();
|
||||
let mut packet = Vec::with_capacity(total);
|
||||
packet.push((4 << 4) | 5);
|
||||
packet.push(0);
|
||||
packet.extend_from_slice(&(total as u16).to_be_bytes());
|
||||
packet.extend_from_slice(&[0, 0, 0, 0]);
|
||||
packet.push(64);
|
||||
packet.push(253);
|
||||
packet.extend_from_slice(&[0, 0]);
|
||||
packet.extend_from_slice(&source.octets());
|
||||
packet.extend_from_slice(&destination.octets());
|
||||
packet.extend_from_slice(payload);
|
||||
Bytes::from(packet)
|
||||
}
|
||||
|
||||
async fn next(tun: &MemoryTun) -> Bytes {
|
||||
tokio::time::timeout(Duration::from_secs(10), tun.pop_to_os())
|
||||
.await
|
||||
.expect("a packet reaches the operating system")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_member_sends_its_internet_traffic_through_an_exit_node_and_gets_the_answers() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("exit-node");
|
||||
let server = Node::spawn(&discovery, "texita").await;
|
||||
let client = Node::spawn(&discovery, "texitb").await;
|
||||
let id = server.agent.join_network(&name, &secret).await.unwrap();
|
||||
client.agent.join_network(&name, &secret).await.unwrap();
|
||||
server.wait_for_tunnels(id, 1).await;
|
||||
client.wait_for_tunnels(id, 1).await;
|
||||
let server_addr = server.overlay(id).await;
|
||||
let client_addr = client.overlay(id).await;
|
||||
let server_tun = server.tun().await;
|
||||
let client_tun = client.tun().await;
|
||||
|
||||
// Off until its owner turns it on: nothing is announced, and choosing it
|
||||
// is refused rather than quietly accepted.
|
||||
assert!(!server.status(id).await.exit_offer);
|
||||
assert!(
|
||||
client
|
||||
.status(id)
|
||||
.await
|
||||
.peers
|
||||
.iter()
|
||||
.all(|peer| !peer.exit_node)
|
||||
);
|
||||
let refused = client.agent.set_exit_via(id, Some(server.id())).await;
|
||||
assert!(refused.is_err(), "{refused:?}");
|
||||
assert!(client.status(id).await.exit_via.is_none());
|
||||
|
||||
// The server turns it on. Its host rules are applied, and the client
|
||||
// learns of it over the control channel.
|
||||
server.agent.set_exit_offer(id, true).await.unwrap();
|
||||
let plan = server.rules.exit().installed().expect("rules were applied");
|
||||
assert_eq!(plan.offer, vec![tsunagi::state::DEFAULT_IPV4_RANGE]);
|
||||
assert!(!plan.client);
|
||||
let status = server.status(id).await;
|
||||
assert!(status.exit_offer);
|
||||
assert!(status.exit_rules.offer.as_ref().unwrap().ok);
|
||||
wait_until("the client sees an exit node", || async {
|
||||
client
|
||||
.status(id)
|
||||
.await
|
||||
.peers
|
||||
.iter()
|
||||
.any(|peer| peer.endpoint_id == server.id() && peer.exit_node)
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
|
||||
// The client chooses it; its routes are installed.
|
||||
client
|
||||
.agent
|
||||
.set_exit_via(id, Some(server.id()))
|
||||
.await
|
||||
.unwrap();
|
||||
let status = client.status(id).await;
|
||||
assert_eq!(status.exit_via, Some(server.id()));
|
||||
assert!(status.exit_via_online);
|
||||
assert!(
|
||||
client
|
||||
.rules
|
||||
.exit()
|
||||
.installed()
|
||||
.expect("client rules")
|
||||
.client
|
||||
);
|
||||
|
||||
// An ordinary packet for the internet leaves the client's interface and
|
||||
// arrives on the server's, from the client's overlay address.
|
||||
client_tun.push_from_os(ipv4_packet(client_addr, INTERNET, b"GET /"));
|
||||
let request = next(&server_tun).await;
|
||||
assert_eq!(&request[12..16], &client_addr.octets());
|
||||
assert_eq!(&request[16..20], &INTERNET.octets());
|
||||
assert_eq!(&request[20..], b"GET /");
|
||||
|
||||
// The host's answer, from the internet's own address, comes back.
|
||||
server_tun.push_from_os(ipv4_packet(INTERNET, client_addr, b"200 OK"));
|
||||
let reply = next(&client_tun).await;
|
||||
assert_eq!(&reply[12..16], &INTERNET.octets());
|
||||
assert_eq!(&reply[16..20], &client_addr.octets());
|
||||
assert_eq!(&reply[20..], b"200 OK");
|
||||
|
||||
// Members still reach each other directly, exit node or not.
|
||||
client_tun.push_from_os(ipv4_packet(client_addr, server_addr, b"ping"));
|
||||
let direct = next(&server_tun).await;
|
||||
assert_eq!(&direct[12..16], &client_addr.octets());
|
||||
assert_eq!(&direct[16..20], &server_addr.octets());
|
||||
|
||||
// Stopping puts everything back, and the choice is gone.
|
||||
client.agent.set_exit_via(id, None).await.unwrap();
|
||||
assert!(client.status(id).await.exit_via.is_none());
|
||||
assert!(client.rules.exit().installed().is_none());
|
||||
server.agent.set_exit_offer(id, false).await.unwrap();
|
||||
assert!(server.rules.exit().installed().is_none());
|
||||
wait_until("the client sees it withdrawn", || async {
|
||||
client
|
||||
.status(id)
|
||||
.await
|
||||
.peers
|
||||
.iter()
|
||||
.all(|peer| !peer.exit_node)
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
|
||||
client.agent.shutdown().await;
|
||||
server.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_agent_that_is_not_offering_drops_a_members_internet_packets() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("exit-node-off");
|
||||
let server = Node::spawn(&discovery, "texitc").await;
|
||||
let client = Node::spawn(&discovery, "texitd").await;
|
||||
let id = server.agent.join_network(&name, &secret).await.unwrap();
|
||||
client.agent.join_network(&name, &secret).await.unwrap();
|
||||
server.wait_for_tunnels(id, 1).await;
|
||||
client.wait_for_tunnels(id, 1).await;
|
||||
let client_addr = client.overlay(id).await;
|
||||
let server_tun = server.tun().await;
|
||||
let client_tun = client.tun().await;
|
||||
|
||||
// With no exit node chosen, the client's own interface has nowhere to
|
||||
// send the packet, and nothing reaches the server.
|
||||
client_tun.push_from_os(ipv4_packet(client_addr, INTERNET, b"nowhere"));
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(300), server_tun.pop_to_os())
|
||||
.await
|
||||
.is_err(),
|
||||
"nothing was sent"
|
||||
);
|
||||
assert!(
|
||||
server.rules.exit().installed().is_none(),
|
||||
"no rules without the switch"
|
||||
);
|
||||
|
||||
client.agent.shutdown().await;
|
||||
server.agent.shutdown().await;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tsunagi::dataplane::IpPlugin;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::ipc::{ControlSocket, request_status};
|
||||
use tsunagi::ipc::{ControlSocket, ControlSocketAccess, request_status};
|
||||
use tsunagi::ipc::{StatusReport, control_socket_path};
|
||||
use tsunagi::overlay::MemoryTunFactory;
|
||||
use tsunagi::testing::{config_with, network, wait_for_peers, wait_until};
|
||||
@@ -41,6 +41,7 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::R
|
||||
hostname: peer.hostname.clone(),
|
||||
transport: format!("{:?}", peer.transport),
|
||||
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
|
||||
exit_node: peer.exit_node,
|
||||
})
|
||||
.collect(),
|
||||
overlay: plugin.overview(net.network_id).map(|view| {
|
||||
@@ -83,6 +84,7 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::R
|
||||
cache_healthy: status.cache_healthy,
|
||||
networks,
|
||||
dns: None,
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -130,9 +132,13 @@ async fn a_client_sees_the_agent_and_its_overlay() {
|
||||
|
||||
// A short path: a Unix socket address is limited to about 100 bytes.
|
||||
let socket_path = dir_a.path().join("agent.sock");
|
||||
let control = ControlSocket::bind(&socket_path, source(agent.clone(), plugin.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
let control = ControlSocket::bind(
|
||||
&socket_path,
|
||||
source(agent.clone(), plugin.clone()),
|
||||
ControlSocketAccess::Private,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = wait_until("the overlay is reported as up", || {
|
||||
let socket_path = socket_path.clone();
|
||||
@@ -193,13 +199,13 @@ async fn a_leftover_socket_file_is_replaced_but_a_live_one_is_not() {
|
||||
|
||||
// A file with nobody listening is a leftover from a crash.
|
||||
std::fs::write(&path, b"stale").unwrap();
|
||||
let first = ControlSocket::bind(&path, Arc::clone(&empty))
|
||||
let first = ControlSocket::bind(&path, Arc::clone(&empty), ControlSocketAccess::Private)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(request_status(&path).await.is_ok());
|
||||
|
||||
// A live socket is not stolen from the agent that owns it.
|
||||
let second = ControlSocket::bind(&path, Arc::clone(&empty)).await;
|
||||
let second = ControlSocket::bind(&path, Arc::clone(&empty), ControlSocketAccess::Private).await;
|
||||
assert!(
|
||||
matches!(second, Err(tsunagi::Error::StateLocked { .. })),
|
||||
"a second agent must not take over a live control socket"
|
||||
@@ -321,9 +327,13 @@ async fn a_client_can_leave_a_network_through_the_running_agent() {
|
||||
wait_for_peers(&agent, network_id, 1).await;
|
||||
|
||||
let socket_path = dir.path().join("control.sock");
|
||||
let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone())))
|
||||
.await
|
||||
.unwrap();
|
||||
let control = ControlSocket::bind(
|
||||
&socket_path,
|
||||
Arc::new(Control(agent.clone())),
|
||||
ControlSocketAccess::Private,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = tsunagi::ipc::leave_network(&socket_path, &network_id.to_string())
|
||||
.await
|
||||
@@ -361,9 +371,13 @@ async fn a_client_can_add_a_network_to_a_running_agent() {
|
||||
agent.join_network(&first, &first_secret).await.unwrap();
|
||||
|
||||
let socket_path = dir.path().join("control.sock");
|
||||
let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone())))
|
||||
.await
|
||||
.unwrap();
|
||||
let control = ControlSocket::bind(
|
||||
&socket_path,
|
||||
Arc::new(Control(agent.clone())),
|
||||
ControlSocketAccess::Private,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = tsunagi::ipc::join_network(
|
||||
&socket_path,
|
||||
|
||||
@@ -388,6 +388,7 @@ impl Agent {
|
||||
async fn reserve_range(&self, network: NetworkId) -> RangePlan {
|
||||
let reserve = |wanted: Ipv4Range| {
|
||||
let reservation = crate::overlay::NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some(wanted),
|
||||
local: None,
|
||||
@@ -603,6 +604,72 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Offers this agent as an exit node in a network, or stops, now and
|
||||
/// after restart. Off unless somebody turns it on.
|
||||
pub async fn set_exit_offer(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
self.inner
|
||||
.storage
|
||||
.set_exit_node(network_id, enabled)
|
||||
.await?;
|
||||
if self.is_active(network_id).await {
|
||||
let (reply, receive) = oneshot::channel();
|
||||
self.command(network_id, NetCommand::SetExitOffer { enabled, reply })
|
||||
.await?;
|
||||
receive.await.map_err(|_| Error::Stopped)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends this device's internet traffic through a member of a network,
|
||||
/// or back the ordinary way with `None`.
|
||||
///
|
||||
/// A device has one default route, so choosing in one network clears the
|
||||
/// choice in the others. The member has to be connected and offering
|
||||
/// to be an exit node. Remembered across restarts.
|
||||
pub async fn set_exit_via(&self, network_id: NetworkId, via: Option<EndpointId>) -> Result<()> {
|
||||
let active = self.is_active(network_id).await;
|
||||
if via.is_some() && !active {
|
||||
return Err(Error::NetworkNotActive(network_id));
|
||||
}
|
||||
if active {
|
||||
let (reply, receive) = oneshot::channel();
|
||||
self.command(network_id, NetCommand::SetExitVia { via, reply })
|
||||
.await?;
|
||||
receive
|
||||
.await
|
||||
.map_err(|_| Error::Stopped)?
|
||||
.map_err(|reason| Error::Rejected { reason })?;
|
||||
}
|
||||
self.inner
|
||||
.storage
|
||||
.set_exit_via(network_id, via.map(|peer| *peer.as_bytes()))
|
||||
.await?;
|
||||
if via.is_some() {
|
||||
// One default route: whatever the other networks were doing, they
|
||||
// are not any more.
|
||||
let others: Vec<NetworkId> = self
|
||||
.inner
|
||||
.networks
|
||||
.read()
|
||||
.await
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|other| *other != network_id)
|
||||
.collect();
|
||||
for other in others {
|
||||
let (reply, receive) = oneshot::channel();
|
||||
if self
|
||||
.command(other, NetCommand::SetExitVia { via: None, reply })
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let _ = receive.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Changes a network's local broadcast policy now and after restart.
|
||||
pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
self.inner
|
||||
@@ -662,16 +729,23 @@ impl Agent {
|
||||
as Arc<dyn crate::discovery::NetworkDiscovery>,
|
||||
)
|
||||
};
|
||||
let broadcast = self
|
||||
let stored = self
|
||||
.inner
|
||||
.storage
|
||||
.list_networks()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|stored| stored.network_id == network_id)
|
||||
.is_none_or(|stored| stored.broadcast);
|
||||
.find(|stored| stored.network_id == network_id);
|
||||
let broadcast = stored.as_ref().is_none_or(|stored| stored.broadcast);
|
||||
let exit_offer = stored.as_ref().is_some_and(|stored| stored.exit_node);
|
||||
let exit_via = stored
|
||||
.as_ref()
|
||||
.and_then(|stored| stored.exit_via)
|
||||
.and_then(|bytes| EndpointId::from_bytes(&bytes).ok());
|
||||
let handle = network::spawn(RuntimeParams {
|
||||
broadcast,
|
||||
exit_offer,
|
||||
exit_via,
|
||||
keys,
|
||||
adapter: self.inner.adapter.clone(),
|
||||
storage: self.inner.storage.clone(),
|
||||
@@ -733,6 +807,7 @@ impl Agent {
|
||||
}
|
||||
if let Some(interface) = self.inner.interface.get() {
|
||||
interface.sync_broadcast_rules().await;
|
||||
interface.sync_exit_rules().await;
|
||||
}
|
||||
self.inner.storage.set_auto_start(network_id, false).await?;
|
||||
Ok(())
|
||||
@@ -886,6 +961,12 @@ impl Agent {
|
||||
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
||||
networks.push(NetworkStatus {
|
||||
broadcast: stored.broadcast,
|
||||
exit_offer: stored.exit_node,
|
||||
exit_via: stored
|
||||
.exit_via
|
||||
.and_then(|bytes| EndpointId::from_bytes(&bytes).ok()),
|
||||
exit_via_online: false,
|
||||
exit_rules: Default::default(),
|
||||
descriptor: keys.descriptor(),
|
||||
name: stored.name,
|
||||
network_id: stored.network_id,
|
||||
|
||||
@@ -66,6 +66,16 @@ pub(crate) enum NetCommand {
|
||||
enabled: bool,
|
||||
reply: oneshot::Sender<()>,
|
||||
},
|
||||
/// Offer this agent as an exit node in this network, or stop.
|
||||
SetExitOffer {
|
||||
enabled: bool,
|
||||
reply: oneshot::Sender<()>,
|
||||
},
|
||||
/// Send this device's internet traffic through a member, or stop.
|
||||
SetExitVia {
|
||||
via: Option<EndpointId>,
|
||||
reply: oneshot::Sender<std::result::Result<(), String>>,
|
||||
},
|
||||
/// Resend this agent's announcement to every peer of this network.
|
||||
Reannounce,
|
||||
/// Answer to a different name from now on.
|
||||
@@ -106,6 +116,8 @@ impl std::fmt::Debug for NetCommand {
|
||||
NetCommand::Status { .. } => f.write_str("Status"),
|
||||
NetCommand::Recheck => f.write_str("Recheck"),
|
||||
NetCommand::SetBroadcast { enabled, .. } => write!(f, "SetBroadcast({enabled})"),
|
||||
NetCommand::SetExitOffer { enabled, .. } => write!(f, "SetExitOffer({enabled})"),
|
||||
NetCommand::SetExitVia { via, .. } => write!(f, "SetExitVia({})", via.is_some()),
|
||||
NetCommand::Reannounce => f.write_str("Reannounce"),
|
||||
NetCommand::SetHostname(_) => f.write_str("SetHostname"),
|
||||
NetCommand::Release { .. } => f.write_str("Release"),
|
||||
@@ -137,6 +149,10 @@ impl NetworkHandle {
|
||||
/// Everything a network runtime needs to run.
|
||||
pub(crate) struct RuntimeParams {
|
||||
pub(crate) broadcast: bool,
|
||||
/// This agent offers itself as an exit node here.
|
||||
pub(crate) exit_offer: bool,
|
||||
/// The member this device's internet traffic goes through.
|
||||
pub(crate) exit_via: Option<EndpointId>,
|
||||
pub(crate) keys: NetworkKeys,
|
||||
pub(crate) adapter: EndpointAdapter,
|
||||
pub(crate) storage: Storage,
|
||||
@@ -331,6 +347,14 @@ struct Runtime {
|
||||
pending_state: Vec<(EndpointId, Vec<SignedRecord>)>,
|
||||
/// The highest version this agent has ever published for this network.
|
||||
own_version: u64,
|
||||
/// Whether the exit node in use was reachable when last looked at, so
|
||||
/// that losing it is said once and getting it back is too.
|
||||
exit_via_was_online: bool,
|
||||
/// What the last announcement said about being an exit node.
|
||||
announced_exit: bool,
|
||||
/// The last hostname each peer announced, kept across restarts so a
|
||||
/// member that is away can still be called by name.
|
||||
known_hostnames: HashMap<EndpointId, String>,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
@@ -382,6 +406,9 @@ impl Runtime {
|
||||
state: StateSet::new(),
|
||||
pending_state: Vec::new(),
|
||||
own_version: 0,
|
||||
exit_via_was_online: false,
|
||||
announced_exit: false,
|
||||
known_hostnames: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +528,27 @@ impl Runtime {
|
||||
self.reannounce();
|
||||
let _ = reply.send(());
|
||||
}
|
||||
NetCommand::SetExitOffer { enabled, reply } => {
|
||||
self.params.exit_offer = enabled;
|
||||
self.apply_exit().await;
|
||||
self.reannounce();
|
||||
let _ = reply.send(());
|
||||
}
|
||||
NetCommand::SetExitVia { via, reply } => {
|
||||
let outcome = match via {
|
||||
Some(peer) if !self.exit_node_online(peer) => Err(
|
||||
"that member is not connected or does not offer to be an exit node"
|
||||
.to_string(),
|
||||
),
|
||||
_ => Ok(()),
|
||||
};
|
||||
if outcome.is_ok() {
|
||||
self.params.exit_via = via;
|
||||
self.exit_via_was_online = via.is_some();
|
||||
self.apply_exit().await;
|
||||
}
|
||||
let _ = reply.send(outcome);
|
||||
}
|
||||
NetCommand::Recheck => self.discovery_round().await,
|
||||
NetCommand::Reannounce => self.reannounce(),
|
||||
NetCommand::Release { reply } => {
|
||||
@@ -844,6 +892,17 @@ impl Runtime {
|
||||
.own_record_version(self.network_id, self.local_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
for (id, hostname) in self
|
||||
.params
|
||||
.storage
|
||||
.peer_hostnames(self.network_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if let Ok(id) = EndpointId::from_bytes(&id) {
|
||||
self.known_hostnames.insert(id, hostname);
|
||||
}
|
||||
}
|
||||
|
||||
self.ensure_own_claim().await;
|
||||
self.publish_allocations().await;
|
||||
@@ -1095,6 +1154,75 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
fn exit_policy(&self) -> crate::overlay::ExitPolicy {
|
||||
crate::overlay::ExitPolicy {
|
||||
offer: self.params.exit_offer,
|
||||
via: self.params.exit_via,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this agent offers to be an exit node and can really be one.
|
||||
fn exit_ready(&self) -> bool {
|
||||
self.params.exit_offer
|
||||
&& self
|
||||
.params
|
||||
.interface
|
||||
.as_ref()
|
||||
.is_some_and(|interface| interface.exit_offer_ready(self.network_id))
|
||||
}
|
||||
|
||||
/// Whether a member is connected and says it is an exit node.
|
||||
fn exit_node_online(&self, peer: EndpointId) -> bool {
|
||||
self.sessions
|
||||
.get(&peer)
|
||||
.is_some_and(|session| session.exit_node)
|
||||
}
|
||||
|
||||
/// Puts the exit settings into the routing table and the host rules.
|
||||
///
|
||||
/// The routes and the rules stay as they are when the exit node in use
|
||||
/// goes away: dropping them would send the traffic out the old way, in
|
||||
/// the clear, without anyone having asked.
|
||||
async fn apply_exit(&mut self) {
|
||||
self.params
|
||||
.routes
|
||||
.set_exit(self.network_id, self.exit_policy());
|
||||
if let Some(interface) = self.params.interface.clone() {
|
||||
interface.sync_exit_rules().await;
|
||||
}
|
||||
// Said only once it is true: the rules may not have taken, or may
|
||||
// only now be possible.
|
||||
if self.exit_ready() != self.announced_exit {
|
||||
self.reannounce();
|
||||
}
|
||||
}
|
||||
|
||||
/// Says so, once, when the exit node in use is lost or comes back.
|
||||
fn watch_exit_via(&mut self) {
|
||||
let Some(via) = self.params.exit_via else {
|
||||
return;
|
||||
};
|
||||
let online = self.exit_node_online(via);
|
||||
if online == self.exit_via_was_online {
|
||||
return;
|
||||
}
|
||||
self.exit_via_was_online = online;
|
||||
if online {
|
||||
tracing::info!(
|
||||
network = %self.network_id,
|
||||
exit_node = %via.fmt_short(),
|
||||
"the exit node is back"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
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`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_broadcast(&self) {
|
||||
self.params
|
||||
.routes
|
||||
@@ -1123,6 +1251,7 @@ impl Runtime {
|
||||
// a packet for ourselves does not go over a tunnel.
|
||||
let local = self.state.address_of(&self.local_id);
|
||||
let routes = crate::overlay::NetworkRoutes {
|
||||
exit: self.exit_policy(),
|
||||
broadcast: self.broadcast_policy(),
|
||||
range: Some(range),
|
||||
local,
|
||||
@@ -1219,6 +1348,7 @@ impl Runtime {
|
||||
// only once the address is really on the interface.
|
||||
if !is_missing {
|
||||
interface.sync_broadcast_rules().await;
|
||||
self.apply_exit().await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1783,8 +1913,11 @@ impl Runtime {
|
||||
});
|
||||
}
|
||||
capabilities.truncate(self.params.limits.max_capabilities);
|
||||
let exit_node = self.exit_ready();
|
||||
self.announced_exit = exit_node;
|
||||
Announcement {
|
||||
broadcast: self.params.broadcast,
|
||||
exit_node,
|
||||
hostname: self.params.hostname.clone(),
|
||||
capabilities,
|
||||
}
|
||||
@@ -1863,6 +1996,7 @@ impl Runtime {
|
||||
self.metrics.disconnects += 1;
|
||||
self.drop_links_for(peer);
|
||||
self.update_broadcast();
|
||||
self.watch_exit_via();
|
||||
self.update_paths();
|
||||
self.announce_reach(false);
|
||||
for plugin in &self.params.plugins {
|
||||
@@ -1885,6 +2019,33 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the name a peer announced, on disk too when it changed.
|
||||
///
|
||||
/// Written off the loop: a name that is not stored this time is stored
|
||||
/// the next time the peer announces it.
|
||||
fn remember_hostname(&mut self, peer: EndpointId, hostname: &str) {
|
||||
if hostname.is_empty()
|
||||
|| self
|
||||
.known_hostnames
|
||||
.get(&peer)
|
||||
.is_some_and(|h| h == hostname)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.known_hostnames.insert(peer, hostname.to_string());
|
||||
let storage = self.params.storage.clone();
|
||||
let network_id = self.network_id;
|
||||
let hostname = hostname.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = storage
|
||||
.remember_peer_hostname(network_id, *peer.as_bytes(), hostname)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(%err, "could not remember a peer's hostname");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn dispatch_message(&mut self, peer: EndpointId, message: ControlMessage) {
|
||||
match &message {
|
||||
ControlMessage::Announce(announcement) => {
|
||||
@@ -1892,9 +2053,12 @@ impl Runtime {
|
||||
if let Some(session) = self.sessions.get_mut(&peer) {
|
||||
session.hostname = Some(announcement.hostname.clone());
|
||||
session.broadcast = announcement.broadcast;
|
||||
session.exit_node = announcement.exit_node;
|
||||
session.capabilities = capabilities.clone();
|
||||
}
|
||||
self.remember_hostname(peer, &announcement.hostname);
|
||||
self.update_broadcast();
|
||||
self.watch_exit_via();
|
||||
self.dispatch_capabilities(peer, &capabilities);
|
||||
self.ensure_links();
|
||||
self.update_paths();
|
||||
@@ -1992,9 +2156,13 @@ impl Runtime {
|
||||
let snapshot = snapshot_connection(&session.conn);
|
||||
PeerStatus {
|
||||
broadcast: session.broadcast,
|
||||
exit_node: session.exit_node,
|
||||
endpoint_id: session.peer,
|
||||
role: session.role,
|
||||
hostname: session.hostname.clone(),
|
||||
hostname: session
|
||||
.hostname
|
||||
.clone()
|
||||
.or_else(|| self.known_hostnames.get(&session.peer).cloned()),
|
||||
protocols: session
|
||||
.capabilities
|
||||
.iter()
|
||||
@@ -2052,6 +2220,7 @@ impl Runtime {
|
||||
// both hold it would be untrue on one of them.
|
||||
overlay_address_v4: self.state.address_of(&endpoint_id),
|
||||
hostname: self.state.hostname_of(&endpoint_id).map(str::to_string),
|
||||
last_hostname: self.known_hostnames.get(&endpoint_id).cloned(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -2060,6 +2229,18 @@ impl Runtime {
|
||||
|
||||
NetworkStatus {
|
||||
broadcast: self.params.broadcast,
|
||||
exit_offer: self.params.exit_offer,
|
||||
exit_via: self.params.exit_via,
|
||||
exit_via_online: self
|
||||
.params
|
||||
.exit_via
|
||||
.is_some_and(|via| self.exit_node_online(via)),
|
||||
exit_rules: self
|
||||
.params
|
||||
.interface
|
||||
.as_ref()
|
||||
.map(|interface| interface.exit_rules(self.network_id))
|
||||
.unwrap_or_default(),
|
||||
descriptor: self.params.keys.descriptor(),
|
||||
name: self.params.keys.name().clone(),
|
||||
network_id: self.network_id,
|
||||
|
||||
@@ -74,6 +74,8 @@ pub(crate) struct Session {
|
||||
pub(crate) hostname: Option<String>,
|
||||
pub(crate) capabilities: Vec<PluginCapability>,
|
||||
pub(crate) broadcast: bool,
|
||||
/// The peer offers itself as an exit node in this network.
|
||||
pub(crate) exit_node: bool,
|
||||
pub(crate) messages_sent: u64,
|
||||
pub(crate) messages_received: u64,
|
||||
pub(crate) bytes_sent: u64,
|
||||
@@ -228,6 +230,7 @@ pub(crate) fn spawn(
|
||||
hostname: None,
|
||||
capabilities: Vec::new(),
|
||||
broadcast: false,
|
||||
exit_node: false,
|
||||
messages_sent: 0,
|
||||
messages_received: 0,
|
||||
bytes_sent: 0,
|
||||
|
||||
@@ -80,6 +80,11 @@ pub struct MemberStatus {
|
||||
/// Signed, so it is still known while the member is away — which is what
|
||||
/// lets an absent member be named rather than shown as a bare id.
|
||||
pub hostname: Option<String>,
|
||||
/// The last name this agent saw it announce, whether or not it is here.
|
||||
///
|
||||
/// Not signed, so it is a convenience for naming a member that is away
|
||||
/// and never a claim: the zone is served from `hostname`.
|
||||
pub last_hostname: Option<String>,
|
||||
}
|
||||
|
||||
/// Status of one authenticated session.
|
||||
@@ -87,6 +92,8 @@ pub struct MemberStatus {
|
||||
pub struct PeerStatus {
|
||||
/// This authenticated peer accepts network broadcasts.
|
||||
pub broadcast: bool,
|
||||
/// This authenticated peer offers itself as an exit node.
|
||||
pub exit_node: bool,
|
||||
/// Authenticated endpoint id.
|
||||
pub endpoint_id: EndpointId,
|
||||
/// Which side this agent played in the handshake.
|
||||
@@ -159,6 +166,14 @@ pub struct NetworkMetrics {
|
||||
pub struct NetworkStatus {
|
||||
/// Local broadcast participation in this network.
|
||||
pub broadcast: bool,
|
||||
/// This agent offers itself as an exit node in this network.
|
||||
pub exit_offer: bool,
|
||||
/// The member this device sends all its internet traffic through.
|
||||
pub exit_via: Option<EndpointId>,
|
||||
/// Whether that member is connected and still offering to be one.
|
||||
pub exit_via_online: bool,
|
||||
/// What the host rules for the exit settings are doing.
|
||||
pub exit_rules: crate::overlay::ExitRulesReport,
|
||||
/// Immutable deterministic description of the network space.
|
||||
pub descriptor: NetworkDescriptor,
|
||||
/// Network name, for convenience.
|
||||
|
||||
@@ -83,6 +83,8 @@ struct TunnelPeerState {
|
||||
link: SharedLink,
|
||||
tx_packets: AtomicU64,
|
||||
rx_packets: AtomicU64,
|
||||
tx_bytes: AtomicU64,
|
||||
rx_bytes: AtomicU64,
|
||||
dropped: AtomicU64,
|
||||
established_at: std::time::Instant,
|
||||
}
|
||||
@@ -100,6 +102,10 @@ pub struct TunnelPeerReport {
|
||||
pub tx_packets: u64,
|
||||
/// Packets successfully received and decrypted.
|
||||
pub rx_packets: u64,
|
||||
/// Plaintext bytes successfully encrypted and sent.
|
||||
pub tx_bytes: u64,
|
||||
/// Plaintext bytes successfully received and decrypted.
|
||||
pub rx_bytes: u64,
|
||||
/// Packets dropped due to encryption/decryption/send failures.
|
||||
pub dropped: u64,
|
||||
/// Uptime in seconds since the data link was established.
|
||||
@@ -145,6 +151,8 @@ impl<C: TunnelCodec> GenericTunnelPlugin<C> {
|
||||
path: state.link.path_description(),
|
||||
tx_packets: state.tx_packets.load(Ordering::Relaxed),
|
||||
rx_packets: state.rx_packets.load(Ordering::Relaxed),
|
||||
tx_bytes: state.tx_bytes.load(Ordering::Relaxed),
|
||||
rx_bytes: state.rx_bytes.load(Ordering::Relaxed),
|
||||
dropped: state.dropped.load(Ordering::Relaxed),
|
||||
uptime_secs: state.established_at.elapsed().as_secs(),
|
||||
})
|
||||
@@ -223,6 +231,9 @@ impl<C: TunnelCodec> IpPlugin for GenericTunnelPlugin<C> {
|
||||
Ok(ciphertext) => {
|
||||
if state.link.send(ciphertext).is_ok() {
|
||||
state.tx_packets.fetch_add(1, Ordering::Relaxed);
|
||||
state
|
||||
.tx_bytes
|
||||
.fetch_add(packet.len() as u64, Ordering::Relaxed);
|
||||
true
|
||||
} else {
|
||||
state.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -242,6 +253,8 @@ impl<C: TunnelCodec> IpPlugin for GenericTunnelPlugin<C> {
|
||||
link: Arc::clone(&link),
|
||||
tx_packets: AtomicU64::new(0),
|
||||
rx_packets: AtomicU64::new(0),
|
||||
tx_bytes: AtomicU64::new(0),
|
||||
rx_bytes: AtomicU64::new(0),
|
||||
dropped: AtomicU64::new(0),
|
||||
established_at: std::time::Instant::now(),
|
||||
});
|
||||
@@ -268,6 +281,9 @@ impl<C: TunnelCodec> IpPlugin for GenericTunnelPlugin<C> {
|
||||
match codec.decrypt(network, peer, &payload) {
|
||||
Ok(packet) => {
|
||||
rx_state.rx_packets.fetch_add(1, Ordering::Relaxed);
|
||||
rx_state
|
||||
.rx_bytes
|
||||
.fetch_add(packet.len() as u64, Ordering::Relaxed);
|
||||
if let Some(ref sink) = sink {
|
||||
sink.deliver(network, peer, packet).await;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,13 @@ pub enum Error {
|
||||
what: String,
|
||||
},
|
||||
|
||||
/// The request is understood and cannot be done as things stand.
|
||||
#[error("{reason}")]
|
||||
Rejected {
|
||||
/// Why, phrased so the message stands on its own.
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// Discovery backend failure. Never fatal for the agent.
|
||||
#[error("discovery error: {0}")]
|
||||
Discovery(String),
|
||||
|
||||
@@ -44,6 +44,21 @@ use windows as transport;
|
||||
#[cfg(any(unix, windows))]
|
||||
pub use transport::ControlSocket;
|
||||
|
||||
/// Who may open the local control socket.
|
||||
///
|
||||
/// The default is owner-only. A system service shares its socket with a GUI or
|
||||
/// CLI running as another user by granting a group; the agent sets that on the
|
||||
/// socket when it binds it, so the access is correct from creation rather than
|
||||
/// patched afterwards.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ControlSocketAccess {
|
||||
/// Owner only — mode `0600`. The per-user agent's default.
|
||||
Private,
|
||||
/// The owner and members of this group — the socket's group is set to this
|
||||
/// gid and the mode to `0660`. The caller resolves a group name to its gid.
|
||||
Group(u32),
|
||||
}
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -58,6 +73,31 @@ use crate::error::{Error, Result};
|
||||
/// Largest accepted local control message.
|
||||
pub const MAX_MESSAGE_LEN: usize = 1024 * 1024;
|
||||
|
||||
/// A string the control protocol must carry but that must never appear in a
|
||||
/// log: it serialises transparently (the text crosses the socket intact) but
|
||||
/// its `Debug` is redacted. Used for a network secret handed back to a local
|
||||
/// client that asked to copy it.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RedactedText(String);
|
||||
|
||||
impl RedactedText {
|
||||
/// Wraps a string.
|
||||
pub fn new(text: impl Into<String>) -> Self {
|
||||
Self(text.into())
|
||||
}
|
||||
|
||||
/// Unwraps to the plain string.
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RedactedText {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("<redacted>")
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the control socket for a state directory lives.
|
||||
///
|
||||
/// A Unix socket path is limited to around 100 bytes, which a state directory
|
||||
@@ -108,10 +148,17 @@ pub fn default_control_socket_path() -> PathBuf {
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
// Prefer a running system service's socket when it is present, so a
|
||||
// GUI or CLI reaches the packaged daemon with no configuration; fall
|
||||
// back to the per-user location otherwise.
|
||||
let system = PathBuf::from("/run/tsunagi/agent.sock");
|
||||
if system.exists() {
|
||||
return system;
|
||||
}
|
||||
if let Ok(paths) = crate::config::StoragePaths::user_default() {
|
||||
control_socket_path(&paths.state_dir)
|
||||
} else {
|
||||
PathBuf::from("/run/tsunagi/agent.sock")
|
||||
system
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +227,29 @@ pub enum Request {
|
||||
#[serde(default)]
|
||||
broadcast: Option<bool>,
|
||||
},
|
||||
/// Ask for a configured network's shared secret, in its `tsn1…` text form,
|
||||
/// so a local GUI or script can copy it to re-share. The reply travels only
|
||||
/// over the owner/group socket and the secret is never logged.
|
||||
Secret {
|
||||
/// Which network, by the id text `status` prints.
|
||||
network_id: String,
|
||||
},
|
||||
/// Offer this agent as an exit node in one network, or stop. Off unless
|
||||
/// asked.
|
||||
SetExitOffer {
|
||||
/// Public network identifier.
|
||||
network_id: String,
|
||||
/// Whether members may send their internet traffic through this agent.
|
||||
enabled: bool,
|
||||
},
|
||||
/// Send all this device's internet traffic through a member of one
|
||||
/// network, or back the ordinary way.
|
||||
SetExitNode {
|
||||
/// Public network identifier.
|
||||
network_id: String,
|
||||
/// The member's endpoint id, or `None` to stop using an exit node.
|
||||
peer: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Request {
|
||||
@@ -200,6 +270,14 @@ impl std::fmt::Debug for Request {
|
||||
write!(f, "Dns {{ enable: {enable}, port: {port:?} }}")
|
||||
}
|
||||
Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: <redacted> }}"),
|
||||
Request::Secret { network_id } => write!(f, "Secret {{ {network_id} }}"),
|
||||
Request::SetExitOffer {
|
||||
network_id,
|
||||
enabled,
|
||||
} => write!(f, "SetExitOffer {{ {network_id}, enabled: {enabled} }}"),
|
||||
Request::SetExitNode { network_id, peer } => {
|
||||
write!(f, "SetExitNode {{ {network_id}, peer: {peer:?} }}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +302,12 @@ pub enum Response {
|
||||
///
|
||||
/// `None` means it is not serving at all.
|
||||
Dns(Option<DnsReport>),
|
||||
/// A network's shared secret, redacted in `Debug`.
|
||||
Secret(RedactedText),
|
||||
/// Whether this agent now offers itself as an exit node.
|
||||
ExitOffer(bool),
|
||||
/// The member this device now sends its internet traffic through.
|
||||
ExitNode(Option<String>),
|
||||
/// The request could not be served.
|
||||
Error(String),
|
||||
}
|
||||
@@ -295,6 +379,38 @@ pub struct StatusReport {
|
||||
pub networks: Vec<NetworkReport>,
|
||||
/// The local DNS service, when one was asked for.
|
||||
pub dns: Option<DnsReport>,
|
||||
/// The state directory the agent is actually using.
|
||||
///
|
||||
/// A client that was not told one would look in its own default, which is
|
||||
/// not the agent's when the agent runs as a service under another user.
|
||||
pub state_dir: String,
|
||||
/// The cache directory the agent is actually using.
|
||||
pub cache_dir: String,
|
||||
/// The executable the agent runs, which is what needs the capability.
|
||||
pub program: String,
|
||||
/// Whether the agent itself can manage the overlay interface.
|
||||
///
|
||||
/// It is the agent that creates the interface, so the client's own
|
||||
/// capabilities say nothing about it.
|
||||
pub privilege: PrivilegeReport,
|
||||
/// The version of the agent's software.
|
||||
pub version: String,
|
||||
/// The data plane protocols it has, each as `name vN` (wire version).
|
||||
pub protocols: Vec<String>,
|
||||
}
|
||||
|
||||
/// What the agent found when it checked whether it may manage an interface.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PrivilegeReport {
|
||||
/// Not checked.
|
||||
#[default]
|
||||
Unknown,
|
||||
/// It can.
|
||||
Available,
|
||||
/// It cannot, with what was found.
|
||||
Missing(String),
|
||||
/// This platform has no provisioner yet.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// The local DNS service.
|
||||
@@ -368,6 +484,9 @@ pub struct NetworkReport {
|
||||
pub range_conflict: Option<String>,
|
||||
/// The overlay, when a protocol is running one.
|
||||
pub overlay: Option<OverlayReport>,
|
||||
/// Exit-node settings: whether this agent offers one, and whether it
|
||||
/// uses one.
|
||||
pub exit: ExitReport,
|
||||
}
|
||||
|
||||
/// One member of the network, from signed state.
|
||||
@@ -379,6 +498,8 @@ pub struct MemberReport {
|
||||
pub overlay_address_v4: Option<String>,
|
||||
/// Consecutive failed dial attempts, when this agent is trying to reach it.
|
||||
pub failed_dials: u32,
|
||||
/// The last hostname this agent saw it announce, kept while it is away.
|
||||
pub hostname: Option<String>,
|
||||
}
|
||||
|
||||
/// One control plane peer.
|
||||
@@ -393,6 +514,8 @@ pub struct PeerReport {
|
||||
pub transport: String,
|
||||
/// Round-trip time in milliseconds, when a path is selected.
|
||||
pub rtt_ms: Option<u64>,
|
||||
/// The peer offers itself as an exit node in this network.
|
||||
pub exit_node: bool,
|
||||
}
|
||||
|
||||
/// The WireGuard overlay of one network.
|
||||
@@ -425,6 +548,35 @@ pub struct OverlayReport {
|
||||
pub broadcast_rules: Option<HostRulesReport>,
|
||||
}
|
||||
|
||||
/// How one set of exit-node host rules went.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuleSetReport {
|
||||
/// Whether every step took.
|
||||
pub ok: bool,
|
||||
/// One line: what is in place, or what is missing and why.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// One network's exit-node settings and what they are doing.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExitReport {
|
||||
/// This agent offers itself as an exit node in this network.
|
||||
pub offering: bool,
|
||||
/// Masquerading and forwarding rules for members' traffic, when offering.
|
||||
pub offer_rules: Option<RuleSetReport>,
|
||||
/// Whether the kernel forwards packets for the overlay interface.
|
||||
/// `Some(false)` means the rules are in place and nothing passes until it
|
||||
/// is turned on. `None` when unknown.
|
||||
pub forwarding: Option<bool>,
|
||||
/// The member this device sends its internet traffic through, as an
|
||||
/// endpoint id.
|
||||
pub via: Option<String>,
|
||||
/// Whether that member is connected and still offering to be one.
|
||||
pub via_online: bool,
|
||||
/// The routes that send this device's traffic through it.
|
||||
pub client_rules: Option<RuleSetReport>,
|
||||
}
|
||||
|
||||
/// What the agent installed on the host so LAN broadcast reaches the overlay.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostRulesReport {
|
||||
@@ -457,6 +609,10 @@ pub struct OverlayPeerReport {
|
||||
pub tx_packets: u64,
|
||||
/// Packets decrypted from this peer.
|
||||
pub rx_packets: u64,
|
||||
/// Plaintext bytes encrypted and sent to this peer.
|
||||
pub tx_bytes: u64,
|
||||
/// Plaintext bytes decrypted from this peer.
|
||||
pub rx_bytes: u64,
|
||||
/// Data packets dropped: wrong source address, or too large for the path.
|
||||
pub dropped: u64,
|
||||
/// WireGuard protocol errors.
|
||||
@@ -560,6 +716,39 @@ pub trait ReportSource: Send + Sync + 'static {
|
||||
) -> BoxFuture<'_, std::result::Result<Option<DnsReport>, String>> {
|
||||
Box::pin(async move { Err("this agent cannot serve DNS".to_string()) })
|
||||
}
|
||||
|
||||
/// Returns a configured network's shared secret in its `tsn1…` text form.
|
||||
///
|
||||
/// Defaulted to a refusal, like the others. An implementor reads it from
|
||||
/// local state; it must never be logged.
|
||||
fn network_secret(
|
||||
&self,
|
||||
_network_id: String,
|
||||
) -> BoxFuture<'_, std::result::Result<String, String>> {
|
||||
Box::pin(async move { Err("this agent cannot read secrets".to_string()) })
|
||||
}
|
||||
|
||||
/// Offers this agent as an exit node in a network, or stops.
|
||||
///
|
||||
/// Defaulted to a refusal, like the others.
|
||||
fn set_exit_offer(
|
||||
&self,
|
||||
_network_id: String,
|
||||
_enabled: bool,
|
||||
) -> BoxFuture<'_, std::result::Result<bool, String>> {
|
||||
Box::pin(async move { Err("this agent cannot be an exit node".to_string()) })
|
||||
}
|
||||
|
||||
/// Sends this device's internet traffic through a member, or stops.
|
||||
///
|
||||
/// Defaulted to a refusal, like the others.
|
||||
fn set_exit_node(
|
||||
&self,
|
||||
_network_id: String,
|
||||
_peer: Option<String>,
|
||||
) -> BoxFuture<'_, std::result::Result<Option<String>, String>> {
|
||||
Box::pin(async move { Err("this agent cannot use an exit node".to_string()) })
|
||||
}
|
||||
}
|
||||
|
||||
impl<F> ReportSource for F
|
||||
@@ -590,7 +779,7 @@ pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
///
|
||||
/// Bump it whenever [`Request`], [`Response`] or anything they contain
|
||||
/// changes shape.
|
||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 15]);
|
||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 17]);
|
||||
|
||||
/// Reads one request off an accepted stream, answers it, writes the response.
|
||||
///
|
||||
@@ -648,6 +837,23 @@ where
|
||||
Ok(report) => Response::Dns(report),
|
||||
Err(reason) => Response::Error(reason),
|
||||
},
|
||||
Request::Secret { network_id } => match source.network_secret(network_id).await {
|
||||
Ok(secret) => Response::Secret(RedactedText::new(secret)),
|
||||
Err(reason) => Response::Error(reason),
|
||||
},
|
||||
Request::SetExitOffer {
|
||||
network_id,
|
||||
enabled,
|
||||
} => match source.set_exit_offer(network_id, enabled).await {
|
||||
Ok(enabled) => Response::ExitOffer(enabled),
|
||||
Err(reason) => Response::Error(reason),
|
||||
},
|
||||
Request::SetExitNode { network_id, peer } => {
|
||||
match source.set_exit_node(network_id, peer).await {
|
||||
Ok(peer) => Response::ExitNode(peer),
|
||||
Err(reason) => Response::Error(reason),
|
||||
}
|
||||
}
|
||||
};
|
||||
write_message(&mut stream, &response).await
|
||||
}
|
||||
@@ -802,6 +1008,56 @@ pub async fn set_dns(
|
||||
}
|
||||
}
|
||||
|
||||
/// Offers the running agent as an exit node in one network, or stops.
|
||||
pub async fn set_exit_offer(
|
||||
path: impl AsRef<Path>,
|
||||
network_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<bool> {
|
||||
let request = Request::SetExitOffer {
|
||||
network_id: network_id.to_owned(),
|
||||
enabled,
|
||||
};
|
||||
match exchange(path.as_ref(), &request, EXCHANGE_TIMEOUT).await? {
|
||||
Response::ExitOffer(enabled) => Ok(enabled),
|
||||
Response::Error(message) => Err(Error::Storage(message)),
|
||||
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the running agent's internet traffic through a member of one
|
||||
/// network, or back the ordinary way with `None`.
|
||||
pub async fn set_exit_node(
|
||||
path: impl AsRef<Path>,
|
||||
network_id: &str,
|
||||
peer: Option<&str>,
|
||||
) -> Result<Option<String>> {
|
||||
let request = Request::SetExitNode {
|
||||
network_id: network_id.to_owned(),
|
||||
peer: peer.map(str::to_owned),
|
||||
};
|
||||
match exchange(path.as_ref(), &request, EXCHANGE_TIMEOUT).await? {
|
||||
Response::ExitNode(peer) => Ok(peer),
|
||||
Response::Error(message) => Err(Error::Storage(message)),
|
||||
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks a running agent for a configured network's shared secret, in its
|
||||
/// `tsn1…` text form, so a local client can copy it. The secret is never
|
||||
/// logged by this path.
|
||||
pub async fn network_secret(path: impl AsRef<Path>, network_id: &str) -> Result<String> {
|
||||
let path = path.as_ref();
|
||||
let request = Request::Secret {
|
||||
network_id: network_id.to_string(),
|
||||
};
|
||||
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
|
||||
Response::Secret(secret) => Ok(secret.into_string()),
|
||||
Response::Error(reason) => Err(Error::Storage(reason)),
|
||||
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn write_message<S, T>(stream: &mut S, value: &T) -> Result<()>
|
||||
where
|
||||
S: AsyncWrite + Unpin,
|
||||
|
||||
@@ -22,8 +22,8 @@ use crate::error::{Error, Result};
|
||||
// The transport-agnostic surface, re-exported so this module is a complete
|
||||
// view of the local control interface on its own.
|
||||
pub use super::{
|
||||
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
|
||||
request_status, set_active, set_dns, set_hostname,
|
||||
CONTROL_PROTOCOL, ControlSocketAccess, EXCHANGE_TIMEOUT, ReportSource, is_serving,
|
||||
join_network, leave_network, request_status, set_active, set_dns, set_hostname,
|
||||
};
|
||||
|
||||
/// Serves the local control interface on a Unix socket.
|
||||
@@ -39,11 +39,15 @@ impl ControlSocket {
|
||||
/// A socket file left behind by a crashed agent is replaced, but only
|
||||
/// after checking that nothing is listening on it, so two live agents
|
||||
/// never fight over one path.
|
||||
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
|
||||
pub async fn bind(
|
||||
path: impl AsRef<Path>,
|
||||
source: Arc<dyn ReportSource>,
|
||||
access: ControlSocketAccess,
|
||||
) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
crate::storage::create_dir(parent)?;
|
||||
create_parent(parent, &access)?;
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
@@ -61,7 +65,7 @@ impl ControlSocket {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
restrict(&path)?;
|
||||
apply_access(&path, &access)?;
|
||||
|
||||
let task = tokio::spawn(serve(listener, source));
|
||||
Ok(Self {
|
||||
@@ -94,9 +98,48 @@ impl Drop for ControlSocket {
|
||||
}
|
||||
}
|
||||
|
||||
fn restrict(path: &Path) -> Result<()> {
|
||||
/// Creates the socket's parent directory.
|
||||
///
|
||||
/// A group-shared socket must sit in a directory the group can traverse, so it
|
||||
/// is `0755`; a private socket keeps the owner-only state-directory default.
|
||||
fn create_parent(parent: &Path, access: &ControlSocketAccess) -> Result<()> {
|
||||
match access {
|
||||
ControlSocketAccess::Private => crate::storage::create_dir(parent),
|
||||
ControlSocketAccess::Group(_) => {
|
||||
std::fs::create_dir_all(parent).map_err(|source| Error::Io {
|
||||
path: parent.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
set_mode(parent, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the access policy to the bound socket.
|
||||
///
|
||||
/// Private is owner-only `0600`. Group sets the socket's group to the gid and
|
||||
/// the mode to `0660`; if the group cannot be set — the agent is not a member,
|
||||
/// say — it falls back to owner-only rather than leaving the group wrong, so
|
||||
/// the socket is never more open than intended.
|
||||
fn apply_access(path: &Path, access: &ControlSocketAccess) -> Result<()> {
|
||||
match access {
|
||||
ControlSocketAccess::Private => set_mode(path, 0o600),
|
||||
ControlSocketAccess::Group(gid) => {
|
||||
if let Err(source) = std::os::unix::fs::chown(path, None, Some(*gid)) {
|
||||
tracing::warn!(
|
||||
path = %path.display(), gid, %source,
|
||||
"cannot set the control socket group; keeping it owner-only"
|
||||
);
|
||||
return set_mode(path, 0o600);
|
||||
}
|
||||
set_mode(path, 0o660)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mode(path: &Path, mode: u32) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|source| {
|
||||
Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
@@ -170,7 +213,9 @@ mod tests {
|
||||
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
||||
Box::pin(async { StatusReport::default() })
|
||||
});
|
||||
let control = ControlSocket::bind(&path, source).await.unwrap();
|
||||
let control = ControlSocket::bind(&path, source, ControlSocketAccess::Private)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
|
||||
.await
|
||||
|
||||
@@ -40,8 +40,8 @@ use tokio::task::JoinHandle;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
pub use super::{
|
||||
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
|
||||
request_status, set_active, set_dns, set_hostname,
|
||||
CONTROL_PROTOCOL, ControlSocketAccess, EXCHANGE_TIMEOUT, ReportSource, is_serving,
|
||||
join_network, leave_network, request_status, set_active, set_dns, set_hostname,
|
||||
};
|
||||
|
||||
/// `ERROR_ACCESS_DENIED`: creating a first instance finds an existing pipe,
|
||||
@@ -149,7 +149,16 @@ impl ControlSocket {
|
||||
/// Claiming the first instance of the name is what detects a second agent:
|
||||
/// if the name already exists, the create is refused and that is reported
|
||||
/// as the state being locked, so two live agents never share one pipe.
|
||||
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
|
||||
///
|
||||
/// `_access` is accepted for parity with the Unix socket; granting a group
|
||||
/// here means a pipe security descriptor and is a separate follow-up, so for
|
||||
/// now the pipe keeps its default (creator plus the built-in access the
|
||||
/// `PipeSecurityAttributes` set).
|
||||
pub async fn bind(
|
||||
path: impl AsRef<Path>,
|
||||
source: Arc<dyn ReportSource>,
|
||||
_access: ControlSocketAccess,
|
||||
) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let name = pipe_name(&path);
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ mod tests {
|
||||
fn limited_and_directed_broadcasts_never_cross_domains_or_target_disabled_peers() {
|
||||
let peer = iroh::SecretKey::from_bytes(&[8; 32]).public();
|
||||
let a = NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
range: Some("10.0.0.0/24".parse().unwrap()),
|
||||
local: Some("10.0.0.1".parse().unwrap()),
|
||||
peers: vec![("10.0.0.2".parse().unwrap(), peer)],
|
||||
@@ -149,6 +150,7 @@ mod tests {
|
||||
},
|
||||
};
|
||||
let b = NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
range: Some("10.1.0.0/24".parse().unwrap()),
|
||||
local: Some("10.1.0.1".parse().unwrap()),
|
||||
peers: vec![("10.1.0.2".parse().unwrap(), peer)],
|
||||
@@ -185,6 +187,7 @@ mod tests {
|
||||
assert!(!table.accepts(id("b"), peer, Ipv4Addr::BROADCAST));
|
||||
assert!(table.accepts(id("a"), peer, Ipv4Addr::BROADCAST));
|
||||
let disabled = NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: BroadcastPolicy {
|
||||
enabled: false,
|
||||
..a.broadcast.clone()
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Exit-node host rules: what makes this device an exit node for others, and
|
||||
//! what sends this device's own traffic through one.
|
||||
//!
|
||||
//! The packet side lives in [`super::router`]; this is the side the operating
|
||||
//! system sees. It is kept apart from the broadcast rules, which have a
|
||||
//! different lifetime and different tags.
|
||||
|
||||
/// How one set of host rules went.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RuleSetReport {
|
||||
/// Whether every step took.
|
||||
pub ok: bool,
|
||||
/// One line: what is in place, or what is missing and why.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// What the host rules of one network's exit-node settings are doing.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ExitRulesReport {
|
||||
/// Masquerading and forwarding for members' traffic, when this agent
|
||||
/// offers itself as an exit node here. `None` means none are wanted.
|
||||
pub offer: Option<RuleSetReport>,
|
||||
/// Whether the kernel forwards packets for the overlay interface.
|
||||
///
|
||||
/// `Some(false)` means the rules are in place but nothing passes until
|
||||
/// forwarding is turned on, which the agent never does itself. `None`
|
||||
/// when it could not be read.
|
||||
pub forwarding: Option<bool>,
|
||||
/// The routes that send this device's traffic through its exit node.
|
||||
/// `None` means it uses none through this network.
|
||||
pub client: Option<RuleSetReport>,
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Host rules for exit nodes: what the operating system needs for this agent
|
||||
//! to carry other members' internet traffic, and for this device's own
|
||||
//! traffic to go through a member.
|
||||
//!
|
||||
//! Two independent halves, planned together because one interface carries
|
||||
//! both:
|
||||
//!
|
||||
//! * **Offering** — one set of rules per network that offers this agent as an
|
||||
//! exit node, chosen by that network's range: masquerade the range out of
|
||||
//! every other interface and let it through the forward chain. A network
|
||||
//! that does not offer has no rules, so its members' packets are dropped by
|
||||
//! the firewall as well as by the agent.
|
||||
//! * **Using** — policy routing that sends everything the host does not have
|
||||
//! a more specific route for into the overlay interface, while the agent's
|
||||
//! own sockets keep the ordinary route (see `linux_exit`).
|
||||
//!
|
||||
//! Nothing a peer announces becomes a host setting. Offering is the owner's
|
||||
//! switch; using is the user's choice of a member. Every object is tagged,
|
||||
//! reversible, and removed when it is no longer wanted, when the network
|
||||
//! leaves or stops, and when the agent stops.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::state::Ipv4Range;
|
||||
|
||||
use super::RuleOutcome;
|
||||
|
||||
/// What the host should hold for one interface's exit settings.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ExitHostPlan {
|
||||
/// The overlay interface.
|
||||
pub interface: String,
|
||||
/// The ranges of every network that offers this agent as an exit node.
|
||||
pub offer: Vec<Ipv4Range>,
|
||||
/// Whether this device sends its internet traffic through a member.
|
||||
pub client: bool,
|
||||
}
|
||||
|
||||
impl ExitHostPlan {
|
||||
/// Whether there is nothing to hold.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.offer.is_empty() && !self.client
|
||||
}
|
||||
}
|
||||
|
||||
/// What applying a plan achieved.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ExitHostReport {
|
||||
/// One outcome per offered range: its masquerade and forward rules.
|
||||
pub offer: Vec<(Ipv4Range, RuleOutcome)>,
|
||||
/// Whether the kernel forwards packets for the interface. `None` when it
|
||||
/// could not be read. The agent only reads this: turning forwarding on is
|
||||
/// the owner's decision.
|
||||
pub forwarding: Option<bool>,
|
||||
/// The routes and rules that send this device's traffic through the
|
||||
/// interface, when the plan asked for them.
|
||||
pub client: Option<RuleOutcome>,
|
||||
}
|
||||
|
||||
/// Installs and removes exit-node rules for an overlay interface.
|
||||
///
|
||||
/// Like the broadcast rules, no call fails as a whole: a step that cannot be
|
||||
/// done is in the report, because the caller's only useful response is to
|
||||
/// say so.
|
||||
pub trait ExitHostRules: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// Makes the host match the plan, replacing what this agent put there
|
||||
/// for the interface before.
|
||||
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport>;
|
||||
|
||||
/// Removes everything this agent put there for the interface. Succeeds
|
||||
/// when nothing is there: it runs on the shutdown path, and once at
|
||||
/// startup to take away what a crashed run left.
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>;
|
||||
}
|
||||
|
||||
/// The tag every firewall object of one offered range carries.
|
||||
pub fn exit_tag(interface: &str, range: &Ipv4Range) -> String {
|
||||
format!("{}{range}", exit_tag_prefix(interface))
|
||||
}
|
||||
|
||||
/// The start of every tag for an interface; what cleanup selects by.
|
||||
pub fn exit_tag_prefix(interface: &str) -> String {
|
||||
format!("tsunagi-exit:{interface}:")
|
||||
}
|
||||
|
||||
/// The one-line detail for a set of offer outcomes.
|
||||
pub fn offer_detail(outcome: &RuleOutcome, range: &Ipv4Range) -> String {
|
||||
match outcome {
|
||||
RuleOutcome::Applied => format!("masquerade + forward for {range} in place"),
|
||||
RuleOutcome::Failed(reason) => reason.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-memory host for exit rules, for tests: nothing is touched.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MockExitRules {
|
||||
state: Arc<Mutex<Option<ExitHostPlan>>>,
|
||||
calls: Arc<Mutex<Vec<String>>>,
|
||||
forwarding: Arc<Mutex<Option<bool>>>,
|
||||
failure: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl MockExitRules {
|
||||
/// An empty host with forwarding turned on.
|
||||
pub fn new() -> Self {
|
||||
let rules = Self::default();
|
||||
*lock(&rules.forwarding) = Some(true);
|
||||
rules
|
||||
}
|
||||
|
||||
/// What the host reports for kernel forwarding from now on.
|
||||
pub fn set_forwarding(&self, forwarding: Option<bool>) {
|
||||
*lock(&self.forwarding) = forwarding;
|
||||
}
|
||||
|
||||
/// Makes every step fail with this reason from now on.
|
||||
pub fn fail_with(&self, reason: Option<String>) {
|
||||
*lock(&self.failure) = reason;
|
||||
}
|
||||
|
||||
/// What is installed now.
|
||||
pub fn installed(&self) -> Option<ExitHostPlan> {
|
||||
lock(&self.state).clone()
|
||||
}
|
||||
|
||||
/// Every `apply:<interface>` / `clear:<interface>` call, in order.
|
||||
pub fn calls(&self) -> Vec<String> {
|
||||
lock(&self.calls).clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ExitHostRules for MockExitRules {
|
||||
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
|
||||
Box::pin(async move {
|
||||
lock(&self.calls).push(format!("apply:{}", plan.interface));
|
||||
let outcome = match lock(&self.failure).clone() {
|
||||
Some(reason) => RuleOutcome::Failed(reason),
|
||||
None => {
|
||||
*lock(&self.state) = Some(plan.clone());
|
||||
RuleOutcome::Applied
|
||||
}
|
||||
};
|
||||
ExitHostReport {
|
||||
offer: plan
|
||||
.offer
|
||||
.iter()
|
||||
.map(|range| (*range, outcome.clone()))
|
||||
.collect(),
|
||||
forwarding: *lock(&self.forwarding),
|
||||
client: plan.client.then_some(outcome),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
lock(&self.calls).push(format!("clear:{interface}"));
|
||||
*lock(&self.state) = None;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn range() -> Ipv4Range {
|
||||
"10.13.37.0/24".parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_name_the_interface_and_the_range() {
|
||||
assert_eq!(
|
||||
exit_tag("tsun0", &range()),
|
||||
"tsunagi-exit:tsun0:10.13.37.0/24"
|
||||
);
|
||||
assert!(exit_tag("tsun0", &range()).starts_with(&exit_tag_prefix("tsun0")));
|
||||
// Another interface's tag never matches this one's prefix.
|
||||
assert!(!exit_tag("tsun01", &range()).starts_with(&exit_tag_prefix("tsun0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_plan_is_nothing_to_hold() {
|
||||
let mut plan = ExitHostPlan::default();
|
||||
assert!(plan.is_empty());
|
||||
plan.client = true;
|
||||
assert!(!plan.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_mock_applies_reports_and_clears() {
|
||||
let rules = MockExitRules::new();
|
||||
let plan = ExitHostPlan {
|
||||
interface: "tsun0".into(),
|
||||
offer: vec![range()],
|
||||
client: true,
|
||||
};
|
||||
let report = rules.apply(&plan).await;
|
||||
assert_eq!(report.offer[0].1, RuleOutcome::Applied);
|
||||
assert_eq!(report.forwarding, Some(true));
|
||||
assert_eq!(report.client, Some(RuleOutcome::Applied));
|
||||
assert_eq!(rules.installed(), Some(plan));
|
||||
|
||||
rules.set_forwarding(Some(false));
|
||||
rules.fail_with(Some("denied".into()));
|
||||
let report = rules
|
||||
.apply(&ExitHostPlan {
|
||||
interface: "tsun0".into(),
|
||||
offer: vec![range()],
|
||||
client: false,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(report.forwarding, Some(false));
|
||||
assert!(matches!(report.offer[0].1, RuleOutcome::Failed(_)));
|
||||
assert_eq!(report.client, None);
|
||||
|
||||
rules.clear("tsun0").await;
|
||||
assert_eq!(rules.installed(), None);
|
||||
assert_eq!(rules.calls(), ["apply:tsun0", "apply:tsun0", "clear:tsun0"]);
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ fn pick_iptables(exists: impl Fn(&str) -> bool) -> Option<&'static str> {
|
||||
|
||||
/// What running `iptables` produced.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Ran {
|
||||
pub(super) enum Ran {
|
||||
/// There is no `iptables` to run.
|
||||
Missing,
|
||||
/// It ran, and exited like this.
|
||||
@@ -63,7 +63,7 @@ enum Ran {
|
||||
|
||||
/// Runs `iptables` with these arguments. A function value so the flow below
|
||||
/// can be tested without touching the host.
|
||||
type Run<'a> = &'a dyn Fn(&[String]) -> Ran;
|
||||
pub(super) type Run<'a> = &'a dyn Fn(&[String]) -> Ran;
|
||||
|
||||
/// The match and target of the rule, without the chain or position.
|
||||
fn rule_spec(plan: &BroadcastRulesPlan) -> Vec<String> {
|
||||
@@ -88,7 +88,7 @@ fn rule_spec(plan: &BroadcastRulesPlan) -> Vec<String> {
|
||||
|
||||
/// Waits briefly for the xtables lock instead of failing when another tool
|
||||
/// holds it.
|
||||
fn locked(args: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
pub(super) fn locked(args: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
["-w", "5"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
@@ -118,7 +118,7 @@ fn list_args() -> Vec<String> {
|
||||
|
||||
/// Splits one `iptables -S` line into words, honouring double quotes, which
|
||||
/// `iptables` puts around comments that need them.
|
||||
fn split_rule_line(line: &str) -> Vec<String> {
|
||||
pub(super) fn split_rule_line(line: &str) -> Vec<String> {
|
||||
let mut words = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut quoted = false;
|
||||
@@ -175,7 +175,7 @@ fn tagged_deletions(listing: &str, tag: &str) -> Vec<Vec<String>> {
|
||||
}
|
||||
|
||||
/// A failure message with the likely fix for the likely cause.
|
||||
fn failure(stderr: &str) -> String {
|
||||
pub(super) fn failure(stderr: &str) -> String {
|
||||
let text = stderr.trim();
|
||||
let lower = text.to_ascii_lowercase();
|
||||
if lower.contains("permission denied") || lower.contains("must be root") {
|
||||
@@ -261,7 +261,7 @@ fn clear_firewall(run: Run<'_>, interface: &str) {
|
||||
}
|
||||
|
||||
/// Runs the real `iptables`.
|
||||
fn run_iptables(args: &[String]) -> Ran {
|
||||
pub(super) fn run_iptables(args: &[String]) -> Ran {
|
||||
let Some(program) = pick_iptables(|path| std::path::Path::new(path).is_file()) else {
|
||||
return Ran::Missing;
|
||||
};
|
||||
@@ -283,7 +283,7 @@ fn run_iptables(args: &[String]) -> Ran {
|
||||
/// The Linux implementation: netlink for the route, `iptables` for the rule.
|
||||
#[derive(Debug)]
|
||||
pub struct LinuxHostRules {
|
||||
route: RouteHandle,
|
||||
pub(super) route: RouteHandle,
|
||||
}
|
||||
|
||||
impl LinuxHostRules {
|
||||
@@ -306,6 +306,10 @@ impl BroadcastHostRules for LinuxHostRules {
|
||||
"netlink+iptables"
|
||||
}
|
||||
|
||||
fn exit_rules(&self) -> Option<&dyn super::ExitHostRules> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
|
||||
Box::pin(async move {
|
||||
let route = self.set_route(plan.interface.clone(), plan.source).await;
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
//! Exit-node host rules on Linux: `iptables` for the offering side, policy
|
||||
//! routing over netlink for the using side.
|
||||
//!
|
||||
//! # Offering
|
||||
//!
|
||||
//! For every network that offers this agent as an exit node, three rules,
|
||||
//! selected by that network's range and carrying the tag
|
||||
//! `tsunagi-exit:<if>:<range>`:
|
||||
//!
|
||||
//! ```text
|
||||
//! -t nat POSTROUTING -s <range> ! -o <if> -j MASQUERADE
|
||||
//! -t filter FORWARD -i <if> -s <range> ! -o <if> -j ACCEPT
|
||||
//! -t filter FORWARD -o <if> -d <range> -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
//! ```
|
||||
//!
|
||||
//! `! -o <if>` is the egress without naming it: whatever interface the
|
||||
//! default route uses, now or after it changes. Kernel forwarding is only
|
||||
//! *read*. Turning it on changes how the whole host behaves, which is the
|
||||
//! owner's decision, so when it is off the rules are installed anyway and the
|
||||
//! report says so.
|
||||
//!
|
||||
//! # Using
|
||||
//!
|
||||
//! Policy routing, with no `/1` routes and nothing in the main table:
|
||||
//!
|
||||
//! ```text
|
||||
//! table 0x7473: default dev <if>
|
||||
//! 5280: from all uidrange <euid>-<euid> lookup main
|
||||
//! 5290: from all lookup main suppress_prefixlength 0
|
||||
//! 5300: from all lookup 0x7473
|
||||
//! ```
|
||||
//!
|
||||
//! The first keeps the agent's *own* sockets off the tunnel: its QUIC, relay,
|
||||
//! DHT and DNS traffic would otherwise be routed into the interface it is
|
||||
//! carrying, a loop. The second lets every specific route — the LAN, another
|
||||
//! VPN, a tunnel to somewhere else — keep working, because only the default
|
||||
//! route is suppressed. The priorities sit after Tailscale's 5270.
|
||||
//!
|
||||
//! The first rule exempts a *user id*, so it is only right when the agent has
|
||||
//! one of its own, as the packaged service does. An agent started from a login
|
||||
//! session would take that user's own applications out of the tunnel with it,
|
||||
//! and the exit node would silently do nothing for them. That is refused, not
|
||||
//! guessed at: see [`client_refusal`].
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::state::Ipv4Range;
|
||||
|
||||
use super::RuleOutcome;
|
||||
use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules, exit_tag, exit_tag_prefix};
|
||||
use super::linux::{LinuxHostRules, Ran, Run, failure, locked, run_iptables, split_rule_line};
|
||||
|
||||
/// The chains the offering rules live in: `(table, chain)`.
|
||||
const CHAINS: [(&str, &str); 2] = [("nat", "POSTROUTING"), ("filter", "FORWARD")];
|
||||
|
||||
/// One firewall rule: where it goes, and its match and target.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Rule {
|
||||
table: &'static str,
|
||||
chain: &'static str,
|
||||
spec: Vec<String>,
|
||||
}
|
||||
|
||||
fn owned(words: &[&str]) -> Vec<String> {
|
||||
words.iter().map(|word| word.to_string()).collect()
|
||||
}
|
||||
|
||||
/// The three rules of one offered range.
|
||||
fn offer_rules(interface: &str, range: &Ipv4Range) -> Vec<Rule> {
|
||||
let range_text = range.to_string();
|
||||
let tag = exit_tag(interface, range);
|
||||
let comment = ["-m", "comment", "--comment", tag.as_str()];
|
||||
let spec = |head: &[&str], tail: &[&str]| -> Vec<String> {
|
||||
owned(head)
|
||||
.into_iter()
|
||||
.chain(owned(&comment))
|
||||
.chain(owned(tail))
|
||||
.collect()
|
||||
};
|
||||
vec![
|
||||
Rule {
|
||||
table: "nat",
|
||||
chain: "POSTROUTING",
|
||||
spec: spec(
|
||||
&["-s", &range_text, "!", "-o", interface],
|
||||
&["-j", "MASQUERADE"],
|
||||
),
|
||||
},
|
||||
Rule {
|
||||
table: "filter",
|
||||
chain: "FORWARD",
|
||||
spec: spec(
|
||||
&["-i", interface, "-s", &range_text, "!", "-o", interface],
|
||||
&["-j", "ACCEPT"],
|
||||
),
|
||||
},
|
||||
Rule {
|
||||
table: "filter",
|
||||
chain: "FORWARD",
|
||||
spec: spec(
|
||||
&[
|
||||
"-o",
|
||||
interface,
|
||||
"-d",
|
||||
&range_text,
|
||||
"-m",
|
||||
"conntrack",
|
||||
"--ctstate",
|
||||
"RELATED,ESTABLISHED",
|
||||
],
|
||||
&["-j", "ACCEPT"],
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn check_args(rule: &Rule) -> Vec<String> {
|
||||
locked(
|
||||
owned(&["-t", rule.table, "-C", rule.chain])
|
||||
.into_iter()
|
||||
.chain(rule.spec.iter().cloned()),
|
||||
)
|
||||
}
|
||||
|
||||
fn insert_args(rule: &Rule) -> Vec<String> {
|
||||
locked(
|
||||
owned(&["-t", rule.table, "-I", rule.chain, "1"])
|
||||
.into_iter()
|
||||
.chain(rule.spec.iter().cloned()),
|
||||
)
|
||||
}
|
||||
|
||||
fn list_args(table: &str, chain: &str) -> Vec<String> {
|
||||
locked(owned(&["-t", table, "-S", chain]))
|
||||
}
|
||||
|
||||
/// A rule found in a listing whose comment starts with our prefix.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Found {
|
||||
/// The whole tag.
|
||||
tag: String,
|
||||
/// The words after `-A <chain>`, which is what `-D` needs.
|
||||
rest: Vec<String>,
|
||||
}
|
||||
|
||||
/// Every rule in an `iptables -S <chain>` listing carrying a tag that starts
|
||||
/// with `prefix`. Anything else in the chain is left alone.
|
||||
fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec<Found> {
|
||||
listing
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let words = split_rule_line(line);
|
||||
let [append, found_chain, rest @ ..] = words.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
if append != "-A" || found_chain != chain {
|
||||
return None;
|
||||
}
|
||||
let tag = rest
|
||||
.windows(2)
|
||||
.find(|pair| pair[0] == "--comment" && pair[1].starts_with(prefix))
|
||||
.map(|pair| pair[1].clone())?;
|
||||
Some(Found {
|
||||
tag,
|
||||
rest: rest.to_vec(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn delete_args(table: &str, chain: &str, found: &Found) -> Vec<String> {
|
||||
locked(
|
||||
owned(&["-t", table, "-D", chain])
|
||||
.into_iter()
|
||||
.chain(found.rest.iter().cloned()),
|
||||
)
|
||||
}
|
||||
|
||||
fn missing_iptables(range: &Ipv4Range, interface: &str) -> String {
|
||||
format!(
|
||||
"iptables not found; install it (the iptables-nft package is enough), or masquerade \
|
||||
{range} out of every interface but `{interface}` and forward it yourself"
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads one chain, or says why not.
|
||||
fn read_chain(run: Run<'_>, table: &str, chain: &str) -> Result<String, Failure> {
|
||||
match run(&list_args(table, chain)) {
|
||||
Ran::Missing => Err(Failure::Missing),
|
||||
Ran::Exited {
|
||||
success: true,
|
||||
stdout,
|
||||
..
|
||||
} => Ok(stdout),
|
||||
Ran::Exited { stderr, .. } => Err(Failure::Failed(failure(&stderr))),
|
||||
}
|
||||
}
|
||||
|
||||
enum Failure {
|
||||
Missing,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Deletes found rules; the first failure ends it.
|
||||
fn delete_all(
|
||||
run: Run<'_>,
|
||||
table: &str,
|
||||
chain: &str,
|
||||
found: &[Found],
|
||||
keep: impl Fn(&Found) -> bool,
|
||||
) -> Result<(), String> {
|
||||
for rule in found.iter().filter(|rule| !keep(rule)) {
|
||||
if let Ran::Exited {
|
||||
success: false,
|
||||
stderr,
|
||||
..
|
||||
} = run(&delete_args(table, chain, rule))
|
||||
{
|
||||
return Err(failure(&stderr));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Makes the firewall hold exactly the offering rules of these ranges for the
|
||||
/// interface, and no others of ours.
|
||||
fn apply_offer(
|
||||
run: Run<'_>,
|
||||
interface: &str,
|
||||
ranges: &[Ipv4Range],
|
||||
) -> Vec<(Ipv4Range, RuleOutcome)> {
|
||||
let fail_all = |reason: String| -> Vec<(Ipv4Range, RuleOutcome)> {
|
||||
ranges
|
||||
.iter()
|
||||
.map(|range| (*range, RuleOutcome::Failed(reason.clone())))
|
||||
.collect()
|
||||
};
|
||||
let prefix = exit_tag_prefix(interface);
|
||||
let wanted: Vec<String> = ranges.iter().map(|r| exit_tag(interface, r)).collect();
|
||||
|
||||
// What is there now, per chain.
|
||||
let mut present: Vec<(&str, &str, Vec<Found>)> = Vec::new();
|
||||
for (table, chain) in CHAINS {
|
||||
match read_chain(run, table, chain) {
|
||||
Ok(listing) => present.push((table, chain, tagged_rules(&listing, chain, &prefix))),
|
||||
Err(Failure::Missing) => {
|
||||
return match ranges.first() {
|
||||
Some(range) => fail_all(missing_iptables(range, interface)),
|
||||
None => Vec::new(),
|
||||
};
|
||||
}
|
||||
Err(Failure::Failed(reason)) => return fail_all(reason),
|
||||
}
|
||||
}
|
||||
|
||||
// Rules for a range no longer offered, or left by an earlier run.
|
||||
for (table, chain, found) in &present {
|
||||
if let Err(reason) = delete_all(run, table, chain, found, |rule| wanted.contains(&rule.tag))
|
||||
{
|
||||
return fail_all(reason);
|
||||
}
|
||||
}
|
||||
|
||||
ranges
|
||||
.iter()
|
||||
.map(|range| {
|
||||
let rules = offer_rules(interface, range);
|
||||
let tag = exit_tag(interface, range);
|
||||
let in_place = rules
|
||||
.iter()
|
||||
.all(|rule| matches!(run(&check_args(rule)), Ran::Exited { success: true, .. }))
|
||||
&& present.iter().all(|(_, chain, found)| {
|
||||
let have = found.iter().filter(|rule| rule.tag == tag).count();
|
||||
have == rules.iter().filter(|rule| rule.chain == *chain).count()
|
||||
});
|
||||
if in_place {
|
||||
return (*range, RuleOutcome::Applied);
|
||||
}
|
||||
// Start this range from a clean slate: a half-installed set, or
|
||||
// one that differs from the plan, is replaced as a whole.
|
||||
for (table, chain, found) in &present {
|
||||
if let Err(reason) = delete_all(run, table, chain, found, |rule| rule.tag != tag) {
|
||||
return (*range, RuleOutcome::Failed(reason));
|
||||
}
|
||||
}
|
||||
for rule in &rules {
|
||||
match run(&insert_args(rule)) {
|
||||
Ran::Missing => {
|
||||
return (
|
||||
*range,
|
||||
RuleOutcome::Failed(missing_iptables(range, interface)),
|
||||
);
|
||||
}
|
||||
Ran::Exited { success: true, .. } => {}
|
||||
Ran::Exited { stderr, .. } => {
|
||||
return (*range, RuleOutcome::Failed(failure(&stderr)));
|
||||
}
|
||||
}
|
||||
}
|
||||
(*range, RuleOutcome::Applied)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Removes every offering rule of ours for the interface. Best effort.
|
||||
fn clear_offer(run: Run<'_>, interface: &str) {
|
||||
let prefix = exit_tag_prefix(interface);
|
||||
for (table, chain) in CHAINS {
|
||||
let Ok(listing) = read_chain(run, table, chain) else {
|
||||
continue;
|
||||
};
|
||||
let found = tagged_rules(&listing, chain, &prefix);
|
||||
if let Err(reason) = delete_all(run, table, chain, &found, |_| false) {
|
||||
tracing::warn!(
|
||||
interface,
|
||||
"cannot remove an exit node firewall rule: {reason}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kernel's forwarding switch for packets arriving on the interface,
|
||||
/// read from the text of the sysctl files. Never written.
|
||||
fn forwarding_state(read: impl Fn(&str) -> Option<String>, interface: &str) -> Option<bool> {
|
||||
let flag = |text: String| match text.trim() {
|
||||
"1" => Some(true),
|
||||
"0" => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
// The name is derived here, but it ends up in a path.
|
||||
if !interface.is_empty() && !interface.contains(['/', '\0']) && interface != ".." {
|
||||
let per_interface = format!("/proc/sys/net/ipv4/conf/{interface}/forwarding");
|
||||
if let Some(state) = read(&per_interface).and_then(flag) {
|
||||
return Some(state);
|
||||
}
|
||||
}
|
||||
read("/proc/sys/net/ipv4/ip_forward").and_then(flag)
|
||||
}
|
||||
|
||||
fn read_sysctl(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
/// The effective user id from the text of `/proc/self/status`.
|
||||
fn parse_euid(status: &str) -> Option<u32> {
|
||||
status
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("Uid:"))
|
||||
.and_then(|rest| rest.split_whitespace().nth(1))
|
||||
.and_then(|effective| effective.parse().ok())
|
||||
}
|
||||
|
||||
/// Why the using side must not be installed for this process, if it must not.
|
||||
///
|
||||
/// The bypass rule exempts this process's *user id* from the tunnel. That is
|
||||
/// right for a service with an id of its own and wrong for a process started
|
||||
/// from somebody's login session, which shares its id with every application
|
||||
/// that person runs. A login session is recognised by `XDG_RUNTIME_DIR`,
|
||||
/// which `systemd --user` and the login managers set and a system service
|
||||
/// does not. Crude on purpose: refusing a service that was started oddly is
|
||||
/// a message, and exempting a user's applications is a silent failure.
|
||||
fn client_refusal(xdg_runtime_dir: Option<&str>) -> Option<String> {
|
||||
xdg_runtime_dir.filter(|dir| !dir.is_empty()).map(|_| {
|
||||
"the exit node needs the agent to run as a system service with a user of its own: \
|
||||
this agent was started from a login session, so routing everything through the \
|
||||
overlay would take that user's own applications out of the tunnel with it"
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
impl LinuxHostRules {
|
||||
async fn blocking<T: Send + 'static>(
|
||||
work: impl FnOnce() -> T + Send + 'static,
|
||||
) -> Result<T, String> {
|
||||
tokio::task::spawn_blocking(work)
|
||||
.await
|
||||
.map_err(|err| format!("the task failed: {err}"))
|
||||
}
|
||||
|
||||
async fn apply_client(&self, interface: &str) -> RuleOutcome {
|
||||
let xdg = std::env::var("XDG_RUNTIME_DIR").ok();
|
||||
if let Some(reason) = client_refusal(xdg.as_deref()) {
|
||||
// Whatever an earlier run left must not stay in force.
|
||||
self.clear_client().await;
|
||||
return RuleOutcome::Failed(reason);
|
||||
}
|
||||
let Some(euid) = std::fs::read_to_string("/proc/self/status")
|
||||
.ok()
|
||||
.and_then(|status| parse_euid(&status))
|
||||
else {
|
||||
return RuleOutcome::Failed("cannot tell which user this agent runs as".to_string());
|
||||
};
|
||||
let route = self.route.clone();
|
||||
let interface = interface.to_string();
|
||||
match Self::blocking(move || route.set_exit_client(&interface, euid)).await {
|
||||
Ok(Ok(())) => RuleOutcome::Applied,
|
||||
Ok(Err(err)) => RuleOutcome::Failed(err.to_string()),
|
||||
Err(reason) => RuleOutcome::Failed(reason),
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_client(&self) {
|
||||
let route = self.route.clone();
|
||||
if let Ok(Err(err)) = Self::blocking(move || route.clear_exit_client()).await {
|
||||
tracing::warn!(%err, "cannot remove the exit node routing rules");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExitHostRules for LinuxHostRules {
|
||||
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
|
||||
Box::pin(async move {
|
||||
let interface = plan.interface.clone();
|
||||
let ranges = plan.offer.clone();
|
||||
let offer = {
|
||||
let (job_interface, job_ranges) = (interface.clone(), ranges.clone());
|
||||
Self::blocking(move || apply_offer(&run_iptables, &job_interface, &job_ranges))
|
||||
.await
|
||||
.unwrap_or_else(|reason| {
|
||||
ranges
|
||||
.iter()
|
||||
.map(|range| (*range, RuleOutcome::Failed(reason.clone())))
|
||||
.collect()
|
||||
})
|
||||
};
|
||||
let forwarding = if ranges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
forwarding_state(read_sysctl, &interface)
|
||||
};
|
||||
let client = if plan.client {
|
||||
Some(self.apply_client(&interface).await)
|
||||
} else {
|
||||
self.clear_client().await;
|
||||
None
|
||||
};
|
||||
ExitHostReport {
|
||||
offer,
|
||||
forwarding,
|
||||
client,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
let name = interface.to_string();
|
||||
let _ = Self::blocking(move || clear_offer(&run_iptables, &name)).await;
|
||||
self.clear_client().await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn range() -> Ipv4Range {
|
||||
"10.13.37.0/24".parse().unwrap()
|
||||
}
|
||||
|
||||
fn words(args: &[String]) -> String {
|
||||
args.join(" ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rules_masquerade_the_range_out_of_every_other_interface_and_forward_it() {
|
||||
let rules = offer_rules("tsun0", &range());
|
||||
assert_eq!(rules.len(), 3);
|
||||
assert_eq!(
|
||||
words(&rules[0].spec),
|
||||
"-s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j MASQUERADE"
|
||||
);
|
||||
assert_eq!((rules[0].table, rules[0].chain), ("nat", "POSTROUTING"));
|
||||
assert_eq!(
|
||||
words(&rules[1].spec),
|
||||
"-i tsun0 -s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT"
|
||||
);
|
||||
assert_eq!((rules[1].table, rules[1].chain), ("filter", "FORWARD"));
|
||||
assert_eq!(
|
||||
words(&rules[2].spec),
|
||||
"-o tsun0 -d 10.13.37.0/24 -m conntrack --ctstate RELATED,ESTABLISHED -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT"
|
||||
);
|
||||
// Nothing may widen to every source or to the other direction.
|
||||
for rule in &rules {
|
||||
assert!(!words(&rule.spec).contains("0.0.0.0/0"));
|
||||
}
|
||||
assert!(words(&insert_args(&rules[1])).starts_with("-w 5 -t filter -I FORWARD 1 -i tsun0"));
|
||||
assert!(words(&check_args(&rules[0])).starts_with("-w 5 -t nat -C POSTROUTING -s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_rules_with_our_tag_for_this_interface_are_selected() {
|
||||
let listing = "\
|
||||
-P FORWARD DROP
|
||||
-A FORWARD -i tsun0 -s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT
|
||||
-A FORWARD -i tsun1 -m comment --comment tsunagi-exit:tsun1:10.99.0.0/24 -j ACCEPT
|
||||
-A FORWARD -i tsun01 -m comment --comment tsunagi-exit:tsun01:10.5.0.0/24 -j ACCEPT
|
||||
-A FORWARD -m comment --comment \"allow tsunagi-exit:tsun0:x\" -j ACCEPT
|
||||
-A FORWARD -m comment --comment tsunagi:tsun0 -j ACCEPT
|
||||
-A INPUT -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT
|
||||
-A FORWARD -j ACCEPT
|
||||
";
|
||||
let found = tagged_rules(listing, "FORWARD", &exit_tag_prefix("tsun0"));
|
||||
assert_eq!(found.len(), 1, "{found:?}");
|
||||
assert_eq!(found[0].tag, "tsunagi-exit:tsun0:10.13.37.0/24");
|
||||
assert_eq!(
|
||||
words(&delete_args("filter", "FORWARD", &found[0])),
|
||||
"-w 5 -t filter -D FORWARD -i tsun0 -s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT"
|
||||
);
|
||||
assert!(tagged_rules("", "FORWARD", "tsunagi-exit:tsun0:").is_empty());
|
||||
}
|
||||
|
||||
/// A scripted `iptables` that remembers which rules it was given.
|
||||
struct Fake {
|
||||
calls: RefCell<Vec<String>>,
|
||||
/// Rules present, as `-S` lines per `(table, chain)`.
|
||||
listing: RefCell<Vec<(String, String)>>,
|
||||
missing: bool,
|
||||
check_ok: bool,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn new(check_ok: bool) -> Self {
|
||||
Self {
|
||||
calls: RefCell::new(Vec::new()),
|
||||
listing: RefCell::new(Vec::new()),
|
||||
missing: false,
|
||||
check_ok,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_rule(self, table: &str, line: &str) -> Self {
|
||||
self.listing
|
||||
.borrow_mut()
|
||||
.push((table.to_string(), line.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
fn run(&self, args: &[String]) -> Ran {
|
||||
if self.missing {
|
||||
return Ran::Missing;
|
||||
}
|
||||
let line = args.join(" ");
|
||||
self.calls.borrow_mut().push(line.clone());
|
||||
let table = args
|
||||
.iter()
|
||||
.position(|word| word == "-t")
|
||||
.map(|at| args[at + 1].clone())
|
||||
.unwrap_or_default();
|
||||
let ok = |stdout: String| Ran::Exited {
|
||||
success: true,
|
||||
stdout,
|
||||
stderr: String::new(),
|
||||
};
|
||||
if line.contains(" -S ") {
|
||||
let listing: Vec<String> = self
|
||||
.listing
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|(t, _)| *t == table)
|
||||
.map(|(_, l)| l.clone())
|
||||
.collect();
|
||||
return ok(listing.join("\n"));
|
||||
}
|
||||
if line.contains(" -C ") {
|
||||
return Ran::Exited {
|
||||
success: self.check_ok,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
};
|
||||
}
|
||||
ok(String::new())
|
||||
}
|
||||
|
||||
fn verbs(&self, verb: &str) -> Vec<String> {
|
||||
self.calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|call| call.contains(&format!(" {verb} ")))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_host_gets_the_three_rules() {
|
||||
let fake = Fake::new(false);
|
||||
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
|
||||
assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]);
|
||||
let inserted = fake.verbs("-I");
|
||||
assert_eq!(inserted.len(), 3, "{inserted:?}");
|
||||
assert!(inserted[0].contains("-t nat -I POSTROUTING 1"));
|
||||
assert!(fake.verbs("-D").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_already_in_place_are_left_alone() {
|
||||
let tag = exit_tag("tsun0", &range());
|
||||
let fake = Fake::new(true)
|
||||
.with_rule(
|
||||
"nat",
|
||||
&format!("-A POSTROUTING -s 10.13.37.0/24 ! -o tsun0 -m comment --comment {tag} -j MASQUERADE"),
|
||||
)
|
||||
.with_rule("filter", &format!("-A FORWARD -i tsun0 -m comment --comment {tag} -j ACCEPT"))
|
||||
.with_rule("filter", &format!("-A FORWARD -o tsun0 -m comment --comment {tag} -j ACCEPT"));
|
||||
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
|
||||
assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]);
|
||||
assert!(fake.verbs("-I").is_empty());
|
||||
assert!(fake.verbs("-D").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_range_is_removed_and_a_half_installed_set_is_replaced() {
|
||||
let old: Ipv4Range = "10.99.0.0/24".parse().unwrap();
|
||||
let old_tag = exit_tag("tsun0", &old);
|
||||
let tag = exit_tag("tsun0", &range());
|
||||
let fake = Fake::new(true)
|
||||
.with_rule(
|
||||
"nat",
|
||||
&format!(
|
||||
"-A POSTROUTING -s 10.99.0.0/24 -m comment --comment {old_tag} -j MASQUERADE"
|
||||
),
|
||||
)
|
||||
// Only one of the three for the current range.
|
||||
.with_rule(
|
||||
"filter",
|
||||
&format!("-A FORWARD -i tsun0 -m comment --comment {tag} -j ACCEPT"),
|
||||
)
|
||||
// Somebody else's rule, which must survive.
|
||||
.with_rule("filter", "-A FORWARD -i eth0 -j ACCEPT");
|
||||
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
|
||||
assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]);
|
||||
let deleted = fake.verbs("-D");
|
||||
assert!(
|
||||
deleted.iter().any(|call| call.contains(&old_tag)),
|
||||
"{deleted:?}"
|
||||
);
|
||||
assert!(
|
||||
deleted.iter().any(|call| call.contains(&tag)),
|
||||
"{deleted:?}"
|
||||
);
|
||||
assert!(
|
||||
deleted.iter().all(|call| !call.contains("eth0")),
|
||||
"{deleted:?}"
|
||||
);
|
||||
assert_eq!(fake.verbs("-I").len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_removes_only_our_rules_for_the_interface() {
|
||||
let tag = exit_tag("tsun0", &range());
|
||||
let other = exit_tag("tsun1", &range());
|
||||
let fake = Fake::new(true)
|
||||
.with_rule(
|
||||
"nat",
|
||||
&format!("-A POSTROUTING -m comment --comment {tag} -j MASQUERADE"),
|
||||
)
|
||||
.with_rule(
|
||||
"nat",
|
||||
&format!("-A POSTROUTING -m comment --comment {other} -j MASQUERADE"),
|
||||
)
|
||||
.with_rule("filter", "-A FORWARD -j ACCEPT");
|
||||
clear_offer(&|args| fake.run(args), "tsun0");
|
||||
let deleted = fake.verbs("-D");
|
||||
assert_eq!(deleted.len(), 1, "{deleted:?}");
|
||||
assert!(deleted[0].contains(&tag));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_iptables_is_a_failure_that_says_what_to_do() {
|
||||
let mut fake = Fake::new(false);
|
||||
fake.missing = true;
|
||||
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
|
||||
let RuleOutcome::Failed(reason) = &outcome[0].1 else {
|
||||
panic!("{outcome:?}");
|
||||
};
|
||||
assert!(reason.contains("iptables not found"), "{reason}");
|
||||
assert!(reason.contains("10.13.37.0/24"), "{reason}");
|
||||
// Nothing wanted and nothing to run is not a failure of anything.
|
||||
assert!(apply_offer(&|args| fake.run(args), "tsun0", &[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_is_read_per_interface_then_globally_and_never_written() {
|
||||
let files = |per: Option<&'static str>, global: Option<&'static str>| {
|
||||
move |path: &str| match path {
|
||||
"/proc/sys/net/ipv4/conf/tsun0/forwarding" => per.map(str::to_string),
|
||||
"/proc/sys/net/ipv4/ip_forward" => global.map(str::to_string),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
forwarding_state(files(Some("1\n"), Some("0\n")), "tsun0"),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
forwarding_state(files(Some("0\n"), Some("1\n")), "tsun0"),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
forwarding_state(files(None, Some("1\n")), "tsun0"),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(forwarding_state(files(None, None), "tsun0"), None);
|
||||
assert_eq!(forwarding_state(files(Some("junk"), None), "tsun0"), None);
|
||||
// A name that would leave the directory is not looked up.
|
||||
assert_eq!(forwarding_state(files(Some("1"), None), "../../x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_effective_uid_is_the_second_field() {
|
||||
let status = "Name:\ttsunagi\nUid:\t1001\t1002\t1001\t1001\nGid:\t5\t5\t5\t5\n";
|
||||
assert_eq!(parse_euid(status), Some(1002));
|
||||
assert_eq!(parse_euid("Name:\tx\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_login_session_is_refused_and_a_service_is_not() {
|
||||
let reason = client_refusal(Some("/run/user/1000")).unwrap();
|
||||
assert!(reason.contains("system service"), "{reason}");
|
||||
assert!(client_refusal(None).is_none());
|
||||
assert!(client_refusal(Some("")).is_none());
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,17 @@ use std::sync::{Arc, Mutex};
|
||||
use crate::BoxFuture;
|
||||
use crate::state::Ipv4Range;
|
||||
|
||||
mod exit;
|
||||
pub use exit::{
|
||||
ExitHostPlan, ExitHostReport, ExitHostRules, MockExitRules, exit_tag, exit_tag_prefix,
|
||||
offer_detail,
|
||||
};
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
mod linux;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
mod linux_exit;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use linux::LinuxHostRules;
|
||||
|
||||
// Compiled everywhere so the scripts it builds are tested everywhere; only
|
||||
@@ -159,6 +167,15 @@ pub trait BroadcastHostRules: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// Removes what this agent put there for the interface. Succeeds when
|
||||
/// nothing is there: it runs on the shutdown path.
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>;
|
||||
|
||||
/// The exit-node rules that go with these, when the platform has them.
|
||||
///
|
||||
/// One object per host, because both halves need the same privileged
|
||||
/// helpers; the default is that this platform has none, which is said
|
||||
/// where it matters rather than pretended.
|
||||
fn exit_rules(&self) -> Option<&dyn ExitHostRules> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The platform has no implementation, which is said rather than pretended.
|
||||
@@ -195,6 +212,7 @@ pub type MockRulesState = std::collections::BTreeMap<String, BroadcastRulesPlan>
|
||||
/// An in-memory host, for tests: nothing is touched.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MockHostRules {
|
||||
exit: MockExitRules,
|
||||
state: Arc<Mutex<MockRulesState>>,
|
||||
calls: Arc<Mutex<Vec<String>>>,
|
||||
firewall_failure: Arc<Mutex<Option<String>>>,
|
||||
@@ -203,7 +221,15 @@ pub struct MockHostRules {
|
||||
impl MockHostRules {
|
||||
/// An empty host.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
Self {
|
||||
exit: MockExitRules::new(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The exit-node half of this host, for inspecting and steering tests.
|
||||
pub fn exit(&self) -> &MockExitRules {
|
||||
&self.exit
|
||||
}
|
||||
|
||||
/// Makes the firewall step fail with this reason from now on.
|
||||
@@ -234,6 +260,10 @@ impl BroadcastHostRules for MockHostRules {
|
||||
"mock"
|
||||
}
|
||||
|
||||
fn exit_rules(&self) -> Option<&dyn ExitHostRules> {
|
||||
Some(&self.exit)
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
|
||||
Box::pin(async move {
|
||||
lock(&self.calls).push(format!("apply:{}", plan.interface));
|
||||
|
||||
@@ -29,7 +29,11 @@ use tokio::task::JoinHandle;
|
||||
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
use super::hostrules::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport};
|
||||
use super::exit::{ExitRulesReport, RuleSetReport};
|
||||
use super::hostrules::{
|
||||
BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, ExitHostPlan, ExitHostReport,
|
||||
RuleOutcome, offer_detail,
|
||||
};
|
||||
use super::packet::IpHeader;
|
||||
use super::router::{Route, RoutingTable};
|
||||
use super::tun::{TunDevice, TunFactory, TunRequest};
|
||||
@@ -158,6 +162,31 @@ pub struct Interface {
|
||||
host_rules: Option<Arc<dyn BroadcastHostRules>>,
|
||||
/// What those rules last reported, `None` while none are installed.
|
||||
rules_report: std::sync::Mutex<Option<BroadcastRulesReport>>,
|
||||
/// What the exit-node rules were last asked for, and what came of it.
|
||||
exit_state: std::sync::Mutex<ExitState>,
|
||||
}
|
||||
|
||||
/// The exit-node host rules as this interface last left them.
|
||||
#[derive(Debug, Default)]
|
||||
struct ExitState {
|
||||
/// Whether what an earlier run left behind has been taken away. Done once,
|
||||
/// at the first sync, whether or not anything is wanted now: a firewall
|
||||
/// rule that forwards a range must not outlive the switch that offered it.
|
||||
cleaned: bool,
|
||||
/// What was last applied, `None` while nothing is.
|
||||
plan: Option<ExitHostPlan>,
|
||||
/// What came of it.
|
||||
report: Option<ExitHostReport>,
|
||||
}
|
||||
|
||||
impl ExitState {
|
||||
/// Whether every step of the last apply took.
|
||||
fn is_ok(&self) -> bool {
|
||||
self.report.as_ref().is_some_and(|report| {
|
||||
report.offer.iter().all(|(_, outcome)| outcome.is_applied())
|
||||
&& report.client.as_ref().is_none_or(RuleOutcome::is_applied)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Interface {
|
||||
@@ -245,6 +274,7 @@ impl Interface {
|
||||
sync_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
host_rules,
|
||||
rules_report: std::sync::Mutex::new(None),
|
||||
exit_state: std::sync::Mutex::new(ExitState::default()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -298,6 +328,193 @@ impl Interface {
|
||||
}
|
||||
}
|
||||
|
||||
/// Brings the host's exit-node rules in line with the routing table.
|
||||
///
|
||||
/// Idempotent, and cheap when nothing changed. It never fails: the
|
||||
/// outcome is kept for [`Self::exit_rules`]. The first call also takes
|
||||
/// away whatever an earlier run left, even when nothing is wanted now.
|
||||
pub async fn sync_exit_rules(&self) {
|
||||
let Some(exit) = self
|
||||
.host_rules
|
||||
.as_ref()
|
||||
.and_then(|rules| rules.exit_rules())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _guard = self.sync_lock.lock().await;
|
||||
let mut ranges: Vec<_> = self
|
||||
.routes
|
||||
.exit_offers()
|
||||
.into_iter()
|
||||
.map(|(_, range)| range)
|
||||
.collect();
|
||||
ranges.sort_by_key(|range| (range.base, range.prefix_len));
|
||||
ranges.dedup();
|
||||
let wanted = ExitHostPlan {
|
||||
interface: self.name.clone(),
|
||||
offer: ranges,
|
||||
client: self.routes.exit_via().is_some(),
|
||||
};
|
||||
|
||||
let first = !std::mem::replace(&mut self.exit_state().cleaned, true);
|
||||
if first {
|
||||
exit.clear(&self.name).await;
|
||||
}
|
||||
let (planned, ok) = {
|
||||
let state = self.exit_state();
|
||||
(state.plan.clone(), state.is_ok())
|
||||
};
|
||||
if wanted.is_empty() {
|
||||
if planned.is_some() {
|
||||
exit.clear(&self.name).await;
|
||||
let mut state = self.exit_state();
|
||||
state.plan = None;
|
||||
state.report = None;
|
||||
tracing::info!(interface = %self.name, "exit node host rules removed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if planned.as_ref() == Some(&wanted) && ok {
|
||||
return;
|
||||
}
|
||||
let report = exit.apply(&wanted).await;
|
||||
for (range, outcome) in &report.offer {
|
||||
match outcome {
|
||||
RuleOutcome::Applied => tracing::info!(
|
||||
interface = %self.name,
|
||||
%range,
|
||||
"exit node firewall rules applied"
|
||||
),
|
||||
RuleOutcome::Failed(reason) => tracing::warn!(
|
||||
interface = %self.name,
|
||||
%range,
|
||||
"exit node firewall rules are incomplete: {reason}"
|
||||
),
|
||||
}
|
||||
}
|
||||
if report.offer.iter().any(|(_, outcome)| outcome.is_applied())
|
||||
&& report.forwarding == Some(false)
|
||||
{
|
||||
tracing::warn!(
|
||||
interface = %self.name,
|
||||
"kernel forwarding is off for this interface, so nothing is forwarded for the \
|
||||
exit node yet: `sysctl -w net.ipv4.conf.{0}.forwarding=1` (or \
|
||||
`net.ipv4.ip_forward=1`), and set it in sysctl.d to keep it",
|
||||
self.name
|
||||
);
|
||||
}
|
||||
match &report.client {
|
||||
Some(RuleOutcome::Applied) => tracing::info!(
|
||||
interface = %self.name,
|
||||
"this device's traffic is routed through its exit node"
|
||||
),
|
||||
Some(RuleOutcome::Failed(reason)) => tracing::warn!(
|
||||
interface = %self.name,
|
||||
"cannot route this device's traffic through the exit node: {reason}"
|
||||
),
|
||||
None => {}
|
||||
}
|
||||
let mut state = self.exit_state();
|
||||
state.plan = Some(wanted);
|
||||
state.report = Some(report);
|
||||
}
|
||||
|
||||
fn exit_state(&self) -> std::sync::MutexGuard<'_, ExitState> {
|
||||
match self.exit_state.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the exit-node rules of a network are doing now.
|
||||
///
|
||||
/// `offer` is `Some` while the network offers this agent as an exit node,
|
||||
/// `client` while this device sends its traffic through a member of it.
|
||||
pub fn exit_rules(&self, network: NetworkId) -> ExitRulesReport {
|
||||
let Some((policy, range)) = self.routes.exit_settings(network) else {
|
||||
return ExitRulesReport::default();
|
||||
};
|
||||
let unavailable = match self
|
||||
.host_rules
|
||||
.as_ref()
|
||||
.map(|rules| rules.exit_rules().is_some())
|
||||
{
|
||||
None => Some(
|
||||
"this overlay interface is not on the host (no TUN device), so there is \
|
||||
nothing to forward to",
|
||||
),
|
||||
Some(false) => Some("exit nodes are only supported on Linux yet"),
|
||||
Some(true) => None,
|
||||
};
|
||||
let failed = |detail: &str| RuleSetReport {
|
||||
ok: false,
|
||||
detail: detail.to_string(),
|
||||
};
|
||||
let state = self.exit_state();
|
||||
let mut out = ExitRulesReport::default();
|
||||
if policy.offer {
|
||||
out.offer = Some(match (unavailable, range) {
|
||||
(Some(why), _) => failed(why),
|
||||
(None, None) => failed("waiting for the network to agree an address range"),
|
||||
(None, Some(range)) => {
|
||||
match state
|
||||
.report
|
||||
.as_ref()
|
||||
.and_then(|report| report.offer.iter().find(|(r, _)| *r == range))
|
||||
{
|
||||
Some((_, outcome)) => RuleSetReport {
|
||||
ok: outcome.is_applied(),
|
||||
detail: offer_detail(outcome, &range),
|
||||
},
|
||||
None => failed("not applied yet"),
|
||||
}
|
||||
}
|
||||
});
|
||||
if unavailable.is_none() {
|
||||
out.forwarding = state.report.as_ref().and_then(|report| report.forwarding);
|
||||
}
|
||||
}
|
||||
if policy.via.is_some()
|
||||
&& self
|
||||
.routes
|
||||
.exit_via()
|
||||
.is_some_and(|(selected, _)| selected == network)
|
||||
{
|
||||
out.client = Some(match unavailable {
|
||||
Some(why) => failed(why),
|
||||
None => match state
|
||||
.report
|
||||
.as_ref()
|
||||
.and_then(|report| report.client.as_ref())
|
||||
{
|
||||
Some(RuleOutcome::Applied) => RuleSetReport {
|
||||
ok: true,
|
||||
detail: "this device's traffic is routed through the exit node".to_string(),
|
||||
},
|
||||
Some(RuleOutcome::Failed(reason)) => failed(reason),
|
||||
None => failed("not applied yet"),
|
||||
},
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether this agent can really act as an exit node in a network: the
|
||||
/// rules members' traffic needs are in place. Kernel forwarding is not
|
||||
/// part of it, because the agent never turns that on itself.
|
||||
pub fn exit_offer_ready(&self, network: NetworkId) -> bool {
|
||||
let Some((policy, Some(range))) = self.routes.exit_settings(network) else {
|
||||
return false;
|
||||
};
|
||||
policy.offer
|
||||
&& self.exit_state().report.as_ref().is_some_and(|report| {
|
||||
report
|
||||
.offer
|
||||
.iter()
|
||||
.any(|(r, outcome)| *r == range && outcome.is_applied())
|
||||
})
|
||||
}
|
||||
|
||||
/// What the broadcast host rules achieved, `None` when none are installed
|
||||
/// or this interface has no host to configure.
|
||||
pub fn broadcast_rules(&self) -> Option<BroadcastRulesReport> {
|
||||
@@ -379,6 +596,16 @@ impl Interface {
|
||||
if let Some(rules) = &self.host_rules {
|
||||
rules.clear(&self.name).await;
|
||||
self.store_rules_report(None);
|
||||
let touched = {
|
||||
let state = self.exit_state();
|
||||
state.cleaned || state.plan.is_some()
|
||||
};
|
||||
if let (true, Some(exit)) = (touched, rules.exit_rules()) {
|
||||
exit.clear(&self.name).await;
|
||||
let mut state = self.exit_state();
|
||||
state.plan = None;
|
||||
state.report = None;
|
||||
}
|
||||
}
|
||||
// The packet loop holds the device open, and it ends only when the
|
||||
// device reports end of stream — which a real interface never does
|
||||
@@ -435,6 +662,11 @@ impl Interface {
|
||||
IpAddr::V4(destination) if self.routes.is_local_destination(network, destination) => {
|
||||
false
|
||||
}
|
||||
// This agent is an exit node of this network: a member's packet
|
||||
// for the internet goes to the host, which masquerades it.
|
||||
IpAddr::V4(destination) if self.routes.accepts_exit_traffic(network, destination) => {
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
self.tally.wrong_destination.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(Rejected::WrongDestination);
|
||||
@@ -525,7 +757,7 @@ mod tests {
|
||||
use crate::overlay::broadcast::BroadcastPolicy;
|
||||
use crate::overlay::hostrules::{MockHostRules, RuleOutcome};
|
||||
use crate::overlay::provision::{ManagedTunFactory, MockProvisioner};
|
||||
use crate::overlay::router::NetworkRoutes;
|
||||
use crate::overlay::router::{ExitPolicy, NetworkRoutes};
|
||||
use crate::overlay::tun::{MemoryTun, MemoryTunFactory};
|
||||
|
||||
fn network(name: &str) -> NetworkId {
|
||||
@@ -667,6 +899,7 @@ mod tests {
|
||||
.set_network(
|
||||
id,
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
local: Some(addr(1)),
|
||||
@@ -833,6 +1066,7 @@ mod tests {
|
||||
.set_network(
|
||||
id,
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
local: Some(addr(9)),
|
||||
@@ -856,6 +1090,7 @@ mod tests {
|
||||
.set_network(
|
||||
id,
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: BroadcastPolicy {
|
||||
enabled: broadcast_enabled,
|
||||
..Default::default()
|
||||
@@ -910,6 +1145,7 @@ mod tests {
|
||||
.set_network(
|
||||
id,
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: BroadcastPolicy::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
local: Some(addr(9)),
|
||||
@@ -978,4 +1214,261 @@ mod tests {
|
||||
assert!(interface.broadcast_rules().is_none());
|
||||
interface.remove().await;
|
||||
}
|
||||
|
||||
const INTERNET: Ipv4Addr = Ipv4Addr::new(93, 184, 216, 34);
|
||||
|
||||
fn offering() -> ExitPolicy {
|
||||
ExitPolicy {
|
||||
offer: true,
|
||||
via: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn using(exit: EndpointId) -> ExitPolicy {
|
||||
ExitPolicy {
|
||||
offer: false,
|
||||
via: Some(exit),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internet_traffic_goes_to_the_exit_node_and_only_once_one_is_chosen() {
|
||||
let (interface, device, routes, carrier, id) = interface(false).await;
|
||||
device.push_from_os(ipv4(addr(1), INTERNET, b"before"));
|
||||
wait_for(&interface, |counters| counters.unroutable).await;
|
||||
assert!(carrier.carried().is_empty(), "no exit node, no route");
|
||||
|
||||
routes.set_exit(id, using(peer(2)));
|
||||
device.push_from_os(ipv4(addr(1), INTERNET, b"after"));
|
||||
wait_for(&interface, |counters| counters.sent).await;
|
||||
let carried = carrier.carried();
|
||||
assert_eq!(carried.len(), 1);
|
||||
assert_eq!(carried[0].0.peer, peer(2));
|
||||
assert_eq!(&carried[0].1[20..], b"after");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_exit_node_may_answer_with_any_source_and_nobody_else_may() {
|
||||
let (interface, device, routes, _carrier, id) = interface(false).await;
|
||||
routes.set_exit(id, using(peer(2)));
|
||||
|
||||
interface
|
||||
.deliver(id, peer(2), ipv4(INTERNET, addr(1), b"reply"))
|
||||
.await
|
||||
.unwrap();
|
||||
let written = tokio::time::timeout(std::time::Duration::from_secs(5), device.pop_to_os())
|
||||
.await
|
||||
.expect("the reply reaches the interface")
|
||||
.unwrap();
|
||||
assert_eq!(&written[20..], b"reply");
|
||||
|
||||
// Another member cannot use the internet's addresses.
|
||||
assert_eq!(
|
||||
interface
|
||||
.deliver(id, peer(3), ipv4(INTERNET, addr(1), b"spoof"))
|
||||
.await,
|
||||
Err(Rejected::WrongSource)
|
||||
);
|
||||
// Nor can the exit node speak for a member.
|
||||
assert_eq!(
|
||||
interface
|
||||
.deliver(id, peer(2), ipv4(addr(3), addr(1), b"spoof"))
|
||||
.await,
|
||||
Err(Rejected::WrongSource)
|
||||
);
|
||||
// And what it sends still has to be for this agent.
|
||||
assert_eq!(
|
||||
interface
|
||||
.deliver(id, peer(2), ipv4(INTERNET, addr(200), b"elsewhere"))
|
||||
.await,
|
||||
Err(Rejected::WrongDestination)
|
||||
);
|
||||
assert_eq!(interface.counters().received, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_exit_node_hands_a_members_internet_packet_to_the_host_only_while_offering() {
|
||||
let (interface, device, routes, _carrier, id) = interface(false).await;
|
||||
|
||||
// Not offering: dropped exactly as before.
|
||||
assert_eq!(
|
||||
interface
|
||||
.deliver(id, peer(2), ipv4(addr(2), INTERNET, b"request"))
|
||||
.await,
|
||||
Err(Rejected::WrongDestination)
|
||||
);
|
||||
assert_eq!(interface.counters().received, 0);
|
||||
|
||||
routes.set_exit(id, offering());
|
||||
interface
|
||||
.deliver(id, peer(2), ipv4(addr(2), INTERNET, b"request"))
|
||||
.await
|
||||
.unwrap();
|
||||
let written = tokio::time::timeout(std::time::Duration::from_secs(5), device.pop_to_os())
|
||||
.await
|
||||
.expect("the request reaches the interface")
|
||||
.unwrap();
|
||||
assert_eq!(&written[20..], b"request");
|
||||
|
||||
// The source must still be the sender's own address.
|
||||
assert_eq!(
|
||||
interface
|
||||
.deliver(id, peer(2), ipv4(addr(3), INTERNET, b"spoof"))
|
||||
.await,
|
||||
Err(Rejected::WrongSource)
|
||||
);
|
||||
assert_eq!(
|
||||
interface
|
||||
.deliver(id, peer(2), ipv4(INTERNET, INTERNET, b"spoof"))
|
||||
.await,
|
||||
Err(Rejected::WrongSource)
|
||||
);
|
||||
// Offering does not make this a door to the overlay or to this host.
|
||||
for destination in [addr(3), addr(200), Ipv4Addr::LOCALHOST] {
|
||||
assert_eq!(
|
||||
interface
|
||||
.deliver(id, peer(2), ipv4(addr(2), destination, b"no"))
|
||||
.await,
|
||||
Err(Rejected::WrongDestination),
|
||||
"{destination}"
|
||||
);
|
||||
}
|
||||
assert_eq!(interface.counters().received, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_exit_rules_follow_the_routing_table_and_leave_with_the_interface() {
|
||||
let rules = MockHostRules::new();
|
||||
let (interface, routes, id) = managed_interface(&rules, false).await;
|
||||
let range: crate::state::Ipv4Range = "10.13.37.0/24".parse().unwrap();
|
||||
|
||||
// Nothing wanted: nothing installed, but what a crashed run left is
|
||||
// taken away once.
|
||||
interface.sync_exit_rules().await;
|
||||
interface.sync_exit_rules().await;
|
||||
assert_eq!(rules.exit().calls(), ["clear:tsunrules"]);
|
||||
assert!(rules.exit().installed().is_none());
|
||||
assert!(!interface.exit_offer_ready(id));
|
||||
assert_eq!(interface.exit_rules(id), ExitRulesReport::default());
|
||||
|
||||
// Offering: the range is masqueraded, and the agent can say so.
|
||||
routes.set_exit(id, offering());
|
||||
assert!(!interface.exit_offer_ready(id), "not applied yet");
|
||||
let waiting = interface.exit_rules(id).offer.unwrap();
|
||||
assert!(!waiting.ok);
|
||||
assert!(waiting.detail.contains("not applied yet"), "{waiting:?}");
|
||||
interface.sync_exit_rules().await;
|
||||
assert_eq!(rules.exit().installed().unwrap().offer, vec![range]);
|
||||
assert!(interface.exit_offer_ready(id));
|
||||
let report = interface.exit_rules(id);
|
||||
assert!(report.offer.as_ref().unwrap().ok);
|
||||
assert_eq!(report.forwarding, Some(true));
|
||||
assert!(report.client.is_none());
|
||||
|
||||
// Unchanged: not applied again.
|
||||
interface.sync_exit_rules().await;
|
||||
assert_eq!(
|
||||
rules
|
||||
.exit()
|
||||
.calls()
|
||||
.iter()
|
||||
.filter(|call| call.starts_with("apply"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
// Forwarding off in the kernel: still installed, still ready, and said.
|
||||
rules.exit().set_forwarding(Some(false));
|
||||
routes.set_exit(
|
||||
id,
|
||||
ExitPolicy {
|
||||
offer: true,
|
||||
via: Some(peer(2)),
|
||||
},
|
||||
);
|
||||
interface.sync_exit_rules().await;
|
||||
assert!(rules.exit().installed().unwrap().client);
|
||||
assert!(interface.exit_offer_ready(id));
|
||||
let report = interface.exit_rules(id);
|
||||
assert_eq!(report.forwarding, Some(false));
|
||||
assert!(report.offer.unwrap().ok);
|
||||
assert!(report.client.unwrap().ok);
|
||||
|
||||
// Turned off: removed, and no longer announced as ready.
|
||||
routes.set_exit(id, ExitPolicy::default());
|
||||
interface.sync_exit_rules().await;
|
||||
assert!(rules.exit().installed().is_none());
|
||||
assert!(!interface.exit_offer_ready(id));
|
||||
assert_eq!(interface.exit_rules(id), ExitRulesReport::default());
|
||||
|
||||
routes.set_exit(id, offering());
|
||||
interface.sync_exit_rules().await;
|
||||
assert!(rules.exit().installed().is_some());
|
||||
interface.remove().await;
|
||||
assert!(
|
||||
rules.exit().installed().is_none(),
|
||||
"gone with the interface"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_exit_step_is_reported_and_the_agent_does_not_claim_to_be_an_exit_node() {
|
||||
let rules = MockHostRules::new();
|
||||
rules.exit().fail_with(Some("iptables needs root".into()));
|
||||
let (interface, routes, id) = managed_interface(&rules, false).await;
|
||||
routes.set_exit(id, offering());
|
||||
interface.sync_exit_rules().await;
|
||||
|
||||
assert!(!interface.exit_offer_ready(id));
|
||||
let offer = interface.exit_rules(id).offer.unwrap();
|
||||
assert!(!offer.ok);
|
||||
assert!(offer.detail.contains("iptables needs root"), "{offer:?}");
|
||||
|
||||
// It is tried again, not given up on, the next time it is asked.
|
||||
rules.exit().fail_with(None);
|
||||
interface.sync_exit_rules().await;
|
||||
assert!(interface.exit_offer_ready(id));
|
||||
interface.remove().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_interface_without_a_host_cannot_be_an_exit_node_and_says_why() {
|
||||
let (interface, _device, routes, _carrier, id) = interface(false).await;
|
||||
routes.set_exit(
|
||||
id,
|
||||
ExitPolicy {
|
||||
offer: true,
|
||||
via: Some(peer(2)),
|
||||
},
|
||||
);
|
||||
interface.sync_exit_rules().await;
|
||||
assert!(!interface.exit_offer_ready(id));
|
||||
let report = interface.exit_rules(id);
|
||||
let offer = report.offer.unwrap();
|
||||
assert!(!offer.ok);
|
||||
assert!(offer.detail.contains("not on the host"), "{offer:?}");
|
||||
assert!(!report.client.unwrap().ok);
|
||||
interface.remove().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_network_waiting_for_its_range_has_nothing_to_masquerade_yet() {
|
||||
let rules = MockHostRules::new();
|
||||
let (interface, routes, _id) = managed_interface(&rules, false).await;
|
||||
let waiting = network("waiting");
|
||||
routes
|
||||
.set_network(
|
||||
waiting,
|
||||
NetworkRoutes {
|
||||
exit: offering(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
interface.sync_exit_rules().await;
|
||||
assert!(rules.exit().installed().is_none());
|
||||
let offer = interface.exit_rules(waiting).offer.unwrap();
|
||||
assert!(offer.detail.contains("address range"), "{offer:?}");
|
||||
interface.remove().await;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ pub enum OverlayError {
|
||||
|
||||
pub mod broadcast;
|
||||
pub mod config;
|
||||
pub mod exit;
|
||||
pub mod hostrules;
|
||||
pub mod interface;
|
||||
pub mod packet;
|
||||
@@ -48,9 +49,10 @@ pub mod router;
|
||||
pub mod tun;
|
||||
|
||||
pub use config::{Cidr, DEFAULT_INTERFACE_PREFIX, MAX_INTERFACE_NAME_LEN, interface_name};
|
||||
pub use exit::{ExitRulesReport, RuleSetReport};
|
||||
pub use hostrules::{
|
||||
BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, MockHostRules, RuleOutcome,
|
||||
UnsupportedHostRules,
|
||||
BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, ExitHostPlan, ExitHostReport,
|
||||
ExitHostRules, MockExitRules, MockHostRules, RuleOutcome, UnsupportedHostRules,
|
||||
};
|
||||
pub use interface::{Counters, Interface, PacketCarrier, Rejected};
|
||||
pub use packet::IpHeader;
|
||||
@@ -59,7 +61,7 @@ pub use provision::{
|
||||
MockHost, MockProvisioner, Privilege, Provisioned, UnsupportedProvisioner, plan_changes,
|
||||
probe_net_admin,
|
||||
};
|
||||
pub use router::{NetworkRoutes, Route, RouteError, RoutingTable};
|
||||
pub use router::{ExitPolicy, NetworkRoutes, Route, RouteError, RoutingTable};
|
||||
pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest, address_is_local};
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
|
||||
@@ -48,7 +48,8 @@ use futures_util::TryStreamExt;
|
||||
use rtnetlink::packet_route::address::{AddressAttribute, AddressMessage};
|
||||
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkFlags, LinkInfo, LinkMessage};
|
||||
use rtnetlink::packet_route::route::RouteScope;
|
||||
use rtnetlink::{LinkMessageBuilder, LinkUnspec, RouteMessageBuilder};
|
||||
use rtnetlink::packet_route::rule::{RuleAction, RuleAttribute, RuleMessage, RuleUidRange};
|
||||
use rtnetlink::{IpVersion, LinkMessageBuilder, LinkUnspec, RouteMessageBuilder};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::overlay::OverlayError;
|
||||
@@ -72,6 +73,11 @@ enum Command {
|
||||
SetBroadcastRoute(String, Ipv4Addr, Reply<()>),
|
||||
/// Remove that route from this interface.
|
||||
DelBroadcastRoute(String, Reply<()>),
|
||||
/// Send the host's default traffic through this interface, except what
|
||||
/// this user id sends.
|
||||
SetExitClient(String, u32, Reply<()>),
|
||||
/// Take those routing rules away again.
|
||||
ClearExitClient(Reply<()>),
|
||||
/// Stop the thread.
|
||||
Stop,
|
||||
}
|
||||
@@ -203,6 +209,20 @@ impl RouteHandle {
|
||||
Command::DelBroadcastRoute(interface, reply)
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends the host's default traffic through `interface`, except what the
|
||||
/// user id `uid` sends. Replaces what an earlier call set up.
|
||||
pub(crate) fn set_exit_client(&self, interface: &str, uid: u32) -> Result<(), OverlayError> {
|
||||
let interface = interface.to_string();
|
||||
call_thread(&self.commands, |reply| {
|
||||
Command::SetExitClient(interface, uid, reply)
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes those rules. Succeeds when none are there.
|
||||
pub(crate) fn clear_exit_client(&self) -> Result<(), OverlayError> {
|
||||
call_thread(&self.commands, Command::ClearExitClient)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NetlinkProvisioner {
|
||||
@@ -324,7 +344,11 @@ fn netlink_thread(requests: mpsc::Receiver<Command>) {
|
||||
Command::Delete(_, reply)
|
||||
| Command::Configure(_, reply)
|
||||
| Command::SetBroadcastRoute(_, _, reply)
|
||||
| Command::DelBroadcastRoute(_, reply) => {
|
||||
| Command::DelBroadcastRoute(_, reply)
|
||||
| Command::SetExitClient(_, _, reply) => {
|
||||
let _ = reply.send(Err(OverlayError::Unavailable(message)));
|
||||
}
|
||||
Command::ClearExitClient(reply) => {
|
||||
let _ = reply.send(Err(OverlayError::Unavailable(message)));
|
||||
}
|
||||
Command::Stop => return,
|
||||
@@ -373,6 +397,22 @@ fn netlink_thread(requests: mpsc::Receiver<Command>) {
|
||||
});
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
Command::SetExitClient(name, uid, reply) => {
|
||||
let result = NetAdmin::acquire().and_then(|guard| {
|
||||
let result = runtime.block_on(set_exit_client(&name, uid));
|
||||
drop(guard);
|
||||
result
|
||||
});
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
Command::ClearExitClient(reply) => {
|
||||
let result = NetAdmin::acquire().and_then(|guard| {
|
||||
let result = runtime.block_on(clear_exit_client());
|
||||
drop(guard);
|
||||
result
|
||||
});
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -596,6 +636,192 @@ async fn del_broadcast_route(name: &str) -> Result<(), OverlayError> {
|
||||
.await
|
||||
}
|
||||
|
||||
/// The routing table that holds the exit node's default route: `tsun` in
|
||||
/// ASCII-ish hex, and nothing else uses it.
|
||||
const EXIT_TABLE: u32 = 0x7473;
|
||||
/// The main table.
|
||||
const MAIN_TABLE: u32 = 254;
|
||||
/// Where the exit rules sit, after Tailscale's 5270.
|
||||
const EXIT_BYPASS_PRIORITY: u32 = 5280;
|
||||
const EXIT_SPECIFIC_PRIORITY: u32 = 5290;
|
||||
const EXIT_DEFAULT_PRIORITY: u32 = 5300;
|
||||
|
||||
/// The table a rule looks up.
|
||||
fn rule_table(rule: &RuleMessage) -> u32 {
|
||||
rule.attributes
|
||||
.iter()
|
||||
.find_map(|attribute| match attribute {
|
||||
RuleAttribute::Table(table) => Some(*table),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(u32::from(rule.header.table))
|
||||
}
|
||||
|
||||
fn rule_priority(rule: &RuleMessage) -> Option<u32> {
|
||||
rule.attributes
|
||||
.iter()
|
||||
.find_map(|attribute| match attribute {
|
||||
RuleAttribute::Priority(priority) => Some(*priority),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a rule is one of the three this agent adds for an exit node.
|
||||
///
|
||||
/// Recognised by shape as well as priority: those numbers are not reserved,
|
||||
/// and a rule somebody else put at one of them must not be removed.
|
||||
fn is_exit_rule(rule: &RuleMessage) -> bool {
|
||||
let table = rule_table(rule);
|
||||
match rule_priority(rule) {
|
||||
Some(EXIT_BYPASS_PRIORITY) => {
|
||||
table == MAIN_TABLE
|
||||
&& rule
|
||||
.attributes
|
||||
.iter()
|
||||
.any(|attribute| matches!(attribute, RuleAttribute::UidRange(_)))
|
||||
}
|
||||
Some(EXIT_SPECIFIC_PRIORITY) => {
|
||||
table == MAIN_TABLE
|
||||
&& rule
|
||||
.attributes
|
||||
.iter()
|
||||
.any(|attribute| matches!(attribute, RuleAttribute::SuppressPrefixLen(0)))
|
||||
}
|
||||
Some(EXIT_DEFAULT_PRIORITY) => table == EXIT_TABLE,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn exit_default_route(index: Option<u32>) -> rtnetlink::packet_route::route::RouteMessage {
|
||||
let mut builder = RouteMessageBuilder::<Ipv4Addr>::new()
|
||||
.destination_prefix(Ipv4Addr::UNSPECIFIED, 0)
|
||||
.table_id(EXIT_TABLE);
|
||||
if let Some(index) = index {
|
||||
builder = builder.output_interface(index).scope(RouteScope::Link);
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// Removes the exit node's routing rules and its table. 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();
|
||||
let mut ours = Vec::new();
|
||||
while let Some(rule) = rules
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|err| OverlayError::Unavailable(format!("cannot read the routing rules: {err}")))?
|
||||
{
|
||||
if is_exit_rule(&rule) {
|
||||
ours.push(rule);
|
||||
}
|
||||
}
|
||||
for rule in ours {
|
||||
match handle.rule().del(rule).execute().await {
|
||||
Ok(()) => {}
|
||||
// Gone already.
|
||||
Err(err) if err.to_string().contains("No such") => {}
|
||||
Err(err) => {
|
||||
return Err(OverlayError::Unavailable(format!(
|
||||
"cannot remove an exit node routing rule: {err}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
match handle.route().del(exit_default_route(None)).execute().await {
|
||||
Ok(()) => Ok(()),
|
||||
// "No such process": no such route, which is the goal.
|
||||
Err(err) if err.to_string().contains("No such") => Ok(()),
|
||||
Err(err) => Err(OverlayError::Unavailable(format!(
|
||||
"cannot remove the exit node default route: {err}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up the using side of an exit node.
|
||||
///
|
||||
/// ```text
|
||||
/// ip route replace default dev <if> table 0x7473
|
||||
/// ip rule add priority 5280 uidrange <uid>-<uid> lookup main
|
||||
/// ip rule add priority 5290 lookup main suppress_prefixlength 0
|
||||
/// ip rule add priority 5300 lookup 0x7473
|
||||
/// ```
|
||||
///
|
||||
/// 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.
|
||||
async fn set_exit_client(name: &str, uid: u32) -> Result<(), OverlayError> {
|
||||
with_netlink(|handle| async move {
|
||||
let index = link_index(&handle, name).await.ok_or_else(|| {
|
||||
OverlayError::Unavailable(format!(
|
||||
"interface `{name}` disappeared before the exit node routes could be added"
|
||||
))
|
||||
})?;
|
||||
remove_exit_client(&handle).await?;
|
||||
let fail = |what: &str, err: rtnetlink::Error| {
|
||||
OverlayError::Unavailable(format!("cannot add the exit node {what}: {err}"))
|
||||
};
|
||||
handle
|
||||
.route()
|
||||
.add(exit_default_route(Some(index)))
|
||||
.replace()
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| fail("default route", err))?;
|
||||
|
||||
let mut bypass = handle
|
||||
.rule()
|
||||
.add()
|
||||
.v4()
|
||||
.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()
|
||||
.v4()
|
||||
.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()
|
||||
.v4()
|
||||
.table_id(EXIT_TABLE)
|
||||
.action(RuleAction::ToTable)
|
||||
.priority(EXIT_DEFAULT_PRIORITY)
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| fail("rule for the default route", err))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn configure_link(configure: &Configure) -> Result<(), OverlayError> {
|
||||
let name = configure.name.as_str();
|
||||
with_netlink(|handle| async move {
|
||||
@@ -737,6 +963,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn rule(priority: u32, table: u32, extra: Vec<RuleAttribute>) -> RuleMessage {
|
||||
let mut message = RuleMessage::default();
|
||||
message.attributes.push(RuleAttribute::Priority(priority));
|
||||
if table > 255 {
|
||||
message.attributes.push(RuleAttribute::Table(table));
|
||||
} else {
|
||||
message.header.table = table as u8;
|
||||
}
|
||||
message.attributes.extend(extra);
|
||||
message
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_three_exit_rules_are_recognised_as_ours() {
|
||||
let uid = RuleAttribute::UidRange(RuleUidRange { start: 5, end: 5 });
|
||||
assert!(is_exit_rule(&rule(5280, 254, vec![uid.clone()])));
|
||||
assert!(is_exit_rule(&rule(
|
||||
5290,
|
||||
254,
|
||||
vec![RuleAttribute::SuppressPrefixLen(0)]
|
||||
)));
|
||||
assert!(is_exit_rule(&rule(5300, EXIT_TABLE, vec![])));
|
||||
|
||||
// Same priorities, somebody else's rules.
|
||||
assert!(!is_exit_rule(&rule(5280, 254, vec![])));
|
||||
assert!(!is_exit_rule(&rule(5290, 254, vec![])));
|
||||
assert!(!is_exit_rule(&rule(5300, 52, vec![])));
|
||||
assert!(!is_exit_rule(&rule(5270, 52, vec![])));
|
||||
assert!(!is_exit_rule(&rule(5280, 100, vec![uid])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_route_of_the_exit_table_names_the_table_and_the_interface() {
|
||||
let route = exit_default_route(Some(7));
|
||||
assert_eq!(route.header.destination_prefix_length, 0);
|
||||
let debug = format!("{route:?}");
|
||||
assert!(debug.contains(&format!("Table({EXIT_TABLE})")), "{debug}");
|
||||
assert!(debug.contains("Oif(7)"), "{debug}");
|
||||
// Deleting names no interface, so it matches whatever it was added on.
|
||||
assert!(!format!("{:?}", exit_default_route(None)).contains("Oif"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interface_that_is_not_a_tun_is_reported_foreign() {
|
||||
let message = LinkMessage::default();
|
||||
|
||||
@@ -40,9 +40,21 @@ pub struct Route {
|
||||
pub peer: EndpointId,
|
||||
}
|
||||
|
||||
/// A network's exit-node settings, as far as packets are concerned.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct ExitPolicy {
|
||||
/// This agent offers itself as an exit node here: packets from members
|
||||
/// for addresses outside every overlay range are handed to the host.
|
||||
pub offer: bool,
|
||||
/// The member all other traffic is sent to, if this device uses one.
|
||||
pub via: Option<EndpointId>,
|
||||
}
|
||||
|
||||
/// What one network contributes to the table.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct NetworkRoutes {
|
||||
/// Exit-node settings of this network.
|
||||
pub exit: ExitPolicy,
|
||||
/// Local broadcast policy and authenticated willing recipients.
|
||||
pub broadcast: BroadcastPolicy,
|
||||
/// The range this network allocates from, once it has agreed one.
|
||||
@@ -153,6 +165,16 @@ impl RoutingTable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces only a network's exit-node settings, without changing addresses.
|
||||
///
|
||||
/// A network that has no entry yet (its range is not agreed) takes them
|
||||
/// from [`NetworkRoutes::exit`] when it is added.
|
||||
pub fn set_exit(&self, network: NetworkId, policy: ExitPolicy) {
|
||||
if let Some(routes) = self.write().get_mut(&network) {
|
||||
routes.exit = policy;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognizes limited and configured subnet broadcasts before unicast lookup.
|
||||
pub fn is_broadcast(&self, destination: Ipv4Addr) -> bool {
|
||||
self.broadcast.load().is_destination(destination)
|
||||
@@ -181,6 +203,10 @@ impl RoutingTable {
|
||||
///
|
||||
/// This agent's own address returns `None`: a packet for ourselves does
|
||||
/// not go over a tunnel, and answering with a peer would send it to one.
|
||||
///
|
||||
/// An address nobody holds, outside every overlay range and an ordinary
|
||||
/// unicast destination, goes to this device's exit node when it has one:
|
||||
/// that is what sending *all* traffic through a member means.
|
||||
pub fn route(&self, destination: Ipv4Addr) -> Option<Route> {
|
||||
let networks = self.read();
|
||||
for (network, routes) in networks.iter() {
|
||||
@@ -198,17 +224,80 @@ impl RoutingTable {
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
// Inside an overlay range, an unowned address is nobody's, not the
|
||||
// internet's.
|
||||
if !is_internet_destination(destination) || in_any_range(&networks, destination) {
|
||||
return None;
|
||||
}
|
||||
exit_via(&networks).map(|(network, peer)| Route { network, peer })
|
||||
}
|
||||
|
||||
/// Whether a peer may send from a source address.
|
||||
///
|
||||
/// A peer speaks only for the address the network agreed it holds. The
|
||||
/// one exception is this device's exit node: what comes back from the
|
||||
/// internet carries the internet's addresses, so from *that* peer any
|
||||
/// source outside the overlay ranges is accepted.
|
||||
pub fn may_send_from(&self, network: NetworkId, peer: EndpointId, source: Ipv4Addr) -> bool {
|
||||
self.read().get(&network).is_some_and(|routes| {
|
||||
routes
|
||||
.peers
|
||||
.iter()
|
||||
.any(|(address, holder)| *address == source && *holder == peer)
|
||||
})
|
||||
let networks = self.read();
|
||||
let Some(routes) = networks.get(&network) else {
|
||||
return false;
|
||||
};
|
||||
if routes
|
||||
.peers
|
||||
.iter()
|
||||
.any(|(address, holder)| *address == source && *holder == peer)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
routes.exit.via == Some(peer)
|
||||
&& is_internet_destination(source)
|
||||
&& !in_any_range(&networks, source)
|
||||
}
|
||||
|
||||
/// Whether this agent, as an exit node of `network`, takes a member's
|
||||
/// packet for `destination` to the host.
|
||||
///
|
||||
/// Only an ordinary unicast address outside every overlay range, so a
|
||||
/// packet for another member, for this agent or for the local network
|
||||
/// segment never leaves by this door. The sender's own source address is
|
||||
/// checked separately, by [`Self::may_send_from`].
|
||||
pub fn accepts_exit_traffic(&self, network: NetworkId, destination: Ipv4Addr) -> bool {
|
||||
let networks = self.read();
|
||||
networks
|
||||
.get(&network)
|
||||
.is_some_and(|routes| routes.exit.offer)
|
||||
&& is_internet_destination(destination)
|
||||
&& !in_any_range(&networks, destination)
|
||||
}
|
||||
|
||||
/// The network and member this device sends its internet traffic through.
|
||||
///
|
||||
/// One default route, so one answer; if several networks claim one (they
|
||||
/// should not) the lowest network id decides, so two runs agree.
|
||||
pub fn exit_via(&self) -> Option<(NetworkId, EndpointId)> {
|
||||
exit_via(&self.read())
|
||||
}
|
||||
|
||||
/// Networks that offer this agent as an exit node, with the range each
|
||||
/// agreed. A network that has not agreed one yet has nothing to
|
||||
/// masquerade and is left out.
|
||||
pub fn exit_offers(&self) -> Vec<(NetworkId, Ipv4Range)> {
|
||||
let mut offers: Vec<(NetworkId, Ipv4Range)> = self
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, routes)| routes.exit.offer)
|
||||
.filter_map(|(network, routes)| Some((*network, routes.range?)))
|
||||
.collect();
|
||||
offers.sort_by_key(|(network, _)| *network);
|
||||
offers
|
||||
}
|
||||
|
||||
/// One network's exit-node settings and its range, if it is in the table.
|
||||
pub fn exit_settings(&self, network: NetworkId) -> Option<(ExitPolicy, Option<Ipv4Range>)> {
|
||||
self.read()
|
||||
.get(&network)
|
||||
.map(|routes| (routes.exit, routes.range))
|
||||
}
|
||||
|
||||
/// Whether an ordinary packet terminates on this host in this network.
|
||||
@@ -275,6 +364,31 @@ impl RoutingTable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an address is an ordinary unicast one: not this host, not a
|
||||
/// multicast or broadcast group, not link-local, not unspecified.
|
||||
fn is_internet_destination(address: Ipv4Addr) -> bool {
|
||||
!(address.is_unspecified()
|
||||
|| address.is_loopback()
|
||||
|| address.is_multicast()
|
||||
|| address.is_broadcast()
|
||||
|| address.is_link_local())
|
||||
}
|
||||
|
||||
/// Whether an address lies in any network's range.
|
||||
fn in_any_range(networks: &HashMap<NetworkId, NetworkRoutes>, address: Ipv4Addr) -> bool {
|
||||
networks
|
||||
.values()
|
||||
.any(|routes| routes.range.is_some_and(|range| range.contains(address)))
|
||||
}
|
||||
|
||||
/// The exit node in use, if any, from the lowest network that names one.
|
||||
fn exit_via(networks: &HashMap<NetworkId, NetworkRoutes>) -> Option<(NetworkId, EndpointId)> {
|
||||
networks
|
||||
.iter()
|
||||
.filter_map(|(network, routes)| Some((*network, routes.exit.via?)))
|
||||
.min_by_key(|(network, _)| *network)
|
||||
}
|
||||
|
||||
/// Whether two ranges share any address.
|
||||
fn ranges_overlap(one: Ipv4Range, other: Ipv4Range) -> bool {
|
||||
// A range contains the other's base, or the other way round. With
|
||||
@@ -308,6 +422,7 @@ mod tests {
|
||||
|
||||
fn routes() -> NetworkRoutes {
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
local: Some(addr(1)),
|
||||
@@ -391,6 +506,7 @@ mod tests {
|
||||
.set_network(
|
||||
second,
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||
@@ -420,6 +536,7 @@ mod tests {
|
||||
if a < b { (a, b) } else { (b, a) }
|
||||
};
|
||||
let mk = |enabled: bool, base: [u8; 4], local: Option<Ipv4Addr>| NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: BroadcastPolicy {
|
||||
enabled,
|
||||
..Default::default()
|
||||
@@ -495,6 +612,7 @@ mod tests {
|
||||
.set_network(
|
||||
network("wide"),
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.0.0.0/8".parse().unwrap()),
|
||||
..Default::default()
|
||||
@@ -507,6 +625,7 @@ mod tests {
|
||||
.set_network(
|
||||
network("narrow"),
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
..Default::default()
|
||||
@@ -549,6 +668,7 @@ mod tests {
|
||||
.set_network(
|
||||
network("two"),
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||
@@ -573,4 +693,233 @@ mod tests {
|
||||
assert_eq!(table.route(addr(2)), None);
|
||||
assert_eq!(table.len(), 1, "the network is known, it just has nothing");
|
||||
}
|
||||
|
||||
fn with_exit(offer: bool, via: Option<EndpointId>) -> NetworkRoutes {
|
||||
NetworkRoutes {
|
||||
exit: ExitPolicy { offer, via },
|
||||
..routes()
|
||||
}
|
||||
}
|
||||
|
||||
const INTERNET: Ipv4Addr = Ipv4Addr::new(93, 184, 216, 34);
|
||||
|
||||
#[test]
|
||||
fn without_an_exit_node_the_internet_has_nowhere_to_go() {
|
||||
let table = RoutingTable::new();
|
||||
table.set_network(network("plain"), routes()).unwrap();
|
||||
assert_eq!(table.route(INTERNET), None);
|
||||
assert_eq!(table.exit_via(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_an_exit_node_every_ordinary_destination_goes_to_it() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("client");
|
||||
table
|
||||
.set_network(id, with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
let exit = Route {
|
||||
network: id,
|
||||
peer: peer(2),
|
||||
};
|
||||
assert_eq!(table.route(INTERNET), Some(exit));
|
||||
assert_eq!(table.route(Ipv4Addr::new(192, 168, 1, 10)), Some(exit));
|
||||
assert_eq!(table.exit_via(), Some((id, peer(2))));
|
||||
// A member's own address still goes to that member.
|
||||
assert_eq!(
|
||||
table.route(addr(3)),
|
||||
Some(Route {
|
||||
network: id,
|
||||
peer: peer(3)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_exit_node_is_never_the_answer_for_what_is_not_the_internet() {
|
||||
let table = RoutingTable::new();
|
||||
table
|
||||
.set_network(network("client"), with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
// Ourselves, a free address inside the overlay range, and everything
|
||||
// that is not an ordinary unicast destination.
|
||||
for destination in [
|
||||
addr(1),
|
||||
addr(200),
|
||||
Ipv4Addr::new(127, 0, 0, 1),
|
||||
Ipv4Addr::new(224, 0, 0, 251),
|
||||
Ipv4Addr::new(239, 255, 255, 250),
|
||||
Ipv4Addr::BROADCAST,
|
||||
Ipv4Addr::UNSPECIFIED,
|
||||
Ipv4Addr::new(169, 254, 1, 1),
|
||||
] {
|
||||
assert_eq!(table.route(destination), None, "{destination}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_networks_range_is_not_the_internet_either() {
|
||||
let table = RoutingTable::new();
|
||||
table
|
||||
.set_network(network("client"), with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
table
|
||||
.set_network(
|
||||
network("other"),
|
||||
NetworkRoutes {
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(table.route(Ipv4Addr::new(10, 99, 7, 7)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_exit_choices_resolve_to_the_lowest_network_id() {
|
||||
let table = RoutingTable::new();
|
||||
let (a, b) = (network("one"), network("two"));
|
||||
table
|
||||
.set_network(a, with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
table
|
||||
.set_network(
|
||||
b,
|
||||
NetworkRoutes {
|
||||
exit: ExitPolicy {
|
||||
offer: false,
|
||||
via: Some(peer(9)),
|
||||
},
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (chosen, _) = table.exit_via().unwrap();
|
||||
assert_eq!(chosen, a.min(b));
|
||||
assert_eq!(table.route(INTERNET).unwrap().network, a.min(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_exit_peer_may_send_from_any_internet_address_but_nobody_else_may() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("client");
|
||||
table
|
||||
.set_network(id, with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
|
||||
// What comes back from the internet carries the internet's addresses.
|
||||
assert!(table.may_send_from(id, peer(2), INTERNET));
|
||||
// Another member still speaks only for its own address.
|
||||
assert!(!table.may_send_from(id, peer(3), INTERNET));
|
||||
assert!(table.may_send_from(id, peer(3), addr(3)));
|
||||
// The exit peer cannot pass itself off as somebody else in the
|
||||
// overlay, nor as this agent, nor as something that is not a source.
|
||||
assert!(!table.may_send_from(id, peer(2), addr(3)));
|
||||
assert!(!table.may_send_from(id, peer(2), addr(1)));
|
||||
assert!(!table.may_send_from(id, peer(2), addr(200)));
|
||||
for source in [
|
||||
Ipv4Addr::LOCALHOST,
|
||||
Ipv4Addr::UNSPECIFIED,
|
||||
Ipv4Addr::new(224, 0, 0, 1),
|
||||
Ipv4Addr::BROADCAST,
|
||||
] {
|
||||
assert!(!table.may_send_from(id, peer(2), source), "{source}");
|
||||
}
|
||||
// Without choosing an exit node nobody gets the exception.
|
||||
let plain = RoutingTable::new();
|
||||
plain.set_network(id, routes()).unwrap();
|
||||
assert!(!plain.may_send_from(id, peer(2), INTERNET));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exit_node_takes_a_members_internet_packets_only_while_it_offers() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("server");
|
||||
table.set_network(id, with_exit(false, None)).unwrap();
|
||||
assert!(!table.accepts_exit_traffic(id, INTERNET));
|
||||
|
||||
table.set_exit(
|
||||
id,
|
||||
ExitPolicy {
|
||||
offer: true,
|
||||
via: None,
|
||||
},
|
||||
);
|
||||
assert!(table.accepts_exit_traffic(id, INTERNET));
|
||||
assert!(table.accepts_exit_traffic(id, Ipv4Addr::new(192, 168, 1, 10)));
|
||||
// Never for another member, for this agent, a free overlay address,
|
||||
// or anything that is not an ordinary unicast destination.
|
||||
for destination in [
|
||||
addr(1),
|
||||
addr(2),
|
||||
addr(200),
|
||||
Ipv4Addr::LOCALHOST,
|
||||
Ipv4Addr::new(224, 0, 0, 251),
|
||||
Ipv4Addr::BROADCAST,
|
||||
Ipv4Addr::UNSPECIFIED,
|
||||
] {
|
||||
assert!(
|
||||
!table.accepts_exit_traffic(id, destination),
|
||||
"{destination}"
|
||||
);
|
||||
}
|
||||
// An unknown network offers nothing.
|
||||
assert!(!table.accepts_exit_traffic(network("elsewhere"), INTERNET));
|
||||
|
||||
table.set_exit(id, ExitPolicy::default());
|
||||
assert!(!table.accepts_exit_traffic(id, INTERNET));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offers_are_listed_by_network_with_the_range_each_agreed() {
|
||||
let table = RoutingTable::new();
|
||||
let (a, b) = (network("a"), network("b"));
|
||||
table.set_network(a, with_exit(true, None)).unwrap();
|
||||
table
|
||||
.set_network(
|
||||
b,
|
||||
NetworkRoutes {
|
||||
exit: ExitPolicy {
|
||||
offer: true,
|
||||
via: None,
|
||||
},
|
||||
// No range agreed yet: nothing to masquerade.
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
table.exit_offers(),
|
||||
vec![(a, "10.13.37.0/24".parse().unwrap())]
|
||||
);
|
||||
assert_eq!(
|
||||
table.exit_settings(a),
|
||||
Some((
|
||||
ExitPolicy {
|
||||
offer: true,
|
||||
via: None
|
||||
},
|
||||
Some("10.13.37.0/24".parse().unwrap())
|
||||
))
|
||||
);
|
||||
assert_eq!(table.exit_settings(network("none")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_exit_for_a_network_with_no_entry_is_remembered_by_the_caller() {
|
||||
// The runtime keeps the policy and passes it with the network when
|
||||
// its range is agreed; a setter for a network the table does not know
|
||||
// yet changes nothing.
|
||||
let table = RoutingTable::new();
|
||||
table.set_exit(
|
||||
network("later"),
|
||||
ExitPolicy {
|
||||
offer: true,
|
||||
via: None,
|
||||
},
|
||||
);
|
||||
assert!(table.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use crate::error::ProtocolError;
|
||||
/// The version in the ALPN is the wire-compatibility version of the control
|
||||
/// protocol. It is independent of the network identity scheme version, so
|
||||
/// bumping it must not change any existing [`crate::NetworkId`].
|
||||
pub const ALPN: &[u8] = b"tsunagi/ctrl/3";
|
||||
pub const ALPN: &[u8] = b"tsunagi/ctrl/4";
|
||||
|
||||
/// ALPN of the tsunagi data plane.
|
||||
///
|
||||
@@ -41,7 +41,7 @@ pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
|
||||
pub const MAX_SIGNATURE_LEN: usize = 64;
|
||||
|
||||
/// Control protocol version carried inside the handshake.
|
||||
pub const PROTOCOL_VERSION: u16 = 3;
|
||||
pub const PROTOCOL_VERSION: u16 = 4;
|
||||
|
||||
/// First message of the handshake, sent by the initiator.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -75,6 +75,11 @@ pub struct AuthProof {
|
||||
pub struct Announcement {
|
||||
/// Local opt-in to receive LAN broadcasts in this network.
|
||||
pub broadcast: bool,
|
||||
/// This agent offers itself as an exit node in this network: members may
|
||||
/// send all their internet traffic through it. Off unless its owner
|
||||
/// turned it on, and only said once the rules that make it work are in
|
||||
/// place.
|
||||
pub exit_node: bool,
|
||||
/// Human-readable hostname. A mutable binding, not an identity.
|
||||
pub hostname: String,
|
||||
/// Announced IP plugin capabilities. Opaque to the core.
|
||||
|
||||
@@ -254,6 +254,18 @@ impl Storage {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persists whether this agent offers itself as an exit node in a network.
|
||||
pub async fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
self.with_state(move |state| state.set_exit_node(network_id, enabled))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persists the member this device sends its internet traffic through.
|
||||
pub async fn set_exit_via(&self, network_id: NetworkId, via: Option<[u8; 32]>) -> Result<()> {
|
||||
self.with_state(move |state| state.set_exit_via(network_id, via.as_ref()))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Updates the auto-start flag of a network.
|
||||
pub async fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
|
||||
self.with_state(move |state| state.set_auto_start(network_id, auto_start))
|
||||
@@ -264,7 +276,8 @@ impl Storage {
|
||||
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.with_state(move |state| {
|
||||
state.remove_network(network_id)?;
|
||||
state.forget_signed_records(network_id)
|
||||
state.forget_signed_records(network_id)?;
|
||||
state.forget_peer_hostnames(network_id)
|
||||
})
|
||||
.await?;
|
||||
self.with_cache((), move |cache| cache.forget_network(network_id))
|
||||
@@ -283,6 +296,26 @@ impl Storage {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Remembers the hostname a member announced, so it can still be named
|
||||
/// once it is away.
|
||||
pub async fn remember_peer_hostname(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
endpoint_id: [u8; 32],
|
||||
hostname: String,
|
||||
) -> Result<()> {
|
||||
self.with_state(move |state| {
|
||||
state.remember_peer_hostname(network_id, &endpoint_id, &hostname)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The hostnames members of a network last announced.
|
||||
pub async fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
|
||||
self.with_state(move |state| state.peer_hostnames(network_id))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Records an address hint. Failures are non-fatal.
|
||||
pub async fn record_hint(
|
||||
&self,
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
|
||||
use crate::state::{RecordBody, SignedRecord};
|
||||
|
||||
/// Schema version written by this build.
|
||||
pub const SCHEMA_VERSION: i64 = 4;
|
||||
pub const SCHEMA_VERSION: i64 = 6;
|
||||
|
||||
/// Key of the stored hostname setting.
|
||||
const SETTING_HOSTNAME: &str = "hostname";
|
||||
@@ -42,6 +42,10 @@ pub struct StoredNetwork {
|
||||
pub auto_start: bool,
|
||||
/// Local broadcast participation, enabled unless explicitly disabled.
|
||||
pub broadcast: bool,
|
||||
/// This agent offers itself as an exit node here. Off unless turned on.
|
||||
pub exit_node: bool,
|
||||
/// The member this device sends all its internet traffic through, if any.
|
||||
pub exit_via: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// The mandatory state store.
|
||||
@@ -191,13 +195,45 @@ impl StateStore {
|
||||
PRAGMA user_version = 4;
|
||||
COMMIT;").map_err(|err| self.corrupt(format!("cannot migrate schema to 4: {err}")))?;
|
||||
}
|
||||
if found < 5 {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
"BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS peer_hostnames (
|
||||
network_id BLOB NOT NULL,
|
||||
endpoint_id BLOB NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
PRIMARY KEY (network_id, endpoint_id)
|
||||
);
|
||||
PRAGMA user_version = 5;
|
||||
COMMIT;",
|
||||
)
|
||||
.map_err(|err| self.corrupt(format!("cannot migrate schema to 5: {err}")))?;
|
||||
}
|
||||
if found < 6 {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
"BEGIN;
|
||||
ALTER TABLE networks ADD COLUMN exit_node INTEGER NOT NULL DEFAULT 0 CHECK (exit_node IN (0,1));
|
||||
ALTER TABLE networks ADD COLUMN exit_via BLOB;
|
||||
PRAGMA user_version = 6;
|
||||
COMMIT;",
|
||||
)
|
||||
.map_err(|err| self.corrupt(format!("cannot migrate schema to 6: {err}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confirms the expected tables exist, so that a truncated or foreign
|
||||
/// database is reported rather than used.
|
||||
fn verify_shape(&self) -> Result<()> {
|
||||
for table in ["device_identity", "networks", "settings", "signed_records"] {
|
||||
for table in [
|
||||
"device_identity",
|
||||
"networks",
|
||||
"settings",
|
||||
"signed_records",
|
||||
"peer_hostnames",
|
||||
] {
|
||||
let present: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
@@ -430,11 +466,56 @@ impl StateStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves whether this agent offers itself as an exit node in a network.
|
||||
pub fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
let changed = self
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE networks SET exit_node = ?2 WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice(), enabled as i64],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot update exit node offer: {err}")))?;
|
||||
if changed == 0 {
|
||||
return Err(Error::NetworkUnknown(network_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves which member this device sends its internet traffic through.
|
||||
///
|
||||
/// One device has one default route, so choosing an exit node in one
|
||||
/// network clears the choice in every other.
|
||||
pub fn set_exit_via(&self, network_id: NetworkId, via: Option<&[u8; 32]>) -> Result<()> {
|
||||
let tx = self
|
||||
.conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
||||
let changed = tx
|
||||
.execute(
|
||||
"UPDATE networks SET exit_via = ?2 WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice(), via.map(|v| v.as_slice())],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
||||
if changed == 0 {
|
||||
return Err(Error::NetworkUnknown(network_id));
|
||||
}
|
||||
if via.is_some() {
|
||||
tx.execute(
|
||||
"UPDATE networks SET exit_via = NULL WHERE network_id != ?1",
|
||||
params![network_id.as_bytes().as_slice()],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lists every configured network.
|
||||
pub fn list_networks(&self) -> Result<Vec<StoredNetwork>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT network_id, name, secret, auto_start, broadcast FROM networks ORDER BY name")
|
||||
.prepare("SELECT network_id, name, secret, auto_start, broadcast, exit_node, exit_via FROM networks ORDER BY name")
|
||||
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
@@ -442,13 +523,21 @@ impl StateStore {
|
||||
let name: String = row.get(1)?;
|
||||
let secret: Vec<u8> = row.get(2)?;
|
||||
let auto_start: i64 = row.get(3)?;
|
||||
Ok((id, name, secret, auto_start != 0, row.get::<_, bool>(4)?))
|
||||
Ok((
|
||||
id,
|
||||
name,
|
||||
secret,
|
||||
auto_start != 0,
|
||||
row.get::<_, bool>(4)?,
|
||||
row.get::<_, bool>(5)?,
|
||||
row.get::<_, Option<Vec<u8>>>(6)?,
|
||||
))
|
||||
})
|
||||
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, secret, auto_start, broadcast) =
|
||||
let (id, name, secret, auto_start, broadcast, exit_node, exit_via) =
|
||||
row.map_err(|err| Error::Storage(format!("cannot read network row: {err}")))?;
|
||||
let id: [u8; 32] = id
|
||||
.as_slice()
|
||||
@@ -460,6 +549,8 @@ impl StateStore {
|
||||
secret: NetworkSecret::from_bytes(secret)?,
|
||||
auto_start,
|
||||
broadcast,
|
||||
exit_node,
|
||||
exit_via: exit_via.and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -635,6 +726,61 @@ impl StateStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remembers the hostname a member last announced.
|
||||
pub fn remember_peer_hostname(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
endpoint_id: &[u8; 32],
|
||||
hostname: &str,
|
||||
) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO peer_hostnames (network_id, endpoint_id, hostname)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(network_id, endpoint_id) DO UPDATE SET hostname = excluded.hostname",
|
||||
params![
|
||||
network_id.as_bytes().as_slice(),
|
||||
endpoint_id.as_slice(),
|
||||
hostname
|
||||
],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot remember a hostname: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The last hostname every member of a network announced.
|
||||
pub fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT endpoint_id, hostname FROM peer_hostnames WHERE network_id = ?1")
|
||||
.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||
let rows = stmt
|
||||
.query_map(params![network_id.as_bytes().as_slice()], |row| {
|
||||
Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, hostname) =
|
||||
row.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||
if let Ok(id) = <[u8; 32]>::try_from(id.as_slice()) {
|
||||
out.push((id, hostname));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Forgets every remembered hostname of a network.
|
||||
pub fn forget_peer_hostnames(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM peer_hostnames WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice()],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot forget hostnames: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads an arbitrary setting.
|
||||
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
||||
self.conn
|
||||
@@ -698,6 +844,60 @@ mod broadcast_storage_tests {
|
||||
use super::*;
|
||||
use crate::identity::NetworkKeys;
|
||||
|
||||
#[test]
|
||||
fn exit_node_choices_are_off_by_default_and_one_choice_clears_the_others() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = StateStore::open(dir.path().join("state.sqlite")).unwrap();
|
||||
let secret = NetworkSecret::from_bytes([5; 32]).unwrap();
|
||||
let make = |name: &str| {
|
||||
let name = NetworkName::new(name).unwrap();
|
||||
let id = NetworkKeys::derive(&name, &secret).network_id();
|
||||
store.upsert_network(id, &name, &secret, true).unwrap();
|
||||
id
|
||||
};
|
||||
let (a, b) = (make("exit-a"), make("exit-b"));
|
||||
let listed = || store.list_networks().unwrap();
|
||||
assert!(
|
||||
listed()
|
||||
.iter()
|
||||
.all(|n| !n.exit_node && n.exit_via.is_none())
|
||||
);
|
||||
|
||||
store.set_exit_node(a, true).unwrap();
|
||||
store.set_exit_via(a, Some(&[7; 32])).unwrap();
|
||||
store.set_exit_via(b, Some(&[8; 32])).unwrap();
|
||||
let by_id = |id: NetworkId| listed().into_iter().find(|n| n.network_id == id).unwrap();
|
||||
assert!(by_id(a).exit_node, "offering is per network and kept");
|
||||
assert_eq!(by_id(a).exit_via, None, "one default route: a was cleared");
|
||||
assert_eq!(by_id(b).exit_via, Some([8; 32]));
|
||||
|
||||
store.set_exit_via(b, None).unwrap();
|
||||
assert_eq!(by_id(b).exit_via, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remembered_hostname_survives_a_restart_and_follows_the_latest_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("state.sqlite");
|
||||
let name = NetworkName::new("hostnames").unwrap();
|
||||
let secret = NetworkSecret::from_bytes([9; 32]).unwrap();
|
||||
let id = NetworkKeys::derive(&name, &secret).network_id();
|
||||
let peer = [3u8; 32];
|
||||
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
store.remember_peer_hostname(id, &peer, "laptop").unwrap();
|
||||
store.remember_peer_hostname(id, &peer, "laptop-2").unwrap();
|
||||
drop(store);
|
||||
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
store.peer_hostnames(id).unwrap(),
|
||||
vec![(peer, "laptop-2".to_string())]
|
||||
);
|
||||
store.forget_peer_hostnames(id).unwrap();
|
||||
assert!(store.peer_hostnames(id).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -714,7 +914,12 @@ mod broadcast_storage_tests {
|
||||
store.set_hostname("old-host").unwrap();
|
||||
store
|
||||
.conn
|
||||
.execute_batch("ALTER TABLE networks DROP COLUMN broadcast; PRAGMA user_version=3;")
|
||||
.execute_batch(
|
||||
"ALTER TABLE networks DROP COLUMN broadcast; \
|
||||
ALTER TABLE networks DROP COLUMN exit_node; \
|
||||
ALTER TABLE networks DROP COLUMN exit_via; \
|
||||
PRAGMA user_version=3;",
|
||||
)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
# Running tsunagi on Arch Linux
|
||||
|
||||
Install the package built for your architecture:
|
||||
|
||||
```sh
|
||||
sudo pacman -U tsunagi-*-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
It drops a static, dependency-free binary at `/usr/bin/tsunagi`, a systemd
|
||||
service, and a polkit rule, and creates a dedicated system user `tsunagi` the
|
||||
service runs as.
|
||||
|
||||
Start it:
|
||||
|
||||
```sh
|
||||
sudo systemctl enable --now tsunagi
|
||||
```
|
||||
|
||||
Control it with `sudo` (the agent's control socket is private to the service
|
||||
user; `sudo` reaches it with no extra flags, because the CLI finds the running
|
||||
service's socket at `/run/tsunagi/agent.sock`):
|
||||
|
||||
```sh
|
||||
sudo tsunagi status
|
||||
sudo tsunagi join <network-name> <tsn1…secret>
|
||||
```
|
||||
|
||||
## How it runs
|
||||
|
||||
The service runs as the unprivileged `tsunagi` user with just two capabilities
|
||||
(`CAP_NET_ADMIN`, `CAP_NET_BIND_SERVICE`) granted by systemd — no root. systemd
|
||||
creates and owns its directories:
|
||||
|
||||
| what | path |
|
||||
|---|---|
|
||||
| state | `/var/lib/tsunagi` |
|
||||
| cache | `/var/cache/tsunagi` |
|
||||
| control socket | `/run/tsunagi/agent.sock` |
|
||||
|
||||
Installing the package enables and starts the service, and adds the user who ran
|
||||
pacman to the `tsunagi` group (log out and in again for it to apply). The
|
||||
control socket is open to that group, so members can run `tsunagi status`, the
|
||||
CLI and the tray GUI without sudo; anyone else adds themselves with `sudo
|
||||
usermod -aG tsunagi "$USER"`. The group also lets its members configure
|
||||
systemd-resolved for the overlay zones through the polkit rule.
|
||||
|
||||
The GUI package depends on `gtk3`, `libayatana-appindicator` (the tray icon),
|
||||
`xdotool` (its `libxdo`), and the usual windowing libraries.
|
||||
|
||||
## Removal
|
||||
|
||||
```sh
|
||||
sudo pacman -R tsunagi
|
||||
```
|
||||
|
||||
stops and disables the service. The `tsunagi` user and `/var/lib/tsunagi` are
|
||||
left in place; remove them by hand if you want them gone.
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assemble an Arch Linux package (.pkg.tar.zst) from prebuilt tsunagi binaries,
|
||||
# using only bsdtar + zstd on a non-Arch host — no makepkg, no Arch container.
|
||||
#
|
||||
# Usage: build-pkg.sh <binary> <arch> <version> <output.pkg.tar.zst> [tray_binary]
|
||||
# <binary> path to the built tsunagi (agent/CLI) executable
|
||||
# <arch> pacman architecture: x86_64 or aarch64
|
||||
# <version> package version (the tag without its leading v)
|
||||
# <output> path to write the package to
|
||||
# [tray_binary] optional tsunagi-tray GUI executable; when given, the package
|
||||
# also installs the tray and a desktop launcher and is named
|
||||
# tsunagi-gui (conflicting with the headless tsunagi).
|
||||
#
|
||||
# Needs bsdtar (libarchive-tools) and zstd on the builder.
|
||||
set -euo pipefail
|
||||
|
||||
binary="$1"
|
||||
arch="$2"
|
||||
version="$3"
|
||||
output="$4"
|
||||
tray="${5:-}"
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
root="$(mktemp -d)"
|
||||
trap 'rm -rf "$root"' EXIT
|
||||
|
||||
# pacman pkgver may not contain '-', so a prerelease tag like 0.1.0-rc.9 becomes
|
||||
# 0.1.0_rc.9.
|
||||
pkgver="$(printf '%s' "$version" | tr '-' '_')"
|
||||
pkgrel=1
|
||||
|
||||
# Payload. Arch keeps units under /usr/lib/systemd/system and installs to
|
||||
# /usr/bin, so the service's ExecStart is rewritten to match.
|
||||
install -D -m 0755 "$binary" "$root/usr/bin/tsunagi"
|
||||
|
||||
mkdir -p "$root/usr/lib/systemd/system"
|
||||
sed 's#/usr/local/bin/tsunagi#/usr/bin/tsunagi#' \
|
||||
"$here/../linux/tsunagi.service" > "$root/usr/lib/systemd/system/tsunagi.service"
|
||||
chmod 0644 "$root/usr/lib/systemd/system/tsunagi.service"
|
||||
|
||||
install -D -m 0644 "$here/../linux/50-tsunagi-resolved.rules" \
|
||||
"$root/usr/share/polkit-1/rules.d/50-tsunagi-resolved.rules"
|
||||
install -D -m 0644 "$here/../../README.md" "$root/usr/share/doc/tsunagi/README.md"
|
||||
install -D -m 0644 "$here/../linux/README.md" "$root/usr/share/doc/tsunagi/INSTALL.md"
|
||||
|
||||
pkgname=tsunagi
|
||||
pkgdesc="Serverless private mesh networking agent"
|
||||
relations=""
|
||||
if [ -n "$tray" ]; then
|
||||
pkgname=tsunagi-gui
|
||||
pkgdesc="Serverless private mesh networking agent, with the tray GUI"
|
||||
relations=$'conflict = tsunagi\nprovides = tsunagi\nreplaces = tsunagi\n'
|
||||
# What the tray and its window load at run time; none of it is linked in
|
||||
# statically, so pacman has to be told.
|
||||
for dep in gtk3 libayatana-appindicator xdotool libxkbcommon wayland libx11 libglvnd; do
|
||||
relations+="depend = ${dep}"$'\n'
|
||||
done
|
||||
install -D -m 0755 "$tray" "$root/usr/bin/tsunagi-tray"
|
||||
install -D -m 0644 "$here/../linux/tsunagi-tray.desktop" \
|
||||
"$root/usr/share/applications/tsunagi-tray.desktop"
|
||||
install -D -m 0644 "$here/../linux/tsunagi.svg" \
|
||||
"$root/usr/share/icons/hicolor/scalable/apps/tsunagi.svg"
|
||||
fi
|
||||
|
||||
# Installed size (payload only), before the metadata files are written.
|
||||
size="$(du -sb "$root" | cut -f1)"
|
||||
|
||||
cat > "$root/.PKGINFO" <<PKGINFO
|
||||
pkgname = ${pkgname}
|
||||
pkgver = ${pkgver}-${pkgrel}
|
||||
pkgdesc = ${pkgdesc}
|
||||
url = https://github.com/house-of-vanity/tsunagi
|
||||
builddate = $(date +%s)
|
||||
packager = AB <ab@hexor.cy>
|
||||
size = ${size}
|
||||
arch = ${arch}
|
||||
${relations}license = WTFPL
|
||||
PKGINFO
|
||||
|
||||
# Scriptlet: create the dedicated system user the service runs as, start the
|
||||
# service, and let whoever ran pacman use it. The control socket belongs to the
|
||||
# `tsunagi` group, so the tray and the CLI work for any member of it; the
|
||||
# person installing is added, since the package cannot know anyone else.
|
||||
cat > "$root/.INSTALL" <<'INSTALL'
|
||||
post_install() {
|
||||
getent group tsunagi >/dev/null 2>&1 || groupadd -r tsunagi
|
||||
getent passwd tsunagi >/dev/null 2>&1 || \
|
||||
useradd -r -g tsunagi -d /var/lib/tsunagi -s /usr/bin/nologin -c "tsunagi agent" tsunagi
|
||||
command -v update-desktop-database >/dev/null 2>&1 && \
|
||||
update-desktop-database -q /usr/share/applications >/dev/null 2>&1 || true
|
||||
command -v gtk-update-icon-cache >/dev/null 2>&1 && \
|
||||
gtk-update-icon-cache -q -t /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||
|
||||
# Whoever ran pacman through sudo or pkexec (an AUR helper does too).
|
||||
installer="${SUDO_USER:-}"
|
||||
if [ -z "$installer" ] && [ -n "${PKEXEC_UID:-}" ]; then
|
||||
installer="$(getent passwd "$PKEXEC_UID" | cut -d: -f1)"
|
||||
fi
|
||||
if [ -n "$installer" ] && [ "$installer" != root ] && id "$installer" >/dev/null 2>&1; then
|
||||
usermod -aG tsunagi "$installer" >/dev/null 2>&1 || true
|
||||
echo ":: $installer was added to the tsunagi group; log out and in again so the"
|
||||
echo " tray and the CLI can reach the agent without sudo."
|
||||
else
|
||||
echo ":: add yourself to the tsunagi group to use the tray and the CLI without"
|
||||
echo " sudo: sudo usermod -aG tsunagi \$USER, then log out and in again."
|
||||
fi
|
||||
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
systemctl enable --now tsunagi.service >/dev/null 2>&1 || \
|
||||
echo ":: could not start tsunagi.service; see: systemctl status tsunagi"
|
||||
fi
|
||||
}
|
||||
post_upgrade() {
|
||||
# Not enabled again: it may have been turned off on purpose. A running
|
||||
# agent is restarted, because the control protocol changes between builds
|
||||
# and a new client cannot talk to an old agent.
|
||||
command -v update-desktop-database >/dev/null 2>&1 && \
|
||||
update-desktop-database -q /usr/share/applications >/dev/null 2>&1 || true
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
systemctl try-restart tsunagi.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
pre_remove() {
|
||||
systemctl disable --now tsunagi.service >/dev/null 2>&1 || true
|
||||
}
|
||||
post_remove() {
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
}
|
||||
INSTALL
|
||||
|
||||
# .MTREE over the metadata and payload (not over itself), gzip-compressed, as
|
||||
# pacman expects.
|
||||
(
|
||||
cd "$root"
|
||||
LANG=C bsdtar -czf .MTREE --format=mtree \
|
||||
--options='!all,use-set,type,uid,gid,mode,time,size,md5,sha256,link' \
|
||||
.PKGINFO .INSTALL usr
|
||||
)
|
||||
|
||||
# Pack with .PKGINFO first, then the rest, and zstd-compress.
|
||||
(
|
||||
cd "$root"
|
||||
bsdtar -cf - .PKGINFO .MTREE .INSTALL usr
|
||||
) | zstd -c -19 -T0 > "$output"
|
||||
|
||||
echo "built $output"
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assemble a dependency-free Debian package from prebuilt tsunagi binaries,
|
||||
# using only dpkg-deb (no cargo-deb, no nfpm).
|
||||
#
|
||||
# Usage: build-deb.sh <binary> <deb_arch> <version> <output.deb> [tray_binary]
|
||||
# <binary> path to the built tsunagi (agent/CLI) executable
|
||||
# <deb_arch> Debian architecture: amd64 or arm64
|
||||
# <version> package version (the tag without its leading v)
|
||||
# <output> path to write the .deb to
|
||||
# [tray_binary] optional path to the tsunagi-tray GUI executable; when given,
|
||||
# the package also installs the tray and a desktop launcher and
|
||||
# is named tsunagi-gui (conflicting with the headless tsunagi).
|
||||
set -euo pipefail
|
||||
|
||||
binary="$1"
|
||||
arch="$2"
|
||||
version="$3"
|
||||
output="$4"
|
||||
tray="${5:-}"
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
root="$(mktemp -d)"
|
||||
trap 'rm -rf "$root"' EXIT
|
||||
|
||||
# Payload. Debian installs to /usr/bin (not /usr/local), so the service's
|
||||
# ExecStart is rewritten to match; the source unit targets /usr/local/bin for
|
||||
# the tarball installer.
|
||||
install -D -m 0755 "$binary" "$root/usr/bin/tsunagi"
|
||||
|
||||
mkdir -p "$root/lib/systemd/system"
|
||||
sed 's#/usr/local/bin/tsunagi#/usr/bin/tsunagi#' \
|
||||
"$here/../linux/tsunagi.service" > "$root/lib/systemd/system/tsunagi.service"
|
||||
chmod 0644 "$root/lib/systemd/system/tsunagi.service"
|
||||
|
||||
install -D -m 0644 "$here/../linux/50-tsunagi-resolved.rules" \
|
||||
"$root/usr/share/polkit-1/rules.d/50-tsunagi-resolved.rules"
|
||||
install -D -m 0644 "$here/../../README.md" "$root/usr/share/doc/tsunagi/README.md"
|
||||
install -D -m 0644 "$here/../linux/README.md" "$root/usr/share/doc/tsunagi/INSTALL.md"
|
||||
|
||||
pkgname=tsunagi
|
||||
extra_control=""
|
||||
desc_gui=""
|
||||
if [ -n "$tray" ]; then
|
||||
pkgname=tsunagi-gui
|
||||
# The GUI package is a superset of the headless one and must not coexist
|
||||
# with it (both ship /usr/bin/tsunagi).
|
||||
extra_control=$'Conflicts: tsunagi\nReplaces: tsunagi\nProvides: tsunagi\n'
|
||||
# What the tray and its window load at run time.
|
||||
extra_control+=$'Depends: libgtk-3-0 | libgtk-3-0t64, libayatana-appindicator3-1 | libappindicator3-1, libxdo3, libxkbcommon0, libwayland-client0, libx11-6, libgl1\n'
|
||||
desc_gui=" It also installs the tray GUI (tsunagi-tray) and a desktop launcher so it can be started from the applications menu."
|
||||
install -D -m 0755 "$tray" "$root/usr/bin/tsunagi-tray"
|
||||
install -D -m 0644 "$here/../linux/tsunagi-tray.desktop" \
|
||||
"$root/usr/share/applications/tsunagi-tray.desktop"
|
||||
install -D -m 0644 "$here/../linux/tsunagi.svg" \
|
||||
"$root/usr/share/icons/hicolor/scalable/apps/tsunagi.svg"
|
||||
fi
|
||||
|
||||
mkdir -p "$root/DEBIAN"
|
||||
|
||||
cat > "$root/DEBIAN/control" <<CTRL
|
||||
Package: ${pkgname}
|
||||
Version: ${version}
|
||||
Architecture: ${arch}
|
||||
Maintainer: AB <ab@hexor.cy>
|
||||
Section: net
|
||||
Priority: optional
|
||||
Homepage: https://github.com/house-of-vanity/tsunagi
|
||||
${extra_control}Description: Serverless private mesh networking agent
|
||||
tsunagi forms small private mesh IP networks between peers, with NAT
|
||||
traversal and no central server. This package installs a static,
|
||||
dependency-free agent, a systemd service that runs it as a dedicated
|
||||
unprivileged "tsunagi" user with only CAP_NET_ADMIN and CAP_NET_BIND_SERVICE,
|
||||
and a polkit rule for the local DNS resolver.${desc_gui}
|
||||
CTRL
|
||||
|
||||
# The dedicated system user the service runs as. It is in the "tsunagi" group,
|
||||
# which the polkit rule grants the resolve1 actions. systemd creates the state,
|
||||
# cache and runtime directories for it on first start.
|
||||
cat > "$root/DEBIAN/postinst" <<'POSTINST'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if ! getent group tsunagi >/dev/null 2>&1; then
|
||||
addgroup --system tsunagi >/dev/null 2>&1 || true
|
||||
fi
|
||||
if ! getent passwd tsunagi >/dev/null 2>&1; then
|
||||
adduser --system --ingroup tsunagi --home /var/lib/tsunagi --no-create-home \
|
||||
--gecos "tsunagi agent" --disabled-login tsunagi >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
POSTINST
|
||||
|
||||
if [ -n "$tray" ]; then
|
||||
cat >> "$root/DEBIAN/postinst" <<'POSTINST_GUI'
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
update-desktop-database -q /usr/share/applications >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||
gtk-update-icon-cache -q -t /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||
fi
|
||||
POSTINST_GUI
|
||||
fi
|
||||
echo "exit 0" >> "$root/DEBIAN/postinst"
|
||||
|
||||
cat > "$root/DEBIAN/prerm" <<'PRERM'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ "$1" = remove ] || [ "$1" = purge ]; then
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl disable --now tsunagi.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
PRERM
|
||||
|
||||
cat > "$root/DEBIAN/postrm" <<'POSTRM'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 0
|
||||
POSTRM
|
||||
|
||||
chmod 0755 "$root/DEBIAN/postinst" "$root/DEBIAN/prerm" "$root/DEBIAN/postrm"
|
||||
|
||||
# --root-owner-group writes the payload as root:root without needing fakeroot.
|
||||
dpkg-deb --root-owner-group --build "$root" "$output"
|
||||
echo "built $output"
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
// Installed by the tsunagi packages.
|
||||
//
|
||||
// polkit decides who may configure systemd-resolved by user/group id, not by
|
||||
// capability, so the agent needs this even though it holds CAP_NET_ADMIN. It
|
||||
// grants exactly the four resolve1 actions the agent calls, and nothing else,
|
||||
// to anyone in the "tsunagi" group.
|
||||
//
|
||||
// The packaged service runs as the "tsunagi" user (which is in that group), so
|
||||
// its resolver configuration is allowed. To also run the agent as your own
|
||||
// user, add yourself to the group:
|
||||
//
|
||||
// sudo usermod -aG tsunagi "$USER"
|
||||
polkit.addRule(function(action, subject) {
|
||||
var allowed = [
|
||||
"org.freedesktop.resolve1.set-dns-servers",
|
||||
"org.freedesktop.resolve1.set-domains",
|
||||
"org.freedesktop.resolve1.set-default-route",
|
||||
"org.freedesktop.resolve1.revert"
|
||||
];
|
||||
if (allowed.indexOf(action.id) >= 0 && subject.isInGroup("tsunagi")) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
Vendored
+19
-12
@@ -2,40 +2,47 @@
|
||||
|
||||
This archive holds a **static** `tsunagi` binary (musl), so it runs on any
|
||||
distribution — Arch, Alpine, NixOS, old glibc systems — with no shared-library
|
||||
dependencies. Alongside it are a systemd unit and an installer.
|
||||
dependencies. Alongside it are a systemd unit, a polkit rule, and an installer.
|
||||
|
||||
Creating the TUN interface needs `CAP_NET_ADMIN`, and configuring
|
||||
systemd-resolved is decided by polkit (by user id, not capability). There are
|
||||
three ways to satisfy that; pick one.
|
||||
systemd-resolved is decided by polkit. There are three ways to run it; pick one.
|
||||
|
||||
## Quick install — systemd service as root
|
||||
> On Debian/Ubuntu and Arch, prefer the native package (`.deb` / `.pkg.tar.zst`)
|
||||
> from the release — it does all of the below for you.
|
||||
|
||||
## Quick install — systemd service as a dedicated user
|
||||
|
||||
```sh
|
||||
sudo ./install.sh
|
||||
sudo systemctl enable --now tsunagi
|
||||
```
|
||||
|
||||
The service runs the agent as root, so the interface, low ports and the
|
||||
resolver all just work (root is not subject to polkit). Control it **as root**,
|
||||
so the client resolves the same state directory the daemon uses:
|
||||
The service runs as an unprivileged `tsunagi` user with just `CAP_NET_ADMIN`
|
||||
and `CAP_NET_BIND_SERVICE` (granted by systemd), not root. systemd creates its
|
||||
state (`/var/lib/tsunagi`), cache (`/var/cache/tsunagi`) and runtime
|
||||
(`/run/tsunagi`) directories, and the polkit rule lets it configure the
|
||||
resolver. Control it with `sudo` — the CLI finds the running service's socket at
|
||||
`/run/tsunagi/agent.sock` with no flags. The socket is open to the `tsunagi`
|
||||
group, so after `sudo usermod -aG tsunagi "$USER"` (and logging in again) no
|
||||
`sudo` is needed:
|
||||
|
||||
```sh
|
||||
sudo tsunagi status
|
||||
sudo tsunagi join <network-name> <tsn1…secret>
|
||||
```
|
||||
|
||||
## As your own user — one capability, no root at runtime
|
||||
## As your own user — one capability, no service
|
||||
|
||||
```sh
|
||||
sudo install -m 0755 tsunagi /usr/local/bin/tsunagi
|
||||
sudo setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsunagi
|
||||
sudo usermod -aG tsunagi "$USER" # so the resolver works; log in again after
|
||||
tsunagi up
|
||||
```
|
||||
|
||||
Nothing is left behind and nothing runs as root. The capability is lost on every
|
||||
rebuild or copy of the binary, so re-run `setcap` after replacing it. For the
|
||||
local DNS resolver, run `tsunagi dns` once: it prints the exact polkit rule that
|
||||
lets your user configure systemd-resolved, ready to paste.
|
||||
Nothing runs as root. The capability is lost on every rebuild or copy of the
|
||||
binary, so re-run `setcap` after replacing it. The agent uses your per-user
|
||||
state directory and socket, so plain `tsunagi status` (no sudo) reaches it.
|
||||
|
||||
## Without touching the OS
|
||||
|
||||
|
||||
Vendored
+27
-19
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env sh
|
||||
# Installs the tsunagi binary and, where systemd is present, a root service.
|
||||
# Run it as root (it writes under /usr/local and /etc):
|
||||
# Installs the static tsunagi binary and, where systemd is present, a service
|
||||
# that runs the agent as a dedicated unprivileged "tsunagi" user. Run as root:
|
||||
#
|
||||
# sudo ./install.sh
|
||||
#
|
||||
# PREFIX overrides the install prefix (default /usr/local).
|
||||
# PREFIX overrides the install prefix (default /usr/local); if you change it,
|
||||
# edit ExecStart in tsunagi.service to match.
|
||||
set -eu
|
||||
|
||||
PREFIX="${PREFIX:-/usr/local}"
|
||||
@@ -19,8 +20,23 @@ echo "Installing tsunagi to ${PREFIX}/bin"
|
||||
install -d "${PREFIX}/bin"
|
||||
install -m 0755 "${here}/tsunagi" "${PREFIX}/bin/tsunagi"
|
||||
|
||||
# The dedicated system user the service runs as (works with either shadow's
|
||||
# useradd or Debian's adduser).
|
||||
if ! getent group tsunagi >/dev/null 2>&1; then
|
||||
groupadd -r tsunagi 2>/dev/null || addgroup --system tsunagi 2>/dev/null || true
|
||||
fi
|
||||
if ! getent passwd tsunagi >/dev/null 2>&1; then
|
||||
useradd -r -g tsunagi -d /var/lib/tsunagi -s /usr/sbin/nologin -c "tsunagi agent" tsunagi 2>/dev/null \
|
||||
|| adduser --system --ingroup tsunagi --home /var/lib/tsunagi --no-create-home \
|
||||
--gecos "tsunagi agent" --disabled-login tsunagi 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# polkit rule so the agent (as the tsunagi user) may configure systemd-resolved.
|
||||
install -d /etc/polkit-1/rules.d
|
||||
install -m 0644 "${here}/50-tsunagi-resolved.rules" /etc/polkit-1/rules.d/50-tsunagi-resolved.rules
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
echo "Installing the systemd service (runs the agent as root)"
|
||||
echo "Installing the systemd service (runs as the tsunagi user)"
|
||||
install -m 0644 "${here}/tsunagi.service" /etc/systemd/system/tsunagi.service
|
||||
systemctl daemon-reload
|
||||
cat <<EOF
|
||||
@@ -29,7 +45,7 @@ Done. Start the agent with:
|
||||
|
||||
sudo systemctl enable --now tsunagi
|
||||
|
||||
Control it as root, so the client shares the agent's state directory:
|
||||
Control it with sudo (the CLI finds the service's socket automatically):
|
||||
|
||||
sudo tsunagi status
|
||||
sudo tsunagi join <network-name> <tsn1…secret>
|
||||
@@ -39,19 +55,11 @@ else
|
||||
|
||||
Installed the binary; no systemd found, so no service was set up. Either:
|
||||
|
||||
* run it directly as root:
|
||||
|
||||
sudo tsunagi up
|
||||
|
||||
* or grant the capability and run it as your own user (no root at runtime):
|
||||
|
||||
sudo setcap cap_net_admin,cap_net_bind_service+p ${PREFIX}/bin/tsunagi
|
||||
tsunagi up
|
||||
|
||||
For the local DNS resolver as a non-root user, run \`tsunagi dns\` once; it
|
||||
prints the polkit rule that lets your user configure systemd-resolved.
|
||||
|
||||
* or skip the interface entirely with \`tsunagi up --no-tun\`: tunnels still
|
||||
form, they just do not reach the operating system.
|
||||
* run it as root: sudo ${PREFIX}/bin/tsunagi up
|
||||
* or as your own user: sudo setcap cap_net_admin,cap_net_bind_service+p ${PREFIX}/bin/tsunagi
|
||||
tsunagi up
|
||||
(add yourself to the tsunagi group so the resolver works:
|
||||
sudo usermod -aG tsunagi "\$USER")
|
||||
* or without touching the OS: tsunagi up --no-tun
|
||||
EOF
|
||||
fi
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=tsunagi
|
||||
GenericName=Mesh network manager
|
||||
Comment=Manage tsunagi networks from the tray
|
||||
Exec=tsunagi-tray
|
||||
Icon=tsunagi
|
||||
Terminal=false
|
||||
Categories=Network;Utility;
|
||||
Keywords=mesh;vpn;overlay;network;
|
||||
StartupNotify=false
|
||||
Vendored
+25
-13
@@ -1,26 +1,38 @@
|
||||
[Unit]
|
||||
Description=tsunagi mesh agent
|
||||
Documentation=https://github.com/Ultradesu/tsunagi
|
||||
Documentation=https://github.com/house-of-vanity/tsunagi
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Runs as a dedicated unprivileged user, not root. The two capabilities are all
|
||||
# it needs: CAP_NET_ADMIN to create and configure the TUN interface, and
|
||||
# CAP_NET_BIND_SERVICE for low ports. The packages create the `tsunagi` user.
|
||||
User=tsunagi
|
||||
Group=tsunagi
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
|
||||
# systemd creates and owns these for the service user, and the agent is pointed
|
||||
# at them: state in /var/lib/tsunagi, cache in /var/cache/tsunagi, and the
|
||||
# control socket in /run/tsunagi. The socket is opened to the `tsunagi` group,
|
||||
# so a user who joins it can run the CLI and the tray without sudo.
|
||||
StateDirectory=tsunagi
|
||||
StateDirectoryMode=0700
|
||||
CacheDirectory=tsunagi
|
||||
RuntimeDirectory=tsunagi
|
||||
RuntimeDirectoryMode=0755
|
||||
Environment=TSUNAGI_STATE_DIR=%S/tsunagi
|
||||
Environment=TSUNAGI_CACHE_DIR=%C/tsunagi
|
||||
Environment=TSUNAGI_CONTROL_SOCKET=%t/tsunagi/agent.sock
|
||||
Environment=TSUNAGI_CONTROL_GROUP=tsunagi
|
||||
|
||||
# The tarball installer puts the binary here; the deb and the Arch package
|
||||
# rewrite this to /usr/bin where they install it.
|
||||
ExecStart=/usr/local/bin/tsunagi up
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# This unit runs the agent as root, which covers everything it needs: creating
|
||||
# the TUN interface (CAP_NET_ADMIN), binding low ports, and configuring
|
||||
# systemd-resolved — root is not subject to polkit, so no polkit rule is needed.
|
||||
#
|
||||
# To run as a non-root user instead, uncomment the three lines below, set a
|
||||
# User=, and install the polkit rule that `tsunagi dns` prints (resolved decides
|
||||
# by user id, not capability, so the rule is what lets a non-root agent set the
|
||||
# resolver):
|
||||
#User=tsunagi
|
||||
#AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
#CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<circle cx="64" cy="64" r="56" fill="#2f80d8"/>
|
||||
<circle cx="64" cy="64" r="30" fill="none" stroke="#ffffff" stroke-width="7" opacity="0.9"/>
|
||||
<circle cx="64" cy="64" r="9" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 328 B |
@@ -0,0 +1,75 @@
|
||||
# Exit nodes
|
||||
|
||||
A member can offer itself as an **exit node** in a network. Other members can
|
||||
then send *all* their internet traffic through it, the way a commercial VPN
|
||||
or Tailscale's exit nodes work. Linux only for now.
|
||||
|
||||
## Using it
|
||||
|
||||
The offering side, per network, off by default:
|
||||
|
||||
```sh
|
||||
tsunagi network exit-node <network> on # or off
|
||||
```
|
||||
|
||||
Rules are installed even when the kernel does not forward yet, and `status`
|
||||
says so. Turning forwarding on is left to you, on purpose:
|
||||
|
||||
```sh
|
||||
sudo sysctl -w net.ipv4.ip_forward=1
|
||||
```
|
||||
|
||||
The using side:
|
||||
|
||||
```sh
|
||||
tsunagi exit-node # who offers one
|
||||
tsunagi exit-node <name|id> # send everything through it
|
||||
tsunagi exit-node off
|
||||
```
|
||||
|
||||
The tray does the same: a checkbox on the network tile to offer, and an icon on
|
||||
each device in the devices window that offers one, with a confirmation.
|
||||
|
||||
One device has one default route, so it uses **one** exit node at a time;
|
||||
choosing another replaces the first. Offering is per network, so a device can
|
||||
be an exit node in one network and not in another.
|
||||
|
||||
## How it works
|
||||
|
||||
- An offering agent says so in its control-plane `Announce` (`exit_node`), and
|
||||
only once its rules are in place. Members see which peers offer one.
|
||||
- **Server:** `iptables` masquerades the overlay range of that network out of
|
||||
any other interface and allows forwarding for it. Rules carry the tag
|
||||
`tsunagi-exit:<interface>:<range>` and are removed when the offer ends, the
|
||||
network stops, and when the agent exits. Only networks that offer have
|
||||
rules, so the others' traffic is not forwarded.
|
||||
- **Client:** policy routing, with no change to the main table. Table `28787`
|
||||
holds `default dev <interface>`, and three rules sit after Tailscale's:
|
||||
`5280 uidrange <agent uid> lookup main` (the agent's own connections never
|
||||
go through the tunnel, which would be a loop), `5290 lookup main
|
||||
suppress_prefixlength 0` (LAN and other specific routes keep working) and
|
||||
`5300 lookup 28787`.
|
||||
- Packets for addresses outside every overlay range are sent to the exit node
|
||||
by the router; the exit node's replies are accepted from any source. A member
|
||||
that is not your exit node cannot do that.
|
||||
|
||||
## What it does not do
|
||||
|
||||
- **The agent must run as a system service** (its own user). The uid exemption
|
||||
would otherwise cover everything you run. A per-user agent refuses to use an
|
||||
exit node and says why.
|
||||
- **If the exit node disappears the rules stay** and traffic is dropped until
|
||||
it returns or you run `tsunagi exit-node off`. This is deliberate: falling
|
||||
back to the direct path would leak traffic nobody agreed to send in the
|
||||
clear. The agent logs it once, `status` and the tray say so loudly.
|
||||
- IPv6 and DNS are untouched. IPv6 traffic still leaves the ordinary way.
|
||||
- Kernel forwarding is only read, never changed.
|
||||
|
||||
## Trust
|
||||
|
||||
Anyone who knows the network's secret can pick your exit node if you offer
|
||||
one, and you carry what they send: it leaves from your address and is yours to
|
||||
answer for. The exit node sees every destination and, for plain traffic, its
|
||||
contents. Choosing an exit node means trusting its owner completely. Offer one
|
||||
only in networks whose members you trust, and only from a machine you are
|
||||
willing to have that traffic come from.
|
||||
+1
-1
@@ -9,7 +9,7 @@ Two versions exist and are independent:
|
||||
|
||||
- **Identity scheme**, `tsunagi-network-id-v1`. Frozen. Changing it creates a
|
||||
different network space for the same name and secret.
|
||||
- **Control protocol**, ALPN `tsunagi/ctrl/3`, `PROTOCOL_VERSION = 3`.
|
||||
- **Control protocol**, ALPN `tsunagi/ctrl/4`, `PROTOCOL_VERSION = 4`.
|
||||
|
||||
Upgrading the crate or bumping the control protocol must never change an
|
||||
existing `NetworkId`.
|
||||
|
||||
+1
-1
@@ -86,5 +86,5 @@ counts sends without storing frames. Results are CPU forwarding cost, **not**
|
||||
end-to-end network latency or VPN throughput; encryption, fragmentation,
|
||||
sockets, congestion and scheduling contribute separately.
|
||||
|
||||
Wire compatibility: control ALPN `tsunagi/ctrl/3`, data ALPN `tsunagi/data/4`.
|
||||
Wire compatibility: control ALPN `tsunagi/ctrl/4`, data ALPN `tsunagi/data/4`.
|
||||
Upgrade every participant together; saved identities and network state persist.
|
||||
@@ -69,6 +69,10 @@ Read this before relying on anything here. The protocol is in
|
||||
punch, the data connection goes through a relay, which then sees the volume
|
||||
and timing of tunnelled traffic — though not its contents, which WireGuard
|
||||
encrypted, nor the iroh layer's contents.
|
||||
- **Exit nodes.** A member that offers one carries other members' internet
|
||||
traffic and sees where it goes; a member that uses one trusts it completely.
|
||||
Anyone holding the network secret can use an offered exit node. See
|
||||
[exit-node.md](exit-node.md).
|
||||
- **Overlay address squatting.** A member can mint many WireGuard keys and
|
||||
therefore occupy many overlay addresses. It cannot pick which ones, but it
|
||||
can consume them and appear as many participants.
|
||||
|
||||
Reference in new issue
Block a user