Compare commits
87
Commits
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Windows: link the C runtime statically, so the agent and the tray start on a
|
||||
# machine that has no Visual C++ redistributable installed. The installer ships
|
||||
# nothing but our own files and Wintun.
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
@@ -0,0 +1,350 @@
|
||||
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
|
||||
# 32-bit ARM with hardware float (ARMv7, `v7l`): single-board and edge
|
||||
# devices. Statically linked like the others.
|
||||
- os: ubuntu-latest
|
||||
target: armv7-unknown-linux-musleabihf
|
||||
asset: tsunagi-linux-armv7
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
deb: true
|
||||
deb_arch: armhf
|
||||
pkg_arch: armv7h
|
||||
- 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 tsng
|
||||
else
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsng
|
||||
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="tsng"
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsng.exe"
|
||||
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
|
||||
|
||||
# The tray GUI alongside it, where it was built (Linux: x86_64 only).
|
||||
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"
|
||||
# The tray as an application bundle, for the Homebrew cask.
|
||||
bash dist/macos/build-app.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi-tray" \
|
||||
"$version" "$stage/Tsunagi.app"
|
||||
;;
|
||||
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
|
||||
|
||||
# The Windows installer: the agent as a service, Wintun, the tray and the
|
||||
# PATH entry in one setup program. Wintun is the signed library from
|
||||
# wintun.net, pinned by hash so a changed download fails the build.
|
||||
- name: Build the Windows installer
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$version = $env:GITHUB_REF_NAME.TrimStart('v')
|
||||
Invoke-WebRequest -Uri https://www.wintun.net/builds/wintun-0.14.1.zip -OutFile wintun.zip
|
||||
$hash = (Get-FileHash wintun.zip -Algorithm SHA256).Hash
|
||||
if ($hash -ne '07C256185D6EE3652E09FA55C0B673E2624B565E02C4B9091C79CA7D2F24EF51') {
|
||||
throw "wintun.zip has an unexpected hash: $hash"
|
||||
}
|
||||
Expand-Archive wintun.zip -DestinationPath wintun-dl
|
||||
choco install innosetup -y --no-progress
|
||||
$iscc = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'
|
||||
$stage = Join-Path $PWD 'dist/pkg/${{ matrix.asset }}'
|
||||
& $iscc "/DAppVersion=$version" "/DSourceDir=$stage" `
|
||||
"/DWintunDir=$(Join-Path $PWD 'wintun-dl/wintun/bin/amd64')" `
|
||||
"/O$PWD" dist/windows/tsunagi.iss
|
||||
if ($LASTEXITCODE -ne 0) { throw "ISCC failed with $LASTEXITCODE" }
|
||||
|
||||
# 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/tsng" \
|
||||
"${{ 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/tsng" \
|
||||
"${{ 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/tsng" \
|
||||
"${{ matrix.deb_arch }}" "$version" \
|
||||
"tsunagi-gui_${version}_${{ matrix.deb_arch }}.deb" "$tray"
|
||||
bash dist/arch/build-pkg.sh \
|
||||
"target/${{ matrix.target }}/release/tsng" \
|
||||
"${{ 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
|
||||
tsunagi-setup-*.exe
|
||||
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' -o -name 'tsunagi-setup-*.exe' \) -print0)
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,322 @@
|
||||
name: Release
|
||||
|
||||
# GitHub version of the release pipeline. The build half mirrors the Gitea one
|
||||
# in .gitea/workflows/release.yml (static musl Linux via cargo-zigbuild, native
|
||||
# macOS and Windows); only the publish half differs: it uses the current
|
||||
# artifact actions (Node 24) and fills the release with `gh`.
|
||||
#
|
||||
# Every job is gated on `github.server_url == 'https://github.com'`, so this file
|
||||
# runs only on GitHub. Gitea runs its own copy in .gitea/workflows (which pins
|
||||
# the older @v3 artifact actions it supports); should a Gitea instance also read
|
||||
# .github/workflows, the guard skips these jobs there so they never fail on the
|
||||
# artifact backend or `gh`.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_NET_RETRY: "10"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.asset }}
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
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
|
||||
# 32-bit ARM with hardware float (ARMv7, `v7l`): single-board and edge
|
||||
# devices. Statically linked like the others.
|
||||
- os: ubuntu-latest
|
||||
target: armv7-unknown-linux-musleabihf
|
||||
asset: tsunagi-linux-armv7
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
deb: true
|
||||
deb_arch: armhf
|
||||
pkg_arch: armv7h
|
||||
- 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@v5
|
||||
|
||||
- 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 tsng
|
||||
else
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsng
|
||||
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="tsng"
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsng.exe"
|
||||
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
|
||||
|
||||
# The tray GUI alongside it, where it was built (Linux: x86_64 only).
|
||||
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"
|
||||
# The tray as an application bundle, for the Homebrew cask.
|
||||
bash dist/macos/build-app.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi-tray" \
|
||||
"$version" "$stage/Tsunagi.app"
|
||||
;;
|
||||
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
|
||||
|
||||
# The Windows installer: the agent as a service, Wintun, the tray and the
|
||||
# PATH entry in one setup program. Wintun is the signed library from
|
||||
# wintun.net, pinned by hash so a changed download fails the build.
|
||||
- name: Build the Windows installer
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$version = $env:GITHUB_REF_NAME.TrimStart('v')
|
||||
Invoke-WebRequest -Uri https://www.wintun.net/builds/wintun-0.14.1.zip -OutFile wintun.zip
|
||||
$hash = (Get-FileHash wintun.zip -Algorithm SHA256).Hash
|
||||
if ($hash -ne '07C256185D6EE3652E09FA55C0B673E2624B565E02C4B9091C79CA7D2F24EF51') {
|
||||
throw "wintun.zip has an unexpected hash: $hash"
|
||||
}
|
||||
Expand-Archive wintun.zip -DestinationPath wintun-dl
|
||||
choco install innosetup -y --no-progress
|
||||
$iscc = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'
|
||||
$stage = Join-Path $PWD 'dist/pkg/${{ matrix.asset }}'
|
||||
& $iscc "/DAppVersion=$version" "/DSourceDir=$stage" `
|
||||
"/DWintunDir=$(Join-Path $PWD 'wintun-dl/wintun/bin/amd64')" `
|
||||
"/O$PWD" dist/windows/tsunagi.iss
|
||||
if ($LASTEXITCODE -ne 0) { throw "ISCC failed with $LASTEXITCODE" }
|
||||
|
||||
# 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/tsng" \
|
||||
"${{ 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/tsng" \
|
||||
"${{ 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/tsng" \
|
||||
"${{ matrix.deb_arch }}" "$version" \
|
||||
"tsunagi-gui_${version}_${{ matrix.deb_arch }}.deb" "$tray"
|
||||
bash dist/arch/build-pkg.sh \
|
||||
"target/${{ matrix.target }}/release/tsng" \
|
||||
"${{ matrix.pkg_arch }}" "$version" \
|
||||
"tsunagi-gui-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst" "$tray"
|
||||
|
||||
- name: Upload the build outputs as run artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: |
|
||||
${{ matrix.asset }}-*.*
|
||||
*.deb
|
||||
*.pkg.tar.zst
|
||||
tsunagi-setup-*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish release
|
||||
needs: build
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download every build artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Show what was collected
|
||||
run: find artifacts -type f
|
||||
|
||||
- name: Create the release and upload the archives
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# download-artifact nests each artifact under its own directory, so
|
||||
# the archives are artifacts/<asset>/<file>.
|
||||
mapfile -t files < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' -o -name 'tsunagi-setup-*.exe' \))
|
||||
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
|
||||
@@ -1 +1,2 @@
|
||||
/target
|
||||
/.claude
|
||||
@@ -8,17 +8,29 @@ before touching the code. Do not restate the full design here; follow the links.
|
||||
A proof-of-concept agent library for small private mesh networks. A user
|
||||
supplies a network name and one shared secret; agents derive the same network
|
||||
space independently, find each other, prove membership and exchange control
|
||||
messages. Scope and non-scope are in [README.md](README.md).
|
||||
messages. What it does and does not do is in [docs/architecture.md](docs/architecture.md) and [docs/threat-model.md](docs/threat-model.md).
|
||||
|
||||
There is no central server, no owner, no registration and no majority vote.
|
||||
Design accordingly: a majority is not a root of trust.
|
||||
|
||||
## User experience
|
||||
|
||||
User convenience is a core requirement. The CLI should feel logical and
|
||||
predictable to an experienced administrator: use consistent selectors and
|
||||
flags, sensible defaults, and actionable errors. Let users refer to configured
|
||||
networks by their exact name as well as by id or a unique id prefix; do not
|
||||
make them copy an opaque identifier when the stored name identifies one network.
|
||||
When a selector is ambiguous, list the matching names and ids and change
|
||||
nothing. Never guess which resource a destructive command should affect.
|
||||
Keep help text and examples in sync with behavior, and preserve existing
|
||||
scripts when improving the interactive workflow.
|
||||
|
||||
## Architectural boundaries
|
||||
|
||||
Keep these separate. Crossing them is the main thing to review for.
|
||||
|
||||
- **Control plane vs data plane.** The separation is **logical, not physical**.
|
||||
The control protocol in `crates/tsunagi/src/proto/` knows nothing about
|
||||
The control protocol in `crates/tsunagi-core/src/proto/` knows nothing about
|
||||
packets, and a protocol crate knows nothing about the control protocol;
|
||||
either can be replaced on its own. Both may ride on iroh — refusing to
|
||||
would throw away iroh's NAT traversal and force the data plane to
|
||||
@@ -45,14 +57,24 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
is why the authenticated transport does too.
|
||||
- **A relay carries bytes, never packets.** A datagram passed on for two
|
||||
other peers goes link in, link out: it is never written to the middle's
|
||||
interface, never decrypted there, and never relayed twice. Reachability
|
||||
that decides a route is first-hand and volatile — a live announcement
|
||||
over the control plane, never a signed record and never second-hand.
|
||||
interface or decrypted there. Multihop routing has a bounded hop limit;
|
||||
equal-cost next hops are chosen per flow. Reachability is first-hand and
|
||||
volatile: each authenticated member advertises its own direct links, of
|
||||
whatever kind. A link is a way to reach a neighbour and a protocol is an
|
||||
end-to-end tunnel to a peer; the two meet only in the envelope, so a
|
||||
tunnel never depends on which transports carry it. Build routing tables on topology changes, never per packet. Transit
|
||||
must not acquire a routing mutex or wait for a protocol/TUN reader.
|
||||
- **Broadcast fanout belongs at local IP ingress.** Participation is local to
|
||||
each network, enabled by default, persisted and advertised to authenticated
|
||||
peers. Resolve one source domain and target only its willing members. Remote
|
||||
delivery never triggers another fanout. Physical LAN exports must extend
|
||||
explicit ingress and source/destination admission policies; never bypass
|
||||
ownership checks or add broadcast flooding to encrypted transit.
|
||||
- **Plugins never learn reachability.** An `IpPlugin` is handed a `PacketLink`
|
||||
per peer and moves datagrams over it. Addresses, hole punching and relays
|
||||
belong to `crates/tsunagi/src/dataplane/transport/`. A plugin announcement says *who*, never
|
||||
belong to `crates/tsunagi-core/src/dataplane/transport/`. A plugin announcement says *who*, never
|
||||
*where*.
|
||||
- **The core never parses a plugin payload.** See `crates/tsunagi/src/dataplane/mod.rs`. Only
|
||||
- **The core never parses a plugin payload.** See `crates/tsunagi-core/src/dataplane/mod.rs`. Only
|
||||
`crates/tsunagi-wg-quic/src/announcement.rs` interprets `wg-quic` payloads, and
|
||||
only after bounding every field. A data plane failure must never stop the
|
||||
control plane.
|
||||
@@ -61,7 +83,7 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
inbound packets are dropped unless their source is the address derived for
|
||||
the peer that sent them. Never trust an address a peer announces.
|
||||
- **Signed state is the only durable agreement.** A fact that must survive a
|
||||
participant being away goes in `crates/tsunagi/src/state/` as a record signed by its
|
||||
participant being away goes in `crates/tsunagi-core/src/state/` as a record signed by its
|
||||
author, never in a session. Merging is deterministic, an older version never
|
||||
rolls back a newer one, and absence from a snapshot is not deletion. Never
|
||||
add a vote or a quorum: a majority is not a trust root here, and it would
|
||||
@@ -70,6 +92,12 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
transaction, **before** the record is announced.
|
||||
- **Plugins own their system objects.** A plugin creates and removes its own
|
||||
interface and nothing else. Never touch routing, DNS or firewall settings.
|
||||
The one exception is the system level, not a plugin: while a network's
|
||||
broadcast is on, `overlay::hostrules` may install exactly a
|
||||
`255.255.255.255/32` route and an inbound UDP firewall allowance (overlay range
|
||||
only) for the interface the agent owns. Both are tagged `tsunagi:<interface>`,
|
||||
reversible, removed on broadcast off, leave and shutdown, and computed from
|
||||
local state, never from a remote announcement.
|
||||
- **Device identity vs network identity.** The iroh endpoint id is the device's
|
||||
public key. `NetworkId` is derived from name + secret only. Never conflate
|
||||
them, and never let one change the other.
|
||||
@@ -89,7 +117,7 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
normal path *and* the important failures. See [docs/testing.md](docs/testing.md).
|
||||
Do not add tests for getters or to move a coverage number.
|
||||
2. **Never change `IDENTITY_SCHEME`, the derivation labels, or the transcript
|
||||
encoding** in `crates/tsunagi/src/identity/network.rs` and `crates/tsunagi/src/proto/handshake.rs` without
|
||||
encoding** in `crates/tsunagi-core/src/identity/network.rs` and `crates/tsunagi-core/src/proto/handshake.rs` without
|
||||
treating it as an incompatible protocol change. Bumping the crate version or
|
||||
the control protocol version must not change an existing `NetworkId`.
|
||||
3. **Secrets never leak.** Not into logs, not into `Debug`, not into status
|
||||
@@ -100,7 +128,7 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
the library; tests opt out explicitly at the top of each file.
|
||||
5. **Bounds before allocation.** Frame lengths are checked against
|
||||
`Limits::max_frame_len` before a buffer is allocated. Every string, list and
|
||||
queue has a limit in `crates/tsunagi/src/config.rs`.
|
||||
queue has a limit in `crates/tsunagi-core/src/config.rs`.
|
||||
6. **Failure is contained.** A bad signature, wrong secret, malformed packet or
|
||||
unknown version rejects one message or one session. It never stops another
|
||||
network and never stops the agent. There is no irreversible global error
|
||||
@@ -117,24 +145,25 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
up shutdown. Wind tasks down with a grace period and then abort.
|
||||
10. **Nothing from a remote announcement becomes a shell command, a filesystem
|
||||
path or an OS setting.** The agent never touches interfaces or OS settings
|
||||
it did not create.
|
||||
it did not create (the tagged broadcast host rules above are for its own
|
||||
interface).
|
||||
|
||||
## Where things live
|
||||
|
||||
| path | responsibility |
|
||||
|---------------------|----------------|
|
||||
| `crates/tsunagi/src/identity/` | device identity; deterministic network space identity and derived keys |
|
||||
| `crates/tsunagi/src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
|
||||
| `crates/tsunagi/src/discovery.rs` | candidate sources; test and static backends |
|
||||
| `crates/tsunagi/src/proto/` | framing, message formats, membership handshake |
|
||||
| `crates/tsunagi/src/net.rs` | iroh endpoint adapter and observability snapshots |
|
||||
| `crates/tsunagi/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
|
||||
| `crates/tsunagi/src/state/` | signed records that outlive a session, their merge rules and address allocation |
|
||||
| `crates/tsunagi/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks |
|
||||
| `crates/tsunagi/src/dataplane/` | the protocol contract and the authenticated packet transport |
|
||||
| `crates/tsunagi/src/dns/` | the DNS view of a network: zone, server, resolver publication |
|
||||
| `crates/tsunagi-wg-quic/` | the `wg-quic` protocol: its keys, its announcement, its tunnels |
|
||||
| `crates/tsunagi-cli/` | the command line agent; the only place that owns a runtime, a logger and signals |
|
||||
| `crates/tsunagi-core/src/identity/` | device identity; deterministic network space identity and derived keys |
|
||||
| `crates/tsunagi-core/src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
|
||||
| `crates/tsunagi-core/src/discovery.rs` | candidate sources; test and static backends |
|
||||
| `crates/tsunagi-core/src/proto/` | framing, message formats, membership handshake |
|
||||
| `crates/tsunagi-core/src/net.rs` | iroh endpoint adapter and observability snapshots |
|
||||
| `crates/tsunagi-core/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
|
||||
| `crates/tsunagi-core/src/state/` | signed records that outlive a session, their merge rules and address allocation |
|
||||
| `crates/tsunagi-core/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks, broadcast host rules |
|
||||
| `crates/tsunagi-core/src/dataplane/` | the protocol contract and the authenticated packet transport |
|
||||
| `crates/tsunagi-core/src/dns/` | the DNS view of a network: zone, server, resolver publication |
|
||||
| `crates/tsunagi-wg-quic/` | the single internal WireGuard cipher, keys and opaque announcement; historical crate name |
|
||||
| `crates/tsunagi/` | the command line agent, built as `tsng`; the only place that owns a runtime, a logger and signals |
|
||||
| `tests/` | integration tests; `tests/common/` is the shared harness |
|
||||
|
||||
Add abstractions only at real substitution or testing boundaries. Do not add a
|
||||
@@ -166,7 +195,26 @@ kind.
|
||||
- Running several library instances in one process is not a test of several
|
||||
system processes; do not describe it as one.
|
||||
|
||||
## Before you open a change
|
||||
## Commit every completed change
|
||||
|
||||
Agents must create a Git commit before finishing any task that changes this
|
||||
repository. Do not leave the user to make the commit. Inspect recent commit
|
||||
messages and follow their style: a short English subject stating the change,
|
||||
then a useful body explaining the problem, final behavior, architectural
|
||||
decisions, compatibility/migration effects, and checks actually performed.
|
||||
Write enough context for a future maintainer or LLM to understand why the code
|
||||
exists. Record failed or skipped checks honestly; never claim unrun tests.
|
||||
|
||||
Before committing, inspect the diff and stage only this task's changes. Preserve
|
||||
unrelated user changes and never commit secrets or generated build artifacts.
|
||||
If Git has no effective author name or email, set only the missing repository-
|
||||
local values to `AB` and `ab@hexor.cy`. Do not override an existing identity or
|
||||
change global Git configuration. Report the resulting commit hash.
|
||||
|
||||
Version bumps, release tags, and pushing commits/tags belong to the user unless
|
||||
they explicitly ask the agent to do them. Read-only tasks need no empty commit.
|
||||
|
||||
## Required validation
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
|
||||
Generated
+3241
-105
File diff suppressed because it is too large.
Load diff
+12
-4
@@ -4,16 +4,24 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*"]
|
||||
exclude = ["vendor/mainline", "vendor/iroh"]
|
||||
|
||||
# Mainline 8.0.0 treats Windows UDP read timeouts as socket failures.
|
||||
# Keep the small receive-loop correction reproducible until an upstream fix.
|
||||
[patch.crates-io]
|
||||
mainline = { path = "vendor/mainline" }
|
||||
# Backport bounded path retries until an iroh release includes the OOM fix.
|
||||
iroh = { path = "vendor/iroh" }
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
rust-version = "1.91"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/tsunagi-net/tsunagi"
|
||||
license = "WTFPL"
|
||||
repository = "https://github.com/house-of-vanity/tsunagi"
|
||||
|
||||
[workspace.lints.rust]
|
||||
missing_docs = "warn"
|
||||
unsafe_code = "forbid"
|
||||
unsafe_code = "deny"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
unwrap_used = "warn"
|
||||
@@ -21,7 +29,7 @@ expect_used = "warn"
|
||||
panic = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
iroh = { version = "1.2", default-features = false, features = ["tls-ring"] }
|
||||
iroh = { version = "1.2", default-features = false, features = ["tls-ring", "portmapper"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
|
||||
|
||||
@@ -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.
|
||||
@@ -1,719 +1,259 @@
|
||||
# tsunagi
|
||||
|
||||
A proof-of-concept Rust library for **small private mesh networks** — a handful
|
||||
of friends, home machines, a few servers. Units to dozens of participants, not
|
||||
thousands.
|
||||
|
||||
The end user configures exactly two things:
|
||||
|
||||
```text
|
||||
network_name
|
||||
secret # one shared secret; "password" and "secret" mean the same value
|
||||
```
|
||||
|
||||
From those, every agent independently derives the same network space. There is
|
||||
no central server, no network owner with special powers, no registration and no
|
||||
majority vote. Anyone who knows the parameters can join; nobody has to trust
|
||||
anybody else.
|
||||
|
||||
## What this proof of concept actually does
|
||||
|
||||
A working library with **real iroh connections** and integration tests:
|
||||
|
||||
- persistent device identity stored in SQLite, stable across restarts;
|
||||
- several independent networks at once in one agent;
|
||||
- deterministic network identity derived from name + secret;
|
||||
- candidates supplied by a replaceable discovery component;
|
||||
- automatic first contact through the public Mainline DHT (BEP44);
|
||||
- real iroh connections plus an explicit mutual proof of network membership;
|
||||
- a small versioned control protocol: handshake, hostname/capability
|
||||
announcement, ping/pong;
|
||||
- automatic reconnect with bounded exponential backoff and jitter;
|
||||
- status snapshots, an event stream and honest diagnostics;
|
||||
- configuration restored after a restart;
|
||||
- correct behaviour when the disposable cache is missing or corrupt;
|
||||
- a **WireGuard data plane**, in userspace: its own key per network, an IPv6
|
||||
overlay with deterministically derived addresses and optional IPv4, real
|
||||
tunnels carried over iroh, and address ownership enforced rather than
|
||||
believed;
|
||||
- a **command line agent**, `tsunagi`, with a local control socket.
|
||||
|
||||
### What it deliberately does **not** do
|
||||
|
||||
Not implemented, and not pretended to be: DNS, routing through
|
||||
intermediate participants, a full CRDT, dynamically loaded plugins, a system
|
||||
service, a complete CLI, or a local control socket. Snapshot synchronisation
|
||||
and signed revocations are designed for but not implemented — see
|
||||
[docs/sync-model.md](docs/sync-model.md). The WireGuard plugin's own limits,
|
||||
including that its system backend is Linux-only, are in
|
||||
[docs/wireguard.md](docs/wireguard.md#limits-and-future-work).
|
||||
|
||||
**Control and data are separated logically, not physically.** Both ride on
|
||||
iroh, on different ALPNs and different connections, so the data plane inherits
|
||||
iroh's hole punching and relay fallback instead of reimplementing them — while
|
||||
the control protocol still knows nothing about packets and can keep a different
|
||||
transport underneath it later. Filtering user traffic remains the operating
|
||||
system's and the user's responsibility, not this library's.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rust 1.91 or newer (iroh 1.2 requires it), edition 2024. Pinned dependencies
|
||||
in `Cargo.lock`.
|
||||
- No internet, no public DHT, no public relay, no administrator rights and no changes
|
||||
to OS network settings are needed to build or test.
|
||||
- WireGuard runs in userspace (boringtun): **no kernel module and no `wg`
|
||||
tool**. Only creating a real network interface needs `CAP_NET_ADMIN`, and
|
||||
`--no-tun` skips even that.
|
||||
|
||||
## Trying it on two machines
|
||||
|
||||
On the first machine:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
./target/release/tsunagi status # this device, the agent, and this host
|
||||
./target/release/tsunagi network # the networks this device belongs to
|
||||
```
|
||||
|
||||
Two commands do the work, and they are separate on purpose: `up` runs the
|
||||
agent — one per device, for as long as you want it — and `join` decides
|
||||
what it belongs to, at any time, while it runs.
|
||||
|
||||
```bash
|
||||
# Terminal one: the agent. It prints its endpoint id and then serves.
|
||||
./target/release/tsunagi up
|
||||
|
||||
# Terminal two: make a network. With no --secret it invents one and prints
|
||||
# it, along with the line to send the other machine.
|
||||
./target/release/tsunagi join --network lab
|
||||
```
|
||||
|
||||
On the second machine, start its agent and join the same network with the
|
||||
secret that was printed:
|
||||
|
||||
```bash
|
||||
./target/release/tsunagi up
|
||||
./target/release/tsunagi join --network lab --secret tsn1...
|
||||
```
|
||||
|
||||
**Name and secret are enough for the first meeting.** Mainline DHT discovery
|
||||
is enabled by default. Each active network publishes this device's endpoint
|
||||
and searches until the first authenticated connection. While connected it
|
||||
only republishes; after 60 seconds without any authenticated connection it
|
||||
searches again. Publication is approximately every five minutes, and records
|
||||
older than 15 minutes are ignored. DHT storage nodes may retain them longer.
|
||||
|
||||
Use `--no-dht` to disable DHT, or `--peer <endpoint-id>` for optional manual
|
||||
bootstrap. `--reach local` disables public DHT regardless of the DHT flag.
|
||||
Endpoint records are public, signed, and contain no network secret. Discovery
|
||||
is not authentication: every connection must still prove network membership.
|
||||
Details and failure behavior are in [docs/mainline-dht.md](docs/mainline-dht.md).
|
||||
|
||||
**One introduction is enough for the whole network.** Members tell each
|
||||
other about the members they know, and every author of a signed record is
|
||||
somebody to try, so a device pointed at one member ends up talking to all
|
||||
of them rather than to the one that happened to be on its command line.
|
||||
What travels is a candidate — an address somebody has seen — and it is
|
||||
authenticated by the handshake like any other; being introduced grants
|
||||
nothing. It is deliberately the same shape a lookup in a distributed hash
|
||||
table would return, so that is a source to add beside this one rather than
|
||||
a redesign.
|
||||
|
||||
An agent with nobody to contact says so in `status` rather than sitting
|
||||
there looking patient.
|
||||
|
||||
Within a few seconds both print something like:
|
||||
|
||||
```text
|
||||
+ peer b47c958462 connected over direct rtt=Some(4.5ms)
|
||||
+ data link to b47c958462 for wg-quic: direct via 192.0.2.7:41234, datagram 1382
|
||||
|
||||
--- status ---
|
||||
control: 1 peer(s), 0 dial failure(s), 0 handshake failure(s)
|
||||
wg-quic: tsun0 on 10.13.37.69/24 mtu 1280, 1/1 tunnel(s) established
|
||||
4jO4kx9Z 10.13.37.237 handshake 3s ago tx=0 rx=0 dropped=0 path=direct via 192.0.2.7:41234
|
||||
```
|
||||
|
||||
`1/1 tunnel(s) established` means a real WireGuard handshake completed.
|
||||
|
||||
## Checking that it works
|
||||
|
||||
From another shell on either machine:
|
||||
|
||||
```bash
|
||||
tsunagi status
|
||||
```
|
||||
|
||||
```text
|
||||
endpoint 7d76ccbbc21bf30767e14422c0494740a2cecc02aa9f82d5b8d57bdae350e7fc
|
||||
hostname tsunagi-7d76ccbbc2
|
||||
bound 0.0.0.0:41641
|
||||
|
||||
network lab (z2o4qwrvnj3zb6st2aoqg4abf342j662q2ujttqrsmz22argk2ba) active
|
||||
peer b345d5271b tsunagi-b345d5271b Direct rtt 24ms
|
||||
overlay tsunz2o4qwrvnj3 fd09:…:c1c6/64 and 100.110.49.177 mtu 1280 1/1 tunnel(s) up
|
||||
SDsEb/WF fd09:…:c4b / 100.65.243.53 handshake 4s ago tx 0 rx 0 Direct via Ip(…)
|
||||
```
|
||||
|
||||
`1/1 tunnel(s) up` and a recent handshake mean the tunnel is live. Then send
|
||||
real traffic to the peer's overlay address:
|
||||
|
||||
```bash
|
||||
ping6 fd09:…:c4b # or
|
||||
ping 100.65.243.53
|
||||
```
|
||||
|
||||
`tx` and `rx` in the status should start moving.
|
||||
|
||||
**Addresses are allocated and then remembered.** The default range is
|
||||
`10.13.37.0/24`; the first member to join settles it and later members adopt
|
||||
what they find, so `--ipv4-range` only matters for whoever starts the network:
|
||||
|
||||
```bash
|
||||
tsunagi up --ipv4-range 10.44.0.0/16
|
||||
tsunagi up --ipv4-range none # no data plane at all
|
||||
```
|
||||
|
||||
One agent has one interface, so two of its networks cannot both use that
|
||||
range. The second takes the range **derived from its own network id**: not
|
||||
picked locally — every member derives the same one from something they all
|
||||
already have — so it is an agreement rather than a guess, and a device in
|
||||
several networks gets an address in each.
|
||||
|
||||
An address is claimed with a record signed by that member's persistent device
|
||||
key, stored, and merged between every replica. A member that disappears for a
|
||||
month comes back to the same address, because the claim outlived the session.
|
||||
No vote is involved — see
|
||||
[docs/wireguard.md](docs/wireguard.md#ipv4-allocated-signed-and-kept) and
|
||||
[docs/sync-model.md](docs/sync-model.md).
|
||||
|
||||
Because the address is allocated at run time rather than derived, it is not
|
||||
known until the agent has started and agreed with its peers. The agent then
|
||||
assigns it to the interface itself.
|
||||
|
||||
## Levels
|
||||
|
||||
The command line is split the way the design is. A bare `tsunagi up` needs
|
||||
only a network name and a secret; everything else sits under the level it
|
||||
belongs to, which `--help` shows as two sections:
|
||||
|
||||
* **System** — what the agent itself does: how it reaches peers (`--reach`),
|
||||
the one overlay interface it owns (`--interface`, `--mtu`, `--no-tun`), the
|
||||
address range (`--ipv4-range`), and the local resolver (`--no-dns` to disable).
|
||||
* **Transport** — which protocols carry packets (`--protocol`, a list) and
|
||||
their own settings (`-o key=value`, or `-o protocol:key=value`).
|
||||
|
||||
```bash
|
||||
tsunagi protocols # what this build can carry packets with
|
||||
```
|
||||
|
||||
```text
|
||||
wg-quic (wire version 4)
|
||||
what WireGuard's cryptography carried in iroh's QUIC datagrams
|
||||
-o keepalive=SECONDS keeps a tunnel and its link warm through a NAT
|
||||
-o mtu=BYTES largest packet a tunnel will carry, at least 576
|
||||
```
|
||||
|
||||
Each protocol declares its own settings, so the agent can list them without
|
||||
knowing anything about the protocol, and a setting no selected protocol takes
|
||||
is refused rather than ignored. `--protocol none` runs the control plane by
|
||||
itself.
|
||||
|
||||
A pair of peers uses a protocol they both have **at the same wire version**.
|
||||
That is not the software version: two peers on different builds carry traffic
|
||||
for each other for as long as the bytes between them have not changed. A peer
|
||||
with nothing in common keeps its control plane — messages and signed state
|
||||
still flow — and simply has no data plane, which `tsunagi status` shows as a
|
||||
session with no agreed protocol.
|
||||
|
||||
## Names
|
||||
|
||||
DNS is enabled by default and serves a local zone for **every network this device is in**,
|
||||
each named after the network, so members can be reached by name instead of
|
||||
by address:
|
||||
|
||||
```bash
|
||||
tsunagi up # DNS is already enabled
|
||||
dig @127.0.0.1 -p 5354 music.lab
|
||||
```
|
||||
|
||||
An explicit choice is remembered across restarts. `--no-dns` or `tsunagi dns off`
|
||||
disables it; `--dns` or `tsunagi dns on` enables it again. Existing saved opt-outs
|
||||
remain respected. The `dns on` and `dns off` commands also take effect immediately
|
||||
on a running agent. `--dns` and `--no-dns` cannot be combined.
|
||||
|
||||
The zone of a network is its name — `--dns-zone` is gone, because with
|
||||
several networks there is no single zone to name. A network name may contain
|
||||
dots, so `--network lab.internal` is how you get `music.lab.internal`.
|
||||
|
||||
Names come from signed state, which is the point: **a member that is
|
||||
switched off still resolves**, because its claim outlived the session.
|
||||
|
||||
The answers are IPv4 addresses, because that is what the overlay is. The
|
||||
*questions* are taken on `127.0.0.1` and `[::1]`, over UDP and TCP, so a
|
||||
resolver reaches it over whichever family it uses; both are published to the
|
||||
system resolver together. Loopback and nothing else: the zones are a view
|
||||
for the host running the agent, and binding an overlay address would put
|
||||
them in front of the whole mesh — an agent in two networks would then answer
|
||||
one network's questions about the other's names.
|
||||
|
||||
The zone is the network name, and it is yours to choose. A name that
|
||||
shadows a real public domain is reported and then used: a network called
|
||||
`ru` warns that every public `.ru` name becomes unreachable from this host,
|
||||
and then does it. Anything that collides with nothing — which is most names
|
||||
— is said nothing about, because a warning that fires on every private name
|
||||
anybody picks is how people learn to ignore warnings.
|
||||
|
||||
On Linux the agent tells systemd-resolved to send questions for that suffix
|
||||
here, over D-Bus, scoped to the overlay interface and as a *routing* domain
|
||||
so it never becomes the resolver for anything else. resolved drops the whole
|
||||
setting when the interface goes, and the interface goes with the agent.
|
||||
|
||||
Only an interface the agent created, though. Under `--no-tun` there is no
|
||||
host interface at all, and the agent says so and serves the zone on
|
||||
loopback rather than configuring whatever else on the host happens to share
|
||||
the name.
|
||||
|
||||
That last step needs permission that `CAP_NET_ADMIN` does not give:
|
||||
systemd-resolved asks polkit, and polkit decides by **user**, not by
|
||||
capability, so there is no way for the agent to arrange it from inside. On
|
||||
a desktop the refusal reads `Interactive authentication required`.
|
||||
|
||||
Running as a system service is enough. Otherwise the agent prints the rule
|
||||
that grants it — the four actions it calls and nothing else — ready to
|
||||
paste:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/polkit-1/rules.d/50-tsunagi-resolved.rules > /dev/null <<'RULE'
|
||||
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.user == "YOUR-USER") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
RULE
|
||||
```
|
||||
|
||||
**Without it the server still runs** — `tsunagi status` prints where it is
|
||||
listening and the exact `dig` line — so the automatic part is missing, not
|
||||
the feature. The refusal is said once rather than on every pass, and retried
|
||||
slowly, because nothing but a person will change it.
|
||||
|
||||
The server is authoritative for its zone and nothing else. No recursion, no
|
||||
forwarding, no cache: pointing a resolver at it can never make it a route to
|
||||
the outside.
|
||||
|
||||
## Privileges
|
||||
|
||||
On Linux the agent **manages its own overlay interface**. It creates the TUN
|
||||
interface, sets the MTU, brings it up and assigns both overlay addresses, all
|
||||
over netlink in process — no `ip` invocation, no shell, nothing that a
|
||||
remote peer could influence.
|
||||
|
||||
That needs `CAP_NET_ADMIN`, granted once:
|
||||
|
||||
```bash
|
||||
sudo setcap cap_net_admin+p /usr/local/bin/tsunagi
|
||||
```
|
||||
|
||||
`+p` rather than `+ep`: the capability is then *permitted* but not
|
||||
*effective*, and the agent raises it only around the handful of netlink calls
|
||||
that need it — a few milliseconds at startup, and again if its address
|
||||
allocation changes. Everything else, including every byte from the network,
|
||||
is handled with it lowered. `+ep` works too; the agent lowers it on the way
|
||||
in.
|
||||
|
||||
`tsunagi status` says which of these applies on the host it runs on, along
|
||||
with what the agent is doing. It grades each finding: **ok** for what works,
|
||||
**warn** for what the agent runs without and you can fix from the line it
|
||||
prints, **FAIL** for what it cannot work around. The words carry the grade as
|
||||
well as the colour, so the report reads the same piped to a file or on a
|
||||
terminal without colour, and it honours `NO_COLOR`.
|
||||
|
||||
Members are listed online first, then the ones that are away. A member that
|
||||
is away is reported plainly rather than flagged: in a mesh of laptops it is
|
||||
the ordinary condition, not a fault. The signed state is what makes that
|
||||
sayable — it remembers who belongs while they are gone, so the report can say
|
||||
"offline, 10.13.37.99 still reserved for it" instead of leaving a
|
||||
dial-failure counter to imply it. Counters are history and are never graded:
|
||||
a peer that left and came back should not leave the report looking broken.
|
||||
|
||||
The other two commands split along the line the system itself draws. `id` is
|
||||
this **device**: the key it signs with and the name it answers to. `network`
|
||||
is what it **belongs to**: which networks, their secrets, joining and
|
||||
leaving. A device outlives every network it is in, and a network outlives
|
||||
any device in it, so a command that mixed them had to be read twice.
|
||||
|
||||
Every item takes the same shape, so there is nothing to remember: name it to
|
||||
see it, name it with a value to change it.
|
||||
|
||||
```
|
||||
tsunagi id everything about this device
|
||||
tsunagi id hostname the name it answers to
|
||||
tsunagi id hostname mango change it
|
||||
tsunagi id key the key it signs with
|
||||
tsunagi id key rotate replace that key
|
||||
|
||||
tsunagi network the networks this device belongs to
|
||||
tsunagi network join -n lab -s tsn1… join one; adds it to a running agent
|
||||
tsunagi network join -n lab resume one this device has, or make it on the spot
|
||||
tsunagi network stop <id> stop serving it, keeping everything
|
||||
tsunagi network start <id> serve it again, from where it left off
|
||||
tsunagi network leave <id> give up the address and name, then forget it
|
||||
tsunagi network secret the secret of each joined network
|
||||
tsunagi network secret <id> just that one, for copying
|
||||
tsunagi network secret generate a fresh secret for a network that does not exist yet
|
||||
|
||||
tsunagi dns whether the local resolver is serving, and what
|
||||
tsunagi dns on start it, now and after every restart
|
||||
tsunagi dns off stop it, now and after every restart
|
||||
```
|
||||
|
||||
**A network without a secret makes one.** `tsunagi join --network lab`
|
||||
resolves a bare name in the obvious way: if this device is already in
|
||||
exactly one network called `lab`, that one — so the name alone resumes what
|
||||
you have; if it is in none, a fresh random secret, printed in full with the
|
||||
line to send the other machine:
|
||||
|
||||
```
|
||||
joined `lab` (k2on43wadbi5x267vp6z3ogkm7nbjedfdoxyauxhtxhwqrprylba)
|
||||
<p align="center">
|
||||
<img src="dist/linux/tsunagi.svg" width="96" alt="">
|
||||
</p>
|
||||
|
||||
<h1 align="center">tsunagi</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>A mesh VPN with no server.</b><br>
|
||||
Pick a network name, share a secret, and your machines are on one private network.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/house-of-vanity/tsunagi/releases/latest"><img alt="release" src="https://img.shields.io/github/v/release/house-of-vanity/tsunagi?include_prereleases"></a>
|
||||
<a href="https://github.com/house-of-vanity/tsunagi/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/house-of-vanity/tsunagi/actions/workflows/ci.yml/badge.svg"></a>
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-WTFPL-blue"></a>
|
||||
</p>
|
||||
|
||||
```console
|
||||
laptop$ tsng join -n home
|
||||
joined `home`
|
||||
secret tsn1u7c…
|
||||
|
||||
Run this on the other machine:
|
||||
server$ tsng join -n home -s tsn1u7c…
|
||||
server$ tsng id hostname server
|
||||
|
||||
tsunagi join --network lab --secret tsn1u7c…
|
||||
|
||||
Its agent has to be running. If it is not:
|
||||
|
||||
tsunagi up --peer 91e83a6e2b7a…
|
||||
laptop$ ssh server.home
|
||||
```
|
||||
|
||||
Two networks of one name is a thing that happens — a mistyped secret makes
|
||||
one — so joining says whether the network was already here, and warns when
|
||||
another configured network answers to the same name. A name is a label and
|
||||
the id is the identity.
|
||||
No account, no coordination server, no admin console, no keys to approve.
|
||||
Anyone who knows the **name** and the **secret** is in. Nobody else can find
|
||||
the network, let alone enter it.
|
||||
|
||||
That is the ad-hoc case: one person makes a network and sends the command
|
||||
round. The secret is printed *only* when the agent invented it — there is
|
||||
nowhere else to read it from — and never when it was supplied, because then
|
||||
it is already yours. Two networks of one name and no secret is the one case
|
||||
with no answer, and it says so instead of choosing.
|
||||
## Why
|
||||
|
||||
A secret is printed by `network secret` and nowhere else — not by `id`, not
|
||||
by `status`, not in a log, a `Debug` rendering or anything sent to a peer.
|
||||
Asking for it is deliberate, because these reports get pasted into chats.
|
||||
Tailscale, Headscale and ZeroTier are great, but each needs a control server:
|
||||
theirs, or one you have to run and keep alive. tsunagi doesn't. Machines find
|
||||
each other through the public BitTorrent DHT, connect directly (hole punching,
|
||||
with relay fallback), and keep the network state among themselves.
|
||||
|
||||
**`up` takes no network at all** if you would rather decide later: it
|
||||
brings up the agent and whatever it is already configured for, and waits.
|
||||
That is the shape of a daemon in one terminal and `tsunagi network join`
|
||||
in another.
|
||||
| | Tailscale | Headscale | ZeroTier | **tsunagi** |
|
||||
| ---------------------- | ------------------ | ---------------- | ------------------- | ------------------- |
|
||||
| Control server | hosted by vendor | you run it | hosted or self-run | **none** |
|
||||
| Account / sign-in | yes | yes | yes | **no** |
|
||||
| To join a network | sign in, get approved | pre-auth key | network ID, approval | **name + secret** |
|
||||
| Direct connections | yes | yes | yes | yes |
|
||||
| Exit nodes | yes | yes | via routes | yes |
|
||||
|
||||
`network join` is also the answer to a question `up` cannot: a state
|
||||
directory belongs to one live agent, so a second `tsunagi up` cannot add a
|
||||
network to the one already running. This adds it over the control socket and
|
||||
it starts at once. With no agent running it is written to the configuration
|
||||
and starts with the next `up`.
|
||||
## Features
|
||||
|
||||
The name is part of the signed state, so changing it revokes the previous
|
||||
one: there is one record per author, a new version replaces the whole claim,
|
||||
and no replica can keep the old name standing. Changing it while the agent
|
||||
runs goes through the agent, which republishes and tells its peers straight
|
||||
away.
|
||||
- **Two things to know:** a network name and a secret. A secret is generated for you when you make a network.
|
||||
- **Direct, encrypted links** between members (WireGuard cryptography), with relay fallback behind strict NATs.
|
||||
- **Names for machines:** `server.home` just works, even while `server` is switched off. A name belongs to whoever claimed it first; a later device with the same name keeps working by address and is told so.
|
||||
- **Exit nodes:** send all your internet traffic through any member that offers one.
|
||||
- **Multi-hop:** members that can't reach each other directly are routed through a member that can, over whatever kind of link each hop has. A member reached that way is online, and shown as relayed; traffic moves to a direct link by itself the moment one comes up.
|
||||
- **LAN games:** UDP broadcasts are relayed, so "LAN" lobbies show up across the mesh.
|
||||
- **Several networks at once** on one device.
|
||||
- **Tray app** for Windows, macOS and Linux; a command line for everything.
|
||||
|
||||
Replacing the signing key makes this device a different member, and it loses
|
||||
the address and name the old key held — nothing can sign on a retired key's
|
||||
behalf, and by design there is no authority that could overrule an author. So
|
||||
the outgoing key signs a release for every network on its way out, which
|
||||
frees them for whoever wants them next, and the whole thing commits at once.
|
||||
## Install
|
||||
|
||||
`status` and `id` both prefer a running agent, which is live and
|
||||
authoritative, and fall back to reading the state store when there is none.
|
||||
Reading takes no directory lock, so neither has to wait for the agent it is
|
||||
asking about — nor does either need one to be running.
|
||||
Grab a build from the [latest release](https://github.com/house-of-vanity/tsunagi/releases/latest).
|
||||
|
||||
### It cleans up after itself
|
||||
| Platform | How |
|
||||
| --------------------- | -------------------------------------------------------------------------------- |
|
||||
| **Windows** | Run `tsunagi-setup-<version>-x86_64.exe`. Installs the service, the tray and `tsng`. |
|
||||
| **macOS** | `brew tap house-of-vanity/tap && brew install --cask tsunagi-gui`<br>CLI only: `brew install tsunagi` |
|
||||
| **Debian / Ubuntu** | `sudo apt install ./tsunagi-gui_<version>_amd64.deb` (`tsunagi_…` for no tray) |
|
||||
| **Arch** | `sudo pacman -U tsunagi-gui-<version>-1-x86_64.pkg.tar.zst` |
|
||||
| **NixOS** | [Flake and module](#nixos) below |
|
||||
| **Any other Linux** | Unpack the tarball, then `sudo ./install.sh && sudo systemctl enable --now tsunagi` |
|
||||
|
||||
The interface is tied to an open file descriptor and is deliberately **not**
|
||||
made persistent, so the kernel removes it when the agent exits — on a clean
|
||||
shutdown, on a panic, on `SIGKILL`, on power loss alike. Keeping it is what
|
||||
would take an action; removing it is the default.
|
||||
Linux packages run the agent as an unprivileged service user. Add yourself to
|
||||
the `tsunagi` group (`sudo usermod -aG tsunagi $USER`, then log in again) to
|
||||
use `tsng` and the tray without `sudo`.
|
||||
|
||||
If something is left behind anyway — an interface made by an older version's
|
||||
manual recipe, or one from a run killed in the instant between creating it and
|
||||
recording it — the next start **replaces it**, along with any stale addresses
|
||||
it carried. Two things are never touched:
|
||||
## Quick start
|
||||
|
||||
* an interface that is not a TUN, because the name colliding with somebody's
|
||||
bridge is not a reason to destroy the bridge;
|
||||
* a TUN that another process is holding open, because that is a working
|
||||
overlay belonging to somebody else — most likely a second agent on this
|
||||
host, which should be given a different `--wg-prefix`.
|
||||
|
||||
Both of those refuse with an explanation rather than guessing.
|
||||
|
||||
Two settings the manual recipe used to need are gone with it.
|
||||
`keep_addr_on_down` existed only because an interface nobody held open lost
|
||||
carrier and had its IPv6 addresses flushed, and `nodad` only because duplicate
|
||||
address detection can never finish without carrier. An interface held open for
|
||||
its whole life has carrier for its whole life.
|
||||
|
||||
### The MTU is 1280
|
||||
|
||||
The default interface MTU stays at 1280 on direct paths, relays and paths
|
||||
inside another VPN. Large encrypted packets are split into smaller QUIC
|
||||
datagrams and reassembled before reaching WireGuard. SSH and full-size TCP
|
||||
segments therefore do not require a manual MTU override or MSS adjustment.
|
||||
The original IP packet, including its DF bit, is preserved. See
|
||||
[docs/wireguard.md](docs/wireguard.md#mtu) for limits and compatibility.
|
||||
|
||||
### Summary
|
||||
|
||||
| approach | agent runs as | notes |
|
||||
|---|---|---|
|
||||
| `setcap cap_net_admin+p` | ordinary user, one capability | recommended: nothing to prepare, nothing left behind. Lost on every rebuild or copy of the binary. |
|
||||
| systemd service | `User=`, `AmbientCapabilities=CAP_NET_ADMIN` | the same, for an installed service |
|
||||
| `sudo tsunagi up` | root | everything works, nothing is isolated |
|
||||
| `--no-tun` | ordinary user, no capabilities | tunnels run and handshake, traffic never reaches the OS |
|
||||
|
||||
On Windows, put `wintun.dll` beside the executable and start `tsunagi up`
|
||||
from PowerShell or Command Prompt opened with **Run as administrator**.
|
||||
Run commands controlling that agent (`join`, `status`, `dns`, `network`)
|
||||
as the same Windows user with the same elevation. If Windows denies access
|
||||
to the agent's control pipe, the command reports a permission error instead
|
||||
of claiming the agent is absent or trying to edit its locked state.
|
||||
Creating a TUN without sufficient privileges also explains how to restart
|
||||
the agent with the required permissions.
|
||||
|
||||
**Not implemented yet.** macOS has no provisioner; `--no-tun` is the way to
|
||||
run it. The control plane and tunnels are unaffected. The decision logic
|
||||
that says *what* to change is shared and tested on every platform.
|
||||
|
||||
## Checks
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
cargo test --locked --workspace --all-targets
|
||||
```sh
|
||||
tsng join -n home # first machine: makes the network and prints a secret
|
||||
tsng join -n home -s tsn1… # every other machine
|
||||
tsng status # who is online, over which path
|
||||
```
|
||||
|
||||
The whole suite runs offline on loopback. Set `TSUNAGI_TEST_LOG=tsunagi=debug`
|
||||
to see agent logs while a test runs.
|
||||
Every new network gets a stable pseudorandom `/24` in `10.0.0.0/8`, derived
|
||||
from its network id. Its members agree on that range even when starting offline,
|
||||
and each gets a name like `<host>.home`. Existing signed ranges are preserved.
|
||||
|
||||
There are also two runnable demos, which are demos and not substitutes for the
|
||||
tests:
|
||||
If two configured networks have overlapping ranges, the agent runs only one.
|
||||
`tsng status`, `tsng network` and the tray show which network blocks the
|
||||
other. `tsng network start <name-or-id>` or the tray’s “Switch to this network”
|
||||
button suspends the conflicting networks. They resume automatically when the
|
||||
selected network stops; the selection survives restarting the agent. Explicitly
|
||||
stopping a suspended network cancels its automatic resume.
|
||||
|
||||
```bash
|
||||
cargo run --example two_agents # control plane only
|
||||
Machines are called `tsunagi-<id>` until you rename them: `tsng id hostname laptop`.
|
||||
|
||||
### Virtual services
|
||||
|
||||
```sh
|
||||
tsng vip create home api
|
||||
tsng vip port add home api tcp/443 --backend server1:6443 --backend server2:7443
|
||||
tsng vip status home api --watch
|
||||
```
|
||||
|
||||
Both run with no privileges and change nothing on the host.
|
||||
Use `api.home:443`. Each client selects the available backend with the lowest
|
||||
measured data-plane RTT. A VIP can have multiple independent TCP and UDP ports.
|
||||
Every member may edit it; the tray's **VIP** button opens a draft editor with
|
||||
explicit Save. [Commands, health, metrics and limits](docs/vip.md).
|
||||
|
||||
## Usage
|
||||
### Exit nodes
|
||||
|
||||
```rust,no_run
|
||||
use std::sync::Arc;
|
||||
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::{NetworkName, NetworkSecret};
|
||||
use tsunagi::proto::ControlMessage;
|
||||
use tsunagi::{Agent, Result};
|
||||
```sh
|
||||
tsng network exit-node home on # on the machine that should be the exit
|
||||
tsng exit-node # on another one: who offers an exit
|
||||
tsng exit-node laptop # send everything through it
|
||||
tsng exit-node off
|
||||
```
|
||||
|
||||
// The library never starts a runtime, installs a logger, handles signals,
|
||||
// forks, or calls process::exit. The binary owns all of that.
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let config = AgentConfig::new(StoragePaths::user_default()?)
|
||||
.with_transport(TransportPolicy::N0Defaults)
|
||||
.with_discovery(Arc::new(SharedMemoryDiscovery::new()));
|
||||
The tray does the same with a checkbox. An exit node sees everything you send
|
||||
through it, so use only members you trust.
|
||||
[Details and per-OS notes](docs/exit-node.md).
|
||||
|
||||
let agent = Agent::spawn(config).await?;
|
||||
The default data transport order is `wg,awg,wg-quic,tcp-tls`. AWG negotiates a
|
||||
fresh masking profile and outer keys for each pair connection attempt, including
|
||||
reentry after fallback. Sustained data loss triggers switching independently of
|
||||
the iroh control connection. [Configuration and limits](docs/amnezia.md).
|
||||
|
||||
let name = NetworkName::new("kitchen-table")?;
|
||||
let secret = NetworkSecret::generate(); // 32 random bytes
|
||||
println!("share this: {}", secret.encode().as_str());
|
||||
Change a network's protocol selection without editing its service or restarting:
|
||||
|
||||
let network = agent.join_network(&name, &secret).await?;
|
||||
```sh
|
||||
tsng protocols # available protocols and settings
|
||||
tsng protocols home # this device's choice for home
|
||||
tsng protocols home awg,wg-quic # prefer AWG, retain QUIC as fallback
|
||||
tsng protocols home none # control plane only
|
||||
```
|
||||
|
||||
let mut events = agent.subscribe();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(event) = events.recv().await {
|
||||
println!("{event:?}");
|
||||
Exact network names, full ids and unique id prefixes work here. The choice is
|
||||
local to this device, saved per network and applied immediately; an offline
|
||||
change applies on the next start. In the tray, expand **data transports** on a network
|
||||
tile to toggle protocols or change their preference order. Adjacent peers need a common external transport at the same wire version.
|
||||
Selection applies to this device's incoming and outgoing data links; control and
|
||||
discovery are independent. A relay's next hop may use a different transport.
|
||||
Every pair uses one internal WireGuard cipher, hidden from these controls.
|
||||
Changing transport or moving direct → relay → direct preserves that channel,
|
||||
addresses and interface. An existing TCP connection can resume after temporary
|
||||
loss, subject to its own timeouts. `tsng up --protocol` supplies the
|
||||
initial defaults for networks without a saved choice; it does not restrict
|
||||
which built-in protocols can be enabled later.
|
||||
|
||||
Remove an obsolete offline device from a network's shared history:
|
||||
|
||||
```sh
|
||||
tsng network forget-node home old-laptop
|
||||
tsng network forget-node home 10.13.37.143
|
||||
```
|
||||
|
||||
Networks accept exact names, full ids or unique id prefixes; nodes accept those
|
||||
id selectors, exact hostnames or overlay IPv4 addresses. Ambiguous selectors
|
||||
change nothing. Start the network first if it is stopped. In **Show devices**,
|
||||
click **Forget** beside an offline node and confirm its identity.
|
||||
|
||||
Every member has the same cleanup right. The signed cleanup frees the observed
|
||||
claim's name and IPv4 reservation, survives restarts and reaches offline replicas
|
||||
when they reconnect. It does not ban the device: a newer signed claim can return.
|
||||
Online nodes and the currently selected exit node cannot be forgotten.
|
||||
[State and limits](docs/sync-model.md#offline-node-cleanup).
|
||||
|
||||
## Platforms
|
||||
|
||||
| Platform | Build | CLI `tsng` | Tray | Exit node: offer / use | Packages |
|
||||
| ------------------------- | ------------ | :--------: | :--: | :--------------------: | ----------------------------------- |
|
||||
| Linux x86_64 | release | ✔ | ✔ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball, Nix |
|
||||
| Linux aarch64 | release | ✔ | –¹ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball, Nix |
|
||||
| Linux armv7 | release | ✔ | –¹ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball |
|
||||
| macOS, Apple silicon | release | ✔ | ✔ | ✔ / ✔² | Homebrew, tarball |
|
||||
| macOS, Intel | from source | ✔³ | ✔³ | ✔ / ✔²³ | – |
|
||||
| Windows x86_64 | release | ✔ | ✔ | ✔ / ✔² | installer, zip |
|
||||
|
||||
<sup>¹ Not in the release; builds from source (and through Nix).
|
||||
² Newer and less battle-tested than on Linux.
|
||||
³ Builds from source, not covered by CI.</sup>
|
||||
|
||||
The Linux CLI is a single static binary: it runs on any distribution.
|
||||
|
||||
## NixOS
|
||||
|
||||
The repository is a flake with a package and a module.
|
||||
|
||||
```nix
|
||||
# flake.nix
|
||||
{
|
||||
inputs.tsunagi.url = "github:house-of-vanity/tsunagi";
|
||||
|
||||
outputs = { nixpkgs, tsunagi, ... }: {
|
||||
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
tsunagi.nixosModules.default
|
||||
{
|
||||
services.tsunagi = {
|
||||
enable = true;
|
||||
tray.enable = true; # optional, for desktops
|
||||
users = [ "alice" ]; # may use tsng and the tray without sudo
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
for peer in agent.network_status(network).await?.connected_peers() {
|
||||
agent
|
||||
.send(network, peer, ControlMessage::Ping { seq: 1, payload: vec![] })
|
||||
.await?;
|
||||
}
|
||||
|
||||
agent.shutdown().await;
|
||||
Ok(())
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`TransportPolicy::LocalOnly` is the default, so a plain `AgentConfig::new` never
|
||||
reaches the internet by accident. Opt into `DirectOnly` or `N0Defaults`
|
||||
explicitly.
|
||||
Just trying it: `nix run github:house-of-vanity/tsunagi#tsunagi-cli -- --help`.
|
||||
`packages.<system>.tsunagi` (with the tray) and `tsunagi-cli` are also exported,
|
||||
along with `overlays.default`.
|
||||
|
||||
### Through somebody in the middle
|
||||
For development from a checkout, `nix develop` also exposes the tray's native
|
||||
runtime libraries, including AppIndicator and graphics/windowing libraries.
|
||||
Cargo-built binaries such as `target/debug/tsunagi-tray` can run directly inside
|
||||
this shell, without installing the packaged wrapper.
|
||||
|
||||
Everybody tries everybody first: the mesh is pairwise, and a relay is only
|
||||
for the pair that cannot manage it. Two members can both reach a third and
|
||||
not each other: a blocked path, a
|
||||
relay that is unavailable, a network only reachable from inside somebody
|
||||
else's building. When that happens the pair is routed through a member that
|
||||
has both.
|
||||
## Build from source
|
||||
|
||||
Nothing is agreed and nothing is elected. Each member says only which peers
|
||||
*it* has a live link with, first-hand, over the control plane and one hop
|
||||
only; everyone picks their own way through from that, deterministically, and
|
||||
drops it the moment a direct link exists. There is no routing protocol, no
|
||||
second-hand claim to weigh, and a relayed datagram is never relayed again —
|
||||
so a loop cannot form.
|
||||
Rust 1.91 or newer.
|
||||
|
||||
The one in the middle carries **bytes it cannot read**: the tunnel stays end
|
||||
to end between the two ends, and a relayed datagram never touches the middle
|
||||
host's interface, so no forwarding, routing or firewall setting of that host
|
||||
is involved. `status` says `via <peer>` on a path that goes through
|
||||
somebody, and counts what this device has carried for others — it is their
|
||||
traffic on your uplink, and that should not be invisible.
|
||||
|
||||
## How peers find each other
|
||||
|
||||
Two different lookups are involved, and only one of them is this project's:
|
||||
|
||||
**1. Resolving one endpoint's address — iroh's, and it works today.**
|
||||
With `--reach relay` or `--reach direct`, iroh publishes a signed
|
||||
record of this endpoint's addresses, keyed by its endpoint id, to the public
|
||||
service run by Number 0 — "n0", the company behind iroh — at `dns.iroh.link`,
|
||||
over pkarr and DNS, and resolves other endpoints the same way. That is why `--peer <endpoint-id>` works with no address attached:
|
||||
iroh looks it up. None of that code is ours.
|
||||
|
||||
**2. Finding who is in a network — Mainline DHT by default.**
|
||||
`NetworkDiscovery` supplies unverified candidates. The Mainline backend uses
|
||||
a signing key derived from the network name and secret to publish signed
|
||||
BEP44 records containing each writer's endpoint ID and addresses. Its 16
|
||||
slots provide starting candidates, not a complete membership list or a limit
|
||||
on network size. After the first authenticated connection, introductions and
|
||||
signed state reveal the other members. `StaticBootstrap` (`--peer`) remains
|
||||
available, and tests use either a local Mainline Testnet or in-memory discovery.
|
||||
|
||||
What this means in practice:
|
||||
|
||||
- With `relay` or `direct`, **your endpoint id and IP addresses are published
|
||||
to a public third-party service** (Number 0's, unless you change it). They are not secret, and the network secret is
|
||||
never published, but an observer of that service learns that your endpoint
|
||||
exists and where it is. `--reach local` publishes nothing.
|
||||
- With DHT enabled, endpoint records are also published to the public Mainline
|
||||
DHT. They are signed, not encrypted; membership still requires the secret.
|
||||
- A relay, when one is needed, sees the volume and timing of your traffic — not
|
||||
its contents. The default relays are Number 0's, in the US, EU and
|
||||
Asia-Pacific.
|
||||
|
||||
## Storage
|
||||
|
||||
Two physically separate SQLite files, placed wherever the library's
|
||||
configuration says (`StoragePaths`). A future system service supplies its own
|
||||
paths; tests always use temporary directories.
|
||||
|
||||
| file | holds | when damaged |
|
||||
|----------------|--------------------------------------------------|-------------------------|
|
||||
| `state.sqlite` | device identity, network configuration, hostname | clear error, never reset |
|
||||
| `cache.sqlite` | address hints and other recoverable data | discarded and recreated |
|
||||
|
||||
The `wg-quic` protocol keeps its own keys in its own store under `wg-quic/`,
|
||||
because a protocol's keys are neither the iroh identity nor the network
|
||||
secret.
|
||||
|
||||
The command line agent puts everything under the platform's per-user
|
||||
directories by default; `--state-dir` and `--cache-dir` override them.
|
||||
|
||||
One state directory belongs to one live agent, enforced with a real OS file
|
||||
lock rather than an existence check. That directory *is* the identity: one
|
||||
agent, one device key, one interface, and as many networks as you like on it
|
||||
— `tsunagi network join` adds them to the agent that is already running,
|
||||
which is why a second `tsunagi up` on the same directory is refused rather
|
||||
than made to work. A second agent on the same host is a second identity, and
|
||||
needs everything of its own: its own state and cache directories, its own
|
||||
interface name and its own overlay range. The library owns no globals, so
|
||||
several of them run side by side in one process as readily as in one host.
|
||||
|
||||
### Leaving, and starting over
|
||||
|
||||
Membership outlives a session, so it also has to be possible to end it.
|
||||
|
||||
```bash
|
||||
tsunagi network # what this device belongs to
|
||||
tsunagi network leave <network-id> # give up the address and the name, then forget it
|
||||
tsunagi wipe --yes # remove everything and be a stranger again
|
||||
```sh
|
||||
cargo build --release --bin tsng # the agent and CLI
|
||||
cargo build --release --bin tsunagi-tray # the tray (Linux needs GTK 3 and libayatana-appindicator)
|
||||
```
|
||||
|
||||
**Stopping is not leaving.** `stop` closes this network's sessions, takes
|
||||
its address off the interface and keeps it from starting again, and that is
|
||||
all: the configuration, the secret, the signed state and the protocol key
|
||||
stay exactly as they are, nothing is announced, and to the others this
|
||||
device is simply away — an ordinary condition they already handle, with its
|
||||
address and name still reserved for it. `start` picks it up where it left
|
||||
off. A network named on the `up` command line is started by that command
|
||||
whatever its stored state, and the start-up banner says so rather than
|
||||
letting a `stop` quietly come back.
|
||||
Windows needs [Wintun](https://www.wintun.net) next to `tsng.exe`.
|
||||
Per-platform notes: [Linux](dist/linux/README.md), [Arch](dist/arch/README.md),
|
||||
[macOS](dist/macos/README.md), [Windows](dist/windows/README.md).
|
||||
|
||||
`leave` is the other one, and it removes, locally: the configuration and
|
||||
the secret, this network's signed records, its cached address hints and the
|
||||
protocol key it used there. What it keeps is this author's version counter
|
||||
for that network — a rejoin has to continue above the release, or every
|
||||
replica would treat the new claim as stale. Everything about *other*
|
||||
networks, and the device identity itself, is untouched.
|
||||
## How it works
|
||||
|
||||
`leave` publishes a signed `Release` **first**, while the agent is running and
|
||||
its sessions are up, so the address and the name are freed for the others
|
||||
instead of staying reserved to a member that has gone. They pass the tombstone
|
||||
on, so a member that was away hears it from them. With no agent running,
|
||||
nothing can sign or send it: the command says so and refuses, and `--offline`
|
||||
drops the network locally while leaving the others holding the old claim. The
|
||||
protocol key for that network goes too — rejoining is joining, not resuming.
|
||||
- Your name and secret derive a network identity. Members publish signed
|
||||
contact records to the public Mainline DHT under it, and find each other there.
|
||||
- Every connection proves knowledge of the secret before it's accepted.
|
||||
Traffic between members is end-to-end encrypted, relays included.
|
||||
- Addresses and names are claimed with each device's own key and replicated
|
||||
between members. There is no one in charge, so nothing to be taken down.
|
||||
|
||||
A network is named by its id, never by its name: two networks can share a
|
||||
name, and choosing between them for the user is how the wrong one gets left. A
|
||||
unique prefix is enough.
|
||||
|
||||
`wipe` removes both directories' contents: the device identity, every network,
|
||||
every signed record and everything a protocol kept beside them. It refuses
|
||||
while an agent is running, and refuses a directory with no `state.sqlite` in
|
||||
it, so a mistyped `--state-dir` cannot take somebody's documents with it.
|
||||
Without `--yes` it only says what it would remove. It is not a goodbye: nobody
|
||||
is told, because after it there is no key left to sign anything with. Leave the
|
||||
networks first if the addresses should be freed.
|
||||
The secret is the only key, so make it a good one (the generated ones are).
|
||||
The [threat model](docs/threat-model.md) says what this does and doesn't protect against.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [docs/architecture.md](docs/architecture.md) — module boundaries and runtime.
|
||||
- [docs/wireguard.md](docs/wireguard.md) — the WireGuard plugin: overlay
|
||||
addressing, announcements, backends, reconciliation.
|
||||
- [docs/protocol.md](docs/protocol.md) — identity derivation, framing, handshake.
|
||||
- [docs/sync-model.md](docs/sync-model.md) — the planned signed-state model and
|
||||
what is deliberately not built yet.
|
||||
- [docs/threat-model.md](docs/threat-model.md) — threat model and known limits.
|
||||
- [docs/testing.md](docs/testing.md) — what the suite covers and what it does not.
|
||||
- [AGENTS.md](AGENTS.md) — rules for anyone (human or agent) changing this repo.
|
||||
|
||||
## Security in one paragraph
|
||||
|
||||
Membership is proved by an HMAC over a transcript keyed by a value derived from
|
||||
the shared secret, bound to the specific iroh connection through the TLS
|
||||
exporter, to the network id, to both endpoint identities and to distinct role
|
||||
labels. This targets high-entropy secrets: there is no PAKE here, so a short
|
||||
human passphrase is guessable offline by anyone who can reach the handshake.
|
||||
Anyone who knows the secret is a full participant and can create many
|
||||
identities. Read [docs/threat-model.md](docs/threat-model.md) before relying on
|
||||
any of this.
|
||||
[Architecture](docs/architecture.md) ·
|
||||
[Protocol](docs/protocol.md) ·
|
||||
[WireGuard data plane](docs/wireguard.md) ·
|
||||
[Pairwise Amnezia transport](docs/amnezia.md) ·
|
||||
[Routing](docs/routing.md) ·
|
||||
[Exit nodes](docs/exit-node.md) ·
|
||||
[Virtual services](docs/vip.md) ·
|
||||
[Threat model](docs/threat-model.md) ·
|
||||
[Contributing](AGENTS.md)
|
||||
|
||||
## Licence
|
||||
|
||||
MIT OR Apache-2.0.
|
||||
[WTFPL](LICENSE): do what the fuck you want to.
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "tsunagi-awg"
|
||||
version = "0.1.0"
|
||||
description = "Ephemeral pairwise Amnezia transport for Tsunagi."
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "WTFPL AND Apache-2.0"
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tsunagi-wireguard = { path = "../tsunagi-wireguard" }
|
||||
|
||||
[dev-dependencies]
|
||||
tsunagi-wg-quic = { path = "../tsunagi-wg-quic" }
|
||||
iroh.workspace = true
|
||||
tokio = { workspace = true, features = ["net", "time", "sync", "macros"] }
|
||||
bytes.workspace = true
|
||||
rand = "0.10"
|
||||
tsunagi-core = { path = "../tsunagi-core", features = ["testing"] }
|
||||
tempfile.workspace = true
|
||||
smoltcp = { version = "0.12", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "socket-tcp"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Friderika Coulson (NanoBee)
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Amnezia packet engine
|
||||
|
||||
`../tsunagi-wireguard/src/masking.rs` is adapted from `src/core/amnezia.rs` in
|
||||
https://github.com/RunaRoo/AmneziaWG-RS at
|
||||
`51579a7d6e8882d1d8d6d00822f09e18a9423b73`, under Apache-2.0.
|
||||
The upstream license is retained in both packages as `LICENSE-AmneziaWG-RS`.
|
||||
|
||||
The adaptation replaces configuration-file loading with bounded typed pair
|
||||
profiles, updates RNG calls to rand 0.10, removes an unnecessary unwrap, and
|
||||
does not embed upstream's Node, global executors, OS interface operations or
|
||||
detached tasks. Noise/WireGuard is supplied by boringtun, already used by
|
||||
Tsunagi. Each pair owns one socket and one cancellable packet-engine task.
|
||||
|
||||
This is Tsunagi AWG wire version 2, not a claim of compatibility with external
|
||||
Amnezia servers. The masking engine substitutes message headers outside the
|
||||
standard WireGuard state machine and restores them before Noise validation.
|
||||
There are no remote file paths, shell hooks, or persisted pair profiles.
|
||||
|
||||
The adapted masking source remains Apache-2.0. Newly written Tsunagi code
|
||||
follows the repository's WTFPL. This package declares both licenses in Cargo
|
||||
metadata; the workspace license alone does not describe its copied source.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Amnezia-masked UDP carrier using the independent per-neighbour WireGuard engine.
|
||||
pub use tsunagi_wireguard::{
|
||||
AWG_PROTOCOL, AWG_WIRE_VERSION, PairOverview, UdpCarrier as AwgPlugin,
|
||||
UdpTransport as AwgTransport, profile,
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
//! A real userspace TCP stack attached to each memory TUN, without host routes.
|
||||
use bytes::Bytes;
|
||||
use smoltcp::{
|
||||
iface::{Config, Interface, SocketHandle, SocketSet},
|
||||
phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken},
|
||||
socket::tcp,
|
||||
time::Instant as TcpInstant,
|
||||
wire::{HardwareAddress, IpAddress, IpCidr},
|
||||
};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
net::Ipv4Addr,
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tsunagi::overlay::MemoryTun;
|
||||
|
||||
struct TunDevice {
|
||||
input: Arc<Mutex<VecDeque<Vec<u8>>>>,
|
||||
tun: Arc<MemoryTun>,
|
||||
}
|
||||
struct Rx(Vec<u8>);
|
||||
impl RxToken for Rx {
|
||||
fn consume<R, F: FnOnce(&[u8]) -> R>(self, f: F) -> R {
|
||||
f(&self.0)
|
||||
}
|
||||
}
|
||||
struct Tx(Arc<MemoryTun>);
|
||||
impl TxToken for Tx {
|
||||
fn consume<R, F: FnOnce(&mut [u8]) -> R>(self, len: usize, f: F) -> R {
|
||||
let mut packet = vec![0; len];
|
||||
let result = f(&mut packet);
|
||||
self.0.push_from_os(Bytes::from(packet));
|
||||
result
|
||||
}
|
||||
}
|
||||
impl Device for TunDevice {
|
||||
type RxToken<'a>
|
||||
= Rx
|
||||
where
|
||||
Self: 'a;
|
||||
type TxToken<'a>
|
||||
= Tx
|
||||
where
|
||||
Self: 'a;
|
||||
fn receive(&mut self, _: TcpInstant) -> Option<(Rx, Tx)> {
|
||||
self.input
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.map(|packet| (Rx(packet), Tx(self.tun.clone())))
|
||||
}
|
||||
fn transmit(&mut self, _: TcpInstant) -> Option<Tx> {
|
||||
Some(Tx(self.tun.clone()))
|
||||
}
|
||||
fn capabilities(&self) -> DeviceCapabilities {
|
||||
let mut caps = DeviceCapabilities::default();
|
||||
caps.medium = Medium::Ip;
|
||||
caps.max_transmission_unit = 1280;
|
||||
caps
|
||||
}
|
||||
}
|
||||
struct Stack {
|
||||
iface: Interface,
|
||||
sockets: SocketSet<'static>,
|
||||
socket: SocketHandle,
|
||||
device: TunDevice,
|
||||
reader: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Stack {
|
||||
fn new(tun: Arc<MemoryTun>, address: Ipv4Addr) -> Self {
|
||||
let input = Arc::new(Mutex::new(VecDeque::new()));
|
||||
let queue = input.clone();
|
||||
let received = tun.clone();
|
||||
let reader = tokio::spawn(async move {
|
||||
while let Some(packet) = received.pop_to_os().await {
|
||||
let mut queue = queue.lock().unwrap();
|
||||
assert!(queue.len() < 256);
|
||||
queue.push_back(packet.to_vec());
|
||||
}
|
||||
});
|
||||
let mut device = TunDevice { input, tun };
|
||||
let mut config = Config::new(HardwareAddress::Ip);
|
||||
config.random_seed = rand::random();
|
||||
let mut iface = Interface::new(config, &mut device, TcpInstant::from_millis(0));
|
||||
let [a, b, c, d] = address.octets();
|
||||
iface.update_ip_addrs(|addresses| {
|
||||
addresses
|
||||
.push(IpCidr::new(IpAddress::v4(a, b, c, d), 24))
|
||||
.unwrap()
|
||||
});
|
||||
let mut sockets = SocketSet::new(Vec::new());
|
||||
let socket = sockets.add(tcp::Socket::new(
|
||||
tcp::SocketBuffer::new(vec![0; 65536]),
|
||||
tcp::SocketBuffer::new(vec![0; 65536]),
|
||||
));
|
||||
Self {
|
||||
iface,
|
||||
sockets,
|
||||
socket,
|
||||
device,
|
||||
reader,
|
||||
}
|
||||
}
|
||||
fn tcp(&mut self) -> &mut tcp::Socket<'static> {
|
||||
self.sockets.get_mut(self.socket)
|
||||
}
|
||||
fn poll(&mut self, now: TcpInstant) {
|
||||
self.iface.poll(now, &mut self.device, &mut self.sockets);
|
||||
}
|
||||
}
|
||||
impl Drop for Stack {
|
||||
fn drop(&mut self) {
|
||||
self.reader.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct TcpPair {
|
||||
client: Stack,
|
||||
server: Stack,
|
||||
started: Instant,
|
||||
}
|
||||
impl TcpPair {
|
||||
pub(super) async fn connect(
|
||||
a: Arc<MemoryTun>,
|
||||
a_ip: Ipv4Addr,
|
||||
b: Arc<MemoryTun>,
|
||||
b_ip: Ipv4Addr,
|
||||
) -> Self {
|
||||
let mut pair = Self {
|
||||
client: Stack::new(a, a_ip),
|
||||
server: Stack::new(b, b_ip),
|
||||
started: Instant::now(),
|
||||
};
|
||||
pair.server.tcp().listen(22).unwrap();
|
||||
let [a, b, c, d] = b_ip.octets();
|
||||
pair.client
|
||||
.sockets
|
||||
.get_mut::<tcp::Socket>(pair.client.socket)
|
||||
.connect(
|
||||
pair.client.iface.context(),
|
||||
(IpAddress::v4(a, b, c, d), 22),
|
||||
40000,
|
||||
)
|
||||
.unwrap();
|
||||
pair.until(|p| {
|
||||
p.client.tcp().state() == tcp::State::Established
|
||||
&& p.server.tcp().state() == tcp::State::Established
|
||||
})
|
||||
.await;
|
||||
pair
|
||||
}
|
||||
fn poll(&mut self) {
|
||||
let now = TcpInstant::from_millis(self.started.elapsed().as_millis() as i64);
|
||||
self.client.poll(now);
|
||||
self.server.poll(now);
|
||||
}
|
||||
async fn until(&mut self, mut ready: impl FnMut(&mut Self) -> bool) {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let mut ticks = tokio::time::interval(Duration::from_millis(2));
|
||||
loop {
|
||||
ticks.tick().await;
|
||||
self.poll();
|
||||
if ready(self) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("same TCP connection must progress across carrier/route changes");
|
||||
}
|
||||
pub(super) fn queue_while_unreachable(&mut self, payload: &[u8]) {
|
||||
assert_eq!(
|
||||
self.client.tcp().send_slice(payload).unwrap(),
|
||||
payload.len()
|
||||
);
|
||||
self.poll();
|
||||
}
|
||||
pub(super) async fn receive_queued(&mut self, payload: &[u8]) {
|
||||
let mut received = Vec::new();
|
||||
self.until(|p| {
|
||||
if p.server.tcp().can_recv() {
|
||||
p.server
|
||||
.tcp()
|
||||
.recv(|bytes| {
|
||||
received.extend_from_slice(bytes);
|
||||
(bytes.len(), ())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
received.len() >= payload.len()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(received, payload);
|
||||
assert_eq!(self.client.tcp().state(), tcp::State::Established);
|
||||
assert_eq!(self.server.tcp().state(), tcp::State::Established);
|
||||
}
|
||||
pub(super) async fn exchange(&mut self, payload: &[u8]) {
|
||||
self.queue_while_unreachable(payload);
|
||||
self.receive_queued(payload).await;
|
||||
assert_eq!(
|
||||
self.server.tcp().send_slice(payload).unwrap(),
|
||||
payload.len()
|
||||
);
|
||||
let mut received = Vec::new();
|
||||
self.until(|p| {
|
||||
if p.client.tcp().can_recv() {
|
||||
p.client
|
||||
.tcp()
|
||||
.recv(|bytes| {
|
||||
received.extend_from_slice(bytes);
|
||||
(bytes.len(), ())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
received.len() >= payload.len()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(received, payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,947 @@
|
||||
//! Real membership, UDP sockets, pair negotiation and nested WireGuard encryption.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
#[path = "common/tcp.rs"]
|
||||
mod tcp;
|
||||
use bytes::Bytes;
|
||||
use std::{
|
||||
net::Ipv4Addr,
|
||||
path::Path,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
use tsunagi::dataplane::IpPlugin;
|
||||
use tsunagi::dataplane::transport::{
|
||||
ControlSender, PacketLink, PacketTransport, SharedLink, TransportError,
|
||||
};
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::overlay::{MemoryTun, MemoryTunFactory};
|
||||
use tsunagi::testing::{config_with, network, wait_for_peers, wait_until};
|
||||
use tsunagi::{Agent, BoxFuture, NetworkId};
|
||||
use tsunagi_awg::{AWG_PROTOCOL, AwgPlugin, AwgTransport};
|
||||
use tsunagi_wg_quic::WireguardConfig;
|
||||
|
||||
struct Node {
|
||||
agent: Agent,
|
||||
plugin: Arc<tsunagi_wg_quic::WireguardPlugin>,
|
||||
transport: Arc<AwgTransport>,
|
||||
tuns: MemoryTunFactory,
|
||||
loss: Arc<AtomicBool>,
|
||||
}
|
||||
impl Node {
|
||||
async fn open(root: &Path, discovery: &SharedMemoryDiscovery, interface: &str) -> Self {
|
||||
Self::open_with_fallback(root, discovery, interface, false).await
|
||||
}
|
||||
async fn open_with_fallback(
|
||||
root: &Path,
|
||||
discovery: &SharedMemoryDiscovery,
|
||||
interface: &str,
|
||||
fallback: bool,
|
||||
) -> Self {
|
||||
Self::open_config(root, discovery, interface, fallback, 1280).await
|
||||
}
|
||||
async fn open_config(
|
||||
root: &Path,
|
||||
discovery: &SharedMemoryDiscovery,
|
||||
interface: &str,
|
||||
fallback: bool,
|
||||
mtu: u32,
|
||||
) -> Self {
|
||||
Self::open_selected(root, discovery, interface, fallback, mtu, None).await
|
||||
}
|
||||
async fn open_selected(
|
||||
root: &Path,
|
||||
discovery: &SharedMemoryDiscovery,
|
||||
interface: &str,
|
||||
fallback: bool,
|
||||
mtu: u32,
|
||||
default_protocols: Option<Vec<String>>,
|
||||
) -> Self {
|
||||
let identity =
|
||||
tsunagi::storage::StateStore::open(tsunagi::StoragePaths::under(root).state_db())
|
||||
.unwrap()
|
||||
.load_or_create_device_identity()
|
||||
.unwrap();
|
||||
let transport = AwgTransport::new(identity.endpoint_id(), Ipv4Addr::LOCALHOST);
|
||||
let plugin = tsunagi_wg_quic::WireguardPlugin::open(
|
||||
WireguardConfig::new(root.join("awg"))
|
||||
.with_mtu(mtu)
|
||||
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let loss = Arc::new(AtomicBool::new(false));
|
||||
let faults = Arc::new(FaultTransport {
|
||||
inner: transport.clone(),
|
||||
loss: loss.clone(),
|
||||
});
|
||||
let tuns = MemoryTunFactory::new();
|
||||
let mut config = config_with(root, discovery)
|
||||
.with_interface(Arc::new(tuns.clone()), interface, mtu)
|
||||
.with_tunnel(plugin.clone() as Arc<dyn IpPlugin>)
|
||||
.with_carrier(AwgPlugin::new(transport.clone()))
|
||||
.with_custom_transport(AWG_PROTOCOL, faults);
|
||||
if fallback {
|
||||
config = config.with_carrier(Arc::new(
|
||||
tsunagi::dataplane::transport::iroh_link::IrohCarrier,
|
||||
));
|
||||
}
|
||||
config.default_protocols = default_protocols;
|
||||
let agent = Agent::spawn(config).await.unwrap();
|
||||
let inbound_agent = agent.clone();
|
||||
let inbound_transport = transport.clone();
|
||||
let inbound_loss = loss.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(mut link) = inbound_transport.next_inbound().await {
|
||||
link.link = impaired(link.link, inbound_loss.clone());
|
||||
let _ = inbound_agent.install_inbound_link(link).await;
|
||||
}
|
||||
});
|
||||
Self {
|
||||
agent,
|
||||
plugin,
|
||||
transport,
|
||||
tuns,
|
||||
loss,
|
||||
}
|
||||
}
|
||||
async fn ready(&self, id: NetworkId, n: usize) {
|
||||
wait_until("AWG outer paths and end-to-end handshakes", || async {
|
||||
(self
|
||||
.transport
|
||||
.overview(id)
|
||||
.iter()
|
||||
.filter(|p| !p.closed && !p.path.contains("establishing"))
|
||||
.count()
|
||||
== n
|
||||
&& self
|
||||
.plugin
|
||||
.overview(id)?
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.tunnel
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.health.is_up() && t.path.contains("awg UDP via"))
|
||||
})
|
||||
.count()
|
||||
== n)
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
fn tun(&self) -> Arc<MemoryTun> {
|
||||
self.tuns
|
||||
.device(&self.agent.overlay().unwrap().interface)
|
||||
.unwrap()
|
||||
}
|
||||
fn addr(&self, id: NetworkId) -> Ipv4Addr {
|
||||
self.plugin
|
||||
.overview(id)
|
||||
.unwrap()
|
||||
.overlay_address_v4
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
fn packet(source: Ipv4Addr, dest: Ipv4Addr, len: usize) -> Bytes {
|
||||
let mut bytes = vec![0; len];
|
||||
bytes[0] = 0x45;
|
||||
bytes[2..4].copy_from_slice(&(len as u16).to_be_bytes());
|
||||
bytes[8] = 64;
|
||||
bytes[9] = 253;
|
||||
bytes[12..16].copy_from_slice(&source.octets());
|
||||
bytes[16..20].copy_from_slice(&dest.octets());
|
||||
rand::fill(&mut bytes[20..]);
|
||||
Bytes::from(bytes)
|
||||
}
|
||||
async fn exchange(a: &Node, b: &Node, id: NetworkId) {
|
||||
for len in [64, 1097, 1280] {
|
||||
let bytes = packet(a.addr(id), b.addr(id), len);
|
||||
a.tun().push_from_os(bytes.clone());
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(5), b.tun().pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
bytes
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_control_only_agent_enables_real_data_without_restart_and_cancels_stale_attempts() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
|
||||
let a = Node::open_selected(
|
||||
roots[0].path(),
|
||||
&discovery,
|
||||
"initial-none-a",
|
||||
true,
|
||||
1280,
|
||||
Some(vec![]),
|
||||
)
|
||||
.await;
|
||||
let b = Node::open_selected(
|
||||
roots[1].path(),
|
||||
&discovery,
|
||||
"initial-none-b",
|
||||
true,
|
||||
1280,
|
||||
Some(vec![]),
|
||||
)
|
||||
.await;
|
||||
let (name, secret) = network("enable-after-control-only");
|
||||
let id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&a.agent, id, 1).await;
|
||||
wait_for_peers(&b.agent, id, 1).await;
|
||||
assert!(a.agent.overlay().is_none());
|
||||
assert!(b.agent.overlay().is_none());
|
||||
for node in [&a, &b] {
|
||||
assert!(
|
||||
node.agent
|
||||
.network_status(id)
|
||||
.await
|
||||
.unwrap()
|
||||
.protocols
|
||||
.is_empty()
|
||||
);
|
||||
node.agent
|
||||
.set_protocols(id, vec!["awg".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(node.agent.overlay().is_some());
|
||||
}
|
||||
a.ready(id, 1).await;
|
||||
b.ready(id, 1).await;
|
||||
exchange(&a, &b, id).await;
|
||||
let before = a.agent.overlay().unwrap().interface;
|
||||
// Changes race unfinished opens and prepared plugin callbacks; the last
|
||||
// selection must win even if an earlier open has completed in its queue.
|
||||
for selection in [
|
||||
vec!["wg-quic".into()],
|
||||
vec![],
|
||||
vec!["awg".into()],
|
||||
vec![],
|
||||
vec!["awg".into()],
|
||||
] {
|
||||
a.agent.set_protocols(id, selection).await.unwrap();
|
||||
}
|
||||
a.ready(id, 1).await;
|
||||
b.ready(id, 1).await;
|
||||
exchange(&a, &b, id).await;
|
||||
assert_eq!(a.agent.overlay().unwrap().interface, before);
|
||||
for node in [&a, &b] {
|
||||
let status = node.agent.network_status(id).await.unwrap();
|
||||
assert_eq!(status.protocols, ["awg"]);
|
||||
assert_eq!(status.metrics.sessions_established, 1);
|
||||
assert_eq!(status.metrics.disconnects, 0);
|
||||
}
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_protocol_selection_preserves_real_tunnels_and_survives_restart() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
|
||||
let a = Node::open_with_fallback(roots[0].path(), &discovery, "runtime-a", true).await;
|
||||
let b = Node::open_with_fallback(roots[1].path(), &discovery, "runtime-b", true).await;
|
||||
let (name, secret) = network("runtime-protocols");
|
||||
let id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
a.ready(id, 1).await;
|
||||
b.ready(id, 1).await;
|
||||
exchange(&a, &b, id).await;
|
||||
let original_key = a.plugin.overview(id).unwrap().public_key;
|
||||
let original_stats = a.plugin.overview(id).unwrap().peers[0]
|
||||
.tunnel
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.stats;
|
||||
|
||||
let (other_name, other_secret, other_id) = loop {
|
||||
let (name, secret) = network("unchanged-runtime-network");
|
||||
let other_id = tsunagi::identity::NetworkKeys::derive(&name, &secret).network_id();
|
||||
if tsunagi::state::derived_ipv4_range(id) != tsunagi::state::derived_ipv4_range(other_id) {
|
||||
break (name, secret, other_id);
|
||||
}
|
||||
};
|
||||
a.agent
|
||||
.join_network(&other_name, &other_secret)
|
||||
.await
|
||||
.unwrap();
|
||||
b.agent
|
||||
.join_network(&other_name, &other_secret)
|
||||
.await
|
||||
.unwrap();
|
||||
a.ready(other_id, 1).await;
|
||||
b.ready(other_id, 1).await;
|
||||
let other_before = a
|
||||
.agent
|
||||
.network_status(other_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.metrics
|
||||
.data_links_established;
|
||||
|
||||
for node in [&a, &b] {
|
||||
let before = node.agent.network_status(id).await.unwrap().protocols;
|
||||
for invalid in [vec!["unknown".into()], vec!["awg".into(), "awg".into()]] {
|
||||
assert!(node.agent.set_protocols(id, invalid).await.is_err());
|
||||
assert_eq!(
|
||||
node.agent.network_status(id).await.unwrap().protocols,
|
||||
before
|
||||
);
|
||||
}
|
||||
}
|
||||
// Exercise updates initiated by either endpoint-id role, including an
|
||||
// empty set. Membership and the control session stay alive throughout.
|
||||
for node in [&a, &b] {
|
||||
node.agent.set_protocols(id, Vec::new()).await.unwrap();
|
||||
wait_until("peer accepts control-only selection", || async {
|
||||
let other = if node.agent.endpoint_id() == a.agent.endpoint_id() {
|
||||
&b
|
||||
} else {
|
||||
&a
|
||||
};
|
||||
let status = other.agent.network_status(id).await.ok()?;
|
||||
(status.peers.len() == 1
|
||||
&& status.peers[0].protocols.is_empty()
|
||||
&& matches!(status.peers[0].data_path, tsunagi::agent::DataPath::None))
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
assert_eq!(node.agent.network_status(id).await.unwrap().peers.len(), 1);
|
||||
node.agent
|
||||
.set_protocols(id, vec!["wg-quic".into(), "awg".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
a.agent
|
||||
.set_protocols(id, vec!["wg-quic".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
wait_until("real QUIC replaces AWG", || async {
|
||||
let a_status = a.agent.network_status(id).await.ok()?;
|
||||
let b_status = b.agent.network_status(id).await.ok()?;
|
||||
(a_status.peers[0].protocols == ["wg-quic"]
|
||||
&& b_status.peers[0].protocols == ["wg-quic"]
|
||||
&& !matches!(a_status.peers[0].data_path, tsunagi::agent::DataPath::None)
|
||||
&& !matches!(b_status.peers[0].data_path, tsunagi::agent::DataPath::None)
|
||||
&& a.transport.overview(id).iter().all(|p| p.closed)
|
||||
&& b.transport.overview(id).iter().all(|p| p.closed))
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
assert_eq!(a.plugin.overview(id).unwrap().public_key, original_key);
|
||||
let stats = a.plugin.overview(id).unwrap().peers[0]
|
||||
.tunnel
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.stats;
|
||||
assert_eq!(stats.counter_epoch, original_stats.counter_epoch);
|
||||
assert!(stats.tx_bytes >= original_stats.tx_bytes);
|
||||
assert!(stats.rx_bytes >= original_stats.rx_bytes);
|
||||
assert!(a.plugin.overview(id).unwrap().peers.iter().any(|peer| {
|
||||
peer.tunnel
|
||||
.as_ref()
|
||||
.is_some_and(|tunnel| tunnel.health.is_up())
|
||||
}));
|
||||
// The same internal plugin supplies addresses and counters on the new carrier.
|
||||
for (source, dest) in [(&a, &b), (&b, &a)] {
|
||||
wait_until("QUIC end-to-end handshake", || async {
|
||||
source
|
||||
.plugin
|
||||
.overview(id)?
|
||||
.peers
|
||||
.iter()
|
||||
.any(|p| p.tunnel.as_ref().is_some_and(|t| t.health.is_up()))
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
let addr = |n: &Node| n.plugin.overview(id).unwrap().overlay_address_v4.unwrap();
|
||||
let bytes = packet(addr(source), addr(dest), 1280);
|
||||
source.tun().push_from_os(bytes.clone());
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(5), dest.tun().pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
bytes
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
a.agent
|
||||
.network_status(other_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.metrics
|
||||
.data_links_established,
|
||||
other_before
|
||||
);
|
||||
exchange(&a, &b, other_id).await;
|
||||
a.agent.shutdown().await;
|
||||
let restarted = Node::open_with_fallback(roots[0].path(), &discovery, "runtime-a", true).await;
|
||||
wait_for_peers(&restarted.agent, id, 1).await;
|
||||
assert_eq!(
|
||||
restarted.agent.network_status(id).await.unwrap().protocols,
|
||||
["wg-quic"]
|
||||
);
|
||||
assert_eq!(
|
||||
b.agent.network_status(other_id).await.unwrap().protocols,
|
||||
["awg", "wg-quic"]
|
||||
);
|
||||
restarted.ready(other_id, 1).await;
|
||||
b.ready(other_id, 1).await;
|
||||
exchange(&restarted, &b, other_id).await;
|
||||
restarted
|
||||
.agent
|
||||
.set_protocols(id, vec!["awg".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
restarted.ready(id, 1).await;
|
||||
b.ready(id, 1).await;
|
||||
exchange(&restarted, &b, id).await;
|
||||
restarted.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn real_pairs_carry_full_size_packets_and_restart_with_fresh_profiles() {
|
||||
tsunagi::testing::init_tracing();
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let roots = [
|
||||
TempDir::new().unwrap(),
|
||||
TempDir::new().unwrap(),
|
||||
TempDir::new().unwrap(),
|
||||
];
|
||||
let a = Node::open(roots[0].path(), &discovery, "awga").await;
|
||||
let b = Node::open(roots[1].path(), &discovery, "awgb").await;
|
||||
let c = Node::open(roots[2].path(), &discovery, "awgc").await;
|
||||
let (name, secret) = network("awg-pairs");
|
||||
let id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
c.agent.join_network(&name, &secret).await.unwrap();
|
||||
for node in [&a, &b, &c] {
|
||||
wait_for_peers(&node.agent, id, 2).await;
|
||||
node.ready(id, 2).await;
|
||||
}
|
||||
let ids: std::collections::HashSet<_> = [&a, &b, &c]
|
||||
.into_iter()
|
||||
.flat_map(|n| n.transport.overview(id).into_iter().map(|p| p.attempt))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
3,
|
||||
"one independent profile for each of three pairs"
|
||||
);
|
||||
exchange(&a, &b, id).await;
|
||||
exchange(&b, &a, id).await;
|
||||
exchange(&a, &c, id).await;
|
||||
let old_ports: Vec<_> = a
|
||||
.transport
|
||||
.overview(id)
|
||||
.into_iter()
|
||||
.filter_map(|p| p.local_port)
|
||||
.collect();
|
||||
a.agent.shutdown().await;
|
||||
for port in old_ports {
|
||||
wait_until("old pair socket released", || async {
|
||||
tokio::net::UdpSocket::bind((Ipv4Addr::LOCALHOST, port))
|
||||
.await
|
||||
.ok()
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let restarted = Node::open(roots[0].path(), &discovery, "awga").await;
|
||||
restarted.ready(id, 2).await;
|
||||
assert!(
|
||||
restarted
|
||||
.transport
|
||||
.overview(id)
|
||||
.iter()
|
||||
.all(|p| !ids.contains(&p.attempt))
|
||||
);
|
||||
exchange(&restarted, &b, id).await;
|
||||
exchange(&c, &restarted, id).await;
|
||||
restarted.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
c.agent.shutdown().await;
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(1), restarted.transport.next_inbound())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
// Drops datagrams at the link boundary. Membership, control, sockets, crypto,
|
||||
// addressing and storage remain real. This substitutes a lossy physical path.
|
||||
#[derive(Debug)]
|
||||
struct FaultTransport {
|
||||
inner: Arc<AwgTransport>,
|
||||
loss: Arc<AtomicBool>,
|
||||
}
|
||||
impl PacketTransport for FaultTransport {
|
||||
fn name(&self) -> &str {
|
||||
AWG_PROTOCOL
|
||||
}
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
fn attach_control(&self, sender: Arc<dyn ControlSender>) {
|
||||
self.inner.attach_control(sender);
|
||||
}
|
||||
fn on_control(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: iroh::EndpointId,
|
||||
protocol: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), TransportError> {
|
||||
self.inner.on_control(network, peer, protocol, bytes)
|
||||
}
|
||||
fn open<'a>(
|
||||
&'a self,
|
||||
network: NetworkId,
|
||||
peer: iroh::EndpointId,
|
||||
protocol: &'a str,
|
||||
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
|
||||
Box::pin(async move {
|
||||
Ok(impaired(
|
||||
self.inner.open(network, peer, protocol).await?,
|
||||
self.loss.clone(),
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
fn impaired(raw: SharedLink, loss: Arc<AtomicBool>) -> SharedLink {
|
||||
Arc::new(FaultLink {
|
||||
raw,
|
||||
loss,
|
||||
sequence: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct FaultLink {
|
||||
raw: SharedLink,
|
||||
loss: Arc<AtomicBool>,
|
||||
sequence: AtomicU64,
|
||||
}
|
||||
impl PacketLink for FaultLink {
|
||||
fn network(&self) -> NetworkId {
|
||||
self.raw.network()
|
||||
}
|
||||
fn peer(&self) -> iroh::EndpointId {
|
||||
self.raw.peer()
|
||||
}
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
self.raw.max_datagram_size()
|
||||
}
|
||||
fn send(&self, bytes: Bytes) -> Result<(), TransportError> {
|
||||
if self.loss.load(Ordering::Relaxed)
|
||||
&& !self
|
||||
.sequence
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
.is_multiple_of(5)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
self.raw.send(bytes)
|
||||
}
|
||||
}
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
self.raw.recv()
|
||||
}
|
||||
fn closed(&self) -> BoxFuture<'_, ()> {
|
||||
self.raw.closed()
|
||||
}
|
||||
fn is_closed(&self) -> bool {
|
||||
self.raw.is_closed()
|
||||
}
|
||||
fn close(&self) {
|
||||
self.raw.close();
|
||||
}
|
||||
fn path_description(&self) -> String {
|
||||
self.raw.path_description()
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn persistent_runtime_loss_retires_the_pair_and_negotiates_fresh_parameters() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
|
||||
let a = Node::open(roots[0].path(), &discovery, "lossa").await;
|
||||
let b = Node::open(roots[1].path(), &discovery, "lossb").await;
|
||||
let (name, secret) = network("awg-loss");
|
||||
let id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
a.ready(id, 1).await;
|
||||
b.ready(id, 1).await;
|
||||
exchange(&a, &b, id).await;
|
||||
let old = a.transport.overview(id)[0].attempt;
|
||||
let mut events = a.agent.subscribe();
|
||||
a.loss.store(true, Ordering::Relaxed);
|
||||
tsunagi::testing::wait_event(&mut events, |event| match event {
|
||||
tsunagi::Event::DataLinkDown {
|
||||
network, reason, ..
|
||||
} if *network == id && reason.contains("delivery checks") => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
a.loss.store(false, Ordering::Relaxed);
|
||||
wait_until("fresh AWG profile after runtime loss", || async {
|
||||
a.transport
|
||||
.overview(id)
|
||||
.iter()
|
||||
.any(|p| p.attempt != old && !p.closed && !p.path.contains("establishing"))
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
a.ready(id, 1).await;
|
||||
b.ready(id, 1).await;
|
||||
let initiator = if a.agent.endpoint_id().as_bytes() < b.agent.endpoint_id().as_bytes() {
|
||||
&a
|
||||
} else {
|
||||
&b
|
||||
};
|
||||
let attempt = initiator.transport.overview(id)[0].attempt;
|
||||
let received = initiator.transport.overview(id)[0].delivery_checks;
|
||||
wait_until(
|
||||
"fresh physical delivery checks answered after installation",
|
||||
|| async {
|
||||
(initiator.transport.overview(id).first()?.delivery_checks >= received + 3)
|
||||
.then_some(())
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(initiator.transport.overview(id)[0].attempt, attempt);
|
||||
wait_for_peers(&a.agent, id, 1).await;
|
||||
exchange(&a, &b, id).await;
|
||||
exchange(&b, &a, id).await;
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_loss_falls_back_to_real_iroh_without_interrupting_control_or_ip() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
|
||||
let a = Node::open_with_fallback(roots[0].path(), &discovery, "fallba", true).await;
|
||||
let b = Node::open_with_fallback(roots[1].path(), &discovery, "fallbb", true).await;
|
||||
let (name, secret) = network("awg-fallback");
|
||||
let id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
a.ready(id, 1).await;
|
||||
b.ready(id, 1).await;
|
||||
exchange(&a, &b, id).await;
|
||||
let mut events = a.agent.subscribe();
|
||||
a.loss.store(true, Ordering::Relaxed);
|
||||
tsunagi::testing::wait_event(&mut events, |event| match event {
|
||||
tsunagi::Event::DataLinkDown {
|
||||
network, reason, ..
|
||||
} if *network == id && reason.contains("delivery checks") => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
for node in [&a, &b] {
|
||||
wait_until("fallback carrier is wg-quic",||async {
|
||||
let status=node.agent.status().await.ok()?;
|
||||
let network=status.networks.iter().find(|n|n.network_id==id)?;
|
||||
(network.members.iter().any(|m|matches!(&m.data_path,tsunagi::agent::DataPath::Direct{transport} if transport=="wg-quic"))).then_some(())
|
||||
}).await;
|
||||
wait_for_peers(&node.agent, id, 1).await;
|
||||
}
|
||||
exchange(&a, &b, id).await;
|
||||
exchange(&b, &a, id).await;
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn opaque_pair_carrier_fragments_its_full_advertised_capacity() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
|
||||
let mut nodes = Vec::new();
|
||||
for root in &roots {
|
||||
let identity = tsunagi::storage::StateStore::open(
|
||||
tsunagi::StoragePaths::under(root.path()).state_db(),
|
||||
)
|
||||
.unwrap()
|
||||
.load_or_create_device_identity()
|
||||
.unwrap();
|
||||
let transport = AwgTransport::new(identity.endpoint_id(), Ipv4Addr::LOCALHOST);
|
||||
let agent = Agent::spawn(
|
||||
config_with(root.path(), &discovery)
|
||||
.with_custom_transport(AWG_PROTOCOL, transport.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
nodes.push((agent, transport));
|
||||
}
|
||||
let (name, secret) = network("awg-capacity");
|
||||
let id = nodes[0].0.join_network(&name, &secret).await.unwrap();
|
||||
nodes[1].0.join_network(&name, &secret).await.unwrap();
|
||||
for (i, (agent, transport)) in nodes.iter().enumerate() {
|
||||
wait_for_peers(agent, id, 1).await;
|
||||
// This fixture tests only the transport contract: register the member
|
||||
// after the real core has authenticated it, without an IP plugin/TUN.
|
||||
transport.allow_peer(id, nodes[1 - i].0.endpoint_id());
|
||||
}
|
||||
nodes.sort_by_key(|(agent, _)| *agent.endpoint_id().as_bytes());
|
||||
let outgoing = nodes[0]
|
||||
.1
|
||||
.open(id, nodes[1].0.endpoint_id(), AWG_PROTOCOL)
|
||||
.await
|
||||
.unwrap();
|
||||
let incoming = tokio::time::timeout(Duration::from_secs(5), nodes[1].1.next_inbound())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.link;
|
||||
for (from, to) in [(&outgoing, &incoming), (&incoming, &outgoing)] {
|
||||
let mut bytes = vec![0; from.max_datagram_size()];
|
||||
rand::fill(&mut bytes[..]);
|
||||
let bytes = Bytes::from(bytes);
|
||||
from.send(bytes.clone()).unwrap();
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(5), to.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
bytes
|
||||
);
|
||||
assert!(matches!(
|
||||
from.send(Bytes::from(vec![0; from.max_datagram_size() + 1])),
|
||||
Err(TransportError::TooLarge { .. })
|
||||
));
|
||||
}
|
||||
outgoing.close();
|
||||
incoming.close();
|
||||
for (agent, transport) in nodes {
|
||||
transport.shutdown();
|
||||
agent.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn encrypted_health_checks_fit_a_smaller_peers_configured_interface_mtu() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
|
||||
let a = Node::open_config(roots[0].path(), &discovery, "smalla", false, 1280).await;
|
||||
let b = Node::open_config(roots[1].path(), &discovery, "smallb", false, 576).await;
|
||||
let (name, secret) = network("awg-small-mtu");
|
||||
let id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
a.ready(id, 1).await;
|
||||
b.ready(id, 1).await;
|
||||
let bytes = packet(a.addr(id), b.addr(id), 576);
|
||||
a.tun().push_from_os(bytes.clone());
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(5), b.tun().pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
bytes
|
||||
);
|
||||
wait_until(
|
||||
"physical delivery checks do not depend on overlay MTU",
|
||||
|| async {
|
||||
(a.transport.overview(id).first()?.delivery_checks >= 4
|
||||
&& b.transport.overview(id).first()?.delivery_checks >= 4)
|
||||
.then_some(())
|
||||
},
|
||||
)
|
||||
.await;
|
||||
for node in [&a, &b] {
|
||||
assert_eq!(
|
||||
node.plugin.overview(id).unwrap().peers[0]
|
||||
.tunnel
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.stats
|
||||
.dropped_oversize,
|
||||
0
|
||||
);
|
||||
}
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disjoint_external_policies_relay_the_same_internal_tunnel_and_keep_it_when_routes_change()
|
||||
{
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let roots = [
|
||||
TempDir::new().unwrap(),
|
||||
TempDir::new().unwrap(),
|
||||
TempDir::new().unwrap(),
|
||||
];
|
||||
let a = Node::open_selected(
|
||||
roots[0].path(),
|
||||
&discovery,
|
||||
"only-awg",
|
||||
true,
|
||||
1280,
|
||||
Some(vec!["awg".into()]),
|
||||
)
|
||||
.await;
|
||||
let b = Node::open_with_fallback(roots[1].path(), &discovery, "mixed-relay", true).await;
|
||||
let c = Node::open_selected(
|
||||
roots[2].path(),
|
||||
&discovery,
|
||||
"only-quic",
|
||||
true,
|
||||
1280,
|
||||
Some(vec!["wg-quic".into()]),
|
||||
)
|
||||
.await;
|
||||
let (name, secret) = network("independent-internal-encryption");
|
||||
let id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
c.agent.join_network(&name, &secret).await.unwrap();
|
||||
for node in [&a, &b, &c] {
|
||||
wait_for_peers(&node.agent, id, 2).await;
|
||||
}
|
||||
wait_until("AWG-only to QUIC-only through an authenticated mixed relay", || async {
|
||||
let status = a.agent.network_status(id).await.ok()?;
|
||||
let peer = status.peers.iter().find(|p| p.endpoint_id == c.agent.endpoint_id())?;
|
||||
(matches!(peer.data_path, tsunagi::agent::DataPath::Relayed { via, .. } if via == b.agent.endpoint_id()) && a.plugin.overview(id)?.established_peers() == 2 && c.plugin.overview(id)?.established_peers() == 2).then_some(())
|
||||
}).await;
|
||||
exchange(&a, &c, id).await;
|
||||
exchange(&c, &a, id).await;
|
||||
let mut tcp = tcp::TcpPair::connect(a.tun(), a.addr(id), c.tun(), c.addr(id)).await;
|
||||
tcp.exchange(b"SSH-like TCP stream over the initial relay")
|
||||
.await;
|
||||
let key = a.plugin.overview(id).unwrap().public_key;
|
||||
let before = a
|
||||
.plugin
|
||||
.overview(id)
|
||||
.unwrap()
|
||||
.peers
|
||||
.iter()
|
||||
.find(|p| p.endpoint_id == c.agent.endpoint_id())
|
||||
.unwrap()
|
||||
.tunnel
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.stats
|
||||
.tx_packets;
|
||||
// A's additional permission creates a direct QUIC route without replacing its IP tunnel.
|
||||
a.agent
|
||||
.set_protocols(id, vec!["wg-quic".into(), "awg".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
wait_until("direct carrier replaces the relay", || async {
|
||||
let status = a.agent.network_status(id).await.ok()?;
|
||||
matches!(
|
||||
status
|
||||
.peers
|
||||
.iter()
|
||||
.find(|p| p.endpoint_id == c.agent.endpoint_id())?
|
||||
.data_path,
|
||||
tsunagi::agent::DataPath::Direct { .. }
|
||||
)
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
tcp.exchange(b"same TCP socket after changing the carrier and next hop")
|
||||
.await;
|
||||
assert_eq!(a.plugin.overview(id).unwrap().public_key, key);
|
||||
assert!(
|
||||
a.plugin
|
||||
.overview(id)
|
||||
.unwrap()
|
||||
.peers
|
||||
.iter()
|
||||
.find(|p| p.endpoint_id == c.agent.endpoint_id())
|
||||
.unwrap()
|
||||
.tunnel
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.stats
|
||||
.tx_packets
|
||||
> before
|
||||
);
|
||||
a.agent.set_protocols(id, Vec::new()).await.unwrap();
|
||||
// Poll the actual TCP stack while the route is absent: its first transmission is lost.
|
||||
let pending = b"TCP retransmission survives the temporary absence of every data carrier";
|
||||
tcp.queue_while_unreachable(pending);
|
||||
a.agent.set_protocols(id, vec!["awg".into()]).await.unwrap();
|
||||
wait_until("forbidden QUIC removed and relay restored", || async {
|
||||
let status = a.agent.network_status(id).await.ok()?;
|
||||
matches!(
|
||||
status
|
||||
.peers
|
||||
.iter()
|
||||
.find(|p| p.endpoint_id == c.agent.endpoint_id())?
|
||||
.data_path,
|
||||
tsunagi::agent::DataPath::Relayed { .. }
|
||||
)
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
assert_eq!(a.plugin.overview(id).unwrap().public_key, key);
|
||||
tcp.receive_queued(pending).await;
|
||||
tcp.exchange(b"same TCP connection after returning to AWG and the relay")
|
||||
.await;
|
||||
drop(tcp);
|
||||
// Transit ciphertext must never be delivered to the middle agent's IP interface.
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(150), b.tun().pop_to_os())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
for node in [&a, &b, &c] {
|
||||
node.agent.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_common_carrier_is_an_actionable_policy_error_without_disabling_control() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
|
||||
let a = Node::open_selected(
|
||||
roots[0].path(),
|
||||
&discovery,
|
||||
"awg-policy",
|
||||
true,
|
||||
1280,
|
||||
Some(vec!["awg".into()]),
|
||||
)
|
||||
.await;
|
||||
let b = Node::open_selected(
|
||||
roots[1].path(),
|
||||
&discovery,
|
||||
"quic-policy",
|
||||
true,
|
||||
1280,
|
||||
Some(vec!["wg-quic".into()]),
|
||||
)
|
||||
.await;
|
||||
let (name, secret) = network("no-common-carrier");
|
||||
let id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_until("explain independent external policies", || async {
|
||||
let status = a.agent.network_status(id).await.ok()?;
|
||||
let peer = status.peers.first()?;
|
||||
peer.data_error
|
||||
.as_ref()?
|
||||
.contains("no common external transport")
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
a.agent
|
||||
.network_status(id)
|
||||
.await
|
||||
.unwrap()
|
||||
.metrics
|
||||
.data_links_established,
|
||||
0
|
||||
);
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Fresh and bounded masking profiles, and rejection of foreign/truncated wire packets.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use tsunagi_awg::profile::PairProfile;
|
||||
|
||||
#[test]
|
||||
fn profiles_are_fresh_bounded_and_redacted() {
|
||||
let profiles: Vec<_> = (0..128).map(|_| PairProfile::generate()).collect();
|
||||
for (i, profile) in profiles.iter().enumerate() {
|
||||
profile.validate().unwrap();
|
||||
assert!(!format!("{profile:?}").contains(&format!("{:?}", profile.header_key())));
|
||||
for other in &profiles[..i] {
|
||||
assert_ne!(profile.header_key(), other.header_key());
|
||||
assert_ne!(profile.id(), other.id());
|
||||
}
|
||||
for packet in profile.signature_packets() {
|
||||
assert!(packet.len() < 1200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_packets_round_trip_and_reject_other_profiles_and_truncation() {
|
||||
let profile = PairProfile::generate();
|
||||
let other = PairProfile::generate();
|
||||
for (kind, size) in [(1, 148), (2, 92), (3, 64), (4, 800)] {
|
||||
let mut message = vec![0x7b; size];
|
||||
message[..4].copy_from_slice(&(kind as u32).to_le_bytes());
|
||||
let wire = profile.encode(&message).unwrap();
|
||||
assert_ne!(&wire[..4], &message[..4]);
|
||||
assert_eq!(profile.decode(&wire).unwrap(), message);
|
||||
assert!(other.decode(&wire).is_none());
|
||||
for length in 0..44 {
|
||||
assert!(profile.decode(&wire[..length]).is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
[package]
|
||||
name = "tsunagi-cli"
|
||||
version = "0.1.0"
|
||||
description = "The tsunagi command line agent."
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "tsunagi"
|
||||
path = "src/main.rs"
|
||||
# The binary and the library share a name, which `cargo doc` cannot map to
|
||||
# two directories. The command line is documented by `--help`, not rustdoc,
|
||||
# so the library keeps the name and this opts out.
|
||||
doc = false
|
||||
|
||||
[dependencies]
|
||||
tsunagi = { path = "../tsunagi", version = "0.1.0", features = ["tun-device", "dns-publish"] }
|
||||
tsunagi-wg-quic = { path = "../tsunagi-wg-quic", version = "0.1.0" }
|
||||
iroh.workspace = true
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "signal"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
# Already in the tree through clap. `anstream` strips the escapes when stdout
|
||||
# is not a terminal and turns on virtual terminal processing on Windows, so
|
||||
# colour is never written where it would show up as rubbish.
|
||||
anstream = "1.0"
|
||||
anstyle = "1.0"
|
||||
# Local interface addresses, for the diagnostics in `status`.
|
||||
netwatch = "0.19.3"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
||||
tempfile.workspace = true
|
||||
simple-dns = "0.12"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,56 +0,0 @@
|
||||
//! What the command line says about this device, against a running agent.
|
||||
//!
|
||||
//! These drive the real binary, which is why they live beside it rather
|
||||
//! than with the library's own tests: the library cannot depend on a binary
|
||||
//! built from a crate that depends on it.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use tempfile::TempDir;
|
||||
use tsunagi::Agent;
|
||||
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
||||
|
||||
/// Asking who this device is must not need the directory lock.
|
||||
///
|
||||
/// The lock belongs to the one agent allowed to *write* the state. `id` only
|
||||
/// reads, so making it take the lock would mean the question could never be
|
||||
/// answered while an agent was running — which is exactly when you want to
|
||||
/// ask it.
|
||||
#[tokio::test]
|
||||
async fn identity_can_be_read_while_an_agent_holds_the_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// Hold the directory the way a running agent does.
|
||||
let agent = Agent::spawn(
|
||||
AgentConfig::new(StoragePaths::under(dir.path()))
|
||||
.with_transport(TransportPolicy::LocalOnly)
|
||||
.with_loopback_bind(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected = agent.endpoint_id().to_string();
|
||||
|
||||
let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
.arg("id")
|
||||
// The same layout `StoragePaths::under` gives the agent above.
|
||||
.arg("--state-dir")
|
||||
.arg(dir.path().join("state"))
|
||||
.arg("--cache-dir")
|
||||
.arg(dir.path().join("cache"))
|
||||
.output()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"`id` failed while an agent was running:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains(&expected),
|
||||
"expected {expected} in:\n{stdout}"
|
||||
);
|
||||
|
||||
agent.shutdown().await;
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
//! Making and joining a network from the command line, against a real agent.
|
||||
//!
|
||||
//! The rules here are small and easy to get wrong in a way no unit test
|
||||
//! notices: what a bare network name means, when a secret is invented, and
|
||||
//! whether what is printed is enough for the other person to paste.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// An agent running as a real process, with its own directories.
|
||||
struct Running {
|
||||
child: std::process::Child,
|
||||
dir: TempDir,
|
||||
}
|
||||
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Running {
|
||||
fn run(&self, args: &[&str]) -> std::process::Output {
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
.args(args)
|
||||
.arg("--state-dir")
|
||||
.arg(self.dir.path().join("state"))
|
||||
.arg("--cache-dir")
|
||||
.arg(self.dir.path().join("cache"))
|
||||
.output()
|
||||
.expect("the agent binary runs")
|
||||
}
|
||||
}
|
||||
|
||||
/// An agent with no network at all, the way a daemon is started before
|
||||
/// anything has been decided.
|
||||
fn start_bare(port: u16) -> Running {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
.arg("up")
|
||||
.arg("--state-dir")
|
||||
.arg(dir.path().join("state"))
|
||||
.arg("--cache-dir")
|
||||
.arg(dir.path().join("cache"))
|
||||
.args(["--reach", "local", "--no-tun", "--no-dns"])
|
||||
.arg("--bind")
|
||||
.arg(format!("127.0.0.1:{port}"))
|
||||
.args(["--log", "error", "--status-interval", "0"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.expect("the agent binary starts");
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
Running { child, dir }
|
||||
}
|
||||
|
||||
/// An agent already in one network, the way most of these start.
|
||||
fn start(network: &str, port: u16) -> Running {
|
||||
let agent = start_bare(port);
|
||||
let joined = agent.run(&[
|
||||
"join",
|
||||
"--network",
|
||||
network,
|
||||
"--secret",
|
||||
"a-secret-for-the-cli-test",
|
||||
]);
|
||||
assert!(
|
||||
joined.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&joined.stderr)
|
||||
);
|
||||
agent
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joining_with_no_secret_makes_one_and_prints_what_to_send() {
|
||||
// The hurry case: a network with somebody, for as long as it is needed,
|
||||
// and then gone. Asking for a secret first is a step with no purpose,
|
||||
// and a secret that is not printed is a network nobody else can join.
|
||||
let agent = start("resident", 45071);
|
||||
|
||||
let joined = agent.run(&["network", "join", "--network", "spontaneous"]);
|
||||
assert!(
|
||||
joined.status.success(),
|
||||
"joining failed: {}",
|
||||
String::from_utf8_lossy(&joined.stderr)
|
||||
);
|
||||
let out = String::from_utf8_lossy(&joined.stdout);
|
||||
assert!(out.contains("joined `spontaneous`"), "{out}");
|
||||
|
||||
// The secret in full, and one line the other side can paste as it is.
|
||||
let secret = out
|
||||
.lines()
|
||||
.find_map(|line| line.trim().strip_prefix("secret "))
|
||||
.expect("the invented secret is printed");
|
||||
assert!(secret.starts_with("tsn1"), "{out}");
|
||||
let command = out
|
||||
.lines()
|
||||
.find(|line| line.contains("tsunagi join --network spontaneous"))
|
||||
.expect("a command to send");
|
||||
assert!(command.contains(secret), "with the secret in it: {out}");
|
||||
// And where to find this device, for an agent that is not up yet.
|
||||
assert!(out.contains("--peer "), "somewhere to find us: {out}");
|
||||
|
||||
// Joining the same name again resumes it rather than making another
|
||||
// network that merely looks the same.
|
||||
let again = agent.run(&["network", "join", "--network", "spontaneous"]);
|
||||
let out = String::from_utf8_lossy(&again.stdout);
|
||||
assert!(again.status.success());
|
||||
assert!(out.contains("already here"), "{out}");
|
||||
assert!(!out.contains("secret tsn1"), "no second secret: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_name_resumes_the_network_of_that_name_rather_than_inventing_one() {
|
||||
// `join --network resident` with no secret is the same rule: this device
|
||||
// has exactly one network of that name, so that is the one meant.
|
||||
let agent = start("resident", 45072);
|
||||
let listed = agent.run(&["network"]);
|
||||
let before = String::from_utf8_lossy(&listed.stdout).to_string();
|
||||
let id = before
|
||||
.lines()
|
||||
.find(|line| line.contains("resident"))
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.expect("the network is listed")
|
||||
.to_string();
|
||||
|
||||
let joined = agent.run(&["network", "join", "--network", "resident"]);
|
||||
assert!(joined.status.success());
|
||||
let out = String::from_utf8_lossy(&joined.stdout);
|
||||
assert!(out.contains(&id), "the same network, not a new one: {out}");
|
||||
assert!(out.contains("already here"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_can_be_stopped_and_resumed_without_losing_anything() {
|
||||
// Leaving gives everything up; stopping is being away. The difference
|
||||
// is what somebody wants when they will be back.
|
||||
let agent = start("resident", 45073);
|
||||
let listed = agent.run(&["network"]);
|
||||
let out = String::from_utf8_lossy(&listed.stdout).to_string();
|
||||
let id = out
|
||||
.lines()
|
||||
.find(|line| line.contains("resident"))
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.expect("the network is listed")
|
||||
.to_string();
|
||||
let secret_before = agent.run(&["network", "secret", &id[..10]]);
|
||||
let secret_before = String::from_utf8_lossy(&secret_before.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
assert!(secret_before.starts_with("tsn1"));
|
||||
|
||||
let stopped = agent.run(&["network", "stop", &id[..10]]);
|
||||
assert!(
|
||||
stopped.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&stopped.stderr)
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&stopped.stdout).contains("stopped `resident`"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&stopped.stdout)
|
||||
);
|
||||
|
||||
// Still configured, and said to be stopped rather than missing.
|
||||
let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string();
|
||||
assert!(listed.contains(&id), "still configured: {listed}");
|
||||
assert!(listed.contains("stopped"), "{listed}");
|
||||
|
||||
// Stopping what is stopped is the state asked for, not an error.
|
||||
let again = agent.run(&["network", "stop", &id[..10]]);
|
||||
assert!(again.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&again.stdout).contains("already stopped"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&again.stdout)
|
||||
);
|
||||
|
||||
let started = agent.run(&["network", "start", &id[..10]]);
|
||||
assert!(started.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&started.stdout).contains("started `resident`"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&started.stdout)
|
||||
);
|
||||
let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string();
|
||||
assert!(listed.contains("running"), "{listed}");
|
||||
|
||||
// And nothing was given up on the way: the same network, same secret.
|
||||
let secret_after = agent.run(&["network", "secret", &id[..10]]);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&secret_after.stdout).trim(),
|
||||
secret_before,
|
||||
"the secret is kept, so this is the same network"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_starts_with_no_network_and_takes_one_later() {
|
||||
// A daemon in one terminal and the deciding done in another: the
|
||||
// agent is the identity, and which networks it is in is a separate
|
||||
// question it can answer at any time.
|
||||
let agent = start_bare(45074);
|
||||
|
||||
let listed = agent.run(&["network"]);
|
||||
assert!(listed.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&listed.stderr).contains("no network has been joined"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&listed.stderr)
|
||||
);
|
||||
|
||||
let joined = agent.run(&["network", "join", "--network", "afterwards"]);
|
||||
assert!(
|
||||
joined.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&joined.stderr)
|
||||
);
|
||||
let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string();
|
||||
assert!(listed.contains("afterwards"), "{listed}");
|
||||
assert!(listed.contains("running"), "and it is live: {listed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_is_the_agent_and_takes_no_network_at_all() {
|
||||
// The two commands are separate on purpose: `up` runs the device's
|
||||
// agent, `join` decides what it is in. A network on `up` would be a
|
||||
// second way to do the same thing, and the one that cannot be undone
|
||||
// without a restart.
|
||||
for argument in ["--network", "--secret"] {
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
.args(["up", argument, "whatever"])
|
||||
.arg("--state-dir")
|
||||
.arg(TempDir::new().unwrap().path().join("state"))
|
||||
.output()
|
||||
.expect("the agent binary runs");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"`up {argument}` should not be a thing"
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("unexpected argument"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
[package]
|
||||
name = "tsunagi-core"
|
||||
version = "0.1.0"
|
||||
description = "Small private mesh networks: persistent agent identity, deterministic network spaces, an iroh control plane, signed state and a local DNS view."
|
||||
readme = "../../README.md"
|
||||
keywords = ["mesh", "p2p", "iroh", "networking"]
|
||||
categories = ["network-programming"]
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "tsunagi"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# A real TUN device, so a plugin can carry actual IP traffic. On Linux it
|
||||
# needs CAP_NET_ADMIN; on Windows the process must run elevated and
|
||||
# `wintun.dll` must be reachable. Without either the in-memory device serves
|
||||
# tests. The platform-specific dependencies are declared per target, so
|
||||
# enabling this only pulls what the target it is built for actually uses.
|
||||
tun-device = [
|
||||
"dep:tun",
|
||||
"dep:rtnetlink",
|
||||
"dep:caps",
|
||||
"dep:futures-util",
|
||||
]
|
||||
# Telling the operating system where to send its DNS questions. systemd-resolved
|
||||
# on Linux, the Name Resolution Policy Table on Windows; the zone and the
|
||||
# server work without it either way.
|
||||
dns-publish = ["dep:zbus"]
|
||||
# The test harness in `testing`, for this crate's own tests and for a
|
||||
# protocol crate's. Not on by default: it is only of use to a test.
|
||||
testing = ["dep:tempfile", "dep:tracing-subscriber"]
|
||||
|
||||
[dependencies]
|
||||
arc-swap = "1.9"
|
||||
iroh.workspace = true
|
||||
# `net` for the local control interface: a Unix socket on Unix, a named pipe
|
||||
# on Windows. Both live behind the same `net` feature.
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
tokio-stream = "0.1"
|
||||
serde.workspace = true
|
||||
postcard.workspace = true
|
||||
bytes.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
hex.workspace = true
|
||||
data-encoding.workspace = true
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
hkdf = "0.13"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.9", features = ["derive"] }
|
||||
rand = "0.10"
|
||||
# Already in the tree through iroh. A packet codec, not a DNS server: the
|
||||
# zone logic is ours and a full server framework would be a large dependency
|
||||
# for answering A records from memory.
|
||||
simple-dns = "0.12"
|
||||
# The IANA top-level domain list, compiled in: one function, no
|
||||
# dependencies, no network. Used only to warn that a zone name shadows a
|
||||
# real public domain, never to refuse one.
|
||||
tld = "2.40"
|
||||
fs4 = { version = "1.1", features = ["sync"] }
|
||||
directories = "6.0"
|
||||
# The real system hostname, without a libc call of our own: this crate is
|
||||
# forbidden `unsafe` and will not make one.
|
||||
gethostname = "1.1"
|
||||
netwatch = "0.19.3"
|
||||
netdev = "0.46"
|
||||
mainline = "8.0"
|
||||
futures-lite = "2.6"
|
||||
tun = { version = "0.8", features = ["async"], optional = true }
|
||||
# Only for the `testing` harness, which a protocol crate's tests use too.
|
||||
tempfile = { version = "3.24", optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
|
||||
# Linux-only interface provisioning. `rtnetlink` configures the interface in
|
||||
# process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN
|
||||
# out of the effective set except during the moments it is used.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
rtnetlink = { version = "0.23", optional = true }
|
||||
# Pure Rust D-Bus, no libdbus to link against. `tokio` rather than the
|
||||
# default reactor, because the agent brings its own.
|
||||
zbus = { version = "5.19", default-features = false, features = ["tokio"], optional = true }
|
||||
caps = { version = "0.5", optional = true }
|
||||
futures-util = { version = "0.3", default-features = false, optional = true }
|
||||
|
||||
# Windows-only interface provisioning. The Wintun adapter itself is created
|
||||
# and driven through the `tun` dependency, whose safe wrapper is the only way
|
||||
# in — loading `wintun.dll` is an `unsafe` call this crate forbids. `netdev`
|
||||
# (already in the tree through `netwatch`) reads the interface table through a
|
||||
# safe wrapper, which is how the provisioner observes an adapter by name
|
||||
# before deciding what to change. Creating an adapter needs the process to be
|
||||
# elevated and `wintun.dll` to be reachable at run time.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Security",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Foundation",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Its own tests use the harness it publishes.
|
||||
tsunagi-core = { path = ".", features = ["testing"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process", "test-util"] }
|
||||
tempfile.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -129,6 +129,19 @@ pub enum Event {
|
||||
/// Why it is not up.
|
||||
reason: String,
|
||||
},
|
||||
/// The name this agent claimed was claimed first by another member.
|
||||
///
|
||||
/// Nothing stops working: this agent keeps its address and every
|
||||
/// connection. It just is not found by that name until it is renamed or
|
||||
/// the member that holds the name lets it go.
|
||||
HostnameTaken {
|
||||
/// The network.
|
||||
network: NetworkId,
|
||||
/// The name that is taken.
|
||||
name: String,
|
||||
/// The member that holds it.
|
||||
holder: EndpointId,
|
||||
},
|
||||
/// An IP plugin reported an error. Never fatal.
|
||||
PluginError {
|
||||
/// The network the call was scoped to.
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -70,20 +70,24 @@ pub(crate) struct Session {
|
||||
pub(crate) conn: Connection,
|
||||
/// Already encoded frame payloads, so the runtime can account for the exact
|
||||
/// number of control bytes it queues.
|
||||
pub(crate) outbound: mpsc::Sender<Vec<u8>>,
|
||||
pub(crate) outbound: mpsc::Sender<zeroize::Zeroizing<Vec<u8>>>,
|
||||
pub(crate) hostname: Option<String>,
|
||||
pub(crate) capabilities: Vec<PluginCapability>,
|
||||
pub(crate) broadcast: bool,
|
||||
/// The peer offers itself as an exit node in this network.
|
||||
pub(crate) exit_node: bool,
|
||||
pub(crate) messages_sent: u64,
|
||||
pub(crate) messages_received: u64,
|
||||
pub(crate) bytes_sent: u64,
|
||||
pub(crate) bytes_received: u64,
|
||||
reader: JoinHandle<()>,
|
||||
writer: JoinHandle<()>,
|
||||
path_monitor: JoinHandle<()>,
|
||||
shutdown: Shutdown,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Signals both tasks to stop and waits for them, with a bounded grace
|
||||
/// Signals all tasks to stop and waits for them, with a bounded grace
|
||||
/// period.
|
||||
///
|
||||
/// A peer that stops reading must not be able to hold up shutdown, so the
|
||||
@@ -93,6 +97,7 @@ impl Session {
|
||||
conn,
|
||||
reader,
|
||||
writer,
|
||||
path_monitor,
|
||||
shutdown,
|
||||
..
|
||||
} = self;
|
||||
@@ -102,23 +107,27 @@ impl Session {
|
||||
|
||||
let reader_abort = reader.abort_handle();
|
||||
let writer_abort = writer.abort_handle();
|
||||
let path_abort = path_monitor.abort_handle();
|
||||
let joined = tokio::time::timeout(STOP_GRACE, async move {
|
||||
let _ = reader.await;
|
||||
let _ = writer.await;
|
||||
let _ = path_monitor.await;
|
||||
})
|
||||
.await;
|
||||
if joined.is_err() {
|
||||
tracing::debug!("session tasks did not wind down in time; aborting them");
|
||||
reader_abort.abort();
|
||||
writer_abort.abort();
|
||||
path_abort.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Aborts both tasks without waiting. Used on replacement.
|
||||
/// Aborts all tasks without waiting. Used on replacement.
|
||||
pub(crate) fn abort(&self) {
|
||||
self.shutdown.trigger();
|
||||
self.reader.abort();
|
||||
self.writer.abort();
|
||||
self.path_monitor.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +163,52 @@ pub(crate) fn spawn(
|
||||
parent_shutdown.clone(),
|
||||
));
|
||||
|
||||
let path_conn = conn.clone();
|
||||
let path_shutdown = shutdown.clone();
|
||||
let path_parent = parent_shutdown.clone();
|
||||
let path_monitor = tokio::spawn(async move {
|
||||
use tokio_stream::StreamExt;
|
||||
let mut events = path_conn.path_events();
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = path_shutdown.wait() => break,
|
||||
_ = path_parent.wait() => break,
|
||||
ev = events.next() => {
|
||||
match ev {
|
||||
Some(iroh::endpoint::PathEvent::Selected { remote_addr, local_addr, .. }) => {
|
||||
tracing::debug!(
|
||||
peer = %peer.fmt_short(),
|
||||
remote = %remote_addr,
|
||||
local = ?local_addr,
|
||||
"Connection path selected (direct / relay / hole punch switch)"
|
||||
);
|
||||
}
|
||||
Some(iroh::endpoint::PathEvent::Opened { remote_addr, .. }) => {
|
||||
tracing::debug!(
|
||||
peer = %peer.fmt_short(),
|
||||
remote = %remote_addr,
|
||||
"Network path opened to peer"
|
||||
);
|
||||
}
|
||||
Some(iroh::endpoint::PathEvent::Closed { remote_addr, .. }) => {
|
||||
tracing::debug!(
|
||||
peer = %peer.fmt_short(),
|
||||
remote = %remote_addr,
|
||||
"Network path closed for peer"
|
||||
);
|
||||
}
|
||||
Some(iroh::endpoint::PathEvent::Lagged { missed, .. }) => {
|
||||
tracing::trace!(peer = %peer.fmt_short(), missed, "Path events lagged");
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let reader = tokio::spawn(reader_task(
|
||||
id,
|
||||
network_id,
|
||||
@@ -174,19 +229,22 @@ pub(crate) fn spawn(
|
||||
outbound: outbound_tx,
|
||||
hostname: None,
|
||||
capabilities: Vec::new(),
|
||||
broadcast: false,
|
||||
exit_node: false,
|
||||
messages_sent: 0,
|
||||
messages_received: 0,
|
||||
bytes_sent: 0,
|
||||
bytes_received: 0,
|
||||
reader,
|
||||
writer,
|
||||
path_monitor,
|
||||
shutdown,
|
||||
}
|
||||
}
|
||||
|
||||
async fn writer_task(
|
||||
mut send: SendStream,
|
||||
mut outbound: mpsc::Receiver<Vec<u8>>,
|
||||
mut outbound: mpsc::Receiver<zeroize::Zeroizing<Vec<u8>>>,
|
||||
limits: Arc<Limits>,
|
||||
shutdown: Shutdown,
|
||||
parent: Shutdown,
|
||||
@@ -273,6 +331,7 @@ async fn reader_task(
|
||||
}
|
||||
};
|
||||
|
||||
let payload = zeroize::Zeroizing::new(payload);
|
||||
let bytes = payload.len();
|
||||
let envelope: Envelope = match decode(&payload) {
|
||||
Ok(envelope) => envelope,
|
||||
File renamed without changes.
@@ -53,6 +53,47 @@ pub struct OverlayStatus {
|
||||
pub addresses: Vec<crate::overlay::Cidr>,
|
||||
/// What has happened on it.
|
||||
pub counters: crate::overlay::Counters,
|
||||
/// The route and firewall allowance installed for LAN broadcast, when
|
||||
/// some network has broadcast on and the interface is on the host.
|
||||
/// `None` is "none installed", not "unknown".
|
||||
pub broadcast_rules: Option<crate::overlay::BroadcastRulesReport>,
|
||||
}
|
||||
|
||||
/// How packets reach a member right now.
|
||||
///
|
||||
/// Being reachable is one thing and being reachable well is another, and
|
||||
/// the second is never a reason to call a member away: a member that can only
|
||||
/// be reached through somebody else is online, over a longer path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DataPath {
|
||||
/// Nothing carries packets to it yet. The member is connected for
|
||||
/// control only, which is the state of a data path being set up.
|
||||
None,
|
||||
/// A direct link, of the named kind of transport.
|
||||
Direct {
|
||||
/// The kind of transport carrying it, e.g. `wg`.
|
||||
transport: String,
|
||||
},
|
||||
/// Through other members.
|
||||
Relayed {
|
||||
/// Number of links on the way, at least two.
|
||||
hops: u8,
|
||||
/// The member the first of them goes to.
|
||||
via: EndpointId,
|
||||
},
|
||||
}
|
||||
|
||||
/// A name that a member claimed and somebody else holds.
|
||||
///
|
||||
/// A name belongs to whoever claimed it first. The member that claimed it
|
||||
/// later is still a member and still reachable by its address; it is just
|
||||
/// not found by that name, on any member of the network.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HostnameConflict {
|
||||
/// The name that is taken.
|
||||
pub name: String,
|
||||
/// The member that holds it.
|
||||
pub holder: EndpointId,
|
||||
}
|
||||
|
||||
/// A member the signed state knows about, connected or not.
|
||||
@@ -67,6 +108,8 @@ pub struct OverlayStatus {
|
||||
/// connected.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemberStatus {
|
||||
/// Current signed claim version, used to bind cleanup confirmation.
|
||||
pub record_version: u64,
|
||||
/// The member's device identity.
|
||||
pub endpoint_id: EndpointId,
|
||||
/// The IPv4 overlay address it claimed and signed for.
|
||||
@@ -76,11 +119,29 @@ pub struct MemberStatus {
|
||||
/// Signed, so it is still known while the member is away — which is what
|
||||
/// lets an absent member be named rather than shown as a bare id.
|
||||
pub hostname: Option<String>,
|
||||
/// The last name this agent saw it announce, whether or not it is here.
|
||||
///
|
||||
/// Not signed, so it is a convenience for naming a member that is away
|
||||
/// and never a claim: the zone is served from `hostname`.
|
||||
pub last_hostname: Option<String>,
|
||||
/// The name it claimed, when somebody else claimed it first.
|
||||
pub hostname_conflict: Option<HostnameConflict>,
|
||||
/// Whether this agent can reach it now, by any path: a live control
|
||||
/// session, or a data path through other members. The one answer to "is
|
||||
/// it online" for every client, so that none of them has to work it out
|
||||
/// from a different part of the picture. This agent counts as online.
|
||||
pub online: bool,
|
||||
/// How packets reach it. [`DataPath::None`] while it is offline.
|
||||
pub data_path: DataPath,
|
||||
}
|
||||
|
||||
/// Status of one authenticated session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeerStatus {
|
||||
/// This authenticated peer accepts network broadcasts.
|
||||
pub broadcast: bool,
|
||||
/// This authenticated peer offers itself as an exit node.
|
||||
pub exit_node: bool,
|
||||
/// Authenticated endpoint id.
|
||||
pub endpoint_id: EndpointId,
|
||||
/// Which side this agent played in the handshake.
|
||||
@@ -97,6 +158,8 @@ pub struct PeerStatus {
|
||||
pub protocols: Vec<String>,
|
||||
/// Capabilities the peer announced. Payloads stay opaque.
|
||||
pub capabilities: Vec<PluginCapability>,
|
||||
/// Why no data route is available, when known.
|
||||
pub data_error: Option<String>,
|
||||
/// How long the session has been up.
|
||||
pub connected_for: Duration,
|
||||
/// Verified paths of the underlying connection.
|
||||
@@ -115,6 +178,8 @@ pub struct PeerStatus {
|
||||
pub control_bytes_sent: u64,
|
||||
/// Control payload bytes read from this session.
|
||||
pub control_bytes_received: u64,
|
||||
/// How packets reach this peer right now.
|
||||
pub data_path: DataPath,
|
||||
}
|
||||
|
||||
/// Counters scoped to one logical network.
|
||||
@@ -148,9 +213,36 @@ pub struct NetworkMetrics {
|
||||
pub data_link_failures: u64,
|
||||
}
|
||||
|
||||
/// An active network preventing another network from starting locally.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RangeConflict {
|
||||
/// The blocking network's public identifier.
|
||||
pub network_id: NetworkId,
|
||||
/// Its configured name.
|
||||
pub name: NetworkName,
|
||||
/// Its overlapping overlay range.
|
||||
pub range: crate::state::Ipv4Range,
|
||||
}
|
||||
|
||||
/// Status of one network.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetworkStatus {
|
||||
/// Shared service configuration with this client's routing observations.
|
||||
pub vips: Vec<crate::vip::VipStatus>,
|
||||
/// Enabled local data-plane protocols, in preference order.
|
||||
pub protocols: Vec<String>,
|
||||
/// Whether this network is requested to start or resume automatically.
|
||||
pub auto_start: bool,
|
||||
/// Local broadcast participation in this network.
|
||||
pub broadcast: bool,
|
||||
/// This agent offers itself as an exit node in this network.
|
||||
pub exit_offer: bool,
|
||||
/// The member this device sends all its internet traffic through.
|
||||
pub exit_via: Option<EndpointId>,
|
||||
/// Whether that member is connected and still offering to be one.
|
||||
pub exit_via_online: bool,
|
||||
/// What the host rules for the exit settings are doing.
|
||||
pub exit_rules: crate::overlay::ExitRulesReport,
|
||||
/// Immutable deterministic description of the network space.
|
||||
pub descriptor: NetworkDescriptor,
|
||||
/// Network name, for convenience.
|
||||
@@ -165,15 +257,16 @@ pub struct NetworkStatus {
|
||||
pub candidates: Vec<CandidateStatus>,
|
||||
/// Members the signed state knows about, whether connected or not.
|
||||
pub members: Vec<MemberStatus>,
|
||||
/// This agent's own name, when another member claimed it first and so
|
||||
/// holds it. This agent keeps working by address; nobody finds it by
|
||||
/// this name until it is renamed or the other member lets it go.
|
||||
pub hostname_conflict: Option<HostnameConflict>,
|
||||
/// The overlay range this network uses, once it has one.
|
||||
pub range: Option<crate::state::Ipv4Range>,
|
||||
/// The range it could not have, because another network on this agent
|
||||
/// already holds it.
|
||||
///
|
||||
/// One agent has one interface, so an address belongs to one network.
|
||||
/// This network waits to adopt whatever its members settle on instead of
|
||||
/// proposing something it could not route.
|
||||
/// The first overlapping active range; `range_conflicts` identifies all blockers.
|
||||
pub range_conflict: Option<crate::state::Ipv4Range>,
|
||||
/// Active networks that would be suspended by switching to this network.
|
||||
pub range_conflicts: Vec<RangeConflict>,
|
||||
/// Per-network counters.
|
||||
pub metrics: NetworkMetrics,
|
||||
/// What has gone through a peer in the middle, in both directions.
|
||||
@@ -12,9 +12,13 @@ use std::time::Duration;
|
||||
|
||||
use crate::dataplane::SharedPlugin;
|
||||
use crate::discovery::{MainlineDiscovery, NetworkDiscovery};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::error::Result;
|
||||
|
||||
/// Qualifier/organisation/application triple used for platform directories.
|
||||
///
|
||||
/// Unused on macOS, where the default paths are fixed system locations rather
|
||||
/// than per-user directories derived from this.
|
||||
#[cfg_attr(target_os = "macos", allow(dead_code))]
|
||||
const APP_NAME: &str = "tsunagi";
|
||||
|
||||
/// Rendezvous slots per network; this bounds a discovery sample, not membership.
|
||||
@@ -74,17 +78,34 @@ impl StoragePaths {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-user platform directories.
|
||||
/// The default platform directories.
|
||||
///
|
||||
/// A future system service can supply its own paths instead.
|
||||
/// Per user on Linux and Windows. On macOS the agent runs as a root
|
||||
/// LaunchDaemon (creating a utun interface needs root, and `$HOME` under
|
||||
/// `sudo` points at the wrong place), so the stores live in the system
|
||||
/// locations instead — `/var/db/tsunagi` for the mandatory state and a
|
||||
/// disposable cache beside it. A caller that wants somewhere else passes
|
||||
/// explicit paths to [`StoragePaths::new`] or [`StoragePaths::under`].
|
||||
pub fn user_default() -> Result<Self> {
|
||||
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
|
||||
Error::Storage("no valid home directory for platform config paths".into())
|
||||
})?;
|
||||
Ok(Self {
|
||||
state_dir: dirs.data_dir().to_path_buf(),
|
||||
cache_dir: dirs.cache_dir().to_path_buf(),
|
||||
})
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Ok(Self {
|
||||
state_dir: PathBuf::from("/var/db/tsunagi"),
|
||||
cache_dir: PathBuf::from("/var/db/tsunagi/cache"),
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
|
||||
crate::error::Error::Storage(
|
||||
"no valid home directory for platform config paths".into(),
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
state_dir: dirs.data_dir().to_path_buf(),
|
||||
cache_dir: dirs.cache_dir().to_path_buf(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Path of the mandatory state database.
|
||||
@@ -137,6 +158,22 @@ pub enum TransportPolicy {
|
||||
/// object where that is possible (notably [`Limits::max_frame_len`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Maximum active VIP identities per network.
|
||||
pub max_vips: usize,
|
||||
/// Maximum permanent VIP deletion tombstones per network.
|
||||
pub max_vip_tombstones: usize,
|
||||
/// Maximum concurrent active TCP port checks across the agent.
|
||||
pub max_vip_checks: usize,
|
||||
/// Maximum signed fields per VIP, including removed fields.
|
||||
pub max_vip_fields: usize,
|
||||
/// Maximum listeners per VIP.
|
||||
pub max_vip_listeners: usize,
|
||||
/// Maximum backend endpoints per listener.
|
||||
pub max_vip_backends: usize,
|
||||
/// Maximum simultaneous local VIP flow bindings.
|
||||
pub max_vip_flows: usize,
|
||||
/// Maximum incomplete IPv4 datagrams per network in VIP reassembly.
|
||||
pub max_vip_fragments: usize,
|
||||
/// Largest accepted control frame payload, in bytes.
|
||||
pub max_frame_len: usize,
|
||||
/// Largest accepted hostname, in bytes.
|
||||
@@ -145,12 +182,16 @@ pub struct Limits {
|
||||
pub max_capabilities: usize,
|
||||
/// Largest opaque plugin payload, in bytes.
|
||||
pub max_capability_data_len: usize,
|
||||
/// Largest private transport negotiation payload (also hard-capped at 4096 bytes).
|
||||
pub max_transport_payload_len: usize,
|
||||
/// Largest accepted echo payload in a ping/pong exchange, in bytes.
|
||||
pub max_echo_payload_len: usize,
|
||||
/// Largest accepted free-text reason string, in bytes.
|
||||
pub max_reason_len: usize,
|
||||
/// Largest number of signed records accepted in one snapshot.
|
||||
pub max_state_records: usize,
|
||||
/// Maximum durable retirement markers retained per network.
|
||||
pub max_retirements: usize,
|
||||
/// Deadline for the whole handshake.
|
||||
pub handshake_timeout: Duration,
|
||||
/// Deadline for one outbound dial attempt.
|
||||
@@ -180,13 +221,23 @@ pub struct Limits {
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_vips: 64,
|
||||
max_vip_tombstones: 1024,
|
||||
max_vip_checks: 32,
|
||||
max_vip_fields: 192,
|
||||
max_vip_listeners: 16,
|
||||
max_vip_backends: 32,
|
||||
max_vip_flows: 4096,
|
||||
max_vip_fragments: 64,
|
||||
max_frame_len: 64 * 1024,
|
||||
max_hostname_len: 255,
|
||||
max_capabilities: 16,
|
||||
max_transport_payload_len: 4096,
|
||||
max_capability_data_len: 4 * 1024,
|
||||
max_echo_payload_len: 4 * 1024,
|
||||
max_reason_len: 256,
|
||||
max_state_records: crate::state::MAX_RECORDS_PER_MESSAGE,
|
||||
max_retirements: 1024,
|
||||
handshake_timeout: Duration::from_secs(10),
|
||||
dial_timeout: Duration::from_secs(10),
|
||||
write_timeout: Duration::from_secs(30),
|
||||
@@ -302,8 +353,16 @@ pub struct AgentConfig {
|
||||
pub limits: Limits,
|
||||
/// Reconnect backoff policy.
|
||||
pub reconnect: ReconnectPolicy,
|
||||
/// IP plugins whose capabilities are announced and dispatched.
|
||||
pub plugins: Vec<SharedPlugin>,
|
||||
/// External data carriers whose capabilities are announced and dispatched.
|
||||
pub carriers: Vec<crate::dataplane::transport::SharedCarrier>,
|
||||
/// The single internal end-to-end engine, independent of carrier selection.
|
||||
pub tunnel: Option<SharedPlugin>,
|
||||
/// Initial ordered selection for networks without a saved override.
|
||||
/// `None` enables all registered carriers; an empty list is control-only.
|
||||
pub default_protocols: Option<Vec<String>>,
|
||||
/// Custom packet transports registered per protocol.
|
||||
pub custom_transports:
|
||||
std::collections::HashMap<String, Arc<dyn crate::dataplane::PacketTransport>>,
|
||||
/// Where the overlay interface comes from, when the agent should have one.
|
||||
///
|
||||
/// One agent, one interface: it belongs here rather than to a protocol,
|
||||
@@ -315,7 +374,11 @@ pub struct AgentConfig {
|
||||
pub interface_name: String,
|
||||
/// The interface MTU.
|
||||
pub interface_mtu: u32,
|
||||
/// The IPv4 overlay range this agent proposes.
|
||||
/// Whether new networks derive their IPv4 range from their network id.
|
||||
///
|
||||
/// Enabled by default. Explicit ranges and IPv4 opt-out disable this.
|
||||
pub overlay_ipv4_auto: bool,
|
||||
/// The IPv4 overlay range this agent proposes when automatic selection is off.
|
||||
///
|
||||
/// Addresses are allocated from it and recorded in signed state, so a
|
||||
/// participant keeps the same one across restarts. A network that has
|
||||
@@ -344,11 +407,15 @@ impl AgentConfig {
|
||||
discovery_interval: Duration::from_secs(5),
|
||||
limits: Limits::default(),
|
||||
reconnect: ReconnectPolicy::default(),
|
||||
plugins: Vec::new(),
|
||||
carriers: Vec::new(),
|
||||
tunnel: None,
|
||||
default_protocols: None,
|
||||
custom_transports: std::collections::HashMap::new(),
|
||||
tun_factory: None,
|
||||
interface_name: "tsun0".to_string(),
|
||||
interface_mtu: 1280,
|
||||
overlay_ipv4_range: Some(crate::state::DEFAULT_IPV4_RANGE),
|
||||
overlay_ipv4_auto: true,
|
||||
overlay_ipv4_range: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,13 +487,30 @@ impl AgentConfig {
|
||||
|
||||
/// Sets the IPv4 overlay range this agent proposes, or disables IPv4.
|
||||
pub fn with_overlay_ipv4_range(mut self, range: Option<crate::state::Ipv4Range>) -> Self {
|
||||
self.overlay_ipv4_auto = false;
|
||||
self.overlay_ipv4_range = range;
|
||||
self
|
||||
}
|
||||
|
||||
/// Registers an IP plugin.
|
||||
pub fn with_plugin(mut self, plugin: SharedPlugin) -> Self {
|
||||
self.plugins.push(plugin);
|
||||
/// Registers an external data carrier.
|
||||
pub fn with_carrier(mut self, plugin: crate::dataplane::transport::SharedCarrier) -> Self {
|
||||
self.carriers.push(plugin);
|
||||
self
|
||||
}
|
||||
|
||||
/// Installs the internal encryption engine. It is never a selectable carrier.
|
||||
pub fn with_tunnel(mut self, tunnel: SharedPlugin) -> Self {
|
||||
self.tunnel = Some(tunnel);
|
||||
self
|
||||
}
|
||||
|
||||
/// Registers a custom packet transport for a specific protocol.
|
||||
pub fn with_custom_transport(
|
||||
mut self,
|
||||
protocol: impl Into<String>,
|
||||
transport: Arc<dyn crate::dataplane::PacketTransport>,
|
||||
) -> Self {
|
||||
self.custom_transports.insert(protocol.into(), transport);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -470,9 +554,9 @@ impl std::fmt::Debug for AgentConfig {
|
||||
.field("limits", &self.limits)
|
||||
.field("reconnect", &self.reconnect)
|
||||
.field(
|
||||
"plugins",
|
||||
"carriers",
|
||||
&self
|
||||
.plugins
|
||||
.carriers
|
||||
.iter()
|
||||
.map(|p| p.protocol_id().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -480,3 +564,5 @@ impl std::fmt::Debug for AgentConfig {
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
/// Maximum number of transport links a routed datagram may traverse.
|
||||
pub const ROUTING_HOP_LIMIT: u8 = 16;
|
||||
@@ -1,25 +1,18 @@
|
||||
//! Boundary between the control plane core and future IP plugins.
|
||||
//! Boundaries between the agent, one IP encryption tunnel, and external carriers.
|
||||
//!
|
||||
//! The data plane is where actual IP connectivity is created. WireGuard is the
|
||||
//! first planned plugin; none is implemented here.
|
||||
//! The core moves bounded opaque capabilities without parsing cipher payloads.
|
||||
//! An [`IpPlugin`] sees routed IP packets and a logical [`PacketLink`] per peer;
|
||||
//! it never learns reachability or the network secret. The agent configures one
|
||||
//! such plugin. Built-in agents use the same internal WireGuard cipher for every
|
||||
//! peer, regardless of which external transports carry the encrypted bytes.
|
||||
//!
|
||||
//! Two rules shape this module:
|
||||
//!
|
||||
//! 1. **The core never parses plugin payloads.** A [`PluginCapability`] carries
|
||||
//! a protocol id, a version, an enabled flag and a bounded opaque blob. The
|
||||
//! core transports the blob and hands it to the matching plugin. It does not
|
||||
//! know what a WireGuard configuration looks like.
|
||||
//! 2. **Plugin keys and lifecycle are separate from iroh identity and from the
|
||||
//! network secret.** A plugin owns its own keys and its own system objects.
|
||||
//!
|
||||
//! An iroh address is *not* automatically a WireGuard address. A future plugin
|
||||
//! is expected to gather its own reachability information and ship it through
|
||||
//! the control plane as its announcement payload.
|
||||
//!
|
||||
//! A data plane failure never stops the daemon: errors returned here are
|
||||
//! recorded and surfaced, the control plane keeps running.
|
||||
//! [`transport::DataCarrier`] owns external capability negotiation and lifecycle.
|
||||
//! It sees reachability but never plaintext overlay packets. A relay forwards
|
||||
//! the encrypted inner bytes without entering an IP plugin or the TUN.
|
||||
//! Data-plane failure never stops control sessions or discovery.
|
||||
|
||||
pub mod relay;
|
||||
pub mod routing;
|
||||
pub mod transport;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -30,11 +23,40 @@ use tokio::sync::mpsc;
|
||||
use crate::BoxFuture;
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
pub use transport::{PacketLink, PacketTransport, SharedLink, TransportError};
|
||||
pub use transport::{
|
||||
MultiPacketTransport, PacketLink, PacketTransport, SharedLink, TransportError,
|
||||
};
|
||||
|
||||
/// Maximum length of a plugin protocol identifier.
|
||||
pub const MAX_PROTOCOL_ID_LEN: usize = 32;
|
||||
|
||||
/// Validates bounded local protocol preferences before storing or applying them.
|
||||
pub(crate) fn validate_protocols(protocols: &[String], max: usize) -> crate::error::Result<()> {
|
||||
if protocols.len() > max {
|
||||
return Err(crate::Error::InvalidProtocols(format!(
|
||||
"at most {max} protocols are allowed"
|
||||
)));
|
||||
}
|
||||
for (index, name) in protocols.iter().enumerate() {
|
||||
if name.is_empty()
|
||||
|| name.len() > MAX_PROTOCOL_ID_LEN
|
||||
|| !name
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
|
||||
{
|
||||
return Err(crate::Error::InvalidProtocols(
|
||||
"names must be 1–32 ASCII letters, digits, hyphens or underscores".into(),
|
||||
));
|
||||
}
|
||||
if protocols[..index].contains(name) {
|
||||
return Err(crate::Error::InvalidProtocols(format!(
|
||||
"`{name}` is listed more than once"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An announcement of one IP plugin's capability.
|
||||
///
|
||||
/// `data` is opaque to the core. Nothing in it may be interpreted as a shell
|
||||
@@ -114,7 +136,7 @@ pub enum PluginError {
|
||||
|
||||
/// A request a plugin makes of the agent that owns it.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum PluginRequest {
|
||||
pub enum PluginRequest {
|
||||
/// Re-send this agent's announcement to every peer of a network.
|
||||
Reannounce(NetworkId),
|
||||
/// Surface a plugin error on the agent's event stream.
|
||||
@@ -126,6 +148,17 @@ pub(crate) enum PluginRequest {
|
||||
/// Human readable reason, free of secrets.
|
||||
reason: String,
|
||||
},
|
||||
/// A plugin reported that a data link or tunnel for a peer failed.
|
||||
ProtocolFailure {
|
||||
/// Network the failure occurred in.
|
||||
network: NetworkId,
|
||||
/// Peer whose protocol failed.
|
||||
peer: EndpointId,
|
||||
/// Protocol that failed.
|
||||
protocol: String,
|
||||
/// Human readable reason.
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The agent-side handle a plugin is given when it is attached.
|
||||
@@ -178,6 +211,29 @@ impl PluginContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// A context configured with a custom packet sink.
|
||||
pub fn with_sink(sink: Arc<dyn PacketSink>) -> Self {
|
||||
Self {
|
||||
sender: None,
|
||||
local: None,
|
||||
sink: Some(sink),
|
||||
}
|
||||
}
|
||||
|
||||
/// A context configured with a channel for plugin requests and custom sink, for tests.
|
||||
#[cfg(feature = "testing")]
|
||||
pub fn for_test(
|
||||
sender: mpsc::Sender<PluginRequest>,
|
||||
local: EndpointId,
|
||||
sink: Option<Arc<dyn PacketSink>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
sender: Some(sender),
|
||||
local: Some(local),
|
||||
sink,
|
||||
}
|
||||
}
|
||||
|
||||
/// This agent's own endpoint id, when the context is attached.
|
||||
///
|
||||
/// A plugin needs it to find itself in the agreed allocation.
|
||||
@@ -219,6 +275,22 @@ impl PluginContext {
|
||||
reason: reason.into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Reports that data plane connectivity or a tunnel for a peer has failed.
|
||||
pub fn report_peer_protocol_failure(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
protocol: impl Into<String>,
|
||||
reason: impl Into<String>,
|
||||
) {
|
||||
self.send(PluginRequest::ProtocolFailure {
|
||||
network,
|
||||
peer,
|
||||
protocol: protocol.into(),
|
||||
reason: reason.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PluginContext {
|
||||
@@ -356,7 +428,9 @@ pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// Always preceded by [`IpPlugin::on_network_deactivated`], so this is
|
||||
/// only about what outlives a session. Defaulted to nothing, for a
|
||||
/// plugin that stores nothing.
|
||||
fn on_network_forgotten(&self, _network: NetworkId) {}
|
||||
fn on_network_forgotten(&self, _network: NetworkId) -> BoxFuture<'_, ()> {
|
||||
Box::pin(async {})
|
||||
}
|
||||
|
||||
/// Called once when the agent shuts down.
|
||||
///
|
||||
@@ -0,0 +1,646 @@
|
||||
//! Userspace forwarding. Topology updates publish immutable tables; the packet
|
||||
//! path takes no routing mutex, walks no graph and never inspects plugin bytes.
|
||||
//! Each transport reader forwards transit immediately, independently of the
|
||||
//! destination protocol's receive task. Only local delivery enters its inbox.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use tokio::sync::{Notify, mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use super::routing::{FlowId, PeerId, RoutingTable, envelope, mix64};
|
||||
use super::transport::{PacketLink, SharedLink, TransportError};
|
||||
use crate::BoxFuture;
|
||||
use crate::config::{MAX_DATA_DATAGRAM, ROUTING_HOP_LIMIT};
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
/// Fixed routing header, subtracted from the logical transport MTU.
|
||||
pub const RELAY_OVERHEAD: usize = envelope::HEADER;
|
||||
const INBOX: usize = 256;
|
||||
|
||||
/// Forwarding counters, shared by all protocols of one network.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct RelayCounters {
|
||||
/// Datagrams originated here and sent through another member.
|
||||
pub sent_via: u64,
|
||||
/// Transit datagrams handed to the next transport link.
|
||||
pub forwarded: u64,
|
||||
/// Local datagrams received through another member.
|
||||
pub received_via: u64,
|
||||
/// Missing or failed next hop.
|
||||
pub dropped_no_link: u64,
|
||||
/// Malformed envelope or unknown member.
|
||||
pub dropped_unknown: u64,
|
||||
/// Exhausted hop limit, including loops during topology convergence.
|
||||
pub dropped_hop_limit: u64,
|
||||
/// Full local protocol inbox. Transit does not use this queue.
|
||||
pub dropped_congested: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Tally {
|
||||
sent_via: AtomicU64,
|
||||
forwarded: AtomicU64,
|
||||
received_via: AtomicU64,
|
||||
dropped_no_link: AtomicU64,
|
||||
dropped_unknown: AtomicU64,
|
||||
dropped_hop_limit: AtomicU64,
|
||||
dropped_congested: AtomicU64,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
fn snapshot(&self) -> RelayCounters {
|
||||
RelayCounters {
|
||||
sent_via: self.sent_via.load(Ordering::Relaxed),
|
||||
forwarded: self.forwarded.load(Ordering::Relaxed),
|
||||
received_via: self.received_via.load(Ordering::Relaxed),
|
||||
dropped_no_link: self.dropped_no_link.load(Ordering::Relaxed),
|
||||
dropped_unknown: self.dropped_unknown.load(Ordering::Relaxed),
|
||||
dropped_hop_limit: self.dropped_hop_limit.load(Ordering::Relaxed),
|
||||
dropped_congested: self.dropped_congested.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The links to one neighbour, best first. Which transport carries a frame
|
||||
/// is decided here and nowhere else: the end-to-end protocol is in the
|
||||
/// envelope and never depends on it.
|
||||
#[derive(Debug)]
|
||||
struct NextHop {
|
||||
peer: PeerId,
|
||||
links: Box<[SharedLink]>,
|
||||
}
|
||||
|
||||
impl NextHop {
|
||||
fn usable(&self) -> bool {
|
||||
self.links.iter().any(|link| !link.is_closed())
|
||||
}
|
||||
|
||||
fn first_open(&self) -> Option<&SharedLink> {
|
||||
self.links.iter().find(|link| !link.is_closed())
|
||||
}
|
||||
|
||||
/// Hands a frame to the best open link that accepts it. A link that
|
||||
/// cannot carry the frame (too large for its path, closed a moment ago)
|
||||
/// leaves it to the next one; the frame is only copied when there is
|
||||
/// another to try.
|
||||
fn send(&self, frame: Bytes) -> Result<(), TransportError> {
|
||||
let mut open = self.links.iter().filter(|link| !link.is_closed());
|
||||
let Some(mut current) = open.next() else {
|
||||
return Err(TransportError::Closed);
|
||||
};
|
||||
for next in open {
|
||||
if current.send(frame.clone()).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
current.send(frame)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ForwardRoute {
|
||||
hops: u8,
|
||||
next: Box<[NextHop]>,
|
||||
}
|
||||
|
||||
impl ForwardRoute {
|
||||
fn select(&self, source: &PeerId, flow: FlowId) -> Option<&NextHop> {
|
||||
if self.next.len() == 1 {
|
||||
return self.next.first().filter(|hop| hop.usable());
|
||||
}
|
||||
let seed = u64::from_le_bytes(source[..8].try_into().ok()?);
|
||||
let start = mix64(flow ^ seed) as usize % self.next.len();
|
||||
// Stable fallback if a link closed just before its table was replaced.
|
||||
(0..self.next.len())
|
||||
.map(|i| &self.next[(start + i) % self.next.len()])
|
||||
.find(|hop| hop.usable())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ForwardingTable {
|
||||
routes: HashMap<PeerId, ForwardRoute>,
|
||||
/// Local delivery, by who sent it and which end-to-end protocol it is.
|
||||
inboxes: HashMap<(PeerId, u64), mpsc::Sender<Bytes>>,
|
||||
members: HashSet<PeerId>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Plane {
|
||||
local: PeerId,
|
||||
table: ArcSwap<ForwardingTable>,
|
||||
tally: Arc<Tally>,
|
||||
}
|
||||
|
||||
impl Plane {
|
||||
/// Synchronous hot path: one snapshot, bounded header decode, table lookup,
|
||||
/// and transport send. An exclusively owned buffer is modified in place.
|
||||
fn receive(&self, incoming: PeerId, frame: Bytes) {
|
||||
let Some(header) = envelope::decode(&frame) else {
|
||||
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
};
|
||||
let table = self.table.load();
|
||||
if !table.members.contains(&header.source) || header.source == self.local {
|
||||
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
if header.destination == self.local {
|
||||
let Some(inbox) = table.inboxes.get(&(header.source, header.protocol)) else {
|
||||
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
};
|
||||
if inbox.try_send(frame.slice(RELAY_OVERHEAD..)).is_err() {
|
||||
self.tally.dropped_congested.fetch_add(1, Ordering::Relaxed);
|
||||
} else if incoming != header.source {
|
||||
self.tally.received_via.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if header.remaining <= 1 {
|
||||
self.tally.dropped_hop_limit.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
let hop = table
|
||||
.routes
|
||||
.get(&header.destination)
|
||||
.and_then(|route| route.select(&header.source, header.flow));
|
||||
let Some(hop) = hop else {
|
||||
self.tally.dropped_no_link.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
};
|
||||
if hop.send(envelope::decrement(frame)).is_ok() {
|
||||
self.tally.forwarded.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
self.tally.dropped_no_link.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One physical link to a neighbour, of whatever kind of transport.
|
||||
#[derive(Debug)]
|
||||
struct RawLink {
|
||||
kind: String,
|
||||
link: SharedLink,
|
||||
reader: JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// How a destination is reached right now.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RouteSummary {
|
||||
/// Number of transport links to it. One is a direct link.
|
||||
pub hops: u8,
|
||||
/// The neighbour the first link goes to. For a direct link that is the
|
||||
/// destination itself.
|
||||
pub via: EndpointId,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct State {
|
||||
plane: Arc<Plane>,
|
||||
/// Transport kinds, best first. A neighbour with several links is
|
||||
/// reached over the first of these it has.
|
||||
priority: Vec<String>,
|
||||
graph: HashMap<PeerId, Vec<PeerId>>,
|
||||
members: HashSet<PeerId>,
|
||||
raw: HashMap<PeerId, Vec<RawLink>>,
|
||||
links: HashMap<(PeerId, String), Arc<PeerLink>>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn rank(&self, kind: &str) -> usize {
|
||||
self.priority
|
||||
.iter()
|
||||
.position(|known| known == kind)
|
||||
.unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// A neighbour's links, best kind first.
|
||||
fn ordered(&self, peer: &PeerId) -> Vec<&RawLink> {
|
||||
let mut links: Vec<&RawLink> = self
|
||||
.raw
|
||||
.get(peer)
|
||||
.map(|links| links.iter().collect())
|
||||
.unwrap_or_default();
|
||||
links.sort_by_key(|raw| (self.rank(&raw.kind), raw.kind.clone()));
|
||||
links
|
||||
}
|
||||
|
||||
fn publish(&self) {
|
||||
let mut graph = self.graph.clone();
|
||||
let mut neighbours: Vec<PeerId> = self
|
||||
.raw
|
||||
.iter()
|
||||
.filter(|(_, links)| links.iter().any(|raw| !raw.link.is_closed()))
|
||||
.map(|(peer, _)| *peer)
|
||||
.collect();
|
||||
neighbours.sort_unstable();
|
||||
graph.insert(self.plane.local, neighbours);
|
||||
let routing = RoutingTable::build(self.plane.local, &graph, ROUTING_HOP_LIMIT);
|
||||
let routes = routing
|
||||
.iter()
|
||||
.filter_map(|(peer, route)| {
|
||||
let next: Box<[_]> = route
|
||||
.next_hops
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let links: Box<[_]> = self
|
||||
.ordered(id)
|
||||
.into_iter()
|
||||
.map(|raw| raw.link.clone())
|
||||
.collect();
|
||||
(!links.is_empty()).then_some(NextHop { peer: *id, links })
|
||||
})
|
||||
.collect();
|
||||
(!next.is_empty()).then_some((
|
||||
*peer,
|
||||
ForwardRoute {
|
||||
hops: route.hops,
|
||||
next,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let mut members = self.members.clone();
|
||||
members.extend(self.raw.keys().copied());
|
||||
self.plane.table.store(Arc::new(ForwardingTable {
|
||||
routes,
|
||||
members,
|
||||
inboxes: self
|
||||
.links
|
||||
.iter()
|
||||
.map(|((peer, _), link)| ((*peer, link.tag), link.inbox_tx.clone()))
|
||||
.collect(),
|
||||
}));
|
||||
}
|
||||
|
||||
fn stop_readers(&mut self) {
|
||||
for raw in self.raw.values().flatten() {
|
||||
raw.reader.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for State {
|
||||
fn drop(&mut self) {
|
||||
self.stop_readers();
|
||||
for link in self.links.values() {
|
||||
link.close();
|
||||
}
|
||||
self.plane.table.store(Arc::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// One network's transport links and its published forwarding table.
|
||||
///
|
||||
/// There is one table for the whole network, whatever protocols run over it.
|
||||
/// A link is a way to reach a neighbour; a protocol is an end-to-end channel
|
||||
/// to a peer. The two meet only in the envelope, which names the protocol, so
|
||||
/// a tunnel keeps working over any mix of transports and a middle member
|
||||
/// needs no protocol in common with either end.
|
||||
#[derive(Debug)]
|
||||
pub struct RelayHub {
|
||||
network: NetworkId,
|
||||
state: Mutex<State>,
|
||||
tally: Arc<Tally>,
|
||||
changed: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl RelayHub {
|
||||
/// Creates the router at this network's local endpoint.
|
||||
pub fn new(network: NetworkId, local: EndpointId) -> Arc<Self> {
|
||||
let tally = Arc::new(Tally::default());
|
||||
Arc::new(Self {
|
||||
network,
|
||||
state: Mutex::new(State {
|
||||
plane: Arc::new(Plane {
|
||||
local: *local.as_bytes(),
|
||||
table: ArcSwap::from_pointee(ForwardingTable::default()),
|
||||
tally: tally.clone(),
|
||||
}),
|
||||
priority: Vec::new(),
|
||||
graph: HashMap::new(),
|
||||
members: HashSet::new(),
|
||||
raw: HashMap::new(),
|
||||
links: HashMap::new(),
|
||||
}),
|
||||
tally,
|
||||
changed: Arc::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn state(&self) -> std::sync::MutexGuard<'_, State> {
|
||||
self.state.lock().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
|
||||
/// Sets which kinds of transport are preferred, best first, when a
|
||||
/// neighbour can be reached over more than one.
|
||||
pub fn set_priority(&self, kinds: Vec<String>) {
|
||||
let mut state = self.state();
|
||||
if state.priority != kinds {
|
||||
state.priority = kinds;
|
||||
state.publish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Wakes the control loop promptly when a transport reader ends.
|
||||
pub async fn changed(&self) {
|
||||
self.changed.notified().await;
|
||||
}
|
||||
|
||||
/// Current counters.
|
||||
pub fn counters(&self) -> RelayCounters {
|
||||
self.tally.snapshot()
|
||||
}
|
||||
|
||||
/// Publishes authenticated topology. Refreshing identical state is cheap.
|
||||
///
|
||||
/// Every row of `graph` is one member's own account of who it has a link
|
||||
/// to; what kind of link it is does not matter here.
|
||||
pub fn set_topology(&self, graph: HashMap<PeerId, Vec<PeerId>>, members: HashSet<PeerId>) {
|
||||
let mut state = self.state();
|
||||
if state.graph != graph || state.members != members {
|
||||
state.graph = graph;
|
||||
state.members = members;
|
||||
state.publish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the current table has a path to this destination.
|
||||
pub fn reachable(&self, peer: EndpointId) -> bool {
|
||||
self.state()
|
||||
.plane
|
||||
.table
|
||||
.load()
|
||||
.routes
|
||||
.contains_key(peer.as_bytes())
|
||||
}
|
||||
|
||||
/// How a destination is reached right now, if it is.
|
||||
pub fn route(&self, peer: EndpointId) -> Option<RouteSummary> {
|
||||
let table = self.state().plane.table.load_full();
|
||||
let route = table.routes.get(peer.as_bytes())?;
|
||||
let via = EndpointId::from_bytes(&route.next.first()?.peer).ok()?;
|
||||
Some(RouteSummary {
|
||||
hops: route.hops,
|
||||
via,
|
||||
})
|
||||
}
|
||||
|
||||
/// The kinds of transport with an open direct link to a peer, best
|
||||
/// first.
|
||||
pub fn direct_kinds(&self, peer: EndpointId) -> Vec<String> {
|
||||
let state = self.state();
|
||||
state
|
||||
.ordered(peer.as_bytes())
|
||||
.into_iter()
|
||||
.filter(|raw| !raw.link.is_closed())
|
||||
.map(|raw| raw.kind.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stable end-to-end channel for a protocol, preserved across topology
|
||||
/// and transport changes.
|
||||
pub fn link(&self, peer: EndpointId, protocol: &str) -> Arc<PeerLink> {
|
||||
let mut state = self.state();
|
||||
let key = (*peer.as_bytes(), protocol.to_owned());
|
||||
if let Some(link) = state.links.get(&key) {
|
||||
return link.clone();
|
||||
}
|
||||
let (inbox_tx, inbox_rx) = mpsc::channel(INBOX);
|
||||
let (closed, _) = watch::channel(false);
|
||||
let link = Arc::new(PeerLink {
|
||||
network: self.network,
|
||||
peer,
|
||||
tag: envelope::protocol_tag(protocol),
|
||||
plane: state.plane.clone(),
|
||||
inbox_tx,
|
||||
inbox_rx: tokio::sync::Mutex::new(inbox_rx),
|
||||
closed,
|
||||
});
|
||||
state.links.insert(key, link.clone());
|
||||
state.publish();
|
||||
link
|
||||
}
|
||||
|
||||
/// Whether the protocol already owns its end-to-end channel to a peer.
|
||||
pub fn has_link(&self, peer: EndpointId, protocol: &str) -> bool {
|
||||
self.state()
|
||||
.links
|
||||
.contains_key(&(*peer.as_bytes(), protocol.to_owned()))
|
||||
}
|
||||
|
||||
/// Installs a raw transport link and starts its independent ingress reader.
|
||||
///
|
||||
/// A neighbour may have one link per kind of transport; installing a
|
||||
/// second of the same kind replaces the first.
|
||||
pub fn set_direct(&self, peer: EndpointId, kind: &str, raw: SharedLink) {
|
||||
if raw.network() != self.network || raw.peer() != peer {
|
||||
return;
|
||||
}
|
||||
let mut state = self.state();
|
||||
let id = *peer.as_bytes();
|
||||
let plane = state.plane.clone();
|
||||
let changed = self.changed.clone();
|
||||
let reader_link = raw.clone();
|
||||
let reader = tokio::spawn(async move {
|
||||
let mut batch = 0;
|
||||
while let Some(frame) = reader_link.recv().await {
|
||||
plane.receive(id, frame);
|
||||
batch += 1;
|
||||
if batch == 64 {
|
||||
tokio::task::yield_now().await;
|
||||
batch = 0;
|
||||
}
|
||||
}
|
||||
changed.notify_one();
|
||||
});
|
||||
let links = state.raw.entry(id).or_default();
|
||||
if let Some(position) = links.iter().position(|known| known.kind == kind) {
|
||||
links.remove(position).reader.abort();
|
||||
}
|
||||
links.push(RawLink {
|
||||
kind: kind.to_owned(),
|
||||
link: raw,
|
||||
reader,
|
||||
});
|
||||
state.publish();
|
||||
}
|
||||
|
||||
/// Removes one physical link without tearing down end-to-end protocol state.
|
||||
pub fn clear_direct(&self, peer: EndpointId, kind: &str) {
|
||||
let mut state = self.state();
|
||||
let mut changed = false;
|
||||
if let Some(links) = state.raw.get_mut(peer.as_bytes()) {
|
||||
if let Some(position) = links.iter().position(|known| known.kind == kind) {
|
||||
links.remove(position).reader.abort();
|
||||
changed = true;
|
||||
}
|
||||
if links.is_empty() {
|
||||
state.raw.remove(peer.as_bytes());
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
state.publish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Retires data channels without removing an authenticated peer's topology.
|
||||
pub fn clear_peer_links(&self, peer: EndpointId) {
|
||||
let mut state = self.state();
|
||||
if let Some(links) = state.raw.remove(peer.as_bytes()) {
|
||||
for raw in links {
|
||||
raw.link.close();
|
||||
raw.reader.abort();
|
||||
}
|
||||
}
|
||||
let gone: Vec<_> = state
|
||||
.links
|
||||
.keys()
|
||||
.filter(|(id, _)| id == peer.as_bytes())
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in gone {
|
||||
if let Some(link) = state.links.remove(&key) {
|
||||
link.close();
|
||||
}
|
||||
}
|
||||
state.publish();
|
||||
}
|
||||
|
||||
/// Revokes all links and topology involving a departed authenticated peer.
|
||||
pub fn remove_peer(&self, peer: EndpointId) {
|
||||
self.clear_peer_links(peer);
|
||||
let mut state = self.state();
|
||||
state.members.remove(peer.as_bytes());
|
||||
state.graph.remove(peer.as_bytes());
|
||||
for neighbors in state.graph.values_mut() {
|
||||
neighbors.retain(|id| id != peer.as_bytes());
|
||||
}
|
||||
state.publish();
|
||||
}
|
||||
|
||||
/// Stops every reader and releases all transport handles.
|
||||
pub fn close(&self) {
|
||||
let mut state = self.state();
|
||||
state.stop_readers();
|
||||
state.raw.clear();
|
||||
for link in state.links.values() {
|
||||
link.close();
|
||||
}
|
||||
state.links.clear();
|
||||
state.graph.clear();
|
||||
state.members.clear();
|
||||
state.plane.table.store(Arc::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// A protocol's end-to-end channel, independent of its current next hop.
|
||||
#[derive(Debug)]
|
||||
pub struct PeerLink {
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
tag: u64,
|
||||
plane: Arc<Plane>,
|
||||
inbox_tx: mpsc::Sender<Bytes>,
|
||||
inbox_rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
|
||||
closed: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl PeerLink {
|
||||
fn close(&self) {
|
||||
self.closed.send_replace(true);
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketLink for PeerLink {
|
||||
fn network(&self) -> NetworkId {
|
||||
self.network
|
||||
}
|
||||
fn peer(&self) -> EndpointId {
|
||||
self.peer
|
||||
}
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
MAX_DATA_DATAGRAM - RELAY_OVERHEAD
|
||||
}
|
||||
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
|
||||
self.send_flow(payload, 0)
|
||||
}
|
||||
fn send_flow(&self, payload: Bytes, flow: FlowId) -> Result<(), TransportError> {
|
||||
if self.is_closed() {
|
||||
return Err(TransportError::Closed);
|
||||
}
|
||||
if payload.len() > self.max_datagram_size() {
|
||||
return Err(TransportError::TooLarge {
|
||||
size: payload.len(),
|
||||
limit: self.max_datagram_size(),
|
||||
});
|
||||
}
|
||||
let table = self.plane.table.load();
|
||||
let route = table
|
||||
.routes
|
||||
.get(self.peer.as_bytes())
|
||||
.ok_or(TransportError::Closed)?;
|
||||
let hop = route
|
||||
.select(&self.plane.local, flow)
|
||||
.ok_or(TransportError::Closed)?;
|
||||
hop.send(envelope::encode(
|
||||
self.plane.local,
|
||||
*self.peer.as_bytes(),
|
||||
flow,
|
||||
self.tag,
|
||||
&payload,
|
||||
))?;
|
||||
if route.hops > 1 {
|
||||
self.plane.tally.sent_via.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move {
|
||||
let mut closed = self.closed.subscribe();
|
||||
if *closed.borrow_and_update() {
|
||||
return None;
|
||||
}
|
||||
let mut inbox = self.inbox_rx.lock().await;
|
||||
tokio::select! { biased; _ = closed.changed() => None, frame = inbox.recv() => frame }
|
||||
})
|
||||
}
|
||||
fn closed(&self) -> BoxFuture<'_, ()> {
|
||||
Box::pin(async move {
|
||||
let mut closed = self.closed.subscribe();
|
||||
if !*closed.borrow_and_update() {
|
||||
let _ = closed.changed().await;
|
||||
}
|
||||
})
|
||||
}
|
||||
fn is_closed(&self) -> bool {
|
||||
*self.closed.borrow()
|
||||
}
|
||||
fn path_description(&self) -> String {
|
||||
let table = self.plane.table.load();
|
||||
match table.routes.get(self.peer.as_bytes()) {
|
||||
Some(route) if route.hops == 1 => route.next[0]
|
||||
.first_open()
|
||||
.map_or_else(|| "unreachable".into(), |link| link.path_description()),
|
||||
Some(route) => format!(
|
||||
"relay {} hops via {} ({} equal paths)",
|
||||
route.hops,
|
||||
hex::encode(route.next[0].peer),
|
||||
route.next.len()
|
||||
),
|
||||
None => "unreachable".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "relay_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,418 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
fn network() -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new("routing-tests").unwrap(),
|
||||
&NetworkSecret::from_bytes([3; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
fn peer(seed: u8) -> EndpointId {
|
||||
iroh::SecretKey::from_bytes(&[seed; 32]).public()
|
||||
}
|
||||
fn id(seed: u8) -> PeerId {
|
||||
*peer(seed).as_bytes()
|
||||
}
|
||||
fn tag() -> u64 {
|
||||
envelope::protocol_tag("ip")
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Wire {
|
||||
peer: EndpointId,
|
||||
sent: Mutex<Vec<Bytes>>,
|
||||
feed: mpsc::Sender<Bytes>,
|
||||
inbound: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
|
||||
dead: AtomicBool,
|
||||
count: AtomicU64,
|
||||
record: bool,
|
||||
}
|
||||
impl Wire {
|
||||
fn new(seed: u8, record: bool) -> Arc<Self> {
|
||||
let (feed, inbound) = mpsc::channel(32);
|
||||
Arc::new(Self {
|
||||
peer: peer(seed),
|
||||
sent: Mutex::default(),
|
||||
feed,
|
||||
inbound: tokio::sync::Mutex::new(inbound),
|
||||
dead: AtomicBool::new(false),
|
||||
count: AtomicU64::new(0),
|
||||
record,
|
||||
})
|
||||
}
|
||||
fn take(&self) -> Vec<Bytes> {
|
||||
std::mem::take(&mut self.sent.lock().unwrap())
|
||||
}
|
||||
}
|
||||
impl PacketLink for Wire {
|
||||
fn network(&self) -> NetworkId {
|
||||
network()
|
||||
}
|
||||
fn peer(&self) -> EndpointId {
|
||||
self.peer
|
||||
}
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
MAX_DATA_DATAGRAM
|
||||
}
|
||||
fn send(&self, frame: Bytes) -> Result<(), TransportError> {
|
||||
if self.is_closed() {
|
||||
return Err(TransportError::Closed);
|
||||
}
|
||||
self.count.fetch_add(1, Ordering::Relaxed);
|
||||
if self.record {
|
||||
self.sent.lock().unwrap().push(frame);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move { self.inbound.lock().await.recv().await })
|
||||
}
|
||||
fn closed(&self) -> BoxFuture<'_, ()> {
|
||||
Box::pin(std::future::pending())
|
||||
}
|
||||
fn is_closed(&self) -> bool {
|
||||
self.dead.load(Ordering::Relaxed)
|
||||
}
|
||||
fn path_description(&self) -> String {
|
||||
"test-direct".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn topology(hub: &RelayHub, edges: &[(u8, &[u8])], members: &[u8]) {
|
||||
hub.set_topology(
|
||||
edges
|
||||
.iter()
|
||||
.map(|(a, bs)| (id(*a), bs.iter().map(|b| id(*b)).collect()))
|
||||
.collect(),
|
||||
members.iter().map(|p| id(*p)).collect(),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ecmp_is_per_flow_direct_wins_and_existing_tunnel_survives_changes() {
|
||||
let hub = RelayHub::new(network(), peer(1));
|
||||
let b = Wire::new(2, true);
|
||||
let c = Wire::new(3, true);
|
||||
hub.set_direct(peer(2), "ip", b.clone());
|
||||
hub.set_direct(peer(3), "ip", c.clone());
|
||||
topology(&hub, &[(2, &[4]), (3, &[4])], &[1, 2, 3, 4]);
|
||||
let link = hub.link(peer(4), "ip");
|
||||
let mut chosen = HashMap::new();
|
||||
for _ in 0..8 {
|
||||
for flow in 0..100 {
|
||||
link.send_flow(Bytes::from_static(b"ciphertext"), flow)
|
||||
.unwrap();
|
||||
}
|
||||
for (hop, wire) in [(2, &b), (3, &c)] {
|
||||
for frame in wire.take() {
|
||||
let header = envelope::decode(&frame).unwrap();
|
||||
assert_eq!(header.source, id(1));
|
||||
assert_eq!(header.destination, id(4));
|
||||
if let Some(old) = chosen.insert(header.flow, hop) {
|
||||
assert_eq!(old, hop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
chosen.values().copied().collect::<HashSet<_>>(),
|
||||
HashSet::from([2, 3])
|
||||
);
|
||||
let direct = Wire::new(4, true);
|
||||
hub.set_direct(peer(4), "ip", direct.clone());
|
||||
link.send_flow(Bytes::from_static(b"direct"), 8).unwrap();
|
||||
assert_eq!(direct.take().len(), 1);
|
||||
assert!(b.take().is_empty() && c.take().is_empty());
|
||||
hub.clear_direct(peer(4), "ip");
|
||||
b.dead.store(true, Ordering::Relaxed);
|
||||
for flow in 0..100 {
|
||||
link.send_flow(Bytes::from_static(b"fallback"), flow)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(c.take().len(), 100);
|
||||
hub.clear_direct(peer(2), "ip");
|
||||
topology(&hub, &[(3, &[])], &[1, 2, 3, 4]);
|
||||
assert!(link.send(Bytes::new()).is_err());
|
||||
assert!(!link.is_closed());
|
||||
assert!(Arc::ptr_eq(&link, &hub.link(peer(4), "ip")));
|
||||
hub.remove_peer(peer(4));
|
||||
assert!(link.is_closed());
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), link.closed())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(link.recv().await.is_none());
|
||||
hub.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_reader_forwards_without_any_plugin_reader_and_isolates_protocols() {
|
||||
let hub = RelayHub::new(network(), peer(2));
|
||||
let a = Wire::new(1, true);
|
||||
let c = Wire::new(3, true);
|
||||
hub.set_direct(peer(1), "ip", a.clone());
|
||||
hub.set_direct(peer(3), "ip", c.clone());
|
||||
topology(&hub, &[(3, &[4])], &[1, 2, 3, 4]);
|
||||
let frame = envelope::encode(id(1), id(4), 91, tag(), b"end-to-end encrypted");
|
||||
let pointer = frame.as_ptr();
|
||||
a.feed.send(frame).await.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
while hub.counters().forwarded == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let sent = c.take();
|
||||
assert_eq!(sent[0].as_ptr(), pointer);
|
||||
assert_eq!(
|
||||
envelope::decode(&sent[0]).unwrap().remaining,
|
||||
ROUTING_HOP_LIMIT - 1
|
||||
);
|
||||
assert_eq!(&sent[0][RELAY_OVERHEAD..], b"end-to-end encrypted");
|
||||
assert_eq!(hub.counters().forwarded, 1);
|
||||
hub.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_frame_is_delivered_only_to_the_protocol_it_names() {
|
||||
let hub = RelayHub::new(network(), peer(2));
|
||||
hub.set_direct(peer(1), "ip", Wire::new(1, true));
|
||||
let ip = hub.link(peer(1), "ip");
|
||||
let other = hub.link(peer(1), "other-ip");
|
||||
let plane = ip.plane.clone();
|
||||
plane.receive(id(1), envelope::encode(id(1), id(2), 0, tag(), b"for ip"));
|
||||
plane.receive(
|
||||
id(1),
|
||||
envelope::encode(
|
||||
id(1),
|
||||
id(2),
|
||||
0,
|
||||
envelope::protocol_tag("other-ip"),
|
||||
b"for other",
|
||||
),
|
||||
);
|
||||
plane.receive(
|
||||
id(1),
|
||||
envelope::encode(
|
||||
id(1),
|
||||
id(2),
|
||||
0,
|
||||
envelope::protocol_tag("nobody-runs-this"),
|
||||
b"for nobody",
|
||||
),
|
||||
);
|
||||
assert_eq!(ip.recv().await.unwrap(), Bytes::from_static(b"for ip"));
|
||||
assert_eq!(
|
||||
other.recv().await.unwrap(),
|
||||
Bytes::from_static(b"for other")
|
||||
);
|
||||
assert_eq!(hub.counters().dropped_unknown, 1);
|
||||
hub.close();
|
||||
}
|
||||
|
||||
/// A middle member joins two neighbours over different transports, and the
|
||||
/// two ends share a protocol the middle has never heard of.
|
||||
#[tokio::test]
|
||||
async fn a_tunnel_crosses_a_middle_member_whose_links_are_of_different_kinds() {
|
||||
let a = RelayHub::new(network(), peer(1));
|
||||
let c = RelayHub::new(network(), peer(3));
|
||||
let b = RelayHub::new(network(), peer(2));
|
||||
let a_to_c = Wire::new(3, true);
|
||||
let c_to_a = Wire::new(1, true);
|
||||
let c_to_b = Wire::new(2, true);
|
||||
let b_to_c = Wire::new(3, true);
|
||||
a.set_direct(peer(3), "wg", a_to_c.clone());
|
||||
c.set_direct(peer(1), "wg", c_to_a);
|
||||
c.set_direct(peer(2), "wg-quic", c_to_b.clone());
|
||||
b.set_direct(peer(3), "wg-quic", b_to_c.clone());
|
||||
topology(&a, &[(3, &[1, 2])], &[1, 2, 3]);
|
||||
topology(&b, &[(3, &[1, 2])], &[1, 2, 3]);
|
||||
topology(&c, &[(1, &[3]), (2, &[3])], &[1, 2, 3]);
|
||||
|
||||
let outgoing = a.link(peer(2), "tunnel");
|
||||
let incoming = b.link(peer(1), "tunnel");
|
||||
outgoing
|
||||
.send_flow(Bytes::from_static(b"sealed"), 5)
|
||||
.unwrap();
|
||||
let frame = a_to_c.take().remove(0);
|
||||
|
||||
// The middle forwards it onto its other kind of link without looking
|
||||
// inside.
|
||||
c.state().plane.receive(id(1), frame);
|
||||
let frame = c_to_b.take().remove(0);
|
||||
assert_eq!(c.counters().forwarded, 1);
|
||||
|
||||
b.state().plane.receive(id(3), frame);
|
||||
assert_eq!(
|
||||
incoming.recv().await.unwrap(),
|
||||
Bytes::from_static(b"sealed")
|
||||
);
|
||||
assert_eq!(b.counters().received_via, 1);
|
||||
assert_eq!(
|
||||
b.route(peer(1)),
|
||||
Some(RouteSummary {
|
||||
hops: 2,
|
||||
via: peer(3)
|
||||
})
|
||||
);
|
||||
a.close();
|
||||
b.close();
|
||||
c.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_neighbour_is_reached_over_the_preferred_kind_and_falls_back_when_it_is_lost() {
|
||||
let hub = RelayHub::new(network(), peer(1));
|
||||
hub.set_priority(vec!["wg".into(), "tcp-tls".into(), "wg-quic".into()]);
|
||||
let slow = Wire::new(2, true);
|
||||
let fast = Wire::new(2, true);
|
||||
hub.set_direct(peer(2), "wg-quic", slow.clone());
|
||||
let link = hub.link(peer(2), "tunnel");
|
||||
link.send(Bytes::from_static(b"one")).unwrap();
|
||||
assert_eq!(slow.take().len(), 1);
|
||||
assert_eq!(hub.direct_kinds(peer(2)), ["wg-quic"]);
|
||||
|
||||
// A better kind appears: traffic moves to it with nothing to redo.
|
||||
hub.set_direct(peer(2), "wg", fast.clone());
|
||||
assert_eq!(hub.direct_kinds(peer(2)), ["wg", "wg-quic"]);
|
||||
link.send(Bytes::from_static(b"two")).unwrap();
|
||||
assert_eq!(fast.take().len(), 1);
|
||||
assert!(slow.take().is_empty());
|
||||
|
||||
// It dies: the worse one carries on, and the logical link is untouched.
|
||||
fast.dead.store(true, Ordering::Relaxed);
|
||||
link.send(Bytes::from_static(b"three")).unwrap();
|
||||
assert_eq!(slow.take().len(), 1);
|
||||
assert!(!link.is_closed());
|
||||
assert_eq!(
|
||||
hub.route(peer(2)),
|
||||
Some(RouteSummary {
|
||||
hops: 1,
|
||||
via: peer(2)
|
||||
})
|
||||
);
|
||||
|
||||
hub.clear_direct(peer(2), "wg-quic");
|
||||
hub.clear_direct(peer(2), "wg");
|
||||
assert!(!hub.reachable(peer(2)));
|
||||
assert!(Arc::ptr_eq(&link, &hub.link(peer(2), "tunnel")));
|
||||
hub.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_delivery_is_bounded_and_malformed_or_unknown_sources_are_dropped() {
|
||||
let hub = RelayHub::new(network(), peer(2));
|
||||
let a = Wire::new(1, true);
|
||||
hub.set_direct(peer(1), "ip", a);
|
||||
let link = hub.link(peer(1), "ip");
|
||||
let plane = link.plane.clone();
|
||||
for _ in 0..INBOX + 1 {
|
||||
plane.receive(id(1), envelope::encode(id(1), id(2), 0, tag(), b"ip"));
|
||||
}
|
||||
assert_eq!(hub.counters().dropped_congested, 1);
|
||||
for _ in 0..INBOX {
|
||||
assert_eq!(link.recv().await.unwrap(), Bytes::from_static(b"ip"));
|
||||
}
|
||||
plane.receive(id(1), Bytes::from_static(b"broken"));
|
||||
plane.receive(
|
||||
id(1),
|
||||
envelope::encode(id(9), id(2), 0, tag(), b"unknown member"),
|
||||
);
|
||||
assert_eq!(hub.counters().dropped_unknown, 2);
|
||||
plane.receive(
|
||||
id(1),
|
||||
envelope::encode(id(1), id(9), 0, tag(), b"unknown destination"),
|
||||
);
|
||||
assert_eq!(hub.counters().dropped_no_link, 1);
|
||||
hub.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inconsistent_tables_cannot_loop_beyond_the_hop_limit() {
|
||||
let a = RelayHub::new(network(), peer(1));
|
||||
let b = RelayHub::new(network(), peer(2));
|
||||
let ab = Wire::new(2, true);
|
||||
let ba = Wire::new(1, true);
|
||||
a.set_direct(peer(2), "ip", ab.clone());
|
||||
b.set_direct(peer(1), "ip", ba.clone());
|
||||
// Deliberately inconsistent snapshots while updates are in flight.
|
||||
topology(&a, &[(2, &[4])], &[1, 2, 3, 4]);
|
||||
topology(&b, &[(1, &[4])], &[1, 2, 3, 4]);
|
||||
let ap = a.link(peer(4), "ip").plane.clone();
|
||||
let bp = b.link(peer(4), "ip").plane.clone();
|
||||
let mut frame = envelope::encode(id(3), id(4), 7, tag(), b"transit");
|
||||
for turn in 0..ROUTING_HOP_LIMIT {
|
||||
let (plane, incoming, wire) = if turn % 2 == 0 {
|
||||
(&ap, id(2), &ab)
|
||||
} else {
|
||||
(&bp, id(1), &ba)
|
||||
};
|
||||
plane.receive(incoming, frame);
|
||||
let sent = wire.take();
|
||||
if turn == ROUTING_HOP_LIMIT - 1 {
|
||||
assert!(sent.is_empty());
|
||||
break;
|
||||
}
|
||||
assert_eq!(sent.len(), 1);
|
||||
frame = sent.into_iter().next().unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
a.counters().forwarded + b.counters().forwarded,
|
||||
u64::from(ROUTING_HOP_LIMIT - 1)
|
||||
);
|
||||
assert_eq!(
|
||||
a.counters().dropped_hop_limit + b.counters().dropped_hop_limit,
|
||||
1
|
||||
);
|
||||
a.close();
|
||||
b.close();
|
||||
}
|
||||
|
||||
/// Measures the actual synchronous transit routine, including header checks,
|
||||
/// snapshot lookup, ECMP, in-place TTL update and mock transport submission.
|
||||
/// Packet construction, encryption and socket I/O are outside this measurement.
|
||||
#[tokio::test]
|
||||
#[ignore = "manual release-mode forwarding microbenchmark"]
|
||||
async fn forwarding_benchmark() {
|
||||
let hub = RelayHub::new(network(), peer(2));
|
||||
let wire = Wire::new(3, false);
|
||||
hub.set_direct(peer(3), "ip", wire.clone());
|
||||
let alternate = Wire::new(5, false);
|
||||
hub.set_direct(peer(5), "ip", alternate.clone());
|
||||
topology(&hub, &[(3, &[4]), (5, &[4])], &[1, 2, 3, 4, 5]);
|
||||
let plane = hub.link(peer(4), "ip").plane.clone();
|
||||
let source = id(1);
|
||||
let destination = id(4);
|
||||
let incoming = id(1);
|
||||
for size in [64, 1280] {
|
||||
let mut elapsed = std::time::Duration::ZERO;
|
||||
const COUNT: usize = 10000;
|
||||
const BATCHES: usize = 100;
|
||||
for _ in 0..BATCHES {
|
||||
let frames: Vec<_> = (0..COUNT)
|
||||
.map(|flow| {
|
||||
envelope::encode(source, destination, flow as u64, tag(), &vec![42; size])
|
||||
})
|
||||
.collect();
|
||||
let start = std::time::Instant::now();
|
||||
for frame in frames {
|
||||
plane.receive(incoming, std::hint::black_box(frame));
|
||||
}
|
||||
elapsed += start.elapsed();
|
||||
}
|
||||
let count = (COUNT * BATCHES) as f64;
|
||||
println!(
|
||||
"transit {size} B: {:.1} ns/packet, {:.2} Mpps (1M packets, two equal paths)",
|
||||
elapsed.as_nanos() as f64 / count,
|
||||
count / elapsed.as_secs_f64() / 1e6
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
wire.count.load(Ordering::Relaxed) + alternate.count.load(Ordering::Relaxed),
|
||||
2000000
|
||||
);
|
||||
hub.close();
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Fixed header. Transit changes one byte in an owned receive buffer.
|
||||
use super::{FlowId, PeerId};
|
||||
use crate::config::{MAX_DATA_DATAGRAM, ROUTING_HOP_LIMIT};
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
|
||||
pub(crate) const HEADER: usize = 2 + 32 + 32 + 8 + 8;
|
||||
const VERSION: u8 = 2;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Header {
|
||||
pub source: PeerId,
|
||||
pub destination: PeerId,
|
||||
pub remaining: u8,
|
||||
pub flow: FlowId,
|
||||
/// Which end-to-end protocol the payload belongs to, whatever links carry it.
|
||||
pub protocol: u64,
|
||||
}
|
||||
|
||||
/// A stable 64-bit tag for a protocol id (FNV-1a), the same on every member.
|
||||
pub(crate) fn protocol_tag(name: &str) -> u64 {
|
||||
name.bytes().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
|
||||
(hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8]) -> Option<Header> {
|
||||
if bytes.len() < HEADER
|
||||
|| bytes.len() > MAX_DATA_DATAGRAM
|
||||
|| bytes[0] != VERSION
|
||||
|| bytes[1] == 0
|
||||
|| bytes[1] > ROUTING_HOP_LIMIT
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Header {
|
||||
source: bytes[2..34].try_into().ok()?,
|
||||
destination: bytes[34..66].try_into().ok()?,
|
||||
remaining: bytes[1],
|
||||
flow: u64::from_be_bytes(bytes[66..74].try_into().ok()?),
|
||||
protocol: u64::from_be_bytes(bytes[74..82].try_into().ok()?),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn encode(
|
||||
source: PeerId,
|
||||
destination: PeerId,
|
||||
flow: FlowId,
|
||||
protocol: u64,
|
||||
payload: &[u8],
|
||||
) -> Bytes {
|
||||
let mut bytes = BytesMut::with_capacity(HEADER + payload.len());
|
||||
bytes.put_u8(VERSION);
|
||||
bytes.put_u8(ROUTING_HOP_LIMIT);
|
||||
bytes.extend_from_slice(&source);
|
||||
bytes.extend_from_slice(&destination);
|
||||
bytes.put_u64(flow);
|
||||
bytes.put_u64(protocol);
|
||||
bytes.extend_from_slice(payload);
|
||||
bytes.freeze()
|
||||
}
|
||||
|
||||
pub(crate) fn decrement(bytes: Bytes) -> Bytes {
|
||||
let mut bytes = match bytes.try_into_mut() {
|
||||
Ok(bytes) => bytes,
|
||||
Err(bytes) => BytesMut::from(bytes.as_ref()),
|
||||
};
|
||||
// Called only after decode and after excluding remaining <= 1.
|
||||
bytes[1] -= 1;
|
||||
bytes.freeze()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
#[test]
|
||||
fn transit_reuses_the_buffer_and_rejects_invalid_headers() {
|
||||
let frame = encode([1; 32], [2; 32], 17, protocol_tag("wg"), &[42; 1280]);
|
||||
let address = frame.as_ptr();
|
||||
let frame = decrement(frame);
|
||||
assert_eq!(
|
||||
frame.as_ptr(),
|
||||
address,
|
||||
"no payload copy for an owned buffer"
|
||||
);
|
||||
let header = decode(&frame).unwrap();
|
||||
assert_eq!(header.remaining, ROUTING_HOP_LIMIT - 1);
|
||||
assert_eq!(header.protocol, protocol_tag("wg"));
|
||||
assert_ne!(protocol_tag("wg"), protocol_tag("wg-quic"));
|
||||
assert_eq!(&frame[HEADER..], &[42; 1280]);
|
||||
for length in 0..HEADER {
|
||||
assert!(decode(&frame[..length]).is_none());
|
||||
}
|
||||
for ttl in [0, ROUTING_HOP_LIMIT + 1] {
|
||||
let mut bad = frame.to_vec();
|
||||
bad[1] = ttl;
|
||||
assert!(decode(&bad).is_none());
|
||||
}
|
||||
let mut bad = frame.to_vec();
|
||||
bad[0] = 99;
|
||||
assert!(decode(&bad).is_none());
|
||||
assert!(decode(&vec![1; MAX_DATA_DATAGRAM + 1]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Optional IP flow classifier for IP plugins, called before encryption.
|
||||
//! The router itself treats this identifier and the payload as opaque.
|
||||
|
||||
use super::FlowId;
|
||||
|
||||
fn hash(parts: &[&[u8]]) -> FlowId {
|
||||
let mut value = 0xcbf29ce484222325u64;
|
||||
for part in parts {
|
||||
for byte in *part {
|
||||
value = (value ^ u64::from(*byte)).wrapping_mul(0x100000001b3);
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// Stable directional IP flow hash, independent of TCP sequence numbers,
|
||||
/// checksums and packet contents. Fragmented traffic uses addresses/protocol
|
||||
/// instead of ports: even non-initial fragments and subsequent fragmented
|
||||
/// datagrams stay together. This deliberately coalesces fragmented flows.
|
||||
/// Malformed packets get a coarse hash; parsing is bounded and never allocates.
|
||||
pub fn ip_flow(packet: &[u8]) -> FlowId {
|
||||
match packet.first().map(|b| b >> 4) {
|
||||
Some(4) if packet.len() >= 20 => {
|
||||
let addresses = &packet[12..20];
|
||||
let protocol = &packet[9..10];
|
||||
let offset = usize::from(packet[0] & 15) * 4;
|
||||
if u16::from_be_bytes([packet[6], packet[7]]) & 0x3fff != 0 {
|
||||
return hash(&[addresses, protocol]);
|
||||
}
|
||||
if offset >= 20
|
||||
&& matches!(packet[9], 6 | 17)
|
||||
&& let Some(ports) = packet.get(offset..offset + 4)
|
||||
{
|
||||
return hash(&[addresses, protocol, ports]);
|
||||
}
|
||||
hash(&[addresses, protocol])
|
||||
}
|
||||
Some(6) if packet.len() >= 40 => {
|
||||
let addresses = &packet[8..40];
|
||||
let mut protocol = packet[6];
|
||||
let mut offset = 40;
|
||||
for _ in 0..8 {
|
||||
match protocol {
|
||||
44 => {
|
||||
if let Some(fragment) = packet.get(offset..offset + 8) {
|
||||
return hash(&[addresses, &fragment[..1]]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
0 | 43 | 60 | 51 => {
|
||||
let Some(header) = packet.get(offset..offset + 2) else {
|
||||
break;
|
||||
};
|
||||
let size = if protocol == 51 {
|
||||
(usize::from(header[1]) + 2) * 4
|
||||
} else {
|
||||
(usize::from(header[1]) + 1) * 8
|
||||
};
|
||||
protocol = header[0];
|
||||
offset += size;
|
||||
}
|
||||
6 | 17 => {
|
||||
if let Some(ports) = packet.get(offset..offset + 4) {
|
||||
return hash(&[addresses, &[protocol], ports]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
hash(&[addresses, &[protocol]])
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tcp_flow_ignores_packet_contents_but_distinguishes_ports() {
|
||||
let mut packet = [0u8; 80];
|
||||
packet[0] = 0x45;
|
||||
packet[9] = 6;
|
||||
packet[12..24].copy_from_slice(&[10, 0, 0, 1, 10, 0, 0, 2, 0, 22, 1, 2]);
|
||||
let flow = ip_flow(&packet);
|
||||
packet[4] = 123; // IP id changes for each unfragmented packet.
|
||||
packet[24..].fill(47);
|
||||
assert_eq!(flow, ip_flow(&packet));
|
||||
packet[23] = 3;
|
||||
assert_ne!(flow, ip_flow(&packet));
|
||||
packet[6] = 0x20;
|
||||
let fragment_flow = ip_flow(&packet);
|
||||
packet[6] = 0;
|
||||
packet[7] = 1;
|
||||
packet[20..].fill(99);
|
||||
assert_eq!(fragment_flow, ip_flow(&packet));
|
||||
packet[4] = 97;
|
||||
assert_eq!(fragment_flow, ip_flow(&packet));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_extensions_and_fragments_are_bounded_and_stable() {
|
||||
let mut packet = [0u8; 80];
|
||||
packet[0] = 0x60;
|
||||
packet[6] = 0;
|
||||
packet[40] = 17;
|
||||
packet[48..52].copy_from_slice(&[1, 2, 3, 4]);
|
||||
let flow = ip_flow(&packet);
|
||||
packet[52..].fill(5);
|
||||
assert_eq!(flow, ip_flow(&packet));
|
||||
packet[50] = 9;
|
||||
assert_ne!(flow, ip_flow(&packet));
|
||||
packet[6] = 44;
|
||||
packet[40] = 6;
|
||||
let flow = ip_flow(&packet);
|
||||
packet[42] = 32;
|
||||
packet[48..].fill(3);
|
||||
assert_eq!(flow, ip_flow(&packet));
|
||||
for len in 0..packet.len() {
|
||||
let _ = ip_flow(&packet[..len]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Transport-independent routing over opaque identities. Tables are built on
|
||||
//! topology changes, never on the packet path. No WireGuard or iroh APIs here.
|
||||
pub(crate) mod envelope;
|
||||
pub mod flow;
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
|
||||
/// Opaque authenticated identity; the transport adapter validates its keys.
|
||||
pub type PeerId = [u8; 32];
|
||||
/// Stable flow identifier, carried unchanged between transit routers.
|
||||
pub type FlowId = u64;
|
||||
|
||||
/// A shortest route with all equal-cost first hops in stable order.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Route {
|
||||
/// Number of transport links to the destination.
|
||||
pub hops: u8,
|
||||
/// Equal-cost first hops. Direct destinations have one entry.
|
||||
pub next_hops: Box<[PeerId]>,
|
||||
}
|
||||
|
||||
/// Immutable shortest-path table, replaced when connectivity changes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RoutingTable {
|
||||
routes: HashMap<PeerId, Route>,
|
||||
}
|
||||
|
||||
impl RoutingTable {
|
||||
/// Computes shortest directed paths bounded by `hop_limit`.
|
||||
/// The caller supplies only authenticated, fresh, compatible edges.
|
||||
pub fn build(local: PeerId, graph: &HashMap<PeerId, Vec<PeerId>>, hop_limit: u8) -> Self {
|
||||
let mut distances = HashMap::from([(local, 0u8)]);
|
||||
let mut first_hops: HashMap<PeerId, BTreeSet<PeerId>> = HashMap::new();
|
||||
let mut pending = VecDeque::from([local]);
|
||||
while let Some(node) = pending.pop_front() {
|
||||
let distance = distances[&node];
|
||||
if distance >= hop_limit {
|
||||
continue;
|
||||
}
|
||||
let inherited = first_hops.get(&node).cloned().unwrap_or_default();
|
||||
for &next in graph.get(&node).into_iter().flatten() {
|
||||
let next_distance = distance + 1;
|
||||
match distances.get(&next) {
|
||||
Some(&known) if known < next_distance => continue,
|
||||
None => {
|
||||
distances.insert(next, next_distance);
|
||||
pending.push_back(next);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let hops = first_hops.entry(next).or_default();
|
||||
if node == local {
|
||||
hops.insert(next);
|
||||
} else {
|
||||
hops.extend(inherited.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
routes: first_hops
|
||||
.into_iter()
|
||||
.map(|(peer, hops)| {
|
||||
(
|
||||
peer,
|
||||
Route {
|
||||
hops: distances[&peer],
|
||||
next_hops: hops.into_iter().collect(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
/// Precomputed route to a destination.
|
||||
pub fn get(&self, destination: &PeerId) -> Option<&Route> {
|
||||
self.routes.get(destination)
|
||||
}
|
||||
/// All reachable destinations, excluding ourselves.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&PeerId, &Route)> {
|
||||
self.routes.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable ECMP mixing, without random state or hashing the payload per hop.
|
||||
pub(crate) fn mix64(mut value: u64) -> u64 {
|
||||
value ^= value >> 30;
|
||||
value = value.wrapping_mul(0xbf58476d1ce4e5b9);
|
||||
value ^= value >> 27;
|
||||
value = value.wrapping_mul(0x94d049bb133111eb);
|
||||
value ^ (value >> 31)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
fn p(n: u8) -> PeerId {
|
||||
[n; 32]
|
||||
}
|
||||
#[test]
|
||||
fn shortest_paths_keep_equal_cost_hops_and_prefer_direct_links() {
|
||||
let mut graph = HashMap::from([
|
||||
(p(1), vec![p(3), p(2)]),
|
||||
(p(2), vec![p(4)]),
|
||||
(p(3), vec![p(4)]),
|
||||
(p(4), vec![p(5)]),
|
||||
(p(5), vec![p(1)]),
|
||||
]);
|
||||
let routes = RoutingTable::build(p(1), &graph, 16);
|
||||
assert_eq!(routes.get(&p(5)).unwrap().hops, 3);
|
||||
assert_eq!(&*routes.get(&p(5)).unwrap().next_hops, &[p(2), p(3)]);
|
||||
assert!(routes.get(&p(1)).is_none());
|
||||
assert!(routes.get(&p(9)).is_none());
|
||||
graph.get_mut(&p(1)).unwrap().push(p(5));
|
||||
assert_eq!(
|
||||
RoutingTable::build(p(1), &graph, 16).get(&p(5)).unwrap(),
|
||||
&Route {
|
||||
hops: 1,
|
||||
next_hops: Box::new([p(5)])
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn topology_removal_and_hop_budget_remove_impossible_routes() {
|
||||
let mut graph = HashMap::from([(p(1), vec![p(2)]), (p(2), vec![p(3)]), (p(3), vec![p(4)])]);
|
||||
assert!(RoutingTable::build(p(1), &graph, 2).get(&p(4)).is_none());
|
||||
assert!(RoutingTable::build(p(1), &graph, 3).get(&p(4)).is_some());
|
||||
graph.remove(&p(2));
|
||||
assert!(RoutingTable::build(p(1), &graph, 16).get(&p(4)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! External link negotiation, deliberately separate from the IP encryption plugin.
|
||||
use crate::dataplane::{PluginCapability, PluginError, ProtocolOption};
|
||||
use crate::{BoxFuture, NetworkId};
|
||||
use iroh::EndpointId;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Capability and lifecycle of one external data carrier.
|
||||
/// Only this layer sees reachability. It never receives plaintext overlay IP packets.
|
||||
pub trait DataCarrier: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// External protocol identifier used in local policy and peer negotiation.
|
||||
fn protocol_id(&self) -> &str;
|
||||
/// Wire version, independent of software releases and the internal tunnel.
|
||||
fn protocol_version(&self) -> u16;
|
||||
/// Supported carrier settings.
|
||||
fn options(&self) -> &'static [ProtocolOption] {
|
||||
&[]
|
||||
}
|
||||
/// Bounded opaque reachability/negotiation capability for one network.
|
||||
fn local_capability(&self, network: NetworkId)
|
||||
-> Result<Option<PluginCapability>, PluginError>;
|
||||
/// Installs a capability received through an authenticated membership session.
|
||||
fn on_peer_capability(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
capability: &PluginCapability,
|
||||
) -> Result<(), PluginError>;
|
||||
/// Supplies the actual owned interface name, including kernel-assigned names.
|
||||
fn on_interface_ready(&self, _name: &str) {}
|
||||
/// Activates this carrier for one local network.
|
||||
fn on_network_activated(&self, _network: NetworkId) {}
|
||||
/// Retires all negotiation and links for this member.
|
||||
fn on_peer_gone(&self, network: NetworkId, peer: EndpointId);
|
||||
/// Retires this network's sockets and negotiations.
|
||||
fn on_network_deactivated(&self, network: NetworkId);
|
||||
/// Forgets durable carrier settings, if any.
|
||||
fn on_network_forgotten(&self, _network: NetworkId) {}
|
||||
/// Stops carrier tasks, with the agent enforcing a shutdown deadline.
|
||||
fn shutdown(&self) -> BoxFuture<'_, ()> {
|
||||
Box::pin(async {})
|
||||
}
|
||||
}
|
||||
/// One shared carrier adapter.
|
||||
pub type SharedCarrier = Arc<dyn DataCarrier>;
|
||||
File renamed without changes.
@@ -0,0 +1,286 @@
|
||||
//! Authenticated probes of the actual data link, independent of control RTT.
|
||||
//! Keys are negotiated privately for each physical link. No fixed wire magic.
|
||||
use super::{PacketLink, SharedLink, TransportError};
|
||||
use crate::{BoxFuture, NetworkId};
|
||||
use bytes::Bytes;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use iroh::EndpointId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, zeroize::ZeroizeOnDrop)]
|
||||
pub(crate) struct Settings {
|
||||
marker: [u8; 16],
|
||||
key: [u8; 32],
|
||||
}
|
||||
impl Settings {
|
||||
pub(crate) fn fresh() -> Self {
|
||||
Self {
|
||||
marker: rand::random(),
|
||||
key: rand::random(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::fmt::Debug for Settings {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("HealthSettings([REDACTED])")
|
||||
}
|
||||
}
|
||||
struct Samples {
|
||||
pending: VecDeque<(u64, Instant)>,
|
||||
recent: VecDeque<bool>,
|
||||
}
|
||||
impl Samples {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
pending: VecDeque::new(),
|
||||
recent: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
fn sent(&mut self, seq: u64, now: Instant) {
|
||||
self.pending.push_back((seq, now));
|
||||
}
|
||||
fn acknowledge(&mut self, seq: u64) {
|
||||
if let Some(i) = self.pending.iter().position(|(n, _)| *n == seq) {
|
||||
self.pending.remove(i);
|
||||
self.recent.push_back(true);
|
||||
}
|
||||
}
|
||||
fn expire(&mut self, now: Instant) {
|
||||
while self
|
||||
.pending
|
||||
.front()
|
||||
.is_some_and(|(_, at)| now.duration_since(*at) >= Duration::from_secs(3))
|
||||
{
|
||||
self.pending.pop_front();
|
||||
self.recent.push_back(false);
|
||||
}
|
||||
while self.recent.len() > 20 {
|
||||
self.recent.pop_front();
|
||||
}
|
||||
}
|
||||
fn degraded(&self) -> bool {
|
||||
self.recent.len() >= 15
|
||||
&& self.recent.iter().filter(|ok| !**ok).count() * 100 >= self.recent.len() * 60
|
||||
}
|
||||
}
|
||||
/// A monitored physical link. It forwards application bytes unchanged.
|
||||
pub(crate) struct HealthLink {
|
||||
raw: SharedLink,
|
||||
settings: Mutex<Option<Settings>>,
|
||||
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
|
||||
stop: watch::Sender<bool>,
|
||||
reason: Mutex<Option<String>>,
|
||||
}
|
||||
impl std::fmt::Debug for HealthLink {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HealthLink")
|
||||
.field("raw", &self.raw)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
match m.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
}
|
||||
}
|
||||
impl HealthLink {
|
||||
pub(crate) fn new(raw: SharedLink, local: EndpointId, settings: Option<Settings>) -> Arc<Self> {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
let (stop, _) = watch::channel(false);
|
||||
let link = Arc::new(Self {
|
||||
raw: raw.clone(),
|
||||
settings: Mutex::new(settings),
|
||||
rx: tokio::sync::Mutex::new(rx),
|
||||
stop,
|
||||
reason: Mutex::new(None),
|
||||
});
|
||||
let weak = Arc::downgrade(&link);
|
||||
let mut stopped = link.stop.subscribe();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut samples = Samples::new();
|
||||
let mut sequence = rand::random::<u64>();
|
||||
let mut since: Option<Instant> = None;
|
||||
let mut marker = None;
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_=stopped.changed()=>break,
|
||||
_=interval.tick()=>{
|
||||
let Some(link)=weak.upgrade()else{break;};let Some(settings)=lock(&link.settings).clone()else{continue;};
|
||||
if marker != Some(settings.marker) { marker = Some(settings.marker); samples = Samples::new(); since = None; }
|
||||
let start=*since.get_or_insert_with(Instant::now);let now=Instant::now();samples.expire(now);
|
||||
if now.duration_since(start)>=Duration::from_secs(20) && samples.degraded() {
|
||||
*lock(&link.reason)=Some("data delivery checks: at least 60% loss over the last 20 probes".into());break;
|
||||
}
|
||||
sequence=sequence.wrapping_add(1);
|
||||
let size=raw.max_datagram_size();
|
||||
let packet=probe(&settings,local,1,sequence,size.min(1000));
|
||||
samples.sent(sequence,now);
|
||||
let _=raw.send(packet);
|
||||
}
|
||||
packet=raw.recv()=>{
|
||||
let Some(packet)=packet else{break;};let Some(link)=weak.upgrade()else{break;};
|
||||
let settings=lock(&link.settings).clone();
|
||||
if let Some(settings)=settings && packet.starts_with(&settings.marker) {
|
||||
if let Some((kind,seq))=verify(&settings,raw.peer(),&packet) {
|
||||
if kind==1 {let _=raw.send(probe(&settings,local,2,seq,packet.len()));}
|
||||
else if kind==2 {samples.acknowledge(seq);}
|
||||
}
|
||||
} else {let _=tx.try_send(packet);}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
raw.close();
|
||||
if let Some(link) = weak.upgrade() {
|
||||
link.stop.send_replace(true);
|
||||
}
|
||||
});
|
||||
link
|
||||
}
|
||||
pub(crate) fn configure(&self, settings: Settings) {
|
||||
*lock(&self.settings) = Some(settings);
|
||||
}
|
||||
}
|
||||
fn probe(settings: &Settings, sender: EndpointId, kind: u8, seq: u64, size: usize) -> Bytes {
|
||||
let mut bytes = vec![0; size.max(57)];
|
||||
bytes[..16].copy_from_slice(&settings.marker);
|
||||
bytes[16] = kind;
|
||||
bytes[17..25].copy_from_slice(&seq.to_be_bytes());
|
||||
rand::fill(&mut bytes[25..size.max(57) - 32]);
|
||||
let tail = bytes.len() - 32;
|
||||
if let Ok(mut mac) = <Hmac<Sha256> as KeyInit>::new_from_slice(&settings.key) {
|
||||
mac.update(sender.as_bytes());
|
||||
mac.update(&bytes[..tail]);
|
||||
bytes[tail..].copy_from_slice(&mac.finalize().into_bytes());
|
||||
}
|
||||
Bytes::from(bytes)
|
||||
}
|
||||
fn verify(settings: &Settings, sender: EndpointId, bytes: &[u8]) -> Option<(u8, u64)> {
|
||||
if bytes.len() < 57 || !bytes.starts_with(&settings.marker) {
|
||||
return None;
|
||||
}
|
||||
let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(&settings.key).ok()?;
|
||||
let tail = bytes.len() - 32;
|
||||
mac.update(sender.as_bytes());
|
||||
mac.update(&bytes[..tail]);
|
||||
mac.verify_slice(&bytes[tail..]).ok()?;
|
||||
Some((
|
||||
bytes[16],
|
||||
u64::from_be_bytes(bytes.get(17..25)?.try_into().ok()?),
|
||||
))
|
||||
}
|
||||
impl PacketLink for HealthLink {
|
||||
fn network(&self) -> NetworkId {
|
||||
self.raw.network()
|
||||
}
|
||||
fn peer(&self) -> EndpointId {
|
||||
self.raw.peer()
|
||||
}
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
self.raw.max_datagram_size()
|
||||
}
|
||||
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
|
||||
if self.is_closed() {
|
||||
Err(TransportError::Closed)
|
||||
} else {
|
||||
self.raw.send(payload)
|
||||
}
|
||||
}
|
||||
fn send_flow(
|
||||
&self,
|
||||
payload: Bytes,
|
||||
flow: super::super::routing::FlowId,
|
||||
) -> Result<(), TransportError> {
|
||||
if self.is_closed() {
|
||||
Err(TransportError::Closed)
|
||||
} else {
|
||||
self.raw.send_flow(payload, flow)
|
||||
}
|
||||
}
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move {
|
||||
let mut stop = self.stop.subscribe();
|
||||
if *stop.borrow() {
|
||||
return None;
|
||||
}
|
||||
let mut rx = self.rx.lock().await;
|
||||
tokio::select! {biased;_=stop.changed()=>None,packet=rx.recv()=>packet}
|
||||
})
|
||||
}
|
||||
fn closed(&self) -> BoxFuture<'_, ()> {
|
||||
Box::pin(async move {
|
||||
let mut stop = self.stop.subscribe();
|
||||
if !*stop.borrow() {
|
||||
let _ = stop.changed().await;
|
||||
}
|
||||
})
|
||||
}
|
||||
fn is_closed(&self) -> bool {
|
||||
*self.stop.borrow() || self.raw.is_closed()
|
||||
}
|
||||
fn close(&self) {
|
||||
self.stop.send_replace(true);
|
||||
self.raw.close();
|
||||
}
|
||||
fn failure_reason(&self) -> Option<String> {
|
||||
lock(&self.reason)
|
||||
.clone()
|
||||
.or_else(|| self.raw.failure_reason())
|
||||
}
|
||||
fn path_description(&self) -> String {
|
||||
self.raw.path_description()
|
||||
}
|
||||
}
|
||||
impl Drop for HealthLink {
|
||||
fn drop(&mut self) {
|
||||
self.stop.send_replace(true);
|
||||
self.raw.close();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
#[test]
|
||||
fn no_false_failure_before_window_but_persistent_loss_is_detected() {
|
||||
let mut samples = Samples::new();
|
||||
let now = Instant::now();
|
||||
for seq in 0..20 {
|
||||
samples.sent(seq, now);
|
||||
if seq % 5 == 0 {
|
||||
samples.acknowledge(seq);
|
||||
}
|
||||
}
|
||||
assert!(!samples.degraded());
|
||||
samples.expire(now + Duration::from_secs(4));
|
||||
assert!(samples.degraded());
|
||||
for seq in 20..40 {
|
||||
samples.sent(seq, now);
|
||||
samples.acknowledge(seq);
|
||||
}
|
||||
samples.expire(now + Duration::from_secs(4));
|
||||
assert!(!samples.degraded());
|
||||
}
|
||||
#[test]
|
||||
fn a_forged_or_reflected_probe_cannot_claim_delivery() {
|
||||
let a = crate::identity::DeviceIdentity::generate().endpoint_id();
|
||||
let b = crate::identity::DeviceIdentity::generate().endpoint_id();
|
||||
let settings = Settings::fresh();
|
||||
let packet = probe(&settings, a, 1, 42, 1100);
|
||||
assert_eq!(verify(&settings, a, &packet), Some((1, 42)));
|
||||
assert!(verify(&settings, b, &packet).is_none());
|
||||
let mut forged = packet.to_vec();
|
||||
forged[16] = 2;
|
||||
assert!(verify(&settings, a, &forged).is_none());
|
||||
}
|
||||
}
|
||||
+58
-1
@@ -40,6 +40,49 @@ use crate::proto::{read_frame, write_frame};
|
||||
use super::fragments::{self, Reassembler};
|
||||
use super::{InboundLink, PacketLink, PacketTransport, SharedLink, TransportError};
|
||||
|
||||
/// QUIC carrier capability. Encryption of end-to-end IP packets is independent.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct IrohCarrier;
|
||||
|
||||
impl super::DataCarrier for IrohCarrier {
|
||||
fn protocol_id(&self) -> &str {
|
||||
"wg-quic"
|
||||
}
|
||||
fn protocol_version(&self) -> u16 {
|
||||
1
|
||||
}
|
||||
fn local_capability(
|
||||
&self,
|
||||
_: NetworkId,
|
||||
) -> Result<Option<crate::dataplane::PluginCapability>, crate::dataplane::PluginError> {
|
||||
Ok(Some(crate::dataplane::PluginCapability {
|
||||
protocol: self.protocol_id().into(),
|
||||
version: self.protocol_version(),
|
||||
enabled: true,
|
||||
data: Vec::new(),
|
||||
}))
|
||||
}
|
||||
fn on_peer_capability(
|
||||
&self,
|
||||
_: NetworkId,
|
||||
_: EndpointId,
|
||||
capability: &crate::dataplane::PluginCapability,
|
||||
) -> Result<(), crate::dataplane::PluginError> {
|
||||
if capability.protocol != self.protocol_id()
|
||||
|| capability.version != self.protocol_version()
|
||||
|| !capability.enabled
|
||||
|| !capability.data.is_empty()
|
||||
{
|
||||
return Err(crate::dataplane::PluginError::Rejected(
|
||||
"unsupported QUIC carrier capability".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn on_peer_gone(&self, _: NetworkId, _: EndpointId) {}
|
||||
fn on_network_deactivated(&self, _: NetworkId) {}
|
||||
}
|
||||
|
||||
/// What the iroh transport needs from the agent.
|
||||
///
|
||||
/// Implemented by the agent, which is the only thing that knows which networks
|
||||
@@ -97,6 +140,9 @@ impl IrohLink {
|
||||
}
|
||||
|
||||
impl PacketLink for IrohLink {
|
||||
fn close(&self) {
|
||||
self.conn.close(0u32.into(), b"data link retired");
|
||||
}
|
||||
fn network(&self) -> NetworkId {
|
||||
self.network
|
||||
}
|
||||
@@ -187,6 +233,10 @@ impl PacketLink for IrohLink {
|
||||
self.conn.close_reason().is_some()
|
||||
}
|
||||
|
||||
fn failure_reason(&self) -> Option<String> {
|
||||
self.conn.close_reason().map(|reason| reason.to_string())
|
||||
}
|
||||
|
||||
fn path_description(&self) -> String {
|
||||
// Report what iroh actually knows, never a guess.
|
||||
let snapshot = crate::net::snapshot_connection(&self.conn);
|
||||
@@ -261,7 +311,8 @@ impl IrohTransport {
|
||||
));
|
||||
}
|
||||
|
||||
let serves = self.lookup.serves(outcome.network_id, &open.protocol).await;
|
||||
let serves = open.protocol == "wg-quic"
|
||||
&& self.lookup.serves(outcome.network_id, &open.protocol).await;
|
||||
if open.max_datagram == 0 || conn.max_datagram_size().is_none() {
|
||||
return Err(TransportError::Other(
|
||||
"data channel has no datagram support".into(),
|
||||
@@ -322,6 +373,12 @@ impl PacketTransport for IrohTransport {
|
||||
"data channel protocol id is out of bounds".into(),
|
||||
));
|
||||
}
|
||||
// This default must never impersonate a missing native carrier.
|
||||
if protocol != "wg-quic" {
|
||||
return Err(TransportError::Declined(format!(
|
||||
"no native `{protocol}` data transport registered"
|
||||
)));
|
||||
}
|
||||
let keys = self
|
||||
.lookup
|
||||
.snapshot()
|
||||
@@ -0,0 +1,320 @@
|
||||
//! The data plane transport boundary.
|
||||
//!
|
||||
//! This is the seam that keeps the control protocol and the data plane
|
||||
//! independent. An [`IpPlugin`] never learns how its packets are moved: it is
|
||||
//! handed a [`PacketLink`] to a peer and writes datagrams into it. Whether
|
||||
//! that link runs over iroh today, a raw UDP socket, or something else
|
||||
//! entirely tomorrow is the transport's business alone.
|
||||
//!
|
||||
//! [`IpPlugin`]: crate::dataplane::IpPlugin
|
||||
//!
|
||||
//! # Why the transport may use iroh
|
||||
//!
|
||||
//! The separation between control and data is **logical**, not a ban on
|
||||
//! sharing technology. Refusing to use iroh for data would throw away exactly
|
||||
//! what iroh is good at — hole punching a direct path between two peers behind
|
||||
//! NAT, with a relay as fallback — and force the data plane to reimplement it.
|
||||
//! So the default transport is [`iroh_link::IrohTransport`], which gives every
|
||||
//! plugin that connectivity for free.
|
||||
//!
|
||||
//! What the separation does buy is that the control protocol in
|
||||
//! [`crate::proto`] knows nothing about packets, and this module knows nothing
|
||||
//! about WireGuard. Either side can be replaced on its own.
|
||||
//!
|
||||
//! # Semantics
|
||||
//!
|
||||
//! A link is an **unreliable, unordered datagram** channel, because that is
|
||||
//! what a tunnelled UDP protocol needs: no retransmission, no head-of-line
|
||||
//! blocking, loss is normal rather than an error. It is authenticated and
|
||||
//! encrypted by the transport, and scoped to exactly one network, one peer and
|
||||
//! one plugin protocol.
|
||||
|
||||
mod fragments;
|
||||
pub(crate) mod health;
|
||||
pub mod iroh_link;
|
||||
|
||||
pub mod carrier;
|
||||
pub use carrier::{DataCarrier, SharedCarrier};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
/// Why a data plane link failed.
|
||||
///
|
||||
/// None of these ever stop the control plane.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum TransportError {
|
||||
/// The peer is not reachable for the data plane right now.
|
||||
#[error("peer is unreachable: {0}")]
|
||||
Unreachable(String),
|
||||
/// The peer declined to open a channel for this protocol.
|
||||
#[error("peer declined a data channel for protocol `{0}`")]
|
||||
Declined(String),
|
||||
/// The link is closed.
|
||||
#[error("data link is closed")]
|
||||
Closed,
|
||||
/// A datagram was larger than the link can carry.
|
||||
#[error("datagram of {size} bytes exceeds the {limit} byte link limit")]
|
||||
TooLarge {
|
||||
/// Size that was attempted.
|
||||
size: usize,
|
||||
/// Largest datagram this link accepts.
|
||||
limit: usize,
|
||||
},
|
||||
/// Anything else.
|
||||
#[error("data transport error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// An authenticated datagram channel to one peer, for one plugin protocol.
|
||||
///
|
||||
/// Dropping the link closes it.
|
||||
pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// The network this link belongs to.
|
||||
fn network(&self) -> NetworkId;
|
||||
|
||||
/// The authenticated peer on the other end.
|
||||
fn peer(&self) -> EndpointId;
|
||||
|
||||
/// The largest datagram this link can carry, in bytes.
|
||||
///
|
||||
/// This is the logical payload limit. A transport can fragment underneath
|
||||
/// it so path MTU changes do not force a plugin to resize the host interface.
|
||||
fn max_datagram_size(&self) -> usize;
|
||||
|
||||
/// Sends one datagram.
|
||||
///
|
||||
/// Delivery is not guaranteed. Returning `Ok` means the datagram was
|
||||
/// handed to the transport, nothing more.
|
||||
fn send(&self, payload: Bytes) -> Result<(), TransportError>;
|
||||
|
||||
/// Sends a datagram with an opaque stable flow identifier. Plugins derive
|
||||
/// this before encryption; transports that do not route can ignore it.
|
||||
fn send_flow(
|
||||
&self,
|
||||
payload: Bytes,
|
||||
_flow: super::routing::FlowId,
|
||||
) -> Result<(), TransportError> {
|
||||
self.send(payload)
|
||||
}
|
||||
|
||||
/// Receives the next datagram, or `None` once the link is finished.
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>>;
|
||||
|
||||
/// Resolves once the link is closed, for whatever reason.
|
||||
fn closed(&self) -> BoxFuture<'_, ()>;
|
||||
|
||||
/// Whether the link is already closed.
|
||||
///
|
||||
/// Lets the owner notice a dead link and ask for a new one without
|
||||
/// keeping a task parked on [`PacketLink::closed`].
|
||||
fn is_closed(&self) -> bool;
|
||||
|
||||
/// Explicitly retires a physical link. Implementations should wake readers.
|
||||
fn close(&self) {}
|
||||
|
||||
/// Observed reason for retiring this link, if available.
|
||||
fn failure_reason(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A short description of the path in use, for diagnostics.
|
||||
///
|
||||
/// Reports what the transport actually knows. It must not invent a value.
|
||||
fn path_description(&self) -> String;
|
||||
}
|
||||
|
||||
/// A shared handle to a link.
|
||||
pub type SharedLink = std::sync::Arc<dyn PacketLink>;
|
||||
|
||||
/// Maximum opaque pair-negotiation payload.
|
||||
pub const MAX_TRANSPORT_PAYLOAD: usize = 4096;
|
||||
|
||||
/// Private transport bytes, never included in Debug output.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, zeroize::ZeroizeOnDrop)]
|
||||
pub struct TransportPayload(Vec<u8>);
|
||||
|
||||
impl<'de> Deserialize<'de> for TransportPayload {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Self, D::Error> {
|
||||
struct Visitor;
|
||||
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||
type Value = TransportPayload;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "at most {MAX_TRANSPORT_PAYLOAD} private transport bytes")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(
|
||||
self,
|
||||
mut sequence: A,
|
||||
) -> std::result::Result<Self::Value, A::Error> {
|
||||
if sequence
|
||||
.size_hint()
|
||||
.is_some_and(|size| size > MAX_TRANSPORT_PAYLOAD)
|
||||
{
|
||||
return Err(serde::de::Error::custom("transport payload too large"));
|
||||
}
|
||||
let mut payload = TransportPayload(Vec::with_capacity(
|
||||
sequence.size_hint().unwrap_or(0).min(MAX_TRANSPORT_PAYLOAD),
|
||||
));
|
||||
while let Some(byte) = sequence.next_element::<u8>()? {
|
||||
if payload.0.len() == MAX_TRANSPORT_PAYLOAD {
|
||||
return Err(serde::de::Error::custom("transport payload too large"));
|
||||
}
|
||||
payload.0.push(byte);
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_seq(Visitor)
|
||||
}
|
||||
}
|
||||
impl TransportPayload {
|
||||
/// Wraps bytes for one authenticated control session.
|
||||
pub fn new(bytes: Vec<u8>) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
/// Borrows private bytes; callers must not log them.
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl std::fmt::Debug for TransportPayload {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("TransportPayload([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
/// Private control-plane sender for transports. Contains no network secret.
|
||||
pub trait ControlSender: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// Queues a bounded message to one authenticated member of one network.
|
||||
fn send<'a>(
|
||||
&'a self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
protocol: &'a str,
|
||||
payload: TransportPayload,
|
||||
) -> BoxFuture<'a, Result<(), TransportError>>;
|
||||
}
|
||||
|
||||
/// An inbound link a peer opened towards us.
|
||||
#[derive(Debug)]
|
||||
pub struct InboundLink {
|
||||
/// The network it belongs to.
|
||||
pub network: NetworkId,
|
||||
/// The peer that opened it.
|
||||
pub peer: EndpointId,
|
||||
/// The plugin protocol it carries.
|
||||
pub protocol: String,
|
||||
/// The link itself.
|
||||
pub link: SharedLink,
|
||||
}
|
||||
|
||||
/// Opens and accepts data plane links.
|
||||
///
|
||||
/// The agent owns one of these and hands links to plugins; plugins never call
|
||||
/// it directly.
|
||||
pub trait PacketTransport: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// Supplies a private sender before network activation.
|
||||
fn attach_control(&self, _control: Arc<dyn ControlSender>) {}
|
||||
/// Validates and queues private negotiation received from an authenticated member.
|
||||
fn on_control(
|
||||
&self,
|
||||
_network: NetworkId,
|
||||
_peer: EndpointId,
|
||||
_protocol: &str,
|
||||
_payload: &[u8],
|
||||
) -> Result<(), TransportError> {
|
||||
Err(TransportError::Declined(self.name().into()))
|
||||
}
|
||||
/// A short name used in diagnostics.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Lets the agent recover the concrete transport to drive its accept side.
|
||||
///
|
||||
/// Accepting is inherently transport-specific — it starts from whatever
|
||||
/// the transport's own listener produced — so it is not part of this
|
||||
/// trait's uniform interface.
|
||||
fn as_any(&self) -> &dyn std::any::Any;
|
||||
|
||||
/// Opens a link to `peer` in `network` for `protocol`.
|
||||
fn open<'a>(
|
||||
&'a self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
protocol: &'a str,
|
||||
) -> BoxFuture<'a, Result<SharedLink, TransportError>>;
|
||||
}
|
||||
|
||||
/// A composite packet transport that dispatches requests by protocol id.
|
||||
#[derive(Debug)]
|
||||
pub struct MultiPacketTransport {
|
||||
default: Arc<dyn PacketTransport>,
|
||||
by_protocol: std::collections::HashMap<String, Arc<dyn PacketTransport>>,
|
||||
}
|
||||
|
||||
impl MultiPacketTransport {
|
||||
/// Creates a new composite transport with a default fallback transport.
|
||||
pub fn new(
|
||||
default: Arc<dyn PacketTransport>,
|
||||
by_protocol: std::collections::HashMap<String, Arc<dyn PacketTransport>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
default,
|
||||
by_protocol,
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the default underlying transport.
|
||||
pub fn default_transport(&self) -> &Arc<dyn PacketTransport> {
|
||||
&self.default
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketTransport for MultiPacketTransport {
|
||||
fn attach_control(&self, control: Arc<dyn ControlSender>) {
|
||||
self.default.attach_control(control.clone());
|
||||
for transport in self.by_protocol.values() {
|
||||
transport.attach_control(control.clone());
|
||||
}
|
||||
}
|
||||
fn on_control(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
protocol: &str,
|
||||
payload: &[u8],
|
||||
) -> Result<(), TransportError> {
|
||||
match self.by_protocol.get(protocol) {
|
||||
Some(transport) => transport.on_control(network, peer, protocol, payload),
|
||||
None => Err(TransportError::Declined(protocol.into())),
|
||||
}
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
"multi"
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn open<'a>(
|
||||
&'a self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
protocol: &'a str,
|
||||
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
|
||||
if let Some(transport) = self.by_protocol.get(protocol) {
|
||||
transport.open(network, peer, protocol)
|
||||
} else {
|
||||
self.default.open(network, peer, protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,333 @@
|
||||
//! Telling the macOS resolver to send some questions here, through
|
||||
//! `/etc/resolver`.
|
||||
//!
|
||||
//! macOS reads one file per domain out of `/etc/resolver`: a file named after
|
||||
//! a domain, containing the `nameserver` and `port` to send that domain's
|
||||
//! questions to, makes the system resolver a *scoped* resolver for exactly
|
||||
//! that suffix and nothing else (see `resolver(5)`). That is the same contract
|
||||
//! the systemd-resolved and NRPT publishers arrange on the other platforms:
|
||||
//! route these suffixes to this server, and claim nothing else.
|
||||
//!
|
||||
//! # Why a port, and so no privileged socket
|
||||
//!
|
||||
//! A resolver file carries a `port`, so the server does not have to sit on 53
|
||||
//! and the agent needs nothing like `CAP_NET_BIND_SERVICE`. It can serve on a
|
||||
//! high port and still be reached for its suffixes.
|
||||
//!
|
||||
//! # Tagged, reversible, and never clobbering
|
||||
//!
|
||||
//! Every file this publisher writes begins with a marker line, so it only ever
|
||||
//! replaces or removes files it wrote itself: a pre-existing `/etc/resolver`
|
||||
//! entry a user set up by hand is left untouched. The files are removed when
|
||||
//! the resolver is turned off and on shutdown; a crash leaves them behind, and
|
||||
//! the next apply for the same domains overwrites them.
|
||||
//!
|
||||
//! # Privilege
|
||||
//!
|
||||
//! Writing under `/etc/resolver` needs root. A permission error is reported as
|
||||
//! a refusal — the kind of failure a person has to act on — rather than a
|
||||
//! transient one, so the agent does not retry it at the ordinary pace.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published};
|
||||
|
||||
/// The directory macOS reads scoped resolvers from.
|
||||
const RESOLVER_DIR: &str = "/etc/resolver";
|
||||
|
||||
/// The first line of every file this publisher writes, so it recognises its
|
||||
/// own and never touches a file a user created.
|
||||
const MARKER: &str = "# managed by tsunagi — do not edit";
|
||||
|
||||
/// Writes and removes `/etc/resolver` files for the overlay zones.
|
||||
#[derive(Debug)]
|
||||
pub struct ResolverDirPublisher {
|
||||
/// The resolver directory. A field rather than a constant so the tests can
|
||||
/// point it at a temporary directory and never touch the real one.
|
||||
dir: PathBuf,
|
||||
/// The domain files written by the last apply, so a later apply can remove
|
||||
/// the ones no longer wanted and `revert` can remove them all.
|
||||
written: Mutex<BTreeSet<String>>,
|
||||
}
|
||||
|
||||
impl Default for ResolverDirPublisher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolverDirPublisher {
|
||||
/// A publisher writing to the real `/etc/resolver`.
|
||||
pub fn new() -> Self {
|
||||
Self::in_dir(RESOLVER_DIR)
|
||||
}
|
||||
|
||||
/// A publisher writing to a specific directory. For the tests.
|
||||
fn in_dir(dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
dir: dir.into(),
|
||||
written: Mutex::new(BTreeSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_inner(&self, published: &Published) -> Result<(), PublishError> {
|
||||
let desired = desired_files(published)?;
|
||||
|
||||
// The directory may not exist yet; creating it needs root.
|
||||
std::fs::create_dir_all(&self.dir).map_err(|err| classify(&self.dir, err))?;
|
||||
|
||||
// Remove the files from a previous apply that are no longer wanted.
|
||||
let previous = lock(&self.written).clone();
|
||||
for name in previous.difference(&keys(&desired)) {
|
||||
remove_if_ours(&self.dir.join(name));
|
||||
}
|
||||
|
||||
for (name, body) in &desired {
|
||||
let path = self.dir.join(name);
|
||||
// Only ever overwrite a file that is already ours, or a name that
|
||||
// is free. A resolver a user set up by hand is left in place and
|
||||
// reported, never clobbered.
|
||||
if path.exists() && !is_ours(&path) {
|
||||
return Err(PublishError::Failed(format!(
|
||||
"{} already exists and was not created by tsunagi; \
|
||||
remove it or rename the network to publish its resolver",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
std::fs::write(&path, body).map_err(|err| classify(&path, err))?;
|
||||
}
|
||||
|
||||
*lock(&self.written) = keys(&desired);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn revert_inner(&self) {
|
||||
let written = std::mem::take(&mut *lock(&self.written));
|
||||
for name in &written {
|
||||
remove_if_ours(&self.dir.join(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsPublisher for ResolverDirPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"resolver-dir"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
// A handful of small file writes, short enough to run in place rather
|
||||
// than hop to the blocking pool.
|
||||
Box::pin(async move { self.apply_inner(published) })
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
self.revert_inner();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolver files a publication implies: a map of file name to contents.
|
||||
fn desired_files(published: &Published) -> Result<Vec<(String, String)>, PublishError> {
|
||||
if published.servers.is_empty() {
|
||||
return Err(PublishError::Failed(
|
||||
"no server address to point the resolver at".to_string(),
|
||||
));
|
||||
}
|
||||
let body = file_contents(published);
|
||||
let mut files = Vec::new();
|
||||
for domain in &published.domains {
|
||||
let name = resolver_file_name(domain)?;
|
||||
files.push((name, body.clone()));
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// The contents of one resolver file: the marker, every server, one port.
|
||||
fn file_contents(published: &Published) -> String {
|
||||
let mut out = String::from(MARKER);
|
||||
out.push('\n');
|
||||
for server in &published.servers {
|
||||
let ip = match server.ip() {
|
||||
IpAddr::V4(v4) => v4.to_string(),
|
||||
IpAddr::V6(v6) => v6.to_string(),
|
||||
};
|
||||
out.push_str("nameserver ");
|
||||
out.push_str(&ip);
|
||||
out.push('\n');
|
||||
}
|
||||
// One port for the file; every server listens on the same one. The `port`
|
||||
// directive is what lets the server stay off 53.
|
||||
if let Some(first) = published.servers.first() {
|
||||
out.push_str("port ");
|
||||
out.push_str(&first.port().to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The file name for a domain, rejecting anything that is not a plain label
|
||||
/// path component: a resolver file name must never escape the directory.
|
||||
fn resolver_file_name(domain: &str) -> Result<String, PublishError> {
|
||||
let trimmed = domain.trim_end_matches('.');
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.contains('/')
|
||||
|| trimmed.contains('\\')
|
||||
|| trimmed.contains(std::path::MAIN_SEPARATOR)
|
||||
|| trimmed == "."
|
||||
|| trimmed == ".."
|
||||
|| trimmed.starts_with('.')
|
||||
{
|
||||
return Err(PublishError::Failed(format!(
|
||||
"cannot make a resolver file name for the domain {domain:?}"
|
||||
)));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
/// Whether a file is one this publisher wrote, by its marker first line.
|
||||
fn is_ours(path: &Path) -> bool {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => text.lines().next() == Some(MARKER),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a file, but only if it is one of ours. Best effort.
|
||||
fn remove_if_ours(path: &Path) {
|
||||
if is_ours(path)
|
||||
&& let Err(err) = std::fs::remove_file(path)
|
||||
&& err.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
tracing::debug!(path = %path.display(), %err, "cannot remove the resolver file");
|
||||
}
|
||||
}
|
||||
|
||||
/// The set of file names in a desired-files list.
|
||||
fn keys(files: &[(String, String)]) -> BTreeSet<String> {
|
||||
files.iter().map(|(name, _)| name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Turns a filesystem error into the right kind of publish error: a permission
|
||||
/// problem is a refusal a person must act on, anything else is a plain failure.
|
||||
fn classify(path: &Path, err: std::io::Error) -> PublishError {
|
||||
if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
PublishError::Refused(format!(
|
||||
"writing {} needs root; start the agent with `sudo` or as a root LaunchDaemon",
|
||||
path.display()
|
||||
))
|
||||
} else {
|
||||
PublishError::Failed(format!("cannot write {}: {err}", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn published(domains: &[&str]) -> Published {
|
||||
Published {
|
||||
interface: "utun5".into(),
|
||||
servers: vec![
|
||||
"10.13.37.1:5353".parse::<SocketAddr>().unwrap(),
|
||||
"[fd00::1]:5353".parse::<SocketAddr>().unwrap(),
|
||||
],
|
||||
domains: domains.iter().map(|d| d.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_lists_every_server_and_one_port_under_the_marker() {
|
||||
let body = file_contents(&published(&["mynet."]));
|
||||
let mut lines = body.lines();
|
||||
assert_eq!(lines.next(), Some(MARKER));
|
||||
assert_eq!(lines.next(), Some("nameserver 10.13.37.1"));
|
||||
assert_eq!(lines.next(), Some("nameserver fd00::1"));
|
||||
assert_eq!(lines.next(), Some("port 5353"));
|
||||
assert_eq!(lines.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_domain_becomes_a_plain_file_name_without_its_trailing_dot() {
|
||||
assert_eq!(resolver_file_name("mynet.").unwrap(), "mynet");
|
||||
assert_eq!(resolver_file_name("my.net").unwrap(), "my.net");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_domain_that_would_escape_the_directory_is_refused() {
|
||||
assert!(resolver_file_name("../etc/passwd").is_err());
|
||||
assert!(resolver_file_name("a/b").is_err());
|
||||
assert!(resolver_file_name("").is_err());
|
||||
assert!(resolver_file_name(".").is_err());
|
||||
assert!(resolver_file_name(".hidden").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_writes_a_file_per_domain_and_revert_removes_them() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let publisher = ResolverDirPublisher::in_dir(dir.path());
|
||||
|
||||
publisher
|
||||
.apply(&published(&["mynet.", "other."]))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(dir.path().join("mynet").is_file());
|
||||
assert!(dir.path().join("other").is_file());
|
||||
|
||||
publisher.revert().await.unwrap();
|
||||
assert!(!dir.path().join("mynet").exists());
|
||||
assert!(!dir.path().join("other").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn re_applying_removes_a_domain_that_is_no_longer_wanted() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let publisher = ResolverDirPublisher::in_dir(dir.path());
|
||||
|
||||
publisher.apply(&published(&["a.", "b."])).await.unwrap();
|
||||
publisher.apply(&published(&["a."])).await.unwrap();
|
||||
|
||||
assert!(dir.path().join("a").is_file());
|
||||
assert!(
|
||||
!dir.path().join("b").exists(),
|
||||
"the dropped domain's file is removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_file_a_user_created_is_never_clobbered_or_removed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let foreign = dir.path().join("mynet");
|
||||
std::fs::write(&foreign, "nameserver 9.9.9.9\n").unwrap();
|
||||
|
||||
let publisher = ResolverDirPublisher::in_dir(dir.path());
|
||||
let err = publisher.apply(&published(&["mynet."])).await.unwrap_err();
|
||||
assert!(matches!(err, PublishError::Failed(_)), "{err:?}");
|
||||
// Untouched.
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&foreign).unwrap(),
|
||||
"nameserver 9.9.9.9\n"
|
||||
);
|
||||
|
||||
// And revert leaves a foreign file alone even if it is in our set.
|
||||
publisher.revert().await.unwrap();
|
||||
assert!(foreign.is_file());
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -28,6 +28,11 @@ mod windows;
|
||||
#[cfg(all(feature = "dns-publish", target_os = "windows"))]
|
||||
pub use windows::NrptPublisher;
|
||||
|
||||
#[cfg(all(feature = "dns-publish", target_os = "macos"))]
|
||||
mod macos;
|
||||
#[cfg(all(feature = "dns-publish", target_os = "macos"))]
|
||||
pub use macos::ResolverDirPublisher;
|
||||
|
||||
mod unsupported;
|
||||
pub use unsupported::UnsupportedPublisher;
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -15,6 +15,12 @@ pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
/// A node-history cleanup is unsafe, stale or cannot be performed.
|
||||
#[error("cannot forget node: {0}")]
|
||||
ForgetNode(String),
|
||||
/// An ordered local data-plane protocol selection is invalid.
|
||||
#[error("invalid protocol selection: {0}")]
|
||||
InvalidProtocols(String),
|
||||
/// The supplied network name does not satisfy the documented rules.
|
||||
#[error("invalid network name: {0}")]
|
||||
InvalidNetworkName(&'static str),
|
||||
@@ -97,6 +103,19 @@ pub enum Error {
|
||||
#[error("network {0} is not configured")]
|
||||
NetworkUnknown(NetworkId),
|
||||
|
||||
/// An overlay range overlaps networks already active on this agent.
|
||||
#[error(
|
||||
"network {network} cannot start: {range} overlaps active networks {conflicts:?}; switch to it to suspend those networks"
|
||||
)]
|
||||
NetworkRangeConflict {
|
||||
/// Network that could not start.
|
||||
network: NetworkId,
|
||||
/// Its agreed or proposed range.
|
||||
range: crate::state::Ipv4Range,
|
||||
/// Active networks that must be suspended first.
|
||||
conflicts: Vec<NetworkId>,
|
||||
},
|
||||
|
||||
/// No session with that peer exists in the given network.
|
||||
#[error("no authenticated session with peer {peer} in network {network}")]
|
||||
NoSuchPeer {
|
||||
@@ -120,6 +139,13 @@ pub enum Error {
|
||||
what: String,
|
||||
},
|
||||
|
||||
/// The request is understood and cannot be done as things stand.
|
||||
#[error("{reason}")]
|
||||
Rejected {
|
||||
/// Why, phrased so the message stands on its own.
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// Discovery backend failure. Never fatal for the agent.
|
||||
#[error("discovery error: {0}")]
|
||||
Discovery(String),
|
||||
@@ -49,11 +49,16 @@ impl DeviceIdentity {
|
||||
self.secret.public()
|
||||
}
|
||||
|
||||
/// The raw secret key bytes, for persistence only.
|
||||
pub(crate) fn secret_bytes(&self) -> [u8; 32] {
|
||||
/// The raw secret key bytes, for persistence and transport certificate generation.
|
||||
pub fn secret_bytes(&self) -> [u8; 32] {
|
||||
self.secret.to_bytes()
|
||||
}
|
||||
|
||||
/// Signs a message with the device's persistent secret key.
|
||||
pub fn sign(&self, message: &[u8]) -> iroh::Signature {
|
||||
self.secret.sign(message)
|
||||
}
|
||||
|
||||
/// A clone of the iroh secret key, for endpoint construction only.
|
||||
pub(crate) fn secret_key(&self) -> SecretKey {
|
||||
self.secret.clone()
|
||||
File renamed without changes.
File diff suppressed because it is too large.
Load diff
@@ -22,8 +22,8 @@ use crate::error::{Error, Result};
|
||||
// The transport-agnostic surface, re-exported so this module is a complete
|
||||
// view of the local control interface on its own.
|
||||
pub use super::{
|
||||
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
|
||||
request_status, set_active, set_dns, set_hostname,
|
||||
CONTROL_PROTOCOL, ControlSocketAccess, EXCHANGE_TIMEOUT, ReportSource, is_serving,
|
||||
join_network, leave_network, request_status, set_active, set_dns, set_hostname,
|
||||
};
|
||||
|
||||
/// Serves the local control interface on a Unix socket.
|
||||
@@ -39,11 +39,15 @@ impl ControlSocket {
|
||||
/// A socket file left behind by a crashed agent is replaced, but only
|
||||
/// after checking that nothing is listening on it, so two live agents
|
||||
/// never fight over one path.
|
||||
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
|
||||
pub async fn bind(
|
||||
path: impl AsRef<Path>,
|
||||
source: Arc<dyn ReportSource>,
|
||||
access: ControlSocketAccess,
|
||||
) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
crate::storage::create_dir(parent)?;
|
||||
create_parent(parent, &access)?;
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
@@ -61,7 +65,7 @@ impl ControlSocket {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
restrict(&path)?;
|
||||
apply_access(&path, &access)?;
|
||||
|
||||
let task = tokio::spawn(serve(listener, source));
|
||||
Ok(Self {
|
||||
@@ -94,9 +98,48 @@ impl Drop for ControlSocket {
|
||||
}
|
||||
}
|
||||
|
||||
fn restrict(path: &Path) -> Result<()> {
|
||||
/// Creates the socket's parent directory.
|
||||
///
|
||||
/// A group-shared socket must sit in a directory the group can traverse, so it
|
||||
/// is `0755`; a private socket keeps the owner-only state-directory default.
|
||||
fn create_parent(parent: &Path, access: &ControlSocketAccess) -> Result<()> {
|
||||
match access {
|
||||
ControlSocketAccess::Private => crate::storage::create_dir(parent),
|
||||
ControlSocketAccess::Group(_) => {
|
||||
std::fs::create_dir_all(parent).map_err(|source| Error::Io {
|
||||
path: parent.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
set_mode(parent, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the access policy to the bound socket.
|
||||
///
|
||||
/// Private is owner-only `0600`. Group sets the socket's group to the gid and
|
||||
/// the mode to `0660`; if the group cannot be set — the agent is not a member,
|
||||
/// say — it falls back to owner-only rather than leaving the group wrong, so
|
||||
/// the socket is never more open than intended.
|
||||
fn apply_access(path: &Path, access: &ControlSocketAccess) -> Result<()> {
|
||||
match access {
|
||||
ControlSocketAccess::Private => set_mode(path, 0o600),
|
||||
ControlSocketAccess::Group(gid) => {
|
||||
if let Err(source) = std::os::unix::fs::chown(path, None, Some(*gid)) {
|
||||
tracing::warn!(
|
||||
path = %path.display(), gid, %source,
|
||||
"cannot set the control socket group; keeping it owner-only"
|
||||
);
|
||||
return set_mode(path, 0o600);
|
||||
}
|
||||
set_mode(path, 0o660)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mode(path: &Path, mode: u32) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|source| {
|
||||
Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
@@ -170,7 +213,9 @@ mod tests {
|
||||
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
||||
Box::pin(async { StatusReport::default() })
|
||||
});
|
||||
let control = ControlSocket::bind(&path, source).await.unwrap();
|
||||
let control = ControlSocket::bind(&path, source, ControlSocketAccess::Private)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
|
||||
.await
|
||||
@@ -18,15 +18,16 @@
|
||||
//!
|
||||
//! # Who may connect
|
||||
//!
|
||||
//! The pipe rejects clients from other machines, and takes the process's
|
||||
//! default security, under which the creating user has full access. Narrowing
|
||||
//! that further with an explicit ACL needs a raw security-descriptor call this
|
||||
//! crate forbids, so it is not attempted; on a single-user machine, and against
|
||||
//! other unprivileged users, the default is the practical equivalent of the
|
||||
//! owner-only Unix socket. There is no leftover to clean up: a pipe exists only
|
||||
//! while its server does.
|
||||
//! Commands must run as the same Windows user with the same elevation as the
|
||||
//! agent. A permission denial is reported as such, never as an absent agent.
|
||||
//! The pipe rejects clients from other machines (`reject_remote_clients(true)`).
|
||||
//! An explicit SDDL security descriptor grants Full Control to Administrators
|
||||
//! and LocalSystem, while granting Read and Write (`GRGW`) to all local users
|
||||
//! with a Medium Integrity SACL label (`S:(ML;;NW;;;ME)`). This allows unprivileged
|
||||
//! user-space tools (such as tray applications, status monitors, or CLI commands
|
||||
//! launched by regular users) to connect to the agent even when the agent was
|
||||
//! started by an elevated Administrator or as a system service.
|
||||
//! A permission denial is reported as such, never as an absent agent.
|
||||
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -39,8 +40,8 @@ use tokio::task::JoinHandle;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
pub use super::{
|
||||
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
|
||||
request_status, set_active, set_dns, set_hostname,
|
||||
CONTROL_PROTOCOL, ControlSocketAccess, EXCHANGE_TIMEOUT, ReportSource, is_serving,
|
||||
join_network, leave_network, request_status, set_active, set_dns, set_hostname,
|
||||
};
|
||||
|
||||
/// `ERROR_ACCESS_DENIED`: creating a first instance finds an existing pipe,
|
||||
@@ -50,6 +51,91 @@ const ERROR_ACCESS_DENIED: i32 = 5;
|
||||
/// is there; the client only has to wait for a free instance.
|
||||
const ERROR_PIPE_BUSY: i32 = 231;
|
||||
|
||||
/// RAII wrapper for Windows named pipe security attributes with permissive local ACL.
|
||||
///
|
||||
/// SDDL specification:
|
||||
/// - `D:(A;;GRGW;;;WD)`: DACL allows Generic Read and Generic Write to Everyone (`WD`).
|
||||
/// Full control (`GA`) remains reserved to Administrators and LocalSystem.
|
||||
/// - `(A;;GA;;;BA)`: DACL allows Generic All to Builtin Administrators (`BA`).
|
||||
/// - `(A;;GA;;;SY)`: DACL allows Generic All to LocalSystem (`SY`).
|
||||
/// - `S:(ML;;NW;;;ME)`: SACL Mandatory Label with Medium Integrity (`ME`) and No Write Up (`NW`).
|
||||
/// This allows unprivileged user-space processes (Medium Integrity) to write to the
|
||||
/// named pipe even when the agent runs as an elevated Administrator (High Integrity).
|
||||
struct PipeSecurityAttributes {
|
||||
attrs: windows_sys::Win32::Security::SECURITY_ATTRIBUTES,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PipeSecurityAttributes {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PipeSecurityAttributes")
|
||||
.field("nLength", &self.attrs.nLength)
|
||||
.field("lpSecurityDescriptor", &self.attrs.lpSecurityDescriptor)
|
||||
.field("bInheritHandle", &self.attrs.bInheritHandle)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PipeSecurityAttributes {
|
||||
fn new() -> Result<Self> {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
|
||||
const SDDL: &str = "D:(A;;GRGW;;;WD)(A;;GA;;;BA)(A;;GA;;;SY)S:(ML;;NW;;;ME)";
|
||||
let wide: Vec<u16> = OsStr::new(SDDL)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
let mut p_sd = std::ptr::null_mut();
|
||||
// SAFETY: We pass a valid null-terminated wide string, standard revision 1,
|
||||
// and a valid pointer to receive the descriptor.
|
||||
let success = unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
wide.as_ptr(),
|
||||
SDDL_REVISION_1,
|
||||
&mut p_sd,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
if success == 0 || p_sd.is_null() {
|
||||
let err = std::io::Error::last_os_error();
|
||||
return Err(Error::Storage(format!(
|
||||
"cannot create pipe security descriptor: {err}"
|
||||
)));
|
||||
}
|
||||
|
||||
let attrs = windows_sys::Win32::Security::SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<windows_sys::Win32::Security::SECURITY_ATTRIBUTES>()
|
||||
as u32,
|
||||
lpSecurityDescriptor: p_sd,
|
||||
bInheritHandle: 0,
|
||||
};
|
||||
|
||||
Ok(Self { attrs })
|
||||
}
|
||||
|
||||
fn as_raw_mut(&mut self) -> *mut std::ffi::c_void {
|
||||
&mut self.attrs as *mut _ as *mut std::ffi::c_void
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PipeSecurityAttributes {
|
||||
fn drop(&mut self) {
|
||||
if !self.attrs.lpSecurityDescriptor.is_null() {
|
||||
// SAFETY: The descriptor was allocated by ConvertStringSecurityDescriptorToSecurityDescriptorW
|
||||
// and must be freed with LocalFree.
|
||||
unsafe {
|
||||
windows_sys::Win32::Foundation::LocalFree(self.attrs.lpSecurityDescriptor);
|
||||
}
|
||||
self.attrs.lpSecurityDescriptor = std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves the local control interface on a named pipe.
|
||||
#[derive(Debug)]
|
||||
pub struct ControlSocket {
|
||||
@@ -63,15 +149,27 @@ impl ControlSocket {
|
||||
/// Claiming the first instance of the name is what detects a second agent:
|
||||
/// if the name already exists, the create is refused and that is reported
|
||||
/// as the state being locked, so two live agents never share one pipe.
|
||||
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
|
||||
///
|
||||
/// `_access` is accepted for parity with the Unix socket; granting a group
|
||||
/// here means a pipe security descriptor and is a separate follow-up, so for
|
||||
/// now the pipe keeps its default (creator plus the built-in access the
|
||||
/// `PipeSecurityAttributes` set).
|
||||
pub async fn bind(
|
||||
path: impl AsRef<Path>,
|
||||
source: Arc<dyn ReportSource>,
|
||||
_access: ControlSocketAccess,
|
||||
) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let name = pipe_name(&path);
|
||||
|
||||
let server = match ServerOptions::new()
|
||||
.first_pipe_instance(true)
|
||||
.reject_remote_clients(true)
|
||||
.create(&name)
|
||||
{
|
||||
let mut sec_attrs = PipeSecurityAttributes::new()?;
|
||||
// SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES.
|
||||
let server = match unsafe {
|
||||
ServerOptions::new()
|
||||
.first_pipe_instance(true)
|
||||
.reject_remote_clients(true)
|
||||
.create_with_security_attributes_raw(&name, sec_attrs.as_raw_mut())
|
||||
} {
|
||||
Ok(server) => server,
|
||||
Err(err) if err.raw_os_error() == Some(ERROR_ACCESS_DENIED) => {
|
||||
return Err(Error::StateLocked { path });
|
||||
@@ -112,7 +210,7 @@ impl Drop for ControlSocket {
|
||||
/// Accepts one client at a time, keeping a fresh instance ready for the next.
|
||||
///
|
||||
/// A named pipe server instance serves a single client, so a new instance is
|
||||
/// created as soon as one is taken — otherwise a second `tsunagi status` while
|
||||
/// created as soon as one is taken — otherwise a second `tsng status` while
|
||||
/// the first is mid-flight would find nothing listening.
|
||||
async fn serve(
|
||||
name: String,
|
||||
@@ -155,10 +253,19 @@ async fn serve(
|
||||
/// Creates the next pipe instance, or `None` if the name can no longer be
|
||||
/// served.
|
||||
fn next_instance(name: &str) -> Option<tokio::net::windows::named_pipe::NamedPipeServer> {
|
||||
match ServerOptions::new()
|
||||
.reject_remote_clients(true)
|
||||
.create(name)
|
||||
{
|
||||
let mut sec_attrs = match PipeSecurityAttributes::new() {
|
||||
Ok(attrs) => attrs,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "cannot build pipe security attributes for next instance");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES.
|
||||
match unsafe {
|
||||
ServerOptions::new()
|
||||
.reject_remote_clients(true)
|
||||
.create_with_security_attributes_raw(name, sec_attrs.as_raw_mut())
|
||||
} {
|
||||
Ok(server) => Some(server),
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "cannot create the next control pipe instance");
|
||||
@@ -187,7 +294,7 @@ pub(crate) async fn connect(path: &Path) -> Result<NamedPipeClient> {
|
||||
format!(
|
||||
"the agent's control pipe exists, but Windows denied access. \
|
||||
Run this command as the same Windows user and with the same \
|
||||
elevation as `tsunagi up` (use an administrator terminal if \
|
||||
elevation as `tsng up` (use an administrator terminal if \
|
||||
the agent is elevated). Windows error: {err}"
|
||||
),
|
||||
),
|
||||
@@ -51,6 +51,7 @@ pub mod storage;
|
||||
pub mod task;
|
||||
#[cfg(feature = "testing")]
|
||||
pub mod testing;
|
||||
pub mod vip;
|
||||
|
||||
pub use agent::{Agent, AgentStatus, Event, NetworkStatus, PeerStatus};
|
||||
pub use config::{AgentConfig, Limits, ReconnectPolicy, StoragePaths, TransportPolicy};
|
||||
@@ -22,6 +22,7 @@
|
||||
//! own reachability data.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use iroh::endpoint::{
|
||||
@@ -221,9 +222,18 @@ fn local_addr_string(addr: &iroh::endpoint::LocalTransportAddr) -> Option<String
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EndpointAdapter {
|
||||
endpoint: Endpoint,
|
||||
owned_interface: Arc<std::sync::RwLock<String>>,
|
||||
}
|
||||
|
||||
impl EndpointAdapter {
|
||||
/// Excludes the real name after interface creation (macOS assigns utunN).
|
||||
pub(crate) fn exclude_interface(&self, name: &str) {
|
||||
*self
|
||||
.owned_interface
|
||||
.write()
|
||||
.unwrap_or_else(|p| p.into_inner()) = name.to_lowercase();
|
||||
}
|
||||
|
||||
/// Binds an endpoint according to `config`, reusing the persistent device key.
|
||||
pub async fn bind(config: &AgentConfig, identity: &DeviceIdentity) -> Result<Self> {
|
||||
let mut builder = Endpoint::builder(presets::Minimal)
|
||||
@@ -240,6 +250,65 @@ impl EndpointAdapter {
|
||||
TransportPolicy::N0Defaults => builder.preset(presets::N0),
|
||||
};
|
||||
|
||||
let owned_interface =
|
||||
Arc::new(std::sync::RwLock::new(config.interface_name.to_lowercase()));
|
||||
{
|
||||
let interface_filter = Arc::clone(&owned_interface);
|
||||
let filter = iroh::address_lookup::AddrFilter::new(move |addrs| {
|
||||
let owned_interface = interface_filter
|
||||
.read()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.clone();
|
||||
let mut overlay_ips = std::collections::HashSet::new();
|
||||
for iface in netdev::get_interfaces() {
|
||||
let name = iface.name.to_lowercase();
|
||||
let friendly = iface
|
||||
.friendly_name
|
||||
.as_deref()
|
||||
.map(str::to_lowercase)
|
||||
.unwrap_or_default();
|
||||
let desc = iface
|
||||
.description
|
||||
.as_deref()
|
||||
.map(str::to_lowercase)
|
||||
.unwrap_or_default();
|
||||
if name == owned_interface
|
||||
|| friendly == owned_interface
|
||||
|| name.starts_with("tsun")
|
||||
|| name.contains("tsunagi")
|
||||
|| name.contains("wintun")
|
||||
|| friendly.starts_with("tsun")
|
||||
|| friendly.contains("tsunagi")
|
||||
|| friendly.contains("wintun")
|
||||
|| desc.contains("wintun")
|
||||
|| desc.contains("tsunagi")
|
||||
{
|
||||
for ip in iface.ipv4 {
|
||||
overlay_ips.insert(std::net::IpAddr::V4(ip.addr()));
|
||||
}
|
||||
for ip in iface.ipv6 {
|
||||
overlay_ips.insert(std::net::IpAddr::V6(ip.addr()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if overlay_ips.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(addrs);
|
||||
}
|
||||
let filtered: Vec<iroh::TransportAddr> = addrs
|
||||
.iter()
|
||||
.filter(|addr| match addr {
|
||||
iroh::TransportAddr::Ip(sock) => !overlay_ips.contains(&sock.ip()),
|
||||
_ => true,
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
std::borrow::Cow::Owned(filtered)
|
||||
});
|
||||
builder = builder
|
||||
.direct_addr_filter(filter.clone())
|
||||
.addr_filter(filter);
|
||||
}
|
||||
|
||||
#[cfg(feature = "testing")]
|
||||
if let Some(transport) = &config.test_quic_transport {
|
||||
builder = builder.transport_config(transport.clone());
|
||||
@@ -259,7 +328,95 @@ impl EndpointAdapter {
|
||||
.await
|
||||
.map_err(|err| Error::Endpoint(format!("cannot bind endpoint: {err}")))?;
|
||||
|
||||
Ok(Self { endpoint })
|
||||
tracing::debug!(
|
||||
endpoint = %endpoint.id().fmt_short(),
|
||||
bound_sockets = ?endpoint.bound_sockets(),
|
||||
"iroh endpoint bound locally"
|
||||
);
|
||||
|
||||
let ep_id = endpoint.id();
|
||||
let mut addr_watcher = endpoint.watch_addr();
|
||||
let mut relay_watcher = endpoint.home_relay_status();
|
||||
let close_token = endpoint.clone();
|
||||
tokio::spawn(async move {
|
||||
use iroh::Watcher;
|
||||
let mut last_addrs: Vec<SocketAddr> = Vec::new();
|
||||
let mut last_relays: Vec<String> = Vec::new();
|
||||
|
||||
let warn_timer = tokio::time::sleep(Duration::from_secs(12));
|
||||
tokio::pin!(warn_timer);
|
||||
let mut warned = false;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = close_token.closed() => {
|
||||
break;
|
||||
}
|
||||
() = &mut warn_timer, if !warned => {
|
||||
warned = true;
|
||||
if last_addrs.is_empty() && !last_relays.is_empty() {
|
||||
tracing::warn!(
|
||||
endpoint = %ep_id.fmt_short(),
|
||||
relays = ?last_relays,
|
||||
"No direct reflexive or UPnP addresses discovered after probe; communications will rely on relay servers"
|
||||
);
|
||||
} else if last_addrs.is_empty() && last_relays.is_empty() {
|
||||
tracing::warn!(
|
||||
endpoint = %ep_id.fmt_short(),
|
||||
"No reachability discovered (neither direct reflexive/UPnP addresses nor relay servers available)"
|
||||
);
|
||||
}
|
||||
}
|
||||
res = addr_watcher.updated() => {
|
||||
let addr = match res {
|
||||
Ok(a) => a,
|
||||
Err(_) => break,
|
||||
};
|
||||
let addrs: Vec<SocketAddr> = addr.ip_addrs().copied().collect();
|
||||
let relays: Vec<String> = addr.relay_urls().map(|u| u.to_string()).collect();
|
||||
|
||||
if addrs != last_addrs || relays != last_relays {
|
||||
tracing::debug!(
|
||||
endpoint = %ep_id.fmt_short(),
|
||||
direct_addrs = ?addrs,
|
||||
relays = ?relays,
|
||||
"Discovered endpoint reachability (STUN/UPnP/local/relay)"
|
||||
);
|
||||
last_addrs = addrs;
|
||||
last_relays = relays;
|
||||
}
|
||||
}
|
||||
res = relay_watcher.updated() => {
|
||||
let relays = match res {
|
||||
Ok(r) => r,
|
||||
Err(_) => break,
|
||||
};
|
||||
for relay in &relays {
|
||||
if relay.is_connected() {
|
||||
tracing::debug!(
|
||||
endpoint = %ep_id.fmt_short(),
|
||||
relay = %relay.url(),
|
||||
"Connected to home relay server"
|
||||
);
|
||||
} else if let Some(err) = relay.last_error() {
|
||||
tracing::debug!(
|
||||
endpoint = %ep_id.fmt_short(),
|
||||
relay = %relay.url(),
|
||||
error = %err,
|
||||
"Relay connection attempt failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
owned_interface,
|
||||
})
|
||||
}
|
||||
|
||||
/// The underlying iroh endpoint.
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Scoped LAN discovery fanout at IP ingress. A domain is one logical network;
|
||||
//! routed copies use ordinary encrypted peer links and are never reflooded.
|
||||
//! Future subnet exporters must supply explicitly authorized ingress domains
|
||||
//! here, not teach the encrypted transit router to inspect application bytes.
|
||||
|
||||
use super::router::{NetworkRoutes, Route};
|
||||
use crate::NetworkId;
|
||||
use iroh::EndpointId;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Local participation and the currently authenticated participants in a domain.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BroadcastPolicy {
|
||||
/// Whether this agent originates and accepts LAN broadcasts in this network.
|
||||
pub enabled: bool,
|
||||
/// Live members explicitly advertising that they accept broadcasts.
|
||||
pub peers: HashSet<EndpointId>,
|
||||
}
|
||||
impl Default for BroadcastPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
peers: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Domain {
|
||||
network: NetworkId,
|
||||
directed: Ipv4Addr,
|
||||
enabled: bool,
|
||||
recipients: Arc<[Route]>,
|
||||
members: HashSet<EndpointId>,
|
||||
}
|
||||
|
||||
/// Immutable ingress-domain index rebuilt with address/policy changes.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct BroadcastTable {
|
||||
sources: HashMap<Ipv4Addr, Arc<Domain>>,
|
||||
networks: HashMap<NetworkId, Arc<Domain>>,
|
||||
destinations: HashSet<Ipv4Addr>,
|
||||
}
|
||||
impl BroadcastTable {
|
||||
pub fn build(networks: &HashMap<NetworkId, NetworkRoutes>) -> Self {
|
||||
let mut table = Self::default();
|
||||
for (&network, routes) in networks {
|
||||
let (Some(range), Some(local)) = (routes.range, routes.local) else {
|
||||
continue;
|
||||
};
|
||||
if range.prefix_len > 30 {
|
||||
continue;
|
||||
}
|
||||
let directed = Ipv4Addr::from(u32::from(range.base) | (u32::MAX >> range.prefix_len));
|
||||
let mut recipients: Vec<_> = routes
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|(_, peer)| routes.broadcast.peers.contains(peer))
|
||||
.map(|(_, peer)| Route {
|
||||
network,
|
||||
peer: *peer,
|
||||
})
|
||||
.collect();
|
||||
recipients.sort_by_key(|route| route.peer);
|
||||
recipients.dedup();
|
||||
let domain = Arc::new(Domain {
|
||||
network,
|
||||
directed,
|
||||
enabled: routes.broadcast.enabled,
|
||||
members: recipients.iter().map(|route| route.peer).collect(),
|
||||
recipients: recipients.into(),
|
||||
});
|
||||
table.sources.insert(local, domain.clone());
|
||||
table.networks.insert(network, domain);
|
||||
table.destinations.insert(directed);
|
||||
}
|
||||
table
|
||||
}
|
||||
pub fn is_destination(&self, destination: Ipv4Addr) -> bool {
|
||||
destination.is_broadcast() || self.destinations.contains(&destination)
|
||||
}
|
||||
pub fn outgoing(&self, source: Ipv4Addr, destination: Ipv4Addr) -> Option<Arc<[Route]>> {
|
||||
let domain = self.sources.get(&source)?;
|
||||
(domain.enabled && (destination.is_broadcast() || destination == domain.directed))
|
||||
.then(|| domain.recipients.clone())
|
||||
}
|
||||
pub fn accepts(&self, network: NetworkId, peer: EndpointId, destination: Ipv4Addr) -> bool {
|
||||
self.networks.get(&network).is_some_and(|domain| {
|
||||
domain.network == network
|
||||
&& domain.enabled
|
||||
&& domain.members.contains(&peer)
|
||||
&& (destination.is_broadcast() || destination == domain.directed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates IPv4/UDP lengths before broadcast fanout. No game-specific ports,
|
||||
/// payload rewriting or checksum changes. IP fragments retain their bytes and
|
||||
/// are reassembled by the destination OS; even later fragments have protocol 17.
|
||||
pub(crate) fn valid_udp(packet: &[u8]) -> bool {
|
||||
if packet.len() < 20 || packet[0] >> 4 != 4 || packet[9] != 17 {
|
||||
return false;
|
||||
}
|
||||
let header = usize::from(packet[0] & 15) * 4;
|
||||
let total = usize::from(u16::from_be_bytes([packet[2], packet[3]]));
|
||||
if header < 20 || total <= header || total > packet.len() {
|
||||
return false;
|
||||
}
|
||||
let fragment = u16::from_be_bytes([packet[6], packet[7]]);
|
||||
if fragment & 0x1fff != 0 {
|
||||
return true;
|
||||
}
|
||||
if total < header + 8 {
|
||||
return false;
|
||||
}
|
||||
let udp = usize::from(u16::from_be_bytes([packet[header + 4], packet[header + 5]]));
|
||||
udp >= 8
|
||||
&& if fragment & 0x2000 != 0 {
|
||||
udp >= total - header
|
||||
} else {
|
||||
udp == total - header
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
fn id(name: &str) -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new(name).unwrap(),
|
||||
&NetworkSecret::from_bytes([4; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
#[test]
|
||||
fn limited_and_directed_broadcasts_never_cross_domains_or_target_disabled_peers() {
|
||||
let peer = iroh::SecretKey::from_bytes(&[8; 32]).public();
|
||||
let a = NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
range: Some("10.0.0.0/24".parse().unwrap()),
|
||||
local: Some("10.0.0.1".parse().unwrap()),
|
||||
peers: vec![("10.0.0.2".parse().unwrap(), peer)],
|
||||
broadcast: BroadcastPolicy {
|
||||
enabled: true,
|
||||
peers: HashSet::from([peer]),
|
||||
},
|
||||
};
|
||||
let b = NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
range: Some("10.1.0.0/24".parse().unwrap()),
|
||||
local: Some("10.1.0.1".parse().unwrap()),
|
||||
peers: vec![("10.1.0.2".parse().unwrap(), peer)],
|
||||
broadcast: BroadcastPolicy::default(),
|
||||
};
|
||||
let table = BroadcastTable::build(&HashMap::from([(id("a"), a.clone()), (id("b"), b)]));
|
||||
let routes = table
|
||||
.outgoing(a.local.unwrap(), Ipv4Addr::BROADCAST)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&*routes,
|
||||
&[Route {
|
||||
network: id("a"),
|
||||
peer
|
||||
}]
|
||||
);
|
||||
assert!(
|
||||
table
|
||||
.outgoing(a.local.unwrap(), "10.1.0.255".parse().unwrap())
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
table
|
||||
.outgoing("192.168.1.2".parse().unwrap(), Ipv4Addr::BROADCAST)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
table
|
||||
.outgoing("10.1.0.1".parse().unwrap(), Ipv4Addr::BROADCAST)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(!table.accepts(id("a"), peer, "10.1.0.255".parse().unwrap()));
|
||||
assert!(!table.accepts(id("b"), peer, Ipv4Addr::BROADCAST));
|
||||
assert!(table.accepts(id("a"), peer, Ipv4Addr::BROADCAST));
|
||||
let disabled = NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: BroadcastPolicy {
|
||||
enabled: false,
|
||||
..a.broadcast.clone()
|
||||
},
|
||||
..a.clone()
|
||||
};
|
||||
let table = BroadcastTable::build(&HashMap::from([(id("a"), disabled)]));
|
||||
assert!(
|
||||
table
|
||||
.outgoing(a.local.unwrap(), Ipv4Addr::BROADCAST)
|
||||
.is_none()
|
||||
);
|
||||
assert!(!table.accepts(id("a"), peer, Ipv4Addr::BROADCAST));
|
||||
}
|
||||
#[test]
|
||||
fn malformed_udp_is_not_amplified_but_ipv4_fragments_are_supported() {
|
||||
let mut packet = vec![0u8; 36];
|
||||
packet[0] = 0x45;
|
||||
packet[3] = 36;
|
||||
packet[9] = 17;
|
||||
packet[25] = 16;
|
||||
assert!(valid_udp(&packet));
|
||||
for length in 0..36 {
|
||||
assert!(!valid_udp(&packet[..length]));
|
||||
}
|
||||
packet[9] = 6;
|
||||
assert!(!valid_udp(&packet));
|
||||
packet[9] = 17;
|
||||
packet[0] = 0x44;
|
||||
assert!(!valid_udp(&packet));
|
||||
packet[0] = 0x45;
|
||||
packet[25] = 8;
|
||||
assert!(!valid_udp(&packet));
|
||||
packet[6] = 0x20;
|
||||
packet[25] = 64;
|
||||
assert!(valid_udp(&packet));
|
||||
packet[6] = 0;
|
||||
packet[7] = 2;
|
||||
assert!(valid_udp(&packet));
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -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,268 @@
|
||||
//! 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,
|
||||
/// The range of every network on the interface. Only the using side
|
||||
/// reads it, to keep this agent's own traffic *to* the overlay inside it
|
||||
/// where the host has no per-user routing (macOS).
|
||||
pub overlay: Vec<Ipv4Range>,
|
||||
/// Underlay addresses this agent talks to directly and must keep reaching
|
||||
/// outside the overlay's default route. Only filled while `client` is set
|
||||
/// and only for hosts that report [`ExitHostRules::needs_bypass`].
|
||||
pub bypass: Vec<std::net::Ipv4Addr>,
|
||||
/// Host names (relay servers) that must stay reachable the same way; the
|
||||
/// host rules resolve them.
|
||||
pub bypass_hosts: Vec<String>,
|
||||
}
|
||||
|
||||
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, ()>;
|
||||
|
||||
/// Refreshes volatile local socket protection without replacing exit routes.
|
||||
fn refresh<'a>(&'a self, _plan: &'a ExitHostPlan) -> BoxFuture<'a, Option<RuleOutcome>> {
|
||||
Box::pin(async { None })
|
||||
}
|
||||
|
||||
/// Whether using an exit node on this host needs the underlay addresses
|
||||
/// the agent talks to, because the host cannot exempt the agent's own
|
||||
/// traffic from the default route by user id (Windows). The default is no.
|
||||
fn needs_bypass(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>>>,
|
||||
needs_bypass: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// Whether this host asks for the underlay addresses, like Windows.
|
||||
pub fn set_needs_bypass(&self, needs: bool) {
|
||||
*lock(&self.needs_bypass) = needs;
|
||||
}
|
||||
|
||||
/// 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 needs_bypass(&self) -> bool {
|
||||
*lock(&self.needs_bypass)
|
||||
}
|
||||
|
||||
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,
|
||||
overlay: vec![range()],
|
||||
..Default::default()
|
||||
};
|
||||
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,
|
||||
overlay: Vec::new(),
|
||||
..Default::default()
|
||||
})
|
||||
.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"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
//! Broadcast host rules on Linux: a netlink route and an `iptables` rule.
|
||||
//!
|
||||
//! The route is added by the netlink thread the interface provisioner already
|
||||
//! owns (see [`super::super::provision`]), because that thread is the only one
|
||||
//! that ever holds `CAP_NET_ADMIN`. It belongs to the interface and goes away
|
||||
//! with it.
|
||||
//!
|
||||
//! The firewall allowance is a rule in the `INPUT` chain, inserted at the top
|
||||
//! so a default-deny chain further down cannot drop discovery replies first:
|
||||
//!
|
||||
//! ```text
|
||||
//! iptables -I INPUT 1 -i <if> -p udp -s <range> -m comment --comment tsunagi:<if> -j ACCEPT
|
||||
//! ```
|
||||
//!
|
||||
//! `iptables` is a program, not a netlink call, so it is looked up in fixed
|
||||
//! system directories rather than on `PATH`, and its arguments are built from
|
||||
//! values this agent computed. It works with both the legacy backend and
|
||||
//! `iptables-nft`. A host with only `nft` or `firewalld` has no `iptables`;
|
||||
//! that is reported on the firewall half and the route is still installed.
|
||||
//!
|
||||
//! The rule carries a comment tag. That is how a rule left behind by a crashed
|
||||
//! run is found and replaced, and why nothing else in the chain is ever
|
||||
//! deleted.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::overlay::provision::RouteHandle;
|
||||
|
||||
use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome, tag_for};
|
||||
|
||||
const CHAIN: &str = "INPUT";
|
||||
|
||||
/// Fixed places `iptables` is installed. Never `PATH`: the agent is
|
||||
/// privileged and a directory a user controls must not decide what it runs.
|
||||
const IPTABLES_DIRECTORIES: [&str; 6] = [
|
||||
"/usr/sbin/iptables",
|
||||
"/sbin/iptables",
|
||||
"/usr/bin/iptables",
|
||||
"/bin/iptables",
|
||||
"/usr/local/sbin/iptables",
|
||||
"/run/current-system/sw/bin/iptables",
|
||||
];
|
||||
|
||||
/// The first candidate that exists.
|
||||
fn pick_iptables(exists: impl Fn(&str) -> bool) -> Option<&'static str> {
|
||||
IPTABLES_DIRECTORIES
|
||||
.into_iter()
|
||||
.find(|candidate| exists(candidate))
|
||||
}
|
||||
|
||||
/// What running `iptables` produced.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) enum Ran {
|
||||
/// There is no `iptables` to run.
|
||||
Missing,
|
||||
/// It ran, and exited like this.
|
||||
Exited {
|
||||
success: bool,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Runs `iptables` with these arguments. A function value so the flow below
|
||||
/// can be tested without touching the host.
|
||||
pub(super) type Run<'a> = &'a dyn Fn(&[String]) -> Ran;
|
||||
|
||||
/// The match and target of the rule, without the chain or position.
|
||||
///
|
||||
/// `comment` false leaves the tag off, for a kernel that has no `comment`
|
||||
/// match; the rule is then recognised by its shape, see [`tagged_deletions`].
|
||||
fn rule_spec(plan: &BroadcastRulesPlan, comment: bool) -> Vec<String> {
|
||||
let range = plan.range.to_string();
|
||||
let tag = plan.tag();
|
||||
let mut words = vec!["-i", plan.interface.as_str(), "-p", "udp", "-s", &range];
|
||||
if comment {
|
||||
words.extend(["-m", "comment", "--comment", &tag]);
|
||||
}
|
||||
words.extend(["-j", "ACCEPT"]);
|
||||
words.into_iter().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// Whether the kernel has the `comment` match, which a rule needs to carry
|
||||
/// its tag. Asked of iptables itself: checking a rule that does not exist
|
||||
/// fails either way, but only a missing match says so.
|
||||
pub(super) fn comment_supported(run: Run<'_>) -> bool {
|
||||
let probe = locked(
|
||||
[
|
||||
"-t",
|
||||
"filter",
|
||||
"-C",
|
||||
"FORWARD",
|
||||
"-m",
|
||||
"comment",
|
||||
"--comment",
|
||||
"tsunagi-probe",
|
||||
"-j",
|
||||
"ACCEPT",
|
||||
]
|
||||
.map(str::to_string),
|
||||
);
|
||||
match run(&probe) {
|
||||
Ran::Exited {
|
||||
success: false,
|
||||
stderr,
|
||||
..
|
||||
} => !stderr.contains("No chain/target/match by that name"),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// `iptables -S` words as option clauses: an option with its value, a negated
|
||||
/// one with its `!`. It prints the options in its own order, not the one a
|
||||
/// rule was written in, so rules are compared by their clauses, sorted.
|
||||
fn clauses(words: &[String]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0;
|
||||
while at < words.len() {
|
||||
let take = if words[at] == "!" { 3 } else { 2 };
|
||||
let end = (at + take).min(words.len());
|
||||
out.push(words[at..end].join(" "));
|
||||
at = end;
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether two rules say the same thing, whatever order they list it in.
|
||||
pub(super) fn same_rule(a: &[String], b: &[String]) -> bool {
|
||||
clauses(a) == clauses(b)
|
||||
}
|
||||
|
||||
/// Waits briefly for the xtables lock instead of failing when another tool
|
||||
/// holds it.
|
||||
pub(super) fn locked(args: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
["-w", "5"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.chain(args)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn check_args(plan: &BroadcastRulesPlan, comment: bool) -> Vec<String> {
|
||||
locked(
|
||||
["-C".to_string(), CHAIN.to_string()]
|
||||
.into_iter()
|
||||
.chain(rule_spec(plan, comment)),
|
||||
)
|
||||
}
|
||||
|
||||
fn insert_args(plan: &BroadcastRulesPlan, comment: bool) -> Vec<String> {
|
||||
locked(
|
||||
["-I".to_string(), CHAIN.to_string(), "1".to_string()]
|
||||
.into_iter()
|
||||
.chain(rule_spec(plan, comment)),
|
||||
)
|
||||
}
|
||||
|
||||
fn list_args() -> Vec<String> {
|
||||
locked(["-S".to_string(), CHAIN.to_string()])
|
||||
}
|
||||
|
||||
/// Splits one `iptables -S` line into words, honouring double quotes, which
|
||||
/// `iptables` puts around comments that need them.
|
||||
pub(super) fn split_rule_line(line: &str) -> Vec<String> {
|
||||
let mut words = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut quoted = false;
|
||||
let mut started = false;
|
||||
for c in line.chars() {
|
||||
match c {
|
||||
'"' => {
|
||||
quoted = !quoted;
|
||||
started = true;
|
||||
}
|
||||
c if c.is_whitespace() && !quoted => {
|
||||
if started {
|
||||
words.push(std::mem::take(&mut current));
|
||||
started = false;
|
||||
}
|
||||
}
|
||||
c => {
|
||||
current.push(c);
|
||||
started = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if started {
|
||||
words.push(current);
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
/// Whether a rule is the one this module writes for some range of the
|
||||
/// interface the tag belongs to, with no comment on it.
|
||||
fn is_uncommented_rule(tag: &str, rest: &[String]) -> bool {
|
||||
let Some(interface) = tag.strip_prefix("tsunagi:") else {
|
||||
return false;
|
||||
};
|
||||
let Some(range) = rest
|
||||
.windows(2)
|
||||
.find(|pair| pair[0] == "-s")
|
||||
.map(|pair| pair[1].as_str())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let ours: Vec<String> = ["-i", interface, "-p", "udp", "-s", range, "-j", "ACCEPT"]
|
||||
.map(str::to_string)
|
||||
.to_vec();
|
||||
same_rule(&ours, rest)
|
||||
}
|
||||
|
||||
/// The delete commands for every rule in an `iptables -S INPUT` listing that
|
||||
/// carries exactly this tag, or, with no `comment` match in the kernel, is
|
||||
/// exactly the rule written for some range. Anything else is left alone.
|
||||
fn tagged_deletions(listing: &str, tag: &str) -> Vec<Vec<String>> {
|
||||
listing
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let words = split_rule_line(line);
|
||||
let [append, chain, rest @ ..] = words.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
if append != "-A" || chain != CHAIN {
|
||||
return None;
|
||||
}
|
||||
let tagged = rest
|
||||
.windows(2)
|
||||
.any(|pair| pair[0] == "--comment" && pair[1] == tag)
|
||||
|| is_uncommented_rule(tag, rest);
|
||||
tagged.then(|| {
|
||||
locked(
|
||||
["-D".to_string(), CHAIN.to_string()]
|
||||
.into_iter()
|
||||
.chain(rest.iter().cloned()),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A failure message with the likely fix for the likely cause.
|
||||
pub(super) fn failure(stderr: &str) -> String {
|
||||
let text = stderr.trim();
|
||||
let lower = text.to_ascii_lowercase();
|
||||
if lower.contains("permission denied") || lower.contains("must be root") {
|
||||
format!("iptables needs root: {text}")
|
||||
} else if text.is_empty() {
|
||||
"iptables failed without saying why".to_string()
|
||||
} else {
|
||||
format!("iptables failed: {text}")
|
||||
}
|
||||
}
|
||||
|
||||
fn missing(plan: &BroadcastRulesPlan) -> String {
|
||||
format!(
|
||||
"iptables not found; install it (the iptables-nft package is enough) or allow inbound \
|
||||
UDP from {} on `{}` in your firewall",
|
||||
plan.range, plan.interface
|
||||
)
|
||||
}
|
||||
|
||||
/// Makes the firewall hold exactly one rule for this interface, the planned one.
|
||||
fn apply_firewall(run: Run<'_>, plan: &BroadcastRulesPlan) -> RuleOutcome {
|
||||
let comment = comment_supported(run);
|
||||
let present = match run(&check_args(plan, comment)) {
|
||||
Ran::Missing => return RuleOutcome::Failed(missing(plan)),
|
||||
Ran::Exited { success, .. } => success,
|
||||
};
|
||||
let listing = match run(&list_args()) {
|
||||
Ran::Missing => return RuleOutcome::Failed(missing(plan)),
|
||||
Ran::Exited {
|
||||
success: true,
|
||||
stdout,
|
||||
..
|
||||
} => stdout,
|
||||
Ran::Exited { stderr, .. } => return RuleOutcome::Failed(failure(&stderr)),
|
||||
};
|
||||
let stale = tagged_deletions(&listing, &plan.tag());
|
||||
// `-C` matched an identical rule and nothing else carries our tag, so
|
||||
// there is nothing to do. Otherwise start from a clean slate for this
|
||||
// interface: a rule from an earlier run may name another range.
|
||||
if present && stale.len() == 1 {
|
||||
return RuleOutcome::Applied;
|
||||
}
|
||||
for delete in &stale {
|
||||
if let Ran::Exited {
|
||||
success: false,
|
||||
stderr,
|
||||
..
|
||||
} = run(delete)
|
||||
{
|
||||
return RuleOutcome::Failed(failure(&stderr));
|
||||
}
|
||||
}
|
||||
match run(&insert_args(plan, comment)) {
|
||||
Ran::Missing => RuleOutcome::Failed(missing(plan)),
|
||||
Ran::Exited { success: true, .. } => RuleOutcome::Applied,
|
||||
Ran::Exited { stderr, .. } => RuleOutcome::Failed(failure(&stderr)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes every rule tagged for this interface. Best effort.
|
||||
fn clear_firewall(run: Run<'_>, interface: &str) {
|
||||
let Ran::Exited {
|
||||
success: true,
|
||||
stdout,
|
||||
..
|
||||
} = run(&list_args())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for delete in tagged_deletions(&stdout, &tag_for(interface)) {
|
||||
if let Ran::Exited {
|
||||
success: false,
|
||||
stderr,
|
||||
..
|
||||
} = run(&delete)
|
||||
{
|
||||
tracing::warn!(
|
||||
interface,
|
||||
"cannot remove the broadcast firewall rule: {}",
|
||||
failure(&stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the real `iptables`.
|
||||
pub(super) fn run_iptables(args: &[String]) -> Ran {
|
||||
run_firewall(false, args)
|
||||
}
|
||||
|
||||
pub(super) fn run_firewall(ipv6: bool, args: &[String]) -> Ran {
|
||||
let v6_paths: Vec<_> = IPTABLES_DIRECTORIES
|
||||
.iter()
|
||||
.map(|p| p.replace("iptables", "ip6tables"))
|
||||
.collect();
|
||||
let v6_program = v6_paths
|
||||
.iter()
|
||||
.find(|path| std::path::Path::new(path).is_file());
|
||||
let program = if ipv6 {
|
||||
v6_program.map(String::as_str)
|
||||
} else {
|
||||
pick_iptables(|path| std::path::Path::new(path).is_file())
|
||||
};
|
||||
let Some(program) = program else {
|
||||
return Ran::Missing;
|
||||
};
|
||||
match std::process::Command::new(program).args(args).output() {
|
||||
Ok(output) => Ran::Exited {
|
||||
success: output.status.success(),
|
||||
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
},
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ran::Missing,
|
||||
Err(err) => Ran::Exited {
|
||||
success: false,
|
||||
stdout: String::new(),
|
||||
stderr: err.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The Linux implementation: netlink for the route, `iptables` for the rule.
|
||||
#[derive(Debug)]
|
||||
pub struct LinuxHostRules {
|
||||
pub(super) route: RouteHandle,
|
||||
/// Interface, sorted socket snapshot and whether exit routes committed.
|
||||
pub(super) underlay_sockets:
|
||||
std::sync::Mutex<Option<(String, Vec<super::linux_exit_mark::Listener>, bool)>>,
|
||||
}
|
||||
|
||||
impl LinuxHostRules {
|
||||
pub(crate) fn new(route: RouteHandle) -> Self {
|
||||
Self {
|
||||
route,
|
||||
underlay_sockets: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_route(&self, interface: String, source: Ipv4Addr) -> RuleOutcome {
|
||||
let route = self.route.clone();
|
||||
match tokio::task::spawn_blocking(move || route.set(&interface, source)).await {
|
||||
Ok(Ok(())) => RuleOutcome::Applied,
|
||||
Ok(Err(err)) => RuleOutcome::Failed(err.to_string()),
|
||||
Err(err) => RuleOutcome::Failed(format!("the route task failed: {err}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BroadcastHostRules for LinuxHostRules {
|
||||
fn name(&self) -> &str {
|
||||
"netlink+iptables"
|
||||
}
|
||||
|
||||
fn exit_rules(&self) -> Option<&dyn super::ExitHostRules> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
|
||||
Box::pin(async move {
|
||||
let route = self.set_route(plan.interface.clone(), plan.source).await;
|
||||
let owned = plan.clone();
|
||||
let firewall =
|
||||
tokio::task::spawn_blocking(move || apply_firewall(&run_iptables, &owned))
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
RuleOutcome::Failed(format!("the firewall task failed: {err}"))
|
||||
});
|
||||
BroadcastRulesReport {
|
||||
plan: plan.clone(),
|
||||
route,
|
||||
firewall,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
let route = self.route.clone();
|
||||
let name = interface.to_string();
|
||||
let route_name = name.clone();
|
||||
if let Ok(Err(err)) =
|
||||
tokio::task::spawn_blocking(move || route.clear(&route_name)).await
|
||||
{
|
||||
tracing::warn!(interface = %name, %err, "cannot remove the broadcast route");
|
||||
}
|
||||
let name = interface.to_string();
|
||||
let _ = tokio::task::spawn_blocking(move || clear_firewall(&run_iptables, &name)).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
fn plan() -> BroadcastRulesPlan {
|
||||
BroadcastRulesPlan {
|
||||
interface: "tsun0".into(),
|
||||
source: Ipv4Addr::new(10, 13, 37, 142),
|
||||
range: "10.13.37.0/24".parse().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn words(args: &[String]) -> String {
|
||||
args.join(" ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rule_is_udp_only_from_the_overlay_range_on_this_interface() {
|
||||
let spec = words(&rule_spec(&plan(), true));
|
||||
assert_eq!(
|
||||
spec,
|
||||
"-i tsun0 -p udp -s 10.13.37.0/24 -m comment --comment tsunagi:tsun0 -j ACCEPT"
|
||||
);
|
||||
// Nothing here may widen to other protocols or every source.
|
||||
assert!(spec.contains("-p udp"));
|
||||
assert!(!spec.contains("0.0.0.0/0"));
|
||||
assert!(words(&insert_args(&plan(), true)).starts_with("-w 5 -I INPUT 1 -i tsun0"));
|
||||
assert!(words(&check_args(&plan(), true)).starts_with("-w 5 -C INPUT -i tsun0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iptables_is_found_in_fixed_directories_never_on_path() {
|
||||
assert_eq!(pick_iptables(|_| false), None);
|
||||
assert_eq!(
|
||||
pick_iptables(|p| p == "/sbin/iptables"),
|
||||
Some("/sbin/iptables")
|
||||
);
|
||||
assert_eq!(pick_iptables(|_| true), Some("/usr/sbin/iptables"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_rules_with_our_exact_tag_are_selected_for_deletion() {
|
||||
let listing = "\
|
||||
-P INPUT DROP
|
||||
-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -m comment --comment \"tsunagi:tsun0\" -j ACCEPT
|
||||
-A INPUT -i tsun0 -m comment --comment tsunagi:tsun0 -j ACCEPT
|
||||
-A INPUT -i tsun1 -p udp -m comment --comment tsunagi:tsun1 -j ACCEPT
|
||||
-A INPUT -p tcp --dport 22 -m comment --comment \"allow ssh tsunagi:tsun0\" -j ACCEPT
|
||||
-A FORWARD -i tsun0 -m comment --comment tsunagi:tsun0 -j ACCEPT
|
||||
-A INPUT -p tcp --dport 22 -j ACCEPT
|
||||
";
|
||||
let deletions = tagged_deletions(listing, "tsunagi:tsun0");
|
||||
assert_eq!(deletions.len(), 2, "{deletions:?}");
|
||||
assert_eq!(
|
||||
words(&deletions[0]),
|
||||
"-w 5 -D INPUT -s 10.13.37.0/24 -i tsun0 -p udp -m comment --comment tsunagi:tsun0 -j ACCEPT"
|
||||
);
|
||||
assert!(words(&deletions[1]).contains("-D INPUT -i tsun0 -m comment"));
|
||||
assert!(tagged_deletions("", "tsunagi:tsun0").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kernel_without_the_comment_match_says_so_in_its_error() {
|
||||
let answer = |stderr: &'static str| {
|
||||
move |_: &[String]| Ran::Exited {
|
||||
success: false,
|
||||
stdout: String::new(),
|
||||
stderr: stderr.to_string(),
|
||||
}
|
||||
};
|
||||
assert!(!comment_supported(&answer(
|
||||
"iptables: No chain/target/match by that name."
|
||||
)));
|
||||
assert!(comment_supported(&answer(
|
||||
"iptables: Bad rule (does a matching rule exist in that chain?)."
|
||||
)));
|
||||
assert!(comment_supported(&|_: &[String]| Ran::Missing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_written_without_a_comment_is_found_by_its_shape() {
|
||||
// Listed the way iptables prints it: its own option order.
|
||||
let listing = "\
|
||||
-P INPUT ACCEPT
|
||||
-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -j ACCEPT
|
||||
-A INPUT -s 10.99.0.0/16 -i tsun0 -p udp -j ACCEPT
|
||||
-A INPUT -s 10.13.37.0/24 -i tsun1 -p udp -j ACCEPT
|
||||
-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -j DROP
|
||||
-A INPUT -s 10.13.37.0/24 -i tsun0 -p tcp -j ACCEPT
|
||||
";
|
||||
let deletions = tagged_deletions(listing, "tsunagi:tsun0");
|
||||
assert_eq!(deletions.len(), 2, "{deletions:?}");
|
||||
assert!(words(&deletions[0]).ends_with("-s 10.13.37.0/24 -i tsun0 -p udp -j ACCEPT"));
|
||||
assert!(words(&deletions[1]).contains("10.99.0.0/16"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_comment_the_rule_has_none() {
|
||||
let with = words(&rule_spec(&plan(), true));
|
||||
let without = words(&rule_spec(&plan(), false));
|
||||
assert!(with.contains("-m comment --comment tsunagi:tsun0"));
|
||||
assert_eq!(without, "-i tsun0 -p udp -s 10.13.37.0/24 -j ACCEPT");
|
||||
}
|
||||
|
||||
/// A scripted `iptables`: answers by the leading verb, records every call.
|
||||
struct Fake {
|
||||
calls: RefCell<Vec<String>>,
|
||||
check_ok: bool,
|
||||
listing: String,
|
||||
insert_stderr: Option<&'static str>,
|
||||
missing: bool,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn new(check_ok: bool, listing: &str) -> Self {
|
||||
Self {
|
||||
calls: RefCell::new(Vec::new()),
|
||||
check_ok,
|
||||
listing: listing.to_string(),
|
||||
insert_stderr: None,
|
||||
missing: false,
|
||||
}
|
||||
}
|
||||
fn run(&self, args: &[String]) -> Ran {
|
||||
// The probe for the `comment` match: not part of what is tested.
|
||||
if args.iter().any(|word| word == "tsunagi-probe") {
|
||||
return Ran::Exited {
|
||||
success: false,
|
||||
stdout: String::new(),
|
||||
stderr: "Bad rule".into(),
|
||||
};
|
||||
}
|
||||
self.calls.borrow_mut().push(words(args));
|
||||
if self.missing {
|
||||
return Ran::Missing;
|
||||
}
|
||||
let ok = |stdout: &str| Ran::Exited {
|
||||
success: true,
|
||||
stdout: stdout.to_string(),
|
||||
stderr: String::new(),
|
||||
};
|
||||
match args.get(2).map(String::as_str) {
|
||||
Some("-C") if self.check_ok => ok(""),
|
||||
Some("-C") => Ran::Exited {
|
||||
success: false,
|
||||
stdout: String::new(),
|
||||
stderr: "Bad rule".into(),
|
||||
},
|
||||
Some("-S") => ok(&self.listing),
|
||||
Some("-I") => match self.insert_stderr {
|
||||
Some(stderr) => Ran::Exited {
|
||||
success: false,
|
||||
stdout: String::new(),
|
||||
stderr: stderr.into(),
|
||||
},
|
||||
None => ok(""),
|
||||
},
|
||||
_ => ok(""),
|
||||
}
|
||||
}
|
||||
fn verbs(&self) -> Vec<String> {
|
||||
self.calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.map(|call| call.split(' ').nth(2).unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
const ONE_OURS: &str =
|
||||
"-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -m comment --comment tsunagi:tsun0 -j ACCEPT\n";
|
||||
|
||||
#[test]
|
||||
fn an_identical_rule_already_in_place_is_left_alone() {
|
||||
let fake = Fake::new(true, ONE_OURS);
|
||||
assert_eq!(
|
||||
apply_firewall(&|a| fake.run(a), &plan()),
|
||||
RuleOutcome::Applied
|
||||
);
|
||||
assert_eq!(fake.verbs(), ["-C", "-S"], "no insert, no delete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_rule_is_inserted_once() {
|
||||
let fake = Fake::new(false, "-P INPUT ACCEPT\n");
|
||||
assert_eq!(
|
||||
apply_firewall(&|a| fake.run(a), &plan()),
|
||||
RuleOutcome::Applied
|
||||
);
|
||||
assert_eq!(fake.verbs(), ["-C", "-S", "-I"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_rule_for_another_range_is_replaced_not_stacked() {
|
||||
// A crashed run left a rule for an old range under our tag.
|
||||
let stale = "-A INPUT -s 10.99.0.0/16 -i tsun0 -p udp -m comment --comment tsunagi:tsun0 -j ACCEPT\n";
|
||||
let fake = Fake::new(false, stale);
|
||||
assert_eq!(
|
||||
apply_firewall(&|a| fake.run(a), &plan()),
|
||||
RuleOutcome::Applied
|
||||
);
|
||||
assert_eq!(fake.verbs(), ["-C", "-S", "-D", "-I"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_of_the_rule_collapse_to_one() {
|
||||
let doubled = format!("{ONE_OURS}{ONE_OURS}");
|
||||
let fake = Fake::new(true, &doubled);
|
||||
assert_eq!(
|
||||
apply_firewall(&|a| fake.run(a), &plan()),
|
||||
RuleOutcome::Applied
|
||||
);
|
||||
assert_eq!(fake.verbs(), ["-C", "-S", "-D", "-D", "-I"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_iptables_says_what_to_install_and_what_to_allow() {
|
||||
let mut fake = Fake::new(false, "");
|
||||
fake.missing = true;
|
||||
let RuleOutcome::Failed(reason) = apply_firewall(&|a| fake.run(a), &plan()) else {
|
||||
panic!("expected a failure");
|
||||
};
|
||||
assert!(reason.contains("iptables not found"), "{reason}");
|
||||
assert!(reason.contains("10.13.37.0/24"), "{reason}");
|
||||
assert!(reason.contains("tsun0"), "{reason}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_insert_is_reported_with_the_cause() {
|
||||
let mut fake = Fake::new(false, "");
|
||||
fake.insert_stderr = Some(
|
||||
"iptables v1.8.10: can't initialize iptables table `filter': Permission denied (you must be root)",
|
||||
);
|
||||
let RuleOutcome::Failed(reason) = apply_firewall(&|a| fake.run(a), &plan()) else {
|
||||
panic!("expected a failure");
|
||||
};
|
||||
assert!(reason.starts_with("iptables needs root"), "{reason}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_removes_only_tagged_rules_and_ignores_a_missing_iptables() {
|
||||
let listing = format!(
|
||||
"-A INPUT -p tcp --dport 22 -j ACCEPT\n{ONE_OURS}-A INPUT -i tsun1 -m comment --comment tsunagi:tsun1 -j ACCEPT\n"
|
||||
);
|
||||
let fake = Fake::new(true, &listing);
|
||||
clear_firewall(&|a| fake.run(a), "tsun0");
|
||||
assert_eq!(fake.verbs(), ["-S", "-D"]);
|
||||
|
||||
let mut gone = Fake::new(true, "");
|
||||
gone.missing = true;
|
||||
clear_firewall(&|a| gone.run(a), "tsun0");
|
||||
assert_eq!(gone.verbs(), ["-S"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_listing_words_honour_quotes() {
|
||||
assert_eq!(
|
||||
split_rule_line("-A INPUT -m comment --comment \"a b\" -j ACCEPT"),
|
||||
[
|
||||
"-A",
|
||||
"INPUT",
|
||||
"-m",
|
||||
"comment",
|
||||
"--comment",
|
||||
"a b",
|
||||
"-j",
|
||||
"ACCEPT"
|
||||
]
|
||||
);
|
||||
assert!(split_rule_line(" ").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,968 @@
|
||||
//! 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>
|
||||
//! 5205: from all fwmark 0x40000000/0x40000000 lookup main
|
||||
//! 5206: from all uidrange <euid>-<euid> lookup main
|
||||
//! 5290: from all lookup main suppress_prefixlength 0
|
||||
//! 5300: from all lookup 0x7473
|
||||
//! ```
|
||||
//!
|
||||
//! The first two keep the agent's *own* sockets and marked replies off the tunnel: its QUIC, relay,
|
||||
//! DHT and DNS traffic would otherwise be routed into the interface it is
|
||||
//! carrying, a loop. The specific-route rule lets the LAN, another
|
||||
//! VPN, a tunnel to somewhere else — keep working, because only the default
|
||||
//! route is suppressed. Mark/UID bypass precedes Tailscale; application rules follow it.
|
||||
//!
|
||||
//! The UID 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, comment_supported, failure, locked, run_firewall, run_iptables,
|
||||
same_rule, split_rule_line,
|
||||
};
|
||||
use super::linux_exit_mark;
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Each carries a comment naming its interface and range, which is how it is
|
||||
/// found again. A kernel without the `comment` match (small router builds
|
||||
/// leave it out) cannot take that, so `comment` false leaves it off and the
|
||||
/// rules are recognised by their shape instead; see [`tagged_rules`].
|
||||
fn offer_rules(interface: &str, range: &Ipv4Range, comment: bool) -> Vec<Rule> {
|
||||
let range_text = range.to_string();
|
||||
let tag = exit_tag(interface, range);
|
||||
let comment: Vec<String> = if comment {
|
||||
owned(&["-m", "comment", "--comment", tag.as_str()])
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let spec = |head: &[&str], tail: &[&str]| -> Vec<String> {
|
||||
owned(head)
|
||||
.into_iter()
|
||||
.chain(comment.iter().cloned())
|
||||
.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 that is ours: carrying a
|
||||
/// tag that starts with `prefix`, or, on a kernel with no `comment` match,
|
||||
/// being exactly one of the three rules this module writes for some range.
|
||||
/// Anything else in the chain is left alone.
|
||||
fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec<Found> {
|
||||
// `tsunagi-exit:<interface>:`
|
||||
let interface = prefix
|
||||
.strip_prefix("tsunagi-exit:")
|
||||
.and_then(|rest| rest.strip_suffix(':'))
|
||||
.unwrap_or_default();
|
||||
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())
|
||||
.or_else(|| uncommented_tag(interface, chain, rest))?;
|
||||
Some(Found {
|
||||
tag,
|
||||
rest: rest.to_vec(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The tag a rule would have carried, when it is one of ours written without
|
||||
/// a comment: its words are those of the rule for the range it names.
|
||||
fn uncommented_tag(interface: &str, chain: &str, rest: &[String]) -> Option<String> {
|
||||
if interface.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let range: Ipv4Range = rest
|
||||
.windows(2)
|
||||
.find(|pair| pair[0] == "-s" || pair[0] == "-d")
|
||||
.and_then(|pair| pair[1].parse().ok())?;
|
||||
offer_rules(interface, &range, false)
|
||||
.iter()
|
||||
.any(|rule| rule.chain == chain && same_rule(&rule.spec, rest))
|
||||
.then(|| exit_tag(interface, &range))
|
||||
}
|
||||
|
||||
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();
|
||||
let comment = comment_supported(run);
|
||||
|
||||
// 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, comment);
|
||||
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()
|
||||
}
|
||||
|
||||
fn check_kernel_rpf(overlay: &str) -> Result<(), String> {
|
||||
let directory = "/proc/sys/net/ipv4/conf";
|
||||
let value = |name: &str, setting: &str| -> Result<u32, String> {
|
||||
read_sysctl(&format!("{directory}/{name}/{setting}"))
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.ok_or_else(|| format!("cannot check {name}/{setting} before installing exit routes"))
|
||||
};
|
||||
let mut interfaces = Vec::new();
|
||||
for entry in std::fs::read_dir(directory).map_err(|err| err.to_string())? {
|
||||
let entry = entry.map_err(|err| err.to_string())?;
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name != "all" && name != "default" && name != "lo" && name != overlay {
|
||||
interfaces.push((
|
||||
name.clone(),
|
||||
value(&name, "rp_filter")?,
|
||||
value(&name, "src_valid_mark")?,
|
||||
));
|
||||
}
|
||||
}
|
||||
linux_exit_mark::check_kernel_rpf(
|
||||
value("all", "rp_filter")?,
|
||||
value("all", "src_valid_mark")?,
|
||||
&interfaces,
|
||||
)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
let _ = self.clear_client(interface).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 installed = self
|
||||
.underlay_sockets
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.as_ref()
|
||||
.is_some_and(|(name, _, routed)| name == interface && *routed);
|
||||
if installed {
|
||||
// A failed socket refresh must never remove an active exit route.
|
||||
return self
|
||||
.refresh_sockets(interface)
|
||||
.await
|
||||
.unwrap_or(RuleOutcome::Applied);
|
||||
}
|
||||
if let Err(reason) = self.clear_client(interface).await {
|
||||
return RuleOutcome::Failed(reason);
|
||||
}
|
||||
let name = interface.to_string();
|
||||
let prepared = Self::blocking(move || {
|
||||
check_kernel_rpf(&name)?;
|
||||
linux_exit_mark::install(
|
||||
&run_firewall,
|
||||
&name,
|
||||
euid,
|
||||
std::path::Path::new("/proc/sys/net/ipv6").is_dir(),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
match prepared {
|
||||
Ok(Ok(sockets)) => {
|
||||
*self
|
||||
.underlay_sockets
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner()) = Some((interface.into(), sockets, false));
|
||||
}
|
||||
Ok(Err(reason)) | Err(reason) => return RuleOutcome::Failed(reason),
|
||||
}
|
||||
let route = self.route.clone();
|
||||
let name = interface.to_string();
|
||||
let outcome = match Self::blocking(move || route.set_exit_client(&name, euid)).await {
|
||||
Ok(Ok(())) => RuleOutcome::Applied,
|
||||
Ok(Err(err)) => RuleOutcome::Failed(err.to_string()),
|
||||
Err(reason) => RuleOutcome::Failed(reason),
|
||||
};
|
||||
if let RuleOutcome::Failed(reason) = outcome {
|
||||
let reason = match self.clear_client(interface).await {
|
||||
Ok(()) => reason,
|
||||
Err(cleanup) => format!("{reason}; cannot roll back exit rules: {cleanup}"),
|
||||
};
|
||||
RuleOutcome::Failed(reason)
|
||||
} else {
|
||||
if let Some((_, _, routed)) = self
|
||||
.underlay_sockets
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.as_mut()
|
||||
{
|
||||
*routed = true;
|
||||
}
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_client(&self, interface: &str) -> Result<(), String> {
|
||||
let route = self.route.clone();
|
||||
Self::blocking(move || route.clear_exit_client())
|
||||
.await?
|
||||
.map_err(|err| err.to_string())?;
|
||||
// Route removal must precede removing reply protection.
|
||||
let name = interface.to_string();
|
||||
Self::blocking(move || {
|
||||
linux_exit_mark::clear(
|
||||
&run_firewall,
|
||||
&name,
|
||||
std::path::Path::new("/proc/sys/net/ipv6").is_dir(),
|
||||
)
|
||||
})
|
||||
.await??;
|
||||
*self
|
||||
.underlay_sockets
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner()) = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refresh_sockets(&self, interface: &str) -> Option<RuleOutcome> {
|
||||
let previous = self
|
||||
.underlay_sockets
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.clone();
|
||||
if previous
|
||||
.as_ref()
|
||||
.is_none_or(|(name, _, routed)| name != interface || !routed)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let name = interface.to_string();
|
||||
let work_name = name.clone();
|
||||
let result = Self::blocking(move || {
|
||||
let uid = std::fs::read_to_string("/proc/self/status")
|
||||
.ok()
|
||||
.and_then(|s| parse_euid(&s))
|
||||
.ok_or("cannot read agent UID")?;
|
||||
check_kernel_rpf(&work_name)?;
|
||||
let v6 = std::path::Path::new("/proc/sys/net/ipv6").is_dir();
|
||||
let sockets = linux_exit_mark::local_sockets(uid, v6)?;
|
||||
if previous
|
||||
.as_ref()
|
||||
.is_some_and(|(name, old, _)| name == &work_name && old == &sockets)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
linux_exit_mark::refresh(&run_firewall, &work_name, v6, &sockets)?;
|
||||
Ok(Some(sockets))
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(None)) => None,
|
||||
Ok(Ok(Some(sockets))) => {
|
||||
*self
|
||||
.underlay_sockets
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner()) = Some((name, sockets, true));
|
||||
Some(RuleOutcome::Applied)
|
||||
}
|
||||
Ok(Err(reason)) | Err(reason) => Some(RuleOutcome::Failed(reason)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExitHostRules for LinuxHostRules {
|
||||
fn refresh<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, Option<RuleOutcome>> {
|
||||
Box::pin(async move {
|
||||
if plan.client {
|
||||
self.refresh_sockets(&plan.interface).await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
if let Err(reason) = self.clear_client(&interface).await {
|
||||
tracing::warn!(%reason, "cannot remove exit-client rules");
|
||||
}
|
||||
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;
|
||||
if let Err(reason) = self.clear_client(interface).await {
|
||||
tracing::warn!(%reason, "cannot remove exit-client rules");
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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(), true);
|
||||
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,
|
||||
/// A kernel with no `comment` match, as small router builds have.
|
||||
no_comment: bool,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn new(check_ok: bool) -> Self {
|
||||
Self {
|
||||
calls: RefCell::new(Vec::new()),
|
||||
listing: RefCell::new(Vec::new()),
|
||||
missing: false,
|
||||
check_ok,
|
||||
no_comment: false,
|
||||
}
|
||||
}
|
||||
|
||||
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 self.no_comment && line.contains("-m comment") {
|
||||
return Ran::Exited {
|
||||
success: false,
|
||||
stdout: String::new(),
|
||||
stderr: "iptables: No chain/target/match by that name.".to_string(),
|
||||
};
|
||||
}
|
||||
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_kernel_without_the_comment_match_gets_the_rules_without_comments() {
|
||||
let mut fake = Fake::new(false);
|
||||
fake.no_comment = true;
|
||||
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.iter().all(|call| !call.contains("comment")),
|
||||
"{inserted:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_written_without_a_comment_are_ours_and_are_removed_with_them() {
|
||||
// The way iptables prints them: its own option order.
|
||||
let listing = "\
|
||||
-P FORWARD ACCEPT
|
||||
-A FORWARD -s 10.13.37.0/24 -i tsun0 ! -o tsun0 -j ACCEPT
|
||||
-A FORWARD -d 10.13.37.0/24 -o tsun0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
-A FORWARD -i tsun0 -j ACCEPT
|
||||
-A FORWARD -s 10.13.37.0/24 -i tsun1 ! -o tsun1 -j ACCEPT
|
||||
";
|
||||
let found = tagged_rules(listing, "FORWARD", &exit_tag_prefix("tsun0"));
|
||||
assert_eq!(found.len(), 2, "{found:?}");
|
||||
assert!(
|
||||
found
|
||||
.iter()
|
||||
.all(|rule| rule.tag == exit_tag("tsun0", &range()))
|
||||
);
|
||||
let nat = "-A POSTROUTING -s 10.13.37.0/24 ! -o tsun0 -j MASQUERADE\n";
|
||||
assert_eq!(
|
||||
tagged_rules(nat, "POSTROUTING", &exit_tag_prefix("tsun0")).len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
//! Privileged routing regression, isolated from the host in named namespaces.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream, UdpSocket};
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::{Child, Command};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::linux::run_firewall;
|
||||
use super::linux_exit_mark::{clear, install, local_sockets, refresh};
|
||||
use crate::overlay::NetlinkProvisioner;
|
||||
|
||||
const TEST: &str = "overlay::hostrules::linux_exit_kernel::strict_firewall_preserves_underlay_and_application_exit_routing";
|
||||
const ROLE: &str = "TSUNAGI_EXIT_KERNEL_TEST_ROLE";
|
||||
|
||||
fn ip(arguments: &[&str]) -> String {
|
||||
let result = Command::new("ip").args(arguments).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"ip {arguments:?}: {}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&result.stdout).into_owned()
|
||||
}
|
||||
|
||||
struct Namespace(String);
|
||||
impl Drop for Namespace {
|
||||
fn drop(&mut self) {
|
||||
let _ = Command::new("ip").args(["netns", "del", &self.0]).status();
|
||||
}
|
||||
}
|
||||
struct Echo(Child);
|
||||
impl Drop for Echo {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn probes() {
|
||||
let should_pass = std::env::var("TSUNAGI_EXIT_KERNEL_EXPECT").unwrap() == "pass";
|
||||
for address in ["198.51.100.2:42000", "[2001:db8:2::2]:42000"] {
|
||||
let address: SocketAddr = address.parse().unwrap();
|
||||
let socket = UdpSocket::bind(if address.is_ipv4() {
|
||||
"0.0.0.0:0"
|
||||
} else {
|
||||
"[::]:0"
|
||||
})
|
||||
.unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(1)))
|
||||
.unwrap();
|
||||
socket.send_to(b"rpf", address).unwrap();
|
||||
let mut packet = [0u8; 3];
|
||||
assert_eq!(
|
||||
socket.recv(&mut packet).is_ok(),
|
||||
should_pass,
|
||||
"UDP {address}"
|
||||
);
|
||||
if should_pass {
|
||||
assert_eq!(&packet, b"rpf");
|
||||
}
|
||||
let connected = TcpStream::connect_timeout(&address, Duration::from_secs(1));
|
||||
assert_eq!(connected.is_ok(), should_pass, "TCP {address}");
|
||||
if let Ok(mut stream) = connected {
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(1)))
|
||||
.unwrap();
|
||||
stream.write_all(b"rpf").unwrap();
|
||||
stream.read_exact(&mut packet).unwrap();
|
||||
assert_eq!(&packet, b"rpf");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_probes(expect: &str) {
|
||||
let result = Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--ignored", "--exact", TEST, "--nocapture"])
|
||||
.env(ROLE, "probe")
|
||||
.env("TSUNAGI_EXIT_KERNEL_EXPECT", expect)
|
||||
.uid(990)
|
||||
.gid(990)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn passive_listener() {
|
||||
let listeners: Vec<_> = ["192.0.2.1:42010", "[2001:db8:1::1]:42010"]
|
||||
.into_iter()
|
||||
.map(|address| TcpListener::bind(address).unwrap())
|
||||
.collect();
|
||||
println!("PASSIVE_READY");
|
||||
let tasks: Vec<_> = listeners
|
||||
.into_iter()
|
||||
.map(|listener| {
|
||||
std::thread::spawn(move || {
|
||||
let (mut socket, _) = listener.accept().unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let mut bytes = [0; 3];
|
||||
socket.read_exact(&mut bytes).unwrap();
|
||||
socket.write_all(&bytes).unwrap();
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for task in tasks {
|
||||
task.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn udp_listener() {
|
||||
let sockets: Vec<_> = ["192.0.2.1:42011", "[2001:db8:1::1]:42011"]
|
||||
.into_iter()
|
||||
.map(|a| UdpSocket::bind(a).unwrap())
|
||||
.collect();
|
||||
println!("PASSIVE_READY");
|
||||
let tasks: Vec<_> = sockets
|
||||
.into_iter()
|
||||
.map(|socket| {
|
||||
std::thread::spawn(move || {
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut bytes = [0; 3];
|
||||
let (len, peer) = socket.recv_from(&mut bytes).unwrap();
|
||||
socket.send_to(&bytes[..len], peer).unwrap();
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for task in tasks {
|
||||
task.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_listener(role: &str) -> Echo {
|
||||
let process = Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--ignored", "--exact", TEST, "--nocapture"])
|
||||
.env(ROLE, role)
|
||||
.uid(990)
|
||||
.gid(990)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut child = Echo(process);
|
||||
let mut output = BufReader::new(child.0.stdout.take().unwrap());
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
assert_ne!(
|
||||
output.read_line(&mut line).unwrap(),
|
||||
0,
|
||||
"listener exited before readiness"
|
||||
);
|
||||
if line.contains("PASSIVE_READY") {
|
||||
// Keep the reader open for libtest's final status after the socket
|
||||
// worker finishes; closing it here makes success a broken pipe.
|
||||
child.0.stdout = Some(output.into_inner());
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn client() {
|
||||
for setting in ["rp_filter", "src_valid_mark"] {
|
||||
std::fs::write(format!("/proc/sys/net/ipv4/conf/all/{setting}"), b"1").unwrap();
|
||||
}
|
||||
std::fs::write("/proc/sys/net/ipv4/conf/rpf-c/rp_filter", b"1").unwrap();
|
||||
for family in [false, true] {
|
||||
let args: Vec<_> = [
|
||||
"-t",
|
||||
"mangle",
|
||||
"-A",
|
||||
"PREROUTING",
|
||||
"-m",
|
||||
"rpfilter",
|
||||
"--invert",
|
||||
"--validmark",
|
||||
"-j",
|
||||
"DROP",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let result = run_firewall(family, &args);
|
||||
assert!(
|
||||
matches!(result, super::linux::Ran::Exited { success: true, .. }),
|
||||
"{result:?}"
|
||||
);
|
||||
}
|
||||
let provisioner = NetlinkProvisioner::new().unwrap();
|
||||
let route = provisioner.host_rules().route;
|
||||
// Model Tailscale's earlier lookup, without running another VPN daemon.
|
||||
ip(&["-4", "rule", "add", "priority", "5270", "table", "52"]);
|
||||
ip(&[
|
||||
"-4", "route", "add", "default", "dev", "tsun0", "table", "52",
|
||||
]);
|
||||
ip(&["-6", "rule", "add", "priority", "5270", "table", "52"]);
|
||||
ip(&[
|
||||
"-6",
|
||||
"route",
|
||||
"add",
|
||||
"unreachable",
|
||||
"default",
|
||||
"table",
|
||||
"52",
|
||||
]);
|
||||
// A foreign VPN must leave neighbour discovery and connected LAN paths
|
||||
// usable; otherwise the fixture itself cannot resolve its uplink neighbour.
|
||||
for prefix in ["fe80::/64", "2001:db8:1::/64"] {
|
||||
ip(&["-6", "route", "add", "throw", prefix, "table", "52"]);
|
||||
}
|
||||
let mut passive = spawn_listener("listener");
|
||||
route.set_exit_client("tsun0", 990).unwrap();
|
||||
// uid bypass alone cannot protect reverse lookups, reproducing the bug.
|
||||
run_probes("blocked");
|
||||
install(&run_firewall, "tsun0", 990, true).unwrap();
|
||||
run_probes("pass");
|
||||
// Pair transports may bind fresh UDP ports after exit activation.
|
||||
let mut dynamic_udp = spawn_listener("udp-listener");
|
||||
refresh(
|
||||
&run_firewall,
|
||||
"tsun0",
|
||||
true,
|
||||
&local_sockets(990, true).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let server = std::env::var("TSUNAGI_EXIT_KERNEL_SERVER").unwrap();
|
||||
let incoming = Command::new("ip").args(["netns", "exec", &server, "python3", "-c", r#"import socket
|
||||
for family,source,target in [(socket.AF_INET,'198.51.100.2','192.0.2.1'),(socket.AF_INET6,'2001:db8:2::2','2001:db8:1::1')]:
|
||||
for kind,port in [(socket.SOCK_STREAM,42010),(socket.SOCK_DGRAM,42011)]:
|
||||
s=socket.socket(family,kind); s.settimeout(2); s.bind((source,0)); s.connect((target,port)); s.sendall(b'rpf'); assert s.recv(3)==b'rpf'; s.close()
|
||||
"#]).output().unwrap();
|
||||
assert!(
|
||||
incoming.status.success(),
|
||||
"first inbound TCP/UDP must be protected: {}",
|
||||
String::from_utf8_lossy(&incoming.stderr)
|
||||
);
|
||||
assert!(passive.0.wait().unwrap().success());
|
||||
assert!(dynamic_udp.0.wait().unwrap().success());
|
||||
// Repeated apply remains usable, and does not redirect application sockets.
|
||||
install(&run_firewall, "tsun0", 990, true).unwrap();
|
||||
run_probes("pass");
|
||||
assert!(ip(&["-4", "route", "get", "198.51.100.2", "uid", "991"]).contains("dev tsun0"));
|
||||
let blocked = Command::new("ip")
|
||||
.args(["-6", "route", "get", "2001:db8:2::2", "uid", "991"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!blocked.status.success(),
|
||||
"application IPv6 must stay blocked"
|
||||
);
|
||||
route.clear_exit_client().unwrap();
|
||||
clear(&run_firewall, "tsun0", true).unwrap();
|
||||
for family in ["-4", "-6"] {
|
||||
assert!(
|
||||
ip(&[family, "rule", "show"]).contains("5270:"),
|
||||
"foreign routing rules survive cleanup"
|
||||
);
|
||||
ip(&[family, "rule", "del", "priority", "5270"]);
|
||||
}
|
||||
assert!(ip(&["-4", "route", "get", "198.51.100.2", "uid", "991"]).contains("dev rpf-c"));
|
||||
assert!(ip(&["-6", "route", "get", "2001:db8:2::2", "uid", "991"]).contains("dev rpf-c"));
|
||||
for family in [false, true] {
|
||||
let result = run_firewall(family, &["-t".into(), "mangle".into(), "-S".into()]);
|
||||
if let super::linux::Ran::Exited { stdout, .. } = result {
|
||||
assert!(!stdout.contains("tsunagi-exit-underlay:"));
|
||||
assert!(!stdout.contains("tsunagi-exit-sockets:"));
|
||||
assert!(
|
||||
stdout.contains("rpfilter"),
|
||||
"strict host filtering is retained"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs root, iproute2, iptables/ip6tables and Python; changes only temporary namespaces"]
|
||||
fn strict_firewall_preserves_underlay_and_application_exit_routing() {
|
||||
match std::env::var(ROLE).as_deref() {
|
||||
Ok("probe") => {
|
||||
probes();
|
||||
return;
|
||||
}
|
||||
Ok("client") => {
|
||||
client();
|
||||
return;
|
||||
}
|
||||
Ok("listener") => {
|
||||
passive_listener();
|
||||
return;
|
||||
}
|
||||
Ok("udp-listener") => {
|
||||
udp_listener();
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let suffix = std::process::id();
|
||||
let client_name = format!("tsng-rpf-c-{suffix}");
|
||||
let server_name = format!("tsng-rpf-s-{suffix}");
|
||||
ip(&["netns", "add", &client_name]);
|
||||
let _client = Namespace(client_name.clone());
|
||||
ip(&["netns", "add", &server_name]);
|
||||
let _server = Namespace(server_name.clone());
|
||||
ip(&[
|
||||
"link",
|
||||
"add",
|
||||
"rpf-c",
|
||||
"netns",
|
||||
&client_name,
|
||||
"type",
|
||||
"veth",
|
||||
"peer",
|
||||
"name",
|
||||
"rpf-s",
|
||||
"netns",
|
||||
&server_name,
|
||||
]);
|
||||
for (namespace, interface, v4, v6) in [
|
||||
(&client_name, "rpf-c", "192.0.2.1/24", "2001:db8:1::1/64"),
|
||||
(&server_name, "rpf-s", "192.0.2.2/24", "2001:db8:1::2/64"),
|
||||
] {
|
||||
ip(&["-n", namespace, "link", "set", "lo", "up"]);
|
||||
ip(&["-n", namespace, "addr", "add", v4, "dev", interface]);
|
||||
ip(&[
|
||||
"-n", namespace, "-6", "addr", "add", v6, "dev", interface, "nodad",
|
||||
]);
|
||||
ip(&["-n", namespace, "link", "set", interface, "up"]);
|
||||
}
|
||||
ip(&[
|
||||
"-n",
|
||||
&server_name,
|
||||
"addr",
|
||||
"add",
|
||||
"198.51.100.2/32",
|
||||
"dev",
|
||||
"lo",
|
||||
]);
|
||||
ip(&[
|
||||
"-n",
|
||||
&server_name,
|
||||
"-6",
|
||||
"addr",
|
||||
"add",
|
||||
"2001:db8:2::2/128",
|
||||
"dev",
|
||||
"lo",
|
||||
"nodad",
|
||||
]);
|
||||
ip(&[
|
||||
"-n",
|
||||
&client_name,
|
||||
"route",
|
||||
"add",
|
||||
"default",
|
||||
"via",
|
||||
"192.0.2.2",
|
||||
]);
|
||||
ip(&[
|
||||
"-n",
|
||||
&client_name,
|
||||
"-6",
|
||||
"route",
|
||||
"add",
|
||||
"default",
|
||||
"via",
|
||||
"2001:db8:1::2",
|
||||
]);
|
||||
ip(&["-n", &client_name, "link", "add", "tsun0", "type", "dummy"]);
|
||||
ip(&[
|
||||
"-n",
|
||||
&client_name,
|
||||
"addr",
|
||||
"add",
|
||||
"10.13.37.1/24",
|
||||
"dev",
|
||||
"tsun0",
|
||||
]);
|
||||
ip(&["-n", &client_name, "link", "set", "tsun0", "up"]);
|
||||
let echo_code = r#"import socket,selectors,sys
|
||||
selector=selectors.DefaultSelector()
|
||||
for family in [socket.AF_INET,socket.AF_INET6]:
|
||||
for kind in [socket.SOCK_DGRAM,socket.SOCK_STREAM]:
|
||||
sock=socket.socket(family,kind)
|
||||
if family==socket.AF_INET6: sock.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1)
|
||||
sock.bind(('198.51.100.2' if family==socket.AF_INET else '2001:db8:2::2',42000))
|
||||
if kind==socket.SOCK_STREAM: sock.listen()
|
||||
selector.register(sock,selectors.EVENT_READ,kind)
|
||||
print('ready',flush=True)
|
||||
while True:
|
||||
for key,_ in selector.select():
|
||||
if key.data==socket.SOCK_DGRAM:
|
||||
data,address=key.fileobj.recvfrom(128); key.fileobj.sendto(data,address)
|
||||
else:
|
||||
conn,_=key.fileobj.accept(); conn.settimeout(2)
|
||||
try: conn.sendall(conn.recv(128))
|
||||
finally: conn.close()
|
||||
"#;
|
||||
let child = Command::new("ip")
|
||||
.args([
|
||||
"netns",
|
||||
"exec",
|
||||
&server_name,
|
||||
"python3",
|
||||
"-u",
|
||||
"-c",
|
||||
echo_code,
|
||||
])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut echo = Echo(child);
|
||||
let mut ready = String::new();
|
||||
BufReader::new(echo.0.stdout.take().unwrap())
|
||||
.read_line(&mut ready)
|
||||
.unwrap();
|
||||
assert_eq!(ready.trim(), "ready");
|
||||
let result = Command::new("ip")
|
||||
.args(["netns", "exec", &client_name])
|
||||
.arg(std::env::current_exe().unwrap())
|
||||
.args(["--ignored", "--exact", TEST, "--nocapture"])
|
||||
.env(ROLE, "client")
|
||||
.env("TSUNAGI_EXIT_KERNEL_SERVER", &server_name)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
//! Own-bit connection marks for exit-client underlay traffic.
|
||||
//! Reply marks are restored before host reverse-path filtering, never accepted
|
||||
//! around it. No peer address becomes a host route or firewall argument.
|
||||
|
||||
use super::linux::{Ran, failure, locked, split_rule_line};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
/// Outside Tailscale's 0xff0000 mask and ordinary low-bit WireGuard marks.
|
||||
pub(crate) const UNDERLAY_MARK: u32 = 0x4000_0000;
|
||||
pub(crate) const UNDERLAY_MASK: u32 = UNDERLAY_MARK;
|
||||
const MARK: &str = "0x40000000/0x40000000";
|
||||
const MASK: &str = "0x40000000";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(super) struct Listener {
|
||||
address: IpAddr,
|
||||
port: u16,
|
||||
tcp: bool,
|
||||
}
|
||||
|
||||
fn parse_listeners(listing: &str, uid: u32, v6: bool, tcp: bool) -> Result<Vec<Listener>, String> {
|
||||
let mut result = Vec::new();
|
||||
for line in listing.lines().skip(1) {
|
||||
let fields: Vec<_> = line.split_whitespace().collect();
|
||||
if (tcp && fields.get(3) != Some(&"0A"))
|
||||
|| fields.get(7).and_then(|s| s.parse::<u32>().ok()) != Some(uid)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (address, port) = fields
|
||||
.get(1)
|
||||
.and_then(|s| s.split_once(':'))
|
||||
.ok_or("malformed local socket")?;
|
||||
let port = u16::from_str_radix(port, 16).map_err(|e| e.to_string())?;
|
||||
let address = if v6 {
|
||||
let mut bytes = [0; 16];
|
||||
for (word, chunk) in bytes.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
let hex = address
|
||||
.get(word * 8..word * 8 + 8)
|
||||
.ok_or("malformed local IPv6 listener")?;
|
||||
chunk.copy_from_slice(
|
||||
&u32::from_str_radix(hex, 16)
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_ne_bytes(),
|
||||
);
|
||||
}
|
||||
IpAddr::V6(Ipv6Addr::from(bytes))
|
||||
} else {
|
||||
IpAddr::V4(Ipv4Addr::from(
|
||||
u32::from_str_radix(address, 16)
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_ne_bytes(),
|
||||
))
|
||||
};
|
||||
if result.len() == 128 {
|
||||
return Err("too many local sockets for exit protection".into());
|
||||
}
|
||||
if port != 0 {
|
||||
result.push(Listener { address, port, tcp });
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(super) fn local_sockets(uid: u32, v6: bool) -> Result<Vec<Listener>, String> {
|
||||
let mut listeners = Vec::new();
|
||||
for (protocol, tcp) in [("tcp", true), ("udp", false)] {
|
||||
for family in [false, true].into_iter().filter(|f| !f || v6) {
|
||||
let path = format!("/proc/net/{protocol}{}", if family { "6" } else { "" });
|
||||
listeners.extend(parse_listeners(
|
||||
&std::fs::read_to_string(path).map_err(|e| e.to_string())?,
|
||||
uid,
|
||||
family,
|
||||
tcp,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
listeners.sort();
|
||||
listeners.dedup();
|
||||
Ok(listeners)
|
||||
}
|
||||
|
||||
fn listener_rules(interface: &str, listener: &Listener) -> Vec<Vec<String>> {
|
||||
let mut base = words(&[
|
||||
"!",
|
||||
"-i",
|
||||
interface,
|
||||
"-p",
|
||||
if listener.tcp { "tcp" } else { "udp" },
|
||||
"--dport",
|
||||
&listener.port.to_string(),
|
||||
]);
|
||||
if !listener.address.is_unspecified() {
|
||||
base.extend(words(&["-d", &listener.address.to_string()]));
|
||||
}
|
||||
// Match an actual local socket, not just a destination port. Listening
|
||||
// sockets need protection on the first packet, before any outbound connmark.
|
||||
base.extend(words(&[
|
||||
"-m",
|
||||
"socket",
|
||||
"--nowildcard",
|
||||
"-m",
|
||||
"comment",
|
||||
"--comment",
|
||||
&listener_tag(interface),
|
||||
]));
|
||||
let save = base
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(words(&[
|
||||
"-m",
|
||||
"mark",
|
||||
"--mark",
|
||||
MARK,
|
||||
"-j",
|
||||
"CONNMARK",
|
||||
"--save-mark",
|
||||
"--nfmask",
|
||||
MASK,
|
||||
"--ctmask",
|
||||
MASK,
|
||||
]))
|
||||
.collect();
|
||||
let mark = base
|
||||
.into_iter()
|
||||
.chain(words(&["-j", "MARK", "--set-xmark", MARK]))
|
||||
.collect();
|
||||
vec![save, mark]
|
||||
}
|
||||
pub(super) type RunFamily<'a> = &'a dyn Fn(bool, &[String]) -> Ran;
|
||||
|
||||
fn tag(interface: &str) -> String {
|
||||
format!("tsunagi-exit-underlay:{interface}")
|
||||
}
|
||||
|
||||
fn listener_tag(interface: &str) -> String {
|
||||
format!("tsunagi-exit-sockets:{interface}")
|
||||
}
|
||||
|
||||
fn words(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|s| (*s).into()).collect()
|
||||
}
|
||||
|
||||
fn rules(interface: &str, uid: u32) -> Vec<(&'static str, Vec<String>)> {
|
||||
let tag = tag(interface);
|
||||
let uid = uid.to_string();
|
||||
// Insert at position one in this order: final OUTPUT order is MARK, SAVE.
|
||||
vec![
|
||||
(
|
||||
"PREROUTING",
|
||||
words(&[
|
||||
"!",
|
||||
"-i",
|
||||
interface,
|
||||
"-m",
|
||||
"connmark",
|
||||
"--mark",
|
||||
MARK,
|
||||
"-m",
|
||||
"comment",
|
||||
"--comment",
|
||||
&tag,
|
||||
"-j",
|
||||
"CONNMARK",
|
||||
"--restore-mark",
|
||||
"--nfmask",
|
||||
MASK,
|
||||
"--ctmask",
|
||||
MASK,
|
||||
]),
|
||||
),
|
||||
(
|
||||
"OUTPUT",
|
||||
words(&[
|
||||
"-m",
|
||||
"owner",
|
||||
"--uid-owner",
|
||||
&uid,
|
||||
"-m",
|
||||
"mark",
|
||||
"--mark",
|
||||
MARK,
|
||||
"-m",
|
||||
"comment",
|
||||
"--comment",
|
||||
&tag,
|
||||
"-j",
|
||||
"CONNMARK",
|
||||
"--save-mark",
|
||||
"--nfmask",
|
||||
MASK,
|
||||
"--ctmask",
|
||||
MASK,
|
||||
]),
|
||||
),
|
||||
(
|
||||
"OUTPUT",
|
||||
words(&[
|
||||
"-m",
|
||||
"owner",
|
||||
"--uid-owner",
|
||||
&uid,
|
||||
"-m",
|
||||
"comment",
|
||||
"--comment",
|
||||
&tag,
|
||||
"-j",
|
||||
"MARK",
|
||||
"--set-xmark",
|
||||
MARK,
|
||||
]),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn execute(run: RunFamily<'_>, v6: bool, args: Vec<String>) -> Result<String, String> {
|
||||
match run(v6, &locked(args)) {
|
||||
Ran::Exited {
|
||||
success: true,
|
||||
stdout,
|
||||
..
|
||||
} => Ok(stdout),
|
||||
Ran::Exited { stderr, .. } => Err(failure(&stderr)),
|
||||
Ran::Missing => Err(format!(
|
||||
"{} is required for exit-node connection marks",
|
||||
if v6 { "ip6tables" } else { "iptables" }
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes only exact interface tags, retaining other agents and host rules.
|
||||
pub(super) fn clear(run: RunFamily<'_>, interface: &str, v6: bool) -> Result<(), String> {
|
||||
clear_tags(run, interface, v6, false)
|
||||
}
|
||||
|
||||
fn clear_tags(
|
||||
run: RunFamily<'_>,
|
||||
interface: &str,
|
||||
v6: bool,
|
||||
sockets_only: bool,
|
||||
) -> Result<(), String> {
|
||||
let wanted = tag(interface);
|
||||
let sockets = listener_tag(interface);
|
||||
for family in [false, true].into_iter().filter(|family| !family || v6) {
|
||||
let chains: &[&str] = if sockets_only {
|
||||
&["PREROUTING"]
|
||||
} else {
|
||||
&["OUTPUT", "PREROUTING"]
|
||||
};
|
||||
for chain in chains {
|
||||
let listing = execute(run, family, words(&["-t", "mangle", "-S", chain]))?;
|
||||
for line in listing.lines() {
|
||||
let tokens = split_rule_line(line);
|
||||
let [append, listed_chain, rest @ ..] = tokens.as_slice() else {
|
||||
continue;
|
||||
};
|
||||
if append == "-A"
|
||||
&& listed_chain == *chain
|
||||
&& rest.windows(2).any(|pair| {
|
||||
pair[0] == "--comment"
|
||||
&& (pair[1] == sockets || (!sockets_only && pair[1] == wanted))
|
||||
})
|
||||
{
|
||||
let args = words(&["-t", "mangle", "-D", chain])
|
||||
.into_iter()
|
||||
.chain(rest.iter().cloned())
|
||||
.collect();
|
||||
execute(run, family, args)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Establishes protection before routing may redirect application traffic.
|
||||
pub(super) fn install(
|
||||
run: RunFamily<'_>,
|
||||
interface: &str,
|
||||
uid: u32,
|
||||
v6: bool,
|
||||
) -> Result<Vec<Listener>, String> {
|
||||
let listeners = local_sockets(uid, v6)?;
|
||||
install_with_listeners(run, interface, uid, v6, &listeners)?;
|
||||
Ok(listeners)
|
||||
}
|
||||
|
||||
fn install_with_listeners(
|
||||
run: RunFamily<'_>,
|
||||
interface: &str,
|
||||
uid: u32,
|
||||
v6: bool,
|
||||
listeners: &[Listener],
|
||||
) -> Result<(), String> {
|
||||
clear(run, interface, v6)?;
|
||||
let result = (|| {
|
||||
for family in [false, true].into_iter().filter(|family| !family || v6) {
|
||||
for (chain, spec) in rules(interface, uid) {
|
||||
let args = words(&["-t", "mangle", "-I", chain, "1"])
|
||||
.into_iter()
|
||||
.chain(spec)
|
||||
.collect();
|
||||
execute(run, family, args)?;
|
||||
}
|
||||
for listener in listeners.iter().filter(|s| s.address.is_ipv6() == family) {
|
||||
for spec in listener_rules(interface, listener) {
|
||||
execute(
|
||||
run,
|
||||
family,
|
||||
words(&["-t", "mangle", "-I", "PREROUTING", "1"])
|
||||
.into_iter()
|
||||
.chain(spec)
|
||||
.collect(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err()
|
||||
&& let Err(cleanup) = clear(run, interface, v6)
|
||||
{
|
||||
return Err(format!(
|
||||
"{}; cannot roll back connection marks: {cleanup}",
|
||||
result.err().unwrap_or_default()
|
||||
));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Updates passive protection without interrupting established connmark rules.
|
||||
pub(super) fn refresh(
|
||||
run: RunFamily<'_>,
|
||||
interface: &str,
|
||||
v6: bool,
|
||||
listeners: &[Listener],
|
||||
) -> Result<(), String> {
|
||||
clear_tags(run, interface, v6, true)?;
|
||||
for family in [false, true].into_iter().filter(|f| !f || v6) {
|
||||
for listener in listeners.iter().filter(|s| s.address.is_ipv6() == family) {
|
||||
for spec in listener_rules(interface, listener) {
|
||||
execute(
|
||||
run,
|
||||
family,
|
||||
words(&["-t", "mangle", "-I", "PREROUTING", "1"])
|
||||
.into_iter()
|
||||
.chain(spec)
|
||||
.collect(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Kernel strict RPF must include the restored mark, independently of iptables.
|
||||
pub(super) fn check_kernel_rpf(
|
||||
all_rpf: u32,
|
||||
all_valid: u32,
|
||||
interfaces: &[(String, u32, u32)],
|
||||
) -> Result<(), String> {
|
||||
for (interface, rpf, valid) in interfaces {
|
||||
if all_rpf.max(*rpf) == 1 && all_valid.max(*valid) == 0 {
|
||||
return Err(format!(
|
||||
"strict rp_filter on `{interface}` ignores connection marks; enable net.ipv4.conf.all.src_valid_mark=1 before using an exit node (rp_filter stays enabled)"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[test]
|
||||
fn both_families_restore_before_firewall_and_preserve_foreign_mark_bits() {
|
||||
let calls = RefCell::new(Vec::new());
|
||||
let run = |v6, args: &[String]| {
|
||||
calls.borrow_mut().push((v6, args.to_vec()));
|
||||
Ran::Exited {
|
||||
success: true,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
}
|
||||
};
|
||||
install_with_listeners(&run, "tsun0", 990, true, &[]).unwrap();
|
||||
let calls = calls.borrow();
|
||||
for family in [false, true] {
|
||||
let inserts: Vec<_> = calls
|
||||
.iter()
|
||||
.filter(|(v6, a)| *v6 == family && a.iter().any(|s| s == "-I"))
|
||||
.map(|(_, a)| a.join(" "))
|
||||
.collect();
|
||||
assert_eq!(inserts.len(), 3);
|
||||
assert!(inserts[0].contains("-I PREROUTING 1 ! -i tsun0 -m connmark"));
|
||||
assert!(inserts[0].contains("--restore-mark --nfmask 0x40000000 --ctmask 0x40000000"));
|
||||
assert!(inserts[1].contains("--uid-owner 990"));
|
||||
assert!(inserts[1].contains("--save-mark"));
|
||||
assert!(inserts[2].contains("--set-xmark 0x40000000/0x40000000"));
|
||||
assert!(
|
||||
!inserts
|
||||
.iter()
|
||||
.any(|s| s.contains("-j ACCEPT") || s.contains("-j RETURN"))
|
||||
);
|
||||
}
|
||||
assert_eq!(UNDERLAY_MASK & 0x00ff_0000, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_retains_other_interfaces_and_rolls_back_partial_ipv6_installation() {
|
||||
let calls = RefCell::new(Vec::new());
|
||||
let run = |v6, args: &[String]| {
|
||||
calls.borrow_mut().push((v6, args.to_vec()));
|
||||
let listing = "-A OUTPUT -m comment --comment tsunagi-exit-underlay:tsun0 -j MARK --set-xmark 0x40000000/0x40000000\n-A OUTPUT -m comment --comment tsunagi-exit-underlay:tsun1 -j MARK --set-xmark 0x40000000/0x40000000\n-A OUTPUT -m comment --comment unrelated -j RETURN\n";
|
||||
Ran::Exited {
|
||||
success: !(v6 && args.iter().any(|s| s == "-I")),
|
||||
stdout: if args.iter().any(|s| s == "-S") {
|
||||
listing.into()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
stderr: "injected IPv6 failure".into(),
|
||||
}
|
||||
};
|
||||
assert!(install_with_listeners(&run, "tsun0", 990, true, &[]).is_err());
|
||||
let calls = calls.borrow();
|
||||
let deletions: Vec<_> = calls
|
||||
.iter()
|
||||
.filter(|(_, a)| a.iter().any(|s| s == "-D"))
|
||||
.collect();
|
||||
assert!(
|
||||
deletions.len() >= 4,
|
||||
"both initial and rollback cleanup run"
|
||||
);
|
||||
assert!(
|
||||
deletions
|
||||
.iter()
|
||||
.all(|(_, a)| a.iter().any(|s| s == "tsunagi-exit-underlay:tsun0"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_strict_validation_requires_mark_awareness_without_weakening_rpf() {
|
||||
let interfaces = vec![("eth0".into(), 1, 0)];
|
||||
assert!(check_kernel_rpf(0, 0, &interfaces).is_err());
|
||||
assert!(check_kernel_rpf(0, 1, &interfaces).is_ok());
|
||||
assert!(check_kernel_rpf(2, 0, &interfaces).is_ok());
|
||||
assert!(check_kernel_rpf(0, 0, &[("eth0".into(), 1, 1)]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_tcp_protection_uses_only_locally_owned_listeners() {
|
||||
let listing = "header\n0: 00000000:01BB 00000000:0000 0A 0:0 0:0 0 990 0 1\n1: 0100007F:1111 00000000:0000 0A 0:0 0:0 0 1000 0 2\n2: 0100007F:2222 00000000:0000 01 0:0 0:0 0 990 0 3\n";
|
||||
let listeners = parse_listeners(listing, 990, false, true).unwrap();
|
||||
assert_eq!(
|
||||
listeners,
|
||||
vec![Listener {
|
||||
address: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
port: 443,
|
||||
tcp: true
|
||||
}]
|
||||
);
|
||||
let rules = listener_rules("tsun0", &listeners[0]);
|
||||
assert!(
|
||||
rules[0]
|
||||
.join(" ")
|
||||
.contains("-p tcp --dport 443 -m socket --nowildcard")
|
||||
);
|
||||
assert!(rules[0].join(" ").contains("--save-mark"));
|
||||
assert!(rules[1].join(" ").contains("--set-xmark"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_parser_checks_family_encoding_and_bounds() {
|
||||
let loopback = Ipv6Addr::LOCALHOST.octets();
|
||||
let hex: String = loopback
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|bytes| format!("{:08X}", u32::from_ne_bytes(*bytes)))
|
||||
.collect();
|
||||
let listing = format!("header\n0: {hex}:01BB 0:0 0A 0:0 0:0 0 990 0 1\n");
|
||||
assert_eq!(
|
||||
parse_listeners(&listing, 990, true, true).unwrap(),
|
||||
vec![Listener {
|
||||
address: IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
port: 443,
|
||||
tcp: true
|
||||
}]
|
||||
);
|
||||
assert!(
|
||||
parse_listeners(
|
||||
"header\n0: BAD:01BB 0:0 0A 0:0 0:0 0 990 0 1",
|
||||
990,
|
||||
true,
|
||||
true
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let huge = format!(
|
||||
"header\n{}",
|
||||
"0: 00000000:01BB 0:0 0A 0:0 0:0 0 990 0 1\n".repeat(129)
|
||||
);
|
||||
assert!(parse_listeners(&huge, 990, false, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_refresh_preserves_general_connmark_protection_and_other_interfaces() {
|
||||
let listing = "header\n0: 00000000:ABA0 0:0 07 0:0 0:0 0 990 0 1\n1: 00000000:0000 0:0 07 0:0 0:0 0 990 0 2\n";
|
||||
let sockets = parse_listeners(listing, 990, false, false).unwrap();
|
||||
assert_eq!(sockets.len(), 1);
|
||||
assert!(!sockets[0].tcp);
|
||||
let calls = RefCell::new(Vec::new());
|
||||
let run = |v6, args: &[String]| {
|
||||
calls.borrow_mut().push((v6, args.join(" ")));
|
||||
Ran::Exited { success: true, stdout: "-A PREROUTING -m comment --comment tsunagi-exit-underlay:tsun0 -j CONNMARK --restore-mark\n-A PREROUTING -m comment --comment tsunagi-exit-sockets:tsun0 -j MARK\n-A PREROUTING -m comment --comment tsunagi-exit-sockets:tsun1 -j MARK\n".into(), stderr: String::new() }
|
||||
};
|
||||
refresh(&run, "tsun0", false, &sockets).unwrap();
|
||||
let calls = calls.borrow();
|
||||
assert!(
|
||||
calls
|
||||
.iter()
|
||||
.any(|(_, a)| a.contains("-I PREROUTING 1") && a.contains("-p udp"))
|
||||
);
|
||||
assert!(
|
||||
calls
|
||||
.iter()
|
||||
.filter(|(_, a)| a.contains("-D"))
|
||||
.all(|(_, a)| a.contains("tsunagi-exit-sockets:tsun0"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Broadcast host rules on macOS: a `route` entry; the firewall is deferred.
|
||||
//!
|
||||
//! The limited-broadcast route is installed with `/sbin/route`, so a game's
|
||||
//! LAN discovery packet leaves through the overlay interface. It belongs to
|
||||
//! the interface and is removed when broadcast is turned off, the network
|
||||
//! leaves, or the agent stops — and, because a utun is torn down with the
|
||||
//! process, it also goes away on its own if the agent is killed.
|
||||
//!
|
||||
//! The inbound-UDP firewall allowance is **not** implemented here. On macOS
|
||||
//! that means a `pf` anchor, which is a good deal more machinery; until it is
|
||||
//! written the firewall half reports itself as missing with what to allow by
|
||||
//! hand, and the route is still installed. Failure is contained: neither half
|
||||
//! can stop the agent, and `status` shows exactly which is in place.
|
||||
//!
|
||||
//! `route` is a program, not a system call, so it is run by absolute path from
|
||||
//! `/sbin` rather than off `PATH`, and its arguments are built from values
|
||||
//! this agent computed — the interface name the kernel assigned, nothing a
|
||||
//! peer said.
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules};
|
||||
use super::macos_exit::{apply_plan, clear_all, run_tool};
|
||||
use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome};
|
||||
|
||||
/// The limited-broadcast destination routed through the overlay interface.
|
||||
const LIMITED_BROADCAST: &str = "255.255.255.255";
|
||||
|
||||
/// The `route` arguments that direct the limited broadcast out an interface.
|
||||
fn route_add_args(interface: &str) -> Vec<String> {
|
||||
vec![
|
||||
"-q".into(),
|
||||
"-n".into(),
|
||||
"add".into(),
|
||||
"-host".into(),
|
||||
LIMITED_BROADCAST.into(),
|
||||
"-interface".into(),
|
||||
interface.into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The `route` arguments that remove it again.
|
||||
fn route_del_args(interface: &str) -> Vec<String> {
|
||||
vec![
|
||||
"-q".into(),
|
||||
"-n".into(),
|
||||
"delete".into(),
|
||||
"-host".into(),
|
||||
LIMITED_BROADCAST.into(),
|
||||
"-interface".into(),
|
||||
interface.into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// What to do by hand while the `pf` allowance is unimplemented.
|
||||
fn firewall_todo(plan: &BroadcastRulesPlan) -> String {
|
||||
format!(
|
||||
"the inbound-UDP firewall allowance is not implemented on macOS yet; if the host \
|
||||
firewall drops discovery replies, allow inbound UDP from {} on `{}` with a pf rule \
|
||||
yourself",
|
||||
plan.range, plan.interface
|
||||
)
|
||||
}
|
||||
|
||||
/// The macOS implementation: `route` for the route, no firewall yet, and
|
||||
/// `pf` plus `route` for exit nodes (see [`super::macos_exit`]).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MacosHostRules {
|
||||
/// The reference `pfctl -E` handed out, held while exit-node rules need
|
||||
/// `pf` on and released when they are removed.
|
||||
pub(super) pf_token: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl MacosHostRules {
|
||||
/// Creates the host rules.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Installs the route, replacing one this agent added earlier so the call
|
||||
/// can be repeated. The delete is best effort: a route that is not there
|
||||
/// is the state the add then reaches anyway.
|
||||
async fn set_route(&self, interface: String) -> RuleOutcome {
|
||||
let _ = run(route_del_args(&interface)).await;
|
||||
match run(route_add_args(&interface)).await {
|
||||
Ok(()) => RuleOutcome::Applied,
|
||||
Err(reason) => RuleOutcome::Failed(reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExitHostRules for MacosHostRules {
|
||||
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
|
||||
Box::pin(async move {
|
||||
let job_plan = plan.clone();
|
||||
let token = Arc::clone(&self.pf_token);
|
||||
tokio::task::spawn_blocking(move || apply_plan(&run_tool, &token, &job_plan))
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
let failed = RuleOutcome::Failed(format!("the task failed: {err}"));
|
||||
ExitHostReport {
|
||||
offer: plan
|
||||
.offer
|
||||
.iter()
|
||||
.map(|range| (*range, failed.clone()))
|
||||
.collect(),
|
||||
forwarding: None,
|
||||
client: plan.client.then_some(failed),
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
let name = interface.to_string();
|
||||
let token = Arc::clone(&self.pf_token);
|
||||
let _ = tokio::task::spawn_blocking(move || clear_all(&run_tool, &token, &name)).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl BroadcastHostRules for MacosHostRules {
|
||||
fn name(&self) -> &str {
|
||||
"route"
|
||||
}
|
||||
|
||||
fn exit_rules(&self) -> Option<&dyn ExitHostRules> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
|
||||
Box::pin(async move {
|
||||
let route = self.set_route(plan.interface.clone()).await;
|
||||
BroadcastRulesReport {
|
||||
plan: plan.clone(),
|
||||
route,
|
||||
firewall: RuleOutcome::Failed(firewall_todo(plan)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
if let Err(reason) = run(route_del_args(interface)).await {
|
||||
tracing::debug!(interface = %interface, %reason, "cannot remove the broadcast route");
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `/sbin/route`, turning a non-zero exit into the message it printed.
|
||||
async fn run(args: Vec<String>) -> Result<(), String> {
|
||||
let display = format!("route {}", args.join(" "));
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
std::process::Command::new("/sbin/route")
|
||||
.args(&args)
|
||||
.output()
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("could not run {display}: {err}"))?
|
||||
.map_err(|err| format!("could not run {display}: {err}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let text = stderr.trim();
|
||||
if text.is_empty() {
|
||||
Err(format!("{display} failed with {}", output.status))
|
||||
} else {
|
||||
Err(format!("{display} failed: {text}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn plan() -> BroadcastRulesPlan {
|
||||
BroadcastRulesPlan {
|
||||
interface: "utun5".into(),
|
||||
source: Ipv4Addr::new(10, 13, 37, 142),
|
||||
range: "10.13.37.0/24".parse().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_route_is_the_limited_broadcast_out_the_interface() {
|
||||
assert_eq!(
|
||||
route_add_args("utun5"),
|
||||
vec![
|
||||
"-q",
|
||||
"-n",
|
||||
"add",
|
||||
"-host",
|
||||
"255.255.255.255",
|
||||
"-interface",
|
||||
"utun5"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
route_del_args("utun5"),
|
||||
vec![
|
||||
"-q",
|
||||
"-n",
|
||||
"delete",
|
||||
"-host",
|
||||
"255.255.255.255",
|
||||
"-interface",
|
||||
"utun5"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_firewall_todo_names_the_range_and_interface() {
|
||||
let todo = firewall_todo(&plan());
|
||||
assert!(todo.contains("10.13.37.0/24"), "{todo}");
|
||||
assert!(todo.contains("utun5"), "{todo}");
|
||||
assert!(todo.contains("pf"), "{todo}");
|
||||
// Deferred, so the firewall half is always reported as not in place;
|
||||
// the route runs for real and so is left to an on-host check. Running
|
||||
// `route` here would need root and would change the host routing
|
||||
// table, which the default test suite must never do.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,915 @@
|
||||
//! Exit-node host rules on macOS: a `pf` anchor and `route` entries.
|
||||
//!
|
||||
//! Compiled on every platform so the rule text and the parsers are tested
|
||||
//! everywhere; only `MacosHostRules` runs them, and only on macOS.
|
||||
//!
|
||||
//! # Offering
|
||||
//!
|
||||
//! Per offered range, in the anchor `com.apple/tsunagi-exit-<if>`:
|
||||
//!
|
||||
//! ```text
|
||||
//! nat on <egress> inet from <range> to any -> (<egress>)
|
||||
//! pass in quick on <if> inet from <range> to ! <range>
|
||||
//! ```
|
||||
//!
|
||||
//! The anchor lives under `com.apple/` on purpose: the stock `/etc/pf.conf`
|
||||
//! evaluates `nat-anchor "com.apple/*"` and `anchor "com.apple/*"`, so the
|
||||
//! rules take effect without editing the user's ruleset. `pf` cannot say
|
||||
//! "every interface but one" in a translation address, so the egress is the
|
||||
//! interface of the default route **when the rules are applied**; after the
|
||||
//! default route moves (Wi-Fi to Ethernet) the rules are re-applied by
|
||||
//! switching the exit node off and on, or restarting the agent.
|
||||
//!
|
||||
//! The kernel's `net.inet.ip.forwarding` is only read, never written: it
|
||||
//! changes how the whole host behaves, which is the owner's decision.
|
||||
//!
|
||||
//! # Using
|
||||
//!
|
||||
//! macOS has no `uidrange` policy routing, and the agent is root, so the
|
||||
//! Linux scheme does not carry over. Instead:
|
||||
//!
|
||||
//! ```text
|
||||
//! route add -net 0.0.0.0/1 -interface <if>
|
||||
//! route add -net 128.0.0.0/1 -interface <if>
|
||||
//! pass out quick on <if> route-to (<egress> <gateway>) inet proto { tcp udp }
|
||||
//! from any to ! <overlay ranges> user <agent uid>
|
||||
//! nat on <egress> inet from (<if>) to any -> (<egress>)
|
||||
//! ```
|
||||
//!
|
||||
//! The two halves outrank the default route without replacing it, and every
|
||||
//! more specific route (LAN, another VPN) keeps working. The `route-to` rule
|
||||
//! sends the *agent's own* traffic — QUIC, relay, DHT, which would otherwise
|
||||
//! loop into the tunnel it carries — back out the physical interface, and the
|
||||
//! translation gives it that interface's address, since an unbound socket
|
||||
//! picked the overlay address when the route pointed at the overlay. Like the
|
||||
//! Linux exemption this is by user id, so anything else running as root also
|
||||
//! bypasses the tunnel.
|
||||
//!
|
||||
//! IPv6 is blocked while an exit node is in use, as on Linux: `::/1` and
|
||||
//! `8000::/1` are rejecting routes, so applications fall back to IPv4 at once
|
||||
//! instead of leaking.
|
||||
//!
|
||||
//! Every object is derived from local state and the interface the agent owns;
|
||||
//! none of it comes from a peer.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::state::Ipv4Range;
|
||||
|
||||
use super::RuleOutcome;
|
||||
use super::exit::{ExitHostPlan, ExitHostReport};
|
||||
|
||||
/// The programs this module runs, always by absolute path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Tool {
|
||||
Route,
|
||||
Pfctl,
|
||||
Sysctl,
|
||||
Id,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
fn path(self) -> &'static str {
|
||||
match self {
|
||||
Self::Route => "/sbin/route",
|
||||
Self::Pfctl => "/sbin/pfctl",
|
||||
Self::Sysctl => "/usr/sbin/sysctl",
|
||||
Self::Id => "/usr/bin/id",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What running a tool came to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) enum Ran {
|
||||
/// The program is not there.
|
||||
Missing,
|
||||
/// It ran.
|
||||
Exited {
|
||||
success: bool,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Runs a tool with optional standard input; injectable so the logic is
|
||||
/// tested without touching the host.
|
||||
pub(super) type Run<'a> = &'a dyn Fn(Tool, &[String], Option<&str>) -> Ran;
|
||||
|
||||
/// Runs a real tool. Blocking: callers use `spawn_blocking`.
|
||||
pub(super) fn run_tool(tool: Tool, args: &[String], stdin: Option<&str>) -> Ran {
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut command = Command::new(tool.path());
|
||||
command
|
||||
.args(args)
|
||||
.stdin(if stdin.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::null()
|
||||
})
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ran::Missing,
|
||||
Err(err) => {
|
||||
return Ran::Exited {
|
||||
success: false,
|
||||
stdout: String::new(),
|
||||
stderr: format!("could not run {}: {err}", tool.path()),
|
||||
};
|
||||
}
|
||||
};
|
||||
if let (Some(text), Some(mut pipe)) = (stdin, child.stdin.take()) {
|
||||
// A write error shows up as the tool's own failure below.
|
||||
let _ = pipe.write_all(text.as_bytes());
|
||||
}
|
||||
match child.wait_with_output() {
|
||||
Ok(output) => Ran::Exited {
|
||||
success: output.status.success(),
|
||||
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
},
|
||||
Err(err) => Ran::Exited {
|
||||
success: false,
|
||||
stdout: String::new(),
|
||||
stderr: format!("could not run {}: {err}", tool.path()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn owned(words: &[&str]) -> Vec<String> {
|
||||
words.iter().map(|word| word.to_string()).collect()
|
||||
}
|
||||
|
||||
/// A name that is safe in a `pf` rule, an anchor path and a table name.
|
||||
fn safe_name(name: &str) -> bool {
|
||||
!name.is_empty() && name.len() <= 32 && name.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// The anchor holding every rule of one interface.
|
||||
fn anchor_name(interface: &str) -> Result<String, String> {
|
||||
if safe_name(interface) {
|
||||
Ok(format!("com.apple/tsunagi-exit-{interface}"))
|
||||
} else {
|
||||
Err(format!(
|
||||
"`{interface}` is not an interface name this agent can write a pf rule for"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The default route as `route -n get default` reports it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct DefaultRoute {
|
||||
pub interface: String,
|
||||
pub gateway: Option<Ipv4Addr>,
|
||||
}
|
||||
|
||||
/// Reads `interface:` and `gateway:` out of `route -n get default`.
|
||||
fn parse_default_route(text: &str) -> Option<DefaultRoute> {
|
||||
let field = |name: &str| {
|
||||
text.lines().find_map(|line| {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
(key.trim() == name).then(|| value.trim().to_string())
|
||||
})
|
||||
};
|
||||
let interface = field("interface").filter(|name| safe_name(name))?;
|
||||
let gateway = field("gateway").and_then(|value| value.parse().ok());
|
||||
Some(DefaultRoute { interface, gateway })
|
||||
}
|
||||
|
||||
fn failure(stderr: &str, stdout: &str) -> String {
|
||||
let text = if stderr.trim().is_empty() {
|
||||
stdout.trim()
|
||||
} else {
|
||||
stderr.trim()
|
||||
};
|
||||
if text.is_empty() {
|
||||
"the command failed without saying why".to_string()
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a tool and turns a failure into a sentence a person can act on.
|
||||
fn run_ok(
|
||||
run: Run<'_>,
|
||||
tool: Tool,
|
||||
args: &[String],
|
||||
stdin: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
match run(tool, args, stdin) {
|
||||
Ran::Missing => Err(format!("{} not found", tool.path())),
|
||||
Ran::Exited {
|
||||
success: true,
|
||||
stdout,
|
||||
..
|
||||
} => Ok(stdout),
|
||||
Ran::Exited { stderr, stdout, .. } => Err(format!(
|
||||
"{} {} failed: {}",
|
||||
tool.path(),
|
||||
args.join(" "),
|
||||
failure(&stderr, &stdout)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The route traffic leaves by now, which is the egress for translation and
|
||||
/// for the agent's own sockets. Read *before* the overlay routes are added.
|
||||
fn default_route(run: Run<'_>, overlay: &str) -> Result<DefaultRoute, String> {
|
||||
let out = run_ok(run, Tool::Route, &owned(&["-n", "get", "default"]), None)
|
||||
.map_err(|why| format!("cannot find the default route: {why}"))?;
|
||||
let route = parse_default_route(&out)
|
||||
.ok_or_else(|| "the host has no default route to send traffic out of".to_string())?;
|
||||
if route.interface == overlay {
|
||||
return Err(format!(
|
||||
"the default route already goes through `{overlay}`"
|
||||
));
|
||||
}
|
||||
Ok(route)
|
||||
}
|
||||
|
||||
/// Whether the kernel forwards IPv4. Read only.
|
||||
fn forwarding_state(run: Run<'_>) -> Option<bool> {
|
||||
let out = run_ok(
|
||||
run,
|
||||
Tool::Sysctl,
|
||||
&owned(&["-n", "net.inet.ip.forwarding"]),
|
||||
None,
|
||||
)
|
||||
.ok()?;
|
||||
match out.trim() {
|
||||
"1" => Some(true),
|
||||
"0" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The effective user id of this process, from `id -u`.
|
||||
fn agent_uid(run: Run<'_>) -> Result<u32, String> {
|
||||
run_ok(run, Tool::Id, &owned(&["-u"]), None)
|
||||
.ok()
|
||||
.and_then(|out| out.trim().parse().ok())
|
||||
.ok_or_else(|| "cannot tell which user this agent runs as".to_string())
|
||||
}
|
||||
|
||||
/// What the using side adds to the rules.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ClientRules<'a> {
|
||||
uid: u32,
|
||||
/// Every overlay range: the agent's own traffic *to* those still goes
|
||||
/// through the interface.
|
||||
overlay: &'a [Ipv4Range],
|
||||
}
|
||||
|
||||
/// The text of the anchor: translation first, filtering after, which is the
|
||||
/// order `pf` insists on.
|
||||
fn pf_rules(
|
||||
interface: &str,
|
||||
egress: &DefaultRoute,
|
||||
offer: &[Ipv4Range],
|
||||
client: Option<&ClientRules<'_>>,
|
||||
) -> String {
|
||||
let out = &egress.interface;
|
||||
let mut nat = Vec::new();
|
||||
let mut filter = Vec::new();
|
||||
for range in offer {
|
||||
nat.push(format!("nat on {out} inet from {range} to any -> ({out})"));
|
||||
filter.push(format!(
|
||||
"pass in quick on {interface} inet from {range} to ! {range}"
|
||||
));
|
||||
}
|
||||
let mut tables = Vec::new();
|
||||
if let Some(client) = client {
|
||||
nat.push(format!(
|
||||
"nat on {out} inet from ({interface}) to any -> ({out})"
|
||||
));
|
||||
let target = if client.overlay.is_empty() {
|
||||
"any".to_string()
|
||||
} else {
|
||||
let ranges: Vec<String> = client.overlay.iter().map(|r| r.to_string()).collect();
|
||||
// A table, because `! { a, b }` expands to one rule per element
|
||||
// in older `pf`, which matches everything.
|
||||
tables.push(format!(
|
||||
"table <tsunagi_overlay_{interface}> const {{ {} }}",
|
||||
ranges.join(", ")
|
||||
));
|
||||
format!("! <tsunagi_overlay_{interface}>")
|
||||
};
|
||||
let via = match egress.gateway {
|
||||
Some(gateway) => format!("({out} {gateway})"),
|
||||
None => out.clone(),
|
||||
};
|
||||
filter.push(format!(
|
||||
"pass out quick on {interface} route-to {via} inet proto {{ tcp udp }} \
|
||||
from any to {target} user {}",
|
||||
client.uid
|
||||
));
|
||||
}
|
||||
tables
|
||||
.into_iter()
|
||||
.chain(nat)
|
||||
.chain(filter)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
+ "\n"
|
||||
}
|
||||
|
||||
/// Turns `pf` on if nobody has, holding the reference `pfctl` hands out so it
|
||||
/// can be released when the agent is done. One reference at most.
|
||||
fn acquire_pf(run: Run<'_>, slot: &Mutex<Option<String>>) -> Result<(), String> {
|
||||
if lock(slot).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
match run(Tool::Pfctl, &owned(&["-E"]), None) {
|
||||
Ran::Missing => Err(format!("{} not found", Tool::Pfctl.path())),
|
||||
Ran::Exited {
|
||||
success,
|
||||
stdout,
|
||||
stderr,
|
||||
} => {
|
||||
// `pfctl -E` reports `Token : <n>` on stderr, and also says so
|
||||
// when pf is already enabled.
|
||||
let token = stdout.lines().chain(stderr.lines()).find_map(parse_token);
|
||||
if !success && token.is_none() {
|
||||
return Err(format!("cannot enable pf: {}", failure(&stderr, &stdout)));
|
||||
}
|
||||
*lock(slot) = token;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_token(line: &str) -> Option<String> {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
let value = value.trim();
|
||||
(key.trim().eq_ignore_ascii_case("token")
|
||||
&& !value.is_empty()
|
||||
&& value.chars().all(|c| c.is_ascii_digit()))
|
||||
.then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn release_pf(run: Run<'_>, slot: &Mutex<Option<String>>) {
|
||||
if let Some(token) = lock(slot).take()
|
||||
&& let Err(why) = run_ok(run, Tool::Pfctl, &["-X".to_string(), token], None)
|
||||
{
|
||||
tracing::debug!(%why, "cannot release the pf reference");
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_args(anchor: &str) -> Vec<String> {
|
||||
owned(&["-q", "-a", anchor, "-F", "all"])
|
||||
}
|
||||
|
||||
fn load_args(anchor: &str) -> Vec<String> {
|
||||
owned(&["-q", "-a", anchor, "-f", "-"])
|
||||
}
|
||||
|
||||
/// The two halves that out-rank the default route.
|
||||
const HALVES: [&str; 2] = ["0.0.0.0/1", "128.0.0.0/1"];
|
||||
/// The IPv6 halves, made unreachable.
|
||||
const HALVES_V6: [&str; 2] = ["::/1", "8000::/1"];
|
||||
|
||||
fn route_add_args(half: &str, interface: &str) -> Vec<String> {
|
||||
owned(&["-q", "-n", "add", "-net", half, "-interface", interface])
|
||||
}
|
||||
|
||||
fn route_del_args(half: &str, interface: &str) -> Vec<String> {
|
||||
owned(&["-q", "-n", "delete", "-net", half, "-interface", interface])
|
||||
}
|
||||
|
||||
fn route_add_v6_args(half: &str) -> Vec<String> {
|
||||
owned(&["-q", "-n", "add", "-inet6", "-net", half, "::1", "-reject"])
|
||||
}
|
||||
|
||||
fn route_del_v6_args(half: &str) -> Vec<String> {
|
||||
owned(&["-q", "-n", "delete", "-inet6", "-net", half, "::1"])
|
||||
}
|
||||
|
||||
/// Takes away the overlay default routes. Best effort: what is not there is
|
||||
/// the state wanted.
|
||||
fn clear_routes(run: Run<'_>, interface: &str) {
|
||||
for half in HALVES {
|
||||
let _ = run(Tool::Route, &route_del_args(half, interface), None);
|
||||
}
|
||||
for half in HALVES_V6 {
|
||||
let _ = run(Tool::Route, &route_del_v6_args(half), None);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_routes(run: Run<'_>, interface: &str) -> Result<(), String> {
|
||||
clear_routes(run, interface);
|
||||
for half in HALVES {
|
||||
if let Err(why) = run_ok(run, Tool::Route, &route_add_args(half, interface), None) {
|
||||
clear_routes(run, interface);
|
||||
return Err(why);
|
||||
}
|
||||
}
|
||||
for half in HALVES_V6 {
|
||||
// A host without IPv6 has nothing to block; not a failure.
|
||||
if let Err(why) = run_ok(run, Tool::Route, &route_add_v6_args(half), None) {
|
||||
tracing::debug!(%why, "cannot block IPv6 for the exit node");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Makes the host match the plan.
|
||||
pub(super) fn apply_plan(
|
||||
run: Run<'_>,
|
||||
token: &Mutex<Option<String>>,
|
||||
plan: &ExitHostPlan,
|
||||
) -> ExitHostReport {
|
||||
let forwarding = if plan.offer.is_empty() {
|
||||
None
|
||||
} else {
|
||||
forwarding_state(run)
|
||||
};
|
||||
let outcome = apply_rules(run, token, plan);
|
||||
let verdict = |ok: bool, why: &str| {
|
||||
if ok {
|
||||
RuleOutcome::Applied
|
||||
} else {
|
||||
RuleOutcome::Failed(why.to_string())
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
Ok(()) => ExitHostReport {
|
||||
offer: plan
|
||||
.offer
|
||||
.iter()
|
||||
.map(|range| (*range, RuleOutcome::Applied))
|
||||
.collect(),
|
||||
forwarding,
|
||||
client: plan.client.then_some(RuleOutcome::Applied),
|
||||
},
|
||||
Err(why) => ExitHostReport {
|
||||
offer: plan
|
||||
.offer
|
||||
.iter()
|
||||
.map(|range| (*range, verdict(false, &why)))
|
||||
.collect(),
|
||||
forwarding,
|
||||
client: plan.client.then(|| verdict(false, &why)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_rules(
|
||||
run: Run<'_>,
|
||||
token: &Mutex<Option<String>>,
|
||||
plan: &ExitHostPlan,
|
||||
) -> Result<(), String> {
|
||||
let interface = plan.interface.as_str();
|
||||
let anchor = anchor_name(interface)?;
|
||||
let egress = default_route(run, interface)?;
|
||||
let uid = if plan.client {
|
||||
Some(agent_uid(run)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let client = uid.map(|uid| ClientRules {
|
||||
uid,
|
||||
overlay: &plan.overlay,
|
||||
});
|
||||
let rules = pf_rules(interface, &egress, &plan.offer, client.as_ref());
|
||||
|
||||
acquire_pf(run, token)?;
|
||||
run_ok(run, Tool::Pfctl, &load_args(&anchor), Some(&rules))
|
||||
.map_err(|why| format!("cannot load the pf rules: {why}"))?;
|
||||
// The routes come after the rules that keep the agent's own traffic off
|
||||
// them: the other order would loop for as long as it takes.
|
||||
if plan.client {
|
||||
set_routes(run, interface)?;
|
||||
} else {
|
||||
clear_routes(run, interface);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes everything this agent put there for the interface.
|
||||
pub(super) fn clear_all(run: Run<'_>, token: &Mutex<Option<String>>, interface: &str) {
|
||||
clear_routes(run, interface);
|
||||
if let Ok(anchor) = anchor_name(interface) {
|
||||
// Flushing an anchor nothing was ever loaded into is not an error
|
||||
// worth reporting.
|
||||
if let Err(why) = run_ok(run, Tool::Pfctl, &flush_args(&anchor), None) {
|
||||
tracing::debug!(%why, "cannot flush the exit node pf anchor");
|
||||
}
|
||||
}
|
||||
release_pf(run, token);
|
||||
}
|
||||
|
||||
#[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()
|
||||
}
|
||||
|
||||
const DEFAULT_ROUTE: &str = " route to: default\n\
|
||||
destination: default\n\
|
||||
mask: default\n\
|
||||
gateway: 192.168.1.1\n\
|
||||
interface: en0\n\
|
||||
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>\n";
|
||||
|
||||
fn egress() -> DefaultRoute {
|
||||
DefaultRoute {
|
||||
interface: "en0".into(),
|
||||
gateway: Some(Ipv4Addr::new(192, 168, 1, 1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A scripted host that records every call.
|
||||
struct Fake {
|
||||
calls: RefCell<Vec<String>>,
|
||||
stdins: RefCell<Vec<String>>,
|
||||
default_route: Option<String>,
|
||||
forwarding: &'static str,
|
||||
pf_missing: bool,
|
||||
pf_load_fails: bool,
|
||||
route_add_fails: bool,
|
||||
pf_enable_output: &'static str,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: RefCell::new(Vec::new()),
|
||||
stdins: RefCell::new(Vec::new()),
|
||||
default_route: Some(DEFAULT_ROUTE.to_string()),
|
||||
forwarding: "1\n",
|
||||
pf_missing: false,
|
||||
pf_load_fails: false,
|
||||
route_add_fails: false,
|
||||
pf_enable_output: "pf enabled\nToken : 4242\n",
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&self, tool: Tool, args: &[String], stdin: Option<&str>) -> Ran {
|
||||
let line = format!("{:?} {}", tool, args.join(" "));
|
||||
self.calls.borrow_mut().push(line);
|
||||
if let Some(text) = stdin {
|
||||
self.stdins.borrow_mut().push(text.to_string());
|
||||
}
|
||||
let ok = |stdout: &str, stderr: &str| Ran::Exited {
|
||||
success: true,
|
||||
stdout: stdout.to_string(),
|
||||
stderr: stderr.to_string(),
|
||||
};
|
||||
let fail = |why: &str| Ran::Exited {
|
||||
success: false,
|
||||
stdout: String::new(),
|
||||
stderr: why.to_string(),
|
||||
};
|
||||
match tool {
|
||||
Tool::Id => ok("0\n", ""),
|
||||
Tool::Sysctl => ok(self.forwarding, ""),
|
||||
Tool::Route if args.first().map(String::as_str) == Some("-n") => {
|
||||
match &self.default_route {
|
||||
Some(text) => ok(text, ""),
|
||||
None => fail("route: writing to routing socket: not in table"),
|
||||
}
|
||||
}
|
||||
Tool::Route if args.contains(&"add".to_string()) && self.route_add_fails => {
|
||||
fail("route: writing to routing socket: File exists")
|
||||
}
|
||||
Tool::Route => ok("", ""),
|
||||
Tool::Pfctl if self.pf_missing => Ran::Missing,
|
||||
Tool::Pfctl if args == ["-E"] => ok("", self.pf_enable_output),
|
||||
Tool::Pfctl if args.contains(&"-f".to_string()) && self.pf_load_fails => {
|
||||
fail("pfctl: Syntax error")
|
||||
}
|
||||
Tool::Pfctl => ok("", ""),
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> Vec<String> {
|
||||
self.calls.borrow().clone()
|
||||
}
|
||||
|
||||
fn position(&self, needle: &str) -> Option<usize> {
|
||||
self.calls.borrow().iter().position(|c| c.contains(needle))
|
||||
}
|
||||
|
||||
fn count(&self, needle: &str) -> usize {
|
||||
self.calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|c| c.contains(needle))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
fn plan(offer: bool, client: bool) -> ExitHostPlan {
|
||||
ExitHostPlan {
|
||||
interface: "utun5".into(),
|
||||
offer: if offer { vec![range()] } else { Vec::new() },
|
||||
client,
|
||||
overlay: vec![range()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_route_is_read_from_route_output() {
|
||||
assert_eq!(parse_default_route(DEFAULT_ROUTE), Some(egress()));
|
||||
// A point-to-point default has no gateway line to use.
|
||||
let ppp = "destination: default\n interface: ppp0\n";
|
||||
assert_eq!(
|
||||
parse_default_route(ppp),
|
||||
Some(DefaultRoute {
|
||||
interface: "ppp0".into(),
|
||||
gateway: None
|
||||
})
|
||||
);
|
||||
assert_eq!(parse_default_route("route: not in table"), None);
|
||||
// An interface name that is not plain letters and digits never ends
|
||||
// up in a rule.
|
||||
assert_eq!(parse_default_route("interface: en0; pass all\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_anchor_is_under_com_apple_and_only_for_plain_names() {
|
||||
assert_eq!(
|
||||
anchor_name("utun5").unwrap(),
|
||||
"com.apple/tsunagi-exit-utun5"
|
||||
);
|
||||
assert!(anchor_name("").is_err());
|
||||
assert!(anchor_name("utun5 pass all").is_err());
|
||||
assert!(anchor_name("../x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offering_translates_the_range_and_admits_it_in() {
|
||||
let text = pf_rules("utun5", &egress(), &[range()], None);
|
||||
assert_eq!(
|
||||
text,
|
||||
"nat on en0 inet from 10.13.37.0/24 to any -> (en0)\n\
|
||||
pass in quick on utun5 inet from 10.13.37.0/24 to ! 10.13.37.0/24\n"
|
||||
);
|
||||
// Translation precedes filtering, as pf requires.
|
||||
assert!(text.find("nat on").unwrap() < text.find("pass in").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn using_sends_the_agents_own_traffic_back_out_the_physical_interface() {
|
||||
let other: Ipv4Range = "10.99.0.0/16".parse().unwrap();
|
||||
let overlay = [range(), other];
|
||||
let client = ClientRules {
|
||||
uid: 0,
|
||||
overlay: &overlay,
|
||||
};
|
||||
let text = pf_rules("utun5", &egress(), &[], Some(&client));
|
||||
assert!(
|
||||
text.starts_with(
|
||||
"table <tsunagi_overlay_utun5> const { 10.13.37.0/24, 10.99.0.0/16 }\n"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("nat on en0 inet from (utun5) to any -> (en0)\n"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(
|
||||
"pass out quick on utun5 route-to (en0 192.168.1.1) inet proto { tcp udp } \
|
||||
from any to ! <tsunagi_overlay_utun5> user 0\n"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
// Only the agent's user, and only what is not the overlay.
|
||||
assert!(!text.contains("from any to any"));
|
||||
|
||||
let direct = DefaultRoute {
|
||||
interface: "ppp0".into(),
|
||||
gateway: None,
|
||||
};
|
||||
let text = pf_rules(
|
||||
"utun5",
|
||||
&direct,
|
||||
&[],
|
||||
Some(&ClientRules {
|
||||
uid: 501,
|
||||
overlay: &[],
|
||||
}),
|
||||
);
|
||||
assert!(text.contains("route-to ppp0 inet"), "{text}");
|
||||
assert!(text.contains("to any user 501"), "{text}");
|
||||
assert!(!text.contains("table"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_overlay_default_routes_are_halves_and_ipv6_is_rejected() {
|
||||
assert_eq!(
|
||||
route_add_args("0.0.0.0/1", "utun5").join(" "),
|
||||
"-q -n add -net 0.0.0.0/1 -interface utun5"
|
||||
);
|
||||
assert_eq!(
|
||||
route_del_args("128.0.0.0/1", "utun5").join(" "),
|
||||
"-q -n delete -net 128.0.0.0/1 -interface utun5"
|
||||
);
|
||||
assert_eq!(
|
||||
route_add_v6_args("8000::/1").join(" "),
|
||||
"-q -n add -inet6 -net 8000::/1 ::1 -reject"
|
||||
);
|
||||
assert_eq!(
|
||||
route_del_v6_args("::/1").join(" "),
|
||||
"-q -n delete -inet6 -net ::/1 ::1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pf_token_is_found_in_either_stream() {
|
||||
assert_eq!(parse_token("Token : 1234"), Some("1234".into()));
|
||||
assert_eq!(parse_token("token: 9"), Some("9".into()));
|
||||
assert_eq!(parse_token("pf enabled"), None);
|
||||
assert_eq!(parse_token("Token : x; rm"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offering_loads_the_anchor_and_reads_but_never_writes_forwarding() {
|
||||
let fake = Fake::new();
|
||||
let token = Mutex::new(None);
|
||||
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
|
||||
assert_eq!(report.offer, vec![(range(), RuleOutcome::Applied)]);
|
||||
assert_eq!(report.forwarding, Some(true));
|
||||
assert_eq!(report.client, None);
|
||||
let calls = fake.calls();
|
||||
assert!(
|
||||
calls
|
||||
.iter()
|
||||
.any(|c| c == "Pfctl -q -a com.apple/tsunagi-exit-utun5 -f -"),
|
||||
"{calls:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
fake.stdins.borrow()[0],
|
||||
"nat on en0 inet from 10.13.37.0/24 to any -> (en0)\n\
|
||||
pass in quick on utun5 inet from 10.13.37.0/24 to ! 10.13.37.0/24\n"
|
||||
);
|
||||
// sysctl is only ever asked, never told.
|
||||
assert!(
|
||||
calls
|
||||
.iter()
|
||||
.filter(|c| c.starts_with("Sysctl"))
|
||||
.all(|c| c == "Sysctl -n net.inet.ip.forwarding"),
|
||||
"{calls:?}"
|
||||
);
|
||||
// No client routes were added for an offer alone.
|
||||
assert_eq!(fake.count(" add "), 0);
|
||||
assert_eq!(token.lock().unwrap().as_deref(), Some("4242"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_off_is_reported_and_unreadable_is_none() {
|
||||
let mut fake = Fake::new();
|
||||
fake.forwarding = "0\n";
|
||||
let token = Mutex::new(None);
|
||||
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
|
||||
assert_eq!(report.forwarding, Some(false));
|
||||
fake.forwarding = "junk";
|
||||
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
|
||||
assert_eq!(report.forwarding, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_client_rules_are_loaded_before_the_routes_that_need_them() {
|
||||
let fake = Fake::new();
|
||||
let token = Mutex::new(None);
|
||||
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(false, true));
|
||||
assert_eq!(report.client, Some(RuleOutcome::Applied));
|
||||
assert!(report.offer.is_empty());
|
||||
let load = fake.position("-f -").unwrap();
|
||||
let add = fake.position(" add -net 0.0.0.0/1").unwrap();
|
||||
assert!(load < add, "{:?}", fake.calls());
|
||||
assert!(fake.position("add -net 128.0.0.0/1").is_some());
|
||||
assert!(fake.position("-inet6 -net ::/1 ::1 -reject").is_some());
|
||||
assert!(fake.stdins.borrow()[0].contains("user 0"));
|
||||
// The default route was read before any overlay route existed.
|
||||
assert!(fake.position("-n get default").unwrap() < add);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_routes_are_added_when_the_rules_did_not_load() {
|
||||
let mut fake = Fake::new();
|
||||
fake.pf_load_fails = true;
|
||||
let token = Mutex::new(None);
|
||||
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, true));
|
||||
let Some(RuleOutcome::Failed(why)) = report.client else {
|
||||
panic!("{report:?}");
|
||||
};
|
||||
assert!(why.contains("cannot load the pf rules"), "{why}");
|
||||
assert!(why.contains("Syntax error"), "{why}");
|
||||
assert!(matches!(report.offer[0].1, RuleOutcome::Failed(_)));
|
||||
// Nothing sent the host's traffic into a tunnel it could not exempt
|
||||
// the agent from.
|
||||
assert_eq!(fake.count(" add "), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_route_is_a_failure_and_leaves_nothing_behind() {
|
||||
let mut fake = Fake::new();
|
||||
fake.route_add_fails = true;
|
||||
let token = Mutex::new(None);
|
||||
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(false, true));
|
||||
let Some(RuleOutcome::Failed(why)) = report.client else {
|
||||
panic!("{report:?}");
|
||||
};
|
||||
assert!(why.contains("File exists"), "{why}");
|
||||
// The cleanup after the failed add removed whatever did land.
|
||||
let last_add = fake
|
||||
.calls()
|
||||
.iter()
|
||||
.rposition(|c| c.contains(" add "))
|
||||
.unwrap();
|
||||
assert!(
|
||||
fake.calls()[last_add..]
|
||||
.iter()
|
||||
.any(|c| c.contains("delete"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_pfctl_or_default_route_says_what_is_wrong() {
|
||||
let mut fake = Fake::new();
|
||||
fake.pf_missing = true;
|
||||
let token = Mutex::new(None);
|
||||
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
|
||||
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
|
||||
panic!("{report:?}");
|
||||
};
|
||||
assert!(why.contains("/sbin/pfctl not found"), "{why}");
|
||||
|
||||
let mut fake = Fake::new();
|
||||
fake.default_route = None;
|
||||
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
|
||||
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
|
||||
panic!("{report:?}");
|
||||
};
|
||||
assert!(why.contains("default route"), "{why}");
|
||||
assert_eq!(fake.count("-f -"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_default_route_through_the_overlay_is_refused() {
|
||||
let mut fake = Fake::new();
|
||||
fake.default_route = Some("gateway: 10.13.37.1\ninterface: utun5\n".into());
|
||||
let token = Mutex::new(None);
|
||||
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
|
||||
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
|
||||
panic!("{report:?}");
|
||||
};
|
||||
assert!(why.contains("already goes through"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pf_is_enabled_once_and_released_once_on_clear() {
|
||||
let fake = Fake::new();
|
||||
let token = Mutex::new(None);
|
||||
let run = |t, a: &[String], s: Option<&str>| fake.run(t, a, s);
|
||||
apply_plan(&run, &token, &plan(true, false));
|
||||
apply_plan(&run, &token, &plan(true, true));
|
||||
assert_eq!(fake.count("Pfctl -E"), 1, "{:?}", fake.calls());
|
||||
|
||||
clear_all(&run, &token, "utun5");
|
||||
assert_eq!(fake.count("Pfctl -X 4242"), 1);
|
||||
assert!(
|
||||
fake.position("-a com.apple/tsunagi-exit-utun5 -F all")
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
fake.position("delete -net 0.0.0.0/1 -interface utun5")
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(*token.lock().unwrap(), None);
|
||||
// A second clear has nothing to release and still succeeds.
|
||||
clear_all(&run, &token, "utun5");
|
||||
assert_eq!(fake.count("Pfctl -X"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_the_client_removes_its_routes() {
|
||||
let fake = Fake::new();
|
||||
let token = Mutex::new(None);
|
||||
let run = |t, a: &[String], s: Option<&str>| fake.run(t, a, s);
|
||||
apply_plan(&run, &token, &plan(true, false));
|
||||
assert!(fake.position("delete -net 0.0.0.0/1").is_some());
|
||||
assert_eq!(fake.count(" add "), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
//! Host rules that let a game's LAN discovery reach the overlay interface.
|
||||
//!
|
||||
//! Broadcast fanout happens at local TUN ingress (see [`super::broadcast`]),
|
||||
//! so a limited broadcast only reaches Tsunagi if the operating system routes
|
||||
//! it through the overlay interface, and a discovery *reply* only reaches the
|
||||
//! game if the host firewall lets it in. Left to the user that is two manual
|
||||
//! steps per host that nobody remembers, so when a network's broadcast is on
|
||||
//! the agent installs exactly these two things for the interface it owns:
|
||||
//!
|
||||
//! * a `255.255.255.255/32` route through the overlay interface, with the
|
||||
//! overlay address as its preferred source, so the game's discovery packet
|
||||
//! leaves with a source address the router recognises;
|
||||
//! * an inbound firewall allowance for **UDP only**, from the overlay range
|
||||
//! only, on that interface only. Replies to a broadcast come from a
|
||||
//! different address than the one the request was sent to, so a stateful
|
||||
//! firewall never matches them to the request. Opening every protocol
|
||||
//! would expose services such as SSH to every member of the network, which
|
||||
//! is not what a game needs.
|
||||
//!
|
||||
//! # Why this is allowed here
|
||||
//!
|
||||
//! The architecture says plugins never touch routing or firewall settings,
|
||||
//! and nothing a peer announces becomes an OS setting. This is neither: it is
|
||||
//! the system level, which owns the interface, configuring that interface from
|
||||
//! values it computed itself (the interface name is derived locally, the
|
||||
//! address and range come from signed state it already verified). Every object
|
||||
//! is tagged `tsunagi:<interface>`, reversible, and removed when broadcast is
|
||||
//! turned off, the network leaves, or the agent stops. A crash can leave a
|
||||
//! firewall rule behind; the next apply for the same interface removes it.
|
||||
//!
|
||||
//! # Failure is contained
|
||||
//!
|
||||
//! Neither step can stop the agent. Each reports its own outcome, so a missing
|
||||
//! firewall tool still leaves the route in place and says what is missing.
|
||||
//!
|
||||
//! # Why a trait
|
||||
//!
|
||||
//! Linux, Windows and the tests do this very differently, while *what* to
|
||||
//! install is the same everywhere and lives in [`BroadcastRulesPlan`] and
|
||||
//! [`rules_summary`], tested on every platform.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::state::Ipv4Range;
|
||||
|
||||
mod exit;
|
||||
pub use exit::{
|
||||
ExitHostPlan, ExitHostReport, ExitHostRules, MockExitRules, exit_tag, exit_tag_prefix,
|
||||
offer_detail,
|
||||
};
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
mod linux;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
mod linux_exit;
|
||||
#[cfg(all(test, feature = "tun-device", target_os = "linux"))]
|
||||
mod linux_exit_kernel;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub(in crate::overlay) mod linux_exit_mark;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use linux::LinuxHostRules;
|
||||
|
||||
// Compiled everywhere so the scripts it builds are tested everywhere; only
|
||||
// the type that runs them is exported on Windows.
|
||||
mod windows;
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
mod windows_exit;
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use windows::WindowsHostRules;
|
||||
|
||||
// Compiled everywhere so its rule text and parsers are tested everywhere.
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
mod macos;
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
mod macos_exit;
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use macos::MacosHostRules;
|
||||
|
||||
/// What the host should be told for one interface.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BroadcastRulesPlan {
|
||||
/// The overlay interface. Derived locally from the network id, never from
|
||||
/// anything a peer said.
|
||||
pub interface: String,
|
||||
/// This agent's overlay address, the route's preferred source.
|
||||
pub source: Ipv4Addr,
|
||||
/// The overlay range inbound UDP is admitted from.
|
||||
pub range: Ipv4Range,
|
||||
}
|
||||
|
||||
impl BroadcastRulesPlan {
|
||||
/// The tag every object carries, so it can be found again and nothing
|
||||
/// else is ever touched.
|
||||
pub fn tag(&self) -> String {
|
||||
tag_for(&self.interface)
|
||||
}
|
||||
}
|
||||
|
||||
/// The tag for an interface's objects.
|
||||
pub fn tag_for(interface: &str) -> String {
|
||||
format!("tsunagi:{interface}")
|
||||
}
|
||||
|
||||
/// What happened to one of the two host objects.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RuleOutcome {
|
||||
/// It is in place.
|
||||
Applied,
|
||||
/// It is not, with a reason a person can act on.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl RuleOutcome {
|
||||
/// Whether the object is in place.
|
||||
pub fn is_applied(&self) -> bool {
|
||||
matches!(self, Self::Applied)
|
||||
}
|
||||
}
|
||||
|
||||
/// What applying a plan achieved.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BroadcastRulesReport {
|
||||
/// What was asked for.
|
||||
pub plan: BroadcastRulesPlan,
|
||||
/// The `255.255.255.255` route.
|
||||
pub route: RuleOutcome,
|
||||
/// The inbound firewall allowance.
|
||||
pub firewall: RuleOutcome,
|
||||
}
|
||||
|
||||
impl BroadcastRulesReport {
|
||||
/// Whether both objects are in place.
|
||||
pub fn is_ok(&self) -> bool {
|
||||
self.route.is_applied() && self.firewall.is_applied()
|
||||
}
|
||||
|
||||
/// One line for `status`.
|
||||
pub fn summary(&self) -> String {
|
||||
rules_summary(&self.route, &self.firewall)
|
||||
}
|
||||
}
|
||||
|
||||
/// The status line for a pair of outcomes.
|
||||
pub fn rules_summary(route: &RuleOutcome, firewall: &RuleOutcome) -> String {
|
||||
match (route, firewall) {
|
||||
(RuleOutcome::Applied, RuleOutcome::Applied) => "ok (route + firewall)".to_string(),
|
||||
(route, firewall) => {
|
||||
let part = |what: &str, outcome: &RuleOutcome| match outcome {
|
||||
RuleOutcome::Applied => format!("{what} ok"),
|
||||
RuleOutcome::Failed(reason) => format!("{what} failed: {reason}"),
|
||||
};
|
||||
format!(
|
||||
"incomplete; {}; {}",
|
||||
part("route", route),
|
||||
part("firewall", firewall)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs and removes the host rules for an overlay interface.
|
||||
///
|
||||
/// Both calls are idempotent. Neither can fail as a whole: a step that cannot
|
||||
/// be done is reported in the [`BroadcastRulesReport`], because the caller's
|
||||
/// only useful response is to say so.
|
||||
pub trait BroadcastHostRules: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// A short name used in diagnostics.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Makes the host match the plan, replacing whatever this agent put there
|
||||
/// for the same interface before.
|
||||
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport>;
|
||||
|
||||
/// Removes what this agent put there for the interface. Succeeds when
|
||||
/// nothing is there: it runs on the shutdown path.
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>;
|
||||
|
||||
/// The exit-node rules that go with these, when the platform has them.
|
||||
///
|
||||
/// One object per host, because both halves need the same privileged
|
||||
/// helpers; the default is that this platform has none, which is said
|
||||
/// where it matters rather than pretended.
|
||||
fn exit_rules(&self) -> Option<&dyn ExitHostRules> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The platform has no implementation, which is said rather than pretended.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct UnsupportedHostRules;
|
||||
|
||||
impl BroadcastHostRules for UnsupportedHostRules {
|
||||
fn name(&self) -> &str {
|
||||
"unsupported"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
|
||||
Box::pin(async move {
|
||||
let reason = "host rules are not implemented on this platform; route \
|
||||
255.255.255.255 through the overlay interface and allow inbound \
|
||||
UDP from the overlay range yourself"
|
||||
.to_string();
|
||||
BroadcastRulesReport {
|
||||
plan: plan.clone(),
|
||||
route: RuleOutcome::Failed(reason.clone()),
|
||||
firewall: RuleOutcome::Failed(reason),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn clear<'a>(&'a self, _interface: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {})
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [`MockHostRules`] currently holds, per interface.
|
||||
pub type MockRulesState = std::collections::BTreeMap<String, BroadcastRulesPlan>;
|
||||
|
||||
/// An in-memory host, for tests: nothing is touched.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MockHostRules {
|
||||
exit: MockExitRules,
|
||||
state: Arc<Mutex<MockRulesState>>,
|
||||
calls: Arc<Mutex<Vec<String>>>,
|
||||
firewall_failure: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl MockHostRules {
|
||||
/// An empty host.
|
||||
pub fn new() -> Self {
|
||||
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.
|
||||
pub fn fail_firewall(&self, reason: impl Into<String>) {
|
||||
*lock(&self.firewall_failure) = Some(reason.into());
|
||||
}
|
||||
|
||||
/// What is installed now.
|
||||
pub fn installed(&self) -> MockRulesState {
|
||||
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 BroadcastHostRules for MockHostRules {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
fn exit_rules(&self) -> Option<&dyn ExitHostRules> {
|
||||
Some(&self.exit)
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
|
||||
Box::pin(async move {
|
||||
lock(&self.calls).push(format!("apply:{}", plan.interface));
|
||||
lock(&self.state).insert(plan.interface.clone(), plan.clone());
|
||||
let firewall = match lock(&self.firewall_failure).clone() {
|
||||
Some(reason) => RuleOutcome::Failed(reason),
|
||||
None => RuleOutcome::Applied,
|
||||
};
|
||||
BroadcastRulesReport {
|
||||
plan: plan.clone(),
|
||||
route: RuleOutcome::Applied,
|
||||
firewall,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
lock(&self.calls).push(format!("clear:{interface}"));
|
||||
lock(&self.state).remove(interface);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn plan() -> BroadcastRulesPlan {
|
||||
BroadcastRulesPlan {
|
||||
interface: "tsun0".into(),
|
||||
source: Ipv4Addr::new(10, 13, 37, 142),
|
||||
range: "10.13.37.0/24".parse().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_object_is_tagged_with_its_interface() {
|
||||
assert_eq!(plan().tag(), "tsunagi:tsun0");
|
||||
assert_eq!(tag_for("tsun7"), "tsunagi:tsun7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_says_which_half_is_missing() {
|
||||
assert_eq!(
|
||||
rules_summary(&RuleOutcome::Applied, &RuleOutcome::Applied),
|
||||
"ok (route + firewall)"
|
||||
);
|
||||
let line = rules_summary(
|
||||
&RuleOutcome::Applied,
|
||||
&RuleOutcome::Failed("iptables not found".into()),
|
||||
);
|
||||
assert!(line.contains("route ok"), "{line}");
|
||||
assert!(
|
||||
line.contains("firewall failed: iptables not found"),
|
||||
"{line}"
|
||||
);
|
||||
let report = BroadcastRulesReport {
|
||||
plan: plan(),
|
||||
route: RuleOutcome::Failed("denied".into()),
|
||||
firewall: RuleOutcome::Applied,
|
||||
};
|
||||
assert!(!report.is_ok());
|
||||
assert!(report.summary().contains("route failed: denied"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_mock_applies_replaces_and_clears_idempotently() {
|
||||
let rules = MockHostRules::new();
|
||||
let first = rules.apply(&plan()).await;
|
||||
assert!(first.is_ok());
|
||||
assert_eq!(rules.installed().len(), 1);
|
||||
|
||||
let mut moved = plan();
|
||||
moved.source = Ipv4Addr::new(10, 13, 37, 7);
|
||||
rules.apply(&moved).await;
|
||||
assert_eq!(rules.installed()["tsun0"].source, moved.source);
|
||||
|
||||
rules.clear("tsun0").await;
|
||||
rules.clear("tsun0").await;
|
||||
assert!(rules.installed().is_empty());
|
||||
assert_eq!(rules.calls().len(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unsupported_platform_reports_both_steps_failed() {
|
||||
let report = UnsupportedHostRules.apply(&plan()).await;
|
||||
assert!(!report.route.is_applied());
|
||||
assert!(!report.firewall.is_applied());
|
||||
UnsupportedHostRules.clear("tsun0").await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Broadcast host rules on Windows: a route and a Defender Firewall rule.
|
||||
//!
|
||||
//! Both are written with the `NetTCPIP` and `NetSecurity` PowerShell modules,
|
||||
//! run by absolute path from `%SystemRoot%` like the DNS publisher does: the
|
||||
//! in-process alternatives are `unsafe` IP Helper calls this crate forbids.
|
||||
//!
|
||||
//! * The route is `255.255.255.255/32` on the overlay interface, on-link, with
|
||||
//! metric 1, written to the active store only so it cannot outlive a reboot.
|
||||
//! Windows picks the interface's own address as the source of a packet sent
|
||||
//! along it; with several networks on one interface that is whichever
|
||||
//! address Windows treats as primary, which this cannot choose.
|
||||
//! * The firewall rule allows inbound UDP on that interface alias from the
|
||||
//! overlay range only, in every profile. Wintun adapters are usually
|
||||
//! classified as a public network, where inbound UDP is otherwise dropped.
|
||||
//! Its display name is the tag `tsunagi:<interface>`, which is how it is
|
||||
//! found again, replaced and removed.
|
||||
//!
|
||||
//! Every value in a script is one this agent produced: the interface name is
|
||||
//! derived from the network id and the range comes from signed state. They are
|
||||
//! still single-quoted and escaped, so nothing could be read as PowerShell
|
||||
//! rather than as data. Changing routes and firewall rules needs an elevated
|
||||
//! process, which is reported as its own cause.
|
||||
//!
|
||||
//! This module is compiled everywhere so its script building is tested
|
||||
//! everywhere. It could not be run against a real Windows host while it was
|
||||
//! written.
|
||||
|
||||
#![cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules};
|
||||
use super::windows_exit;
|
||||
use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome, tag_for};
|
||||
|
||||
/// A PowerShell single-quoted literal, with any embedded quote doubled.
|
||||
fn ps_literal(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
/// The step that makes the route match the plan.
|
||||
///
|
||||
/// A leftover route (from a crashed run, or for an old interface index) is
|
||||
/// removed first so the result is exactly one route.
|
||||
fn route_script(plan: &BroadcastRulesPlan) -> String {
|
||||
let alias = ps_literal(&plan.interface);
|
||||
format!(
|
||||
"$ErrorActionPreference = 'Stop'\n\
|
||||
$alias = {alias}\n\
|
||||
Get-NetRoute -DestinationPrefix '255.255.255.255/32' -InterfaceAlias $alias \
|
||||
-PolicyStore ActiveStore -ErrorAction SilentlyContinue | \
|
||||
Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
|
||||
New-NetRoute -DestinationPrefix '255.255.255.255/32' -InterfaceAlias $alias \
|
||||
-NextHop '0.0.0.0' -RouteMetric 1 -PolicyStore ActiveStore | Out-Null\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// The step that makes the firewall hold exactly one rule for the interface.
|
||||
fn firewall_script(plan: &BroadcastRulesPlan) -> String {
|
||||
let alias = ps_literal(&plan.interface);
|
||||
let tag = ps_literal(&plan.tag());
|
||||
let range = ps_literal(&plan.range.to_string());
|
||||
format!(
|
||||
"$ErrorActionPreference = 'Stop'\n\
|
||||
$alias = {alias}\n\
|
||||
$tag = {tag}\n\
|
||||
Get-NetFirewallRule -DisplayName $tag -ErrorAction SilentlyContinue | \
|
||||
Remove-NetFirewallRule -ErrorAction SilentlyContinue\n\
|
||||
New-NetFirewallRule -DisplayName $tag -Direction Inbound -Action Allow -Protocol UDP \
|
||||
-InterfaceAlias $alias -RemoteAddress {range} -Profile Any | Out-Null\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// The script that removes both, ignoring what is not there.
|
||||
fn clear_script(interface: &str) -> String {
|
||||
let alias = ps_literal(interface);
|
||||
let tag = ps_literal(&tag_for(interface));
|
||||
format!(
|
||||
"$ErrorActionPreference = 'SilentlyContinue'\n\
|
||||
Get-NetRoute -DestinationPrefix '255.255.255.255/32' -InterfaceAlias {alias} \
|
||||
-PolicyStore ActiveStore | Remove-NetRoute -Confirm:$false\n\
|
||||
Get-NetFirewallRule -DisplayName {tag} | Remove-NetFirewallRule\n\
|
||||
exit 0\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Turns what PowerShell said into a cause a person can act on.
|
||||
fn classify(what: &str, text: &str) -> String {
|
||||
let lower = text.to_ascii_lowercase();
|
||||
if lower.contains("access is denied")
|
||||
|| lower.contains("requires elevation")
|
||||
|| lower.contains("run as administrator")
|
||||
|| lower.contains("permissiondenied")
|
||||
{
|
||||
format!("{what}: Windows refused; run tsng from an elevated (administrator) process")
|
||||
} else if lower.contains("is not recognized") || lower.contains("commandnotfound") {
|
||||
format!("{what}: the NetTCPIP/NetSecurity PowerShell modules are not available: {text}")
|
||||
} else if text.is_empty() {
|
||||
format!("{what}: failed without saying why")
|
||||
} else {
|
||||
format!("{what}: {text}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The absolute path to Windows PowerShell, so nothing on `PATH` can stand in
|
||||
/// for it.
|
||||
fn powershell_path() -> std::path::PathBuf {
|
||||
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into());
|
||||
std::path::Path::new(&root)
|
||||
.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe")
|
||||
}
|
||||
|
||||
/// Runs a script and returns what it said on failure.
|
||||
fn run_powershell(script: &str) -> Result<(), String> {
|
||||
run_powershell_output(script).map(|_| ())
|
||||
}
|
||||
|
||||
/// Runs a script and returns what it printed, or what it said on failure.
|
||||
fn run_powershell_output(script: &str) -> Result<String, String> {
|
||||
let output = std::process::Command::new(powershell_path())
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script,
|
||||
])
|
||||
.output()
|
||||
.map_err(|err| format!("could not run PowerShell: {err}"))?;
|
||||
if output.status.success() {
|
||||
return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let text = if stderr.trim().is_empty() {
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
} else {
|
||||
stderr.trim().to_string()
|
||||
};
|
||||
Err(text)
|
||||
}
|
||||
|
||||
async fn step(what: &'static str, script: String) -> RuleOutcome {
|
||||
match tokio::task::spawn_blocking(move || run_powershell(&script)).await {
|
||||
Ok(Ok(())) => RuleOutcome::Applied,
|
||||
Ok(Err(text)) => RuleOutcome::Failed(classify(what, &text)),
|
||||
Err(err) => RuleOutcome::Failed(format!("{what}: the task failed: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Windows implementation, through PowerShell.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WindowsHostRules;
|
||||
|
||||
impl WindowsHostRules {
|
||||
/// Creates it. Nothing is run until [`BroadcastHostRules::apply`].
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
/// The IPv4 addresses a host name resolves to; empty when it does not.
|
||||
fn resolve_host(host: &str) -> Vec<std::net::Ipv4Addr> {
|
||||
use std::net::{IpAddr, ToSocketAddrs};
|
||||
(host, 443)
|
||||
.to_socket_addrs()
|
||||
.map(|addrs| {
|
||||
addrs
|
||||
.filter_map(|addr| match addr.ip() {
|
||||
IpAddr::V4(ip) => Some(ip),
|
||||
IpAddr::V6(_) => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl ExitHostRules for WindowsHostRules {
|
||||
fn needs_bypass(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
|
||||
Box::pin(async move {
|
||||
let job = plan.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
windows_exit::apply_plan(
|
||||
&|script| run_powershell_output(script),
|
||||
&resolve_host,
|
||||
&job,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
let failed = RuleOutcome::Failed(format!("the task failed: {err}"));
|
||||
ExitHostReport {
|
||||
offer: plan
|
||||
.offer
|
||||
.iter()
|
||||
.map(|range| (*range, failed.clone()))
|
||||
.collect(),
|
||||
forwarding: None,
|
||||
client: plan.client.then_some(failed),
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
let name = interface.to_string();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
windows_exit::clear_all(&|script| run_powershell_output(script), &name)
|
||||
})
|
||||
.await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl BroadcastHostRules for WindowsHostRules {
|
||||
fn name(&self) -> &str {
|
||||
"windows-netsecurity"
|
||||
}
|
||||
|
||||
fn exit_rules(&self) -> Option<&dyn ExitHostRules> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
|
||||
Box::pin(async move {
|
||||
let route = step("route", route_script(plan)).await;
|
||||
let firewall = step("firewall", firewall_script(plan)).await;
|
||||
BroadcastRulesReport {
|
||||
plan: plan.clone(),
|
||||
route,
|
||||
firewall,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
if let RuleOutcome::Failed(reason) = step("clear", clear_script(interface)).await {
|
||||
tracing::warn!(
|
||||
interface,
|
||||
"cannot remove the broadcast host rules: {reason}"
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn plan() -> BroadcastRulesPlan {
|
||||
BroadcastRulesPlan {
|
||||
interface: "tsun0".into(),
|
||||
source: Ipv4Addr::new(10, 13, 37, 142),
|
||||
range: "10.13.37.0/24".parse().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quote_in_a_value_cannot_escape_the_literal() {
|
||||
assert_eq!(ps_literal("a'b"), "'a''b'");
|
||||
let mut hostile = plan();
|
||||
hostile.interface = "x'; Remove-Item C:\\ #".into();
|
||||
let script = route_script(&hostile);
|
||||
assert!(
|
||||
script.contains("$alias = 'x''; Remove-Item C:\\ #'"),
|
||||
"{script}"
|
||||
);
|
||||
assert!(firewall_script(&hostile).contains("'x''; Remove-Item C:\\ #'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_route_is_on_link_metric_one_in_the_active_store_and_replaces_its_own() {
|
||||
let script = route_script(&plan());
|
||||
assert!(script.contains("'255.255.255.255/32'"));
|
||||
assert!(script.contains("-NextHop '0.0.0.0'"));
|
||||
assert!(script.contains("-RouteMetric 1"));
|
||||
assert!(script.contains("-PolicyStore ActiveStore"));
|
||||
assert!(
|
||||
script.find("Remove-NetRoute").unwrap() < script.find("New-NetRoute").unwrap(),
|
||||
"a stale route goes before the new one:\n{script}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_firewall_rule_is_udp_only_from_the_overlay_range_on_this_interface() {
|
||||
let script = firewall_script(&plan());
|
||||
assert!(script.contains("-Protocol UDP"));
|
||||
assert!(script.contains("-Direction Inbound -Action Allow"));
|
||||
assert!(script.contains("-InterfaceAlias $alias"));
|
||||
assert!(script.contains("-RemoteAddress '10.13.37.0/24'"));
|
||||
assert!(script.contains("$tag = 'tsunagi:tsun0'"));
|
||||
assert!(!script.contains("-Protocol Any") && !script.contains("-Protocol TCP"));
|
||||
assert!(
|
||||
script.find("Remove-NetFirewallRule").unwrap()
|
||||
< script.find("New-NetFirewallRule").unwrap(),
|
||||
"{script}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_touches_only_our_route_and_our_tagged_rule() {
|
||||
let script = clear_script("tsun0");
|
||||
assert!(script.contains("-InterfaceAlias 'tsun0'"));
|
||||
assert!(script.contains("Get-NetFirewallRule -DisplayName 'tsunagi:tsun0'"));
|
||||
assert!(script.contains("exit 0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_names_elevation_and_a_missing_module_names_the_module() {
|
||||
assert!(classify("route", "Access is denied").contains("elevated"));
|
||||
assert!(
|
||||
classify(
|
||||
"firewall",
|
||||
"The term 'New-NetFirewallRule' is not recognized"
|
||||
)
|
||||
.contains("modules are not available")
|
||||
);
|
||||
assert_eq!(classify("route", "boom"), "route: boom");
|
||||
assert!(classify("route", "").contains("without saying why"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_is_found_by_absolute_path() {
|
||||
let path = powershell_path();
|
||||
assert!(path.ends_with("powershell.exe"));
|
||||
assert!(path.to_string_lossy().contains("System32"), "{path:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
//! Exit-node host rules on Windows: WinNAT through PowerShell.
|
||||
//!
|
||||
//! Compiled on every platform so the scripts and the plan logic are tested
|
||||
//! everywhere; only `WindowsHostRules` runs them, and only on Windows. It
|
||||
//! could not be run against a real Windows host while it was written.
|
||||
//!
|
||||
//! # Offering
|
||||
//!
|
||||
//! One NAT object per offered range, named with the usual tag
|
||||
//! `tsunagi-exit:<if>:<range>`:
|
||||
//!
|
||||
//! ```text
|
||||
//! New-NetNat -Name <tag> -InternalIPInterfaceAddressPrefix <range>
|
||||
//! ```
|
||||
//!
|
||||
//! WinNAT translates that range out of whichever interface the traffic leaves
|
||||
//! by, which is the Windows counterpart of the Linux masquerade. Windows
|
||||
//! forwards per interface, and the egress is not known here, so forwarding is
|
||||
//! only *read* for the overlay interface; turning it on for the interfaces
|
||||
//! involved is the owner's decision (`Set-NetIPInterface -Forwarding Enabled`).
|
||||
//! Some Windows versions allow a single NAT per host: a second offered range
|
||||
//! then reports its own failure.
|
||||
//!
|
||||
//! # Using
|
||||
//!
|
||||
//! Windows has no per-user or per-process routing, so the Linux scheme (an
|
||||
//! exemption for the agent's user) does not carry over. Instead the agent's
|
||||
//! own underlay traffic is exempted by *destination*:
|
||||
//!
|
||||
//! ```text
|
||||
//! New-NetRoute 0.0.0.0/1 on the overlay interface, metric 1
|
||||
//! New-NetRoute 128.0.0.0/1 on the overlay interface, metric 1
|
||||
//! New-NetRoute <ip>/32 via the physical default gateway, metric 7373
|
||||
//! ```
|
||||
//!
|
||||
//! The halves out-rank the default route without replacing it, so every more
|
||||
//! specific route (LAN, another VPN) keeps working. A `/32` per address the
|
||||
//! agent talks to directly (the verified direct paths of its connections, and
|
||||
//! the relays it uses, resolved when the rules are applied) keeps that traffic
|
||||
//! on the physical network instead of looping into the tunnel it carries. The
|
||||
//! metric 7373 marks those routes as ours, which is how stale ones are found
|
||||
//! and removed. Everything is written to the active store, so a reboot clears
|
||||
//! it. IPv6 is blackholed with `::/1` and `8000::/1` on the overlay interface,
|
||||
//! which has no IPv6: applications fall back to IPv4 after a delay, where
|
||||
//! Linux and macOS answer at once. The bypass follows the agent's paths as
|
||||
//! they change; a path that appears after the last apply is reached through
|
||||
//! the tunnel until the next one.
|
||||
//!
|
||||
//! The bypass routes are written before the halves, and removed after them.
|
||||
|
||||
use super::RuleOutcome;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use super::exit::{ExitHostPlan, ExitHostReport, exit_tag, exit_tag_prefix};
|
||||
use crate::state::Ipv4Range;
|
||||
|
||||
/// The route metric that marks a bypass route as this agent's.
|
||||
const BYPASS_METRIC: u32 = 7373;
|
||||
|
||||
/// Runs a PowerShell script and returns what it printed.
|
||||
pub(super) type RunScript<'a> = &'a dyn Fn(&str) -> Result<String, String>;
|
||||
|
||||
/// Resolves a host name to its IPv4 addresses.
|
||||
pub(super) type Resolve<'a> = &'a dyn Fn(&str) -> Vec<Ipv4Addr>;
|
||||
|
||||
/// A PowerShell single-quoted literal, with any embedded quote doubled.
|
||||
fn ps_literal(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
/// Creates the NAT of one range, replacing one this agent made before.
|
||||
fn nat_script(interface: &str, range: &Ipv4Range) -> String {
|
||||
let name = ps_literal(&exit_tag(interface, range));
|
||||
let prefix = ps_literal(&range.to_string());
|
||||
format!(
|
||||
"$ErrorActionPreference = 'Stop'\n\
|
||||
$name = {name}\n\
|
||||
Get-NetNat -Name $name -ErrorAction SilentlyContinue | \
|
||||
Remove-NetNat -Confirm:$false -ErrorAction SilentlyContinue\n\
|
||||
New-NetNat -Name $name -InternalIPInterfaceAddressPrefix {prefix} | Out-Null\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Removes this interface's NAT objects except those named in `keep`.
|
||||
fn prune_script(interface: &str, keep: &[Ipv4Range]) -> String {
|
||||
let prefix = ps_literal(&exit_tag_prefix(interface));
|
||||
let keep: Vec<String> = keep
|
||||
.iter()
|
||||
.map(|range| ps_literal(&exit_tag(interface, range)))
|
||||
.collect();
|
||||
format!(
|
||||
"$ErrorActionPreference = 'SilentlyContinue'\n\
|
||||
$prefix = {prefix}\n\
|
||||
$keep = @({})\n\
|
||||
Get-NetNat | Where-Object {{ $_.Name.StartsWith($prefix) -and \
|
||||
($keep -notcontains $_.Name) }} | Remove-NetNat -Confirm:$false\n\
|
||||
exit 0\n",
|
||||
keep.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
/// Prints `Enabled` or `Disabled` for the interface's IPv4 forwarding.
|
||||
fn forwarding_script(interface: &str) -> String {
|
||||
let alias = ps_literal(interface);
|
||||
format!(
|
||||
"$ErrorActionPreference = 'Stop'\n\
|
||||
(Get-NetIPInterface -InterfaceAlias {alias} -AddressFamily IPv4).Forwarding\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Prints `<interface index> <next hop>` of the best default route that is
|
||||
/// not through the overlay interface.
|
||||
fn gateway_script(interface: &str) -> String {
|
||||
let alias = ps_literal(interface);
|
||||
format!(
|
||||
"$ErrorActionPreference = 'Stop'\n\
|
||||
$alias = {alias}\n\
|
||||
$best = Get-NetRoute -DestinationPrefix '0.0.0.0/0' -AddressFamily IPv4 \
|
||||
-PolicyStore ActiveStore | Where-Object {{ $_.InterfaceAlias -ne $alias -and \
|
||||
$_.NextHop -ne '0.0.0.0' }} | Sort-Object {{ $_.RouteMetric + \
|
||||
(Get-NetIPInterface -InterfaceIndex $_.InterfaceIndex -AddressFamily IPv4).InterfaceMetric }} | \
|
||||
Select-Object -First 1\n\
|
||||
if (-not $best) {{ throw 'the host has no default gateway outside the overlay' }}\n\
|
||||
\"$($best.InterfaceIndex) $($best.NextHop)\"\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// The default gateway as the script above printed it.
|
||||
fn parse_gateway(text: &str) -> Option<(u32, Ipv4Addr)> {
|
||||
let mut words = text.split_whitespace();
|
||||
let index = words.next()?.parse().ok()?;
|
||||
let hop = words.next()?.parse().ok()?;
|
||||
words.next().is_none().then_some((index, hop))
|
||||
}
|
||||
|
||||
/// Replaces the bypass routes with one per address, through the gateway.
|
||||
fn bypass_script(index: u32, gateway: Ipv4Addr, addresses: &[Ipv4Addr]) -> String {
|
||||
let list: Vec<String> = addresses
|
||||
.iter()
|
||||
.map(|address| ps_literal(&address.to_string()))
|
||||
.collect();
|
||||
format!(
|
||||
"$ErrorActionPreference = 'Stop'\n\
|
||||
Get-NetRoute -AddressFamily IPv4 -PolicyStore ActiveStore -ErrorAction SilentlyContinue | \
|
||||
Where-Object {{ $_.RouteMetric -eq {BYPASS_METRIC} -and $_.DestinationPrefix.EndsWith('/32') }} | \
|
||||
Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
|
||||
foreach ($ip in @({})) {{\n\
|
||||
New-NetRoute -DestinationPrefix \"$ip/32\" -InterfaceIndex {index} -NextHop '{gateway}' \
|
||||
-RouteMetric {BYPASS_METRIC} -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Out-Null\n\
|
||||
}}\n",
|
||||
list.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
/// Sends everything else through the overlay interface.
|
||||
fn halves_script(interface: &str) -> String {
|
||||
let alias = ps_literal(interface);
|
||||
format!(
|
||||
"$ErrorActionPreference = 'Stop'\n\
|
||||
$alias = {alias}\n\
|
||||
foreach ($p in '0.0.0.0/1', '128.0.0.0/1') {{\n\
|
||||
Get-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -PolicyStore ActiveStore \
|
||||
-ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
|
||||
New-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -NextHop '0.0.0.0' \
|
||||
-RouteMetric 1 -PolicyStore ActiveStore | Out-Null\n\
|
||||
}}\n\
|
||||
foreach ($p in '::/1', '8000::/1') {{\n\
|
||||
Get-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -PolicyStore ActiveStore \
|
||||
-ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
|
||||
New-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -NextHop '::' \
|
||||
-RouteMetric 1 -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Out-Null\n\
|
||||
}}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Removes the halves and every bypass route. What is not there is the state
|
||||
/// wanted.
|
||||
fn remove_client_script(interface: &str) -> String {
|
||||
let alias = ps_literal(interface);
|
||||
format!(
|
||||
"$ErrorActionPreference = 'SilentlyContinue'\n\
|
||||
$alias = {alias}\n\
|
||||
foreach ($p in '0.0.0.0/1', '128.0.0.0/1', '::/1', '8000::/1') {{\n\
|
||||
Get-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -PolicyStore ActiveStore | \
|
||||
Remove-NetRoute -Confirm:$false\n\
|
||||
}}\n\
|
||||
Get-NetRoute -AddressFamily IPv4 -PolicyStore ActiveStore | \
|
||||
Where-Object {{ $_.RouteMetric -eq {BYPASS_METRIC} -and $_.DestinationPrefix.EndsWith('/32') }} | \
|
||||
Remove-NetRoute -Confirm:$false\n\
|
||||
exit 0\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Every address to keep direct: the ones given and what the names resolve
|
||||
/// to, sorted and without duplicates.
|
||||
fn bypass_addresses(
|
||||
resolve: Resolve<'_>,
|
||||
addresses: &[Ipv4Addr],
|
||||
hosts: &[String],
|
||||
) -> Vec<Ipv4Addr> {
|
||||
let mut all: std::collections::BTreeSet<Ipv4Addr> = addresses.iter().copied().collect();
|
||||
for host in hosts {
|
||||
all.extend(resolve(host));
|
||||
}
|
||||
all.into_iter()
|
||||
.filter(|a| !a.is_loopback() && !a.is_unspecified() && !a.is_multicast())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn apply_client(
|
||||
run: RunScript<'_>,
|
||||
resolve: Resolve<'_>,
|
||||
plan: &ExitHostPlan,
|
||||
) -> Result<(), String> {
|
||||
let interface = plan.interface.as_str();
|
||||
let gateway = run(&gateway_script(interface))?;
|
||||
let (index, hop) = parse_gateway(&gateway)
|
||||
.ok_or_else(|| format!("cannot read the default gateway from `{}`", gateway.trim()))?;
|
||||
let addresses = bypass_addresses(resolve, &plan.bypass, &plan.bypass_hosts);
|
||||
run(&bypass_script(index, hop, &addresses))?;
|
||||
if let Err(why) = run(&halves_script(interface)) {
|
||||
// Do not leave a half-installed set: bypass routes alone are harmless
|
||||
// but the next apply starts clean.
|
||||
let _ = run(&remove_client_script(interface));
|
||||
return Err(why);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_forwarding(text: &str) -> Option<bool> {
|
||||
match text.trim().to_ascii_lowercase().as_str() {
|
||||
"enabled" | "1" => Some(true),
|
||||
"disabled" | "0" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the host match the plan.
|
||||
pub(super) fn apply_plan(
|
||||
run: RunScript<'_>,
|
||||
resolve: Resolve<'_>,
|
||||
plan: &ExitHostPlan,
|
||||
) -> ExitHostReport {
|
||||
let interface = plan.interface.as_str();
|
||||
// What an earlier plan left and this one no longer wants.
|
||||
let pruned = run(&prune_script(interface, &plan.offer));
|
||||
let offer = plan
|
||||
.offer
|
||||
.iter()
|
||||
.map(|range| {
|
||||
let outcome = match &pruned {
|
||||
Err(why) => RuleOutcome::Failed(why.clone()),
|
||||
Ok(_) => match run(&nat_script(interface, range)) {
|
||||
Ok(_) => RuleOutcome::Applied,
|
||||
Err(why) => RuleOutcome::Failed(why),
|
||||
},
|
||||
};
|
||||
(*range, outcome)
|
||||
})
|
||||
.collect();
|
||||
let client = if plan.client {
|
||||
Some(match apply_client(run, resolve, plan) {
|
||||
Ok(()) => RuleOutcome::Applied,
|
||||
Err(why) => RuleOutcome::Failed(why),
|
||||
})
|
||||
} else {
|
||||
// Whatever an earlier plan left must not stay in force.
|
||||
let _ = run(&remove_client_script(interface));
|
||||
None
|
||||
};
|
||||
let forwarding = if plan.offer.is_empty() {
|
||||
None
|
||||
} else {
|
||||
run(&forwarding_script(interface))
|
||||
.ok()
|
||||
.and_then(|text| parse_forwarding(&text))
|
||||
};
|
||||
ExitHostReport {
|
||||
offer,
|
||||
forwarding,
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes every NAT object of this agent's for the interface.
|
||||
pub(super) fn clear_all(run: RunScript<'_>, interface: &str) {
|
||||
if let Err(why) = run(&prune_script(interface, &[])) {
|
||||
tracing::warn!(interface, "cannot remove the exit node NAT: {why}");
|
||||
}
|
||||
if let Err(why) = run(&remove_client_script(interface)) {
|
||||
tracing::warn!(interface, "cannot remove the exit node routes: {why}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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 plan(client: bool) -> ExitHostPlan {
|
||||
ExitHostPlan {
|
||||
interface: "tsun0".into(),
|
||||
offer: vec![range()],
|
||||
client,
|
||||
overlay: vec![range()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_nat_is_named_with_the_tag_and_replaces_its_own() {
|
||||
let script = nat_script("tsun0", &range());
|
||||
assert!(
|
||||
script.contains("$name = 'tsunagi-exit:tsun0:10.13.37.0/24'"),
|
||||
"{script}"
|
||||
);
|
||||
assert!(script.contains("-InternalIPInterfaceAddressPrefix '10.13.37.0/24'"));
|
||||
assert!(
|
||||
script.find("Remove-NetNat").unwrap() < script.find("New-NetNat").unwrap(),
|
||||
"{script}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quote_in_a_value_cannot_escape_the_literal() {
|
||||
let script = nat_script("x'; Remove-Item C:\\ #", &range());
|
||||
assert!(script.contains("'tsunagi-exit:x''; Remove-Item C:\\ #:10.13.37.0/24'"));
|
||||
assert!(prune_script("x'y", &[]).contains("'tsunagi-exit:x''y:'"));
|
||||
assert!(forwarding_script("x'y").contains("'x''y'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pruning_keeps_wanted_ranges_and_only_selects_this_interfaces_nats() {
|
||||
let script = prune_script("tsun0", &[range()]);
|
||||
assert!(script.contains("$prefix = 'tsunagi-exit:tsun0:'"));
|
||||
assert!(script.contains("$keep = @('tsunagi-exit:tsun0:10.13.37.0/24')"));
|
||||
assert!(script.contains("StartsWith($prefix)"));
|
||||
assert!(script.contains("exit 0"));
|
||||
assert!(prune_script("tsun0", &[]).contains("$keep = @()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_text_is_read_never_written() {
|
||||
assert_eq!(parse_forwarding("Enabled\r\n"), Some(true));
|
||||
assert_eq!(parse_forwarding("Disabled"), Some(false));
|
||||
assert_eq!(parse_forwarding("what"), None);
|
||||
let script = forwarding_script("tsun0");
|
||||
assert!(script.contains("Get-NetIPInterface"));
|
||||
assert!(!script.contains("Set-NetIPInterface"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offering_prunes_then_creates_and_reads_forwarding() {
|
||||
let calls = RefCell::new(Vec::new());
|
||||
let run = |script: &str| -> Result<String, String> {
|
||||
calls.borrow_mut().push(script.to_string());
|
||||
Ok(if script.contains("Get-NetIPInterface") {
|
||||
"Disabled\n".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
})
|
||||
};
|
||||
let report = apply_plan(&run, &no_names, &plan(false));
|
||||
assert_eq!(report.offer, vec![(range(), RuleOutcome::Applied)]);
|
||||
assert_eq!(report.forwarding, Some(false));
|
||||
assert_eq!(report.client, None);
|
||||
let calls = calls.borrow();
|
||||
assert!(calls[0].contains("StartsWith"), "{calls:?}");
|
||||
assert!(calls[1].contains("New-NetNat"), "{calls:?}");
|
||||
// Without a client, the routes of an earlier plan are taken away.
|
||||
assert!(calls[2].contains("RouteMetric -eq 7373"), "{calls:?}");
|
||||
assert!(calls[3].contains("Get-NetIPInterface"), "{calls:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_nat_is_that_ranges_failure() {
|
||||
let run = |script: &str| -> Result<String, String> {
|
||||
if script.contains("New-NetNat") {
|
||||
Err("A NAT with this prefix already exists".into())
|
||||
} else {
|
||||
Ok(String::new())
|
||||
}
|
||||
};
|
||||
let report = apply_plan(&run, &no_names, &plan(false));
|
||||
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
|
||||
panic!("{report:?}");
|
||||
};
|
||||
assert!(why.contains("already exists"), "{why}");
|
||||
}
|
||||
|
||||
fn no_names(_: &str) -> Vec<Ipv4Addr> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn client_plan() -> ExitHostPlan {
|
||||
ExitHostPlan {
|
||||
interface: "tsun0".into(),
|
||||
offer: Vec::new(),
|
||||
client: true,
|
||||
overlay: vec![range()],
|
||||
bypass: vec![Ipv4Addr::new(138, 201, 61, 182)],
|
||||
bypass_hosts: vec!["relay.example".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gateway_is_read_from_the_script_output() {
|
||||
assert_eq!(
|
||||
parse_gateway("12 192.168.1.1\r\n"),
|
||||
Some((12, Ipv4Addr::new(192, 168, 1, 1)))
|
||||
);
|
||||
assert_eq!(parse_gateway(""), None);
|
||||
assert_eq!(parse_gateway("12"), None);
|
||||
assert_eq!(parse_gateway("12 gw"), None);
|
||||
assert_eq!(parse_gateway("12 1.1.1.1 extra"), None);
|
||||
let script = gateway_script("tsun0");
|
||||
assert!(script.contains("-ne $alias"));
|
||||
assert!(script.contains("-ne '0.0.0.0'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bypass_routes_are_marked_host_routes_through_the_physical_gateway() {
|
||||
let script = bypass_script(
|
||||
12,
|
||||
Ipv4Addr::new(192, 168, 1, 1),
|
||||
&[Ipv4Addr::new(1, 2, 3, 4), Ipv4Addr::new(5, 6, 7, 8)],
|
||||
);
|
||||
assert!(script.contains("@('1.2.3.4', '5.6.7.8')"), "{script}");
|
||||
assert!(script.contains("-InterfaceIndex 12 -NextHop '192.168.1.1'"));
|
||||
assert!(script.contains("-RouteMetric 7373"));
|
||||
assert!(script.contains("-PolicyStore ActiveStore"));
|
||||
// Stale marked routes go first, and only /32 ones with our metric.
|
||||
assert!(script.find("Remove-NetRoute").unwrap() < script.find("New-NetRoute").unwrap());
|
||||
assert!(script.contains("RouteMetric -eq 7373"));
|
||||
assert!(script.contains("EndsWith('/32')"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_halves_go_through_the_overlay_and_ipv6_is_blackholed() {
|
||||
let script = halves_script("tsun0");
|
||||
assert!(script.contains("'0.0.0.0/1', '128.0.0.0/1'"));
|
||||
assert!(script.contains("-InterfaceAlias $alias -NextHop '0.0.0.0'"));
|
||||
assert!(script.contains("'::/1', '8000::/1'"));
|
||||
assert!(script.contains("-PolicyStore ActiveStore"));
|
||||
assert!(
|
||||
!script.contains("0.0.0.0/0"),
|
||||
"the default route is left alone"
|
||||
);
|
||||
assert!(halves_script("x'y").contains("'x''y'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addresses_are_resolved_merged_and_cleaned() {
|
||||
let resolve = |host: &str| -> Vec<Ipv4Addr> {
|
||||
match host {
|
||||
"relay.example" => vec![Ipv4Addr::new(9, 9, 9, 9), Ipv4Addr::new(1, 2, 3, 4)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
let all = bypass_addresses(
|
||||
&resolve,
|
||||
&[
|
||||
Ipv4Addr::new(1, 2, 3, 4),
|
||||
Ipv4Addr::LOCALHOST,
|
||||
Ipv4Addr::UNSPECIFIED,
|
||||
],
|
||||
&["relay.example".into(), "gone.example".into()],
|
||||
);
|
||||
assert_eq!(
|
||||
all,
|
||||
vec![Ipv4Addr::new(1, 2, 3, 4), Ipv4Addr::new(9, 9, 9, 9)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bypass_is_written_before_the_halves_that_need_it() {
|
||||
let calls = RefCell::new(Vec::new());
|
||||
let run = |script: &str| -> Result<String, String> {
|
||||
calls.borrow_mut().push(script.to_string());
|
||||
Ok(if script.contains("Select-Object -First 1") {
|
||||
"12 192.168.1.1\n".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
})
|
||||
};
|
||||
let resolve = |_: &str| vec![Ipv4Addr::new(9, 9, 9, 9)];
|
||||
let report = apply_plan(&run, &resolve, &client_plan());
|
||||
assert_eq!(report.client, Some(RuleOutcome::Applied));
|
||||
let calls = calls.borrow();
|
||||
let at = |needle: &str| calls.iter().position(|c| c.contains(needle)).unwrap();
|
||||
assert!(at("Select-Object -First 1") < at("-RouteMetric 7373"));
|
||||
assert!(at("-RouteMetric 7373") < at("'0.0.0.0/1', '128.0.0.0/1'"));
|
||||
let bypass = &calls[at("-RouteMetric 7373")];
|
||||
assert!(
|
||||
bypass.contains("'9.9.9.9'") && bypass.contains("'138.201.61.182'"),
|
||||
"{bypass}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_goes_through_the_overlay_when_the_bypass_cannot_be_written() {
|
||||
let run = |script: &str| -> Result<String, String> {
|
||||
if script.contains("Select-Object -First 1") {
|
||||
Ok("12 192.168.1.1".to_string())
|
||||
} else if script.contains("-RouteMetric 7373") && script.contains("foreach ($ip") {
|
||||
Err("Access is denied".to_string())
|
||||
} else {
|
||||
assert!(!script.contains("-NextHop '0.0.0.0'"), "{script}");
|
||||
Ok(String::new())
|
||||
}
|
||||
};
|
||||
let report = apply_plan(&run, &no_names, &client_plan());
|
||||
assert_eq!(
|
||||
report.client,
|
||||
Some(RuleOutcome::Failed("Access is denied".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_gateway_is_a_failure_that_installs_no_route() {
|
||||
let run = |script: &str| -> Result<String, String> {
|
||||
assert!(!script.contains("New-NetRoute"), "{script}");
|
||||
if script.contains("Select-Object -First 1") {
|
||||
Err("the host has no default gateway outside the overlay".into())
|
||||
} else {
|
||||
Ok(String::new())
|
||||
}
|
||||
};
|
||||
let report = apply_plan(&run, &no_names, &client_plan());
|
||||
let Some(RuleOutcome::Failed(why)) = report.client else {
|
||||
panic!("{report:?}");
|
||||
};
|
||||
assert!(why.contains("no default gateway"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_halves_step_removes_what_was_written() {
|
||||
let calls = RefCell::new(Vec::new());
|
||||
let run = |script: &str| -> Result<String, String> {
|
||||
calls.borrow_mut().push(script.to_string());
|
||||
if script.contains("Select-Object -First 1") {
|
||||
Ok("12 192.168.1.1".to_string())
|
||||
} else if script.contains("'128.0.0.0/1'") && script.contains("New-NetRoute") {
|
||||
Err("boom".to_string())
|
||||
} else {
|
||||
Ok(String::new())
|
||||
}
|
||||
};
|
||||
let report = apply_plan(&run, &no_names, &client_plan());
|
||||
assert!(matches!(report.client, Some(RuleOutcome::Failed(_))));
|
||||
assert!(calls.borrow().last().unwrap().contains("Remove-NetRoute"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_the_client_removes_its_routes_and_clearing_does_too() {
|
||||
let calls = RefCell::new(Vec::new());
|
||||
let run = |script: &str| -> Result<String, String> {
|
||||
calls.borrow_mut().push(script.to_string());
|
||||
Ok(String::new())
|
||||
};
|
||||
let report = apply_plan(&run, &no_names, &plan(false));
|
||||
assert_eq!(report.client, None);
|
||||
assert!(
|
||||
calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|c| c.contains("RouteMetric -eq 7373"))
|
||||
);
|
||||
let script = remove_client_script("tsun0");
|
||||
assert!(script.contains("'0.0.0.0/1', '128.0.0.0/1', '::/1', '8000::/1'"));
|
||||
assert!(script.contains("exit 0"));
|
||||
// Only routes on our interface, or marked ours, are touched.
|
||||
assert!(script.contains("-InterfaceAlias $alias"));
|
||||
calls.borrow_mut().clear();
|
||||
clear_all(&run, "tsun0");
|
||||
assert!(
|
||||
calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|c| c.contains("RouteMetric -eq 7373"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_prunes_everything_for_the_interface() {
|
||||
let seen = RefCell::new(Vec::<String>::new());
|
||||
let run = |script: &str| -> Result<String, String> {
|
||||
seen.borrow_mut().push(script.to_string());
|
||||
Ok(String::new())
|
||||
};
|
||||
clear_all(&run, "tsun0");
|
||||
let seen = seen.borrow();
|
||||
assert!(seen[0].contains("$keep = @()"));
|
||||
assert!(seen[0].contains("'tsunagi-exit:tsun0:'"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,8 @@
|
||||
//!
|
||||
//! * [`provision`] creates the interface and configures it, and removes it
|
||||
//! again. Platform mechanics behind one decision function.
|
||||
//! * [`hostrules`] installs the route and firewall allowance that let a
|
||||
//! game's LAN discovery reach the interface, while broadcast is on.
|
||||
//! * [`tun`] is the packet interface itself, real or in memory.
|
||||
//! * [`packet`] reads just enough of an IP header to route by it.
|
||||
//! * [`config`] names interfaces and describes addresses.
|
||||
@@ -36,7 +38,10 @@ pub enum OverlayError {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
pub mod broadcast;
|
||||
pub mod config;
|
||||
pub mod exit;
|
||||
pub mod hostrules;
|
||||
pub mod interface;
|
||||
pub mod packet;
|
||||
pub mod provision;
|
||||
@@ -44,6 +49,11 @@ pub mod router;
|
||||
pub mod tun;
|
||||
|
||||
pub use config::{Cidr, DEFAULT_INTERFACE_PREFIX, MAX_INTERFACE_NAME_LEN, interface_name};
|
||||
pub use exit::{ExitRulesReport, RuleSetReport};
|
||||
pub use hostrules::{
|
||||
BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, ExitHostPlan, ExitHostReport,
|
||||
ExitHostRules, MockExitRules, MockHostRules, RuleOutcome, UnsupportedHostRules,
|
||||
};
|
||||
pub use interface::{Counters, Interface, PacketCarrier, Rejected};
|
||||
pub use packet::IpHeader;
|
||||
pub use provision::{
|
||||
@@ -51,11 +61,20 @@ pub use provision::{
|
||||
MockHost, MockProvisioner, Privilege, Provisioned, UnsupportedProvisioner, plan_changes,
|
||||
probe_net_admin,
|
||||
};
|
||||
pub use router::{NetworkRoutes, Route, RouteError, RoutingTable};
|
||||
pub use router::{ExitPolicy, NetworkRoutes, Route, RouteError, RoutingTable};
|
||||
pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest, address_is_local};
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use hostrules::LinuxHostRules;
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use hostrules::MacosHostRules;
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use hostrules::WindowsHostRules;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use provision::NetlinkProvisioner;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use provision::UtunProvisioner;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use provision::WintunProvisioner;
|
||||
File renamed without changes.
+17
-1
@@ -11,6 +11,7 @@ use std::sync::Arc;
|
||||
use crate::BoxFuture;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::super::hostrules::BroadcastHostRules;
|
||||
use super::super::tun::{TunDevice, TunFactory, TunRequest};
|
||||
use super::{InterfacePlan, InterfaceProvisioner};
|
||||
|
||||
@@ -30,12 +31,23 @@ fn plan_for(request: &TunRequest) -> Result<InterfacePlan, OverlayError> {
|
||||
#[derive(Debug)]
|
||||
pub struct ManagedTunFactory {
|
||||
provisioner: Arc<dyn InterfaceProvisioner>,
|
||||
host_rules: Option<Arc<dyn BroadcastHostRules>>,
|
||||
}
|
||||
|
||||
impl ManagedTunFactory {
|
||||
/// Wraps a provisioner.
|
||||
pub fn new(provisioner: Arc<dyn InterfaceProvisioner>) -> Self {
|
||||
Self { provisioner }
|
||||
Self {
|
||||
provisioner,
|
||||
host_rules: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets the agent install the broadcast route and firewall allowance for
|
||||
/// the interface this factory manages.
|
||||
pub fn with_host_rules(mut self, rules: Arc<dyn BroadcastHostRules>) -> Self {
|
||||
self.host_rules = Some(rules);
|
||||
self
|
||||
}
|
||||
|
||||
/// The provisioner underneath.
|
||||
@@ -54,6 +66,10 @@ impl TunFactory for ManagedTunFactory {
|
||||
true
|
||||
}
|
||||
|
||||
fn host_rules(&self) -> Option<Arc<dyn BroadcastHostRules>> {
|
||||
self.host_rules.clone()
|
||||
}
|
||||
|
||||
fn create<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,531 @@
|
||||
//! Managing the overlay interface on macOS, through utun and `ifconfig`/`route`.
|
||||
//!
|
||||
//! The shape is the same as the Linux and Windows provisioners: observe what
|
||||
//! is there, [`plan_changes`](super::plan_changes) to decide what to do, apply
|
||||
//! the difference. Only the mechanisms differ.
|
||||
//!
|
||||
//! # Creating the interface
|
||||
//!
|
||||
//! The interface is a `utun` device, created through the `tun` dependency's
|
||||
//! safe wrapper — [`open_tun`](super::super::tun) — over the in-kernel
|
||||
//! `com.apple.net.utun_control` control socket. No kernel extension and no
|
||||
//! Network Extension entitlement is involved; what it needs is root. The
|
||||
//! device is not made persistent, so closing the last file descriptor removes
|
||||
//! the interface — it goes away when the agent does, however the agent ends.
|
||||
//!
|
||||
//! # The kernel names the interface, not us
|
||||
//!
|
||||
//! Unlike Linux and Windows, a utun interface cannot be given an arbitrary
|
||||
//! name: it is always `utunN`, and the unit number is assigned by the kernel.
|
||||
//! So the name derived from the network id (`tsun…`) is only ever a *request*;
|
||||
//! the real name is read back off the device after it is opened, and
|
||||
//! everything downstream — the host rules, the resolver publication — uses the
|
||||
//! real one. The agent adopts it as soon as the device is created (see
|
||||
//! [`Interface`](crate::overlay::Interface)).
|
||||
//!
|
||||
//! # Configuring it
|
||||
//!
|
||||
//! Addresses, MTU and routes are applied with `ifconfig` and `route`. On Linux
|
||||
//! the same work is done in process over a netlink socket; here there is no
|
||||
//! equivalent this crate may call, because the in-process route is raw `ioctl`
|
||||
//! and `PF_ROUTE` and that is `unsafe`. The tools are invoked by absolute path
|
||||
//! from `/sbin`, so nothing on `PATH` can stand in for them, and every value
|
||||
//! handed to them is one this agent derived — an interface name chosen by the
|
||||
//! kernel, an address it allocated — never anything a peer said.
|
||||
//!
|
||||
//! # Observing it
|
||||
//!
|
||||
//! The interface table is read through `netdev`, a safe wrapper over
|
||||
//! `getifaddrs`. An interface is matched by its real `utunN` name, which only
|
||||
//! matters on reconfiguration: the first create always makes a fresh device.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::super::config::Cidr;
|
||||
use super::super::tun::{TunDevice, TunRequest, open_tun};
|
||||
use super::privilege::{Privilege, probe_net_admin};
|
||||
use super::{
|
||||
InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, Provisioned, plan_changes,
|
||||
};
|
||||
|
||||
/// Manages the overlay interface with utun and `ifconfig`/`route`.
|
||||
#[derive(Debug)]
|
||||
pub struct UtunProvisioner {
|
||||
/// Interfaces this process created, by their real `utunN` name, so a
|
||||
/// reconfiguration adjusts them rather than trying to create them again.
|
||||
ours: Mutex<Vec<String>>,
|
||||
/// Devices kept alive for as long as the interface should exist. Dropping
|
||||
/// one closes the last descriptor, which removes the utin interface.
|
||||
held: Mutex<Vec<(String, Arc<dyn TunDevice>)>>,
|
||||
}
|
||||
|
||||
impl UtunProvisioner {
|
||||
/// Creates the provisioner, after checking the platform supports one.
|
||||
///
|
||||
/// Whether the process is actually root is not checked here — reading the
|
||||
/// effective uid needs a libc call this crate forbids — so a missing
|
||||
/// privilege surfaces at utun creation with a precise message, the same
|
||||
/// way the Linux and Windows paths treat the open itself as the honest
|
||||
/// answer.
|
||||
pub fn new() -> Result<Self, OverlayError> {
|
||||
match probe_net_admin() {
|
||||
Privilege::Available => {}
|
||||
Privilege::Missing(reason) => return Err(OverlayError::Unavailable(reason)),
|
||||
Privilege::Unsupported => {
|
||||
return Err(OverlayError::Unavailable(
|
||||
"interface management is not compiled in".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
ours: Mutex::new(Vec::new()),
|
||||
held: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_ours(&self, name: &str) -> bool {
|
||||
lock(&self.ours).iter().any(|owned| owned == name)
|
||||
}
|
||||
|
||||
/// Opens the utun device, which is what creates it. The kernel assigns the
|
||||
/// name, so the returned device reports a `utunN` that is not `plan.name`.
|
||||
fn create_device(&self, plan: &InterfacePlan) -> Result<Arc<dyn TunDevice>, OverlayError> {
|
||||
let request = TunRequest::bare(plan.name.clone(), plan.mtu);
|
||||
open_tun(&request)
|
||||
}
|
||||
}
|
||||
|
||||
impl InterfaceProvisioner for UtunProvisioner {
|
||||
fn name(&self) -> &str {
|
||||
"utun"
|
||||
}
|
||||
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
|
||||
Box::pin(async move {
|
||||
// A reconfiguration: the plan carries the real `utunN` name the
|
||||
// agent adopted after the first create, so the interface is there
|
||||
// and only the addresses and MTU need bringing into line.
|
||||
if self.is_ours(&plan.name) {
|
||||
let current = observe(&plan.name).await;
|
||||
let changes = plan_changes(¤t, plan, true)?;
|
||||
configure(&plan.name, &changes).await?;
|
||||
return Ok(Provisioned {
|
||||
changes,
|
||||
device: None,
|
||||
});
|
||||
}
|
||||
|
||||
// The first create. The kernel assigns the unit, so the device is
|
||||
// opened first and its real name used for everything after.
|
||||
let device = self.create_device(plan)?;
|
||||
let real = device.name().to_string();
|
||||
|
||||
// A fresh interface starts with nothing on it, so the whole plan is
|
||||
// applied. `plan_changes` against an absent interface is exactly
|
||||
// that decision; its `create_link` flag is already satisfied by the
|
||||
// open above, so `configure` ignores it and only applies the rest.
|
||||
let changes = plan_changes(&InterfaceState::absent(), plan, false)?;
|
||||
|
||||
// A failure here leaves an interface that exists but cannot carry
|
||||
// traffic. Drop the device so the utun closes, and report, rather
|
||||
// than leave a trap behind.
|
||||
if let Err(err) = configure(&real, &changes).await {
|
||||
drop(device);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
lock(&self.ours).push(real.clone());
|
||||
lock(&self.held).push((real, Arc::clone(&device)));
|
||||
Ok(Provisioned {
|
||||
changes,
|
||||
device: Some(device),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move {
|
||||
// Dropping the device closes the last descriptor, which removes the
|
||||
// utun interface and the routes that were bound to it. There is
|
||||
// nothing to flush: a utun never outlives the process that holds it.
|
||||
lock(&self.held).retain(|(held, _)| held != name);
|
||||
lock(&self.ours).retain(|owned| owned != name);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `fe80::/10` and `169.254.0.0/16`, which macOS assigns on its own.
|
||||
fn is_link_local(addr: IpAddr) -> bool {
|
||||
match addr {
|
||||
IpAddr::V4(addr) => addr.is_link_local(),
|
||||
IpAddr::V6(addr) => (addr.segments()[0] & 0xffc0) == 0xfe80,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the interface named `name` currently looks like.
|
||||
///
|
||||
/// Never fails: a name nothing is using reads as [`InterfaceState::absent`],
|
||||
/// which is what the plan then acts on.
|
||||
async fn observe(name: &str) -> InterfaceState {
|
||||
let name = name.to_string();
|
||||
let found = tokio::task::spawn_blocking(move || {
|
||||
netdev::get_interfaces()
|
||||
.into_iter()
|
||||
.find(|iface| iface.name == name)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let Some(iface) = found else {
|
||||
return InterfaceState::absent();
|
||||
};
|
||||
|
||||
let mut addresses: Vec<Cidr> = Vec::new();
|
||||
for net in &iface.ipv4 {
|
||||
if let Ok(cidr) = Cidr::new(IpAddr::V4(net.addr()), net.prefix_len())
|
||||
&& !is_link_local(cidr.addr)
|
||||
{
|
||||
addresses.push(cidr);
|
||||
}
|
||||
}
|
||||
for net in &iface.ipv6 {
|
||||
if let Ok(cidr) = Cidr::new(IpAddr::V6(net.addr()), net.prefix_len())
|
||||
&& !is_link_local(cidr.addr)
|
||||
{
|
||||
addresses.push(cidr);
|
||||
}
|
||||
}
|
||||
addresses.sort();
|
||||
|
||||
InterfaceState {
|
||||
// Only ever reached for an interface this agent created (see
|
||||
// `reconcile`), so it is ours and a TUN by construction.
|
||||
kind: LinkKind::Tun,
|
||||
attached: iface.is_up(),
|
||||
up: iface.is_up(),
|
||||
mtu: iface.mtu.unwrap_or(0),
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies MTU, link state, addresses and the on-link routes they imply.
|
||||
async fn configure(name: &str, changes: &super::Changes) -> Result<(), OverlayError> {
|
||||
if let Some(mtu) = changes.set_mtu {
|
||||
set_mtu(name, mtu).await?;
|
||||
}
|
||||
for cidr in &changes.remove {
|
||||
// Best effort: an address that is already gone is the outcome wanted.
|
||||
if let Err(err) = address_del(name, *cidr).await {
|
||||
tracing::debug!(interface = %name, %cidr, %err, "could not remove address");
|
||||
}
|
||||
if let Err(err) = route_del(name, *cidr).await {
|
||||
tracing::debug!(interface = %name, %cidr, %err, "could not remove route");
|
||||
}
|
||||
}
|
||||
for cidr in &changes.add {
|
||||
address_add(name, *cidr).await?;
|
||||
// The subnet route makes the whole overlay range reach the interface;
|
||||
// a point-to-point utun does not install one from the address alone. A
|
||||
// host address (/32 or /128) needs none. Best effort: a route that is
|
||||
// already there is the outcome wanted.
|
||||
if let Err(err) = route_add(name, *cidr).await {
|
||||
tracing::debug!(interface = %name, %cidr, %err, "could not add route");
|
||||
}
|
||||
}
|
||||
if changes.bring_up {
|
||||
// Best effort: a utun with a descriptor open is up already, and a
|
||||
// failure to nudge it is not a reason to fail the whole reconcile.
|
||||
if let Err(err) = set_up(name).await {
|
||||
tracing::debug!(interface = %name, %err, "could not enable the interface");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The network address of a CIDR, with the host bits cleared.
|
||||
fn network_of(cidr: Cidr) -> IpAddr {
|
||||
match cidr.addr {
|
||||
IpAddr::V4(addr) => {
|
||||
let bits = u32::from(addr);
|
||||
let mask = if cidr.prefix_len == 0 {
|
||||
0
|
||||
} else {
|
||||
u32::MAX << (32 - u32::from(cidr.prefix_len))
|
||||
};
|
||||
IpAddr::V4(Ipv4Addr::from(bits & mask))
|
||||
}
|
||||
IpAddr::V6(addr) => {
|
||||
let bits = u128::from(addr);
|
||||
let mask = if cidr.prefix_len == 0 {
|
||||
0
|
||||
} else {
|
||||
u128::MAX << (128 - u32::from(cidr.prefix_len))
|
||||
};
|
||||
IpAddr::V6(Ipv6Addr::from(bits & mask))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a CIDR is a single host address, which needs no subnet route.
|
||||
fn is_host_route(cidr: Cidr) -> bool {
|
||||
match cidr.addr {
|
||||
IpAddr::V4(_) => cidr.prefix_len >= 32,
|
||||
IpAddr::V6(_) => cidr.prefix_len >= 128,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ifconfig` arguments that add an address to a utun interface.
|
||||
///
|
||||
/// A utun is point-to-point, so the IPv4 form names the address as its own
|
||||
/// peer; `alias` lets several coexist on the one interface.
|
||||
fn address_add_args(name: &str, cidr: Cidr) -> Vec<String> {
|
||||
match cidr.addr {
|
||||
IpAddr::V4(addr) => vec![
|
||||
name.into(),
|
||||
"inet".into(),
|
||||
format!("{addr}/{}", cidr.prefix_len),
|
||||
addr.to_string(),
|
||||
"alias".into(),
|
||||
],
|
||||
IpAddr::V6(addr) => vec![
|
||||
name.into(),
|
||||
"inet6".into(),
|
||||
format!("{addr}/{}", cidr.prefix_len),
|
||||
"alias".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ifconfig` arguments that remove an address from a utun interface.
|
||||
fn address_del_args(name: &str, cidr: Cidr) -> Vec<String> {
|
||||
let family = match cidr.addr {
|
||||
IpAddr::V4(_) => "inet",
|
||||
IpAddr::V6(_) => "inet6",
|
||||
};
|
||||
vec![
|
||||
name.into(),
|
||||
family.into(),
|
||||
cidr.addr.to_string(),
|
||||
"-alias".into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The `route` arguments that point a CIDR's subnet at the interface.
|
||||
fn route_add_args(name: &str, cidr: Cidr) -> Vec<String> {
|
||||
let family = match cidr.addr {
|
||||
IpAddr::V4(_) => "-inet",
|
||||
IpAddr::V6(_) => "-inet6",
|
||||
};
|
||||
vec![
|
||||
"-q".into(),
|
||||
"-n".into(),
|
||||
"add".into(),
|
||||
family.into(),
|
||||
"-net".into(),
|
||||
format!("{}/{}", network_of(cidr), cidr.prefix_len),
|
||||
"-interface".into(),
|
||||
name.into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The `route` arguments that remove that subnet route.
|
||||
fn route_del_args(name: &str, cidr: Cidr) -> Vec<String> {
|
||||
let family = match cidr.addr {
|
||||
IpAddr::V4(_) => "-inet",
|
||||
IpAddr::V6(_) => "-inet6",
|
||||
};
|
||||
vec![
|
||||
"-q".into(),
|
||||
"-n".into(),
|
||||
"delete".into(),
|
||||
family.into(),
|
||||
"-net".into(),
|
||||
format!("{}/{}", network_of(cidr), cidr.prefix_len),
|
||||
"-interface".into(),
|
||||
name.into(),
|
||||
]
|
||||
}
|
||||
|
||||
async fn address_add(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
||||
ifconfig(address_add_args(name, cidr)).await
|
||||
}
|
||||
|
||||
async fn address_del(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
||||
ifconfig(address_del_args(name, cidr)).await
|
||||
}
|
||||
|
||||
async fn route_add(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
||||
if is_host_route(cidr) {
|
||||
return Ok(());
|
||||
}
|
||||
route(route_add_args(name, cidr)).await
|
||||
}
|
||||
|
||||
async fn route_del(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
||||
if is_host_route(cidr) {
|
||||
return Ok(());
|
||||
}
|
||||
route(route_del_args(name, cidr)).await
|
||||
}
|
||||
|
||||
async fn set_mtu(name: &str, mtu: u32) -> Result<(), OverlayError> {
|
||||
ifconfig(vec![name.into(), "mtu".into(), mtu.to_string()]).await
|
||||
}
|
||||
|
||||
async fn set_up(name: &str) -> Result<(), OverlayError> {
|
||||
ifconfig(vec![name.into(), "up".into()]).await
|
||||
}
|
||||
|
||||
/// Runs `/sbin/ifconfig`, turning a non-zero exit into an error with its text.
|
||||
async fn ifconfig(args: Vec<String>) -> Result<(), OverlayError> {
|
||||
run("/sbin/ifconfig", args).await
|
||||
}
|
||||
|
||||
/// Runs `/sbin/route`, turning a non-zero exit into an error with its text.
|
||||
async fn route(args: Vec<String>) -> Result<(), OverlayError> {
|
||||
run("/sbin/route", args).await
|
||||
}
|
||||
|
||||
/// Runs a system tool by absolute path, so nothing on `PATH` can stand in for
|
||||
/// it, and turns a non-zero exit into an error carrying what it said.
|
||||
async fn run(program: &'static str, args: Vec<String>) -> Result<(), OverlayError> {
|
||||
let display = format!("{program} {}", args.join(" "));
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
std::process::Command::new(program).args(&args).output()
|
||||
})
|
||||
.await
|
||||
.map_err(|err| OverlayError::Other(format!("could not run {display}: {err}")))?
|
||||
.map_err(|err| OverlayError::Unavailable(format!("could not run {display}: {err}")))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let message = {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let text = if stderr.trim().is_empty() {
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
} else {
|
||||
stderr.trim().to_string()
|
||||
};
|
||||
if text.is_empty() {
|
||||
format!("exit code {}", output.status)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
};
|
||||
Err(OverlayError::Unavailable(format!(
|
||||
"{display} failed: {message}"
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn v4(last: u8, prefix: u8) -> Cidr {
|
||||
Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, last)), prefix).unwrap()
|
||||
}
|
||||
|
||||
fn v6(last: u16, prefix: u8) -> Cidr {
|
||||
Cidr::new(
|
||||
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, last)),
|
||||
prefix,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v4_address_is_added_point_to_point_with_an_alias() {
|
||||
let args = address_add_args("utun5", v4(69, 24));
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["utun5", "inet", "10.13.37.69/24", "10.13.37.69", "alias"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v6_address_is_added_with_a_prefix_and_an_alias() {
|
||||
let args = address_add_args("utun5", v6(1, 128));
|
||||
assert_eq!(args, vec!["utun5", "inet6", "fd00::1/128", "alias"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_an_address_names_the_family_and_removes_the_alias() {
|
||||
let args = address_del_args("utun5", v4(69, 24));
|
||||
assert_eq!(args, vec!["utun5", "inet", "10.13.37.69", "-alias"]);
|
||||
let args = address_del_args("utun5", v6(1, 128));
|
||||
assert_eq!(args, vec!["utun5", "inet6", "fd00::1", "-alias"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subnet_route_points_the_masked_network_at_the_interface() {
|
||||
let args = route_add_args("utun5", v4(69, 24));
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-q",
|
||||
"-n",
|
||||
"add",
|
||||
"-inet",
|
||||
"-net",
|
||||
"10.13.37.0/24",
|
||||
"-interface",
|
||||
"utun5"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_address_installs_no_subnet_route() {
|
||||
assert!(is_host_route(v4(69, 32)));
|
||||
assert!(is_host_route(v6(1, 128)));
|
||||
assert!(!is_host_route(v4(69, 24)));
|
||||
assert!(!is_host_route(v6(1, 64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_network_address_clears_the_host_bits() {
|
||||
assert_eq!(
|
||||
network_of(v4(69, 24)),
|
||||
IpAddr::V4(Ipv4Addr::new(10, 13, 37, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
network_of(Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 69)), 16).unwrap()),
|
||||
IpAddr::V4(Ipv4Addr::new(10, 13, 0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
network_of(v6(0x1234, 64)),
|
||||
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_local_addresses_are_not_ours_to_manage() {
|
||||
assert!(is_link_local(IpAddr::V6(Ipv6Addr::new(
|
||||
0xfe80, 0, 0, 0, 0, 0, 0, 1
|
||||
))));
|
||||
assert!(is_link_local(IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1))));
|
||||
assert!(!is_link_local(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 1))));
|
||||
assert!(!is_link_local(IpAddr::V6(Ipv6Addr::new(
|
||||
0xfd00, 0, 0, 0, 0, 0, 0, 1
|
||||
))));
|
||||
}
|
||||
}
|
||||
File renamed without changes.
+10
-1
@@ -20,11 +20,13 @@
|
||||
//! pure and tested on every platform, and only the execution is behind
|
||||
//! [`InterfaceProvisioner`].
|
||||
//!
|
||||
//! Four implementations:
|
||||
//! Five implementations:
|
||||
//!
|
||||
//! * `NetlinkProvisioner` on Linux, which needs `CAP_NET_ADMIN`.
|
||||
//! * `WintunProvisioner` on Windows, a Wintun adapter configured with `netsh`,
|
||||
//! which needs an elevated process.
|
||||
//! * `UtunProvisioner` on macOS, a utun interface configured with `ifconfig`
|
||||
//! and `route`, which needs root.
|
||||
//! * [`MockProvisioner`], an in-memory host used by the tests.
|
||||
//! * [`UnsupportedProvisioner`] elsewhere, which fails with an explanation
|
||||
//! and a pointer at the manual route rather than pretending to work.
|
||||
@@ -52,12 +54,19 @@ pub use mock::{MockHost, MockProvisioner};
|
||||
mod linux;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use linux::NetlinkProvisioner;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub(crate) use linux::RouteHandle;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
mod windows;
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use windows::WintunProvisioner;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
mod macos;
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use macos::UtunProvisioner;
|
||||
|
||||
mod privilege;
|
||||
pub use privilege::{Privilege, probe_net_admin};
|
||||
|
||||
+31
-7
@@ -15,7 +15,7 @@
|
||||
//! Grant it with:
|
||||
//!
|
||||
//! ```text
|
||||
//! sudo setcap cap_net_admin+p /usr/local/bin/tsunagi
|
||||
//! sudo setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsng
|
||||
//! ```
|
||||
//!
|
||||
//! `+p` rather than `+ep`: with `+p` the capability is permitted but not
|
||||
@@ -49,7 +49,7 @@ impl Privilege {
|
||||
/// How to obtain it, for a diagnostic.
|
||||
pub fn how_to_grant(program: &str) -> String {
|
||||
format!(
|
||||
"Grant it once with `sudo setcap cap_net_admin+p {program}` \
|
||||
"Grant it once with `sudo setcap cap_net_admin,cap_net_bind_service+p {program}` \
|
||||
and the agent manages its own interface. Without it, run with \
|
||||
`--no-tun`: the tunnels still form, they just do not reach the \
|
||||
operating system."
|
||||
@@ -63,15 +63,20 @@ pub use linux_impl::{NetAdmin, probe_net_admin};
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use windows_impl::probe_net_admin;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use macos_impl::probe_net_admin;
|
||||
|
||||
#[cfg(not(any(
|
||||
all(feature = "tun-device", target_os = "linux"),
|
||||
all(feature = "tun-device", target_os = "windows")
|
||||
all(feature = "tun-device", target_os = "windows"),
|
||||
all(feature = "tun-device", target_os = "macos")
|
||||
)))]
|
||||
pub use other_impl::probe_net_admin;
|
||||
|
||||
#[cfg(not(any(
|
||||
all(feature = "tun-device", target_os = "linux"),
|
||||
all(feature = "tun-device", target_os = "windows")
|
||||
all(feature = "tun-device", target_os = "windows"),
|
||||
all(feature = "tun-device", target_os = "macos")
|
||||
)))]
|
||||
mod other_impl {
|
||||
use super::Privilege;
|
||||
@@ -101,6 +106,25 @@ mod windows_impl {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
mod macos_impl {
|
||||
use super::Privilege;
|
||||
|
||||
/// Whether this process can configure interfaces.
|
||||
///
|
||||
/// macOS has no capability to hold and lower the way Linux does: creating
|
||||
/// a utun interface simply needs the process to be root. Reading the
|
||||
/// effective uid means a libc call this crate forbids, so the probe is
|
||||
/// deliberately optimistic — it reports that the platform can manage
|
||||
/// interfaces — and the real check is left to the utun open, which fails
|
||||
/// with a precise "run with sudo" message when the process is not root.
|
||||
/// This matches how the Linux path treats the open itself as the honest
|
||||
/// answer.
|
||||
pub fn probe_net_admin() -> Privilege {
|
||||
Privilege::Available
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
mod linux_impl {
|
||||
use caps::{CapSet, Capability};
|
||||
@@ -147,7 +171,7 @@ mod linux_impl {
|
||||
caps::raise(None, CapSet::Effective, Capability::CAP_NET_ADMIN).map_err(|err| {
|
||||
OverlayError::Unavailable(format!(
|
||||
"cannot raise CAP_NET_ADMIN: {err}. {}",
|
||||
Privilege::how_to_grant("tsunagi")
|
||||
Privilege::how_to_grant("tsng")
|
||||
))
|
||||
})?;
|
||||
Ok(Self { raised: true })
|
||||
@@ -180,8 +204,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_grant_instructions_name_the_program() {
|
||||
let text = Privilege::how_to_grant("/usr/local/bin/tsunagi");
|
||||
assert!(text.contains("setcap cap_net_admin+p /usr/local/bin/tsunagi"));
|
||||
let text = Privilege::how_to_grant("/usr/local/bin/tsng");
|
||||
assert!(text.contains("setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsng"));
|
||||
assert!(text.contains("--no-tun"), "the fallback is offered too");
|
||||
}
|
||||
|
||||
File renamed without changes.
+6
-1
@@ -262,7 +262,12 @@ async fn configure(plan: &InterfacePlan, changes: &super::Changes) -> Result<(),
|
||||
set_mtu(&plan.name, mtu).await?;
|
||||
}
|
||||
for cidr in &changes.add {
|
||||
address_add(&plan.name, *cidr).await?;
|
||||
if let Err(err) = address_add(&plan.name, *cidr).await {
|
||||
let err_str = err.to_string();
|
||||
if !err_str.contains("already exists") && !err_str.contains("0x80071392") {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
if changes.bring_up {
|
||||
// Best effort: a Wintun adapter with a session is up already, and a
|
||||
@@ -0,0 +1,958 @@
|
||||
//! Which peer a packet is for, and whether a peer may have sent it.
|
||||
//!
|
||||
//! One agent has one interface, so a packet coming off it could belong to any
|
||||
//! network the agent is in and to any protocol currently carrying traffic.
|
||||
//! Deciding which is this table's job, and it is the reason the interface
|
||||
//! belongs to the system level: the answer comes from signed state, which no
|
||||
//! protocol owns.
|
||||
//!
|
||||
//! Two questions, and they are not the same one:
|
||||
//!
|
||||
//! * **Outbound**: this destination address — whose is it? A packet for
|
||||
//! nobody is dropped rather than broadcast.
|
||||
//! * **Inbound**: this peer decrypted a packet with this source address —
|
||||
//! is that address actually its? A peer may not speak for anybody else,
|
||||
//! and the check consults the signed claim rather than anything the peer
|
||||
//! said.
|
||||
//!
|
||||
//! Nothing here is derived from a protocol's key. An address is allocated at
|
||||
//! the system level and signed by the member that holds it, so every protocol
|
||||
//! carries traffic for the same addresses and the table is the same whichever
|
||||
//! one is in use.
|
||||
|
||||
use super::broadcast::{BroadcastPolicy, BroadcastTable};
|
||||
use arc_swap::ArcSwap;
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use iroh::EndpointId;
|
||||
|
||||
use crate::identity::NetworkId;
|
||||
use crate::state::Ipv4Range;
|
||||
|
||||
/// Where an outbound packet is going.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Route {
|
||||
/// The network the destination belongs to.
|
||||
pub network: NetworkId,
|
||||
/// The member that holds the destination address.
|
||||
pub peer: EndpointId,
|
||||
}
|
||||
|
||||
/// A network's exit-node settings, as far as packets are concerned.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct ExitPolicy {
|
||||
/// This agent offers itself as an exit node here: packets from members
|
||||
/// for addresses outside every overlay range are handed to the host.
|
||||
pub offer: bool,
|
||||
/// The member all other traffic is sent to, if this device uses one.
|
||||
pub via: Option<EndpointId>,
|
||||
}
|
||||
|
||||
/// What one network contributes to the table.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct NetworkRoutes {
|
||||
/// Exit-node settings of this network.
|
||||
pub exit: ExitPolicy,
|
||||
/// Local broadcast policy and authenticated willing recipients.
|
||||
pub broadcast: BroadcastPolicy,
|
||||
/// The range this network allocates from, once it has agreed one.
|
||||
pub range: Option<Ipv4Range>,
|
||||
/// This agent's own address in the network.
|
||||
pub local: Option<Ipv4Addr>,
|
||||
/// Every other member's address, as the signed state has it.
|
||||
pub peers: Vec<(Ipv4Addr, EndpointId)>,
|
||||
}
|
||||
|
||||
/// Why a network cannot be added to the table.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum RouteError {
|
||||
/// Two networks would answer for the same addresses.
|
||||
#[error(
|
||||
"network {network} uses {range}, which overlaps {other_range} already in use by \
|
||||
{other}. One agent has one interface, so an address can belong to only one of \
|
||||
them: stop the other network before starting this one."
|
||||
)]
|
||||
Overlap {
|
||||
/// The network being added.
|
||||
network: NetworkId,
|
||||
/// The range it wants.
|
||||
range: Ipv4Range,
|
||||
/// The network already using an overlapping range.
|
||||
other: NetworkId,
|
||||
/// That network's range.
|
||||
other_range: Ipv4Range,
|
||||
},
|
||||
}
|
||||
|
||||
/// Address ownership across every network this agent is in.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RoutingTable {
|
||||
pub(crate) vips: Arc<crate::vip::VipRouter>,
|
||||
broadcast: ArcSwap<BroadcastTable>,
|
||||
networks: RwLock<HashMap<NetworkId, NetworkRoutes>>,
|
||||
}
|
||||
|
||||
impl RoutingTable {
|
||||
/// An empty table.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn read(&self) -> std::sync::RwLockReadGuard<'_, HashMap<NetworkId, NetworkRoutes>> {
|
||||
match self.networks.read() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self) -> std::sync::RwLockWriteGuard<'_, HashMap<NetworkId, NetworkRoutes>> {
|
||||
match self.networks.write() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces what one network contributes.
|
||||
///
|
||||
/// Rejected when its range overlaps another network's. With one
|
||||
/// interface an address can belong to only one network, and guessing
|
||||
/// which would send somebody's traffic to a stranger.
|
||||
pub fn set_network(&self, network: NetworkId, routes: NetworkRoutes) -> Result<(), RouteError> {
|
||||
let mut networks = self.write();
|
||||
if let Some(range) = routes.range {
|
||||
for (other, existing) in networks.iter() {
|
||||
if *other == network {
|
||||
continue;
|
||||
}
|
||||
let Some(other_range) = existing.range else {
|
||||
continue;
|
||||
};
|
||||
if ranges_overlap(range, other_range) {
|
||||
return Err(RouteError::Overlap {
|
||||
network,
|
||||
range,
|
||||
other: *other,
|
||||
other_range,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
networks.insert(network, routes);
|
||||
self.broadcast
|
||||
.store(Arc::new(BroadcastTable::build(&networks)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forgets a network.
|
||||
pub fn remove_network(&self, network: NetworkId) {
|
||||
self.vips.remove_network(network);
|
||||
let mut networks = self.write();
|
||||
networks.remove(&network);
|
||||
self.broadcast
|
||||
.store(Arc::new(BroadcastTable::build(&networks)));
|
||||
}
|
||||
|
||||
/// Replaces only a network's broadcast participation, without changing addresses.
|
||||
pub fn set_broadcast(&self, network: NetworkId, policy: BroadcastPolicy) {
|
||||
let mut networks = self.write();
|
||||
if let Some(routes) = networks.get_mut(&network) {
|
||||
if routes.broadcast == policy {
|
||||
return;
|
||||
}
|
||||
routes.broadcast = policy;
|
||||
self.broadcast
|
||||
.store(Arc::new(BroadcastTable::build(&networks)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces only a network's exit-node settings, without changing addresses.
|
||||
///
|
||||
/// A network that has no entry yet (its range is not agreed) takes them
|
||||
/// from [`NetworkRoutes::exit`] when it is added.
|
||||
pub fn set_exit(&self, network: NetworkId, policy: ExitPolicy) {
|
||||
if let Some(routes) = self.write().get_mut(&network) {
|
||||
routes.exit = policy;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognizes limited and configured subnet broadcasts before unicast lookup.
|
||||
pub fn is_broadcast(&self, destination: Ipv4Addr) -> bool {
|
||||
self.broadcast.load().is_destination(destination)
|
||||
}
|
||||
|
||||
/// A precomputed domain-scoped recipient list; no per-packet graph search.
|
||||
pub fn broadcast_recipients(
|
||||
&self,
|
||||
source: Ipv4Addr,
|
||||
destination: Ipv4Addr,
|
||||
) -> Option<Arc<[Route]>> {
|
||||
self.broadcast.load().outgoing(source, destination)
|
||||
}
|
||||
|
||||
/// Local admission after the plugin authenticated and decrypted the sender.
|
||||
pub fn accepts_broadcast(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
destination: Ipv4Addr,
|
||||
) -> bool {
|
||||
self.broadcast.load().accepts(network, peer, destination)
|
||||
}
|
||||
|
||||
/// The peer that holds a destination address, if anybody 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.
|
||||
///
|
||||
/// An address nobody holds, outside every overlay range and an ordinary
|
||||
/// unicast destination, goes to this device's exit node when it has one:
|
||||
/// that is what sending *all* traffic through a member means.
|
||||
pub fn route(&self, destination: Ipv4Addr) -> Option<Route> {
|
||||
let networks = self.read();
|
||||
for (network, routes) in networks.iter() {
|
||||
if routes.local == Some(destination) {
|
||||
return None;
|
||||
}
|
||||
if let Some((_, peer)) = routes
|
||||
.peers
|
||||
.iter()
|
||||
.find(|(address, _)| *address == destination)
|
||||
{
|
||||
return Some(Route {
|
||||
network: *network,
|
||||
peer: *peer,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Inside an overlay range, an unowned address is nobody's, not the
|
||||
// internet's.
|
||||
if !is_internet_destination(destination) || in_any_range(&networks, destination) {
|
||||
return None;
|
||||
}
|
||||
exit_via(&networks).map(|(network, peer)| Route { network, peer })
|
||||
}
|
||||
|
||||
/// Whether a peer may send from a source address.
|
||||
///
|
||||
/// A peer speaks only for the address the network agreed it holds. The
|
||||
/// one exception is this device's exit node: what comes back from the
|
||||
/// internet carries the internet's addresses, so from *that* peer any
|
||||
/// source outside the overlay ranges is accepted.
|
||||
pub fn may_send_from(&self, network: NetworkId, peer: EndpointId, source: Ipv4Addr) -> bool {
|
||||
let networks = self.read();
|
||||
let Some(routes) = networks.get(&network) else {
|
||||
return false;
|
||||
};
|
||||
if routes
|
||||
.peers
|
||||
.iter()
|
||||
.any(|(address, holder)| *address == source && *holder == peer)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
routes.exit.via == Some(peer)
|
||||
&& is_internet_destination(source)
|
||||
&& !in_any_range(&networks, source)
|
||||
}
|
||||
|
||||
/// Whether this agent, as an exit node of `network`, takes a member's
|
||||
/// packet for `destination` to the host.
|
||||
///
|
||||
/// Only an ordinary unicast address outside every overlay range, so a
|
||||
/// packet for another member, for this agent or for the local network
|
||||
/// segment never leaves by this door. The sender's own source address is
|
||||
/// checked separately, by [`Self::may_send_from`].
|
||||
pub fn accepts_exit_traffic(&self, network: NetworkId, destination: Ipv4Addr) -> bool {
|
||||
let networks = self.read();
|
||||
networks
|
||||
.get(&network)
|
||||
.is_some_and(|routes| routes.exit.offer)
|
||||
&& is_internet_destination(destination)
|
||||
&& !in_any_range(&networks, destination)
|
||||
}
|
||||
|
||||
/// The network and member this device sends its internet traffic through.
|
||||
///
|
||||
/// One default route, so one answer; if several networks claim one (they
|
||||
/// should not) the lowest network id decides, so two runs agree.
|
||||
pub fn exit_via(&self) -> Option<(NetworkId, EndpointId)> {
|
||||
exit_via(&self.read())
|
||||
}
|
||||
|
||||
/// Networks that offer this agent as an exit node, with the range each
|
||||
/// agreed. A network that has not agreed one yet has nothing to
|
||||
/// masquerade and is left out.
|
||||
pub fn exit_offers(&self) -> Vec<(NetworkId, Ipv4Range)> {
|
||||
let mut offers: Vec<(NetworkId, Ipv4Range)> = self
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, routes)| routes.exit.offer)
|
||||
.filter_map(|(network, routes)| Some((*network, routes.range?)))
|
||||
.collect();
|
||||
offers.sort_by_key(|(network, _)| *network);
|
||||
offers
|
||||
}
|
||||
|
||||
/// The range of every network in the table that has agreed one, sorted.
|
||||
pub fn overlay_ranges(&self) -> Vec<Ipv4Range> {
|
||||
let mut ranges: Vec<Ipv4Range> = self
|
||||
.read()
|
||||
.values()
|
||||
.filter_map(|routes| routes.range)
|
||||
.collect();
|
||||
ranges.sort_by_key(|range| (range.base, range.prefix_len));
|
||||
ranges.dedup();
|
||||
ranges
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Exported LAN subnets must extend this admission policy explicitly later.
|
||||
pub fn is_local_destination(&self, network: NetworkId, destination: Ipv4Addr) -> bool {
|
||||
self.read()
|
||||
.get(&network)
|
||||
.is_some_and(|routes| routes.local == Some(destination))
|
||||
}
|
||||
|
||||
/// Every address this agent should answer to, with its prefix length.
|
||||
pub fn local_addresses(&self) -> Vec<(Ipv4Addr, u8)> {
|
||||
let mut addresses: Vec<(Ipv4Addr, u8)> = self
|
||||
.read()
|
||||
.values()
|
||||
.filter_map(|routes| Some((routes.local?, routes.range?.prefix_len)))
|
||||
.collect();
|
||||
addresses.sort();
|
||||
addresses
|
||||
}
|
||||
|
||||
/// The one network whose address the limited broadcast route is bound to.
|
||||
///
|
||||
/// The host has a single `255.255.255.255` route per interface, and one
|
||||
/// interface carries every network, so only one overlay address can be its
|
||||
/// source. The choice is the lowest [`NetworkId`] among networks that have
|
||||
/// broadcast enabled, a range and a local address: deterministic, so two
|
||||
/// runs with the same networks pick the same one.
|
||||
pub fn broadcast_host_target(&self) -> Option<(Ipv4Addr, Ipv4Range)> {
|
||||
let networks = self.read();
|
||||
networks
|
||||
.iter()
|
||||
.filter(|(_, routes)| routes.broadcast.enabled)
|
||||
.filter_map(|(network, routes)| Some((*network, routes.local?, routes.range?)))
|
||||
.min_by_key(|(network, _, _)| *network)
|
||||
.map(|(_, local, range)| (local, range))
|
||||
}
|
||||
|
||||
/// Whether a range would collide with one another network already uses.
|
||||
///
|
||||
/// Asked before proposing a range rather than after: with one interface
|
||||
/// an agent that claimed an address it could not route would also be
|
||||
/// telling everybody else to use that range, and "the range of the
|
||||
/// lowest author wins" would spread the collision instead of containing
|
||||
/// it.
|
||||
pub fn would_overlap(&self, network: NetworkId, range: Ipv4Range) -> Option<Ipv4Range> {
|
||||
self.read().iter().find_map(|(other, existing)| {
|
||||
if *other == network {
|
||||
return None;
|
||||
}
|
||||
let other_range = existing.range?;
|
||||
ranges_overlap(range, other_range).then_some(other_range)
|
||||
})
|
||||
}
|
||||
|
||||
/// All active networks whose reserved ranges intersect this range.
|
||||
pub fn overlapping_networks(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
range: Ipv4Range,
|
||||
) -> Vec<(NetworkId, Ipv4Range)> {
|
||||
let mut conflicts: Vec<_> = self
|
||||
.read()
|
||||
.iter()
|
||||
.filter_map(|(other, routes)| {
|
||||
let other_range = routes.range?;
|
||||
(*other != network && ranges_overlap(range, other_range))
|
||||
.then_some((*other, other_range))
|
||||
})
|
||||
.collect();
|
||||
conflicts.sort_by_key(|(id, _)| *id);
|
||||
conflicts
|
||||
}
|
||||
|
||||
/// How many networks the table covers.
|
||||
pub fn len(&self) -> usize {
|
||||
self.read().len()
|
||||
}
|
||||
|
||||
/// Whether it covers none.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.read().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an address is an ordinary unicast one: not this host, not a
|
||||
/// multicast or broadcast group, not link-local, not unspecified.
|
||||
fn is_internet_destination(address: Ipv4Addr) -> bool {
|
||||
!(address.is_unspecified()
|
||||
|| address.is_loopback()
|
||||
|| address.is_multicast()
|
||||
|| address.is_broadcast()
|
||||
|| address.is_link_local())
|
||||
}
|
||||
|
||||
/// Whether an address lies in any network's range.
|
||||
fn in_any_range(networks: &HashMap<NetworkId, NetworkRoutes>, address: Ipv4Addr) -> bool {
|
||||
networks
|
||||
.values()
|
||||
.any(|routes| routes.range.is_some_and(|range| range.contains(address)))
|
||||
}
|
||||
|
||||
/// The exit node in use, if any, from the lowest network that names one.
|
||||
fn exit_via(networks: &HashMap<NetworkId, NetworkRoutes>) -> Option<(NetworkId, EndpointId)> {
|
||||
networks
|
||||
.iter()
|
||||
.filter_map(|(network, routes)| Some((*network, routes.exit.via?)))
|
||||
.min_by_key(|(network, _)| *network)
|
||||
}
|
||||
|
||||
/// Whether two ranges share any address.
|
||||
fn ranges_overlap(one: Ipv4Range, other: Ipv4Range) -> bool {
|
||||
// A range contains the other's base, or the other way round. With
|
||||
// prefix-aligned ranges that is the whole of it: two blocks either nest
|
||||
// or are disjoint.
|
||||
one.contains(other.base) || other.contains(one.base)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
|
||||
fn network(name: &str) -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new(name).unwrap(),
|
||||
&NetworkSecret::from_bytes(vec![4u8; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
|
||||
fn peer(seed: u8) -> EndpointId {
|
||||
iroh::SecretKey::from_bytes(&[seed; 32]).public()
|
||||
}
|
||||
|
||||
fn addr(last: u8) -> Ipv4Addr {
|
||||
Ipv4Addr::new(10, 13, 37, last)
|
||||
}
|
||||
|
||||
fn routes() -> NetworkRoutes {
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
local: Some(addr(1)),
|
||||
peers: vec![(addr(2), peer(2)), (addr(3), peer(3))],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packet_goes_to_the_member_that_holds_its_destination() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("routing");
|
||||
table.set_network(id, routes()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
table.route(addr(2)),
|
||||
Some(Route {
|
||||
network: id,
|
||||
peer: peer(2)
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
table.route(addr(3)),
|
||||
Some(Route {
|
||||
network: id,
|
||||
peer: peer(3)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packet_for_nobody_has_nowhere_to_go() {
|
||||
// Dropped rather than sent to everybody: a tunnel is not a broadcast
|
||||
// domain, and flooding would hand one member another's traffic.
|
||||
let table = RoutingTable::new();
|
||||
table.set_network(network("routing"), routes()).unwrap();
|
||||
assert_eq!(table.route(addr(200)), None);
|
||||
assert_eq!(table.route(Ipv4Addr::new(192, 0, 2, 1)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packet_for_ourselves_is_not_sent_to_a_peer() {
|
||||
let table = RoutingTable::new();
|
||||
table.set_network(network("routing"), routes()).unwrap();
|
||||
assert_eq!(table.route(addr(1)), None, "that address is ours");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peer_may_only_send_from_the_address_it_holds() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("ownership");
|
||||
table.set_network(id, routes()).unwrap();
|
||||
|
||||
assert!(table.may_send_from(id, peer(2), addr(2)));
|
||||
// Somebody else's address, and its own network's.
|
||||
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(99)));
|
||||
// A peer that holds nothing here.
|
||||
assert!(!table.may_send_from(id, peer(9), addr(2)));
|
||||
// And the question is per network, not global.
|
||||
assert!(!table.may_send_from(network("elsewhere"), peer(2), addr(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_networks_with_overlapping_ranges_are_refused() {
|
||||
// One interface means an address belongs to one network. Accepting
|
||||
// both and guessing would send somebody's traffic to a stranger.
|
||||
let table = RoutingTable::new();
|
||||
let first = network("first");
|
||||
let second = network("second");
|
||||
table.set_network(first, routes()).unwrap();
|
||||
|
||||
let err = table.set_network(second, routes()).unwrap_err();
|
||||
let RouteError::Overlap { other, .. } = err;
|
||||
assert_eq!(other, first);
|
||||
assert!(err.to_string().contains("stop the other network"), "{err}");
|
||||
assert_eq!(table.len(), 1, "the overlapping network is not added");
|
||||
|
||||
// A range of its own is fine.
|
||||
table
|
||||
.set_network(
|
||||
second,
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||
peers: vec![(Ipv4Addr::new(10, 99, 0, 2), peer(4))],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(table.len(), 2);
|
||||
assert_eq!(
|
||||
table.route(Ipv4Addr::new(10, 99, 0, 2)),
|
||||
Some(Route {
|
||||
network: second,
|
||||
peer: peer(4)
|
||||
})
|
||||
);
|
||||
// And the first network still answers for its own.
|
||||
assert_eq!(table.route(addr(2)).map(|route| route.network), Some(first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_broadcast_target_is_the_lowest_enabled_network_with_an_address() {
|
||||
let table = RoutingTable::new();
|
||||
assert_eq!(table.broadcast_host_target(), None);
|
||||
|
||||
let (low, high) = {
|
||||
let (a, b) = (network("first"), network("second"));
|
||||
if a < b { (a, b) } else { (b, a) }
|
||||
};
|
||||
let mk = |enabled: bool, base: [u8; 4], local: Option<Ipv4Addr>| NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: BroadcastPolicy {
|
||||
enabled,
|
||||
..Default::default()
|
||||
},
|
||||
range: Some(
|
||||
format!("{}.{}.{}.0/24", base[0], base[1], base[2])
|
||||
.parse()
|
||||
.unwrap(),
|
||||
),
|
||||
local,
|
||||
peers: Vec::new(),
|
||||
};
|
||||
let a = Ipv4Addr::new(10, 1, 0, 1);
|
||||
let b = Ipv4Addr::new(10, 2, 0, 1);
|
||||
|
||||
// The lower id has broadcast off, so the higher one is chosen.
|
||||
table
|
||||
.set_network(low, mk(false, [10, 1, 0, 0], Some(a)))
|
||||
.unwrap();
|
||||
table
|
||||
.set_network(high, mk(true, [10, 2, 0, 0], Some(b)))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
table.broadcast_host_target(),
|
||||
Some((b, "10.2.0.0/24".parse().unwrap()))
|
||||
);
|
||||
|
||||
// Both enabled: the lowest id wins, whatever the insertion order.
|
||||
table
|
||||
.set_network(low, mk(true, [10, 1, 0, 0], Some(a)))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
table.broadcast_host_target(),
|
||||
Some((a, "10.1.0.0/24".parse().unwrap()))
|
||||
);
|
||||
|
||||
// No local address yet: it cannot be the route's source.
|
||||
table
|
||||
.set_network(low, mk(true, [10, 1, 0, 0], None))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
table.broadcast_host_target(),
|
||||
Some((b, "10.2.0.0/24".parse().unwrap()))
|
||||
);
|
||||
|
||||
table.remove_network(high);
|
||||
assert_eq!(table.broadcast_host_target(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_can_be_asked_about_before_it_is_proposed() {
|
||||
// The point of asking first: an agent that claims an address it
|
||||
// cannot route also tells everybody else to use that range.
|
||||
let table = RoutingTable::new();
|
||||
let first = network("first");
|
||||
let second = network("second");
|
||||
table.set_network(first, routes()).unwrap();
|
||||
|
||||
let mine: Ipv4Range = "10.13.37.0/24".parse().unwrap();
|
||||
assert_eq!(table.would_overlap(second, mine), Some(mine));
|
||||
// Its own range is not a collision with itself.
|
||||
assert_eq!(table.would_overlap(first, mine), None);
|
||||
assert_eq!(
|
||||
table.would_overlap(second, "10.99.0.0/16".parse().unwrap()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_range_counts_as_overlapping() {
|
||||
let table = RoutingTable::new();
|
||||
table
|
||||
.set_network(
|
||||
network("wide"),
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.0.0.0/8".parse().unwrap()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// Inside the /8, in either direction.
|
||||
assert!(
|
||||
table
|
||||
.set_network(
|
||||
network("narrow"),
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_network_does_not_count_as_overlapping_itself() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("refresh");
|
||||
table.set_network(id, routes()).unwrap();
|
||||
|
||||
let mut updated = routes();
|
||||
updated.peers.push((addr(4), peer(4)));
|
||||
table.set_network(id, updated).unwrap();
|
||||
assert_eq!(table.len(), 1);
|
||||
assert!(table.route(addr(4)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_a_network_takes_its_addresses_with_it() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("leaving");
|
||||
table.set_network(id, routes()).unwrap();
|
||||
table.remove_network(id);
|
||||
|
||||
assert!(table.is_empty());
|
||||
assert_eq!(table.route(addr(2)), None);
|
||||
assert!(!table.may_send_from(id, peer(2), addr(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_local_addresses_are_what_the_interface_should_carry() {
|
||||
let table = RoutingTable::new();
|
||||
table.set_network(network("one"), routes()).unwrap();
|
||||
table
|
||||
.set_network(
|
||||
network("two"),
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||
peers: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
table.local_addresses(),
|
||||
vec![(addr(1), 24), (Ipv4Addr::new(10, 99, 0, 1), 16)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_with_no_address_yet_contributes_nothing() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("waiting");
|
||||
table.set_network(id, NetworkRoutes::default()).unwrap();
|
||||
|
||||
assert!(table.local_addresses().is_empty());
|
||||
assert_eq!(table.route(addr(2)), None);
|
||||
assert_eq!(table.len(), 1, "the network is known, it just has nothing");
|
||||
}
|
||||
|
||||
fn with_exit(offer: bool, via: Option<EndpointId>) -> NetworkRoutes {
|
||||
NetworkRoutes {
|
||||
exit: ExitPolicy { offer, via },
|
||||
..routes()
|
||||
}
|
||||
}
|
||||
|
||||
const INTERNET: Ipv4Addr = Ipv4Addr::new(93, 184, 216, 34);
|
||||
|
||||
#[test]
|
||||
fn without_an_exit_node_the_internet_has_nowhere_to_go() {
|
||||
let table = RoutingTable::new();
|
||||
table.set_network(network("plain"), routes()).unwrap();
|
||||
assert_eq!(table.route(INTERNET), None);
|
||||
assert_eq!(table.exit_via(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_an_exit_node_every_ordinary_destination_goes_to_it() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("client");
|
||||
table
|
||||
.set_network(id, with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
let exit = Route {
|
||||
network: id,
|
||||
peer: peer(2),
|
||||
};
|
||||
assert_eq!(table.route(INTERNET), Some(exit));
|
||||
assert_eq!(table.route(Ipv4Addr::new(192, 168, 1, 10)), Some(exit));
|
||||
assert_eq!(table.exit_via(), Some((id, peer(2))));
|
||||
// A member's own address still goes to that member.
|
||||
assert_eq!(
|
||||
table.route(addr(3)),
|
||||
Some(Route {
|
||||
network: id,
|
||||
peer: peer(3)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_exit_node_is_never_the_answer_for_what_is_not_the_internet() {
|
||||
let table = RoutingTable::new();
|
||||
table
|
||||
.set_network(network("client"), with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
// Ourselves, a free address inside the overlay range, and everything
|
||||
// that is not an ordinary unicast destination.
|
||||
for destination in [
|
||||
addr(1),
|
||||
addr(200),
|
||||
Ipv4Addr::new(127, 0, 0, 1),
|
||||
Ipv4Addr::new(224, 0, 0, 251),
|
||||
Ipv4Addr::new(239, 255, 255, 250),
|
||||
Ipv4Addr::BROADCAST,
|
||||
Ipv4Addr::UNSPECIFIED,
|
||||
Ipv4Addr::new(169, 254, 1, 1),
|
||||
] {
|
||||
assert_eq!(table.route(destination), None, "{destination}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_networks_range_is_not_the_internet_either() {
|
||||
let table = RoutingTable::new();
|
||||
table
|
||||
.set_network(network("client"), with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
table
|
||||
.set_network(
|
||||
network("other"),
|
||||
NetworkRoutes {
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(table.route(Ipv4Addr::new(10, 99, 7, 7)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_exit_choices_resolve_to_the_lowest_network_id() {
|
||||
let table = RoutingTable::new();
|
||||
let (a, b) = (network("one"), network("two"));
|
||||
table
|
||||
.set_network(a, with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
table
|
||||
.set_network(
|
||||
b,
|
||||
NetworkRoutes {
|
||||
exit: ExitPolicy {
|
||||
offer: false,
|
||||
via: Some(peer(9)),
|
||||
},
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (chosen, _) = table.exit_via().unwrap();
|
||||
assert_eq!(chosen, a.min(b));
|
||||
assert_eq!(table.route(INTERNET).unwrap().network, a.min(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_exit_peer_may_send_from_any_internet_address_but_nobody_else_may() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("client");
|
||||
table
|
||||
.set_network(id, with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
|
||||
// What comes back from the internet carries the internet's addresses.
|
||||
assert!(table.may_send_from(id, peer(2), INTERNET));
|
||||
// Another member still speaks only for its own address.
|
||||
assert!(!table.may_send_from(id, peer(3), INTERNET));
|
||||
assert!(table.may_send_from(id, peer(3), addr(3)));
|
||||
// The exit peer cannot pass itself off as somebody else in the
|
||||
// overlay, nor as this agent, nor as something that is not a source.
|
||||
assert!(!table.may_send_from(id, peer(2), addr(3)));
|
||||
assert!(!table.may_send_from(id, peer(2), addr(1)));
|
||||
assert!(!table.may_send_from(id, peer(2), addr(200)));
|
||||
for source in [
|
||||
Ipv4Addr::LOCALHOST,
|
||||
Ipv4Addr::UNSPECIFIED,
|
||||
Ipv4Addr::new(224, 0, 0, 1),
|
||||
Ipv4Addr::BROADCAST,
|
||||
] {
|
||||
assert!(!table.may_send_from(id, peer(2), source), "{source}");
|
||||
}
|
||||
// Without choosing an exit node nobody gets the exception.
|
||||
let plain = RoutingTable::new();
|
||||
plain.set_network(id, routes()).unwrap();
|
||||
assert!(!plain.may_send_from(id, peer(2), INTERNET));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exit_node_takes_a_members_internet_packets_only_while_it_offers() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("server");
|
||||
table.set_network(id, with_exit(false, None)).unwrap();
|
||||
assert!(!table.accepts_exit_traffic(id, INTERNET));
|
||||
|
||||
table.set_exit(
|
||||
id,
|
||||
ExitPolicy {
|
||||
offer: true,
|
||||
via: None,
|
||||
},
|
||||
);
|
||||
assert!(table.accepts_exit_traffic(id, INTERNET));
|
||||
assert!(table.accepts_exit_traffic(id, Ipv4Addr::new(192, 168, 1, 10)));
|
||||
// Never for another member, for this agent, a free overlay address,
|
||||
// or anything that is not an ordinary unicast destination.
|
||||
for destination in [
|
||||
addr(1),
|
||||
addr(2),
|
||||
addr(200),
|
||||
Ipv4Addr::LOCALHOST,
|
||||
Ipv4Addr::new(224, 0, 0, 251),
|
||||
Ipv4Addr::BROADCAST,
|
||||
Ipv4Addr::UNSPECIFIED,
|
||||
] {
|
||||
assert!(
|
||||
!table.accepts_exit_traffic(id, destination),
|
||||
"{destination}"
|
||||
);
|
||||
}
|
||||
// An unknown network offers nothing.
|
||||
assert!(!table.accepts_exit_traffic(network("elsewhere"), INTERNET));
|
||||
|
||||
table.set_exit(id, ExitPolicy::default());
|
||||
assert!(!table.accepts_exit_traffic(id, INTERNET));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offers_are_listed_by_network_with_the_range_each_agreed() {
|
||||
let table = RoutingTable::new();
|
||||
let (a, b) = (network("a"), network("b"));
|
||||
table.set_network(a, with_exit(true, None)).unwrap();
|
||||
table
|
||||
.set_network(
|
||||
b,
|
||||
NetworkRoutes {
|
||||
exit: ExitPolicy {
|
||||
offer: true,
|
||||
via: None,
|
||||
},
|
||||
// No range agreed yet: nothing to masquerade.
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
table.exit_offers(),
|
||||
vec![(a, "10.13.37.0/24".parse().unwrap())]
|
||||
);
|
||||
assert_eq!(
|
||||
table.exit_settings(a),
|
||||
Some((
|
||||
ExitPolicy {
|
||||
offer: true,
|
||||
via: None
|
||||
},
|
||||
Some("10.13.37.0/24".parse().unwrap())
|
||||
))
|
||||
);
|
||||
assert_eq!(table.exit_settings(network("none")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_exit_for_a_network_with_no_entry_is_remembered_by_the_caller() {
|
||||
// The runtime keeps the policy and passes it with the network when
|
||||
// its range is agreed; a setter for a network the table does not know
|
||||
// yet changes nothing.
|
||||
let table = RoutingTable::new();
|
||||
table.set_exit(
|
||||
network("later"),
|
||||
ExitPolicy {
|
||||
offer: true,
|
||||
via: None,
|
||||
},
|
||||
);
|
||||
assert!(table.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,14 @@ pub trait TunFactory: Send + Sync + std::fmt::Debug + 'static {
|
||||
Box::pin(async move { Ok(()) })
|
||||
}
|
||||
|
||||
/// The host rules for this factory's interface, when it manages the host.
|
||||
///
|
||||
/// `None` for an in-memory device or an interface somebody else prepared:
|
||||
/// there is nothing of ours to route or to open in a firewall.
|
||||
fn host_rules(&self) -> Option<Arc<dyn super::hostrules::BroadcastHostRules>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Removes an interface this factory created.
|
||||
///
|
||||
/// Runs on the teardown path, so it reports rather than fails: there is
|
||||
@@ -221,7 +229,31 @@ impl TunDevice for MemoryTun {
|
||||
/// enough here: the agent chose the address, so anything else holding it is a
|
||||
/// problem in its own right.
|
||||
pub fn address_is_local(address: std::net::IpAddr) -> bool {
|
||||
std::net::UdpSocket::bind((address, 0)).is_ok()
|
||||
if std::net::UdpSocket::bind((address, 0)).is_ok() {
|
||||
return true;
|
||||
}
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
{
|
||||
if let Ok(interfaces) = std::panic::catch_unwind(netdev::get_interfaces) {
|
||||
for iface in interfaces {
|
||||
if iface.is_up() {
|
||||
match address {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
if iface.ipv4.iter().any(|ip| ip.addr() == v4) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
if iface.ipv6.iter().any(|ip| ip.addr() == v6) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Creates [`MemoryTun`] devices.
|
||||
@@ -385,7 +417,15 @@ mod system {
|
||||
/// across this call, and such a guard must not span an `await` because
|
||||
/// Linux capabilities are per thread.
|
||||
pub(crate) fn open_tun(request: &TunRequest) -> Result<Arc<dyn TunDevice>, OverlayError> {
|
||||
// macOS mutates neither field below (it neither names the interface nor
|
||||
// sets a platform config), so `mut` is unused only there.
|
||||
#[cfg_attr(target_os = "macos", allow(unused_mut))]
|
||||
let mut config = tun::Configuration::default();
|
||||
// macOS names its tunnels `utunN` and assigns the unit number itself.
|
||||
// A name derived from the network id (`tsun…`) is not a legal utun
|
||||
// name, so there the kernel is left to choose and the real name is read
|
||||
// back below. Linux and Windows take the requested name verbatim.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
config.tun_name(&request.name);
|
||||
#[cfg(target_os = "linux")]
|
||||
config.platform_config(|platform| {
|
||||
@@ -403,9 +443,20 @@ mod system {
|
||||
|
||||
let device = tun::create_as_async(&config).map_err(|err| open_error(&request.name, err))?;
|
||||
|
||||
// The interface name the operating system actually gave us: the
|
||||
// requested one everywhere except macOS, where the kernel chose the
|
||||
// utun unit and so the real name has to be read back off the device.
|
||||
#[cfg(target_os = "macos")]
|
||||
let name = {
|
||||
use tun::AbstractDevice;
|
||||
device.tun_name().unwrap_or_else(|_| request.name.clone())
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let name = request.name.clone();
|
||||
|
||||
let (reader, writer) = tokio::io::split(device);
|
||||
Ok(Arc::new(SystemTun {
|
||||
name: request.name.clone(),
|
||||
name,
|
||||
mtu: request.mtu,
|
||||
reader: Mutex::new(reader),
|
||||
writer: Mutex::new(writer),
|
||||
@@ -432,12 +483,15 @@ mod system {
|
||||
if denied {
|
||||
let hint = if cfg!(target_os = "windows") {
|
||||
"Open PowerShell or Command Prompt with 'Run as administrator' and \
|
||||
start `tsunagi up` there."
|
||||
start `tsng up` there."
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"Start `tsunagi up` as root or grant CAP_NET_ADMIN with \
|
||||
`sudo setcap cap_net_admin+p /path/to/tsunagi`."
|
||||
"Start `tsng up` as root or grant CAP_NET_ADMIN with \
|
||||
`sudo setcap cap_net_admin,cap_net_bind_service+p /path/to/tsunagi`."
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"Creating a utun interface needs root on macOS; start `tsng up` \
|
||||
with `sudo`, or install it as a root LaunchDaemon."
|
||||
} else {
|
||||
"Start `tsunagi up` with the privileges required to create a TUN interface."
|
||||
"Start `tsng up` with the privileges required to create a TUN interface."
|
||||
};
|
||||
return OverlayError::Unavailable(format!(
|
||||
"permission denied while creating the TUN interface `{name}`. \
|
||||
@@ -455,7 +509,7 @@ mod system {
|
||||
fn open_hint() -> &'static str {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
"Creating one needs CAP_NET_ADMIN; grant it with `setcap cap_net_admin+p`, \
|
||||
"Creating one needs CAP_NET_ADMIN; grant it with `setcap cap_net_admin,cap_net_bind_service+p`, \
|
||||
or run with `--no-tun` to keep the tunnels off the operating system."
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -464,7 +518,12 @@ mod system {
|
||||
run as Administrator with the DLL beside the executable, or run with `--no-tun` \
|
||||
to keep the tunnels off the operating system."
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
"Creating a utun interface needs root; start the agent with `sudo` or as a root \
|
||||
LaunchDaemon, or run with `--no-tun` to keep the tunnels off the operating system."
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
"Run with `--no-tun` to keep the tunnels off the operating system."
|
||||
}
|
||||
@@ -480,7 +539,7 @@ mod system {
|
||||
let message = open_error("tsun0", error).to_string();
|
||||
assert!(message.contains("permission denied"), "{message}");
|
||||
assert!(message.contains("tsun0"), "{message}");
|
||||
assert!(message.contains("tsunagi up"), "{message}");
|
||||
assert!(message.contains("tsng up"), "{message}");
|
||||
#[cfg(target_os = "windows")]
|
||||
assert!(message.contains("Run as administrator"), "{message}");
|
||||
#[cfg(target_os = "linux")]
|
||||
File renamed without changes.
File renamed without changes.
@@ -20,7 +20,7 @@ use crate::error::ProtocolError;
|
||||
/// The version in the ALPN is the wire-compatibility version of the control
|
||||
/// protocol. It is independent of the network identity scheme version, so
|
||||
/// bumping it must not change any existing [`crate::NetworkId`].
|
||||
pub const ALPN: &[u8] = b"tsunagi/ctrl/1";
|
||||
pub const ALPN: &[u8] = b"tsunagi/ctrl/8";
|
||||
|
||||
/// ALPN of the tsunagi data plane.
|
||||
///
|
||||
@@ -29,11 +29,14 @@ pub const ALPN: &[u8] = b"tsunagi/ctrl/1";
|
||||
/// saturated or broken data plane cannot disturb control traffic, and the
|
||||
/// transport underneath can be replaced without touching the control protocol.
|
||||
///
|
||||
/// Version 3 fragments logical datagrams below the peer-relay envelope, so
|
||||
/// the overlay's MTU is independent of the current QUIC path MTU. Older data
|
||||
/// versions cannot form a data link; control and persistent identities remain
|
||||
/// compatible. Both ends, including any intermediate peer, must be upgraded.
|
||||
pub const DATA_ALPN: &[u8] = b"tsunagi/data/3";
|
||||
/// Version 4 adds source, destination, hop limit and stable flow id above
|
||||
/// transport fragmentation. Version 5 adds the end-to-end protocol to that
|
||||
/// envelope, so a tunnel no longer depends on the kind of link under it, and
|
||||
/// a first-claimed time to a signed hostname claim. All members must upgrade
|
||||
/// together. Version 6 uses one fixed inner WireGuard channel and independent
|
||||
/// external carrier capabilities. Control authentication, persistent identities
|
||||
/// and network configuration do not change.
|
||||
pub const DATA_ALPN: &[u8] = b"tsunagi/data/6";
|
||||
|
||||
/// Largest plugin protocol identifier accepted when opening a data channel.
|
||||
pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
|
||||
@@ -42,7 +45,9 @@ pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
|
||||
pub const MAX_SIGNATURE_LEN: usize = 64;
|
||||
|
||||
/// Control protocol version carried inside the handshake.
|
||||
pub const PROTOCOL_VERSION: u16 = 1;
|
||||
pub const PROTOCOL_VERSION: u16 = 8;
|
||||
|
||||
pub use crate::dataplane::transport::{MAX_TRANSPORT_PAYLOAD, TransportPayload};
|
||||
|
||||
/// First message of the handshake, sent by the initiator.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -74,6 +79,13 @@ pub struct AuthProof {
|
||||
/// What this agent tells a peer about itself.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Announcement {
|
||||
/// Local opt-in to receive LAN broadcasts in this network.
|
||||
pub broadcast: bool,
|
||||
/// This agent offers itself as an exit node in this network: members may
|
||||
/// send all their internet traffic through it. Off unless its owner
|
||||
/// turned it on, and only said once the rules that make it work are in
|
||||
/// place.
|
||||
pub exit_node: bool,
|
||||
/// Human-readable hostname. A mutable binding, not an identity.
|
||||
pub hostname: String,
|
||||
/// Announced IP plugin capabilities. Opaque to the core.
|
||||
@@ -106,6 +118,13 @@ pub struct DataOpenAck {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum ControlMessage {
|
||||
/// Private transport negotiation; never persisted or rebroadcast.
|
||||
Transport {
|
||||
/// Transport protocol id; the payload stays opaque to the core.
|
||||
protocol: String,
|
||||
/// Secret negotiation data, redacted in diagnostics.
|
||||
payload: TransportPayload,
|
||||
},
|
||||
/// Hostname and capability announcement.
|
||||
Announce(Announcement),
|
||||
/// A small request used to verify that the exchange works.
|
||||
@@ -154,8 +173,8 @@ pub enum ControlMessage {
|
||||
/// and repairs itself; and volatile, so it lives here and not in
|
||||
/// signed state, which is for what has to survive a member being away.
|
||||
Reachable {
|
||||
/// The peers, as raw endpoint keys.
|
||||
peers: Vec<[u8; 32]>,
|
||||
/// Direct links, scoped to the plugin protocol they actually carry.
|
||||
links: Vec<ReachableLink>,
|
||||
},
|
||||
/// Graceful goodbye.
|
||||
///
|
||||
@@ -164,6 +183,16 @@ pub enum ControlMessage {
|
||||
/// Short free-text reason.
|
||||
reason: String,
|
||||
},
|
||||
/// One complete signed VIP field snapshot, merged atomically.
|
||||
Vips {
|
||||
/// Signed registers bounded by the per-VIP field limit.
|
||||
entries: Vec<crate::vip::VipEntry>,
|
||||
},
|
||||
/// Signed cleanup markers, each proving the retired claim version.
|
||||
Retirements {
|
||||
/// Bounded by the per-message signed-state limit.
|
||||
markers: Vec<crate::state::SignedRetirement>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Somewhere a member has been seen.
|
||||
@@ -183,6 +212,15 @@ pub struct PeerHint {
|
||||
pub addrs: Vec<String>,
|
||||
}
|
||||
|
||||
/// One directly observed edge, advertised only by its authenticated source.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ReachableLink {
|
||||
/// The other end of this transport link.
|
||||
pub peer: [u8; 32],
|
||||
/// Protocol carried by the link.
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
/// Longest address text accepted in a hint.
|
||||
pub const MAX_HINT_ADDR_LEN: usize = 128;
|
||||
|
||||
@@ -247,6 +285,48 @@ pub fn validate_capability(
|
||||
/// network, the other networks or the agent.
|
||||
pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), ProtocolError> {
|
||||
match message {
|
||||
ControlMessage::Vips { entries } => {
|
||||
check_len("vip.fields", entries.len(), limits.max_vip_fields)?;
|
||||
for e in entries {
|
||||
check_len("vip.id", e.vip.len(), 32)?;
|
||||
check_len("vip.key", e.key.len(), 160)?;
|
||||
check_len("vip.value", e.value.as_ref().map_or(0, Vec::len), 256)?;
|
||||
check_len("vip.signature", e.signature.len(), 64)?;
|
||||
}
|
||||
}
|
||||
ControlMessage::Retirements { markers } => {
|
||||
check_len("retirements", markers.len(), limits.max_state_records)?;
|
||||
for marker in markers {
|
||||
check_len(
|
||||
"retirement.signature",
|
||||
marker.signature.len(),
|
||||
MAX_SIGNATURE_LEN,
|
||||
)?;
|
||||
check_len(
|
||||
"retirement.target.signature",
|
||||
marker.target.signature.len(),
|
||||
MAX_SIGNATURE_LEN,
|
||||
)?;
|
||||
if let Some(name) = marker.target.body.hostname() {
|
||||
check_len(
|
||||
"retirement.target.hostname",
|
||||
name.len(),
|
||||
limits.max_hostname_len,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
ControlMessage::Transport { protocol, payload } => {
|
||||
if protocol.is_empty() {
|
||||
return Err(ProtocolError::Malformed("empty transport protocol"));
|
||||
}
|
||||
check_len("transport.protocol", protocol.len(), MAX_PROTOCOL_ID_LEN)?;
|
||||
check_len(
|
||||
"transport.payload",
|
||||
payload.as_bytes().len(),
|
||||
limits.max_transport_payload_len.min(MAX_TRANSPORT_PAYLOAD),
|
||||
)?;
|
||||
}
|
||||
ControlMessage::Peers { peers } => {
|
||||
check_len("peers", peers.len(), limits.max_state_records)?;
|
||||
for hint in peers {
|
||||
@@ -256,10 +336,18 @@ pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), Protoco
|
||||
}
|
||||
}
|
||||
}
|
||||
ControlMessage::Reachable { peers } => {
|
||||
// One entry per member at most; a sender claiming more than a
|
||||
// network can hold is not describing this network.
|
||||
check_len("reachable.peers", peers.len(), limits.max_state_records)?;
|
||||
ControlMessage::Reachable { links } => {
|
||||
check_len("reachable.links", links.len(), limits.max_state_records)?;
|
||||
for link in links {
|
||||
if link.protocol.is_empty() {
|
||||
return Err(ProtocolError::Malformed("empty routing protocol"));
|
||||
}
|
||||
check_len(
|
||||
"reachable.protocol",
|
||||
link.protocol.len(),
|
||||
MAX_PROTOCOL_ID_LEN,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
ControlMessage::Announce(announcement) => {
|
||||
check_len(
|
||||
@@ -295,10 +383,13 @@ pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), Protoco
|
||||
/// A short, stable label for a message kind, for metrics and diagnostics.
|
||||
pub fn kind(message: &ControlMessage) -> &'static str {
|
||||
match message {
|
||||
ControlMessage::Transport { .. } => "transport",
|
||||
ControlMessage::Announce(_) => "announce",
|
||||
ControlMessage::Ping { .. } => "ping",
|
||||
ControlMessage::Pong { .. } => "pong",
|
||||
ControlMessage::State { .. } => "state",
|
||||
ControlMessage::Retirements { .. } => "retirements",
|
||||
ControlMessage::Vips { .. } => "vips",
|
||||
ControlMessage::Reachable { .. } => "reachable",
|
||||
ControlMessage::Peers { .. } => "peers",
|
||||
ControlMessage::Bye { .. } => "bye",
|
||||
File renamed without changes.
File renamed without changes.
@@ -28,16 +28,25 @@
|
||||
//! replica that has the record needs nothing else to interpret it, and
|
||||
//! recovery never depends on replaying a chain from the beginning.
|
||||
//!
|
||||
//! Any member may also sign a separate retirement of another author's proved
|
||||
//! claim version. It suppresses stale reservations without changing that
|
||||
//! author's record, and a newer claim remains valid.
|
||||
//!
|
||||
//! # What this slice does not do yet
|
||||
//!
|
||||
//! Compaction, revocation of a whole author, and snapshots covering more than
|
||||
//! one record type. See `docs/sync-model.md` for the shape those take.
|
||||
//! Compaction and permanent revocation of a whole author. See
|
||||
//! `docs/sync-model.md` for the implemented cleanup semantics.
|
||||
|
||||
pub mod allocator;
|
||||
/// Signed shared virtual service state and its merge rules.
|
||||
pub mod vip;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
mod retirement;
|
||||
pub use retirement::{MAX_RETIREMENT_LEN, SignedRetirement};
|
||||
|
||||
use iroh::{EndpointId, SecretKey, Signature};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -109,12 +118,8 @@ impl std::str::FromStr for Ipv4Range {
|
||||
}
|
||||
}
|
||||
|
||||
/// The IPv4 overlay range used unless something else is configured or agreed.
|
||||
///
|
||||
/// A small, specific `/24`: memorable, and far less likely to overlap a
|
||||
/// network the machine is already on than taking a whole `/8` or `/10` would
|
||||
/// be. Because addresses are allocated rather than derived, 254 of them is
|
||||
/// plenty for the size of network this is for.
|
||||
/// The legacy default range, retained for existing networks and explicit use.
|
||||
/// New networks use [`derived_ipv4_range`] by default.
|
||||
pub const DEFAULT_IPV4_RANGE: Ipv4Range = Ipv4Range {
|
||||
base: Ipv4Addr::new(10, 13, 37, 0),
|
||||
prefix_len: 24,
|
||||
@@ -122,15 +127,10 @@ pub const DEFAULT_IPV4_RANGE: Ipv4Range = Ipv4Range {
|
||||
|
||||
/// A range derived from a network's own identity.
|
||||
///
|
||||
/// One agent has one interface, so two of its networks cannot both use the
|
||||
/// configured range. The second needs another one — and it cannot be picked
|
||||
/// locally, because every member has to arrive at the same answer without
|
||||
/// being told. So it comes from the network id, which every member already
|
||||
/// has and nobody can choose: the same network derives the same range on
|
||||
/// every device.
|
||||
///
|
||||
/// Inside 10/8 like the default, and never equal to it, so the first
|
||||
/// network keeps what it has always had.
|
||||
/// Every member independently derives the same pseudorandom `/24` in 10/8,
|
||||
/// including isolated first members. Stored signed ranges take precedence.
|
||||
/// The derivation is kept stable for networks already using it; the legacy
|
||||
/// default is excluded. Distinct network ids can still produce collisions.
|
||||
pub fn derived_ipv4_range(network: NetworkId) -> Ipv4Range {
|
||||
let bytes = network.as_bytes();
|
||||
let second = bytes[0];
|
||||
@@ -150,7 +150,7 @@ pub fn derived_ipv4_range(network: NetworkId) -> Ipv4Range {
|
||||
}
|
||||
|
||||
/// Frozen domain separator for the bytes a record signature covers.
|
||||
pub const RECORD_DOMAIN: &str = "tsunagi-signed-record-v2";
|
||||
pub const RECORD_DOMAIN: &str = "tsunagi-signed-record-v3";
|
||||
|
||||
/// Longest hostname a record may carry.
|
||||
///
|
||||
@@ -207,6 +207,18 @@ pub enum RecordBody {
|
||||
range: Option<Ipv4Range>,
|
||||
/// The name this author answers to.
|
||||
hostname: Option<String>,
|
||||
/// When this author first claimed that name, in milliseconds since
|
||||
/// the Unix epoch, kept across every later version that keeps the
|
||||
/// name. It is what makes a name belong to whoever claimed it first
|
||||
/// rather than to whoever happens to sort lowest, so a member that
|
||||
/// arrives later with the same name cannot take it over.
|
||||
///
|
||||
/// The author's own word: a member with a wrong clock can claim an
|
||||
/// earlier time. That costs it nothing it could not do by other
|
||||
/// means, since anybody who knows the network secret is a member,
|
||||
/// and it cannot take an address or reach anything it was not
|
||||
/// already entitled to.
|
||||
hostname_since: Option<u64>,
|
||||
},
|
||||
/// This author gives up everything it claimed.
|
||||
///
|
||||
@@ -243,6 +255,14 @@ impl RecordBody {
|
||||
}
|
||||
}
|
||||
|
||||
/// When the author first claimed the hostname, if it has one.
|
||||
pub fn hostname_since(&self) -> Option<u64> {
|
||||
match self {
|
||||
RecordBody::Claim { hostname_since, .. } => *hostname_since,
|
||||
RecordBody::Release => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(96);
|
||||
match self {
|
||||
@@ -250,6 +270,7 @@ impl RecordBody {
|
||||
address,
|
||||
range,
|
||||
hostname,
|
||||
hostname_since,
|
||||
} => {
|
||||
push_lp(&mut out, b"claim");
|
||||
push_opt(
|
||||
@@ -270,6 +291,13 @@ impl RecordBody {
|
||||
.as_deref(),
|
||||
);
|
||||
push_opt(&mut out, hostname.as_deref().map(str::as_bytes));
|
||||
push_opt(
|
||||
&mut out,
|
||||
hostname_since
|
||||
.map(|since| since.to_be_bytes())
|
||||
.as_ref()
|
||||
.map(|bytes| &bytes[..]),
|
||||
);
|
||||
}
|
||||
RecordBody::Release => {
|
||||
push_lp(&mut out, b"release");
|
||||
@@ -391,8 +419,12 @@ impl SignedRecord {
|
||||
address,
|
||||
range,
|
||||
hostname,
|
||||
hostname_since,
|
||||
} = &self.body
|
||||
{
|
||||
if hostname.is_none() && hostname_since.is_some() {
|
||||
return Err(StateError::Malformed("a time was claimed for no hostname"));
|
||||
}
|
||||
// Bounds before anything is believed, because all of this came
|
||||
// off the network.
|
||||
if let Some(range) = range {
|
||||
@@ -465,6 +497,7 @@ pub enum Merged {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StateSet {
|
||||
records: HashMap<EndpointId, SignedRecord>,
|
||||
retirements: HashMap<EndpointId, SignedRetirement>,
|
||||
}
|
||||
|
||||
impl StateSet {
|
||||
@@ -477,6 +510,7 @@ impl StateSet {
|
||||
pub fn from_verified(records: impl IntoIterator<Item = (EndpointId, SignedRecord)>) -> Self {
|
||||
Self {
|
||||
records: records.into_iter().collect(),
|
||||
retirements: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,6 +539,52 @@ impl StateSet {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Visible records, excluding versions retired by authenticated members.
|
||||
pub fn active_records(&self) -> Vec<SignedRecord> {
|
||||
self.records()
|
||||
.into_iter()
|
||||
.filter(|r| r.author_id().is_ok_and(|id| !self.is_retired(&id)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the current known version is at or below a proved retirement.
|
||||
pub fn is_retired(&self, author: &EndpointId) -> bool {
|
||||
self.retirements
|
||||
.get(author)
|
||||
.is_some_and(|m| self.version_of(author) <= m.target.version)
|
||||
}
|
||||
|
||||
/// Strongest retained cleanup proof for an identity.
|
||||
pub fn retirement_of(&self, target: &EndpointId) -> Option<&SignedRetirement> {
|
||||
self.retirements.get(target)
|
||||
}
|
||||
|
||||
/// Durable markers in target-id order, including markers surpassed by a fresh claim.
|
||||
pub fn retirements(&self) -> Vec<SignedRetirement> {
|
||||
let mut markers: Vec<_> = self.retirements.values().cloned().collect();
|
||||
markers.sort_by_key(|m| m.target.author);
|
||||
markers
|
||||
}
|
||||
|
||||
/// Accepts any member's signed cleanup after checking both signatures.
|
||||
pub fn merge_retirement(
|
||||
&mut self,
|
||||
network: NetworkId,
|
||||
marker: SignedRetirement,
|
||||
max: usize,
|
||||
) -> Result<bool, StateError> {
|
||||
let target = marker.verify(network)?;
|
||||
if let Some(old) = self.retirements.get(&target) {
|
||||
if !marker.supersedes(old) {
|
||||
return Ok(false);
|
||||
}
|
||||
} else if self.retirements.len() >= max {
|
||||
return Err(StateError::Malformed("retirement capacity reached"));
|
||||
}
|
||||
self.retirements.insert(target, marker);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Merges one record, verifying it first.
|
||||
///
|
||||
/// Merging is into the existing set, never a wholesale replacement, and an
|
||||
@@ -568,6 +648,9 @@ impl StateSet {
|
||||
pub fn address_holders(&self) -> HashMap<Ipv4Addr, EndpointId> {
|
||||
let mut holders: HashMap<Ipv4Addr, EndpointId> = HashMap::new();
|
||||
for (author, record) in &self.records {
|
||||
if self.is_retired(author) {
|
||||
continue;
|
||||
}
|
||||
let Some(address) = record.body.claimed_address() else {
|
||||
continue;
|
||||
};
|
||||
@@ -585,25 +668,36 @@ impl StateSet {
|
||||
|
||||
/// Who currently holds each claimed hostname.
|
||||
///
|
||||
/// The same rule as addresses, for the same reason: a name is owned, two
|
||||
/// members may claim one, and every replica has to reach the same answer
|
||||
/// about who has it without asking anybody.
|
||||
/// A name belongs to the member that claimed it first, and the others
|
||||
/// that claim it keep working by address but are not found by that name.
|
||||
/// "First" is the time each author signed into its own claim, with the
|
||||
/// lower id deciding a tie and a claim with no time counting as last, so
|
||||
/// every replica reaches the same answer about who has it without
|
||||
/// asking anybody. It is a pure function of the records: nothing depends
|
||||
/// on the order they arrived in, so there is no race to lose.
|
||||
pub fn hostname_holders(&self) -> HashMap<String, EndpointId> {
|
||||
let mut holders: HashMap<String, EndpointId> = HashMap::new();
|
||||
let mut holders: HashMap<String, (u64, EndpointId)> = HashMap::new();
|
||||
for (author, record) in &self.records {
|
||||
if self.is_retired(author) {
|
||||
continue;
|
||||
}
|
||||
let Some(hostname) = record.body.hostname() else {
|
||||
continue;
|
||||
};
|
||||
let claim = (record.body.hostname_since().unwrap_or(u64::MAX), *author);
|
||||
holders
|
||||
.entry(hostname.to_string())
|
||||
.and_modify(|held| {
|
||||
if author.as_bytes() < held.as_bytes() {
|
||||
*held = *author;
|
||||
if (claim.0, claim.1.as_bytes()) < (held.0, held.1.as_bytes()) {
|
||||
*held = claim;
|
||||
}
|
||||
})
|
||||
.or_insert(*author);
|
||||
.or_insert(claim);
|
||||
}
|
||||
holders
|
||||
.into_iter()
|
||||
.map(|(hostname, (_, author))| (hostname, author))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The hostname an author holds, if it holds one uncontested.
|
||||
@@ -612,6 +706,22 @@ impl StateSet {
|
||||
(self.hostname_holders().get(hostname) == Some(author)).then_some(hostname)
|
||||
}
|
||||
|
||||
/// The name an author claimed and the member that holds it instead, when
|
||||
/// somebody who claimed it earlier does.
|
||||
pub fn hostname_taken(&self, author: &EndpointId) -> Option<(&str, EndpointId)> {
|
||||
if self.is_retired(author) {
|
||||
return None;
|
||||
}
|
||||
let hostname = self.records.get(author)?.body.hostname()?;
|
||||
let holder = *self.hostname_holders().get(hostname)?;
|
||||
(holder != *author).then_some((hostname, holder))
|
||||
}
|
||||
|
||||
/// When an author first claimed the name it holds now, if it has one.
|
||||
pub fn hostname_since_of(&self, author: &EndpointId) -> Option<u64> {
|
||||
self.records.get(author)?.body.hostname_since()
|
||||
}
|
||||
|
||||
/// The address an author holds, if it holds one uncontested.
|
||||
pub fn address_of(&self, author: &EndpointId) -> Option<Ipv4Addr> {
|
||||
let address = self.records.get(author)?.body.claimed_address()?;
|
||||
@@ -620,21 +730,44 @@ impl StateSet {
|
||||
|
||||
/// The range the network settled on, if anybody has said.
|
||||
///
|
||||
/// When claims disagree, the one from the lowest author id wins, so every
|
||||
/// replica reaches the same answer. A participant joining later therefore
|
||||
/// adopts the range already in use instead of imposing its own.
|
||||
/// A legacy or explicit range takes precedence over the derived default,
|
||||
/// so a fresh member's default cannot renumber an established network.
|
||||
/// Other disagreements use the lowest author id, independently of arrival
|
||||
/// order. The signed record format and network identity remain unchanged.
|
||||
pub fn agreed_range(&self) -> Option<Ipv4Range> {
|
||||
let mut authors: Vec<&EndpointId> = self.records.keys().collect();
|
||||
authors.sort_by_key(|author| *author.as_bytes());
|
||||
authors
|
||||
.into_iter()
|
||||
.find_map(|author| self.records.get(author)?.body.range())
|
||||
self.records
|
||||
.iter()
|
||||
.chain(
|
||||
self.retirements
|
||||
.iter()
|
||||
.filter(|(author, _)| !self.records.contains_key(*author))
|
||||
.map(|(author, marker)| (author, &marker.target)),
|
||||
)
|
||||
.filter_map(|(author, record)| {
|
||||
let range = record.body.range()?;
|
||||
let derived = derived_ipv4_range(NetworkId::from_bytes(record.network));
|
||||
Some(((range == derived, *author.as_bytes()), range))
|
||||
})
|
||||
.min_by_key(|(priority, _)| *priority)
|
||||
.map(|(_, range)| range)
|
||||
}
|
||||
|
||||
/// The highest version this author has published, as far as is known.
|
||||
pub fn version_of(&self, author: &EndpointId) -> u64 {
|
||||
self.records.get(author).map_or(0, |record| record.version)
|
||||
}
|
||||
|
||||
/// Highest witnessed author counter across claims and authored cleanup.
|
||||
/// This differs from the claim version used to decide retirement.
|
||||
pub fn authored_version(&self, author: &EndpointId) -> u64 {
|
||||
self.retirements
|
||||
.values()
|
||||
.filter(|m| m.author == *author.as_bytes())
|
||||
.map(|m| m.version)
|
||||
.chain(std::iter::once(self.version_of(author)))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -661,6 +794,7 @@ mod tests {
|
||||
address: Some(address.parse().unwrap()),
|
||||
range: Some(range()),
|
||||
hostname: None,
|
||||
hostname_since: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,6 +854,7 @@ mod tests {
|
||||
address: Some("10.99.0.1".parse().unwrap()),
|
||||
range: Some(range()),
|
||||
hostname: None,
|
||||
hostname_since: None,
|
||||
},
|
||||
);
|
||||
assert!(matches!(outside.verify(id), Err(StateError::Malformed(_))));
|
||||
@@ -735,6 +870,7 @@ mod tests {
|
||||
prefix_len: 31,
|
||||
}),
|
||||
hostname: None,
|
||||
hostname_since: None,
|
||||
},
|
||||
);
|
||||
assert!(matches!(no_hosts.verify(id), Err(StateError::Malformed(_))));
|
||||
@@ -776,6 +912,7 @@ mod tests {
|
||||
address: None,
|
||||
range: None,
|
||||
hostname: Some(bad.to_string()),
|
||||
hostname_since: None,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
@@ -797,6 +934,7 @@ mod tests {
|
||||
address: Some("10.13.37.5".parse().unwrap()),
|
||||
range: None,
|
||||
hostname: None,
|
||||
hostname_since: None,
|
||||
},
|
||||
);
|
||||
assert!(matches!(record.verify(id), Err(StateError::Malformed(_))));
|
||||
@@ -818,6 +956,7 @@ mod tests {
|
||||
address: None,
|
||||
range: None,
|
||||
hostname: Some("music".into()),
|
||||
hostname_since: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
@@ -860,6 +999,7 @@ mod tests {
|
||||
address: None,
|
||||
range: None,
|
||||
hostname: Some(name.to_string()),
|
||||
hostname_since: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
@@ -879,6 +1019,109 @@ mod tests {
|
||||
assert_eq!(set.hostname_of(&secret.public()), Some("new"));
|
||||
}
|
||||
|
||||
fn named_at(
|
||||
secret: &SecretKey,
|
||||
id: NetworkId,
|
||||
version: u64,
|
||||
name: &str,
|
||||
since: u64,
|
||||
) -> SignedRecord {
|
||||
SignedRecord::sign(
|
||||
secret,
|
||||
id,
|
||||
version,
|
||||
RecordBody::Claim {
|
||||
address: None,
|
||||
range: None,
|
||||
hostname: Some(name.into()),
|
||||
hostname_since: Some(since),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_belongs_to_whoever_claimed_it_first_whatever_their_ids_or_arrival_order() {
|
||||
let id = network("naming");
|
||||
let mut keys = [SecretKey::generate(), SecretKey::generate()];
|
||||
keys.sort_by_key(|key| *key.public().as_bytes());
|
||||
let [low, high] = keys;
|
||||
|
||||
// The member with the higher id claimed first, so it holds the name,
|
||||
// and the lower id does not take it back by sorting first.
|
||||
let early = named_at(&high, id, 1, "music", 1_000);
|
||||
let late = named_at(&low, id, 1, "music", 2_000);
|
||||
for order in [[&early, &late], [&late, &early]] {
|
||||
let mut set = StateSet::new();
|
||||
for record in order {
|
||||
set.merge(id, (*record).clone()).unwrap();
|
||||
}
|
||||
assert_eq!(set.hostname_of(&high.public()), Some("music"));
|
||||
assert_eq!(set.hostname_of(&low.public()), None);
|
||||
assert_eq!(
|
||||
set.hostname_taken(&low.public()),
|
||||
Some(("music", high.public()))
|
||||
);
|
||||
assert_eq!(set.hostname_taken(&high.public()), None);
|
||||
}
|
||||
|
||||
// At the same instant the lower id decides, the same way everywhere.
|
||||
let mut set = StateSet::new();
|
||||
set.merge(id, named_at(&high, id, 1, "music", 5)).unwrap();
|
||||
set.merge(id, named_at(&low, id, 1, "music", 5)).unwrap();
|
||||
assert_eq!(set.hostname_of(&low.public()), Some("music"));
|
||||
assert_eq!(set.hostname_of(&high.public()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_loses_is_found_again_once_the_holder_lets_it_go() {
|
||||
let id = network("naming");
|
||||
let first = SecretKey::generate();
|
||||
let second = SecretKey::generate();
|
||||
let mut set = StateSet::new();
|
||||
set.merge(id, named_at(&first, id, 1, "music", 1)).unwrap();
|
||||
set.merge(id, named_at(&second, id, 1, "music", 2)).unwrap();
|
||||
assert_eq!(set.hostname_of(&second.public()), None);
|
||||
|
||||
// Renaming starts the clock afresh, so it cannot be used to jump the
|
||||
// queue for a name somebody else already holds.
|
||||
set.merge(id, named_at(&first, id, 2, "other", 3)).unwrap();
|
||||
assert_eq!(set.hostname_of(&second.public()), Some("music"));
|
||||
set.merge(id, named_at(&first, id, 3, "music", 4)).unwrap();
|
||||
assert_eq!(set.hostname_of(&second.public()), Some("music"));
|
||||
assert_eq!(set.hostname_of(&first.public()), None);
|
||||
|
||||
// And a release hands it on.
|
||||
set.merge(id, SignedRecord::sign(&second, id, 2, RecordBody::Release))
|
||||
.unwrap();
|
||||
assert_eq!(set.hostname_of(&first.public()), Some("music"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_time_with_no_name_is_not_a_valid_claim() {
|
||||
let id = network("naming");
|
||||
let secret = SecretKey::generate();
|
||||
let record = SignedRecord::sign(
|
||||
&secret,
|
||||
id,
|
||||
1,
|
||||
RecordBody::Claim {
|
||||
address: None,
|
||||
range: None,
|
||||
hostname: None,
|
||||
hostname_since: Some(1),
|
||||
},
|
||||
);
|
||||
assert!(matches!(record.verify(id), Err(StateError::Malformed(_))));
|
||||
|
||||
// The time is signed: moving it breaks the signature, so nobody can
|
||||
// improve another member's place in the queue.
|
||||
let mut forged = named_at(&secret, id, 1, "music", 9_000);
|
||||
if let RecordBody::Claim { hostname_since, .. } = &mut forged.body {
|
||||
*hostname_since = Some(1);
|
||||
}
|
||||
assert_eq!(forged.verify(id).unwrap_err(), StateError::BadSignature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_gives_up_the_name_as_well_as_the_address() {
|
||||
let secret = SecretKey::generate();
|
||||
@@ -894,6 +1137,7 @@ mod tests {
|
||||
address: Some("10.13.37.5".parse().unwrap()),
|
||||
range: Some(range()),
|
||||
hostname: Some("music".into()),
|
||||
hostname_since: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1057,6 +1301,7 @@ mod tests {
|
||||
address: Some("10.99.0.7".parse().unwrap()),
|
||||
range: Some(custom),
|
||||
hostname: None,
|
||||
hostname_since: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1098,7 +1343,7 @@ mod tests {
|
||||
assert_eq!(once.base.octets()[0], 10, "inside 10/8 like the default");
|
||||
assert_ne!(
|
||||
once, DEFAULT_IPV4_RANGE,
|
||||
"the default belongs to whichever network asked first"
|
||||
"the legacy default remains excluded"
|
||||
);
|
||||
|
||||
// A different network derives a different range, which is the
|
||||
@@ -1110,4 +1355,34 @@ mod tests {
|
||||
.network_id();
|
||||
assert_ne!(once, derived_ipv4_range(other));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_derived_default_cannot_displace_a_legacy_or_explicit_range() {
|
||||
let id = network("range-migration");
|
||||
let a = SecretKey::generate();
|
||||
let b = SecretKey::generate();
|
||||
let (low, high) = if a.public().as_bytes() < b.public().as_bytes() {
|
||||
(a, b)
|
||||
} else {
|
||||
(b, a)
|
||||
};
|
||||
for established in [DEFAULT_IPV4_RANGE, "10.90.0.0/16".parse().unwrap()] {
|
||||
let body = |range: Ipv4Range| RecordBody::Claim {
|
||||
address: Some(Ipv4Addr::from(u32::from(range.base) + 1)),
|
||||
range: Some(range),
|
||||
hostname: None,
|
||||
hostname_since: None,
|
||||
};
|
||||
let fresh = SignedRecord::sign(&low, id, 1, body(derived_ipv4_range(id)));
|
||||
let existing = SignedRecord::sign(&high, id, 1, body(established));
|
||||
for records in [
|
||||
vec![fresh.clone(), existing.clone()],
|
||||
vec![existing.clone(), fresh.clone()],
|
||||
] {
|
||||
let mut state = StateSet::new();
|
||||
state.merge_all(id, records);
|
||||
assert_eq!(state.agreed_range(), Some(established));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! A member may retire an observed claim, never another identity's future.
|
||||
use iroh::{EndpointId, SecretKey, Signature};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{RecordBody, SignedRecord, StateError, push_lp};
|
||||
use crate::NetworkId;
|
||||
|
||||
/// Maximum encoded retirement size, including its bounded claim proof.
|
||||
pub const MAX_RETIREMENT_LEN: usize = 1024;
|
||||
|
||||
/// A network member's signed retirement of a proved claim version.
|
||||
///
|
||||
/// The original author's signed claim proves the cutoff really existed.
|
||||
/// Newer claims remain valid. This is neither a forged Release nor a ban.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SignedRetirement {
|
||||
/// Device identity of the member requesting cleanup.
|
||||
pub author: [u8; 32],
|
||||
/// Network in which cleanup applies.
|
||||
pub network: [u8; 32],
|
||||
/// Requester's durable author counter, committed with this marker.
|
||||
pub version: u64,
|
||||
/// The target's original signed claim; bounds the retired version.
|
||||
pub target: SignedRecord,
|
||||
/// Requester's Ed25519 signature over this marker and its proof.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
impl SignedRetirement {
|
||||
fn canonical_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(768);
|
||||
push_lp(&mut out, b"tsunagi-node-retirement/v1");
|
||||
push_lp(&mut out, &self.network);
|
||||
push_lp(&mut out, &self.author);
|
||||
push_lp(&mut out, &self.version.to_be_bytes());
|
||||
// Bind every field of the original signed claim, including its signature.
|
||||
push_lp(&mut out, &self.target.network);
|
||||
push_lp(&mut out, &self.target.author);
|
||||
push_lp(&mut out, &self.target.version.to_be_bytes());
|
||||
push_lp(&mut out, &self.target.body.canonical());
|
||||
push_lp(&mut out, &self.target.signature);
|
||||
out
|
||||
}
|
||||
|
||||
/// Signs a marker with the requesting member's persistent device key.
|
||||
pub fn sign(
|
||||
secret: &SecretKey,
|
||||
network: NetworkId,
|
||||
version: u64,
|
||||
target: SignedRecord,
|
||||
) -> Self {
|
||||
let mut marker = Self {
|
||||
author: *secret.public().as_bytes(),
|
||||
network: *network.as_bytes(),
|
||||
version,
|
||||
target,
|
||||
signature: Vec::new(),
|
||||
};
|
||||
marker.signature = secret.sign(&marker.canonical_bytes()).to_bytes().to_vec();
|
||||
marker
|
||||
}
|
||||
|
||||
/// Verifies both authors, scope and a finite, genuinely signed cutoff.
|
||||
pub fn verify(&self, network: NetworkId) -> Result<EndpointId, StateError> {
|
||||
if self.network != *network.as_bytes() {
|
||||
return Err(StateError::WrongNetwork);
|
||||
}
|
||||
if self.version == 0 || self.version > i64::MAX as u64 {
|
||||
return Err(StateError::Malformed("retirement version is out of range"));
|
||||
}
|
||||
if !matches!(self.target.body, RecordBody::Claim { .. }) {
|
||||
return Err(StateError::Malformed("only a claim can be retired"));
|
||||
}
|
||||
let target = self.target.verify(network)?;
|
||||
let author = EndpointId::from_bytes(&self.author).map_err(|_| StateError::BadAuthor)?;
|
||||
if author == target {
|
||||
return Err(StateError::Malformed(
|
||||
"use Release to retire your own claim",
|
||||
));
|
||||
}
|
||||
let raw: [u8; Signature::LENGTH] =
|
||||
self.signature
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| StateError::BadSignatureLength {
|
||||
expected: Signature::LENGTH,
|
||||
})?;
|
||||
author
|
||||
.verify(&self.canonical_bytes(), &Signature::from_bytes(&raw))
|
||||
.map_err(|_| StateError::BadSignature)?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// A deterministic strongest marker per target, independent of arrival order.
|
||||
pub(crate) fn supersedes(&self, other: &Self) -> bool {
|
||||
self.target.version > other.target.version
|
||||
|| (self.target.version == other.target.version
|
||||
&& (self.canonical_bytes(), &self.signature)
|
||||
< (other.canonical_bytes(), &other.signature))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::state::{Ipv4Range, StateSet};
|
||||
|
||||
fn claim(key: &SecretKey, id: NetworkId, version: u64) -> SignedRecord {
|
||||
SignedRecord::sign(
|
||||
key,
|
||||
id,
|
||||
version,
|
||||
RecordBody::Claim {
|
||||
address: Some("10.23.0.2".parse().unwrap()),
|
||||
range: Some("10.23.0.0/24".parse::<Ipv4Range>().unwrap()),
|
||||
hostname: Some("retired".into()),
|
||||
hostname_since: Some(123),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_release_claims_in_any_merge_order_and_older_snapshots_cannot_restore_them() {
|
||||
let id = NetworkId::from_bytes([7; 32]);
|
||||
let target = SecretKey::generate();
|
||||
let cleaner = SecretKey::generate();
|
||||
let record = claim(&target, id, 5);
|
||||
let marker = SignedRetirement::sign(&cleaner, id, 8, record.clone());
|
||||
for first in [true, false] {
|
||||
let mut state = StateSet::new();
|
||||
if first {
|
||||
state.merge_retirement(id, marker.clone(), 16).unwrap();
|
||||
}
|
||||
state.merge(id, record.clone()).unwrap();
|
||||
if !first {
|
||||
state.merge_retirement(id, marker.clone(), 16).unwrap();
|
||||
}
|
||||
assert!(state.address_holders().is_empty());
|
||||
assert!(state.hostname_holders().is_empty());
|
||||
state.merge(id, claim(&target, id, 4)).unwrap();
|
||||
assert!(state.is_retired(&target.public()));
|
||||
assert!(
|
||||
state.agreed_range().is_some(),
|
||||
"cleanup does not renumber the network"
|
||||
);
|
||||
state.merge(id, claim(&target, id, 6)).unwrap();
|
||||
assert!(!state.is_retired(&target.public()));
|
||||
assert_eq!(state.hostname_of(&target.public()), Some("retired"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_signature_scope_tampering_future_cutoffs_and_capacity_are_checked() {
|
||||
let id = NetworkId::from_bytes([7; 32]);
|
||||
let target = SecretKey::generate();
|
||||
let cleaner = SecretKey::generate();
|
||||
let marker = SignedRetirement::sign(&cleaner, id, 8, claim(&target, id, 5));
|
||||
assert!(marker.verify(NetworkId::from_bytes([8; 32])).is_err());
|
||||
let mut forged = marker.clone();
|
||||
forged.target.version = 99;
|
||||
assert!(forged.verify(id).is_err());
|
||||
let mut forged = marker.clone();
|
||||
forged.signature[0] ^= 1;
|
||||
assert!(forged.verify(id).is_err());
|
||||
let mut proof = marker.target.clone();
|
||||
proof.version = 99;
|
||||
assert!(
|
||||
SignedRetirement::sign(&cleaner, id, 8, proof)
|
||||
.verify(id)
|
||||
.is_err()
|
||||
);
|
||||
let mut state = StateSet::new();
|
||||
assert!(state.merge_retirement(id, marker.clone(), 0).is_err());
|
||||
assert!(state.merge_retirement(id, marker.clone(), 1).unwrap());
|
||||
assert!(!state.merge_retirement(id, marker.clone(), 1).unwrap());
|
||||
assert!(postcard::to_stdvec(&marker).unwrap().len() <= MAX_RETIREMENT_LEN);
|
||||
let another = SecretKey::generate();
|
||||
assert!(
|
||||
state
|
||||
.merge_retirement(
|
||||
id,
|
||||
SignedRetirement::sign(&cleaner, id, 9, claim(&another, id, 1)),
|
||||
1
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn competing_cleanup_markers_converge_and_keep_the_highest_observed_cutoff() {
|
||||
let id = NetworkId::from_bytes([7; 32]);
|
||||
let target = SecretKey::generate();
|
||||
let a = SecretKey::generate();
|
||||
let b = SecretKey::generate();
|
||||
let markers = [
|
||||
SignedRetirement::sign(&a, id, 1, claim(&target, id, 4)),
|
||||
SignedRetirement::sign(&a, id, 2, claim(&target, id, 5)),
|
||||
SignedRetirement::sign(&b, id, 3, claim(&target, id, 5)),
|
||||
];
|
||||
let mut forward = StateSet::new();
|
||||
let mut reverse = StateSet::new();
|
||||
for marker in &markers {
|
||||
forward.merge_retirement(id, marker.clone(), 16).unwrap();
|
||||
}
|
||||
for marker in markers.iter().rev() {
|
||||
reverse.merge_retirement(id, marker.clone(), 16).unwrap();
|
||||
}
|
||||
assert_eq!(forward.retirements(), reverse.retirements());
|
||||
assert_eq!(
|
||||
forward
|
||||
.retirement_of(&target.public())
|
||||
.unwrap()
|
||||
.target
|
||||
.version,
|
||||
5
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
//! Signed multi-writer virtual service registers and deterministic merge rules.
|
||||
use crate::error::Result;
|
||||
use crate::vip::{Backend, Listener, Protocol, VipConfig, VipStatus, invalid};
|
||||
use crate::{Limits, NetworkId};
|
||||
use iroh::{EndpointId, SecretKey, Signature};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// A signed, independently mergeable field. A removed field keeps its tombstone.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VipEntry {
|
||||
/// Network scope.
|
||||
pub network: [u8; 32],
|
||||
/// Stable service id.
|
||||
pub vip: String,
|
||||
/// Canonical field key.
|
||||
pub key: String,
|
||||
/// Encoded field value; absent means deletion.
|
||||
pub value: Option<Vec<u8>>,
|
||||
/// Lamport clock, independent of wall time.
|
||||
pub clock: u64,
|
||||
/// Persistent signing identity.
|
||||
pub author: [u8; 32],
|
||||
/// Author's durable network counter.
|
||||
pub version: u64,
|
||||
/// Ed25519 signature.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
impl VipEntry {
|
||||
fn bytes(&self) -> Vec<u8> {
|
||||
let mut e = self.clone();
|
||||
e.signature.clear();
|
||||
let mut out = b"tsunagi-vip-field/v1\0".to_vec();
|
||||
if let Ok(bytes) = postcard::to_stdvec(&e) {
|
||||
out.extend(bytes);
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Verifies bounds, network and authorship before the entry is merged.
|
||||
pub fn verify(&self, network: NetworkId) -> Result<()> {
|
||||
if self.network != *network.as_bytes()
|
||||
|| self.vip.len() != 32
|
||||
|| hex::decode(&self.vip).is_err()
|
||||
|| self.key.len() > 160
|
||||
|| self.value.as_ref().is_some_and(|v| v.len() > 256)
|
||||
|| self.version == 0
|
||||
|| self.version > i64::MAX as u64
|
||||
|| self.clock == 0
|
||||
|| self.clock > i64::MAX as u64
|
||||
{
|
||||
return Err(invalid("invalid VIP field bounds or network"));
|
||||
}
|
||||
let author =
|
||||
EndpointId::from_bytes(&self.author).map_err(|_| invalid("invalid VIP author"))?;
|
||||
let raw: [u8; 64] = self
|
||||
.signature
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| invalid("invalid VIP signature length"))?;
|
||||
author
|
||||
.verify(&self.bytes(), &Signature::from_bytes(&raw))
|
||||
.map_err(|_| invalid("invalid VIP signature"))?;
|
||||
if self.key != "deleted"
|
||||
&& self.key != "name"
|
||||
&& !self.key.starts_with("l/")
|
||||
&& !self.key.starts_with("b/")
|
||||
{
|
||||
return Err(invalid("unknown VIP field"));
|
||||
}
|
||||
match self.key.as_str() {
|
||||
"deleted" => {
|
||||
if self.value.as_deref() != Some(&[1][..]) {
|
||||
return Err(invalid("invalid VIP deletion tombstone"));
|
||||
}
|
||||
}
|
||||
"name" => {
|
||||
let value = self
|
||||
.value
|
||||
.as_ref()
|
||||
.ok_or_else(|| invalid("VIP name cannot be absent"))?;
|
||||
let name: String =
|
||||
postcard::from_bytes(value).map_err(|_| invalid("invalid VIP name field"))?;
|
||||
VipConfig {
|
||||
id: self.vip.clone(),
|
||||
name,
|
||||
listeners: vec![],
|
||||
}
|
||||
.validate(&Limits::default())?;
|
||||
}
|
||||
key if key.starts_with("l/") => {
|
||||
let parts: Vec<_> = key.split('/').collect();
|
||||
if parts.len() != 3
|
||||
|| parts[1].parse::<Protocol>().is_err()
|
||||
|| parts[2].parse::<u16>().ok().is_none_or(|p| p == 0)
|
||||
{
|
||||
return Err(invalid("invalid listener field key"));
|
||||
}
|
||||
if let Some(value) = &self.value {
|
||||
let l: Listener = postcard::from_bytes(value)
|
||||
.map_err(|_| invalid("invalid listener field"))?;
|
||||
if !l.backends.is_empty() || key != format!("l/{}/{}", l.protocol, l.port) {
|
||||
return Err(invalid("listener key does not match its value"));
|
||||
}
|
||||
VipConfig {
|
||||
id: self.vip.clone(),
|
||||
name: "validation".into(),
|
||||
listeners: vec![l],
|
||||
}
|
||||
.validate(&Limits::default())?;
|
||||
}
|
||||
}
|
||||
key if key.starts_with("b/") => {
|
||||
let parts: Vec<_> = key.split('/').collect();
|
||||
if parts.len() != 6
|
||||
|| parts[1].parse::<Protocol>().is_err()
|
||||
|| parts[2].parse::<u16>().ok().is_none_or(|p| p == 0)
|
||||
|| parts[3].len() != 32
|
||||
|| hex::decode(parts[3]).is_err()
|
||||
|| parts[4].parse::<EndpointId>().is_err()
|
||||
|| parts[5].parse::<u16>().ok().is_none_or(|p| p == 0)
|
||||
{
|
||||
return Err(invalid("invalid backend field key"));
|
||||
}
|
||||
if let Some(value) = &self.value {
|
||||
let b: Backend = postcard::from_bytes(value)
|
||||
.map_err(|_| invalid("invalid backend field"))?;
|
||||
if b.peer != parts[4] || b.port.to_string() != parts[5] {
|
||||
return Err(invalid("backend key does not match its value"));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(invalid("unknown VIP field")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn rank(&self) -> (u64, [u8; 32], Vec<u8>) {
|
||||
(self.clock, self.author, self.bytes())
|
||||
}
|
||||
}
|
||||
/// Signed registers retained across absences, including deletion tombstones.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct VipSet {
|
||||
entries: BTreeMap<(String, String), VipEntry>,
|
||||
}
|
||||
impl VipSet {
|
||||
/// Highest retained version genuinely signed by this author.
|
||||
pub fn authored_version(&self, author: EndpointId) -> u64 {
|
||||
self.entries
|
||||
.values()
|
||||
.filter(|e| e.author == *author.as_bytes())
|
||||
.map(|e| e.version)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
/// Complete independently verifiable state of one service, including tombstones.
|
||||
pub fn entries(&self, id: &str) -> Vec<VipEntry> {
|
||||
self.entries
|
||||
.values()
|
||||
.filter(|e| e.vip == id)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
/// All retained service ids, including deleted services.
|
||||
pub fn ids(&self) -> Vec<String> {
|
||||
self.entries
|
||||
.keys()
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
/// Opaque optimistic concurrency token for a whole service.
|
||||
pub fn revision(&self, id: &str) -> String {
|
||||
let mut hash = Sha256::new();
|
||||
for e in self.entries(id) {
|
||||
hash.update(e.bytes());
|
||||
hash.update(&e.signature);
|
||||
}
|
||||
hex::encode(hash.finalize())
|
||||
}
|
||||
/// Atomically verifies and merges one complete service snapshot.
|
||||
pub fn merge(
|
||||
&mut self,
|
||||
network: NetworkId,
|
||||
entries: Vec<VipEntry>,
|
||||
limits: &Limits,
|
||||
) -> Result<bool> {
|
||||
if entries.len() > limits.max_vip_fields {
|
||||
return Err(invalid("too many VIP fields"));
|
||||
}
|
||||
let mut next = self.clone();
|
||||
for e in entries {
|
||||
e.verify(network)?;
|
||||
let key = (e.vip.clone(), e.key.clone());
|
||||
if next
|
||||
.entries
|
||||
.get(&key)
|
||||
.is_none_or(|old| e.rank() > old.rank())
|
||||
{
|
||||
next.entries.insert(key, e);
|
||||
}
|
||||
}
|
||||
let deleted: BTreeSet<_> = next
|
||||
.entries
|
||||
.values()
|
||||
.filter(|e| e.key == "deleted" && e.value.is_some())
|
||||
.map(|e| e.vip.clone())
|
||||
.collect();
|
||||
// A permanent service tombstone alone prevents every former field's replay.
|
||||
next.entries
|
||||
.retain(|(id, key), _| !deleted.contains(id) || key == "deleted");
|
||||
let ids = next.ids();
|
||||
if ids.len().saturating_sub(deleted.len()) > limits.max_vips
|
||||
|| deleted.len() > limits.max_vip_tombstones
|
||||
|| ids
|
||||
.iter()
|
||||
.any(|id| next.entries(id).len() > limits.max_vip_fields)
|
||||
{
|
||||
return Err(invalid(
|
||||
"VIP state capacity exceeded; deletion protection is retained",
|
||||
));
|
||||
}
|
||||
let changed = self.entries != next.entries;
|
||||
*self = next;
|
||||
Ok(changed)
|
||||
}
|
||||
/// Materialized configuration. A service tombstone permanently retires its id.
|
||||
pub fn configs(&self) -> Vec<VipConfig> {
|
||||
self.ids()
|
||||
.into_iter()
|
||||
.filter_map(|id| self.config(&id))
|
||||
.collect()
|
||||
}
|
||||
fn config(&self, id: &str) -> Option<VipConfig> {
|
||||
let fields = self.entries(id);
|
||||
if fields
|
||||
.iter()
|
||||
.any(|e| e.key == "deleted" && e.value.is_some())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let name = fields
|
||||
.iter()
|
||||
.find(|e| e.key == "name")?
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|v| postcard::from_bytes::<String>(v).ok())?;
|
||||
let mut listeners: Vec<Listener> = fields
|
||||
.iter()
|
||||
.filter(|e| e.key.starts_with("l/"))
|
||||
.filter_map(|e| e.value.as_ref().and_then(|v| postcard::from_bytes(v).ok()))
|
||||
.collect();
|
||||
for l in &mut listeners {
|
||||
l.backends = fields
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
e.key
|
||||
.starts_with(&format!("b/{}/{}/{}/", l.protocol, l.port, l.id))
|
||||
})
|
||||
.filter_map(|e| e.value.as_ref().and_then(|v| postcard::from_bytes(v).ok()))
|
||||
.collect();
|
||||
l.backends.sort();
|
||||
}
|
||||
listeners.sort_by_key(|l| (l.protocol, l.port));
|
||||
Some(VipConfig {
|
||||
id: id.into(),
|
||||
name,
|
||||
listeners,
|
||||
})
|
||||
}
|
||||
/// Builds a signed patch from the user's complete, version-checked draft.
|
||||
pub fn edit(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
secret: &SecretKey,
|
||||
version: u64,
|
||||
config: &VipConfig,
|
||||
expected: Option<&str>,
|
||||
limits: &Limits,
|
||||
) -> Result<Vec<VipEntry>> {
|
||||
config.validate(limits)?;
|
||||
if let Some(revision) = expected {
|
||||
if revision != self.revision(&config.id) {
|
||||
return Err(invalid("VIP changed while editing; reload before saving"));
|
||||
}
|
||||
} else if !self.entries(&config.id).is_empty() {
|
||||
return Err(invalid("VIP id already exists"));
|
||||
}
|
||||
if self
|
||||
.entries
|
||||
.get(&(config.id.clone(), "deleted".into()))
|
||||
.is_some_and(|e| e.value.is_some())
|
||||
{
|
||||
return Err(invalid("VIP was deleted; create a new VIP"));
|
||||
}
|
||||
let before = self
|
||||
.config(&config.id)
|
||||
.map(|c| flatten(&c))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let after = flatten(config)?;
|
||||
let keys: BTreeSet<_> = before.keys().chain(after.keys()).cloned().collect();
|
||||
let changes = keys
|
||||
.into_iter()
|
||||
.filter_map(|key| {
|
||||
let value = after.get(&key).cloned();
|
||||
(before.get(&key) != value.as_ref()).then_some((key, value))
|
||||
})
|
||||
.collect();
|
||||
self.sign(network, secret, version, &config.id, changes)
|
||||
}
|
||||
/// Builds a permanent service tombstone after checking the selected revision.
|
||||
pub fn delete(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
secret: &SecretKey,
|
||||
version: u64,
|
||||
id: &str,
|
||||
expected: &str,
|
||||
) -> Result<Vec<VipEntry>> {
|
||||
if self.config(id).is_none() || self.revision(id) != expected {
|
||||
return Err(invalid("VIP missing or changed while editing"));
|
||||
}
|
||||
self.sign(
|
||||
network,
|
||||
secret,
|
||||
version,
|
||||
id,
|
||||
vec![("deleted".into(), Some(vec![1]))],
|
||||
)
|
||||
}
|
||||
fn sign(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
secret: &SecretKey,
|
||||
version: u64,
|
||||
id: &str,
|
||||
changes: Vec<(String, Option<Vec<u8>>)>,
|
||||
) -> Result<Vec<VipEntry>> {
|
||||
let clock = self
|
||||
.entries
|
||||
.values()
|
||||
.map(|e| e.clock)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.checked_add(1)
|
||||
.filter(|n| *n <= i64::MAX as u64)
|
||||
.ok_or_else(|| invalid("VIP clock exhausted"))?;
|
||||
Ok(changes
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
let mut e = VipEntry {
|
||||
network: *network.as_bytes(),
|
||||
vip: id.into(),
|
||||
key,
|
||||
value,
|
||||
clock,
|
||||
author: *secret.public().as_bytes(),
|
||||
version,
|
||||
signature: vec![],
|
||||
};
|
||||
e.signature = secret.sign(&e.bytes()).to_bytes().to_vec();
|
||||
e
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
pub(crate) fn flatten(cfg: &VipConfig) -> Result<BTreeMap<String, Vec<u8>>> {
|
||||
let mut fields = BTreeMap::new();
|
||||
fields.insert(
|
||||
"name".into(),
|
||||
postcard::to_stdvec(&cfg.name).map_err(|e| invalid(e.to_string()))?,
|
||||
);
|
||||
for l in &cfg.listeners {
|
||||
let mut settings = l.clone();
|
||||
settings.backends.clear();
|
||||
fields.insert(
|
||||
format!("l/{}/{}", l.protocol, l.port),
|
||||
postcard::to_stdvec(&settings).map_err(|e| invalid(e.to_string()))?,
|
||||
);
|
||||
for b in &l.backends {
|
||||
fields.insert(
|
||||
format!("b/{}/{}/{}/{}/{}", l.protocol, l.port, l.id, b.peer, b.port),
|
||||
postcard::to_stdvec(b).map_err(|e| invalid(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(fields)
|
||||
}
|
||||
impl VipSet {
|
||||
/// Allocates internal addresses deterministically against durable node claims.
|
||||
pub(crate) fn views(
|
||||
&self,
|
||||
range: Option<crate::state::Ipv4Range>,
|
||||
mut taken: std::collections::HashSet<Ipv4Addr>,
|
||||
hostnames: &BTreeSet<String>,
|
||||
limits: &Limits,
|
||||
) -> Vec<VipStatus> {
|
||||
let mut names = hostnames.clone();
|
||||
let mut result = Vec::new();
|
||||
for config in self.configs() {
|
||||
let mut error = config.validate(limits).err().map(|e| e.to_string());
|
||||
if !names.insert(config.name.clone()) {
|
||||
error = Some("name conflicts with another VIP or node".into());
|
||||
}
|
||||
let address = range.and_then(|r| {
|
||||
let usable = crate::state::allocator::usable_addresses(r);
|
||||
if usable == 0 {
|
||||
return None;
|
||||
}
|
||||
let digest = Sha256::digest(config.id.as_bytes());
|
||||
let seed = u64::from_be_bytes([
|
||||
digest[0], digest[1], digest[2], digest[3], digest[4], digest[5], digest[6],
|
||||
digest[7],
|
||||
]);
|
||||
let bits = 32 - u32::from(r.prefix_len);
|
||||
let mask = if bits == 32 { 0 } else { u32::MAX << bits };
|
||||
(0..usable).find_map(|step| {
|
||||
let offset = (seed.wrapping_add(step) % usable + 1) as u32;
|
||||
let ip = Ipv4Addr::from((u32::from(r.base) & mask) | offset);
|
||||
taken.insert(ip).then_some(ip)
|
||||
})
|
||||
});
|
||||
if address.is_none() {
|
||||
error = Some("no free overlay address".into());
|
||||
}
|
||||
result.push(VipStatus {
|
||||
revision: self.revision(&config.id),
|
||||
config,
|
||||
address,
|
||||
error,
|
||||
listeners: vec![],
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -248,17 +248,70 @@ impl Storage {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persists local broadcast participation for one configured network.
|
||||
pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
self.with_state(move |state| state.set_broadcast(network_id, enabled))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persists the ordered protocol selection off the async executor.
|
||||
pub async fn set_protocols(&self, network_id: NetworkId, protocols: Vec<String>) -> Result<()> {
|
||||
self.with_state(move |state| state.set_protocols(network_id, &protocols))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persists whether this agent offers itself as an exit node in a network.
|
||||
pub async fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
self.with_state(move |state| state.set_exit_node(network_id, enabled))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persists the member this device sends its internet traffic through.
|
||||
pub async fn set_exit_via(&self, network_id: NetworkId, via: Option<[u8; 32]>) -> Result<()> {
|
||||
self.with_state(move |state| state.set_exit_via(network_id, via.as_ref()))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Updates the auto-start flag of a network.
|
||||
pub async fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
|
||||
self.with_state(move |state| state.set_auto_start(network_id, auto_start))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Configured networks in the user's most recently selected order.
|
||||
pub async fn network_start_order(&self) -> Result<Vec<NetworkId>> {
|
||||
self.with_state(|state| {
|
||||
let configured = state.list_networks()?;
|
||||
let text = state
|
||||
.get_setting("network_start_order")?
|
||||
.unwrap_or_default();
|
||||
let mut order = Vec::new();
|
||||
for word in text.split(',') {
|
||||
if let Ok(id) = word.parse::<NetworkId>()
|
||||
&& configured.iter().any(|n| n.network_id == id)
|
||||
&& !order.contains(&id)
|
||||
{
|
||||
order.push(id);
|
||||
}
|
||||
}
|
||||
Ok(order)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Prefers this network at startup without disabling suspended networks.
|
||||
pub async fn prefer_network(&self, network: NetworkId) -> Result<()> {
|
||||
self.with_state(move |state| state.prefer_network(network))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Removes a network configuration and its cached hints.
|
||||
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.with_state(move |state| {
|
||||
state.remove_network(network_id)?;
|
||||
state.forget_signed_records(network_id)
|
||||
state.forget_signed_records(network_id)?;
|
||||
state.forget_retirements(network_id)?;
|
||||
state.forget_peer_hostnames(network_id)
|
||||
})
|
||||
.await?;
|
||||
self.with_cache((), move |cache| cache.forget_network(network_id))
|
||||
@@ -277,6 +330,26 @@ impl Storage {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Remembers the hostname a member announced, so it can still be named
|
||||
/// once it is away.
|
||||
pub async fn remember_peer_hostname(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
endpoint_id: [u8; 32],
|
||||
hostname: String,
|
||||
) -> Result<()> {
|
||||
self.with_state(move |state| {
|
||||
state.remember_peer_hostname(network_id, &endpoint_id, &hostname)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The hostnames members of a network last announced.
|
||||
pub async fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
|
||||
self.with_state(move |state| state.peer_hostnames(network_id))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Records an address hint. Failures are non-fatal.
|
||||
pub async fn record_hint(
|
||||
&self,
|
||||
@@ -297,6 +370,50 @@ impl Storage {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Loads mandatory signed VIP fields with a strict capacity bound.
|
||||
pub async fn vip_fields(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
max: usize,
|
||||
) -> Result<Vec<crate::vip::VipEntry>> {
|
||||
self.with_state(move |state| state.vip_fields(network, max))
|
||||
.await
|
||||
}
|
||||
/// Stores a complete merged service snapshot and optional own counter.
|
||||
pub async fn put_vip_fields(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
entries: Vec<crate::vip::VipEntry>,
|
||||
own: Option<([u8; 32], u64)>,
|
||||
) -> Result<()> {
|
||||
self.with_state(move |state| state.put_vip_fields(network, &entries, own))
|
||||
.await
|
||||
}
|
||||
/// Loads verified durable cleanup proofs off the executor.
|
||||
pub async fn retirements(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
max: usize,
|
||||
) -> Result<Vec<crate::state::SignedRetirement>> {
|
||||
self.with_state(move |state| state.retirements(network, max))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persists a received cleanup proof before applying it.
|
||||
pub async fn put_retirement(&self, marker: crate::state::SignedRetirement) -> Result<()> {
|
||||
self.with_state(move |state| state.put_retirement(&marker))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Commits our cleanup and author counter together before announcement.
|
||||
pub async fn publish_own_retirement(
|
||||
&self,
|
||||
marker: crate::state::SignedRetirement,
|
||||
) -> Result<()> {
|
||||
self.with_state(move |state| state.publish_own_retirement(&marker))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Stores a record received from another replica.
|
||||
pub async fn put_signed_record(&self, record: SignedRecord) -> Result<()> {
|
||||
self.with_state(move |state| state.put_signed_record(&record))
|
||||
File diff suppressed because it is too large.
Load diff
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,239 @@
|
||||
//! Named, shared virtual services. Signed field registers converge without an
|
||||
//! owner; routing, health and connection state remain local to each client.
|
||||
use crate::Limits;
|
||||
use crate::error::{Error, Result};
|
||||
use iroh::EndpointId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::Ipv4Addr;
|
||||
pub(crate) mod packet;
|
||||
pub(crate) mod router;
|
||||
pub use crate::state::vip::{VipEntry, VipSet};
|
||||
pub(crate) use router::VipRouter;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// IP transport carried by a listener.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Protocol {
|
||||
/// TCP.
|
||||
Tcp,
|
||||
/// UDP.
|
||||
Udp,
|
||||
}
|
||||
impl std::fmt::Display for Protocol {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Tcp => "tcp",
|
||||
Self::Udp => "udp",
|
||||
})
|
||||
}
|
||||
}
|
||||
impl std::str::FromStr for Protocol {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> std::result::Result<Self, String> {
|
||||
match s {
|
||||
"tcp" => Ok(Self::Tcp),
|
||||
"udp" => Ok(Self::Udp),
|
||||
_ => Err("protocol must be tcp or udp".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A node identity and its application port.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Backend {
|
||||
/// Full device identity, resolved when editing.
|
||||
pub peer: String,
|
||||
/// Destination port.
|
||||
pub port: u16,
|
||||
}
|
||||
/// One independently routed port of a VIP.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Listener {
|
||||
/// Stable listener generation; prevents removed backend fields resurfacing on re-creation.
|
||||
pub id: String,
|
||||
/// TCP or UDP.
|
||||
pub protocol: Protocol,
|
||||
/// Client-facing port.
|
||||
pub port: u16,
|
||||
/// Enable active TCP connect checks; UDP uses path checks and ICMP errors.
|
||||
pub port_check: bool,
|
||||
/// Period between checks in milliseconds.
|
||||
pub check_interval_ms: u32,
|
||||
/// Deadline of a port check in milliseconds.
|
||||
pub check_timeout_ms: u32,
|
||||
/// Consecutive failed checks before exclusion.
|
||||
pub failure_threshold: u8,
|
||||
/// Consecutive successes before recovery.
|
||||
pub recovery_threshold: u8,
|
||||
/// Idle expiry for this listener's flows, in seconds.
|
||||
pub idle_timeout_secs: u32,
|
||||
/// Allowed destination nodes and ports.
|
||||
pub backends: Vec<Backend>,
|
||||
}
|
||||
impl Listener {
|
||||
/// Creates a listener with automatic recovery and bounded checks.
|
||||
pub fn new(protocol: Protocol, port: u16) -> Self {
|
||||
Self {
|
||||
id: hex::encode(rand::random::<[u8; 16]>()),
|
||||
protocol,
|
||||
port,
|
||||
port_check: true,
|
||||
check_interval_ms: 3000,
|
||||
check_timeout_ms: 2000,
|
||||
failure_threshold: 2,
|
||||
recovery_threshold: 1,
|
||||
idle_timeout_secs: if protocol == Protocol::Tcp { 7200 } else { 120 },
|
||||
backends: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Entire editable service; its address is an internal derived reservation.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct VipConfig {
|
||||
/// Stable random 128-bit id in hexadecimal.
|
||||
pub id: String,
|
||||
/// One DNS label, unique within the network.
|
||||
pub name: String,
|
||||
/// Independent listeners.
|
||||
pub listeners: Vec<Listener>,
|
||||
}
|
||||
impl VipConfig {
|
||||
/// Creates an empty named service with a fresh stable identity.
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: hex::encode(rand::random::<[u8; 16]>()),
|
||||
name: name.into(),
|
||||
listeners: vec![],
|
||||
}
|
||||
}
|
||||
/// Validates a complete draft before any state is changed.
|
||||
pub fn validate(&self, limits: &Limits) -> Result<()> {
|
||||
if self.id.len() != 32 || hex::decode(&self.id).is_err() {
|
||||
return Err(invalid("invalid VIP id"));
|
||||
}
|
||||
if self.name.is_empty()
|
||||
|| self.name.len() > 63
|
||||
|| self.name.starts_with('-')
|
||||
|| self.name.ends_with('-')
|
||||
|| !self
|
||||
.name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
{
|
||||
return Err(invalid(
|
||||
"VIP name must be a lowercase DNS label (letters, digits and hyphens)",
|
||||
));
|
||||
}
|
||||
if self.listeners.len() > limits.max_vip_listeners {
|
||||
return Err(invalid("too many VIP listeners"));
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for l in &self.listeners {
|
||||
if l.id.len() != 32 || hex::decode(&l.id).is_err() {
|
||||
return Err(invalid("invalid listener identity"));
|
||||
}
|
||||
if l.port == 0 || !seen.insert((l.protocol, l.port)) {
|
||||
return Err(invalid(
|
||||
"listener ports must be nonzero and unique per protocol",
|
||||
));
|
||||
}
|
||||
if !(250..=3_600_000).contains(&l.check_interval_ms)
|
||||
|| !(100..=60_000).contains(&l.check_timeout_ms)
|
||||
|| l.failure_threshold == 0
|
||||
|| l.recovery_threshold == 0
|
||||
|| !(1..=604800).contains(&l.idle_timeout_secs)
|
||||
{
|
||||
return Err(invalid(
|
||||
"invalid check interval, timeout, thresholds or flow idle timeout",
|
||||
));
|
||||
}
|
||||
if l.backends.len() > limits.max_vip_backends {
|
||||
return Err(invalid("too many listener backends"));
|
||||
}
|
||||
let mut backends = BTreeSet::new();
|
||||
for b in &l.backends {
|
||||
if b.port == 0 || b.peer.parse::<EndpointId>().is_err() || !backends.insert(b) {
|
||||
return Err(invalid(
|
||||
"backend must have a unique node identity and a nonzero port",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if crate::state::vip::flatten(self)?.len() > limits.max_vip_fields {
|
||||
return Err(invalid("VIP configuration exceeds signed field capacity"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub(crate) fn invalid(reason: impl Into<String>) -> Error {
|
||||
Error::Rejected {
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
/// Cumulative IP traffic, excluding probes. Reset on agent restart.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Counters {
|
||||
/// Outbound packets.
|
||||
pub tx_packets: u64,
|
||||
/// Outbound bytes.
|
||||
pub tx_bytes: u64,
|
||||
/// Inbound packets.
|
||||
pub rx_packets: u64,
|
||||
/// Inbound bytes.
|
||||
pub rx_bytes: u64,
|
||||
/// Created flow bindings.
|
||||
pub flows: u64,
|
||||
/// UDP failovers.
|
||||
pub failovers: u64,
|
||||
/// Packets rejected or lacking an eligible backend.
|
||||
pub dropped: u64,
|
||||
}
|
||||
/// One backend, as seen by this client.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BackendStatus {
|
||||
/// Configured endpoint.
|
||||
pub backend: Backend,
|
||||
/// Eligibility and reason.
|
||||
pub state: String,
|
||||
/// Smoothed full data-plane RTT, microseconds; unknown stays absent.
|
||||
pub rtt_us: Option<u64>,
|
||||
/// Last successful path measurement age.
|
||||
pub measurement_age_ms: Option<u64>,
|
||||
/// Current bindings.
|
||||
pub active_flows: u32,
|
||||
/// Local traffic counters.
|
||||
pub counters: Counters,
|
||||
}
|
||||
/// Runtime view of one listener.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ListenerStatus {
|
||||
/// Protocol.
|
||||
pub protocol: Protocol,
|
||||
/// Frontend port.
|
||||
pub port: u16,
|
||||
/// Best backend for a new flow, if measured and eligible.
|
||||
pub selected: Option<Backend>,
|
||||
/// Configured and draining endpoints.
|
||||
pub backends: Vec<BackendStatus>,
|
||||
/// Local counters.
|
||||
pub counters: Counters,
|
||||
}
|
||||
/// Configuration and local diagnostics returned through the same API to CLI and GUI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VipStatus {
|
||||
/// Persisted configuration.
|
||||
pub config: VipConfig,
|
||||
/// Opaque revision used when saving a draft.
|
||||
pub revision: String,
|
||||
/// Internal service address.
|
||||
pub address: Option<Ipv4Addr>,
|
||||
/// Conflict or allocation issue, if any.
|
||||
pub error: Option<String>,
|
||||
/// Local listener status.
|
||||
pub listeners: Vec<ListenerStatus>,
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
//! Bounded IPv4 parsing, NAT checksums and fragment reassembly at local ingress.
|
||||
use super::Protocol;
|
||||
use bytes::Bytes;
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::time::{Duration, Instant};
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct Tuple {
|
||||
pub src: Ipv4Addr,
|
||||
pub dst: Ipv4Addr,
|
||||
pub sport: u16,
|
||||
pub dport: u16,
|
||||
pub protocol: Protocol,
|
||||
}
|
||||
impl Tuple {
|
||||
pub fn reverse(self) -> Self {
|
||||
Self {
|
||||
src: self.dst,
|
||||
dst: self.src,
|
||||
sport: self.dport,
|
||||
dport: self.sport,
|
||||
protocol: self.protocol,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn header(p: &[u8]) -> Option<usize> {
|
||||
if p.len() < 20 || p[0] >> 4 != 4 {
|
||||
return None;
|
||||
}
|
||||
let n = usize::from(p[0] & 15) * 4;
|
||||
let total = usize::from(u16::from_be_bytes([p[2], p[3]]));
|
||||
(n >= 20 && n <= p.len() && total == p.len() && checksum(&p[..n]) == 0).then_some(n)
|
||||
}
|
||||
pub(crate) fn addresses(p: &[u8]) -> Option<(Ipv4Addr, Ipv4Addr)> {
|
||||
header(p)?;
|
||||
Some((
|
||||
Ipv4Addr::new(p[12], p[13], p[14], p[15]),
|
||||
Ipv4Addr::new(p[16], p[17], p[18], p[19]),
|
||||
))
|
||||
}
|
||||
pub(crate) fn tuple(p: &[u8]) -> Option<Tuple> {
|
||||
let n = header(p)?;
|
||||
if u16::from_be_bytes([p[6], p[7]]) & 0x3fff != 0 {
|
||||
return None;
|
||||
}
|
||||
let protocol = match p[9] {
|
||||
6 => Protocol::Tcp,
|
||||
17 => Protocol::Udp,
|
||||
_ => return None,
|
||||
};
|
||||
let min = if protocol == Protocol::Tcp { 20 } else { 8 };
|
||||
if p.len() < n + min {
|
||||
return None;
|
||||
}
|
||||
if protocol == Protocol::Tcp {
|
||||
let h = usize::from(p[n + 12] >> 4) * 4;
|
||||
if h < 20 || n + h > p.len() {
|
||||
return None;
|
||||
}
|
||||
} else if usize::from(u16::from_be_bytes([p[n + 4], p[n + 5]])) != p.len() - n {
|
||||
return None;
|
||||
}
|
||||
let (src, dst) = addresses(p)?;
|
||||
Some(Tuple {
|
||||
src,
|
||||
dst,
|
||||
sport: u16::from_be_bytes([p[n], p[n + 1]]),
|
||||
dport: u16::from_be_bytes([p[n + 2], p[n + 3]]),
|
||||
protocol,
|
||||
})
|
||||
}
|
||||
pub(crate) fn rewrite(p: &[u8], t: Tuple) -> Option<Bytes> {
|
||||
let n = header(p)?;
|
||||
if p.len() < n + if t.protocol == Protocol::Tcp { 20 } else { 8 } {
|
||||
return None;
|
||||
}
|
||||
let mut out = p.to_vec();
|
||||
out[12..16].copy_from_slice(&t.src.octets());
|
||||
out[16..20].copy_from_slice(&t.dst.octets());
|
||||
out[n..n + 2].copy_from_slice(&t.sport.to_be_bytes());
|
||||
out[n + 2..n + 4].copy_from_slice(&t.dport.to_be_bytes());
|
||||
finish(&mut out, n, t.protocol);
|
||||
Some(Bytes::from(out))
|
||||
}
|
||||
fn finish(p: &mut [u8], n: usize, protocol: Protocol) {
|
||||
p[10..12].fill(0);
|
||||
let c = checksum(&p[..n]);
|
||||
p[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
let offset = n + if protocol == Protocol::Tcp { 16 } else { 6 };
|
||||
p[offset..offset + 2].fill(0);
|
||||
let mut sum = 0u32;
|
||||
for pair in p[12..20].as_chunks::<2>().0 {
|
||||
sum += u32::from(u16::from_be_bytes([pair[0], pair[1]]));
|
||||
}
|
||||
sum += u32::from(p[9]) + (p.len() - n) as u32;
|
||||
let c = checksum_seed(&p[n..], sum);
|
||||
let c = if c == 0 && protocol == Protocol::Udp {
|
||||
0xffff
|
||||
} else {
|
||||
c
|
||||
};
|
||||
p[offset..offset + 2].copy_from_slice(&c.to_be_bytes());
|
||||
}
|
||||
pub(crate) fn checksum(p: &[u8]) -> u16 {
|
||||
checksum_seed(p, 0)
|
||||
}
|
||||
fn checksum_seed(p: &[u8], mut sum: u32) -> u16 {
|
||||
for pair in p.chunks(2) {
|
||||
sum += u32::from(u16::from_be_bytes([pair[0], *pair.get(1).unwrap_or(&0)]));
|
||||
}
|
||||
while sum >> 16 != 0 {
|
||||
sum = (sum & 0xffff) + (sum >> 16);
|
||||
}
|
||||
!(sum as u16)
|
||||
}
|
||||
pub(crate) fn tcp_syn(p: &[u8]) -> bool {
|
||||
header(p).is_some_and(|n| p[9] == 6 && p.get(n + 13).is_some_and(|f| f & 0x12 == 2))
|
||||
}
|
||||
pub(crate) fn syn_sequence(p: &[u8]) -> Option<u32> {
|
||||
if !tcp_syn(p) {
|
||||
return None;
|
||||
}
|
||||
let n = header(p)?;
|
||||
let bytes = p.get(n + 4..n + 8)?;
|
||||
Some(u32::from_be_bytes(bytes.try_into().ok()?))
|
||||
}
|
||||
pub(crate) fn empty_syn(p: &[u8]) -> bool {
|
||||
header(p).is_some_and(|n| {
|
||||
tcp_syn(p)
|
||||
&& p.get(n + 12)
|
||||
.is_some_and(|h| n + usize::from(h >> 4) * 4 == p.len())
|
||||
})
|
||||
}
|
||||
pub(crate) fn syn_ack(p: &[u8]) -> bool {
|
||||
header(p).is_some_and(|n| p[9] == 6 && p.get(n + 13).is_some_and(|f| f & 0x12 == 0x12))
|
||||
}
|
||||
pub(crate) fn tcp_reset(p: &[u8]) -> bool {
|
||||
header(p).is_some_and(|n| p[9] == 6 && p.get(n + 13).is_some_and(|f| f & 4 != 0))
|
||||
}
|
||||
/// Rewrites an ICMP error and its quoted original header after NAT lookup.
|
||||
pub(crate) fn icmp_quote(p: &[u8]) -> Option<Tuple> {
|
||||
let n = header(p)?;
|
||||
if p[9] != 1 || p.len() < n + 8 + 28 || ![3, 11, 12].contains(&p[n]) {
|
||||
return None;
|
||||
}
|
||||
let q = &p[n + 8..];
|
||||
let h = usize::from(q[0] & 15) * 4;
|
||||
if q[0] >> 4 != 4 || h < 20 || q.len() < h + 8 {
|
||||
return None;
|
||||
}
|
||||
let protocol = match q[9] {
|
||||
6 => Protocol::Tcp,
|
||||
17 => Protocol::Udp,
|
||||
_ => return None,
|
||||
};
|
||||
Some(Tuple {
|
||||
src: Ipv4Addr::new(q[12], q[13], q[14], q[15]),
|
||||
dst: Ipv4Addr::new(q[16], q[17], q[18], q[19]),
|
||||
sport: u16::from_be_bytes([q[h], q[h + 1]]),
|
||||
dport: u16::from_be_bytes([q[h + 2], q[h + 3]]),
|
||||
protocol,
|
||||
})
|
||||
}
|
||||
pub(crate) fn rewrite_icmp(p: &[u8], original: Tuple) -> Option<Bytes> {
|
||||
let n = header(p)?;
|
||||
icmp_quote(p)?;
|
||||
let mut out = p.to_vec();
|
||||
out[12..16].copy_from_slice(&original.dst.octets());
|
||||
out[16..20].copy_from_slice(&original.src.octets());
|
||||
let q = &mut out[n + 8..];
|
||||
let h = usize::from(q[0] & 15) * 4;
|
||||
// Incrementally update the quoted transport checksum when it is present.
|
||||
let off = h + if original.protocol == Protocol::Tcp {
|
||||
16
|
||||
} else {
|
||||
6
|
||||
};
|
||||
if q.len() >= off + 2 {
|
||||
let old = u16::from_be_bytes([q[off], q[off + 1]]);
|
||||
if old != 0 || original.protocol == Protocol::Tcp {
|
||||
let mut sum = u32::from(!old);
|
||||
let mut replacement = Vec::from(original.src.octets());
|
||||
replacement.extend(original.dst.octets());
|
||||
replacement.extend(original.sport.to_be_bytes());
|
||||
replacement.extend(original.dport.to_be_bytes());
|
||||
let previous = [&q[12..20], &q[h..h + 4]].concat();
|
||||
for (a, b) in previous
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(replacement.as_chunks::<2>().0)
|
||||
{
|
||||
sum += u32::from(!u16::from_be_bytes([a[0], a[1]]))
|
||||
+ u32::from(u16::from_be_bytes([b[0], b[1]]));
|
||||
}
|
||||
while sum >> 16 != 0 {
|
||||
sum = (sum & 0xffff) + (sum >> 16);
|
||||
}
|
||||
let c = !(sum as u16);
|
||||
q[off..off + 2].copy_from_slice(
|
||||
&(if c == 0 && original.protocol == Protocol::Udp {
|
||||
0xffff
|
||||
} else {
|
||||
c
|
||||
})
|
||||
.to_be_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
q[12..16].copy_from_slice(&original.src.octets());
|
||||
q[16..20].copy_from_slice(&original.dst.octets());
|
||||
q[h..h + 2].copy_from_slice(&original.sport.to_be_bytes());
|
||||
q[h + 2..h + 4].copy_from_slice(&original.dport.to_be_bytes());
|
||||
q[10..12].fill(0);
|
||||
let c = checksum(&q[..h]);
|
||||
q[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
out[n + 2..n + 4].fill(0);
|
||||
let c = checksum(&out[n..]);
|
||||
out[n + 2..n + 4].copy_from_slice(&c.to_be_bytes());
|
||||
out[10..12].fill(0);
|
||||
let c = checksum(&out[..n]);
|
||||
out[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
Some(Bytes::from(out))
|
||||
}
|
||||
pub(crate) fn reject(p: &[u8]) -> Option<Bytes> {
|
||||
let t = tuple(p)?;
|
||||
let n = header(p)?;
|
||||
if t.protocol == Protocol::Tcp {
|
||||
if !tcp_syn(p) {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![0; 40];
|
||||
out[0] = 0x45;
|
||||
out[2..4].copy_from_slice(&40u16.to_be_bytes());
|
||||
out[8] = 64;
|
||||
out[9] = 6;
|
||||
out[12..16].copy_from_slice(&t.dst.octets());
|
||||
out[16..20].copy_from_slice(&t.src.octets());
|
||||
out[20..22].copy_from_slice(&t.dport.to_be_bytes());
|
||||
out[22..24].copy_from_slice(&t.sport.to_be_bytes());
|
||||
let seq = u32::from_be_bytes(p[n + 4..n + 8].try_into().ok()?);
|
||||
out[28..32].copy_from_slice(&seq.wrapping_add(1).to_be_bytes());
|
||||
out[32] = 0x50;
|
||||
out[33] = 0x14;
|
||||
finish(&mut out, 20, Protocol::Tcp);
|
||||
Some(Bytes::from(out))
|
||||
} else {
|
||||
let q = p.len().min(n + 8);
|
||||
let mut out = vec![0; 28 + q];
|
||||
out[0] = 0x45;
|
||||
let len = out.len() as u16;
|
||||
out[2..4].copy_from_slice(&len.to_be_bytes());
|
||||
out[8] = 64;
|
||||
out[9] = 1;
|
||||
out[12..16].copy_from_slice(&t.dst.octets());
|
||||
out[16..20].copy_from_slice(&t.src.octets());
|
||||
out[20] = 3;
|
||||
out[21] = 3;
|
||||
out[28..].copy_from_slice(&p[..q]);
|
||||
let c = checksum(&out[20..]);
|
||||
out[22..24].copy_from_slice(&c.to_be_bytes());
|
||||
let c = checksum(&out[..20]);
|
||||
out[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
Some(Bytes::from(out))
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct Datagram {
|
||||
at: Instant,
|
||||
header: Option<Vec<u8>>,
|
||||
parts: std::collections::BTreeMap<usize, Vec<u8>>,
|
||||
end: Option<usize>,
|
||||
bytes: usize,
|
||||
}
|
||||
type FragmentKey = (Option<[u8; 32]>, Ipv4Addr, Ipv4Addr, u16, u8);
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Reassembly {
|
||||
pending: HashMap<FragmentKey, Datagram>,
|
||||
}
|
||||
impl Reassembly {
|
||||
pub fn expire(&mut self, now: Instant) {
|
||||
self.pending
|
||||
.retain(|_, d| now.duration_since(d.at) < Duration::from_secs(5));
|
||||
}
|
||||
pub fn accept(&mut self, p: Bytes, max: usize, scope: Option<[u8; 32]>) -> Option<Bytes> {
|
||||
let n = header(&p)?;
|
||||
let flags = u16::from_be_bytes([p[6], p[7]]);
|
||||
if flags & 0x3fff == 0 {
|
||||
return Some(p);
|
||||
}
|
||||
let now = Instant::now();
|
||||
self.pending
|
||||
.retain(|_, d| now.duration_since(d.at) < Duration::from_secs(5));
|
||||
let (src, dst) = addresses(&p)?;
|
||||
let key = (scope, src, dst, u16::from_be_bytes([p[4], p[5]]), p[9]);
|
||||
let start = usize::from(flags & 0x1fff) * 8;
|
||||
let payload = &p[n..];
|
||||
let end = start.checked_add(payload.len())?;
|
||||
if payload.is_empty()
|
||||
|| end > 65515
|
||||
|| flags & 0x2000 != 0 && !payload.len().is_multiple_of(8)
|
||||
{
|
||||
self.pending.remove(&key);
|
||||
return None;
|
||||
}
|
||||
if !self.pending.contains_key(&key) && self.pending.len() >= max {
|
||||
return None;
|
||||
}
|
||||
let d = self.pending.entry(key).or_insert_with(|| Datagram {
|
||||
at: now,
|
||||
header: None,
|
||||
parts: Default::default(),
|
||||
end: None,
|
||||
bytes: 0,
|
||||
});
|
||||
if d.parts
|
||||
.get(&start)
|
||||
.is_some_and(|bytes| bytes.as_slice() == payload)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if d.parts.iter().any(|(s, b)| *s < end && start < s + b.len()) {
|
||||
self.pending.remove(&key);
|
||||
return None;
|
||||
}
|
||||
if flags & 0x2000 == 0 {
|
||||
if d.end.is_some_and(|e| e != end) {
|
||||
self.pending.remove(&key);
|
||||
return None;
|
||||
}
|
||||
d.end = Some(end);
|
||||
}
|
||||
if d.end.is_some_and(|e| end > e) {
|
||||
self.pending.remove(&key);
|
||||
return None;
|
||||
}
|
||||
if start == 0 {
|
||||
d.header = Some(p[..n].to_vec());
|
||||
}
|
||||
d.bytes += payload.len();
|
||||
d.parts.insert(start, payload.to_vec());
|
||||
let complete = d.end.is_some_and(|e| e == d.bytes) && d.header.is_some();
|
||||
if !complete {
|
||||
return None;
|
||||
}
|
||||
let d = self.pending.remove(&key)?;
|
||||
let mut out = d.header?;
|
||||
for part in d.parts.values() {
|
||||
out.extend(part);
|
||||
}
|
||||
if out.len() > 65535 {
|
||||
return None;
|
||||
}
|
||||
let len = out.len() as u16;
|
||||
out[2..4].copy_from_slice(&len.to_be_bytes());
|
||||
out[6..8].fill(0);
|
||||
out[10..12].fill(0);
|
||||
let h = usize::from(out[0] & 15) * 4;
|
||||
let c = checksum(&out[..h]);
|
||||
out[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
Some(Bytes::from(out))
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-fragments an assembled NAT datagram for the unchanged overlay MTU.
|
||||
pub(crate) fn fragments(packet: Bytes, mtu: u32) -> Vec<Bytes> {
|
||||
if packet.len() <= mtu as usize {
|
||||
return vec![packet];
|
||||
}
|
||||
let Some(n) = header(&packet) else {
|
||||
return vec![];
|
||||
};
|
||||
if u16::from_be_bytes([packet[6], packet[7]]) & 0x4000 != 0 {
|
||||
return vec![];
|
||||
}
|
||||
let size = (mtu as usize).saturating_sub(n) / 8 * 8;
|
||||
if size == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let mut copied = Vec::new();
|
||||
let mut offset = 20;
|
||||
while offset < n {
|
||||
let kind = packet[offset];
|
||||
if kind == 0 {
|
||||
break;
|
||||
}
|
||||
if kind == 1 {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(&len) = packet.get(offset + 1) else {
|
||||
return vec![];
|
||||
};
|
||||
let len = usize::from(len);
|
||||
if len < 2 || offset + len > n {
|
||||
return vec![];
|
||||
}
|
||||
if kind & 0x80 != 0 {
|
||||
copied.extend_from_slice(&packet[offset..offset + len]);
|
||||
}
|
||||
offset += len;
|
||||
}
|
||||
while !copied.len().is_multiple_of(4) {
|
||||
copied.push(0);
|
||||
}
|
||||
let mut result = Vec::new();
|
||||
let payload = &packet[n..];
|
||||
for (start, part) in payload.chunks(size).enumerate() {
|
||||
let offset = start * size;
|
||||
let mut out = if offset == 0 {
|
||||
packet[..n].to_vec()
|
||||
} else {
|
||||
let mut h = packet[..20].to_vec();
|
||||
h.extend_from_slice(&copied);
|
||||
h[0] = 0x40 | (h.len() / 4) as u8;
|
||||
h
|
||||
};
|
||||
let header_len = out.len();
|
||||
out.extend(part);
|
||||
let len = out.len() as u16;
|
||||
out[2..4].copy_from_slice(&len.to_be_bytes());
|
||||
let flags = (offset / 8) as u16
|
||||
| if offset + part.len() < payload.len() {
|
||||
0x2000
|
||||
} else {
|
||||
0
|
||||
};
|
||||
out[6..8].copy_from_slice(&flags.to_be_bytes());
|
||||
out[10..12].fill(0);
|
||||
let c = checksum(&out[..header_len]);
|
||||
out[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
result.push(Bytes::from(out));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
fn datagram() -> Bytes {
|
||||
let mut p = vec![0; 3028];
|
||||
p[0] = 0x45;
|
||||
p[2..4].copy_from_slice(&3028u16.to_be_bytes());
|
||||
p[8] = 64;
|
||||
p[9] = 17;
|
||||
p[12..16].copy_from_slice(&[10, 0, 0, 1]);
|
||||
p[16..20].copy_from_slice(&[10, 0, 0, 2]);
|
||||
p[20..22].copy_from_slice(&1000u16.to_be_bytes());
|
||||
p[22..24].copy_from_slice(&2000u16.to_be_bytes());
|
||||
p[24..26].copy_from_slice(&3008u16.to_be_bytes());
|
||||
finish(&mut p, 20, Protocol::Udp);
|
||||
Bytes::from(p)
|
||||
}
|
||||
#[test]
|
||||
fn fragmented_udp_reassembles_out_of_order_and_nat_checksums_are_valid() {
|
||||
let p = datagram();
|
||||
let parts = fragments(p.clone(), 1280);
|
||||
assert_eq!(parts.len(), 3);
|
||||
let mut reassembly = Reassembly::default();
|
||||
let mut result = None;
|
||||
for part in parts.into_iter().rev() {
|
||||
if let Some(p) = reassembly.accept(part, 8, None) {
|
||||
result = Some(p);
|
||||
}
|
||||
}
|
||||
assert_eq!(result, Some(p.clone()));
|
||||
let mut t = tuple(&p).unwrap();
|
||||
t.dst = Ipv4Addr::new(10, 0, 0, 240);
|
||||
t.dport = 53;
|
||||
let out = rewrite(&p, t).unwrap();
|
||||
assert_eq!(header(&out), Some(20));
|
||||
let mut sum = 0u32;
|
||||
for pair in out[12..20].as_chunks::<2>().0 {
|
||||
sum += u32::from(u16::from_be_bytes([pair[0], pair[1]]));
|
||||
}
|
||||
sum += 17 + (out.len() - 20) as u32;
|
||||
assert_eq!(checksum_seed(&out[20..], sum), 0);
|
||||
}
|
||||
#[test]
|
||||
fn overlaps_and_capacity_are_rejected_without_allocating_unbounded_state() {
|
||||
let parts = fragments(datagram(), 1280);
|
||||
let mut r = Reassembly::default();
|
||||
assert!(r.accept(parts[0].clone(), 0, None).is_none());
|
||||
assert!(r.pending.is_empty());
|
||||
assert!(r.accept(parts[0].clone(), 1, None).is_none());
|
||||
assert!(r.accept(parts[0].clone(), 1, None).is_none());
|
||||
assert_eq!(r.pending.len(), 1);
|
||||
let mut conflict = parts[0].to_vec();
|
||||
conflict[20] ^= 1;
|
||||
assert!(r.accept(Bytes::from(conflict), 1, None).is_none());
|
||||
assert!(r.pending.is_empty());
|
||||
for size in 0..80 {
|
||||
let p = vec![0xff; size];
|
||||
assert!(tuple(&p).is_none());
|
||||
assert!(icmp_quote(&p).is_none());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn fragment_assembly_is_scoped_to_authenticated_sender_and_expires_when_idle() {
|
||||
let packet = datagram();
|
||||
let parts = fragments(packet.clone(), 1280);
|
||||
let mut r = Reassembly::default();
|
||||
assert!(r.accept(parts[0].clone(), 8, Some([1; 32])).is_none());
|
||||
assert!(r.accept(parts[1].clone(), 8, Some([2; 32])).is_none());
|
||||
assert!(r.accept(parts[2].clone(), 8, Some([1; 32])).is_none());
|
||||
assert_eq!(r.accept(parts[1].clone(), 8, Some([1; 32])), Some(packet));
|
||||
assert_eq!(r.pending.len(), 1);
|
||||
r.expire(Instant::now() + Duration::from_secs(6));
|
||||
assert!(r.pending.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod icmp_tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
#[test]
|
||||
fn pmtu_errors_restore_quoted_addresses_ports_and_checksums() {
|
||||
let original = Tuple {
|
||||
src: Ipv4Addr::new(10, 0, 0, 1),
|
||||
dst: Ipv4Addr::new(10, 0, 0, 240),
|
||||
sport: 40000,
|
||||
dport: 53,
|
||||
protocol: Protocol::Udp,
|
||||
};
|
||||
let mut p = vec![0; 36];
|
||||
p[0] = 0x45;
|
||||
p[2..4].copy_from_slice(&36u16.to_be_bytes());
|
||||
p[8] = 64;
|
||||
p[9] = 17;
|
||||
p[24..26].copy_from_slice(&16u16.to_be_bytes());
|
||||
let c = checksum(&p[..20]);
|
||||
p[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
let p = rewrite(&p, original).unwrap();
|
||||
let wire = Tuple {
|
||||
dst: Ipv4Addr::new(10, 0, 0, 2),
|
||||
sport: 50000,
|
||||
dport: 5353,
|
||||
..original
|
||||
};
|
||||
let translated = rewrite(&p, wire).unwrap();
|
||||
let mut error = vec![0; 56];
|
||||
error[0] = 0x45;
|
||||
error[2..4].copy_from_slice(&56u16.to_be_bytes());
|
||||
error[8] = 64;
|
||||
error[9] = 1;
|
||||
error[12..16].copy_from_slice(&wire.dst.octets());
|
||||
error[16..20].copy_from_slice(&wire.src.octets());
|
||||
error[20] = 3;
|
||||
error[21] = 4;
|
||||
error[26..28].copy_from_slice(&1200u16.to_be_bytes());
|
||||
error[28..].copy_from_slice(&translated[..28]);
|
||||
let c = checksum(&error[20..]);
|
||||
error[22..24].copy_from_slice(&c.to_be_bytes());
|
||||
let c = checksum(&error[..20]);
|
||||
error[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
let restored = rewrite_icmp(&error, original).unwrap();
|
||||
assert_eq!(icmp_quote(&restored), Some(original));
|
||||
assert_eq!(checksum(&restored[20..]), 0);
|
||||
assert_eq!(checksum(&restored[28..48]), 0);
|
||||
assert_eq!(&restored[54..56], &p[26..28]);
|
||||
assert_eq!(&restored[26..28], &1200u16.to_be_bytes());
|
||||
assert_eq!(&restored[12..16], &original.dst.octets());
|
||||
}
|
||||
}
|
||||
Loaded 100 of 321 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user