Compare commits
32
Commits
No files matched your search
@@ -0,0 +1,312 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Build the agent for every supported target on a version tag, leave each build
|
||||||
|
# as a run artifact, and then — only after every target has built — attach the
|
||||||
|
# archives to the release. Linux is a fully static musl binary (built with
|
||||||
|
# cargo-zigbuild) so one artifact runs on any distro and can be repackaged
|
||||||
|
# however a distro prefers.
|
||||||
|
#
|
||||||
|
# Targeted at Gitea Actions: artifacts use @v3 (the v4 backend is not available
|
||||||
|
# on Gitea/GHES), and the release is filled through Gitea's own API with curl
|
||||||
|
# rather than `gh`, which speaks only to github.com.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
CARGO_NET_RETRY: "10"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: ${{ matrix.asset }}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
target: x86_64-unknown-linux-musl
|
||||||
|
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
|
||||||
|
archive: tar.gz
|
||||||
|
zig: false
|
||||||
|
- os: windows-latest
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
asset: tsunagi-windows-x86_64
|
||||||
|
archive: zip
|
||||||
|
zig: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
key: ${{ matrix.target }}
|
||||||
|
|
||||||
|
# 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 and packaging tooling
|
||||||
|
if: matrix.zig
|
||||||
|
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
|
||||||
|
with:
|
||||||
|
# Pin a real, archived Zig release. Left unpinned the action resolves
|
||||||
|
# to the newest version it knows of (a yet-unreleased 0.16.0-dev),
|
||||||
|
# whose tarball 404s on ziglang.org while the community mirrors flake.
|
||||||
|
version: 0.14.1
|
||||||
|
- name: Install cargo-zigbuild
|
||||||
|
if: matrix.zig
|
||||||
|
uses: taiki-e/install-action@v2
|
||||||
|
with:
|
||||||
|
tool: cargo-zigbuild
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "${{ matrix.zig }}" == "true" ]]; then
|
||||||
|
# zig's linker ignores the `-O1` GNU-ld optimization flag Rust passes
|
||||||
|
# and says so on stderr, which rustc now surfaces as a linker_messages
|
||||||
|
# warning. It is cosmetic — the binary is correct — so quiet just that
|
||||||
|
# one lint to keep the musl build log clean. A real link error still
|
||||||
|
# fails the build.
|
||||||
|
export RUSTFLAGS="${RUSTFLAGS:-} -A linker_messages"
|
||||||
|
cargo zigbuild --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
||||||
|
else
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
version="${GITHUB_REF_NAME#v}"
|
||||||
|
stage="dist/pkg/${{ matrix.asset }}"
|
||||||
|
mkdir -p "$stage"
|
||||||
|
|
||||||
|
bin="tsunagi"
|
||||||
|
[[ "${{ 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 \
|
||||||
|
dist/linux/50-tsunagi-resolved.rules "$stage/"
|
||||||
|
cp dist/linux/README.md "$stage/INSTALL.md"
|
||||||
|
;;
|
||||||
|
macOS)
|
||||||
|
cp dist/macos/cy.hexor.tsunagi.plist "$stage/"
|
||||||
|
cp dist/macos/README.md "$stage/INSTALL.md"
|
||||||
|
;;
|
||||||
|
Windows)
|
||||||
|
cp dist/windows/README.md "$stage/INSTALL.md"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
archive="${{ matrix.asset }}-${version}.${{ matrix.archive }}"
|
||||||
|
if [[ "${{ matrix.archive }}" == "zip" ]]; then
|
||||||
|
(cd dist/pkg && 7z a "../../${archive}" "${{ matrix.asset }}")
|
||||||
|
else
|
||||||
|
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 (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 }}-*.*
|
||||||
|
*.deb
|
||||||
|
*.pkg.tar.zst
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish release
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download every build artifact
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Show what was collected
|
||||||
|
run: find artifacts -type f
|
||||||
|
|
||||||
|
- name: Create the release and upload the archives
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
# Gitea injects this with access to the current repository. If the
|
||||||
|
# release calls below return 403, the repo/runner is configured to
|
||||||
|
# hand Actions a read-only token — grant it write access to releases.
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -u
|
||||||
|
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
# Gitea sets GITHUB_API_URL to its own API root (…/api/v1); fall back
|
||||||
|
# to building it from the server URL if it is absent.
|
||||||
|
base="${GITHUB_API_URL:-${GITHUB_SERVER_URL}/api/v1}"
|
||||||
|
api="${base}/repos/${GITHUB_REPOSITORY}"
|
||||||
|
auth="Authorization: token ${TOKEN}"
|
||||||
|
echo "release API: $api"
|
||||||
|
|
||||||
|
prerelease=false
|
||||||
|
case "$tag" in *-*) prerelease=true ;; esac
|
||||||
|
|
||||||
|
# The release id for this tag, if it already exists.
|
||||||
|
code=$(curl -sS -o resp.json -w '%{http_code}' -H "$auth" "$api/releases/tags/$tag" || true)
|
||||||
|
echo "get release by tag: HTTP $code"
|
||||||
|
id=$(grep -o '"id":[0-9]*' resp.json 2>/dev/null | head -n1 | cut -d: -f2 || true)
|
||||||
|
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
code=$(curl -sS -o resp.json -w '%{http_code}' -X POST -H "$auth" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"tag_name\":\"$tag\",\"name\":\"tsunagi $tag\",\"prerelease\":$prerelease}" \
|
||||||
|
"$api/releases" || true)
|
||||||
|
echo "create release: HTTP $code"
|
||||||
|
id=$(grep -o '"id":[0-9]*' resp.json 2>/dev/null | head -n1 | cut -d: -f2 || true)
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "could not create the release; response was:" >&2
|
||||||
|
cat resp.json >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "release id: $id"
|
||||||
|
|
||||||
|
# download-artifact@v3 nests each artifact in a directory named after
|
||||||
|
# its upload, with the single archive inside. The read loop runs in
|
||||||
|
# this shell (process substitution, not a pipe) so a failed upload
|
||||||
|
# actually fails the job.
|
||||||
|
rc=0
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
code=$(curl -sS -o up.json -w '%{http_code}' -X POST -H "$auth" \
|
||||||
|
-F "attachment=@${f}" \
|
||||||
|
"$api/releases/$id/assets?name=${name}" || true)
|
||||||
|
echo "upload $name: HTTP $code"
|
||||||
|
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
|
||||||
|
cat up.json >&2 || true
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
done < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' \) -print0)
|
||||||
|
exit "$rc"
|
||||||
+153
-25
@@ -1,15 +1,21 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Build the agent for every supported target on a version tag and attach the
|
# GitHub version of the release pipeline. The build half mirrors the Gitea one
|
||||||
# archives to a GitHub release. Linux is a fully static musl binary (built with
|
# in .gitea/workflows/release.yml (static musl Linux via cargo-zigbuild, native
|
||||||
# cargo-zigbuild) so one artifact runs on any distro — Arch, Alpine, NixOS, old
|
# macOS and Windows); only the publish half differs: it uses the current
|
||||||
# glibc systems alike — and can then be repackaged however a distro prefers.
|
# 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 (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:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
@@ -18,6 +24,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: ${{ matrix.asset }}
|
name: ${{ matrix.asset }}
|
||||||
|
if: ${{ github.server_url == 'https://github.com' }}
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -28,11 +35,18 @@ jobs:
|
|||||||
asset: tsunagi-linux-x86_64
|
asset: tsunagi-linux-x86_64
|
||||||
archive: tar.gz
|
archive: tar.gz
|
||||||
zig: true
|
zig: true
|
||||||
|
deb: true
|
||||||
|
deb_arch: amd64
|
||||||
|
pkg_arch: x86_64
|
||||||
|
gnu_target: x86_64-unknown-linux-gnu
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
target: aarch64-unknown-linux-musl
|
target: aarch64-unknown-linux-musl
|
||||||
asset: tsunagi-linux-aarch64
|
asset: tsunagi-linux-aarch64
|
||||||
archive: tar.gz
|
archive: tar.gz
|
||||||
zig: true
|
zig: true
|
||||||
|
deb: true
|
||||||
|
deb_arch: arm64
|
||||||
|
pkg_arch: aarch64
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
target: aarch64-apple-darwin
|
target: aarch64-apple-darwin
|
||||||
asset: tsunagi-macos-aarch64
|
asset: tsunagi-macos-aarch64
|
||||||
@@ -45,7 +59,7 @@ jobs:
|
|||||||
zig: false
|
zig: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
@@ -58,19 +72,16 @@ jobs:
|
|||||||
# aws-lc-sys (iroh's rustls backend) and the bundled sqlite are C, and the
|
# 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
|
# 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.
|
# 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
|
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
|
- name: Install zig
|
||||||
if: matrix.zig
|
if: matrix.zig
|
||||||
uses: mlugg/setup-zig@v2
|
uses: mlugg/setup-zig@v2
|
||||||
with:
|
with:
|
||||||
# Pin a real, archived Zig release. Left unpinned the action resolves
|
# Pin a real, archived Zig release. Left unpinned the action resolves
|
||||||
# to the newest version it knows of (a yet-unreleased 0.16.0-dev here),
|
# to the newest version it knows of (a yet-unreleased 0.16.0-dev),
|
||||||
# whose tarball 404s on ziglang.org while the community mirrors flake —
|
# whose tarball 404s on ziglang.org while the community mirrors flake.
|
||||||
# exactly the failure this build hit. A concrete release always has a
|
|
||||||
# permanent official URL, so the download succeeds even if mirrors are
|
|
||||||
# down, and the version stays compatible with cargo-zigbuild.
|
|
||||||
version: 0.14.1
|
version: 0.14.1
|
||||||
- name: Install cargo-zigbuild
|
- name: Install cargo-zigbuild
|
||||||
if: matrix.zig
|
if: matrix.zig
|
||||||
@@ -83,11 +94,41 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [[ "${{ matrix.zig }}" == "true" ]]; then
|
if [[ "${{ matrix.zig }}" == "true" ]]; then
|
||||||
|
# zig's linker ignores the `-O1` GNU-ld optimization flag Rust passes
|
||||||
|
# and says so on stderr, which rustc now surfaces as a linker_messages
|
||||||
|
# warning. It is cosmetic — the binary is correct — so quiet just that
|
||||||
|
# one lint to keep the musl build log clean. A real link error still
|
||||||
|
# fails the build.
|
||||||
|
export RUSTFLAGS="${RUSTFLAGS:-} -A linker_messages"
|
||||||
cargo zigbuild --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
cargo zigbuild --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
||||||
else
|
else
|
||||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
||||||
fi
|
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
|
- name: Package
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -101,12 +142,24 @@ jobs:
|
|||||||
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
|
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
|
||||||
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
|
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
|
# The top-level README, plus the per-OS install notes and integration
|
||||||
# files, so the archive carries what the platform needs to set up.
|
# files, so the archive carries what the platform needs to set up.
|
||||||
cp README.md "$stage/"
|
cp README.md "$stage/"
|
||||||
case "${{ runner.os }}" in
|
case "${{ runner.os }}" in
|
||||||
Linux)
|
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"
|
cp dist/linux/README.md "$stage/INSTALL.md"
|
||||||
;;
|
;;
|
||||||
macOS)
|
macOS)
|
||||||
@@ -125,32 +178,107 @@ jobs:
|
|||||||
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
|
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- 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:
|
with:
|
||||||
name: ${{ matrix.asset }}
|
name: ${{ matrix.asset }}
|
||||||
path: ${{ matrix.asset }}-*.*
|
path: |
|
||||||
|
${{ matrix.asset }}-*.*
|
||||||
|
*.deb
|
||||||
|
*.pkg.tar.zst
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Publish release
|
name: Publish release
|
||||||
needs: build
|
needs: build
|
||||||
|
if: ${{ github.server_url == 'https://github.com' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Download every build artifact
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
- uses: actions/download-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
|
||||||
- name: Create release and upload assets
|
- name: Show what was collected
|
||||||
|
run: find artifacts -type f
|
||||||
|
|
||||||
|
- name: Create the release and upload the archives
|
||||||
|
shell: bash
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
RELEASE_TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
gh release create "$RELEASE_TAG" artifacts/*/* \
|
|
||||||
--title "tsunagi ${RELEASE_TAG}" \
|
# download-artifact nests each artifact under its own directory, so
|
||||||
--generate-notes \
|
# the archives are artifacts/<asset>/<file>.
|
||||||
--verify-tag
|
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=()
|
||||||
|
case "$TAG" in *-*) prerelease=(--prerelease) ;; esac
|
||||||
|
|
||||||
|
# Create the release on the first run; on a re-run, add/replace assets
|
||||||
|
# on the release that is already there.
|
||||||
|
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
gh release upload "$TAG" "${files[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
else
|
||||||
|
gh release create "$TAG" "${files[@]}" \
|
||||||
|
--repo "$GITHUB_REPOSITORY" \
|
||||||
|
--title "tsunagi $TAG" \
|
||||||
|
--generate-notes \
|
||||||
|
--verify-tag \
|
||||||
|
"${prerelease[@]}"
|
||||||
|
fi
|
||||||
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]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.91"
|
rust-version = "1.91"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "WTFPL"
|
||||||
repository = "https://github.com/tsunagi-net/tsunagi"
|
repository = "https://github.com/house-of-vanity/tsunagi"
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
missing_docs = "warn"
|
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/protocol.md](docs/protocol.md) — identity derivation, framing, handshake.
|
||||||
- [docs/sync-model.md](docs/sync-model.md) — the planned signed-state model and
|
- [docs/sync-model.md](docs/sync-model.md) — the planned signed-state model and
|
||||||
what is deliberately not built yet.
|
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/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.
|
- [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.
|
- [AGENTS.md](AGENTS.md) — rules for anyone (human or agent) changing this repo.
|
||||||
@@ -763,4 +764,4 @@ any of this.
|
|||||||
|
|
||||||
## Licence
|
## 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`.
|
# Local interface addresses, for the diagnostics in `status`.
|
||||||
netwatch = "0.19.3"
|
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]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
+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.len(), 1);
|
||||||
assert_eq!(remaining[0].network_id, before[1].network_id);
|
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 tempfile::TempDir;
|
||||||
use tsunagi::dataplane::IpPlugin;
|
use tsunagi::dataplane::IpPlugin;
|
||||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
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::ipc::{StatusReport, control_socket_path};
|
||||||
use tsunagi::overlay::MemoryTunFactory;
|
use tsunagi::overlay::MemoryTunFactory;
|
||||||
use tsunagi::testing::{config_with, network, wait_for_peers, wait_until};
|
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(),
|
hostname: peer.hostname.clone(),
|
||||||
transport: format!("{:?}", peer.transport),
|
transport: format!("{:?}", peer.transport),
|
||||||
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
|
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
|
||||||
|
exit_node: peer.exit_node,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
overlay: plugin.overview(net.network_id).map(|view| {
|
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,
|
cache_healthy: status.cache_healthy,
|
||||||
networks,
|
networks,
|
||||||
dns: None,
|
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.
|
// A short path: a Unix socket address is limited to about 100 bytes.
|
||||||
let socket_path = dir_a.path().join("agent.sock");
|
let socket_path = dir_a.path().join("agent.sock");
|
||||||
let control = ControlSocket::bind(&socket_path, source(agent.clone(), plugin.clone()))
|
let control = ControlSocket::bind(
|
||||||
.await
|
&socket_path,
|
||||||
.unwrap();
|
source(agent.clone(), plugin.clone()),
|
||||||
|
ControlSocketAccess::Private,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let report = wait_until("the overlay is reported as up", || {
|
let report = wait_until("the overlay is reported as up", || {
|
||||||
let socket_path = socket_path.clone();
|
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.
|
// A file with nobody listening is a leftover from a crash.
|
||||||
std::fs::write(&path, b"stale").unwrap();
|
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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(request_status(&path).await.is_ok());
|
assert!(request_status(&path).await.is_ok());
|
||||||
|
|
||||||
// A live socket is not stolen from the agent that owns it.
|
// 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!(
|
assert!(
|
||||||
matches!(second, Err(tsunagi::Error::StateLocked { .. })),
|
matches!(second, Err(tsunagi::Error::StateLocked { .. })),
|
||||||
"a second agent must not take over a live control socket"
|
"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;
|
wait_for_peers(&agent, network_id, 1).await;
|
||||||
|
|
||||||
let socket_path = dir.path().join("control.sock");
|
let socket_path = dir.path().join("control.sock");
|
||||||
let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone())))
|
let control = ControlSocket::bind(
|
||||||
.await
|
&socket_path,
|
||||||
.unwrap();
|
Arc::new(Control(agent.clone())),
|
||||||
|
ControlSocketAccess::Private,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let report = tsunagi::ipc::leave_network(&socket_path, &network_id.to_string())
|
let report = tsunagi::ipc::leave_network(&socket_path, &network_id.to_string())
|
||||||
.await
|
.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();
|
agent.join_network(&first, &first_secret).await.unwrap();
|
||||||
|
|
||||||
let socket_path = dir.path().join("control.sock");
|
let socket_path = dir.path().join("control.sock");
|
||||||
let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone())))
|
let control = ControlSocket::bind(
|
||||||
.await
|
&socket_path,
|
||||||
.unwrap();
|
Arc::new(Control(agent.clone())),
|
||||||
|
ControlSocketAccess::Private,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let report = tsunagi::ipc::join_network(
|
let report = tsunagi::ipc::join_network(
|
||||||
&socket_path,
|
&socket_path,
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ impl Agent {
|
|||||||
async fn reserve_range(&self, network: NetworkId) -> RangePlan {
|
async fn reserve_range(&self, network: NetworkId) -> RangePlan {
|
||||||
let reserve = |wanted: Ipv4Range| {
|
let reserve = |wanted: Ipv4Range| {
|
||||||
let reservation = crate::overlay::NetworkRoutes {
|
let reservation = crate::overlay::NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: Default::default(),
|
broadcast: Default::default(),
|
||||||
range: Some(wanted),
|
range: Some(wanted),
|
||||||
local: None,
|
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.
|
/// Changes a network's local broadcast policy now and after restart.
|
||||||
pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||||
self.inner
|
self.inner
|
||||||
@@ -662,16 +729,23 @@ impl Agent {
|
|||||||
as Arc<dyn crate::discovery::NetworkDiscovery>,
|
as Arc<dyn crate::discovery::NetworkDiscovery>,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let broadcast = self
|
let stored = self
|
||||||
.inner
|
.inner
|
||||||
.storage
|
.storage
|
||||||
.list_networks()
|
.list_networks()
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|stored| stored.network_id == network_id)
|
.find(|stored| stored.network_id == network_id);
|
||||||
.is_none_or(|stored| stored.broadcast);
|
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 {
|
let handle = network::spawn(RuntimeParams {
|
||||||
broadcast,
|
broadcast,
|
||||||
|
exit_offer,
|
||||||
|
exit_via,
|
||||||
keys,
|
keys,
|
||||||
adapter: self.inner.adapter.clone(),
|
adapter: self.inner.adapter.clone(),
|
||||||
storage: self.inner.storage.clone(),
|
storage: self.inner.storage.clone(),
|
||||||
@@ -733,6 +807,7 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
if let Some(interface) = self.inner.interface.get() {
|
if let Some(interface) = self.inner.interface.get() {
|
||||||
interface.sync_broadcast_rules().await;
|
interface.sync_broadcast_rules().await;
|
||||||
|
interface.sync_exit_rules().await;
|
||||||
}
|
}
|
||||||
self.inner.storage.set_auto_start(network_id, false).await?;
|
self.inner.storage.set_auto_start(network_id, false).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -886,6 +961,12 @@ impl Agent {
|
|||||||
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
||||||
networks.push(NetworkStatus {
|
networks.push(NetworkStatus {
|
||||||
broadcast: stored.broadcast,
|
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(),
|
descriptor: keys.descriptor(),
|
||||||
name: stored.name,
|
name: stored.name,
|
||||||
network_id: stored.network_id,
|
network_id: stored.network_id,
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ pub(crate) enum NetCommand {
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
reply: oneshot::Sender<()>,
|
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.
|
/// Resend this agent's announcement to every peer of this network.
|
||||||
Reannounce,
|
Reannounce,
|
||||||
/// Answer to a different name from now on.
|
/// 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::Status { .. } => f.write_str("Status"),
|
||||||
NetCommand::Recheck => f.write_str("Recheck"),
|
NetCommand::Recheck => f.write_str("Recheck"),
|
||||||
NetCommand::SetBroadcast { enabled, .. } => write!(f, "SetBroadcast({enabled})"),
|
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::Reannounce => f.write_str("Reannounce"),
|
||||||
NetCommand::SetHostname(_) => f.write_str("SetHostname"),
|
NetCommand::SetHostname(_) => f.write_str("SetHostname"),
|
||||||
NetCommand::Release { .. } => f.write_str("Release"),
|
NetCommand::Release { .. } => f.write_str("Release"),
|
||||||
@@ -137,6 +149,10 @@ impl NetworkHandle {
|
|||||||
/// Everything a network runtime needs to run.
|
/// Everything a network runtime needs to run.
|
||||||
pub(crate) struct RuntimeParams {
|
pub(crate) struct RuntimeParams {
|
||||||
pub(crate) broadcast: bool,
|
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) keys: NetworkKeys,
|
||||||
pub(crate) adapter: EndpointAdapter,
|
pub(crate) adapter: EndpointAdapter,
|
||||||
pub(crate) storage: Storage,
|
pub(crate) storage: Storage,
|
||||||
@@ -331,6 +347,14 @@ struct Runtime {
|
|||||||
pending_state: Vec<(EndpointId, Vec<SignedRecord>)>,
|
pending_state: Vec<(EndpointId, Vec<SignedRecord>)>,
|
||||||
/// The highest version this agent has ever published for this network.
|
/// The highest version this agent has ever published for this network.
|
||||||
own_version: u64,
|
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 {
|
impl Runtime {
|
||||||
@@ -382,6 +406,9 @@ impl Runtime {
|
|||||||
state: StateSet::new(),
|
state: StateSet::new(),
|
||||||
pending_state: Vec::new(),
|
pending_state: Vec::new(),
|
||||||
own_version: 0,
|
own_version: 0,
|
||||||
|
exit_via_was_online: false,
|
||||||
|
announced_exit: false,
|
||||||
|
known_hostnames: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +528,27 @@ impl Runtime {
|
|||||||
self.reannounce();
|
self.reannounce();
|
||||||
let _ = reply.send(());
|
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::Recheck => self.discovery_round().await,
|
||||||
NetCommand::Reannounce => self.reannounce(),
|
NetCommand::Reannounce => self.reannounce(),
|
||||||
NetCommand::Release { reply } => {
|
NetCommand::Release { reply } => {
|
||||||
@@ -844,6 +892,17 @@ impl Runtime {
|
|||||||
.own_record_version(self.network_id, self.local_id)
|
.own_record_version(self.network_id, self.local_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(0);
|
.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.ensure_own_claim().await;
|
||||||
self.publish_allocations().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) {
|
fn update_broadcast(&self) {
|
||||||
self.params
|
self.params
|
||||||
.routes
|
.routes
|
||||||
@@ -1123,6 +1251,7 @@ impl Runtime {
|
|||||||
// a packet for ourselves does not go over a tunnel.
|
// a packet for ourselves does not go over a tunnel.
|
||||||
let local = self.state.address_of(&self.local_id);
|
let local = self.state.address_of(&self.local_id);
|
||||||
let routes = crate::overlay::NetworkRoutes {
|
let routes = crate::overlay::NetworkRoutes {
|
||||||
|
exit: self.exit_policy(),
|
||||||
broadcast: self.broadcast_policy(),
|
broadcast: self.broadcast_policy(),
|
||||||
range: Some(range),
|
range: Some(range),
|
||||||
local,
|
local,
|
||||||
@@ -1219,6 +1348,7 @@ impl Runtime {
|
|||||||
// only once the address is really on the interface.
|
// only once the address is really on the interface.
|
||||||
if !is_missing {
|
if !is_missing {
|
||||||
interface.sync_broadcast_rules().await;
|
interface.sync_broadcast_rules().await;
|
||||||
|
self.apply_exit().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1783,8 +1913,11 @@ impl Runtime {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
capabilities.truncate(self.params.limits.max_capabilities);
|
capabilities.truncate(self.params.limits.max_capabilities);
|
||||||
|
let exit_node = self.exit_ready();
|
||||||
|
self.announced_exit = exit_node;
|
||||||
Announcement {
|
Announcement {
|
||||||
broadcast: self.params.broadcast,
|
broadcast: self.params.broadcast,
|
||||||
|
exit_node,
|
||||||
hostname: self.params.hostname.clone(),
|
hostname: self.params.hostname.clone(),
|
||||||
capabilities,
|
capabilities,
|
||||||
}
|
}
|
||||||
@@ -1863,6 +1996,7 @@ impl Runtime {
|
|||||||
self.metrics.disconnects += 1;
|
self.metrics.disconnects += 1;
|
||||||
self.drop_links_for(peer);
|
self.drop_links_for(peer);
|
||||||
self.update_broadcast();
|
self.update_broadcast();
|
||||||
|
self.watch_exit_via();
|
||||||
self.update_paths();
|
self.update_paths();
|
||||||
self.announce_reach(false);
|
self.announce_reach(false);
|
||||||
for plugin in &self.params.plugins {
|
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) {
|
fn dispatch_message(&mut self, peer: EndpointId, message: ControlMessage) {
|
||||||
match &message {
|
match &message {
|
||||||
ControlMessage::Announce(announcement) => {
|
ControlMessage::Announce(announcement) => {
|
||||||
@@ -1892,9 +2053,12 @@ impl Runtime {
|
|||||||
if let Some(session) = self.sessions.get_mut(&peer) {
|
if let Some(session) = self.sessions.get_mut(&peer) {
|
||||||
session.hostname = Some(announcement.hostname.clone());
|
session.hostname = Some(announcement.hostname.clone());
|
||||||
session.broadcast = announcement.broadcast;
|
session.broadcast = announcement.broadcast;
|
||||||
|
session.exit_node = announcement.exit_node;
|
||||||
session.capabilities = capabilities.clone();
|
session.capabilities = capabilities.clone();
|
||||||
}
|
}
|
||||||
|
self.remember_hostname(peer, &announcement.hostname);
|
||||||
self.update_broadcast();
|
self.update_broadcast();
|
||||||
|
self.watch_exit_via();
|
||||||
self.dispatch_capabilities(peer, &capabilities);
|
self.dispatch_capabilities(peer, &capabilities);
|
||||||
self.ensure_links();
|
self.ensure_links();
|
||||||
self.update_paths();
|
self.update_paths();
|
||||||
@@ -1992,9 +2156,13 @@ impl Runtime {
|
|||||||
let snapshot = snapshot_connection(&session.conn);
|
let snapshot = snapshot_connection(&session.conn);
|
||||||
PeerStatus {
|
PeerStatus {
|
||||||
broadcast: session.broadcast,
|
broadcast: session.broadcast,
|
||||||
|
exit_node: session.exit_node,
|
||||||
endpoint_id: session.peer,
|
endpoint_id: session.peer,
|
||||||
role: session.role,
|
role: session.role,
|
||||||
hostname: session.hostname.clone(),
|
hostname: session
|
||||||
|
.hostname
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.known_hostnames.get(&session.peer).cloned()),
|
||||||
protocols: session
|
protocols: session
|
||||||
.capabilities
|
.capabilities
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2052,6 +2220,7 @@ impl Runtime {
|
|||||||
// both hold it would be untrue on one of them.
|
// both hold it would be untrue on one of them.
|
||||||
overlay_address_v4: self.state.address_of(&endpoint_id),
|
overlay_address_v4: self.state.address_of(&endpoint_id),
|
||||||
hostname: self.state.hostname_of(&endpoint_id).map(str::to_string),
|
hostname: self.state.hostname_of(&endpoint_id).map(str::to_string),
|
||||||
|
last_hostname: self.known_hostnames.get(&endpoint_id).cloned(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -2060,6 +2229,18 @@ impl Runtime {
|
|||||||
|
|
||||||
NetworkStatus {
|
NetworkStatus {
|
||||||
broadcast: self.params.broadcast,
|
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(),
|
descriptor: self.params.keys.descriptor(),
|
||||||
name: self.params.keys.name().clone(),
|
name: self.params.keys.name().clone(),
|
||||||
network_id: self.network_id,
|
network_id: self.network_id,
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ pub(crate) struct Session {
|
|||||||
pub(crate) hostname: Option<String>,
|
pub(crate) hostname: Option<String>,
|
||||||
pub(crate) capabilities: Vec<PluginCapability>,
|
pub(crate) capabilities: Vec<PluginCapability>,
|
||||||
pub(crate) broadcast: bool,
|
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_sent: u64,
|
||||||
pub(crate) messages_received: u64,
|
pub(crate) messages_received: u64,
|
||||||
pub(crate) bytes_sent: u64,
|
pub(crate) bytes_sent: u64,
|
||||||
@@ -228,6 +230,7 @@ pub(crate) fn spawn(
|
|||||||
hostname: None,
|
hostname: None,
|
||||||
capabilities: Vec::new(),
|
capabilities: Vec::new(),
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
|
exit_node: false,
|
||||||
messages_sent: 0,
|
messages_sent: 0,
|
||||||
messages_received: 0,
|
messages_received: 0,
|
||||||
bytes_sent: 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
|
/// 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.
|
/// lets an absent member be named rather than shown as a bare id.
|
||||||
pub hostname: Option<String>,
|
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.
|
/// Status of one authenticated session.
|
||||||
@@ -87,6 +92,8 @@ pub struct MemberStatus {
|
|||||||
pub struct PeerStatus {
|
pub struct PeerStatus {
|
||||||
/// This authenticated peer accepts network broadcasts.
|
/// This authenticated peer accepts network broadcasts.
|
||||||
pub broadcast: bool,
|
pub broadcast: bool,
|
||||||
|
/// This authenticated peer offers itself as an exit node.
|
||||||
|
pub exit_node: bool,
|
||||||
/// Authenticated endpoint id.
|
/// Authenticated endpoint id.
|
||||||
pub endpoint_id: EndpointId,
|
pub endpoint_id: EndpointId,
|
||||||
/// Which side this agent played in the handshake.
|
/// Which side this agent played in the handshake.
|
||||||
@@ -159,6 +166,14 @@ pub struct NetworkMetrics {
|
|||||||
pub struct NetworkStatus {
|
pub struct NetworkStatus {
|
||||||
/// Local broadcast participation in this network.
|
/// Local broadcast participation in this network.
|
||||||
pub broadcast: bool,
|
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.
|
/// Immutable deterministic description of the network space.
|
||||||
pub descriptor: NetworkDescriptor,
|
pub descriptor: NetworkDescriptor,
|
||||||
/// Network name, for convenience.
|
/// Network name, for convenience.
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ struct TunnelPeerState {
|
|||||||
link: SharedLink,
|
link: SharedLink,
|
||||||
tx_packets: AtomicU64,
|
tx_packets: AtomicU64,
|
||||||
rx_packets: AtomicU64,
|
rx_packets: AtomicU64,
|
||||||
|
tx_bytes: AtomicU64,
|
||||||
|
rx_bytes: AtomicU64,
|
||||||
dropped: AtomicU64,
|
dropped: AtomicU64,
|
||||||
established_at: std::time::Instant,
|
established_at: std::time::Instant,
|
||||||
}
|
}
|
||||||
@@ -100,6 +102,10 @@ pub struct TunnelPeerReport {
|
|||||||
pub tx_packets: u64,
|
pub tx_packets: u64,
|
||||||
/// Packets successfully received and decrypted.
|
/// Packets successfully received and decrypted.
|
||||||
pub rx_packets: u64,
|
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.
|
/// Packets dropped due to encryption/decryption/send failures.
|
||||||
pub dropped: u64,
|
pub dropped: u64,
|
||||||
/// Uptime in seconds since the data link was established.
|
/// Uptime in seconds since the data link was established.
|
||||||
@@ -145,6 +151,8 @@ impl<C: TunnelCodec> GenericTunnelPlugin<C> {
|
|||||||
path: state.link.path_description(),
|
path: state.link.path_description(),
|
||||||
tx_packets: state.tx_packets.load(Ordering::Relaxed),
|
tx_packets: state.tx_packets.load(Ordering::Relaxed),
|
||||||
rx_packets: state.rx_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),
|
dropped: state.dropped.load(Ordering::Relaxed),
|
||||||
uptime_secs: state.established_at.elapsed().as_secs(),
|
uptime_secs: state.established_at.elapsed().as_secs(),
|
||||||
})
|
})
|
||||||
@@ -223,6 +231,9 @@ impl<C: TunnelCodec> IpPlugin for GenericTunnelPlugin<C> {
|
|||||||
Ok(ciphertext) => {
|
Ok(ciphertext) => {
|
||||||
if state.link.send(ciphertext).is_ok() {
|
if state.link.send(ciphertext).is_ok() {
|
||||||
state.tx_packets.fetch_add(1, Ordering::Relaxed);
|
state.tx_packets.fetch_add(1, Ordering::Relaxed);
|
||||||
|
state
|
||||||
|
.tx_bytes
|
||||||
|
.fetch_add(packet.len() as u64, Ordering::Relaxed);
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
state.dropped.fetch_add(1, Ordering::Relaxed);
|
state.dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
@@ -242,6 +253,8 @@ impl<C: TunnelCodec> IpPlugin for GenericTunnelPlugin<C> {
|
|||||||
link: Arc::clone(&link),
|
link: Arc::clone(&link),
|
||||||
tx_packets: AtomicU64::new(0),
|
tx_packets: AtomicU64::new(0),
|
||||||
rx_packets: AtomicU64::new(0),
|
rx_packets: AtomicU64::new(0),
|
||||||
|
tx_bytes: AtomicU64::new(0),
|
||||||
|
rx_bytes: AtomicU64::new(0),
|
||||||
dropped: AtomicU64::new(0),
|
dropped: AtomicU64::new(0),
|
||||||
established_at: std::time::Instant::now(),
|
established_at: std::time::Instant::now(),
|
||||||
});
|
});
|
||||||
@@ -268,6 +281,9 @@ impl<C: TunnelCodec> IpPlugin for GenericTunnelPlugin<C> {
|
|||||||
match codec.decrypt(network, peer, &payload) {
|
match codec.decrypt(network, peer, &payload) {
|
||||||
Ok(packet) => {
|
Ok(packet) => {
|
||||||
rx_state.rx_packets.fetch_add(1, Ordering::Relaxed);
|
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 {
|
if let Some(ref sink) = sink {
|
||||||
sink.deliver(network, peer, packet).await;
|
sink.deliver(network, peer, packet).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ pub enum Error {
|
|||||||
what: String,
|
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.
|
/// Discovery backend failure. Never fatal for the agent.
|
||||||
#[error("discovery error: {0}")]
|
#[error("discovery error: {0}")]
|
||||||
Discovery(String),
|
Discovery(String),
|
||||||
|
|||||||
@@ -44,6 +44,21 @@ use windows as transport;
|
|||||||
#[cfg(any(unix, windows))]
|
#[cfg(any(unix, windows))]
|
||||||
pub use transport::ControlSocket;
|
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::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -58,6 +73,31 @@ use crate::error::{Error, Result};
|
|||||||
/// Largest accepted local control message.
|
/// Largest accepted local control message.
|
||||||
pub const MAX_MESSAGE_LEN: usize = 1024 * 1024;
|
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.
|
/// Where the control socket for a state directory lives.
|
||||||
///
|
///
|
||||||
/// A Unix socket path is limited to around 100 bytes, which a state directory
|
/// 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")))]
|
#[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() {
|
if let Ok(paths) = crate::config::StoragePaths::user_default() {
|
||||||
control_socket_path(&paths.state_dir)
|
control_socket_path(&paths.state_dir)
|
||||||
} else {
|
} else {
|
||||||
PathBuf::from("/run/tsunagi/agent.sock")
|
system
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +227,29 @@ pub enum Request {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
broadcast: Option<bool>,
|
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 {
|
impl std::fmt::Debug for Request {
|
||||||
@@ -200,6 +270,14 @@ impl std::fmt::Debug for Request {
|
|||||||
write!(f, "Dns {{ enable: {enable}, port: {port:?} }}")
|
write!(f, "Dns {{ enable: {enable}, port: {port:?} }}")
|
||||||
}
|
}
|
||||||
Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: <redacted> }}"),
|
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.
|
/// `None` means it is not serving at all.
|
||||||
Dns(Option<DnsReport>),
|
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.
|
/// The request could not be served.
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
@@ -295,6 +379,38 @@ pub struct StatusReport {
|
|||||||
pub networks: Vec<NetworkReport>,
|
pub networks: Vec<NetworkReport>,
|
||||||
/// The local DNS service, when one was asked for.
|
/// The local DNS service, when one was asked for.
|
||||||
pub dns: Option<DnsReport>,
|
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.
|
/// The local DNS service.
|
||||||
@@ -368,6 +484,9 @@ pub struct NetworkReport {
|
|||||||
pub range_conflict: Option<String>,
|
pub range_conflict: Option<String>,
|
||||||
/// The overlay, when a protocol is running one.
|
/// The overlay, when a protocol is running one.
|
||||||
pub overlay: Option<OverlayReport>,
|
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.
|
/// One member of the network, from signed state.
|
||||||
@@ -379,6 +498,8 @@ pub struct MemberReport {
|
|||||||
pub overlay_address_v4: Option<String>,
|
pub overlay_address_v4: Option<String>,
|
||||||
/// Consecutive failed dial attempts, when this agent is trying to reach it.
|
/// Consecutive failed dial attempts, when this agent is trying to reach it.
|
||||||
pub failed_dials: u32,
|
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.
|
/// One control plane peer.
|
||||||
@@ -393,6 +514,8 @@ pub struct PeerReport {
|
|||||||
pub transport: String,
|
pub transport: String,
|
||||||
/// Round-trip time in milliseconds, when a path is selected.
|
/// Round-trip time in milliseconds, when a path is selected.
|
||||||
pub rtt_ms: Option<u64>,
|
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.
|
/// The WireGuard overlay of one network.
|
||||||
@@ -425,6 +548,35 @@ pub struct OverlayReport {
|
|||||||
pub broadcast_rules: Option<HostRulesReport>,
|
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.
|
/// What the agent installed on the host so LAN broadcast reaches the overlay.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct HostRulesReport {
|
pub struct HostRulesReport {
|
||||||
@@ -457,6 +609,10 @@ pub struct OverlayPeerReport {
|
|||||||
pub tx_packets: u64,
|
pub tx_packets: u64,
|
||||||
/// Packets decrypted from this peer.
|
/// Packets decrypted from this peer.
|
||||||
pub rx_packets: u64,
|
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.
|
/// Data packets dropped: wrong source address, or too large for the path.
|
||||||
pub dropped: u64,
|
pub dropped: u64,
|
||||||
/// WireGuard protocol errors.
|
/// WireGuard protocol errors.
|
||||||
@@ -560,6 +716,39 @@ pub trait ReportSource: Send + Sync + 'static {
|
|||||||
) -> BoxFuture<'_, std::result::Result<Option<DnsReport>, String>> {
|
) -> BoxFuture<'_, std::result::Result<Option<DnsReport>, String>> {
|
||||||
Box::pin(async move { Err("this agent cannot serve DNS".to_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
|
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
|
/// Bump it whenever [`Request`], [`Response`] or anything they contain
|
||||||
/// changes shape.
|
/// 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.
|
/// Reads one request off an accepted stream, answers it, writes the response.
|
||||||
///
|
///
|
||||||
@@ -648,6 +837,23 @@ where
|
|||||||
Ok(report) => Response::Dns(report),
|
Ok(report) => Response::Dns(report),
|
||||||
Err(reason) => Response::Error(reason),
|
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
|
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<()>
|
pub(crate) async fn write_message<S, T>(stream: &mut S, value: &T) -> Result<()>
|
||||||
where
|
where
|
||||||
S: AsyncWrite + Unpin,
|
S: AsyncWrite + Unpin,
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ use crate::error::{Error, Result};
|
|||||||
// The transport-agnostic surface, re-exported so this module is a complete
|
// The transport-agnostic surface, re-exported so this module is a complete
|
||||||
// view of the local control interface on its own.
|
// view of the local control interface on its own.
|
||||||
pub use super::{
|
pub use super::{
|
||||||
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
|
CONTROL_PROTOCOL, ControlSocketAccess, EXCHANGE_TIMEOUT, ReportSource, is_serving,
|
||||||
request_status, set_active, set_dns, set_hostname,
|
join_network, leave_network, request_status, set_active, set_dns, set_hostname,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Serves the local control interface on a Unix socket.
|
/// 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
|
/// 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
|
/// after checking that nothing is listening on it, so two live agents
|
||||||
/// never fight over one path.
|
/// 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();
|
let path = path.as_ref().to_path_buf();
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
crate::storage::create_dir(parent)?;
|
create_parent(parent, &access)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
@@ -61,7 +65,7 @@ impl ControlSocket {
|
|||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
restrict(&path)?;
|
apply_access(&path, &access)?;
|
||||||
|
|
||||||
let task = tokio::spawn(serve(listener, source));
|
let task = tokio::spawn(serve(listener, source));
|
||||||
Ok(Self {
|
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;
|
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 {
|
Error::Io {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
source,
|
source,
|
||||||
@@ -170,7 +213,9 @@ mod tests {
|
|||||||
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
||||||
Box::pin(async { StatusReport::default() })
|
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)
|
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ use tokio::task::JoinHandle;
|
|||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
pub use super::{
|
pub use super::{
|
||||||
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
|
CONTROL_PROTOCOL, ControlSocketAccess, EXCHANGE_TIMEOUT, ReportSource, is_serving,
|
||||||
request_status, set_active, set_dns, set_hostname,
|
join_network, leave_network, request_status, set_active, set_dns, set_hostname,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `ERROR_ACCESS_DENIED`: creating a first instance finds an existing pipe,
|
/// `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:
|
/// 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
|
/// 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.
|
/// 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 path = path.as_ref().to_path_buf();
|
||||||
let name = pipe_name(&path);
|
let name = pipe_name(&path);
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ mod tests {
|
|||||||
fn limited_and_directed_broadcasts_never_cross_domains_or_target_disabled_peers() {
|
fn limited_and_directed_broadcasts_never_cross_domains_or_target_disabled_peers() {
|
||||||
let peer = iroh::SecretKey::from_bytes(&[8; 32]).public();
|
let peer = iroh::SecretKey::from_bytes(&[8; 32]).public();
|
||||||
let a = NetworkRoutes {
|
let a = NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
range: Some("10.0.0.0/24".parse().unwrap()),
|
range: Some("10.0.0.0/24".parse().unwrap()),
|
||||||
local: Some("10.0.0.1".parse().unwrap()),
|
local: Some("10.0.0.1".parse().unwrap()),
|
||||||
peers: vec![("10.0.0.2".parse().unwrap(), peer)],
|
peers: vec![("10.0.0.2".parse().unwrap(), peer)],
|
||||||
@@ -149,6 +150,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let b = NetworkRoutes {
|
let b = NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
range: Some("10.1.0.0/24".parse().unwrap()),
|
range: Some("10.1.0.0/24".parse().unwrap()),
|
||||||
local: Some("10.1.0.1".parse().unwrap()),
|
local: Some("10.1.0.1".parse().unwrap()),
|
||||||
peers: vec![("10.1.0.2".parse().unwrap(), peer)],
|
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("b"), peer, Ipv4Addr::BROADCAST));
|
||||||
assert!(table.accepts(id("a"), peer, Ipv4Addr::BROADCAST));
|
assert!(table.accepts(id("a"), peer, Ipv4Addr::BROADCAST));
|
||||||
let disabled = NetworkRoutes {
|
let disabled = NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: BroadcastPolicy {
|
broadcast: BroadcastPolicy {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
..a.broadcast.clone()
|
..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.
|
/// What running `iptables` produced.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
enum Ran {
|
pub(super) enum Ran {
|
||||||
/// There is no `iptables` to run.
|
/// There is no `iptables` to run.
|
||||||
Missing,
|
Missing,
|
||||||
/// It ran, and exited like this.
|
/// It ran, and exited like this.
|
||||||
@@ -63,7 +63,7 @@ enum Ran {
|
|||||||
|
|
||||||
/// Runs `iptables` with these arguments. A function value so the flow below
|
/// Runs `iptables` with these arguments. A function value so the flow below
|
||||||
/// can be tested without touching the host.
|
/// 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.
|
/// The match and target of the rule, without the chain or position.
|
||||||
fn rule_spec(plan: &BroadcastRulesPlan) -> Vec<String> {
|
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
|
/// Waits briefly for the xtables lock instead of failing when another tool
|
||||||
/// holds it.
|
/// holds it.
|
||||||
fn locked(args: impl IntoIterator<Item = String>) -> Vec<String> {
|
pub(super) fn locked(args: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||||
["-w", "5"]
|
["-w", "5"]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
@@ -118,7 +118,7 @@ fn list_args() -> Vec<String> {
|
|||||||
|
|
||||||
/// Splits one `iptables -S` line into words, honouring double quotes, which
|
/// Splits one `iptables -S` line into words, honouring double quotes, which
|
||||||
/// `iptables` puts around comments that need them.
|
/// `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 words = Vec::new();
|
||||||
let mut current = String::new();
|
let mut current = String::new();
|
||||||
let mut quoted = false;
|
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.
|
/// 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 text = stderr.trim();
|
||||||
let lower = text.to_ascii_lowercase();
|
let lower = text.to_ascii_lowercase();
|
||||||
if lower.contains("permission denied") || lower.contains("must be root") {
|
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`.
|
/// 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 {
|
let Some(program) = pick_iptables(|path| std::path::Path::new(path).is_file()) else {
|
||||||
return Ran::Missing;
|
return Ran::Missing;
|
||||||
};
|
};
|
||||||
@@ -283,7 +283,7 @@ fn run_iptables(args: &[String]) -> Ran {
|
|||||||
/// The Linux implementation: netlink for the route, `iptables` for the rule.
|
/// The Linux implementation: netlink for the route, `iptables` for the rule.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct LinuxHostRules {
|
pub struct LinuxHostRules {
|
||||||
route: RouteHandle,
|
pub(super) route: RouteHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LinuxHostRules {
|
impl LinuxHostRules {
|
||||||
@@ -306,6 +306,10 @@ impl BroadcastHostRules for LinuxHostRules {
|
|||||||
"netlink+iptables"
|
"netlink+iptables"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn exit_rules(&self) -> Option<&dyn super::ExitHostRules> {
|
||||||
|
Some(self)
|
||||||
|
}
|
||||||
|
|
||||||
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
|
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let route = self.set_route(plan.interface.clone(), plan.source).await;
|
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::BoxFuture;
|
||||||
use crate::state::Ipv4Range;
|
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"))]
|
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||||
mod linux;
|
mod linux;
|
||||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||||
|
mod linux_exit;
|
||||||
|
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||||
pub use linux::LinuxHostRules;
|
pub use linux::LinuxHostRules;
|
||||||
|
|
||||||
// Compiled everywhere so the scripts it builds are tested everywhere; only
|
// Compiled everywhere so the scripts it builds are tested everywhere; only
|
||||||
@@ -159,6 +167,15 @@ pub trait BroadcastHostRules: Send + Sync + std::fmt::Debug + 'static {
|
|||||||
/// Removes what this agent put there for the interface. Succeeds when
|
/// Removes what this agent put there for the interface. Succeeds when
|
||||||
/// nothing is there: it runs on the shutdown path.
|
/// nothing is there: it runs on the shutdown path.
|
||||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>;
|
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.
|
/// 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.
|
/// An in-memory host, for tests: nothing is touched.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct MockHostRules {
|
pub struct MockHostRules {
|
||||||
|
exit: MockExitRules,
|
||||||
state: Arc<Mutex<MockRulesState>>,
|
state: Arc<Mutex<MockRulesState>>,
|
||||||
calls: Arc<Mutex<Vec<String>>>,
|
calls: Arc<Mutex<Vec<String>>>,
|
||||||
firewall_failure: Arc<Mutex<Option<String>>>,
|
firewall_failure: Arc<Mutex<Option<String>>>,
|
||||||
@@ -203,7 +221,15 @@ pub struct MockHostRules {
|
|||||||
impl MockHostRules {
|
impl MockHostRules {
|
||||||
/// An empty host.
|
/// An empty host.
|
||||||
pub fn new() -> Self {
|
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.
|
/// Makes the firewall step fail with this reason from now on.
|
||||||
@@ -234,6 +260,10 @@ impl BroadcastHostRules for MockHostRules {
|
|||||||
"mock"
|
"mock"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn exit_rules(&self) -> Option<&dyn ExitHostRules> {
|
||||||
|
Some(&self.exit)
|
||||||
|
}
|
||||||
|
|
||||||
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
|
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
lock(&self.calls).push(format!("apply:{}", plan.interface));
|
lock(&self.calls).push(format!("apply:{}", plan.interface));
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ use tokio::task::JoinHandle;
|
|||||||
|
|
||||||
use crate::identity::NetworkId;
|
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::packet::IpHeader;
|
||||||
use super::router::{Route, RoutingTable};
|
use super::router::{Route, RoutingTable};
|
||||||
use super::tun::{TunDevice, TunFactory, TunRequest};
|
use super::tun::{TunDevice, TunFactory, TunRequest};
|
||||||
@@ -158,6 +162,31 @@ pub struct Interface {
|
|||||||
host_rules: Option<Arc<dyn BroadcastHostRules>>,
|
host_rules: Option<Arc<dyn BroadcastHostRules>>,
|
||||||
/// What those rules last reported, `None` while none are installed.
|
/// What those rules last reported, `None` while none are installed.
|
||||||
rules_report: std::sync::Mutex<Option<BroadcastRulesReport>>,
|
rules_report: std::sync::Mutex<Option<BroadcastRulesReport>>,
|
||||||
|
/// What the exit-node rules were last asked for, and what came of it.
|
||||||
|
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 {
|
impl Interface {
|
||||||
@@ -245,6 +274,7 @@ impl Interface {
|
|||||||
sync_lock: Arc::new(tokio::sync::Mutex::new(())),
|
sync_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||||
host_rules,
|
host_rules,
|
||||||
rules_report: std::sync::Mutex::new(None),
|
rules_report: std::sync::Mutex::new(None),
|
||||||
|
exit_state: std::sync::Mutex::new(ExitState::default()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
/// What the broadcast host rules achieved, `None` when none are installed
|
||||||
/// or this interface has no host to configure.
|
/// or this interface has no host to configure.
|
||||||
pub fn broadcast_rules(&self) -> Option<BroadcastRulesReport> {
|
pub fn broadcast_rules(&self) -> Option<BroadcastRulesReport> {
|
||||||
@@ -379,6 +596,16 @@ impl Interface {
|
|||||||
if let Some(rules) = &self.host_rules {
|
if let Some(rules) = &self.host_rules {
|
||||||
rules.clear(&self.name).await;
|
rules.clear(&self.name).await;
|
||||||
self.store_rules_report(None);
|
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
|
// The packet loop holds the device open, and it ends only when the
|
||||||
// device reports end of stream — which a real interface never does
|
// 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) => {
|
IpAddr::V4(destination) if self.routes.is_local_destination(network, destination) => {
|
||||||
false
|
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);
|
self.tally.wrong_destination.fetch_add(1, Ordering::Relaxed);
|
||||||
return Err(Rejected::WrongDestination);
|
return Err(Rejected::WrongDestination);
|
||||||
@@ -525,7 +757,7 @@ mod tests {
|
|||||||
use crate::overlay::broadcast::BroadcastPolicy;
|
use crate::overlay::broadcast::BroadcastPolicy;
|
||||||
use crate::overlay::hostrules::{MockHostRules, RuleOutcome};
|
use crate::overlay::hostrules::{MockHostRules, RuleOutcome};
|
||||||
use crate::overlay::provision::{ManagedTunFactory, MockProvisioner};
|
use crate::overlay::provision::{ManagedTunFactory, MockProvisioner};
|
||||||
use crate::overlay::router::NetworkRoutes;
|
use crate::overlay::router::{ExitPolicy, NetworkRoutes};
|
||||||
use crate::overlay::tun::{MemoryTun, MemoryTunFactory};
|
use crate::overlay::tun::{MemoryTun, MemoryTunFactory};
|
||||||
|
|
||||||
fn network(name: &str) -> NetworkId {
|
fn network(name: &str) -> NetworkId {
|
||||||
@@ -667,6 +899,7 @@ mod tests {
|
|||||||
.set_network(
|
.set_network(
|
||||||
id,
|
id,
|
||||||
NetworkRoutes {
|
NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: Default::default(),
|
broadcast: Default::default(),
|
||||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||||
local: Some(addr(1)),
|
local: Some(addr(1)),
|
||||||
@@ -833,6 +1066,7 @@ mod tests {
|
|||||||
.set_network(
|
.set_network(
|
||||||
id,
|
id,
|
||||||
NetworkRoutes {
|
NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: Default::default(),
|
broadcast: Default::default(),
|
||||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||||
local: Some(addr(9)),
|
local: Some(addr(9)),
|
||||||
@@ -856,6 +1090,7 @@ mod tests {
|
|||||||
.set_network(
|
.set_network(
|
||||||
id,
|
id,
|
||||||
NetworkRoutes {
|
NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: BroadcastPolicy {
|
broadcast: BroadcastPolicy {
|
||||||
enabled: broadcast_enabled,
|
enabled: broadcast_enabled,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -910,6 +1145,7 @@ mod tests {
|
|||||||
.set_network(
|
.set_network(
|
||||||
id,
|
id,
|
||||||
NetworkRoutes {
|
NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: BroadcastPolicy::default(),
|
broadcast: BroadcastPolicy::default(),
|
||||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||||
local: Some(addr(9)),
|
local: Some(addr(9)),
|
||||||
@@ -978,4 +1214,261 @@ mod tests {
|
|||||||
assert!(interface.broadcast_rules().is_none());
|
assert!(interface.broadcast_rules().is_none());
|
||||||
interface.remove().await;
|
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 broadcast;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod exit;
|
||||||
pub mod hostrules;
|
pub mod hostrules;
|
||||||
pub mod interface;
|
pub mod interface;
|
||||||
pub mod packet;
|
pub mod packet;
|
||||||
@@ -48,9 +49,10 @@ pub mod router;
|
|||||||
pub mod tun;
|
pub mod tun;
|
||||||
|
|
||||||
pub use config::{Cidr, DEFAULT_INTERFACE_PREFIX, MAX_INTERFACE_NAME_LEN, interface_name};
|
pub use config::{Cidr, DEFAULT_INTERFACE_PREFIX, MAX_INTERFACE_NAME_LEN, interface_name};
|
||||||
|
pub use exit::{ExitRulesReport, RuleSetReport};
|
||||||
pub use hostrules::{
|
pub use hostrules::{
|
||||||
BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, MockHostRules, RuleOutcome,
|
BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, ExitHostPlan, ExitHostReport,
|
||||||
UnsupportedHostRules,
|
ExitHostRules, MockExitRules, MockHostRules, RuleOutcome, UnsupportedHostRules,
|
||||||
};
|
};
|
||||||
pub use interface::{Counters, Interface, PacketCarrier, Rejected};
|
pub use interface::{Counters, Interface, PacketCarrier, Rejected};
|
||||||
pub use packet::IpHeader;
|
pub use packet::IpHeader;
|
||||||
@@ -59,7 +61,7 @@ pub use provision::{
|
|||||||
MockHost, MockProvisioner, Privilege, Provisioned, UnsupportedProvisioner, plan_changes,
|
MockHost, MockProvisioner, Privilege, Provisioned, UnsupportedProvisioner, plan_changes,
|
||||||
probe_net_admin,
|
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};
|
pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest, address_is_local};
|
||||||
|
|
||||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
#[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::address::{AddressAttribute, AddressMessage};
|
||||||
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkFlags, LinkInfo, LinkMessage};
|
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkFlags, LinkInfo, LinkMessage};
|
||||||
use rtnetlink::packet_route::route::RouteScope;
|
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::BoxFuture;
|
||||||
use crate::overlay::OverlayError;
|
use crate::overlay::OverlayError;
|
||||||
@@ -72,6 +73,11 @@ enum Command {
|
|||||||
SetBroadcastRoute(String, Ipv4Addr, Reply<()>),
|
SetBroadcastRoute(String, Ipv4Addr, Reply<()>),
|
||||||
/// Remove that route from this interface.
|
/// Remove that route from this interface.
|
||||||
DelBroadcastRoute(String, Reply<()>),
|
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 the thread.
|
||||||
Stop,
|
Stop,
|
||||||
}
|
}
|
||||||
@@ -203,6 +209,20 @@ impl RouteHandle {
|
|||||||
Command::DelBroadcastRoute(interface, reply)
|
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 {
|
impl Drop for NetlinkProvisioner {
|
||||||
@@ -324,7 +344,11 @@ fn netlink_thread(requests: mpsc::Receiver<Command>) {
|
|||||||
Command::Delete(_, reply)
|
Command::Delete(_, reply)
|
||||||
| Command::Configure(_, reply)
|
| Command::Configure(_, reply)
|
||||||
| Command::SetBroadcastRoute(_, _, 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)));
|
let _ = reply.send(Err(OverlayError::Unavailable(message)));
|
||||||
}
|
}
|
||||||
Command::Stop => return,
|
Command::Stop => return,
|
||||||
@@ -373,6 +397,22 @@ fn netlink_thread(requests: mpsc::Receiver<Command>) {
|
|||||||
});
|
});
|
||||||
let _ = reply.send(result);
|
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
|
.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> {
|
async fn configure_link(configure: &Configure) -> Result<(), OverlayError> {
|
||||||
let name = configure.name.as_str();
|
let name = configure.name.as_str();
|
||||||
with_netlink(|handle| async move {
|
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]
|
#[test]
|
||||||
fn an_interface_that_is_not_a_tun_is_reported_foreign() {
|
fn an_interface_that_is_not_a_tun_is_reported_foreign() {
|
||||||
let message = LinkMessage::default();
|
let message = LinkMessage::default();
|
||||||
|
|||||||
@@ -40,9 +40,21 @@ pub struct Route {
|
|||||||
pub peer: EndpointId,
|
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.
|
/// What one network contributes to the table.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct NetworkRoutes {
|
pub struct NetworkRoutes {
|
||||||
|
/// Exit-node settings of this network.
|
||||||
|
pub exit: ExitPolicy,
|
||||||
/// Local broadcast policy and authenticated willing recipients.
|
/// Local broadcast policy and authenticated willing recipients.
|
||||||
pub broadcast: BroadcastPolicy,
|
pub broadcast: BroadcastPolicy,
|
||||||
/// The range this network allocates from, once it has agreed one.
|
/// 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.
|
/// Recognizes limited and configured subnet broadcasts before unicast lookup.
|
||||||
pub fn is_broadcast(&self, destination: Ipv4Addr) -> bool {
|
pub fn is_broadcast(&self, destination: Ipv4Addr) -> bool {
|
||||||
self.broadcast.load().is_destination(destination)
|
self.broadcast.load().is_destination(destination)
|
||||||
@@ -181,6 +203,10 @@ impl RoutingTable {
|
|||||||
///
|
///
|
||||||
/// This agent's own address returns `None`: a packet for ourselves does
|
/// 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.
|
/// 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> {
|
pub fn route(&self, destination: Ipv4Addr) -> Option<Route> {
|
||||||
let networks = self.read();
|
let networks = self.read();
|
||||||
for (network, routes) in networks.iter() {
|
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.
|
/// 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 {
|
pub fn may_send_from(&self, network: NetworkId, peer: EndpointId, source: Ipv4Addr) -> bool {
|
||||||
self.read().get(&network).is_some_and(|routes| {
|
let networks = self.read();
|
||||||
routes
|
let Some(routes) = networks.get(&network) else {
|
||||||
.peers
|
return false;
|
||||||
.iter()
|
};
|
||||||
.any(|(address, holder)| *address == source && *holder == peer)
|
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.
|
/// 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.
|
/// Whether two ranges share any address.
|
||||||
fn ranges_overlap(one: Ipv4Range, other: Ipv4Range) -> bool {
|
fn ranges_overlap(one: Ipv4Range, other: Ipv4Range) -> bool {
|
||||||
// A range contains the other's base, or the other way round. With
|
// A range contains the other's base, or the other way round. With
|
||||||
@@ -308,6 +422,7 @@ mod tests {
|
|||||||
|
|
||||||
fn routes() -> NetworkRoutes {
|
fn routes() -> NetworkRoutes {
|
||||||
NetworkRoutes {
|
NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: Default::default(),
|
broadcast: Default::default(),
|
||||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||||
local: Some(addr(1)),
|
local: Some(addr(1)),
|
||||||
@@ -391,6 +506,7 @@ mod tests {
|
|||||||
.set_network(
|
.set_network(
|
||||||
second,
|
second,
|
||||||
NetworkRoutes {
|
NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: Default::default(),
|
broadcast: Default::default(),
|
||||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||||
@@ -420,6 +536,7 @@ mod tests {
|
|||||||
if a < b { (a, b) } else { (b, a) }
|
if a < b { (a, b) } else { (b, a) }
|
||||||
};
|
};
|
||||||
let mk = |enabled: bool, base: [u8; 4], local: Option<Ipv4Addr>| NetworkRoutes {
|
let mk = |enabled: bool, base: [u8; 4], local: Option<Ipv4Addr>| NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: BroadcastPolicy {
|
broadcast: BroadcastPolicy {
|
||||||
enabled,
|
enabled,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -495,6 +612,7 @@ mod tests {
|
|||||||
.set_network(
|
.set_network(
|
||||||
network("wide"),
|
network("wide"),
|
||||||
NetworkRoutes {
|
NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: Default::default(),
|
broadcast: Default::default(),
|
||||||
range: Some("10.0.0.0/8".parse().unwrap()),
|
range: Some("10.0.0.0/8".parse().unwrap()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -507,6 +625,7 @@ mod tests {
|
|||||||
.set_network(
|
.set_network(
|
||||||
network("narrow"),
|
network("narrow"),
|
||||||
NetworkRoutes {
|
NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: Default::default(),
|
broadcast: Default::default(),
|
||||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -549,6 +668,7 @@ mod tests {
|
|||||||
.set_network(
|
.set_network(
|
||||||
network("two"),
|
network("two"),
|
||||||
NetworkRoutes {
|
NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: Default::default(),
|
broadcast: Default::default(),
|
||||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||||
@@ -573,4 +693,233 @@ mod tests {
|
|||||||
assert_eq!(table.route(addr(2)), None);
|
assert_eq!(table.route(addr(2)), None);
|
||||||
assert_eq!(table.len(), 1, "the network is known, it just has nothing");
|
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
|
/// The version in the ALPN is the wire-compatibility version of the control
|
||||||
/// protocol. It is independent of the network identity scheme version, so
|
/// protocol. It is independent of the network identity scheme version, so
|
||||||
/// bumping it must not change any existing [`crate::NetworkId`].
|
/// 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.
|
/// 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;
|
pub const MAX_SIGNATURE_LEN: usize = 64;
|
||||||
|
|
||||||
/// Control protocol version carried inside the handshake.
|
/// 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.
|
/// First message of the handshake, sent by the initiator.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -75,6 +75,11 @@ pub struct AuthProof {
|
|||||||
pub struct Announcement {
|
pub struct Announcement {
|
||||||
/// Local opt-in to receive LAN broadcasts in this network.
|
/// Local opt-in to receive LAN broadcasts in this network.
|
||||||
pub broadcast: bool,
|
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.
|
/// Human-readable hostname. A mutable binding, not an identity.
|
||||||
pub hostname: String,
|
pub hostname: String,
|
||||||
/// Announced IP plugin capabilities. Opaque to the core.
|
/// Announced IP plugin capabilities. Opaque to the core.
|
||||||
|
|||||||
@@ -254,6 +254,18 @@ impl Storage {
|
|||||||
.await
|
.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.
|
/// Updates the auto-start flag of a network.
|
||||||
pub async fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
|
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))
|
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<()> {
|
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
||||||
self.with_state(move |state| {
|
self.with_state(move |state| {
|
||||||
state.remove_network(network_id)?;
|
state.remove_network(network_id)?;
|
||||||
state.forget_signed_records(network_id)
|
state.forget_signed_records(network_id)?;
|
||||||
|
state.forget_peer_hostnames(network_id)
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
self.with_cache((), move |cache| cache.forget_network(network_id))
|
self.with_cache((), move |cache| cache.forget_network(network_id))
|
||||||
@@ -283,6 +296,26 @@ impl Storage {
|
|||||||
.await
|
.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.
|
/// Records an address hint. Failures are non-fatal.
|
||||||
pub async fn record_hint(
|
pub async fn record_hint(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
|
|||||||
use crate::state::{RecordBody, SignedRecord};
|
use crate::state::{RecordBody, SignedRecord};
|
||||||
|
|
||||||
/// Schema version written by this build.
|
/// Schema version written by this build.
|
||||||
pub const SCHEMA_VERSION: i64 = 4;
|
pub const SCHEMA_VERSION: i64 = 6;
|
||||||
|
|
||||||
/// Key of the stored hostname setting.
|
/// Key of the stored hostname setting.
|
||||||
const SETTING_HOSTNAME: &str = "hostname";
|
const SETTING_HOSTNAME: &str = "hostname";
|
||||||
@@ -42,6 +42,10 @@ pub struct StoredNetwork {
|
|||||||
pub auto_start: bool,
|
pub auto_start: bool,
|
||||||
/// Local broadcast participation, enabled unless explicitly disabled.
|
/// Local broadcast participation, enabled unless explicitly disabled.
|
||||||
pub broadcast: bool,
|
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.
|
/// The mandatory state store.
|
||||||
@@ -191,13 +195,45 @@ impl StateStore {
|
|||||||
PRAGMA user_version = 4;
|
PRAGMA user_version = 4;
|
||||||
COMMIT;").map_err(|err| self.corrupt(format!("cannot migrate schema to 4: {err}")))?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirms the expected tables exist, so that a truncated or foreign
|
/// Confirms the expected tables exist, so that a truncated or foreign
|
||||||
/// database is reported rather than used.
|
/// database is reported rather than used.
|
||||||
fn verify_shape(&self) -> Result<()> {
|
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
|
let present: Option<String> = self
|
||||||
.conn
|
.conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -430,11 +466,56 @@ impl StateStore {
|
|||||||
Ok(())
|
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.
|
/// Lists every configured network.
|
||||||
pub fn list_networks(&self) -> Result<Vec<StoredNetwork>> {
|
pub fn list_networks(&self) -> Result<Vec<StoredNetwork>> {
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.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}")))?;
|
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
@@ -442,13 +523,21 @@ impl StateStore {
|
|||||||
let name: String = row.get(1)?;
|
let name: String = row.get(1)?;
|
||||||
let secret: Vec<u8> = row.get(2)?;
|
let secret: Vec<u8> = row.get(2)?;
|
||||||
let auto_start: i64 = row.get(3)?;
|
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}")))?;
|
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
||||||
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for row in rows {
|
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}")))?;
|
row.map_err(|err| Error::Storage(format!("cannot read network row: {err}")))?;
|
||||||
let id: [u8; 32] = id
|
let id: [u8; 32] = id
|
||||||
.as_slice()
|
.as_slice()
|
||||||
@@ -460,6 +549,8 @@ impl StateStore {
|
|||||||
secret: NetworkSecret::from_bytes(secret)?,
|
secret: NetworkSecret::from_bytes(secret)?,
|
||||||
auto_start,
|
auto_start,
|
||||||
broadcast,
|
broadcast,
|
||||||
|
exit_node,
|
||||||
|
exit_via: exit_via.and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
@@ -635,6 +726,61 @@ impl StateStore {
|
|||||||
Ok(())
|
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.
|
/// Reads an arbitrary setting.
|
||||||
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
||||||
self.conn
|
self.conn
|
||||||
@@ -698,6 +844,60 @@ mod broadcast_storage_tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::identity::NetworkKeys;
|
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]
|
#[test]
|
||||||
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
|
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -714,7 +914,12 @@ mod broadcast_storage_tests {
|
|||||||
store.set_hostname("old-host").unwrap();
|
store.set_hostname("old-host").unwrap();
|
||||||
store
|
store
|
||||||
.conn
|
.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();
|
.unwrap();
|
||||||
drop(store);
|
drop(store);
|
||||||
let store = StateStore::open(&path).unwrap();
|
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
|
This archive holds a **static** `tsunagi` binary (musl), so it runs on any
|
||||||
distribution — Arch, Alpine, NixOS, old glibc systems — with no shared-library
|
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
|
Creating the TUN interface needs `CAP_NET_ADMIN`, and configuring
|
||||||
systemd-resolved is decided by polkit (by user id, not capability). There are
|
systemd-resolved is decided by polkit. There are three ways to run it; pick one.
|
||||||
three ways to satisfy that; 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
|
```sh
|
||||||
sudo ./install.sh
|
sudo ./install.sh
|
||||||
sudo systemctl enable --now tsunagi
|
sudo systemctl enable --now tsunagi
|
||||||
```
|
```
|
||||||
|
|
||||||
The service runs the agent as root, so the interface, low ports and the
|
The service runs as an unprivileged `tsunagi` user with just `CAP_NET_ADMIN`
|
||||||
resolver all just work (root is not subject to polkit). Control it **as root**,
|
and `CAP_NET_BIND_SERVICE` (granted by systemd), not root. systemd creates its
|
||||||
so the client resolves the same state directory the daemon uses:
|
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
|
```sh
|
||||||
sudo tsunagi status
|
sudo tsunagi status
|
||||||
sudo tsunagi join <network-name> <tsn1…secret>
|
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
|
```sh
|
||||||
sudo install -m 0755 tsunagi /usr/local/bin/tsunagi
|
sudo install -m 0755 tsunagi /usr/local/bin/tsunagi
|
||||||
sudo setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsunagi
|
sudo setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsunagi
|
||||||
|
sudo usermod -aG tsunagi "$USER" # so the resolver works; log in again after
|
||||||
tsunagi up
|
tsunagi up
|
||||||
```
|
```
|
||||||
|
|
||||||
Nothing is left behind and nothing runs as root. The capability is lost on every
|
Nothing runs as root. The capability is lost on every rebuild or copy of the
|
||||||
rebuild or copy of the binary, so re-run `setcap` after replacing it. For the
|
binary, so re-run `setcap` after replacing it. The agent uses your per-user
|
||||||
local DNS resolver, run `tsunagi dns` once: it prints the exact polkit rule that
|
state directory and socket, so plain `tsunagi status` (no sudo) reaches it.
|
||||||
lets your user configure systemd-resolved, ready to paste.
|
|
||||||
|
|
||||||
## Without touching the OS
|
## Without touching the OS
|
||||||
|
|
||||||
|
|||||||
Vendored
+27
-19
@@ -1,10 +1,11 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
# Installs the tsunagi binary and, where systemd is present, a root service.
|
# Installs the static tsunagi binary and, where systemd is present, a service
|
||||||
# Run it as root (it writes under /usr/local and /etc):
|
# that runs the agent as a dedicated unprivileged "tsunagi" user. Run as root:
|
||||||
#
|
#
|
||||||
# sudo ./install.sh
|
# 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
|
set -eu
|
||||||
|
|
||||||
PREFIX="${PREFIX:-/usr/local}"
|
PREFIX="${PREFIX:-/usr/local}"
|
||||||
@@ -19,8 +20,23 @@ echo "Installing tsunagi to ${PREFIX}/bin"
|
|||||||
install -d "${PREFIX}/bin"
|
install -d "${PREFIX}/bin"
|
||||||
install -m 0755 "${here}/tsunagi" "${PREFIX}/bin/tsunagi"
|
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
|
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
|
install -m 0644 "${here}/tsunagi.service" /etc/systemd/system/tsunagi.service
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -29,7 +45,7 @@ Done. Start the agent with:
|
|||||||
|
|
||||||
sudo systemctl enable --now tsunagi
|
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 status
|
||||||
sudo tsunagi join <network-name> <tsn1…secret>
|
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:
|
Installed the binary; no systemd found, so no service was set up. Either:
|
||||||
|
|
||||||
* run it directly as root:
|
* 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
|
||||||
sudo tsunagi up
|
tsunagi up
|
||||||
|
(add yourself to the tsunagi group so the resolver works:
|
||||||
* or grant the capability and run it as your own user (no root at runtime):
|
sudo usermod -aG tsunagi "\$USER")
|
||||||
|
* or without touching the OS: tsunagi up --no-tun
|
||||||
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.
|
|
||||||
EOF
|
EOF
|
||||||
fi
|
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]
|
[Unit]
|
||||||
Description=tsunagi mesh agent
|
Description=tsunagi mesh agent
|
||||||
Documentation=https://github.com/Ultradesu/tsunagi
|
Documentation=https://github.com/house-of-vanity/tsunagi
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
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
|
ExecStart=/usr/local/bin/tsunagi up
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
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]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
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
|
- **Identity scheme**, `tsunagi-network-id-v1`. Frozen. Changing it creates a
|
||||||
different network space for the same name and secret.
|
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
|
Upgrading the crate or bumping the control protocol must never change an
|
||||||
existing `NetworkId`.
|
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,
|
end-to-end network latency or VPN throughput; encryption, fragmentation,
|
||||||
sockets, congestion and scheduling contribute separately.
|
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.
|
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
|
punch, the data connection goes through a relay, which then sees the volume
|
||||||
and timing of tunnelled traffic — though not its contents, which WireGuard
|
and timing of tunnelled traffic — though not its contents, which WireGuard
|
||||||
encrypted, nor the iroh layer's contents.
|
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
|
- **Overlay address squatting.** A member can mint many WireGuard keys and
|
||||||
therefore occupy many overlay addresses. It cannot pick which ones, but it
|
therefore occupy many overlay addresses. It cannot pick which ones, but it
|
||||||
can consume them and appear as many participants.
|
can consume them and appear as many participants.
|
||||||
|
|||||||
Reference in new issue
Block a user