Compare commits
69
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
|
||||||
@@ -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
|
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
|
supplies a network name and one shared secret; agents derive the same network
|
||||||
space independently, find each other, prove membership and exchange control
|
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.
|
There is no central server, no owner, no registration and no majority vote.
|
||||||
Design accordingly: a majority is not a root of trust.
|
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
|
## Architectural boundaries
|
||||||
|
|
||||||
Keep these separate. Crossing them is the main thing to review for.
|
Keep these separate. Crossing them is the main thing to review for.
|
||||||
|
|
||||||
- **Control plane vs data plane.** The separation is **logical, not physical**.
|
- **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;
|
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
|
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
|
would throw away iroh's NAT traversal and force the data plane to
|
||||||
@@ -58,9 +70,9 @@ Keep these separate. Crossing them is the main thing to review for.
|
|||||||
ownership checks or add broadcast flooding to encrypted transit.
|
ownership checks or add broadcast flooding to encrypted transit.
|
||||||
- **Plugins never learn reachability.** An `IpPlugin` is handed a `PacketLink`
|
- **Plugins never learn reachability.** An `IpPlugin` is handed a `PacketLink`
|
||||||
per peer and moves datagrams over it. Addresses, hole punching and relays
|
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*.
|
*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
|
`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
|
only after bounding every field. A data plane failure must never stop the
|
||||||
control plane.
|
control plane.
|
||||||
@@ -69,7 +81,7 @@ Keep these separate. Crossing them is the main thing to review for.
|
|||||||
inbound packets are dropped unless their source is the address derived for
|
inbound packets are dropped unless their source is the address derived for
|
||||||
the peer that sent them. Never trust an address a peer announces.
|
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
|
- **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
|
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
|
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
|
add a vote or a quorum: a majority is not a trust root here, and it would
|
||||||
@@ -78,6 +90,12 @@ Keep these separate. Crossing them is the main thing to review for.
|
|||||||
transaction, **before** the record is announced.
|
transaction, **before** the record is announced.
|
||||||
- **Plugins own their system objects.** A plugin creates and removes its own
|
- **Plugins own their system objects.** A plugin creates and removes its own
|
||||||
interface and nothing else. Never touch routing, DNS or firewall settings.
|
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
|
- **Device identity vs network identity.** The iroh endpoint id is the device's
|
||||||
public key. `NetworkId` is derived from name + secret only. Never conflate
|
public key. `NetworkId` is derived from name + secret only. Never conflate
|
||||||
them, and never let one change the other.
|
them, and never let one change the other.
|
||||||
@@ -97,7 +115,7 @@ Keep these separate. Crossing them is the main thing to review for.
|
|||||||
normal path *and* the important failures. See [docs/testing.md](docs/testing.md).
|
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.
|
Do not add tests for getters or to move a coverage number.
|
||||||
2. **Never change `IDENTITY_SCHEME`, the derivation labels, or the transcript
|
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
|
treating it as an incompatible protocol change. Bumping the crate version or
|
||||||
the control protocol version must not change an existing `NetworkId`.
|
the control protocol version must not change an existing `NetworkId`.
|
||||||
3. **Secrets never leak.** Not into logs, not into `Debug`, not into status
|
3. **Secrets never leak.** Not into logs, not into `Debug`, not into status
|
||||||
@@ -108,7 +126,7 @@ Keep these separate. Crossing them is the main thing to review for.
|
|||||||
the library; tests opt out explicitly at the top of each file.
|
the library; tests opt out explicitly at the top of each file.
|
||||||
5. **Bounds before allocation.** Frame lengths are checked against
|
5. **Bounds before allocation.** Frame lengths are checked against
|
||||||
`Limits::max_frame_len` before a buffer is allocated. Every string, list and
|
`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
|
6. **Failure is contained.** A bad signature, wrong secret, malformed packet or
|
||||||
unknown version rejects one message or one session. It never stops another
|
unknown version rejects one message or one session. It never stops another
|
||||||
network and never stops the agent. There is no irreversible global error
|
network and never stops the agent. There is no irreversible global error
|
||||||
@@ -125,24 +143,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.
|
up shutdown. Wind tasks down with a grace period and then abort.
|
||||||
10. **Nothing from a remote announcement becomes a shell command, a filesystem
|
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
|
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
|
## Where things live
|
||||||
|
|
||||||
| path | responsibility |
|
| path | responsibility |
|
||||||
|---------------------|----------------|
|
|---------------------|----------------|
|
||||||
| `crates/tsunagi/src/identity/` | device identity; deterministic network space identity and derived keys |
|
| `crates/tsunagi-core/src/identity/` | device identity; deterministic network space identity and derived keys |
|
||||||
| `crates/tsunagi/src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
|
| `crates/tsunagi-core/src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
|
||||||
| `crates/tsunagi/src/discovery.rs` | candidate sources; test and static backends |
|
| `crates/tsunagi-core/src/discovery.rs` | candidate sources; test and static backends |
|
||||||
| `crates/tsunagi/src/proto/` | framing, message formats, membership handshake |
|
| `crates/tsunagi-core/src/proto/` | framing, message formats, membership handshake |
|
||||||
| `crates/tsunagi/src/net.rs` | iroh endpoint adapter and observability snapshots |
|
| `crates/tsunagi-core/src/net.rs` | iroh endpoint adapter and observability snapshots |
|
||||||
| `crates/tsunagi/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
|
| `crates/tsunagi-core/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-core/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-core/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks, broadcast host rules |
|
||||||
| `crates/tsunagi/src/dataplane/` | the protocol contract and the authenticated packet transport |
|
| `crates/tsunagi-core/src/dataplane/` | the protocol contract and the authenticated packet transport |
|
||||||
| `crates/tsunagi/src/dns/` | the DNS view of a network: zone, server, resolver publication |
|
| `crates/tsunagi-core/src/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-wg-quic/` | the `wg-quic` protocol: its keys, its announcement, its tunnels |
|
||||||
| `crates/tsunagi-cli/` | the command line agent; the only place that owns a runtime, a logger and signals |
|
| `crates/tsunagi/` | the command line agent, built as `tsng`; the only place that owns a runtime, a logger and signals |
|
||||||
| `tests/` | integration tests; `tests/common/` is the shared harness |
|
| `tests/` | integration tests; `tests/common/` is the shared harness |
|
||||||
|
|
||||||
Add abstractions only at real substitution or testing boundaries. Do not add a
|
Add abstractions only at real substitution or testing boundaries. Do not add a
|
||||||
|
|||||||
Generated
+3149
-101
File diff suppressed because it is too large.
Load diff
+4
-4
@@ -14,12 +14,12 @@ mainline = { path = "vendor/mainline" }
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.91"
|
rust-version = "1.91"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "WTFPL"
|
||||||
repository = "https://github.com/tsunagi-net/tsunagi"
|
repository = "https://github.com/house-of-vanity/tsunagi"
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
missing_docs = "warn"
|
missing_docs = "warn"
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "deny"
|
||||||
|
|
||||||
[workspace.lints.clippy]
|
[workspace.lints.clippy]
|
||||||
unwrap_used = "warn"
|
unwrap_used = "warn"
|
||||||
@@ -27,7 +27,7 @@ expect_used = "warn"
|
|||||||
panic = "warn"
|
panic = "warn"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[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"] }
|
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
|
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,743 +1,184 @@
|
|||||||
# tsunagi
|
<p align="center">
|
||||||
|
<img src="dist/linux/tsunagi.svg" width="96" alt="">
|
||||||
A proof-of-concept Rust library for **small private mesh networks** — a handful
|
</p>
|
||||||
of friends, home machines, a few servers. Units to dozens of participants, not
|
|
||||||
thousands.
|
<h1 align="center">tsunagi</h1>
|
||||||
|
|
||||||
The end user configures exactly two things:
|
<p align="center">
|
||||||
|
<b>A mesh VPN with no server.</b><br>
|
||||||
```text
|
Pick a network name, share a secret, and your machines are on one private network.
|
||||||
network_name
|
</p>
|
||||||
secret # one shared secret; "password" and "secret" mean the same value
|
|
||||||
```
|
<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>
|
||||||
From those, every agent independently derives the same network space. There is
|
<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>
|
||||||
no central server, no network owner with special powers, no registration and no
|
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-WTFPL-blue"></a>
|
||||||
majority vote. Anyone who knows the parameters can join; nobody has to trust
|
</p>
|
||||||
anybody else.
|
|
||||||
|
```console
|
||||||
## What this proof of concept actually does
|
laptop$ tsng join -n home
|
||||||
|
joined `home`
|
||||||
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)
|
|
||||||
secret tsn1u7c…
|
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…
|
laptop$ ssh server.home
|
||||||
|
|
||||||
Its agent has to be running. If it is not:
|
|
||||||
|
|
||||||
tsunagi up --peer 91e83a6e2b7a…
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Two networks of one name is a thing that happens — a mistyped secret makes
|
No account, no coordination server, no admin console, no keys to approve.
|
||||||
one — so joining says whether the network was already here, and warns when
|
Anyone who knows the **name** and the **secret** is in. Nobody else can find
|
||||||
another configured network answers to the same name. A name is a label and
|
the network, let alone enter it.
|
||||||
the id is the identity.
|
|
||||||
|
|
||||||
That is the ad-hoc case: one person makes a network and sends the command
|
## Why
|
||||||
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.
|
|
||||||
|
|
||||||
A secret is printed by `network secret` and nowhere else — not by `id`, not
|
Tailscale, Headscale and ZeroTier are great, but each needs a control server:
|
||||||
by `status`, not in a log, a `Debug` rendering or anything sent to a peer.
|
theirs, or one you have to run and keep alive. tsunagi doesn't. Machines find
|
||||||
Asking for it is deliberate, because these reports get pasted into chats.
|
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
|
| | Tailscale | Headscale | ZeroTier | **tsunagi** |
|
||||||
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`
|
| Control server | hosted by vendor | you run it | hosted or self-run | **none** |
|
||||||
in another.
|
| 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
|
## Features
|
||||||
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`.
|
|
||||||
|
|
||||||
The name is part of the signed state, so changing it revokes the previous
|
- **Two things to know:** a network name and a secret. A secret is generated for you when you make a network.
|
||||||
one: there is one record per author, a new version replaces the whole claim,
|
- **Direct, encrypted links** between members (WireGuard cryptography), with relay fallback behind strict NATs.
|
||||||
and no replica can keep the old name standing. Changing it while the agent
|
- **Names for machines:** `server.home` just works, even while `server` is switched off.
|
||||||
runs goes through the agent, which republishes and tells its peers straight
|
- **Exit nodes:** send all your internet traffic through any member that offers one.
|
||||||
away.
|
- **Multi-hop:** members that can't reach each other directly are routed through a member that can.
|
||||||
|
- **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
|
## Install
|
||||||
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.
|
|
||||||
|
|
||||||
`status` and `id` both prefer a running agent, which is live and
|
Grab a build from the [latest release](https://github.com/house-of-vanity/tsunagi/releases/latest).
|
||||||
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.
|
|
||||||
|
|
||||||
### 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**
|
Linux packages run the agent as an unprivileged service user. Add yourself to
|
||||||
made persistent, so the kernel removes it when the agent exits — on a clean
|
the `tsunagi` group (`sudo usermod -aG tsunagi $USER`, then log in again) to
|
||||||
shutdown, on a panic, on `SIGKILL`, on power loss alike. Keeping it is what
|
use `tsng` and the tray without `sudo`.
|
||||||
would take an action; removing it is the default.
|
|
||||||
|
|
||||||
If something is left behind anyway — an interface made by an older version's
|
## Quick start
|
||||||
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:
|
|
||||||
|
|
||||||
* an interface that is not a TUN, because the name colliding with somebody's
|
```sh
|
||||||
bridge is not a reason to destroy the bridge;
|
tsng join -n home # first machine: makes the network and prints a secret
|
||||||
* a TUN that another process is holding open, because that is a working
|
tsng join -n home -s tsn1… # every other machine
|
||||||
overlay belonging to somebody else — most likely a second agent on this
|
tsng status # who is online, over which path
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The whole suite runs offline on loopback. Set `TSUNAGI_TEST_LOG=tsunagi=debug`
|
Every member gets an address in `10.13.37.0/24` and a name like `<host>.home`.
|
||||||
to see agent logs while a test runs.
|
Machines are called `tsunagi-<id>` until you rename them: `tsng id hostname laptop`.
|
||||||
|
|
||||||
There are also two runnable demos, which are demos and not substitutes for the
|
### Exit nodes
|
||||||
tests:
|
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
cargo run --example two_agents # control plane only
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
Both run with no privileges and change nothing on the host.
|
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).
|
||||||
|
|
||||||
## Usage
|
## Platforms
|
||||||
|
|
||||||
```rust,no_run
|
| Platform | Build | CLI `tsng` | Tray | Exit node: offer / use | Packages |
|
||||||
use std::sync::Arc;
|
| ------------------------- | ------------ | :--------: | :--: | :--------------------: | ----------------------------------- |
|
||||||
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
| Linux x86_64 | release | ✔ | ✔ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball, Nix |
|
||||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
| Linux aarch64 | release | ✔ | –¹ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball, Nix |
|
||||||
use tsunagi::identity::{NetworkName, NetworkSecret};
|
| Linux armv7 | release | ✔ | –¹ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball |
|
||||||
use tsunagi::proto::ControlMessage;
|
| macOS, Apple silicon | release | ✔ | ✔ | ✔ / ✔² | Homebrew, tarball |
|
||||||
use tsunagi::{Agent, Result};
|
| macOS, Intel | from source | ✔³ | ✔³ | ✔ / ✔²³ | – |
|
||||||
|
| Windows x86_64 | release | ✔ | ✔ | ✔ / ✔² | installer, zip |
|
||||||
|
|
||||||
// The library never starts a runtime, installs a logger, handles signals,
|
<sup>¹ Not in the release; builds from source (and through Nix).
|
||||||
// forks, or calls process::exit. The binary owns all of that.
|
² Newer and less battle-tested than on Linux.
|
||||||
#[tokio::main]
|
³ Builds from source, not covered by CI.</sup>
|
||||||
async fn main() -> Result<()> {
|
|
||||||
let config = AgentConfig::new(StoragePaths::user_default()?)
|
|
||||||
.with_transport(TransportPolicy::N0Defaults)
|
|
||||||
.with_discovery(Arc::new(SharedMemoryDiscovery::new()));
|
|
||||||
|
|
||||||
let agent = Agent::spawn(config).await?;
|
The Linux CLI is a single static binary: it runs on any distribution.
|
||||||
|
|
||||||
let name = NetworkName::new("kitchen-table")?;
|
## NixOS
|
||||||
let secret = NetworkSecret::generate(); // 32 random bytes
|
|
||||||
println!("share this: {}", secret.encode().as_str());
|
|
||||||
|
|
||||||
let network = agent.join_network(&name, &secret).await?;
|
The repository is a flake with a package and a module.
|
||||||
|
|
||||||
let mut events = agent.subscribe();
|
```nix
|
||||||
tokio::spawn(async move {
|
# flake.nix
|
||||||
while let Ok(event) = events.recv().await {
|
{
|
||||||
println!("{event:?}");
|
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
|
Just trying it: `nix run github:house-of-vanity/tsunagi#tsunagi-cli -- --help`.
|
||||||
reaches the internet by accident. Opt into `DirectOnly` or `N0Defaults`
|
`packages.<system>.tsunagi` (with the tray) and `tsunagi-cli` are also exported,
|
||||||
explicitly.
|
along with `overlays.default`.
|
||||||
|
|
||||||
### Through somebody in the middle
|
## Build from source
|
||||||
|
|
||||||
Tsunagi routes the overlay in userspace. A direct data link always wins;
|
Rust 1.91 or newer.
|
||||||
otherwise the shortest available path can pass through several members.
|
|
||||||
Each authenticated member advertises only its own live, protocol-specific
|
|
||||||
transport links. The control plane collects these into a graph and replaces
|
|
||||||
the routing table when connectivity changes; announcements expire after
|
|
||||||
90 seconds without refresh.
|
|
||||||
|
|
||||||
Transit goes transport → router → transport, without entering WireGuard or
|
|
||||||
the intermediate host's TUN. The payload stays encrypted between the original
|
|
||||||
endpoints. TUN connects only the local OS to Tsunagi; the kernel needs only
|
|
||||||
the overlay route through that interface, with no IP forwarding configuration.
|
|
||||||
|
|
||||||
A 16-hop limit bounds loops while topology updates converge. Equal shortest
|
|
||||||
paths use a stable flow hash derived before encryption, so packets of a TCP
|
|
||||||
or UDP flow keep their path while topology is unchanged. Physical link changes
|
|
||||||
do not replace end-to-end WireGuard tunnels or change the 1280-byte default MTU.
|
|
||||||
`status` reports the hop count and first next hop for relayed paths.
|
|
||||||
|
|
||||||
The hot path reads an immutable routing snapshot and sends directly to a cached
|
|
||||||
transport handle. It takes no routing mutex, walks no graph and does not parse
|
|
||||||
the encrypted payload. See [routing.md](docs/routing.md) for the architecture,
|
|
||||||
limits and reproducible forwarding microbenchmark.
|
|
||||||
|
|
||||||
This wire format requires all members to upgrade together (control ALPN 3,
|
|
||||||
data ALPN 4); saved identities, network names, secrets and addresses survive.
|
|
||||||
|
|
||||||
### LAN game discovery
|
|
||||||
|
|
||||||
IPv4 UDP broadcast relay is enabled by default for each network. The IP router
|
|
||||||
sends one encrypted copy to each participating peer, including through multihop
|
|
||||||
paths. It supports `255.255.255.255` and the overlay subnet's broadcast address;
|
|
||||||
received broadcasts never trigger another fanout.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
tsunagi join -n games --no-broadcast
|
cargo build --release --bin tsng # the agent and CLI
|
||||||
tsunagi join -n games --broadcast
|
cargo build --release --bin tsunagi-tray # the tray (Linux needs GTK 3 and libayatana-appindicator)
|
||||||
tsunagi network broadcast <network-id-or-prefix> off
|
|
||||||
tsunagi network broadcast <network-id-or-prefix> on
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The choice persists across restarts and a plain `join`. It can be changed while
|
Windows needs [Wintun](https://www.wintun.net) next to `tsng.exe`.
|
||||||
the agent runs; `status` shows it for each network. The game must send through
|
Per-platform notes: [Linux](dist/linux/README.md), [Arch](dist/arch/README.md),
|
||||||
the Tsunagi interface. Physical LAN capture/subnet sharing is not implemented.
|
[macOS](dist/macos/README.md), [Windows](dist/windows/README.md).
|
||||||
See [broadcast.md](docs/broadcast.md) for domain isolation and future LAN gateways.
|
|
||||||
|
|
||||||
## How peers find each other
|
## How it works
|
||||||
|
|
||||||
Two different lookups are involved, and only one of them is this project's:
|
- 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.
|
||||||
|
|
||||||
**1. Resolving one endpoint's address — iroh's, and it works today.**
|
The secret is the only key, so make it a good one (the generated ones are).
|
||||||
With `--reach relay` or `--reach direct`, iroh publishes a signed
|
The [threat model](docs/threat-model.md) says what this does and doesn't protect against.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [docs/architecture.md](docs/architecture.md) — module boundaries and runtime.
|
[Architecture](docs/architecture.md) ·
|
||||||
- [docs/wireguard.md](docs/wireguard.md) — the WireGuard plugin: overlay
|
[Protocol](docs/protocol.md) ·
|
||||||
addressing, announcements, backends, reconciliation.
|
[WireGuard data plane](docs/wireguard.md) ·
|
||||||
- [docs/protocol.md](docs/protocol.md) — identity derivation, framing, handshake.
|
[Routing](docs/routing.md) ·
|
||||||
- [docs/sync-model.md](docs/sync-model.md) — the planned signed-state model and
|
[Exit nodes](docs/exit-node.md) ·
|
||||||
what is deliberately not built yet.
|
[Threat model](docs/threat-model.md) ·
|
||||||
- [docs/threat-model.md](docs/threat-model.md) — threat model and known limits.
|
[Contributing](AGENTS.md)
|
||||||
- [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.
|
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
MIT OR Apache-2.0.
|
[WTFPL](LICENSE): do what the fuck you want to.
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
[package]
|
||||||
|
name = "tsunagi-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Small private mesh networks: persistent agent identity, deterministic network spaces, an iroh control plane, signed state and a local DNS view."
|
||||||
|
readme = "../../README.md"
|
||||||
|
keywords = ["mesh", "p2p", "iroh", "networking"]
|
||||||
|
categories = ["network-programming"]
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "tsunagi"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
# A real TUN device, so a plugin can carry actual IP traffic. On Linux it
|
||||||
|
# needs CAP_NET_ADMIN; on Windows the process must run elevated and
|
||||||
|
# `wintun.dll` must be reachable. Without either the in-memory device serves
|
||||||
|
# tests. The platform-specific dependencies are declared per target, so
|
||||||
|
# enabling this only pulls what the target it is built for actually uses.
|
||||||
|
tun-device = [
|
||||||
|
"dep:tun",
|
||||||
|
"dep:rtnetlink",
|
||||||
|
"dep:caps",
|
||||||
|
"dep:futures-util",
|
||||||
|
]
|
||||||
|
# Telling the operating system where to send its DNS questions. systemd-resolved
|
||||||
|
# on Linux, the Name Resolution Policy Table on Windows; the zone and the
|
||||||
|
# server work without it either way.
|
||||||
|
dns-publish = ["dep:zbus"]
|
||||||
|
# The test harness in `testing`, for this crate's own tests and for a
|
||||||
|
# protocol crate's. Not on by default: it is only of use to a test.
|
||||||
|
testing = ["dep:tempfile", "dep:tracing-subscriber"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
arc-swap = "1.9"
|
||||||
|
iroh.workspace = true
|
||||||
|
# `net` for the local control interface: a Unix socket on Unix, a named pipe
|
||||||
|
# on Windows. Both live behind the same `net` feature.
|
||||||
|
tokio = { workspace = true, features = ["net"] }
|
||||||
|
tokio-stream = "0.1"
|
||||||
|
serde.workspace = true
|
||||||
|
postcard.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
data-encoding.workspace = true
|
||||||
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
|
hkdf = "0.13"
|
||||||
|
hmac = "0.13"
|
||||||
|
sha2 = "0.11"
|
||||||
|
subtle = "2.6"
|
||||||
|
zeroize = { version = "1.9", features = ["derive"] }
|
||||||
|
rand = "0.10"
|
||||||
|
# Already in the tree through iroh. A packet codec, not a DNS server: the
|
||||||
|
# zone logic is ours and a full server framework would be a large dependency
|
||||||
|
# for answering A records from memory.
|
||||||
|
simple-dns = "0.12"
|
||||||
|
# The IANA top-level domain list, compiled in: one function, no
|
||||||
|
# dependencies, no network. Used only to warn that a zone name shadows a
|
||||||
|
# real public domain, never to refuse one.
|
||||||
|
tld = "2.40"
|
||||||
|
fs4 = { version = "1.1", features = ["sync"] }
|
||||||
|
directories = "6.0"
|
||||||
|
# The real system hostname, without a libc call of our own: this crate is
|
||||||
|
# forbidden `unsafe` and will not make one.
|
||||||
|
gethostname = "1.1"
|
||||||
|
netwatch = "0.19.3"
|
||||||
|
netdev = "0.46"
|
||||||
|
mainline = "8.0"
|
||||||
|
futures-lite = "2.6"
|
||||||
|
tun = { version = "0.8", features = ["async"], optional = true }
|
||||||
|
# Only for the `testing` harness, which a protocol crate's tests use too.
|
||||||
|
tempfile = { version = "3.24", optional = true }
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||||
|
|
||||||
|
# Linux-only interface provisioning. `rtnetlink` configures the interface in
|
||||||
|
# process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN
|
||||||
|
# out of the effective set except during the moments it is used.
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
rtnetlink = { version = "0.23", optional = true }
|
||||||
|
# Pure Rust D-Bus, no libdbus to link against. `tokio` rather than the
|
||||||
|
# default reactor, because the agent brings its own.
|
||||||
|
zbus = { version = "5.19", default-features = false, features = ["tokio"], optional = true }
|
||||||
|
caps = { version = "0.5", optional = true }
|
||||||
|
futures-util = { version = "0.3", default-features = false, optional = true }
|
||||||
|
|
||||||
|
# Windows-only interface provisioning. The Wintun adapter itself is created
|
||||||
|
# and driven through the `tun` dependency, whose safe wrapper is the only way
|
||||||
|
# in — loading `wintun.dll` is an `unsafe` call this crate forbids. `netdev`
|
||||||
|
# (already in the tree through `netwatch`) reads the interface table through a
|
||||||
|
# safe wrapper, which is how the provisioner observes an adapter by name
|
||||||
|
# before deciding what to change. Creating an adapter needs the process to be
|
||||||
|
# elevated and `wintun.dll` to be reachable at run time.
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows-sys = { version = "0.61", features = [
|
||||||
|
"Win32_Security",
|
||||||
|
"Win32_Security_Authorization",
|
||||||
|
"Win32_Foundation",
|
||||||
|
] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# Its own tests use the harness it publishes.
|
||||||
|
tsunagi-core = { path = ".", features = ["testing"] }
|
||||||
|
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process", "test-util"] }
|
||||||
|
tempfile.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File renamed without changes.
@@ -281,11 +281,19 @@ impl Agent {
|
|||||||
// The data plane rides on iroh too, which is where it gets hole
|
// The data plane rides on iroh too, which is where it gets hole
|
||||||
// punching and relay fallback from. It is a separate ALPN and a
|
// punching and relay fallback from. It is a separate ALPN and a
|
||||||
// separate connection, so the two planes stay independent.
|
// separate connection, so the two planes stay independent.
|
||||||
let transport: Arc<dyn PacketTransport> = Arc::new(IrohTransport::new(
|
let iroh_transport: Arc<dyn PacketTransport> = Arc::new(IrohTransport::new(
|
||||||
inner.adapter.clone(),
|
inner.adapter.clone(),
|
||||||
Arc::clone(&inner.limits),
|
Arc::clone(&inner.limits),
|
||||||
Arc::new(TransportCtx(Arc::downgrade(&inner))) as Arc<dyn TransportContext>,
|
Arc::new(TransportCtx(Arc::downgrade(&inner))) as Arc<dyn TransportContext>,
|
||||||
));
|
));
|
||||||
|
let transport: Arc<dyn PacketTransport> = if inner.config.custom_transports.is_empty() {
|
||||||
|
iroh_transport
|
||||||
|
} else {
|
||||||
|
Arc::new(crate::dataplane::MultiPacketTransport::new(
|
||||||
|
iroh_transport,
|
||||||
|
inner.config.custom_transports.clone(),
|
||||||
|
))
|
||||||
|
};
|
||||||
let _ = inner.transport.set(transport);
|
let _ = inner.transport.set(transport);
|
||||||
|
|
||||||
if let CacheOutcome::Reset(reason) = inner.storage.cache_outcome().clone() {
|
if let CacheOutcome::Reset(reason) = inner.storage.cache_outcome().clone() {
|
||||||
@@ -346,6 +354,26 @@ impl Agent {
|
|||||||
self.inner.adapter.loopback_addr()
|
self.inner.adapter.loopback_addr()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delivers an inbound data plane link to the matching network runtime.
|
||||||
|
pub async fn install_inbound_link(
|
||||||
|
&self,
|
||||||
|
inbound: crate::dataplane::transport::InboundLink,
|
||||||
|
) -> Result<()> {
|
||||||
|
let network = inbound.network;
|
||||||
|
let sender = {
|
||||||
|
let networks = self.inner.networks.read().await;
|
||||||
|
networks.get(&network).map(|handle| handle.commands.clone())
|
||||||
|
};
|
||||||
|
let Some(sender) = sender else {
|
||||||
|
return Err(Error::NetworkNotActive(network));
|
||||||
|
};
|
||||||
|
sender
|
||||||
|
.send(NetCommand::InboundLink(Box::new(inbound)))
|
||||||
|
.await
|
||||||
|
.map_err(|_| Error::NetworkNotActive(network))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Reserves the configured overlay range for a network, if it can.
|
/// Reserves the configured overlay range for a network, if it can.
|
||||||
///
|
///
|
||||||
/// Done here, where activations are serialised, rather than inside the
|
/// Done here, where activations are serialised, rather than inside the
|
||||||
@@ -357,12 +385,10 @@ impl Agent {
|
|||||||
/// waits to adopt whatever it settles on. One agent has one interface, so
|
/// waits to adopt whatever it settles on. One agent has one interface, so
|
||||||
/// proposing a range it could not route would be worse than having none:
|
/// proposing a range it could not route would be worse than having none:
|
||||||
/// the lowest author's range wins, and the collision would spread.
|
/// the lowest author's range wins, and the collision would spread.
|
||||||
fn reserve_range(&self, network: NetworkId) -> RangePlan {
|
async fn reserve_range(&self, network: NetworkId) -> RangePlan {
|
||||||
let Some(configured) = self.inner.config.overlay_ipv4_range else {
|
|
||||||
return RangePlan::default();
|
|
||||||
};
|
|
||||||
let reserve = |wanted: Ipv4Range| {
|
let reserve = |wanted: Ipv4Range| {
|
||||||
let reservation = crate::overlay::NetworkRoutes {
|
let reservation = crate::overlay::NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: Default::default(),
|
broadcast: Default::default(),
|
||||||
range: Some(wanted),
|
range: Some(wanted),
|
||||||
local: None,
|
local: None,
|
||||||
@@ -371,6 +397,44 @@ impl Agent {
|
|||||||
self.inner.routes.set_network(network, reservation).is_ok()
|
self.inner.routes.set_network(network, reservation).is_ok()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// If this network already has signed records in storage, check if its
|
||||||
|
// members have already agreed on a range. If so, preserve and reserve
|
||||||
|
// THAT range rather than preemptively seizing the default configured range.
|
||||||
|
let stored_records = self
|
||||||
|
.inner
|
||||||
|
.storage
|
||||||
|
.signed_records(network)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut state_set = crate::state::StateSet::new();
|
||||||
|
state_set.merge_all(network, stored_records);
|
||||||
|
if let Some(agreed) = state_set.agreed_range() {
|
||||||
|
if reserve(agreed) {
|
||||||
|
return RangePlan {
|
||||||
|
propose: if Some(agreed) == self.inner.config.overlay_ipv4_range {
|
||||||
|
Some(agreed)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
..RangePlan::default()
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
%network,
|
||||||
|
range = %agreed,
|
||||||
|
"agreed range for this network overlaps another network already active on this interface"
|
||||||
|
);
|
||||||
|
return RangePlan {
|
||||||
|
conflict: Some(agreed),
|
||||||
|
..RangePlan::default()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(configured) = self.inner.config.overlay_ipv4_range else {
|
||||||
|
return RangePlan::default();
|
||||||
|
};
|
||||||
|
|
||||||
// The configured range first, so a network a device has always had
|
// The configured range first, so a network a device has always had
|
||||||
// keeps the addresses it has always had.
|
// keeps the addresses it has always had.
|
||||||
if reserve(configured) {
|
if reserve(configured) {
|
||||||
@@ -418,6 +482,7 @@ impl Agent {
|
|||||||
mtu: interface.mtu(),
|
mtu: interface.mtu(),
|
||||||
addresses: interface.wanted_addresses(),
|
addresses: interface.wanted_addresses(),
|
||||||
counters: interface.counters(),
|
counters: interface.counters(),
|
||||||
|
broadcast_rules: interface.broadcast_rules(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,7 +582,7 @@ impl Agent {
|
|||||||
"`{name}` is already configured with a different secret, as {}. \
|
"`{name}` is already configured with a different secret, as {}. \
|
||||||
Joining with this one adds a second network under the same name; \
|
Joining with this one adds a second network under the same name; \
|
||||||
they share nothing. Check the secret, or use \
|
they share nothing. Check the secret, or use \
|
||||||
`tsunagi network secret` \
|
`tsng network secret` \
|
||||||
to see which is which.",
|
to see which is which.",
|
||||||
other.network_id
|
other.network_id
|
||||||
);
|
);
|
||||||
@@ -539,6 +604,72 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Offers this agent as an exit node in a network, or stops, now and
|
||||||
|
/// after restart. Off unless somebody turns it on.
|
||||||
|
pub async fn set_exit_offer(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||||
|
self.inner
|
||||||
|
.storage
|
||||||
|
.set_exit_node(network_id, enabled)
|
||||||
|
.await?;
|
||||||
|
if self.is_active(network_id).await {
|
||||||
|
let (reply, receive) = oneshot::channel();
|
||||||
|
self.command(network_id, NetCommand::SetExitOffer { enabled, reply })
|
||||||
|
.await?;
|
||||||
|
receive.await.map_err(|_| Error::Stopped)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends this device's internet traffic through a member of a network,
|
||||||
|
/// or back the ordinary way with `None`.
|
||||||
|
///
|
||||||
|
/// A device has one default route, so choosing in one network clears the
|
||||||
|
/// choice in the others. The member has to be connected and offering
|
||||||
|
/// to be an exit node. Remembered across restarts.
|
||||||
|
pub async fn set_exit_via(&self, network_id: NetworkId, via: Option<EndpointId>) -> Result<()> {
|
||||||
|
let active = self.is_active(network_id).await;
|
||||||
|
if via.is_some() && !active {
|
||||||
|
return Err(Error::NetworkNotActive(network_id));
|
||||||
|
}
|
||||||
|
if active {
|
||||||
|
let (reply, receive) = oneshot::channel();
|
||||||
|
self.command(network_id, NetCommand::SetExitVia { via, reply })
|
||||||
|
.await?;
|
||||||
|
receive
|
||||||
|
.await
|
||||||
|
.map_err(|_| Error::Stopped)?
|
||||||
|
.map_err(|reason| Error::Rejected { reason })?;
|
||||||
|
}
|
||||||
|
self.inner
|
||||||
|
.storage
|
||||||
|
.set_exit_via(network_id, via.map(|peer| *peer.as_bytes()))
|
||||||
|
.await?;
|
||||||
|
if via.is_some() {
|
||||||
|
// One default route: whatever the other networks were doing, they
|
||||||
|
// are not any more.
|
||||||
|
let others: Vec<NetworkId> = self
|
||||||
|
.inner
|
||||||
|
.networks
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.keys()
|
||||||
|
.copied()
|
||||||
|
.filter(|other| *other != network_id)
|
||||||
|
.collect();
|
||||||
|
for other in others {
|
||||||
|
let (reply, receive) = oneshot::channel();
|
||||||
|
if self
|
||||||
|
.command(other, NetCommand::SetExitVia { via: None, reply })
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
let _ = receive.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Changes a network's local broadcast policy now and after restart.
|
/// Changes a network's local broadcast policy now and after restart.
|
||||||
pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||||
self.inner
|
self.inner
|
||||||
@@ -582,7 +713,7 @@ impl Agent {
|
|||||||
return Err(Error::NetworkAlreadyActive(network_id));
|
return Err(Error::NetworkAlreadyActive(network_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
let range = self.reserve_range(network_id);
|
let range = self.reserve_range(network_id).await;
|
||||||
let mut backends = Vec::new();
|
let mut backends = Vec::new();
|
||||||
if let Some(discovery) = &self.inner.config.discovery {
|
if let Some(discovery) = &self.inner.config.discovery {
|
||||||
backends.push(discovery.clone());
|
backends.push(discovery.clone());
|
||||||
@@ -598,16 +729,23 @@ impl Agent {
|
|||||||
as Arc<dyn crate::discovery::NetworkDiscovery>,
|
as Arc<dyn crate::discovery::NetworkDiscovery>,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let broadcast = self
|
let stored = self
|
||||||
.inner
|
.inner
|
||||||
.storage
|
.storage
|
||||||
.list_networks()
|
.list_networks()
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|stored| stored.network_id == network_id)
|
.find(|stored| stored.network_id == network_id);
|
||||||
.is_none_or(|stored| stored.broadcast);
|
let broadcast = stored.as_ref().is_none_or(|stored| stored.broadcast);
|
||||||
|
let exit_offer = stored.as_ref().is_some_and(|stored| stored.exit_node);
|
||||||
|
let exit_via = stored
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|stored| stored.exit_via)
|
||||||
|
.and_then(|bytes| EndpointId::from_bytes(&bytes).ok());
|
||||||
let handle = network::spawn(RuntimeParams {
|
let handle = network::spawn(RuntimeParams {
|
||||||
broadcast,
|
broadcast,
|
||||||
|
exit_offer,
|
||||||
|
exit_via,
|
||||||
keys,
|
keys,
|
||||||
adapter: self.inner.adapter.clone(),
|
adapter: self.inner.adapter.clone(),
|
||||||
storage: self.inner.storage.clone(),
|
storage: self.inner.storage.clone(),
|
||||||
@@ -662,11 +800,20 @@ impl Agent {
|
|||||||
// itself stays, because the agent owns it and other networks may
|
// itself stays, because the agent owns it and other networks may
|
||||||
// still be using it.
|
// still be using it.
|
||||||
self.inner.routes.remove_network(network_id);
|
self.inner.routes.remove_network(network_id);
|
||||||
|
if let Some(interface) = self.inner.interface.get() {
|
||||||
|
interface
|
||||||
|
.set_underlay(network_id, Vec::new(), Vec::new())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
if let Some(interface) = self.inner.interface.get()
|
if let Some(interface) = self.inner.interface.get()
|
||||||
&& let Err(err) = interface.sync_addresses().await
|
&& let Err(err) = interface.sync_addresses().await
|
||||||
{
|
{
|
||||||
tracing::warn!(%err, "cannot update the overlay addresses");
|
tracing::warn!(%err, "cannot update the overlay addresses");
|
||||||
}
|
}
|
||||||
|
if let Some(interface) = self.inner.interface.get() {
|
||||||
|
interface.sync_broadcast_rules().await;
|
||||||
|
interface.sync_exit_rules().await;
|
||||||
|
}
|
||||||
self.inner.storage.set_auto_start(network_id, false).await?;
|
self.inner.storage.set_auto_start(network_id, false).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -819,6 +966,12 @@ impl Agent {
|
|||||||
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
||||||
networks.push(NetworkStatus {
|
networks.push(NetworkStatus {
|
||||||
broadcast: stored.broadcast,
|
broadcast: stored.broadcast,
|
||||||
|
exit_offer: stored.exit_node,
|
||||||
|
exit_via: stored
|
||||||
|
.exit_via
|
||||||
|
.and_then(|bytes| EndpointId::from_bytes(&bytes).ok()),
|
||||||
|
exit_via_online: false,
|
||||||
|
exit_rules: Default::default(),
|
||||||
descriptor: keys.descriptor(),
|
descriptor: keys.descriptor(),
|
||||||
name: stored.name,
|
name: stored.name,
|
||||||
network_id: stored.network_id,
|
network_id: stored.network_id,
|
||||||
@@ -1055,6 +1208,26 @@ async fn plugin_request_loop(weak: Weak<Inner>, mut requests: mpsc::Receiver<Plu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PluginRequest::ProtocolFailure {
|
||||||
|
network,
|
||||||
|
peer,
|
||||||
|
protocol,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
let sender = {
|
||||||
|
let networks = inner.networks.read().await;
|
||||||
|
networks.get(&network).map(|handle| handle.commands.clone())
|
||||||
|
};
|
||||||
|
if let Some(sender) = sender {
|
||||||
|
let _ = sender
|
||||||
|
.send(NetCommand::ProtocolFailure {
|
||||||
|
peer,
|
||||||
|
protocol,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1200,8 +1373,23 @@ async fn handle_inbound_data(inner: Arc<Inner>, conn: iroh::endpoint::Connection
|
|||||||
conn.close(5u32.into(), b"data plane not ready");
|
conn.close(5u32.into(), b"data plane not ready");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
// Downcasting is avoided by keeping the accept side on the concrete type.
|
let iroh_transport =
|
||||||
let Some(iroh_transport) = transport.as_ref().as_any().downcast_ref::<IrohTransport>() else {
|
if let Some(iroh) = transport.as_ref().as_any().downcast_ref::<IrohTransport>() {
|
||||||
|
Some(iroh)
|
||||||
|
} else if let Some(multi) = transport
|
||||||
|
.as_ref()
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<crate::dataplane::MultiPacketTransport>()
|
||||||
|
{
|
||||||
|
multi
|
||||||
|
.default_transport()
|
||||||
|
.as_ref()
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<IrohTransport>()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let Some(iroh_transport) = iroh_transport else {
|
||||||
conn.close(5u32.into(), b"unsupported data transport");
|
conn.close(5u32.into(), b"unsupported data transport");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -66,6 +66,16 @@ pub(crate) enum NetCommand {
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
reply: oneshot::Sender<()>,
|
reply: oneshot::Sender<()>,
|
||||||
},
|
},
|
||||||
|
/// Offer this agent as an exit node in this network, or stop.
|
||||||
|
SetExitOffer {
|
||||||
|
enabled: bool,
|
||||||
|
reply: oneshot::Sender<()>,
|
||||||
|
},
|
||||||
|
/// Send this device's internet traffic through a member, or stop.
|
||||||
|
SetExitVia {
|
||||||
|
via: Option<EndpointId>,
|
||||||
|
reply: oneshot::Sender<std::result::Result<(), String>>,
|
||||||
|
},
|
||||||
/// Resend this agent's announcement to every peer of this network.
|
/// Resend this agent's announcement to every peer of this network.
|
||||||
Reannounce,
|
Reannounce,
|
||||||
/// Answer to a different name from now on.
|
/// Answer to a different name from now on.
|
||||||
@@ -86,6 +96,12 @@ pub(crate) enum NetCommand {
|
|||||||
/// Human readable reason, free of secrets.
|
/// Human readable reason, free of secrets.
|
||||||
reason: String,
|
reason: String,
|
||||||
},
|
},
|
||||||
|
/// An IP plugin reported that data plane connectivity or a tunnel for a peer failed.
|
||||||
|
ProtocolFailure {
|
||||||
|
peer: EndpointId,
|
||||||
|
protocol: String,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for NetCommand {
|
impl std::fmt::Debug for NetCommand {
|
||||||
@@ -100,10 +116,15 @@ impl std::fmt::Debug for NetCommand {
|
|||||||
NetCommand::Status { .. } => f.write_str("Status"),
|
NetCommand::Status { .. } => f.write_str("Status"),
|
||||||
NetCommand::Recheck => f.write_str("Recheck"),
|
NetCommand::Recheck => f.write_str("Recheck"),
|
||||||
NetCommand::SetBroadcast { enabled, .. } => write!(f, "SetBroadcast({enabled})"),
|
NetCommand::SetBroadcast { enabled, .. } => write!(f, "SetBroadcast({enabled})"),
|
||||||
|
NetCommand::SetExitOffer { enabled, .. } => write!(f, "SetExitOffer({enabled})"),
|
||||||
|
NetCommand::SetExitVia { via, .. } => write!(f, "SetExitVia({})", via.is_some()),
|
||||||
NetCommand::Reannounce => f.write_str("Reannounce"),
|
NetCommand::Reannounce => f.write_str("Reannounce"),
|
||||||
NetCommand::SetHostname(_) => f.write_str("SetHostname"),
|
NetCommand::SetHostname(_) => f.write_str("SetHostname"),
|
||||||
NetCommand::Release { .. } => f.write_str("Release"),
|
NetCommand::Release { .. } => f.write_str("Release"),
|
||||||
NetCommand::PluginError { protocol, .. } => write!(f, "PluginError({protocol})"),
|
NetCommand::PluginError { protocol, .. } => write!(f, "PluginError({protocol})"),
|
||||||
|
NetCommand::ProtocolFailure { peer, protocol, .. } => {
|
||||||
|
write!(f, "ProtocolFailure({}, {protocol})", peer.fmt_short())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,6 +149,10 @@ impl NetworkHandle {
|
|||||||
/// Everything a network runtime needs to run.
|
/// Everything a network runtime needs to run.
|
||||||
pub(crate) struct RuntimeParams {
|
pub(crate) struct RuntimeParams {
|
||||||
pub(crate) broadcast: bool,
|
pub(crate) broadcast: bool,
|
||||||
|
/// This agent offers itself as an exit node here.
|
||||||
|
pub(crate) exit_offer: bool,
|
||||||
|
/// The member this device's internet traffic goes through.
|
||||||
|
pub(crate) exit_via: Option<EndpointId>,
|
||||||
pub(crate) keys: NetworkKeys,
|
pub(crate) keys: NetworkKeys,
|
||||||
pub(crate) adapter: EndpointAdapter,
|
pub(crate) adapter: EndpointAdapter,
|
||||||
pub(crate) storage: Storage,
|
pub(crate) storage: Storage,
|
||||||
@@ -239,6 +264,24 @@ const REACH_EXPIRY: Duration = Duration::from_secs(90);
|
|||||||
/// can arrive and be adopted instead.
|
/// can arrive and be adopted instead.
|
||||||
const RANGE_PROPOSAL_GRACE: Duration = Duration::from_secs(3);
|
const RANGE_PROPOSAL_GRACE: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ProtocolDialState {
|
||||||
|
failures: u32,
|
||||||
|
cooldown_until: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATA_LINK_BASE_BACKOFF: Duration = Duration::from_secs(3);
|
||||||
|
const DATA_LINK_MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
fn data_link_backoff(failures: u32) -> Duration {
|
||||||
|
if failures == 0 {
|
||||||
|
return Duration::ZERO;
|
||||||
|
}
|
||||||
|
let shift = (failures - 1).min(5);
|
||||||
|
let secs = (DATA_LINK_BASE_BACKOFF.as_secs() << shift).min(DATA_LINK_MAX_BACKOFF.as_secs());
|
||||||
|
Duration::from_secs(secs)
|
||||||
|
}
|
||||||
|
|
||||||
struct Runtime {
|
struct Runtime {
|
||||||
discovery_worker: Option<DiscoveryWorker>,
|
discovery_worker: Option<DiscoveryWorker>,
|
||||||
discovery_candidates: mpsc::Receiver<Candidate>,
|
discovery_candidates: mpsc::Receiver<Candidate>,
|
||||||
@@ -284,6 +327,8 @@ struct Runtime {
|
|||||||
opening: HashSet<(EndpointId, String)>,
|
opening: HashSet<(EndpointId, String)>,
|
||||||
link_results_tx: mpsc::Sender<LinkOutcome>,
|
link_results_tx: mpsc::Sender<LinkOutcome>,
|
||||||
link_results_rx: mpsc::Receiver<LinkOutcome>,
|
link_results_rx: mpsc::Receiver<LinkOutcome>,
|
||||||
|
/// Protocol dial failure count and backoff cooldown per peer and protocol.
|
||||||
|
protocol_dial_states: HashMap<(EndpointId, String), ProtocolDialState>,
|
||||||
/// Peers already told about a protocol version that cannot match, so it
|
/// Peers already told about a protocol version that cannot match, so it
|
||||||
/// is said once rather than on every announcement.
|
/// is said once rather than on every announcement.
|
||||||
reported_mismatch: HashSet<(EndpointId, String)>,
|
reported_mismatch: HashSet<(EndpointId, String)>,
|
||||||
@@ -302,6 +347,14 @@ struct Runtime {
|
|||||||
pending_state: Vec<(EndpointId, Vec<SignedRecord>)>,
|
pending_state: Vec<(EndpointId, Vec<SignedRecord>)>,
|
||||||
/// The highest version this agent has ever published for this network.
|
/// The highest version this agent has ever published for this network.
|
||||||
own_version: u64,
|
own_version: u64,
|
||||||
|
/// Whether the exit node in use was reachable when last looked at, so
|
||||||
|
/// that losing it is said once and getting it back is too.
|
||||||
|
exit_via_was_online: bool,
|
||||||
|
/// What the last announcement said about being an exit node.
|
||||||
|
announced_exit: bool,
|
||||||
|
/// The last hostname each peer announced, kept across restarts so a
|
||||||
|
/// member that is away can still be called by name.
|
||||||
|
known_hostnames: HashMap<EndpointId, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Runtime {
|
impl Runtime {
|
||||||
@@ -346,12 +399,16 @@ impl Runtime {
|
|||||||
opening: HashSet::new(),
|
opening: HashSet::new(),
|
||||||
link_results_tx,
|
link_results_tx,
|
||||||
link_results_rx,
|
link_results_rx,
|
||||||
|
protocol_dial_states: HashMap::new(),
|
||||||
reported_mismatch: HashSet::new(),
|
reported_mismatch: HashSet::new(),
|
||||||
released: false,
|
released: false,
|
||||||
reported_missing: None,
|
reported_missing: None,
|
||||||
state: StateSet::new(),
|
state: StateSet::new(),
|
||||||
pending_state: Vec::new(),
|
pending_state: Vec::new(),
|
||||||
own_version: 0,
|
own_version: 0,
|
||||||
|
exit_via_was_online: false,
|
||||||
|
announced_exit: false,
|
||||||
|
known_hostnames: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +479,7 @@ impl Runtime {
|
|||||||
self.session_events_rx.close();
|
self.session_events_rx.close();
|
||||||
self.hub.close();
|
self.hub.close();
|
||||||
self.links.clear();
|
self.links.clear();
|
||||||
|
self.protocol_dial_states.clear();
|
||||||
let peers: Vec<EndpointId> = self.sessions.keys().copied().collect();
|
let peers: Vec<EndpointId> = self.sessions.keys().copied().collect();
|
||||||
for peer in peers {
|
for peer in peers {
|
||||||
if let Some(session) = self.sessions.remove(&peer) {
|
if let Some(session) = self.sessions.remove(&peer) {
|
||||||
@@ -464,9 +522,33 @@ impl Runtime {
|
|||||||
NetCommand::SetBroadcast { enabled, reply } => {
|
NetCommand::SetBroadcast { enabled, reply } => {
|
||||||
self.params.broadcast = enabled;
|
self.params.broadcast = enabled;
|
||||||
self.update_broadcast();
|
self.update_broadcast();
|
||||||
|
if let Some(interface) = self.params.interface.clone() {
|
||||||
|
interface.sync_broadcast_rules().await;
|
||||||
|
}
|
||||||
self.reannounce();
|
self.reannounce();
|
||||||
let _ = reply.send(());
|
let _ = reply.send(());
|
||||||
}
|
}
|
||||||
|
NetCommand::SetExitOffer { enabled, reply } => {
|
||||||
|
self.params.exit_offer = enabled;
|
||||||
|
self.apply_exit().await;
|
||||||
|
self.reannounce();
|
||||||
|
let _ = reply.send(());
|
||||||
|
}
|
||||||
|
NetCommand::SetExitVia { via, reply } => {
|
||||||
|
let outcome = match via {
|
||||||
|
Some(peer) if !self.exit_node_online(peer) => Err(
|
||||||
|
"that member is not connected or does not offer to be an exit node"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
_ => Ok(()),
|
||||||
|
};
|
||||||
|
if outcome.is_ok() {
|
||||||
|
self.params.exit_via = via;
|
||||||
|
self.exit_via_was_online = via.is_some();
|
||||||
|
self.apply_exit().await;
|
||||||
|
}
|
||||||
|
let _ = reply.send(outcome);
|
||||||
|
}
|
||||||
NetCommand::Recheck => self.discovery_round().await,
|
NetCommand::Recheck => self.discovery_round().await,
|
||||||
NetCommand::Reannounce => self.reannounce(),
|
NetCommand::Reannounce => self.reannounce(),
|
||||||
NetCommand::Release { reply } => {
|
NetCommand::Release { reply } => {
|
||||||
@@ -500,6 +582,26 @@ impl Runtime {
|
|||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
NetCommand::ProtocolFailure {
|
||||||
|
peer,
|
||||||
|
protocol,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
if self.links.remove(&(peer, protocol.clone())).is_some() {
|
||||||
|
self.hub.clear_direct(peer, &protocol);
|
||||||
|
}
|
||||||
|
self.metrics.data_link_failures += 1;
|
||||||
|
self.record_protocol_failure(peer, &protocol);
|
||||||
|
self.emit(Event::DataLinkDown {
|
||||||
|
network: self.network_id,
|
||||||
|
peer,
|
||||||
|
protocol,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
self.ensure_links();
|
||||||
|
self.update_paths();
|
||||||
|
self.announce_reach(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,6 +712,34 @@ impl Runtime {
|
|||||||
self.start_dials();
|
self.start_dials();
|
||||||
// Last, so it carries what this round learned.
|
// Last, so it carries what this round learned.
|
||||||
self.introduce_peers();
|
self.introduce_peers();
|
||||||
|
self.refresh_underlay().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hands the interface the underlay destinations this network depends
|
||||||
|
/// on: the verified direct IPv4 paths of every control connection, and
|
||||||
|
/// the relays in use. Only hosts that cannot exempt the agent from an
|
||||||
|
/// exit node's default route read it.
|
||||||
|
async fn refresh_underlay(&self) {
|
||||||
|
let Some(interface) = self.params.interface.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut addresses = Vec::new();
|
||||||
|
let mut hosts = Vec::new();
|
||||||
|
for session in self.sessions.values() {
|
||||||
|
for path in snapshot_connection(&session.conn).paths {
|
||||||
|
match path.remote {
|
||||||
|
PathAddr::Ip(std::net::SocketAddr::V4(socket)) => addresses.push(*socket.ip()),
|
||||||
|
PathAddr::Relay(url) => hosts.extend(relay_host(&url)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for url in self.params.adapter.snapshot().relay_urls {
|
||||||
|
hosts.extend(relay_host(&url));
|
||||||
|
}
|
||||||
|
interface
|
||||||
|
.set_underlay(self.network_id, addresses, hosts)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_candidate(&mut self, candidate: Candidate) {
|
fn add_candidate(&mut self, candidate: Candidate) {
|
||||||
@@ -790,6 +920,17 @@ impl Runtime {
|
|||||||
.own_record_version(self.network_id, self.local_id)
|
.own_record_version(self.network_id, self.local_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
for (id, hostname) in self
|
||||||
|
.params
|
||||||
|
.storage
|
||||||
|
.peer_hostnames(self.network_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
{
|
||||||
|
if let Ok(id) = EndpointId::from_bytes(&id) {
|
||||||
|
self.known_hostnames.insert(id, hostname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.ensure_own_claim().await;
|
self.ensure_own_claim().await;
|
||||||
self.publish_allocations().await;
|
self.publish_allocations().await;
|
||||||
@@ -1041,6 +1182,75 @@ impl Runtime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn exit_policy(&self) -> crate::overlay::ExitPolicy {
|
||||||
|
crate::overlay::ExitPolicy {
|
||||||
|
offer: self.params.exit_offer,
|
||||||
|
via: self.params.exit_via,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this agent offers to be an exit node and can really be one.
|
||||||
|
fn exit_ready(&self) -> bool {
|
||||||
|
self.params.exit_offer
|
||||||
|
&& self
|
||||||
|
.params
|
||||||
|
.interface
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|interface| interface.exit_offer_ready(self.network_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a member is connected and says it is an exit node.
|
||||||
|
fn exit_node_online(&self, peer: EndpointId) -> bool {
|
||||||
|
self.sessions
|
||||||
|
.get(&peer)
|
||||||
|
.is_some_and(|session| session.exit_node)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts the exit settings into the routing table and the host rules.
|
||||||
|
///
|
||||||
|
/// The routes and the rules stay as they are when the exit node in use
|
||||||
|
/// goes away: dropping them would send the traffic out the old way, in
|
||||||
|
/// the clear, without anyone having asked.
|
||||||
|
async fn apply_exit(&mut self) {
|
||||||
|
self.params
|
||||||
|
.routes
|
||||||
|
.set_exit(self.network_id, self.exit_policy());
|
||||||
|
if let Some(interface) = self.params.interface.clone() {
|
||||||
|
interface.sync_exit_rules().await;
|
||||||
|
}
|
||||||
|
// Said only once it is true: the rules may not have taken, or may
|
||||||
|
// only now be possible.
|
||||||
|
if self.exit_ready() != self.announced_exit {
|
||||||
|
self.reannounce();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Says so, once, when the exit node in use is lost or comes back.
|
||||||
|
fn watch_exit_via(&mut self) {
|
||||||
|
let Some(via) = self.params.exit_via else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let online = self.exit_node_online(via);
|
||||||
|
if online == self.exit_via_was_online {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.exit_via_was_online = online;
|
||||||
|
if online {
|
||||||
|
tracing::info!(
|
||||||
|
network = %self.network_id,
|
||||||
|
exit_node = %via.fmt_short(),
|
||||||
|
"the exit node is back"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
network = %self.network_id,
|
||||||
|
exit_node = %via.fmt_short(),
|
||||||
|
"the exit node is gone or no longer offers to be one; internet traffic stays \
|
||||||
|
routed through it and is dropped until it returns or `tsng exit-node off`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn update_broadcast(&self) {
|
fn update_broadcast(&self) {
|
||||||
self.params
|
self.params
|
||||||
.routes
|
.routes
|
||||||
@@ -1069,6 +1279,7 @@ impl Runtime {
|
|||||||
// a packet for ourselves does not go over a tunnel.
|
// a packet for ourselves does not go over a tunnel.
|
||||||
let local = self.state.address_of(&self.local_id);
|
let local = self.state.address_of(&self.local_id);
|
||||||
let routes = crate::overlay::NetworkRoutes {
|
let routes = crate::overlay::NetworkRoutes {
|
||||||
|
exit: self.exit_policy(),
|
||||||
broadcast: self.broadcast_policy(),
|
broadcast: self.broadcast_policy(),
|
||||||
range: Some(range),
|
range: Some(range),
|
||||||
local,
|
local,
|
||||||
@@ -1110,19 +1321,40 @@ impl Runtime {
|
|||||||
// wrong here before. Said once per address, not every round.
|
// wrong here before. Said once per address, not every round.
|
||||||
// An in-memory interface has no addresses to be missing from: with
|
// An in-memory interface has no addresses to be missing from: with
|
||||||
// `--no-tun` this is the arrangement asked for, not a fault.
|
// `--no-tun` this is the arrangement asked for, not a fault.
|
||||||
let missing = match local {
|
let is_missing = if let Some(address) = local {
|
||||||
Some(address)
|
if interface.on_host() {
|
||||||
if interface.on_host()
|
let mut local_found =
|
||||||
&& !crate::overlay::address_is_local(std::net::IpAddr::V4(address)) =>
|
crate::overlay::address_is_local(std::net::IpAddr::V4(address));
|
||||||
{
|
// On Windows, duplicate address detection (DAD) and netsh/IP helper updates
|
||||||
|
// may take a short moment before a newly assigned address is ready for binding.
|
||||||
|
if !local_found && cfg!(target_os = "windows") {
|
||||||
|
for _ in 0..10 {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||||
|
if crate::overlay::address_is_local(std::net::IpAddr::V4(address)) {
|
||||||
|
local_found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!local_found
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
let missing = if is_missing {
|
||||||
|
if let Some(address) = local {
|
||||||
let already = self.reported_missing == Some(address);
|
let already = self.reported_missing == Some(address);
|
||||||
self.reported_missing = Some(address);
|
self.reported_missing = Some(address);
|
||||||
(!already).then_some(address)
|
(!already).then_some(address)
|
||||||
}
|
} else {
|
||||||
_ => {
|
|
||||||
self.reported_missing = None;
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
self.reported_missing = None;
|
||||||
|
None
|
||||||
};
|
};
|
||||||
if let Some(address) = missing {
|
if let Some(address) = missing {
|
||||||
self.emit(Event::PluginError {
|
self.emit(Event::PluginError {
|
||||||
@@ -1139,6 +1371,13 @@ impl Runtime {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The broadcast route names this address as its source, so it goes in
|
||||||
|
// only once the address is really on the interface.
|
||||||
|
if !is_missing {
|
||||||
|
interface.sync_broadcast_rules().await;
|
||||||
|
self.apply_exit().await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ data plane
|
// ------------------------------------------------------------ data plane
|
||||||
@@ -1152,6 +1391,25 @@ impl Runtime {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record_protocol_failure(&mut self, peer: EndpointId, protocol: &str) {
|
||||||
|
let key = (peer, protocol.to_string());
|
||||||
|
let entry = self
|
||||||
|
.protocol_dial_states
|
||||||
|
.entry(key)
|
||||||
|
.or_insert(ProtocolDialState {
|
||||||
|
failures: 0,
|
||||||
|
cooldown_until: std::time::Instant::now(),
|
||||||
|
});
|
||||||
|
entry.failures = entry.failures.saturating_add(1);
|
||||||
|
let backoff = data_link_backoff(entry.failures);
|
||||||
|
entry.cooldown_until = std::time::Instant::now() + backoff;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_protocol_success(&mut self, peer: EndpointId, protocol: &str) {
|
||||||
|
self.protocol_dial_states
|
||||||
|
.remove(&(peer, protocol.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens whatever data plane links are missing, and forgets dead ones.
|
/// Opens whatever data plane links are missing, and forgets dead ones.
|
||||||
///
|
///
|
||||||
/// Only one side dials, chosen by a rule both sides compute the same way,
|
/// Only one side dials, chosen by a rule both sides compute the same way,
|
||||||
@@ -1177,6 +1435,7 @@ impl Runtime {
|
|||||||
// reachable through somebody is decided below; the protocol's
|
// reachable through somebody is decided below; the protocol's
|
||||||
// link stays either way.
|
// link stays either way.
|
||||||
self.hub.clear_direct(peer, &protocol);
|
self.hub.clear_direct(peer, &protocol);
|
||||||
|
self.record_protocol_failure(peer, &protocol);
|
||||||
self.emit(Event::DataLinkDown {
|
self.emit(Event::DataLinkDown {
|
||||||
network: self.network_id,
|
network: self.network_id,
|
||||||
peer,
|
peer,
|
||||||
@@ -1185,32 +1444,17 @@ impl Runtime {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both the name and the version have to match. A peer offering
|
// Choose the highest priority mutually supported protocol for each peer.
|
||||||
// `wg-quic` at a version this build does not speak is not a peer to
|
// If a direct data link is already open or opening, do not dial another.
|
||||||
// carry traffic with, and a link opened anyway would fail later and
|
// If a protocol recently failed, fall back to other mutually supported
|
||||||
// say less about why.
|
// protocols according to backoff cooldown.
|
||||||
let wanted: Vec<(EndpointId, String)> = self
|
let now = std::time::Instant::now();
|
||||||
.sessions
|
let mut wanted: Vec<(EndpointId, String)> = Vec::new();
|
||||||
.values()
|
for session in self.sessions.values() {
|
||||||
.flat_map(|session| {
|
|
||||||
let peer = session.peer;
|
let peer = session.peer;
|
||||||
session
|
if self.links.keys().any(|(p, _)| *p == peer)
|
||||||
.capabilities
|
|| self.opening.iter().any(|(p, _)| *p == peer)
|
||||||
.iter()
|
{
|
||||||
.filter(|capability| capability.enabled)
|
|
||||||
.map(move |capability| (peer, capability.protocol.clone(), capability.version))
|
|
||||||
})
|
|
||||||
.filter(|(_, protocol, version)| {
|
|
||||||
served
|
|
||||||
.iter()
|
|
||||||
.any(|(name, ours)| name == protocol && ours == version)
|
|
||||||
})
|
|
||||||
.map(|(peer, protocol, _)| (peer, protocol))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for (peer, protocol) in wanted {
|
|
||||||
let key = (peer, protocol.clone());
|
|
||||||
if self.links.contains_key(&key) || self.opening.contains(&key) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if self.no_direct_data(peer) {
|
if self.no_direct_data(peer) {
|
||||||
@@ -1221,6 +1465,39 @@ impl Runtime {
|
|||||||
if self.local_id.as_bytes() >= peer.as_bytes() {
|
if self.local_id.as_bytes() >= peer.as_bytes() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let supported: Vec<String> = served
|
||||||
|
.iter()
|
||||||
|
.filter(|(name, ours)| {
|
||||||
|
session
|
||||||
|
.capabilities
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.enabled && c.protocol == *name && c.version == *ours)
|
||||||
|
})
|
||||||
|
.map(|(name, _)| name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if supported.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chosen = supported.iter().find(|proto| {
|
||||||
|
match self.protocol_dial_states.get(&(peer, (*proto).clone())) {
|
||||||
|
Some(state) => state.cooldown_until <= now,
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(proto) = chosen {
|
||||||
|
wanted.push((peer, proto.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (peer, protocol) in wanted {
|
||||||
|
let key = (peer, protocol.clone());
|
||||||
|
if self.links.contains_key(&key) || self.opening.contains(&key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
self.opening.insert(key);
|
self.opening.insert(key);
|
||||||
|
|
||||||
let results = self.link_results_tx.clone();
|
let results = self.link_results_tx.clone();
|
||||||
@@ -1455,6 +1732,7 @@ impl Runtime {
|
|||||||
self.opening.remove(&key);
|
self.opening.remove(&key);
|
||||||
match outcome.result {
|
match outcome.result {
|
||||||
Ok(link) => {
|
Ok(link) => {
|
||||||
|
self.record_protocol_success(outcome.peer, &outcome.protocol);
|
||||||
self.adopt_link(outcome.peer, outcome.protocol, link);
|
self.adopt_link(outcome.peer, outcome.protocol, link);
|
||||||
// A new link is both a path for us and one we can offer
|
// A new link is both a path for us and one we can offer
|
||||||
// others, so it changes what we route and what we say.
|
// others, so it changes what we route and what we say.
|
||||||
@@ -1464,12 +1742,15 @@ impl Runtime {
|
|||||||
Err(reason) => {
|
Err(reason) => {
|
||||||
// A data plane that cannot be set up is reported, never fatal.
|
// A data plane that cannot be set up is reported, never fatal.
|
||||||
self.metrics.data_link_failures += 1;
|
self.metrics.data_link_failures += 1;
|
||||||
|
self.record_protocol_failure(outcome.peer, &outcome.protocol);
|
||||||
self.emit(Event::DataLinkDown {
|
self.emit(Event::DataLinkDown {
|
||||||
network: self.network_id,
|
network: self.network_id,
|
||||||
peer: outcome.peer,
|
peer: outcome.peer,
|
||||||
protocol: outcome.protocol,
|
protocol: outcome.protocol,
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
|
// Fall back to other mutually supported protocols immediately.
|
||||||
|
self.ensure_links();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1497,6 +1778,7 @@ impl Runtime {
|
|||||||
if self.no_direct_data(inbound.peer) {
|
if self.no_direct_data(inbound.peer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
self.record_protocol_success(inbound.peer, &inbound.protocol);
|
||||||
self.adopt_link(inbound.peer, inbound.protocol, inbound.link);
|
self.adopt_link(inbound.peer, inbound.protocol, inbound.link);
|
||||||
self.update_paths();
|
self.update_paths();
|
||||||
self.announce_reach(false);
|
self.announce_reach(false);
|
||||||
@@ -1544,6 +1826,7 @@ impl Runtime {
|
|||||||
// protocol's link goes with it.
|
// protocol's link goes with it.
|
||||||
self.hub.remove_peer(peer);
|
self.hub.remove_peer(peer);
|
||||||
self.reachable.remove(&peer);
|
self.reachable.remove(&peer);
|
||||||
|
self.protocol_dial_states.retain(|(p, _), _| *p != peer);
|
||||||
let keys: Vec<(EndpointId, String)> = self
|
let keys: Vec<(EndpointId, String)> = self
|
||||||
.links
|
.links
|
||||||
.keys()
|
.keys()
|
||||||
@@ -1658,8 +1941,11 @@ impl Runtime {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
capabilities.truncate(self.params.limits.max_capabilities);
|
capabilities.truncate(self.params.limits.max_capabilities);
|
||||||
|
let exit_node = self.exit_ready();
|
||||||
|
self.announced_exit = exit_node;
|
||||||
Announcement {
|
Announcement {
|
||||||
broadcast: self.params.broadcast,
|
broadcast: self.params.broadcast,
|
||||||
|
exit_node,
|
||||||
hostname: self.params.hostname.clone(),
|
hostname: self.params.hostname.clone(),
|
||||||
capabilities,
|
capabilities,
|
||||||
}
|
}
|
||||||
@@ -1738,6 +2024,7 @@ impl Runtime {
|
|||||||
self.metrics.disconnects += 1;
|
self.metrics.disconnects += 1;
|
||||||
self.drop_links_for(peer);
|
self.drop_links_for(peer);
|
||||||
self.update_broadcast();
|
self.update_broadcast();
|
||||||
|
self.watch_exit_via();
|
||||||
self.update_paths();
|
self.update_paths();
|
||||||
self.announce_reach(false);
|
self.announce_reach(false);
|
||||||
for plugin in &self.params.plugins {
|
for plugin in &self.params.plugins {
|
||||||
@@ -1760,6 +2047,33 @@ impl Runtime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keeps the name a peer announced, on disk too when it changed.
|
||||||
|
///
|
||||||
|
/// Written off the loop: a name that is not stored this time is stored
|
||||||
|
/// the next time the peer announces it.
|
||||||
|
fn remember_hostname(&mut self, peer: EndpointId, hostname: &str) {
|
||||||
|
if hostname.is_empty()
|
||||||
|
|| self
|
||||||
|
.known_hostnames
|
||||||
|
.get(&peer)
|
||||||
|
.is_some_and(|h| h == hostname)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.known_hostnames.insert(peer, hostname.to_string());
|
||||||
|
let storage = self.params.storage.clone();
|
||||||
|
let network_id = self.network_id;
|
||||||
|
let hostname = hostname.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = storage
|
||||||
|
.remember_peer_hostname(network_id, *peer.as_bytes(), hostname)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::debug!(%err, "could not remember a peer's hostname");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn dispatch_message(&mut self, peer: EndpointId, message: ControlMessage) {
|
fn dispatch_message(&mut self, peer: EndpointId, message: ControlMessage) {
|
||||||
match &message {
|
match &message {
|
||||||
ControlMessage::Announce(announcement) => {
|
ControlMessage::Announce(announcement) => {
|
||||||
@@ -1767,9 +2081,12 @@ impl Runtime {
|
|||||||
if let Some(session) = self.sessions.get_mut(&peer) {
|
if let Some(session) = self.sessions.get_mut(&peer) {
|
||||||
session.hostname = Some(announcement.hostname.clone());
|
session.hostname = Some(announcement.hostname.clone());
|
||||||
session.broadcast = announcement.broadcast;
|
session.broadcast = announcement.broadcast;
|
||||||
|
session.exit_node = announcement.exit_node;
|
||||||
session.capabilities = capabilities.clone();
|
session.capabilities = capabilities.clone();
|
||||||
}
|
}
|
||||||
|
self.remember_hostname(peer, &announcement.hostname);
|
||||||
self.update_broadcast();
|
self.update_broadcast();
|
||||||
|
self.watch_exit_via();
|
||||||
self.dispatch_capabilities(peer, &capabilities);
|
self.dispatch_capabilities(peer, &capabilities);
|
||||||
self.ensure_links();
|
self.ensure_links();
|
||||||
self.update_paths();
|
self.update_paths();
|
||||||
@@ -1867,9 +2184,13 @@ impl Runtime {
|
|||||||
let snapshot = snapshot_connection(&session.conn);
|
let snapshot = snapshot_connection(&session.conn);
|
||||||
PeerStatus {
|
PeerStatus {
|
||||||
broadcast: session.broadcast,
|
broadcast: session.broadcast,
|
||||||
|
exit_node: session.exit_node,
|
||||||
endpoint_id: session.peer,
|
endpoint_id: session.peer,
|
||||||
role: session.role,
|
role: session.role,
|
||||||
hostname: session.hostname.clone(),
|
hostname: session
|
||||||
|
.hostname
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.known_hostnames.get(&session.peer).cloned()),
|
||||||
protocols: session
|
protocols: session
|
||||||
.capabilities
|
.capabilities
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1927,6 +2248,7 @@ impl Runtime {
|
|||||||
// both hold it would be untrue on one of them.
|
// both hold it would be untrue on one of them.
|
||||||
overlay_address_v4: self.state.address_of(&endpoint_id),
|
overlay_address_v4: self.state.address_of(&endpoint_id),
|
||||||
hostname: self.state.hostname_of(&endpoint_id).map(str::to_string),
|
hostname: self.state.hostname_of(&endpoint_id).map(str::to_string),
|
||||||
|
last_hostname: self.known_hostnames.get(&endpoint_id).cloned(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1935,6 +2257,18 @@ impl Runtime {
|
|||||||
|
|
||||||
NetworkStatus {
|
NetworkStatus {
|
||||||
broadcast: self.params.broadcast,
|
broadcast: self.params.broadcast,
|
||||||
|
exit_offer: self.params.exit_offer,
|
||||||
|
exit_via: self.params.exit_via,
|
||||||
|
exit_via_online: self
|
||||||
|
.params
|
||||||
|
.exit_via
|
||||||
|
.is_some_and(|via| self.exit_node_online(via)),
|
||||||
|
exit_rules: self
|
||||||
|
.params
|
||||||
|
.interface
|
||||||
|
.as_ref()
|
||||||
|
.map(|interface| interface.exit_rules(self.network_id))
|
||||||
|
.unwrap_or_default(),
|
||||||
descriptor: self.params.keys.descriptor(),
|
descriptor: self.params.keys.descriptor(),
|
||||||
name: self.params.keys.name().clone(),
|
name: self.params.keys.name().clone(),
|
||||||
network_id: self.network_id,
|
network_id: self.network_id,
|
||||||
@@ -2059,3 +2393,131 @@ fn merge_addr(existing: &mut iroh::EndpointAddr, incoming: &iroh::EndpointAddr)
|
|||||||
existing.addrs.insert(addr.clone());
|
existing.addrs.insert(addr.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_data_link_backoff_progression() {
|
||||||
|
assert_eq!(data_link_backoff(0), Duration::ZERO);
|
||||||
|
assert_eq!(data_link_backoff(1), Duration::from_secs(3));
|
||||||
|
assert_eq!(data_link_backoff(2), Duration::from_secs(6));
|
||||||
|
assert_eq!(data_link_backoff(3), Duration::from_secs(12));
|
||||||
|
assert_eq!(data_link_backoff(4), Duration::from_secs(24));
|
||||||
|
assert_eq!(data_link_backoff(5), Duration::from_secs(48));
|
||||||
|
assert_eq!(data_link_backoff(6), Duration::from_secs(60));
|
||||||
|
assert_eq!(data_link_backoff(10), Duration::from_secs(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_protocol_dial_state_cooldown_and_fallback_selection() {
|
||||||
|
let peer = crate::identity::DeviceIdentity::generate().endpoint_id();
|
||||||
|
let supported = ["wg", "tcp-tls", "wg-quic"];
|
||||||
|
let mut states: HashMap<(EndpointId, String), ProtocolDialState> = HashMap::new();
|
||||||
|
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
|
||||||
|
// 1. Without cooldown, the highest priority protocol "wg" is chosen
|
||||||
|
let chosen =
|
||||||
|
supported
|
||||||
|
.iter()
|
||||||
|
.find(|proto| match states.get(&(peer, (*proto).to_string())) {
|
||||||
|
Some(state) => state.cooldown_until <= now,
|
||||||
|
None => true,
|
||||||
|
});
|
||||||
|
assert_eq!(chosen.copied(), Some("wg"));
|
||||||
|
|
||||||
|
// 2. Record failure for "wg" putting it on cooldown
|
||||||
|
states.insert(
|
||||||
|
(peer, "wg".to_string()),
|
||||||
|
ProtocolDialState {
|
||||||
|
failures: 1,
|
||||||
|
cooldown_until: now + Duration::from_secs(3),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// With "wg" on cooldown, selection must fall back to next priority "tcp-tls"
|
||||||
|
let chosen_after_failure =
|
||||||
|
supported
|
||||||
|
.iter()
|
||||||
|
.find(|proto| match states.get(&(peer, (*proto).to_string())) {
|
||||||
|
Some(state) => state.cooldown_until <= now,
|
||||||
|
None => true,
|
||||||
|
});
|
||||||
|
assert_eq!(chosen_after_failure.copied(), Some("tcp-tls"));
|
||||||
|
|
||||||
|
// 3. If "tcp-tls" also fails, selection falls back to "wg-quic"
|
||||||
|
states.insert(
|
||||||
|
(peer, "tcp-tls".to_string()),
|
||||||
|
ProtocolDialState {
|
||||||
|
failures: 1,
|
||||||
|
cooldown_until: now + Duration::from_secs(3),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let chosen_quic =
|
||||||
|
supported
|
||||||
|
.iter()
|
||||||
|
.find(|proto| match states.get(&(peer, (*proto).to_string())) {
|
||||||
|
Some(state) => state.cooldown_until <= now,
|
||||||
|
None => true,
|
||||||
|
});
|
||||||
|
assert_eq!(chosen_quic.copied(), Some("wg-quic"));
|
||||||
|
|
||||||
|
// 4. Once success is recorded (entry removed), "wg" is preferred again
|
||||||
|
states.remove(&(peer, "wg".to_string()));
|
||||||
|
let chosen_restored =
|
||||||
|
supported
|
||||||
|
.iter()
|
||||||
|
.find(|proto| match states.get(&(peer, (*proto).to_string())) {
|
||||||
|
Some(state) => state.cooldown_until <= now,
|
||||||
|
None => true,
|
||||||
|
});
|
||||||
|
assert_eq!(chosen_restored.copied(), Some("wg"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host name of a relay URL such as `https://relay.example:443/`.
|
||||||
|
fn relay_host(url: &str) -> Option<String> {
|
||||||
|
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
|
||||||
|
let authority = rest.split(['/', '?', '#']).next()?;
|
||||||
|
let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
||||||
|
let host = if host.starts_with('[') {
|
||||||
|
host.split_once(']')?.0.trim_start_matches('[')
|
||||||
|
} else {
|
||||||
|
host.split(':').next()?
|
||||||
|
};
|
||||||
|
(!host.is_empty()
|
||||||
|
&& host.len() <= 253
|
||||||
|
&& host
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == ':'))
|
||||||
|
.then(|| host.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod relay_host_tests {
|
||||||
|
use super::relay_host;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_host_is_taken_out_of_a_relay_url() {
|
||||||
|
assert_eq!(
|
||||||
|
relay_host("https://relay.example.com./").as_deref(),
|
||||||
|
Some("relay.example.com.")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
relay_host("https://Euw1-1.relay.iroh.network:443/x").as_deref(),
|
||||||
|
Some("euw1-1.relay.iroh.network")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
relay_host("http://192.0.2.7:3340").as_deref(),
|
||||||
|
Some("192.0.2.7")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
relay_host("https://[2001:db8::1]:443/").as_deref(),
|
||||||
|
Some("2001:db8::1")
|
||||||
|
);
|
||||||
|
assert_eq!(relay_host("https:///").as_deref(), None);
|
||||||
|
assert_eq!(relay_host("https://bad host/").as_deref(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,17 +74,20 @@ pub(crate) struct Session {
|
|||||||
pub(crate) hostname: Option<String>,
|
pub(crate) hostname: Option<String>,
|
||||||
pub(crate) capabilities: Vec<PluginCapability>,
|
pub(crate) capabilities: Vec<PluginCapability>,
|
||||||
pub(crate) broadcast: bool,
|
pub(crate) broadcast: bool,
|
||||||
|
/// The peer offers itself as an exit node in this network.
|
||||||
|
pub(crate) exit_node: bool,
|
||||||
pub(crate) messages_sent: u64,
|
pub(crate) messages_sent: u64,
|
||||||
pub(crate) messages_received: u64,
|
pub(crate) messages_received: u64,
|
||||||
pub(crate) bytes_sent: u64,
|
pub(crate) bytes_sent: u64,
|
||||||
pub(crate) bytes_received: u64,
|
pub(crate) bytes_received: u64,
|
||||||
reader: JoinHandle<()>,
|
reader: JoinHandle<()>,
|
||||||
writer: JoinHandle<()>,
|
writer: JoinHandle<()>,
|
||||||
|
path_monitor: JoinHandle<()>,
|
||||||
shutdown: Shutdown,
|
shutdown: Shutdown,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
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.
|
/// period.
|
||||||
///
|
///
|
||||||
/// A peer that stops reading must not be able to hold up shutdown, so the
|
/// A peer that stops reading must not be able to hold up shutdown, so the
|
||||||
@@ -94,6 +97,7 @@ impl Session {
|
|||||||
conn,
|
conn,
|
||||||
reader,
|
reader,
|
||||||
writer,
|
writer,
|
||||||
|
path_monitor,
|
||||||
shutdown,
|
shutdown,
|
||||||
..
|
..
|
||||||
} = self;
|
} = self;
|
||||||
@@ -103,23 +107,27 @@ impl Session {
|
|||||||
|
|
||||||
let reader_abort = reader.abort_handle();
|
let reader_abort = reader.abort_handle();
|
||||||
let writer_abort = writer.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 joined = tokio::time::timeout(STOP_GRACE, async move {
|
||||||
let _ = reader.await;
|
let _ = reader.await;
|
||||||
let _ = writer.await;
|
let _ = writer.await;
|
||||||
|
let _ = path_monitor.await;
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
if joined.is_err() {
|
if joined.is_err() {
|
||||||
tracing::debug!("session tasks did not wind down in time; aborting them");
|
tracing::debug!("session tasks did not wind down in time; aborting them");
|
||||||
reader_abort.abort();
|
reader_abort.abort();
|
||||||
writer_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) {
|
pub(crate) fn abort(&self) {
|
||||||
self.shutdown.trigger();
|
self.shutdown.trigger();
|
||||||
self.reader.abort();
|
self.reader.abort();
|
||||||
self.writer.abort();
|
self.writer.abort();
|
||||||
|
self.path_monitor.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +163,52 @@ pub(crate) fn spawn(
|
|||||||
parent_shutdown.clone(),
|
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(
|
let reader = tokio::spawn(reader_task(
|
||||||
id,
|
id,
|
||||||
network_id,
|
network_id,
|
||||||
@@ -176,12 +230,14 @@ pub(crate) fn spawn(
|
|||||||
hostname: None,
|
hostname: None,
|
||||||
capabilities: Vec::new(),
|
capabilities: Vec::new(),
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
|
exit_node: false,
|
||||||
messages_sent: 0,
|
messages_sent: 0,
|
||||||
messages_received: 0,
|
messages_received: 0,
|
||||||
bytes_sent: 0,
|
bytes_sent: 0,
|
||||||
bytes_received: 0,
|
bytes_received: 0,
|
||||||
reader,
|
reader,
|
||||||
writer,
|
writer,
|
||||||
|
path_monitor,
|
||||||
shutdown,
|
shutdown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File renamed without changes.
@@ -53,6 +53,10 @@ pub struct OverlayStatus {
|
|||||||
pub addresses: Vec<crate::overlay::Cidr>,
|
pub addresses: Vec<crate::overlay::Cidr>,
|
||||||
/// What has happened on it.
|
/// What has happened on it.
|
||||||
pub counters: crate::overlay::Counters,
|
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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A member the signed state knows about, connected or not.
|
/// A member the signed state knows about, connected or not.
|
||||||
@@ -76,6 +80,11 @@ pub struct MemberStatus {
|
|||||||
/// Signed, so it is still known while the member is away — which is what
|
/// Signed, so it is still known while the member is away — which is what
|
||||||
/// lets an absent member be named rather than shown as a bare id.
|
/// lets an absent member be named rather than shown as a bare id.
|
||||||
pub hostname: Option<String>,
|
pub hostname: Option<String>,
|
||||||
|
/// The last name this agent saw it announce, whether or not it is here.
|
||||||
|
///
|
||||||
|
/// Not signed, so it is a convenience for naming a member that is away
|
||||||
|
/// and never a claim: the zone is served from `hostname`.
|
||||||
|
pub last_hostname: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status of one authenticated session.
|
/// Status of one authenticated session.
|
||||||
@@ -83,6 +92,8 @@ pub struct MemberStatus {
|
|||||||
pub struct PeerStatus {
|
pub struct PeerStatus {
|
||||||
/// This authenticated peer accepts network broadcasts.
|
/// This authenticated peer accepts network broadcasts.
|
||||||
pub broadcast: bool,
|
pub broadcast: bool,
|
||||||
|
/// This authenticated peer offers itself as an exit node.
|
||||||
|
pub exit_node: bool,
|
||||||
/// Authenticated endpoint id.
|
/// Authenticated endpoint id.
|
||||||
pub endpoint_id: EndpointId,
|
pub endpoint_id: EndpointId,
|
||||||
/// Which side this agent played in the handshake.
|
/// Which side this agent played in the handshake.
|
||||||
@@ -155,6 +166,14 @@ pub struct NetworkMetrics {
|
|||||||
pub struct NetworkStatus {
|
pub struct NetworkStatus {
|
||||||
/// Local broadcast participation in this network.
|
/// Local broadcast participation in this network.
|
||||||
pub broadcast: bool,
|
pub broadcast: bool,
|
||||||
|
/// This agent offers itself as an exit node in this network.
|
||||||
|
pub exit_offer: bool,
|
||||||
|
/// The member this device sends all its internet traffic through.
|
||||||
|
pub exit_via: Option<EndpointId>,
|
||||||
|
/// Whether that member is connected and still offering to be one.
|
||||||
|
pub exit_via_online: bool,
|
||||||
|
/// What the host rules for the exit settings are doing.
|
||||||
|
pub exit_rules: crate::overlay::ExitRulesReport,
|
||||||
/// Immutable deterministic description of the network space.
|
/// Immutable deterministic description of the network space.
|
||||||
pub descriptor: NetworkDescriptor,
|
pub descriptor: NetworkDescriptor,
|
||||||
/// Network name, for convenience.
|
/// Network name, for convenience.
|
||||||
@@ -12,9 +12,13 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crate::dataplane::SharedPlugin;
|
use crate::dataplane::SharedPlugin;
|
||||||
use crate::discovery::{MainlineDiscovery, NetworkDiscovery};
|
use crate::discovery::{MainlineDiscovery, NetworkDiscovery};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::Result;
|
||||||
|
|
||||||
/// Qualifier/organisation/application triple used for platform directories.
|
/// 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";
|
const APP_NAME: &str = "tsunagi";
|
||||||
|
|
||||||
/// Rendezvous slots per network; this bounds a discovery sample, not membership.
|
/// Rendezvous slots per network; this bounds a discovery sample, not membership.
|
||||||
@@ -74,18 +78,35 @@ 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> {
|
pub fn user_default() -> Result<Self> {
|
||||||
|
#[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(|| {
|
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
|
||||||
Error::Storage("no valid home directory for platform config paths".into())
|
crate::error::Error::Storage(
|
||||||
|
"no valid home directory for platform config paths".into(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
state_dir: dirs.data_dir().to_path_buf(),
|
state_dir: dirs.data_dir().to_path_buf(),
|
||||||
cache_dir: dirs.cache_dir().to_path_buf(),
|
cache_dir: dirs.cache_dir().to_path_buf(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Path of the mandatory state database.
|
/// Path of the mandatory state database.
|
||||||
pub fn state_db(&self) -> PathBuf {
|
pub fn state_db(&self) -> PathBuf {
|
||||||
@@ -304,6 +325,9 @@ pub struct AgentConfig {
|
|||||||
pub reconnect: ReconnectPolicy,
|
pub reconnect: ReconnectPolicy,
|
||||||
/// IP plugins whose capabilities are announced and dispatched.
|
/// IP plugins whose capabilities are announced and dispatched.
|
||||||
pub plugins: Vec<SharedPlugin>,
|
pub plugins: Vec<SharedPlugin>,
|
||||||
|
/// 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.
|
/// Where the overlay interface comes from, when the agent should have one.
|
||||||
///
|
///
|
||||||
/// One agent, one interface: it belongs here rather than to a protocol,
|
/// One agent, one interface: it belongs here rather than to a protocol,
|
||||||
@@ -345,6 +369,7 @@ impl AgentConfig {
|
|||||||
limits: Limits::default(),
|
limits: Limits::default(),
|
||||||
reconnect: ReconnectPolicy::default(),
|
reconnect: ReconnectPolicy::default(),
|
||||||
plugins: Vec::new(),
|
plugins: Vec::new(),
|
||||||
|
custom_transports: std::collections::HashMap::new(),
|
||||||
tun_factory: None,
|
tun_factory: None,
|
||||||
interface_name: "tsun0".to_string(),
|
interface_name: "tsun0".to_string(),
|
||||||
interface_mtu: 1280,
|
interface_mtu: 1280,
|
||||||
@@ -430,6 +455,16 @@ impl AgentConfig {
|
|||||||
self
|
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
|
||||||
|
}
|
||||||
|
|
||||||
/// Gives the agent an overlay interface, from this factory.
|
/// Gives the agent an overlay interface, from this factory.
|
||||||
pub fn with_interface(
|
pub fn with_interface(
|
||||||
mut self,
|
mut self,
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
pub mod relay;
|
pub mod relay;
|
||||||
pub mod routing;
|
pub mod routing;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
pub mod tunnel;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -31,7 +32,10 @@ use tokio::sync::mpsc;
|
|||||||
use crate::BoxFuture;
|
use crate::BoxFuture;
|
||||||
use crate::identity::NetworkId;
|
use crate::identity::NetworkId;
|
||||||
|
|
||||||
pub use transport::{PacketLink, PacketTransport, SharedLink, TransportError};
|
pub use transport::{
|
||||||
|
MultiPacketTransport, PacketLink, PacketTransport, SharedLink, TransportError,
|
||||||
|
};
|
||||||
|
pub use tunnel::{GenericTunnelPlugin, TunnelCodec};
|
||||||
|
|
||||||
/// Maximum length of a plugin protocol identifier.
|
/// Maximum length of a plugin protocol identifier.
|
||||||
pub const MAX_PROTOCOL_ID_LEN: usize = 32;
|
pub const MAX_PROTOCOL_ID_LEN: usize = 32;
|
||||||
@@ -115,7 +119,7 @@ pub enum PluginError {
|
|||||||
|
|
||||||
/// A request a plugin makes of the agent that owns it.
|
/// A request a plugin makes of the agent that owns it.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) enum PluginRequest {
|
pub enum PluginRequest {
|
||||||
/// Re-send this agent's announcement to every peer of a network.
|
/// Re-send this agent's announcement to every peer of a network.
|
||||||
Reannounce(NetworkId),
|
Reannounce(NetworkId),
|
||||||
/// Surface a plugin error on the agent's event stream.
|
/// Surface a plugin error on the agent's event stream.
|
||||||
@@ -127,6 +131,17 @@ pub(crate) enum PluginRequest {
|
|||||||
/// Human readable reason, free of secrets.
|
/// Human readable reason, free of secrets.
|
||||||
reason: String,
|
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.
|
/// The agent-side handle a plugin is given when it is attached.
|
||||||
@@ -179,6 +194,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.
|
/// This agent's own endpoint id, when the context is attached.
|
||||||
///
|
///
|
||||||
/// A plugin needs it to find itself in the agreed allocation.
|
/// A plugin needs it to find itself in the agreed allocation.
|
||||||
@@ -220,6 +258,22 @@ impl PluginContext {
|
|||||||
reason: reason.into(),
|
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 {
|
impl std::fmt::Debug for PluginContext {
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+50
@@ -32,6 +32,8 @@
|
|||||||
mod fragments;
|
mod fragments;
|
||||||
pub mod iroh_link;
|
pub mod iroh_link;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use iroh::EndpointId;
|
use iroh::EndpointId;
|
||||||
|
|
||||||
@@ -155,3 +157,51 @@ pub trait PacketTransport: Send + Sync + std::fmt::Debug + 'static {
|
|||||||
protocol: &'a str,
|
protocol: &'a str,
|
||||||
) -> BoxFuture<'a, Result<SharedLink, TransportError>>;
|
) -> 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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
//! Generic harness for IP tunnel plugins.
|
||||||
|
//!
|
||||||
|
//! Implementing [`super::IpPlugin`] directly requires managing per-peer background
|
||||||
|
//! tasks, synchronization, packet queues, and graceful shutdown.
|
||||||
|
//!
|
||||||
|
//! [`TunnelCodec`] provides a simplified interface where a plugin author only
|
||||||
|
//! defines cryptographic transformation and handshake payloads.
|
||||||
|
//! [`GenericTunnelPlugin`] adapts any [`TunnelCodec`] into a full [`super::IpPlugin`].
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use iroh::EndpointId;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
use super::{IpPlugin, PluginCapability, PluginContext, PluginError, ProtocolOption, SharedLink};
|
||||||
|
use crate::BoxFuture;
|
||||||
|
use crate::identity::NetworkId;
|
||||||
|
|
||||||
|
/// The simplified contract for an IP tunnel protocol implementation.
|
||||||
|
///
|
||||||
|
/// Implementors only handle key announcements, link lifecycle events,
|
||||||
|
/// and packet encrypt/decrypt. Concurrency and task management are handled
|
||||||
|
/// by [`GenericTunnelPlugin`].
|
||||||
|
pub trait TunnelCodec: Send + Sync + 'static {
|
||||||
|
/// Protocol identifier, e.g. `tcp-tls`.
|
||||||
|
fn protocol_id(&self) -> &str;
|
||||||
|
|
||||||
|
/// Protocol wire version.
|
||||||
|
fn protocol_version(&self) -> u16;
|
||||||
|
|
||||||
|
/// Options accepted by the protocol.
|
||||||
|
fn options(&self) -> &'static [ProtocolOption] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates local capability announcement data for a network.
|
||||||
|
fn local_capability_data(&self, network: NetworkId) -> Result<Vec<u8>, PluginError>;
|
||||||
|
|
||||||
|
/// Handles a peer's announced capability payload.
|
||||||
|
fn on_peer_capability_data(
|
||||||
|
&self,
|
||||||
|
network: NetworkId,
|
||||||
|
peer: EndpointId,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<(), PluginError>;
|
||||||
|
|
||||||
|
/// Called when an authenticated link to a peer is established.
|
||||||
|
fn on_peer_link_up(&self, network: NetworkId, peer: EndpointId, link: &SharedLink) {
|
||||||
|
let _ = (network, peer, link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when a peer goes away.
|
||||||
|
fn on_peer_down(&self, network: NetworkId, peer: EndpointId) {
|
||||||
|
let _ = (network, peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypts an outbound IP packet into a payload to send over the link.
|
||||||
|
fn encrypt(
|
||||||
|
&self,
|
||||||
|
network: NetworkId,
|
||||||
|
peer: EndpointId,
|
||||||
|
packet: &[u8],
|
||||||
|
) -> Result<Bytes, PluginError>;
|
||||||
|
|
||||||
|
/// Decrypts an inbound payload from the link into an IP packet.
|
||||||
|
fn decrypt(
|
||||||
|
&self,
|
||||||
|
network: NetworkId,
|
||||||
|
peer: EndpointId,
|
||||||
|
payload: &[u8],
|
||||||
|
) -> Result<Bytes, PluginError>;
|
||||||
|
|
||||||
|
/// Called during shutdown.
|
||||||
|
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
|
||||||
|
Box::pin(async {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TunnelPeerState {
|
||||||
|
link: SharedLink,
|
||||||
|
tx_packets: AtomicU64,
|
||||||
|
rx_packets: AtomicU64,
|
||||||
|
tx_bytes: AtomicU64,
|
||||||
|
rx_bytes: AtomicU64,
|
||||||
|
dropped: AtomicU64,
|
||||||
|
established_at: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistics and status for one active peer in [`GenericTunnelPlugin`].
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TunnelPeerReport {
|
||||||
|
/// Peer endpoint identity.
|
||||||
|
pub peer: EndpointId,
|
||||||
|
/// Protocol identifier.
|
||||||
|
pub protocol: String,
|
||||||
|
/// Human-readable path description.
|
||||||
|
pub path: String,
|
||||||
|
/// Packets successfully encrypted and sent.
|
||||||
|
pub tx_packets: u64,
|
||||||
|
/// Packets successfully received and decrypted.
|
||||||
|
pub rx_packets: u64,
|
||||||
|
/// Plaintext bytes successfully encrypted and sent.
|
||||||
|
pub tx_bytes: u64,
|
||||||
|
/// Plaintext bytes successfully received and decrypted.
|
||||||
|
pub rx_bytes: u64,
|
||||||
|
/// Packets dropped due to encryption/decryption/send failures.
|
||||||
|
pub dropped: u64,
|
||||||
|
/// Uptime in seconds since the data link was established.
|
||||||
|
pub uptime_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic wrapper that turns a [`TunnelCodec`] into a full [`IpPlugin`].
|
||||||
|
pub struct GenericTunnelPlugin<C: TunnelCodec> {
|
||||||
|
codec: Arc<C>,
|
||||||
|
context: Mutex<Option<PluginContext>>,
|
||||||
|
links: RwLock<HashMap<(NetworkId, EndpointId), Arc<TunnelPeerState>>>,
|
||||||
|
tasks: Mutex<HashMap<(NetworkId, EndpointId), JoinHandle<()>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: TunnelCodec> GenericTunnelPlugin<C> {
|
||||||
|
/// Wraps `codec` into an [`IpPlugin`].
|
||||||
|
pub fn new(codec: C) -> Self {
|
||||||
|
Self {
|
||||||
|
codec: Arc::new(codec),
|
||||||
|
context: Mutex::new(None),
|
||||||
|
links: RwLock::new(HashMap::new()),
|
||||||
|
tasks: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the underlying codec.
|
||||||
|
pub fn codec(&self) -> &C {
|
||||||
|
&self.codec
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the active peers with established data links for a network.
|
||||||
|
pub fn active_peers(&self, network: NetworkId) -> Vec<TunnelPeerReport> {
|
||||||
|
let guard = match self.links.read() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
guard
|
||||||
|
.iter()
|
||||||
|
.filter(|((net, _), _)| *net == network)
|
||||||
|
.map(|((_, peer), state)| TunnelPeerReport {
|
||||||
|
peer: *peer,
|
||||||
|
protocol: self.codec.protocol_id().to_string(),
|
||||||
|
path: state.link.path_description(),
|
||||||
|
tx_packets: state.tx_packets.load(Ordering::Relaxed),
|
||||||
|
rx_packets: state.rx_packets.load(Ordering::Relaxed),
|
||||||
|
tx_bytes: state.tx_bytes.load(Ordering::Relaxed),
|
||||||
|
rx_bytes: state.rx_bytes.load(Ordering::Relaxed),
|
||||||
|
dropped: state.dropped.load(Ordering::Relaxed),
|
||||||
|
uptime_secs: state.established_at.elapsed().as_secs(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: TunnelCodec> std::fmt::Debug for GenericTunnelPlugin<C> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("GenericTunnelPlugin")
|
||||||
|
.field("protocol_id", &self.codec.protocol_id())
|
||||||
|
.field("protocol_version", &self.codec.protocol_version())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: TunnelCodec> IpPlugin for GenericTunnelPlugin<C> {
|
||||||
|
fn protocol_id(&self) -> &str {
|
||||||
|
self.codec.protocol_id()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_version(&self) -> u16 {
|
||||||
|
self.codec.protocol_version()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options(&self) -> &'static [ProtocolOption] {
|
||||||
|
self.codec.options()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach(&self, context: PluginContext) {
|
||||||
|
if let Ok(mut guard) = self.context.lock() {
|
||||||
|
*guard = Some(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_capability(
|
||||||
|
&self,
|
||||||
|
network: NetworkId,
|
||||||
|
) -> Result<Option<PluginCapability>, PluginError> {
|
||||||
|
let data = self.codec.local_capability_data(network)?;
|
||||||
|
Ok(Some(PluginCapability {
|
||||||
|
protocol: self.codec.protocol_id().to_string(),
|
||||||
|
version: self.codec.protocol_version(),
|
||||||
|
enabled: true,
|
||||||
|
data,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_peer_capability(
|
||||||
|
&self,
|
||||||
|
network: NetworkId,
|
||||||
|
peer: EndpointId,
|
||||||
|
capability: &PluginCapability,
|
||||||
|
) -> Result<(), PluginError> {
|
||||||
|
if capability.protocol != self.codec.protocol_id() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.codec
|
||||||
|
.on_peer_capability_data(network, peer, &capability.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn carry(&self, network: NetworkId, peer: EndpointId, packet: bytes::Bytes) -> bool {
|
||||||
|
let state = {
|
||||||
|
let guard = match self.links.read() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
guard.get(&(network, peer)).cloned()
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(state) = state else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.codec.encrypt(network, peer, &packet) {
|
||||||
|
Ok(ciphertext) => {
|
||||||
|
if state.link.send(ciphertext).is_ok() {
|
||||||
|
state.tx_packets.fetch_add(1, Ordering::Relaxed);
|
||||||
|
state
|
||||||
|
.tx_bytes
|
||||||
|
.fetch_add(packet.len() as u64, Ordering::Relaxed);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
state.dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
state.dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
|
tracing::debug!(%peer, %err, "tunnel encrypt failed");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_peer_link(&self, network: NetworkId, peer: EndpointId, link: SharedLink) {
|
||||||
|
let state = Arc::new(TunnelPeerState {
|
||||||
|
link: Arc::clone(&link),
|
||||||
|
tx_packets: AtomicU64::new(0),
|
||||||
|
rx_packets: AtomicU64::new(0),
|
||||||
|
tx_bytes: AtomicU64::new(0),
|
||||||
|
rx_bytes: AtomicU64::new(0),
|
||||||
|
dropped: AtomicU64::new(0),
|
||||||
|
established_at: std::time::Instant::now(),
|
||||||
|
});
|
||||||
|
{
|
||||||
|
let mut guard = match self.links.write() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
guard.insert((network, peer), Arc::clone(&state));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.codec.on_peer_link_up(network, peer, &link);
|
||||||
|
|
||||||
|
let codec = Arc::clone(&self.codec);
|
||||||
|
let sink = match self.context.lock() {
|
||||||
|
Ok(g) => g.as_ref().map(|ctx| ctx.packet_sink()),
|
||||||
|
Err(p) => p.into_inner().as_ref().map(|ctx| ctx.packet_sink()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let rx_link = Arc::clone(&link);
|
||||||
|
let rx_state = Arc::clone(&state);
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
while let Some(payload) = rx_link.recv().await {
|
||||||
|
match codec.decrypt(network, peer, &payload) {
|
||||||
|
Ok(packet) => {
|
||||||
|
rx_state.rx_packets.fetch_add(1, Ordering::Relaxed);
|
||||||
|
rx_state
|
||||||
|
.rx_bytes
|
||||||
|
.fetch_add(packet.len() as u64, Ordering::Relaxed);
|
||||||
|
if let Some(ref sink) = sink {
|
||||||
|
sink.deliver(network, peer, packet).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
rx_state.dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
|
tracing::debug!(%peer, %err, "tunnel decrypt failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut guard = match self.tasks.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
if let Some(old) = guard.insert((network, peer), handle) {
|
||||||
|
old.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_peer_gone(&self, network: NetworkId, peer: EndpointId) {
|
||||||
|
{
|
||||||
|
let mut guard = match self.tasks.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
if let Some(task) = guard.remove(&(network, peer)) {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut guard = match self.links.write() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
guard.remove(&(network, peer));
|
||||||
|
}
|
||||||
|
self.codec.on_peer_down(network, peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_network_deactivated(&self, network: NetworkId) {
|
||||||
|
let keys_to_remove: Vec<(NetworkId, EndpointId)> = {
|
||||||
|
let guard = match self.tasks.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
guard
|
||||||
|
.keys()
|
||||||
|
.filter(|(net, _)| *net == network)
|
||||||
|
.copied()
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = match self.tasks.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
for key in &keys_to_remove {
|
||||||
|
if let Some(task) = guard.remove(key) {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = match self.links.write() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
guard.retain(|(net, _), _| *net != network);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
{
|
||||||
|
let mut guard = match self.tasks.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
for (_, task) in guard.drain() {
|
||||||
|
tasks.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for task in tasks {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
self.codec.shutdown().await;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
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"))]
|
#[cfg(all(feature = "dns-publish", target_os = "windows"))]
|
||||||
pub use windows::NrptPublisher;
|
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;
|
mod unsupported;
|
||||||
pub use unsupported::UnsupportedPublisher;
|
pub use unsupported::UnsupportedPublisher;
|
||||||
|
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -120,6 +120,13 @@ pub enum Error {
|
|||||||
what: String,
|
what: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// The request is understood and cannot be done as things stand.
|
||||||
|
#[error("{reason}")]
|
||||||
|
Rejected {
|
||||||
|
/// Why, phrased so the message stands on its own.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Discovery backend failure. Never fatal for the agent.
|
/// Discovery backend failure. Never fatal for the agent.
|
||||||
#[error("discovery error: {0}")]
|
#[error("discovery error: {0}")]
|
||||||
Discovery(String),
|
Discovery(String),
|
||||||
@@ -49,11 +49,16 @@ impl DeviceIdentity {
|
|||||||
self.secret.public()
|
self.secret.public()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The raw secret key bytes, for persistence only.
|
/// The raw secret key bytes, for persistence and transport certificate generation.
|
||||||
pub(crate) fn secret_bytes(&self) -> [u8; 32] {
|
pub fn secret_bytes(&self) -> [u8; 32] {
|
||||||
self.secret.to_bytes()
|
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.
|
/// A clone of the iroh secret key, for endpoint construction only.
|
||||||
pub(crate) fn secret_key(&self) -> SecretKey {
|
pub(crate) fn secret_key(&self) -> SecretKey {
|
||||||
self.secret.clone()
|
self.secret.clone()
|
||||||
File renamed without changes.
@@ -44,6 +44,21 @@ use windows as transport;
|
|||||||
#[cfg(any(unix, windows))]
|
#[cfg(any(unix, windows))]
|
||||||
pub use transport::ControlSocket;
|
pub use transport::ControlSocket;
|
||||||
|
|
||||||
|
/// Who may open the local control socket.
|
||||||
|
///
|
||||||
|
/// The default is owner-only. A system service shares its socket with a GUI or
|
||||||
|
/// CLI running as another user by granting a group; the agent sets that on the
|
||||||
|
/// socket when it binds it, so the access is correct from creation rather than
|
||||||
|
/// patched afterwards.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ControlSocketAccess {
|
||||||
|
/// Owner only — mode `0600`. The per-user agent's default.
|
||||||
|
Private,
|
||||||
|
/// The owner and members of this group — the socket's group is set to this
|
||||||
|
/// gid and the mode to `0660`. The caller resolves a group name to its gid.
|
||||||
|
Group(u32),
|
||||||
|
}
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -58,6 +73,31 @@ use crate::error::{Error, Result};
|
|||||||
/// Largest accepted local control message.
|
/// Largest accepted local control message.
|
||||||
pub const MAX_MESSAGE_LEN: usize = 1024 * 1024;
|
pub const MAX_MESSAGE_LEN: usize = 1024 * 1024;
|
||||||
|
|
||||||
|
/// A string the control protocol must carry but that must never appear in a
|
||||||
|
/// log: it serialises transparently (the text crosses the socket intact) but
|
||||||
|
/// its `Debug` is redacted. Used for a network secret handed back to a local
|
||||||
|
/// client that asked to copy it.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RedactedText(String);
|
||||||
|
|
||||||
|
impl RedactedText {
|
||||||
|
/// Wraps a string.
|
||||||
|
pub fn new(text: impl Into<String>) -> Self {
|
||||||
|
Self(text.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwraps to the plain string.
|
||||||
|
pub fn into_string(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for RedactedText {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("<redacted>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Where the control socket for a state directory lives.
|
/// Where the control socket for a state directory lives.
|
||||||
///
|
///
|
||||||
/// A Unix socket path is limited to around 100 bytes, which a state directory
|
/// A Unix socket path is limited to around 100 bytes, which a state directory
|
||||||
@@ -82,6 +122,47 @@ pub fn control_socket_path(state_dir: &Path) -> PathBuf {
|
|||||||
state_dir.join("agent.sock")
|
state_dir.join("agent.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the default system control socket lives on this platform.
|
||||||
|
///
|
||||||
|
/// On Windows, the system-wide `%ProgramData%\tsunagi\agent.sock` is used so that
|
||||||
|
/// any local user, tray application, or CLI tool can manage the running agent
|
||||||
|
/// without needing to know which user account or service started it.
|
||||||
|
/// On macOS the agent runs as a root LaunchDaemon, so a fixed system path under
|
||||||
|
/// `/var/run` is used for the same reason, and it stays short enough for the
|
||||||
|
/// Unix socket path limit. On other Unix it derives from the default per-user
|
||||||
|
/// state directory.
|
||||||
|
pub fn default_control_socket_path() -> PathBuf {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if let Some(program_data) = std::env::var_os("ProgramData") {
|
||||||
|
let pd = PathBuf::from(program_data);
|
||||||
|
if pd.is_absolute() {
|
||||||
|
return pd.join("tsunagi").join("agent.sock");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathBuf::from(r"C:\ProgramData\tsunagi\agent.sock")
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
PathBuf::from("/var/run/tsunagi/agent.sock")
|
||||||
|
}
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
{
|
||||||
|
// Prefer a running system service's socket when it is present, so a
|
||||||
|
// GUI or CLI reaches the packaged daemon with no configuration; fall
|
||||||
|
// back to the per-user location otherwise.
|
||||||
|
let system = PathBuf::from("/run/tsunagi/agent.sock");
|
||||||
|
if system.exists() {
|
||||||
|
return system;
|
||||||
|
}
|
||||||
|
if let Ok(paths) = crate::config::StoragePaths::user_default() {
|
||||||
|
control_socket_path(&paths.state_dir)
|
||||||
|
} else {
|
||||||
|
system
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What a client asks for.
|
/// What a client asks for.
|
||||||
///
|
///
|
||||||
/// `Debug` is written by hand rather than derived: one of these carries a
|
/// `Debug` is written by hand rather than derived: one of these carries a
|
||||||
@@ -133,7 +214,7 @@ pub enum Request {
|
|||||||
///
|
///
|
||||||
/// Asked of the running agent because that is the only way to add a
|
/// Asked of the running agent because that is the only way to add a
|
||||||
/// network to an agent that is already up: the state directory belongs
|
/// network to an agent that is already up: the state directory belongs
|
||||||
/// to one live agent, so a second `tsunagi up` cannot.
|
/// to one live agent, so a second `tsng up` cannot.
|
||||||
Join {
|
Join {
|
||||||
/// The network name.
|
/// The network name.
|
||||||
name: String,
|
name: String,
|
||||||
@@ -146,6 +227,29 @@ pub enum Request {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
broadcast: Option<bool>,
|
broadcast: Option<bool>,
|
||||||
},
|
},
|
||||||
|
/// Ask for a configured network's shared secret, in its `tsn1…` text form,
|
||||||
|
/// so a local GUI or script can copy it to re-share. The reply travels only
|
||||||
|
/// over the owner/group socket and the secret is never logged.
|
||||||
|
Secret {
|
||||||
|
/// Which network, by the id text `status` prints.
|
||||||
|
network_id: String,
|
||||||
|
},
|
||||||
|
/// Offer this agent as an exit node in one network, or stop. Off unless
|
||||||
|
/// asked.
|
||||||
|
SetExitOffer {
|
||||||
|
/// Public network identifier.
|
||||||
|
network_id: String,
|
||||||
|
/// Whether members may send their internet traffic through this agent.
|
||||||
|
enabled: bool,
|
||||||
|
},
|
||||||
|
/// Send all this device's internet traffic through a member of one
|
||||||
|
/// network, or back the ordinary way.
|
||||||
|
SetExitNode {
|
||||||
|
/// Public network identifier.
|
||||||
|
network_id: String,
|
||||||
|
/// The member's endpoint id, or `None` to stop using an exit node.
|
||||||
|
peer: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for Request {
|
impl std::fmt::Debug for Request {
|
||||||
@@ -166,6 +270,14 @@ impl std::fmt::Debug for Request {
|
|||||||
write!(f, "Dns {{ enable: {enable}, port: {port:?} }}")
|
write!(f, "Dns {{ enable: {enable}, port: {port:?} }}")
|
||||||
}
|
}
|
||||||
Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: <redacted> }}"),
|
Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: <redacted> }}"),
|
||||||
|
Request::Secret { network_id } => write!(f, "Secret {{ {network_id} }}"),
|
||||||
|
Request::SetExitOffer {
|
||||||
|
network_id,
|
||||||
|
enabled,
|
||||||
|
} => write!(f, "SetExitOffer {{ {network_id}, enabled: {enabled} }}"),
|
||||||
|
Request::SetExitNode { network_id, peer } => {
|
||||||
|
write!(f, "SetExitNode {{ {network_id}, peer: {peer:?} }}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,6 +302,12 @@ pub enum Response {
|
|||||||
///
|
///
|
||||||
/// `None` means it is not serving at all.
|
/// `None` means it is not serving at all.
|
||||||
Dns(Option<DnsReport>),
|
Dns(Option<DnsReport>),
|
||||||
|
/// A network's shared secret, redacted in `Debug`.
|
||||||
|
Secret(RedactedText),
|
||||||
|
/// Whether this agent now offers itself as an exit node.
|
||||||
|
ExitOffer(bool),
|
||||||
|
/// The member this device now sends its internet traffic through.
|
||||||
|
ExitNode(Option<String>),
|
||||||
/// The request could not be served.
|
/// The request could not be served.
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
@@ -261,6 +379,38 @@ pub struct StatusReport {
|
|||||||
pub networks: Vec<NetworkReport>,
|
pub networks: Vec<NetworkReport>,
|
||||||
/// The local DNS service, when one was asked for.
|
/// The local DNS service, when one was asked for.
|
||||||
pub dns: Option<DnsReport>,
|
pub dns: Option<DnsReport>,
|
||||||
|
/// The state directory the agent is actually using.
|
||||||
|
///
|
||||||
|
/// A client that was not told one would look in its own default, which is
|
||||||
|
/// not the agent's when the agent runs as a service under another user.
|
||||||
|
pub state_dir: String,
|
||||||
|
/// The cache directory the agent is actually using.
|
||||||
|
pub cache_dir: String,
|
||||||
|
/// The executable the agent runs, which is what needs the capability.
|
||||||
|
pub program: String,
|
||||||
|
/// Whether the agent itself can manage the overlay interface.
|
||||||
|
///
|
||||||
|
/// It is the agent that creates the interface, so the client's own
|
||||||
|
/// capabilities say nothing about it.
|
||||||
|
pub privilege: PrivilegeReport,
|
||||||
|
/// The version of the agent's software.
|
||||||
|
pub version: String,
|
||||||
|
/// The data plane protocols it has, each as `name vN` (wire version).
|
||||||
|
pub protocols: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the agent found when it checked whether it may manage an interface.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum PrivilegeReport {
|
||||||
|
/// Not checked.
|
||||||
|
#[default]
|
||||||
|
Unknown,
|
||||||
|
/// It can.
|
||||||
|
Available,
|
||||||
|
/// It cannot, with what was found.
|
||||||
|
Missing(String),
|
||||||
|
/// This platform has no provisioner yet.
|
||||||
|
Unsupported,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The local DNS service.
|
/// The local DNS service.
|
||||||
@@ -334,6 +484,9 @@ pub struct NetworkReport {
|
|||||||
pub range_conflict: Option<String>,
|
pub range_conflict: Option<String>,
|
||||||
/// The overlay, when a protocol is running one.
|
/// The overlay, when a protocol is running one.
|
||||||
pub overlay: Option<OverlayReport>,
|
pub overlay: Option<OverlayReport>,
|
||||||
|
/// Exit-node settings: whether this agent offers one, and whether it
|
||||||
|
/// uses one.
|
||||||
|
pub exit: ExitReport,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One member of the network, from signed state.
|
/// One member of the network, from signed state.
|
||||||
@@ -345,6 +498,8 @@ pub struct MemberReport {
|
|||||||
pub overlay_address_v4: Option<String>,
|
pub overlay_address_v4: Option<String>,
|
||||||
/// Consecutive failed dial attempts, when this agent is trying to reach it.
|
/// Consecutive failed dial attempts, when this agent is trying to reach it.
|
||||||
pub failed_dials: u32,
|
pub failed_dials: u32,
|
||||||
|
/// The last hostname this agent saw it announce, kept while it is away.
|
||||||
|
pub hostname: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One control plane peer.
|
/// One control plane peer.
|
||||||
@@ -359,6 +514,8 @@ pub struct PeerReport {
|
|||||||
pub transport: String,
|
pub transport: String,
|
||||||
/// Round-trip time in milliseconds, when a path is selected.
|
/// Round-trip time in milliseconds, when a path is selected.
|
||||||
pub rtt_ms: Option<u64>,
|
pub rtt_ms: Option<u64>,
|
||||||
|
/// The peer offers itself as an exit node in this network.
|
||||||
|
pub exit_node: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The WireGuard overlay of one network.
|
/// The WireGuard overlay of one network.
|
||||||
@@ -386,6 +543,50 @@ pub struct OverlayReport {
|
|||||||
pub multicast_packets: u64,
|
pub multicast_packets: u64,
|
||||||
/// One destination nobody owned, if there was one.
|
/// One destination nobody owned, if there was one.
|
||||||
pub unroutable_sample: Option<String>,
|
pub unroutable_sample: Option<String>,
|
||||||
|
/// The broadcast route and firewall allowance on this host, when the
|
||||||
|
/// agent installed them. `None` means none are installed.
|
||||||
|
pub broadcast_rules: Option<HostRulesReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How one set of exit-node host rules went.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuleSetReport {
|
||||||
|
/// Whether every step took.
|
||||||
|
pub ok: bool,
|
||||||
|
/// One line: what is in place, or what is missing and why.
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One network's exit-node settings and what they are doing.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ExitReport {
|
||||||
|
/// This agent offers itself as an exit node in this network.
|
||||||
|
pub offering: bool,
|
||||||
|
/// Masquerading and forwarding rules for members' traffic, when offering.
|
||||||
|
pub offer_rules: Option<RuleSetReport>,
|
||||||
|
/// Whether the kernel forwards packets for the overlay interface.
|
||||||
|
/// `Some(false)` means the rules are in place and nothing passes until it
|
||||||
|
/// is turned on. `None` when unknown.
|
||||||
|
pub forwarding: Option<bool>,
|
||||||
|
/// The member this device sends its internet traffic through, as an
|
||||||
|
/// endpoint id.
|
||||||
|
pub via: Option<String>,
|
||||||
|
/// Whether that member is connected and still offering to be one.
|
||||||
|
pub via_online: bool,
|
||||||
|
/// The routes that send this device's traffic through it.
|
||||||
|
pub client_rules: Option<RuleSetReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the agent installed on the host so LAN broadcast reaches the overlay.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct HostRulesReport {
|
||||||
|
/// Whether both the route and the firewall allowance are in place.
|
||||||
|
pub ok: bool,
|
||||||
|
/// One line: `ok (route + firewall)`, or what is missing and why.
|
||||||
|
pub detail: String,
|
||||||
|
/// The overlay address the `255.255.255.255` route is bound to. With
|
||||||
|
/// several networks on one interface only one address can be its source.
|
||||||
|
pub source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One overlay peer.
|
/// One overlay peer.
|
||||||
@@ -394,7 +595,9 @@ pub struct OverlayPeerReport {
|
|||||||
/// The peer's control plane identity, so a tunnel can be matched to the
|
/// The peer's control plane identity, so a tunnel can be matched to the
|
||||||
/// session and the member it belongs to.
|
/// session and the member it belongs to.
|
||||||
pub endpoint_id: String,
|
pub endpoint_id: String,
|
||||||
/// The peer's WireGuard public key.
|
/// Protocol carrying overlay traffic for this peer (e.g. "tcp-tls", "wg-quic").
|
||||||
|
pub protocol: String,
|
||||||
|
/// The peer's WireGuard public key (if applicable).
|
||||||
pub public_key: String,
|
pub public_key: String,
|
||||||
/// The overlay address the network agreed it holds.
|
/// The overlay address the network agreed it holds.
|
||||||
pub address: Option<String>,
|
pub address: Option<String>,
|
||||||
@@ -406,6 +609,10 @@ pub struct OverlayPeerReport {
|
|||||||
pub tx_packets: u64,
|
pub tx_packets: u64,
|
||||||
/// Packets decrypted from this peer.
|
/// Packets decrypted from this peer.
|
||||||
pub rx_packets: u64,
|
pub rx_packets: u64,
|
||||||
|
/// Plaintext bytes encrypted and sent to this peer.
|
||||||
|
pub tx_bytes: u64,
|
||||||
|
/// Plaintext bytes decrypted from this peer.
|
||||||
|
pub rx_bytes: u64,
|
||||||
/// Data packets dropped: wrong source address, or too large for the path.
|
/// Data packets dropped: wrong source address, or too large for the path.
|
||||||
pub dropped: u64,
|
pub dropped: u64,
|
||||||
/// WireGuard protocol errors.
|
/// WireGuard protocol errors.
|
||||||
@@ -509,6 +716,39 @@ pub trait ReportSource: Send + Sync + 'static {
|
|||||||
) -> BoxFuture<'_, std::result::Result<Option<DnsReport>, String>> {
|
) -> BoxFuture<'_, std::result::Result<Option<DnsReport>, String>> {
|
||||||
Box::pin(async move { Err("this agent cannot serve DNS".to_string()) })
|
Box::pin(async move { Err("this agent cannot serve DNS".to_string()) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a configured network's shared secret in its `tsn1…` text form.
|
||||||
|
///
|
||||||
|
/// Defaulted to a refusal, like the others. An implementor reads it from
|
||||||
|
/// local state; it must never be logged.
|
||||||
|
fn network_secret(
|
||||||
|
&self,
|
||||||
|
_network_id: String,
|
||||||
|
) -> BoxFuture<'_, std::result::Result<String, String>> {
|
||||||
|
Box::pin(async move { Err("this agent cannot read secrets".to_string()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offers this agent as an exit node in a network, or stops.
|
||||||
|
///
|
||||||
|
/// Defaulted to a refusal, like the others.
|
||||||
|
fn set_exit_offer(
|
||||||
|
&self,
|
||||||
|
_network_id: String,
|
||||||
|
_enabled: bool,
|
||||||
|
) -> BoxFuture<'_, std::result::Result<bool, String>> {
|
||||||
|
Box::pin(async move { Err("this agent cannot be an exit node".to_string()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends this device's internet traffic through a member, or stops.
|
||||||
|
///
|
||||||
|
/// Defaulted to a refusal, like the others.
|
||||||
|
fn set_exit_node(
|
||||||
|
&self,
|
||||||
|
_network_id: String,
|
||||||
|
_peer: Option<String>,
|
||||||
|
) -> BoxFuture<'_, std::result::Result<Option<String>, String>> {
|
||||||
|
Box::pin(async move { Err("this agent cannot use an exit node".to_string()) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<F> ReportSource for F
|
impl<F> ReportSource for F
|
||||||
@@ -524,7 +764,7 @@ where
|
|||||||
///
|
///
|
||||||
/// A local answer comes from memory, so anything this slow means the agent is
|
/// A local answer comes from memory, so anything this slow means the agent is
|
||||||
/// wedged rather than busy. Saying so beats waiting: unbounded, one wedged
|
/// wedged rather than busy. Saying so beats waiting: unbounded, one wedged
|
||||||
/// runtime leaves `tsunagi status` hanging with nothing on screen and no way
|
/// runtime leaves `tsng status` hanging with nothing on screen and no way
|
||||||
/// out but Ctrl-C.
|
/// out but Ctrl-C.
|
||||||
pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
|
pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
@@ -539,7 +779,7 @@ pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
|
|||||||
///
|
///
|
||||||
/// Bump it whenever [`Request`], [`Response`] or anything they contain
|
/// Bump it whenever [`Request`], [`Response`] or anything they contain
|
||||||
/// changes shape.
|
/// changes shape.
|
||||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 14]);
|
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 17]);
|
||||||
|
|
||||||
/// Reads one request off an accepted stream, answers it, writes the response.
|
/// Reads one request off an accepted stream, answers it, writes the response.
|
||||||
///
|
///
|
||||||
@@ -597,6 +837,23 @@ where
|
|||||||
Ok(report) => Response::Dns(report),
|
Ok(report) => Response::Dns(report),
|
||||||
Err(reason) => Response::Error(reason),
|
Err(reason) => Response::Error(reason),
|
||||||
},
|
},
|
||||||
|
Request::Secret { network_id } => match source.network_secret(network_id).await {
|
||||||
|
Ok(secret) => Response::Secret(RedactedText::new(secret)),
|
||||||
|
Err(reason) => Response::Error(reason),
|
||||||
|
},
|
||||||
|
Request::SetExitOffer {
|
||||||
|
network_id,
|
||||||
|
enabled,
|
||||||
|
} => match source.set_exit_offer(network_id, enabled).await {
|
||||||
|
Ok(enabled) => Response::ExitOffer(enabled),
|
||||||
|
Err(reason) => Response::Error(reason),
|
||||||
|
},
|
||||||
|
Request::SetExitNode { network_id, peer } => {
|
||||||
|
match source.set_exit_node(network_id, peer).await {
|
||||||
|
Ok(peer) => Response::ExitNode(peer),
|
||||||
|
Err(reason) => Response::Error(reason),
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
write_message(&mut stream, &response).await
|
write_message(&mut stream, &response).await
|
||||||
}
|
}
|
||||||
@@ -751,6 +1008,56 @@ pub async fn set_dns(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Offers the running agent as an exit node in one network, or stops.
|
||||||
|
pub async fn set_exit_offer(
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
network_id: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let request = Request::SetExitOffer {
|
||||||
|
network_id: network_id.to_owned(),
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
|
match exchange(path.as_ref(), &request, EXCHANGE_TIMEOUT).await? {
|
||||||
|
Response::ExitOffer(enabled) => Ok(enabled),
|
||||||
|
Response::Error(message) => Err(Error::Storage(message)),
|
||||||
|
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the running agent's internet traffic through a member of one
|
||||||
|
/// network, or back the ordinary way with `None`.
|
||||||
|
pub async fn set_exit_node(
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
network_id: &str,
|
||||||
|
peer: Option<&str>,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
let request = Request::SetExitNode {
|
||||||
|
network_id: network_id.to_owned(),
|
||||||
|
peer: peer.map(str::to_owned),
|
||||||
|
};
|
||||||
|
match exchange(path.as_ref(), &request, EXCHANGE_TIMEOUT).await? {
|
||||||
|
Response::ExitNode(peer) => Ok(peer),
|
||||||
|
Response::Error(message) => Err(Error::Storage(message)),
|
||||||
|
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks a running agent for a configured network's shared secret, in its
|
||||||
|
/// `tsn1…` text form, so a local client can copy it. The secret is never
|
||||||
|
/// logged by this path.
|
||||||
|
pub async fn network_secret(path: impl AsRef<Path>, network_id: &str) -> Result<String> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let request = Request::Secret {
|
||||||
|
network_id: network_id.to_string(),
|
||||||
|
};
|
||||||
|
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
|
||||||
|
Response::Secret(secret) => Ok(secret.into_string()),
|
||||||
|
Response::Error(reason) => Err(Error::Storage(reason)),
|
||||||
|
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn write_message<S, T>(stream: &mut S, value: &T) -> Result<()>
|
pub(crate) async fn write_message<S, T>(stream: &mut S, value: &T) -> Result<()>
|
||||||
where
|
where
|
||||||
S: AsyncWrite + Unpin,
|
S: AsyncWrite + Unpin,
|
||||||
@@ -22,8 +22,8 @@ use crate::error::{Error, Result};
|
|||||||
// The transport-agnostic surface, re-exported so this module is a complete
|
// The transport-agnostic surface, re-exported so this module is a complete
|
||||||
// view of the local control interface on its own.
|
// view of the local control interface on its own.
|
||||||
pub use super::{
|
pub use super::{
|
||||||
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
|
CONTROL_PROTOCOL, ControlSocketAccess, EXCHANGE_TIMEOUT, ReportSource, is_serving,
|
||||||
request_status, set_active, set_dns, set_hostname,
|
join_network, leave_network, request_status, set_active, set_dns, set_hostname,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Serves the local control interface on a Unix socket.
|
/// Serves the local control interface on a Unix socket.
|
||||||
@@ -39,11 +39,15 @@ impl ControlSocket {
|
|||||||
/// A socket file left behind by a crashed agent is replaced, but only
|
/// A socket file left behind by a crashed agent is replaced, but only
|
||||||
/// after checking that nothing is listening on it, so two live agents
|
/// after checking that nothing is listening on it, so two live agents
|
||||||
/// never fight over one path.
|
/// never fight over one path.
|
||||||
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
|
pub async fn bind(
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
source: Arc<dyn ReportSource>,
|
||||||
|
access: ControlSocketAccess,
|
||||||
|
) -> Result<Self> {
|
||||||
let path = path.as_ref().to_path_buf();
|
let path = path.as_ref().to_path_buf();
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
crate::storage::create_dir(parent)?;
|
create_parent(parent, &access)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
@@ -61,7 +65,7 @@ impl ControlSocket {
|
|||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
restrict(&path)?;
|
apply_access(&path, &access)?;
|
||||||
|
|
||||||
let task = tokio::spawn(serve(listener, source));
|
let task = tokio::spawn(serve(listener, source));
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -94,9 +98,48 @@ impl Drop for ControlSocket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restrict(path: &Path) -> Result<()> {
|
/// Creates the socket's parent directory.
|
||||||
|
///
|
||||||
|
/// A group-shared socket must sit in a directory the group can traverse, so it
|
||||||
|
/// is `0755`; a private socket keeps the owner-only state-directory default.
|
||||||
|
fn create_parent(parent: &Path, access: &ControlSocketAccess) -> Result<()> {
|
||||||
|
match access {
|
||||||
|
ControlSocketAccess::Private => crate::storage::create_dir(parent),
|
||||||
|
ControlSocketAccess::Group(_) => {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|source| Error::Io {
|
||||||
|
path: parent.to_path_buf(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
set_mode(parent, 0o755)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the access policy to the bound socket.
|
||||||
|
///
|
||||||
|
/// Private is owner-only `0600`. Group sets the socket's group to the gid and
|
||||||
|
/// the mode to `0660`; if the group cannot be set — the agent is not a member,
|
||||||
|
/// say — it falls back to owner-only rather than leaving the group wrong, so
|
||||||
|
/// the socket is never more open than intended.
|
||||||
|
fn apply_access(path: &Path, access: &ControlSocketAccess) -> Result<()> {
|
||||||
|
match access {
|
||||||
|
ControlSocketAccess::Private => set_mode(path, 0o600),
|
||||||
|
ControlSocketAccess::Group(gid) => {
|
||||||
|
if let Err(source) = std::os::unix::fs::chown(path, None, Some(*gid)) {
|
||||||
|
tracing::warn!(
|
||||||
|
path = %path.display(), gid, %source,
|
||||||
|
"cannot set the control socket group; keeping it owner-only"
|
||||||
|
);
|
||||||
|
return set_mode(path, 0o600);
|
||||||
|
}
|
||||||
|
set_mode(path, 0o660)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_mode(path: &Path, mode: u32) -> Result<()> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|source| {
|
||||||
Error::Io {
|
Error::Io {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
source,
|
source,
|
||||||
@@ -170,7 +213,9 @@ mod tests {
|
|||||||
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
||||||
Box::pin(async { StatusReport::default() })
|
Box::pin(async { StatusReport::default() })
|
||||||
});
|
});
|
||||||
let control = ControlSocket::bind(&path, source).await.unwrap();
|
let control = ControlSocket::bind(&path, source, ControlSocketAccess::Private)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
|
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
|
||||||
.await
|
.await
|
||||||
@@ -18,15 +18,16 @@
|
|||||||
//!
|
//!
|
||||||
//! # Who may connect
|
//! # Who may connect
|
||||||
//!
|
//!
|
||||||
//! The pipe rejects clients from other machines, and takes the process's
|
//! The pipe rejects clients from other machines (`reject_remote_clients(true)`).
|
||||||
//! default security, under which the creating user has full access. Narrowing
|
//! An explicit SDDL security descriptor grants Full Control to Administrators
|
||||||
//! that further with an explicit ACL needs a raw security-descriptor call this
|
//! and LocalSystem, while granting Read and Write (`GRGW`) to all local users
|
||||||
//! crate forbids, so it is not attempted; on a single-user machine, and against
|
//! with a Medium Integrity SACL label (`S:(ML;;NW;;;ME)`). This allows unprivileged
|
||||||
//! other unprivileged users, the default is the practical equivalent of the
|
//! user-space tools (such as tray applications, status monitors, or CLI commands
|
||||||
//! owner-only Unix socket. There is no leftover to clean up: a pipe exists only
|
//! launched by regular users) to connect to the agent even when the agent was
|
||||||
//! while its server does.
|
//! started by an elevated Administrator or as a system service.
|
||||||
//! Commands must run as the same Windows user with the same elevation as the
|
//! A permission denial is reported as such, never as an absent agent.
|
||||||
//! agent. A permission denial is reported as such, never as an absent agent.
|
|
||||||
|
#![allow(unsafe_code)]
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -39,8 +40,8 @@ use tokio::task::JoinHandle;
|
|||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
pub use super::{
|
pub use super::{
|
||||||
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
|
CONTROL_PROTOCOL, ControlSocketAccess, EXCHANGE_TIMEOUT, ReportSource, is_serving,
|
||||||
request_status, set_active, set_dns, set_hostname,
|
join_network, leave_network, request_status, set_active, set_dns, set_hostname,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `ERROR_ACCESS_DENIED`: creating a first instance finds an existing pipe,
|
/// `ERROR_ACCESS_DENIED`: creating a first instance finds an existing pipe,
|
||||||
@@ -50,6 +51,91 @@ const ERROR_ACCESS_DENIED: i32 = 5;
|
|||||||
/// is there; the client only has to wait for a free instance.
|
/// is there; the client only has to wait for a free instance.
|
||||||
const ERROR_PIPE_BUSY: i32 = 231;
|
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.
|
/// Serves the local control interface on a named pipe.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ControlSocket {
|
pub struct ControlSocket {
|
||||||
@@ -63,15 +149,27 @@ impl ControlSocket {
|
|||||||
/// Claiming the first instance of the name is what detects a second agent:
|
/// Claiming the first instance of the name is what detects a second agent:
|
||||||
/// if the name already exists, the create is refused and that is reported
|
/// if the name already exists, the create is refused and that is reported
|
||||||
/// as the state being locked, so two live agents never share one pipe.
|
/// as the state being locked, so two live agents never share one pipe.
|
||||||
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
|
///
|
||||||
|
/// `_access` is accepted for parity with the Unix socket; granting a group
|
||||||
|
/// here means a pipe security descriptor and is a separate follow-up, so for
|
||||||
|
/// now the pipe keeps its default (creator plus the built-in access the
|
||||||
|
/// `PipeSecurityAttributes` set).
|
||||||
|
pub async fn bind(
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
source: Arc<dyn ReportSource>,
|
||||||
|
_access: ControlSocketAccess,
|
||||||
|
) -> Result<Self> {
|
||||||
let path = path.as_ref().to_path_buf();
|
let path = path.as_ref().to_path_buf();
|
||||||
let name = pipe_name(&path);
|
let name = pipe_name(&path);
|
||||||
|
|
||||||
let server = match ServerOptions::new()
|
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)
|
.first_pipe_instance(true)
|
||||||
.reject_remote_clients(true)
|
.reject_remote_clients(true)
|
||||||
.create(&name)
|
.create_with_security_attributes_raw(&name, sec_attrs.as_raw_mut())
|
||||||
{
|
} {
|
||||||
Ok(server) => server,
|
Ok(server) => server,
|
||||||
Err(err) if err.raw_os_error() == Some(ERROR_ACCESS_DENIED) => {
|
Err(err) if err.raw_os_error() == Some(ERROR_ACCESS_DENIED) => {
|
||||||
return Err(Error::StateLocked { path });
|
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.
|
/// 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
|
/// 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.
|
/// the first is mid-flight would find nothing listening.
|
||||||
async fn serve(
|
async fn serve(
|
||||||
name: String,
|
name: String,
|
||||||
@@ -155,10 +253,19 @@ async fn serve(
|
|||||||
/// Creates the next pipe instance, or `None` if the name can no longer be
|
/// Creates the next pipe instance, or `None` if the name can no longer be
|
||||||
/// served.
|
/// served.
|
||||||
fn next_instance(name: &str) -> Option<tokio::net::windows::named_pipe::NamedPipeServer> {
|
fn next_instance(name: &str) -> Option<tokio::net::windows::named_pipe::NamedPipeServer> {
|
||||||
match ServerOptions::new()
|
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)
|
.reject_remote_clients(true)
|
||||||
.create(name)
|
.create_with_security_attributes_raw(name, sec_attrs.as_raw_mut())
|
||||||
{
|
} {
|
||||||
Ok(server) => Some(server),
|
Ok(server) => Some(server),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::debug!(%err, "cannot create the next control pipe instance");
|
tracing::debug!(%err, "cannot create the next control pipe instance");
|
||||||
@@ -187,7 +294,7 @@ pub(crate) async fn connect(path: &Path) -> Result<NamedPipeClient> {
|
|||||||
format!(
|
format!(
|
||||||
"the agent's control pipe exists, but Windows denied access. \
|
"the agent's control pipe exists, but Windows denied access. \
|
||||||
Run this command as the same Windows user and with the same \
|
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}"
|
the agent is elevated). Windows error: {err}"
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
File renamed without changes.
@@ -240,6 +240,57 @@ impl EndpointAdapter {
|
|||||||
TransportPolicy::N0Defaults => builder.preset(presets::N0),
|
TransportPolicy::N0Defaults => builder.preset(presets::N0),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if matches!(
|
||||||
|
config.transport,
|
||||||
|
TransportPolicy::N0Defaults | TransportPolicy::DirectOnly
|
||||||
|
) {
|
||||||
|
let filter = iroh::address_lookup::AddrFilter::new(|addrs| {
|
||||||
|
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.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.addr_filter(filter);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "testing")]
|
#[cfg(feature = "testing")]
|
||||||
if let Some(transport) = &config.test_quic_transport {
|
if let Some(transport) = &config.test_quic_transport {
|
||||||
builder = builder.transport_config(transport.clone());
|
builder = builder.transport_config(transport.clone());
|
||||||
@@ -259,6 +310,91 @@ impl EndpointAdapter {
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| Error::Endpoint(format!("cannot bind endpoint: {err}")))?;
|
.map_err(|err| Error::Endpoint(format!("cannot bind endpoint: {err}")))?;
|
||||||
|
|
||||||
|
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 })
|
Ok(Self { endpoint })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +140,7 @@ mod tests {
|
|||||||
fn limited_and_directed_broadcasts_never_cross_domains_or_target_disabled_peers() {
|
fn limited_and_directed_broadcasts_never_cross_domains_or_target_disabled_peers() {
|
||||||
let peer = iroh::SecretKey::from_bytes(&[8; 32]).public();
|
let peer = iroh::SecretKey::from_bytes(&[8; 32]).public();
|
||||||
let a = NetworkRoutes {
|
let a = NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
range: Some("10.0.0.0/24".parse().unwrap()),
|
range: Some("10.0.0.0/24".parse().unwrap()),
|
||||||
local: Some("10.0.0.1".parse().unwrap()),
|
local: Some("10.0.0.1".parse().unwrap()),
|
||||||
peers: vec![("10.0.0.2".parse().unwrap(), peer)],
|
peers: vec![("10.0.0.2".parse().unwrap(), peer)],
|
||||||
@@ -149,6 +150,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let b = NetworkRoutes {
|
let b = NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
range: Some("10.1.0.0/24".parse().unwrap()),
|
range: Some("10.1.0.0/24".parse().unwrap()),
|
||||||
local: Some("10.1.0.1".parse().unwrap()),
|
local: Some("10.1.0.1".parse().unwrap()),
|
||||||
peers: vec![("10.1.0.2".parse().unwrap(), peer)],
|
peers: vec![("10.1.0.2".parse().unwrap(), peer)],
|
||||||
@@ -185,6 +187,7 @@ mod tests {
|
|||||||
assert!(!table.accepts(id("b"), peer, Ipv4Addr::BROADCAST));
|
assert!(!table.accepts(id("b"), peer, Ipv4Addr::BROADCAST));
|
||||||
assert!(table.accepts(id("a"), peer, Ipv4Addr::BROADCAST));
|
assert!(table.accepts(id("a"), peer, Ipv4Addr::BROADCAST));
|
||||||
let disabled = NetworkRoutes {
|
let disabled = NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: BroadcastPolicy {
|
broadcast: BroadcastPolicy {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
..a.broadcast.clone()
|
..a.broadcast.clone()
|
||||||
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,263 @@
|
|||||||
|
//! 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, ()>;
|
||||||
|
|
||||||
|
/// 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,687 @@
|
|||||||
|
//! 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; 5] = [
|
||||||
|
"/usr/sbin/iptables",
|
||||||
|
"/sbin/iptables",
|
||||||
|
"/usr/bin/iptables",
|
||||||
|
"/bin/iptables",
|
||||||
|
"/usr/local/sbin/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 {
|
||||||
|
let Some(program) = pick_iptables(|path| std::path::Path::new(path).is_file()) 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxHostRules {
|
||||||
|
pub(crate) fn new(route: RouteHandle) -> Self {
|
||||||
|
Self { route }
|
||||||
|
}
|
||||||
|
|
||||||
|
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,813 @@
|
|||||||
|
//! Exit-node host rules on Linux: `iptables` for the offering side, policy
|
||||||
|
//! routing over netlink for the using side.
|
||||||
|
//!
|
||||||
|
//! # Offering
|
||||||
|
//!
|
||||||
|
//! For every network that offers this agent as an exit node, three rules,
|
||||||
|
//! selected by that network's range and carrying the tag
|
||||||
|
//! `tsunagi-exit:<if>:<range>`:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! -t nat POSTROUTING -s <range> ! -o <if> -j MASQUERADE
|
||||||
|
//! -t filter FORWARD -i <if> -s <range> ! -o <if> -j ACCEPT
|
||||||
|
//! -t filter FORWARD -o <if> -d <range> -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `! -o <if>` is the egress without naming it: whatever interface the
|
||||||
|
//! default route uses, now or after it changes. Kernel forwarding is only
|
||||||
|
//! *read*. Turning it on changes how the whole host behaves, which is the
|
||||||
|
//! owner's decision, so when it is off the rules are installed anyway and the
|
||||||
|
//! report says so.
|
||||||
|
//!
|
||||||
|
//! # Using
|
||||||
|
//!
|
||||||
|
//! Policy routing, with no `/1` routes and nothing in the main table:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! table 0x7473: default dev <if>
|
||||||
|
//! 5280: from all uidrange <euid>-<euid> lookup main
|
||||||
|
//! 5290: from all lookup main suppress_prefixlength 0
|
||||||
|
//! 5300: from all lookup 0x7473
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The first keeps the agent's *own* sockets off the tunnel: its QUIC, relay,
|
||||||
|
//! DHT and DNS traffic would otherwise be routed into the interface it is
|
||||||
|
//! carrying, a loop. The second lets every specific route — the LAN, another
|
||||||
|
//! VPN, a tunnel to somewhere else — keep working, because only the default
|
||||||
|
//! route is suppressed. The priorities sit after Tailscale's 5270.
|
||||||
|
//!
|
||||||
|
//! The first rule exempts a *user id*, so it is only right when the agent has
|
||||||
|
//! one of its own, as the packaged service does. An agent started from a login
|
||||||
|
//! session would take that user's own applications out of the tunnel with it,
|
||||||
|
//! and the exit node would silently do nothing for them. That is refused, not
|
||||||
|
//! guessed at: see [`client_refusal`].
|
||||||
|
|
||||||
|
use crate::BoxFuture;
|
||||||
|
use crate::state::Ipv4Range;
|
||||||
|
|
||||||
|
use super::RuleOutcome;
|
||||||
|
use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules, exit_tag, exit_tag_prefix};
|
||||||
|
use super::linux::{
|
||||||
|
LinuxHostRules, Ran, Run, comment_supported, failure, locked, run_iptables, same_rule,
|
||||||
|
split_rule_line,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The chains the offering rules live in: `(table, chain)`.
|
||||||
|
const CHAINS: [(&str, &str); 2] = [("nat", "POSTROUTING"), ("filter", "FORWARD")];
|
||||||
|
|
||||||
|
/// One firewall rule: where it goes, and its match and target.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct Rule {
|
||||||
|
table: &'static str,
|
||||||
|
chain: &'static str,
|
||||||
|
spec: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owned(words: &[&str]) -> Vec<String> {
|
||||||
|
words.iter().map(|word| word.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The three rules of one offered range.
|
||||||
|
///
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective user id from the text of `/proc/self/status`.
|
||||||
|
fn parse_euid(status: &str) -> Option<u32> {
|
||||||
|
status
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("Uid:"))
|
||||||
|
.and_then(|rest| rest.split_whitespace().nth(1))
|
||||||
|
.and_then(|effective| effective.parse().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why the using side must not be installed for this process, if it must not.
|
||||||
|
///
|
||||||
|
/// The bypass rule exempts this process's *user id* from the tunnel. That is
|
||||||
|
/// right for a service with an id of its own and wrong for a process started
|
||||||
|
/// from somebody's login session, which shares its id with every application
|
||||||
|
/// that person runs. A login session is recognised by `XDG_RUNTIME_DIR`,
|
||||||
|
/// which `systemd --user` and the login managers set and a system service
|
||||||
|
/// does not. Crude on purpose: refusing a service that was started oddly is
|
||||||
|
/// a message, and exempting a user's applications is a silent failure.
|
||||||
|
fn client_refusal(xdg_runtime_dir: Option<&str>) -> Option<String> {
|
||||||
|
xdg_runtime_dir.filter(|dir| !dir.is_empty()).map(|_| {
|
||||||
|
"the exit node needs the agent to run as a system service with a user of its own: \
|
||||||
|
this agent was started from a login session, so routing everything through the \
|
||||||
|
overlay would take that user's own applications out of the tunnel with it"
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxHostRules {
|
||||||
|
async fn blocking<T: Send + 'static>(
|
||||||
|
work: impl FnOnce() -> T + Send + 'static,
|
||||||
|
) -> Result<T, String> {
|
||||||
|
tokio::task::spawn_blocking(work)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("the task failed: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_client(&self, interface: &str) -> RuleOutcome {
|
||||||
|
let xdg = std::env::var("XDG_RUNTIME_DIR").ok();
|
||||||
|
if let Some(reason) = client_refusal(xdg.as_deref()) {
|
||||||
|
// Whatever an earlier run left must not stay in force.
|
||||||
|
self.clear_client().await;
|
||||||
|
return RuleOutcome::Failed(reason);
|
||||||
|
}
|
||||||
|
let Some(euid) = std::fs::read_to_string("/proc/self/status")
|
||||||
|
.ok()
|
||||||
|
.and_then(|status| parse_euid(&status))
|
||||||
|
else {
|
||||||
|
return RuleOutcome::Failed("cannot tell which user this agent runs as".to_string());
|
||||||
|
};
|
||||||
|
let route = self.route.clone();
|
||||||
|
let interface = interface.to_string();
|
||||||
|
match Self::blocking(move || route.set_exit_client(&interface, euid)).await {
|
||||||
|
Ok(Ok(())) => RuleOutcome::Applied,
|
||||||
|
Ok(Err(err)) => RuleOutcome::Failed(err.to_string()),
|
||||||
|
Err(reason) => RuleOutcome::Failed(reason),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_client(&self) {
|
||||||
|
let route = self.route.clone();
|
||||||
|
if let Ok(Err(err)) = Self::blocking(move || route.clear_exit_client()).await {
|
||||||
|
tracing::warn!(%err, "cannot remove the exit node routing rules");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExitHostRules for LinuxHostRules {
|
||||||
|
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let interface = plan.interface.clone();
|
||||||
|
let ranges = plan.offer.clone();
|
||||||
|
let offer = {
|
||||||
|
let (job_interface, job_ranges) = (interface.clone(), ranges.clone());
|
||||||
|
Self::blocking(move || apply_offer(&run_iptables, &job_interface, &job_ranges))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|reason| {
|
||||||
|
ranges
|
||||||
|
.iter()
|
||||||
|
.map(|range| (*range, RuleOutcome::Failed(reason.clone())))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let forwarding = if ranges.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
forwarding_state(read_sysctl, &interface)
|
||||||
|
};
|
||||||
|
let client = if plan.client {
|
||||||
|
Some(self.apply_client(&interface).await)
|
||||||
|
} else {
|
||||||
|
self.clear_client().await;
|
||||||
|
None
|
||||||
|
};
|
||||||
|
ExitHostReport {
|
||||||
|
offer,
|
||||||
|
forwarding,
|
||||||
|
client,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let name = interface.to_string();
|
||||||
|
let _ = Self::blocking(move || clear_offer(&run_iptables, &name)).await;
|
||||||
|
self.clear_client().await;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn range() -> Ipv4Range {
|
||||||
|
"10.13.37.0/24".parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn words(args: &[String]) -> String {
|
||||||
|
args.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_rules_masquerade_the_range_out_of_every_other_interface_and_forward_it() {
|
||||||
|
let rules = offer_rules("tsun0", &range(), 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,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,365 @@
|
|||||||
|
//! 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(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
|
//! * [`provision`] creates the interface and configures it, and removes it
|
||||||
//! again. Platform mechanics behind one decision function.
|
//! 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.
|
//! * [`tun`] is the packet interface itself, real or in memory.
|
||||||
//! * [`packet`] reads just enough of an IP header to route by it.
|
//! * [`packet`] reads just enough of an IP header to route by it.
|
||||||
//! * [`config`] names interfaces and describes addresses.
|
//! * [`config`] names interfaces and describes addresses.
|
||||||
@@ -38,6 +40,8 @@ pub enum OverlayError {
|
|||||||
|
|
||||||
pub mod broadcast;
|
pub mod broadcast;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod exit;
|
||||||
|
pub mod hostrules;
|
||||||
pub mod interface;
|
pub mod interface;
|
||||||
pub mod packet;
|
pub mod packet;
|
||||||
pub mod provision;
|
pub mod provision;
|
||||||
@@ -45,6 +49,11 @@ pub mod router;
|
|||||||
pub mod tun;
|
pub mod tun;
|
||||||
|
|
||||||
pub use config::{Cidr, DEFAULT_INTERFACE_PREFIX, MAX_INTERFACE_NAME_LEN, interface_name};
|
pub use config::{Cidr, DEFAULT_INTERFACE_PREFIX, MAX_INTERFACE_NAME_LEN, interface_name};
|
||||||
|
pub use exit::{ExitRulesReport, RuleSetReport};
|
||||||
|
pub use hostrules::{
|
||||||
|
BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, ExitHostPlan, ExitHostReport,
|
||||||
|
ExitHostRules, MockExitRules, MockHostRules, RuleOutcome, UnsupportedHostRules,
|
||||||
|
};
|
||||||
pub use interface::{Counters, Interface, PacketCarrier, Rejected};
|
pub use interface::{Counters, Interface, PacketCarrier, Rejected};
|
||||||
pub use packet::IpHeader;
|
pub use packet::IpHeader;
|
||||||
pub use provision::{
|
pub use provision::{
|
||||||
@@ -52,11 +61,20 @@ pub use provision::{
|
|||||||
MockHost, MockProvisioner, Privilege, Provisioned, UnsupportedProvisioner, plan_changes,
|
MockHost, MockProvisioner, Privilege, Provisioned, UnsupportedProvisioner, plan_changes,
|
||||||
probe_net_admin,
|
probe_net_admin,
|
||||||
};
|
};
|
||||||
pub use router::{NetworkRoutes, Route, RouteError, RoutingTable};
|
pub use router::{ExitPolicy, NetworkRoutes, Route, RouteError, RoutingTable};
|
||||||
pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest, address_is_local};
|
pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest, address_is_local};
|
||||||
|
|
||||||
|
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||||
|
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"))]
|
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||||
pub use provision::NetlinkProvisioner;
|
pub use provision::NetlinkProvisioner;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||||
|
pub use provision::UtunProvisioner;
|
||||||
|
|
||||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||||
pub use provision::WintunProvisioner;
|
pub use provision::WintunProvisioner;
|
||||||
File renamed without changes.
+17
-1
@@ -11,6 +11,7 @@ use std::sync::Arc;
|
|||||||
use crate::BoxFuture;
|
use crate::BoxFuture;
|
||||||
use crate::overlay::OverlayError;
|
use crate::overlay::OverlayError;
|
||||||
|
|
||||||
|
use super::super::hostrules::BroadcastHostRules;
|
||||||
use super::super::tun::{TunDevice, TunFactory, TunRequest};
|
use super::super::tun::{TunDevice, TunFactory, TunRequest};
|
||||||
use super::{InterfacePlan, InterfaceProvisioner};
|
use super::{InterfacePlan, InterfaceProvisioner};
|
||||||
|
|
||||||
@@ -30,12 +31,23 @@ fn plan_for(request: &TunRequest) -> Result<InterfacePlan, OverlayError> {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ManagedTunFactory {
|
pub struct ManagedTunFactory {
|
||||||
provisioner: Arc<dyn InterfaceProvisioner>,
|
provisioner: Arc<dyn InterfaceProvisioner>,
|
||||||
|
host_rules: Option<Arc<dyn BroadcastHostRules>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ManagedTunFactory {
|
impl ManagedTunFactory {
|
||||||
/// Wraps a provisioner.
|
/// Wraps a provisioner.
|
||||||
pub fn new(provisioner: Arc<dyn InterfaceProvisioner>) -> Self {
|
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.
|
/// The provisioner underneath.
|
||||||
@@ -54,6 +66,10 @@ impl TunFactory for ManagedTunFactory {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn host_rules(&self) -> Option<Arc<dyn BroadcastHostRules>> {
|
||||||
|
self.host_rules.clone()
|
||||||
|
}
|
||||||
|
|
||||||
fn create<'a>(
|
fn create<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
request: TunRequest,
|
request: TunRequest,
|
||||||
+563
-16
@@ -39,7 +39,7 @@
|
|||||||
//! capability is raised immediately before that call and lowered immediately
|
//! capability is raised immediately before that call and lowered immediately
|
||||||
//! after, and the connection task lives and dies inside it.
|
//! after, and the connection task lives and dies inside it.
|
||||||
|
|
||||||
use std::net::IpAddr;
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
use std::sync::{Arc, Mutex, mpsc};
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
|
|
||||||
use futures_util::TryStreamExt;
|
use futures_util::TryStreamExt;
|
||||||
@@ -47,7 +47,9 @@ use futures_util::TryStreamExt;
|
|||||||
// of step with the client that sends them.
|
// of step with the client that sends them.
|
||||||
use rtnetlink::packet_route::address::{AddressAttribute, AddressMessage};
|
use rtnetlink::packet_route::address::{AddressAttribute, AddressMessage};
|
||||||
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkFlags, LinkInfo, LinkMessage};
|
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkFlags, LinkInfo, LinkMessage};
|
||||||
use rtnetlink::{LinkMessageBuilder, LinkUnspec};
|
use rtnetlink::packet_route::route::RouteScope;
|
||||||
|
use rtnetlink::packet_route::rule::{RuleAction, RuleAttribute, RuleMessage, RuleUidRange};
|
||||||
|
use rtnetlink::{IpVersion, LinkMessageBuilder, LinkUnspec, RouteMessageBuilder};
|
||||||
|
|
||||||
use crate::BoxFuture;
|
use crate::BoxFuture;
|
||||||
use crate::overlay::OverlayError;
|
use crate::overlay::OverlayError;
|
||||||
@@ -67,6 +69,15 @@ enum Command {
|
|||||||
Delete(String, Reply<()>),
|
Delete(String, Reply<()>),
|
||||||
/// Apply MTU, link state and addresses.
|
/// Apply MTU, link state and addresses.
|
||||||
Configure(Box<Configure>, Reply<()>),
|
Configure(Box<Configure>, Reply<()>),
|
||||||
|
/// Route `255.255.255.255/32` through this interface from this address.
|
||||||
|
SetBroadcastRoute(String, Ipv4Addr, Reply<()>),
|
||||||
|
/// Remove that route from this interface.
|
||||||
|
DelBroadcastRoute(String, Reply<()>),
|
||||||
|
/// Send the host's default traffic through this interface, except what
|
||||||
|
/// this user id sends.
|
||||||
|
SetExitClient(String, u32, Reply<()>),
|
||||||
|
/// Take those routing rules away again.
|
||||||
|
ClearExitClient(Reply<()>),
|
||||||
/// Stop the thread.
|
/// Stop the thread.
|
||||||
Stop,
|
Stop,
|
||||||
}
|
}
|
||||||
@@ -133,13 +144,18 @@ impl NetlinkProvisioner {
|
|||||||
&self,
|
&self,
|
||||||
make: impl FnOnce(Reply<T>) -> Command,
|
make: impl FnOnce(Reply<T>) -> Command,
|
||||||
) -> Result<T, OverlayError> {
|
) -> Result<T, OverlayError> {
|
||||||
let (reply_tx, reply_rx) = mpsc::channel();
|
call_thread(&self.commands, make)
|
||||||
self.commands
|
}
|
||||||
.send(make(reply_tx))
|
|
||||||
.map_err(|_| OverlayError::Unavailable("the netlink thread has stopped".to_string()))?;
|
/// The broadcast host rules for this host.
|
||||||
reply_rx.recv().map_err(|_| {
|
///
|
||||||
OverlayError::Unavailable("the netlink thread stopped mid-request".to_string())
|
/// The route is added by this provisioner's netlink thread, because it is
|
||||||
})?
|
/// the only one that ever holds `CAP_NET_ADMIN`, and only for the duration
|
||||||
|
/// of a call. Keep the provisioner alive for as long as the rules are used.
|
||||||
|
pub fn host_rules(&self) -> crate::overlay::hostrules::LinuxHostRules {
|
||||||
|
crate::overlay::hostrules::LinuxHostRules::new(RouteHandle {
|
||||||
|
commands: self.commands.clone(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_ours(&self, name: &str) -> bool {
|
fn is_ours(&self, name: &str) -> bool {
|
||||||
@@ -157,6 +173,58 @@ impl NetlinkProvisioner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn call_thread<T: Send + 'static>(
|
||||||
|
commands: &mpsc::Sender<Command>,
|
||||||
|
make: impl FnOnce(Reply<T>) -> Command,
|
||||||
|
) -> Result<T, OverlayError> {
|
||||||
|
let (reply_tx, reply_rx) = mpsc::channel();
|
||||||
|
commands
|
||||||
|
.send(make(reply_tx))
|
||||||
|
.map_err(|_| OverlayError::Unavailable("the netlink thread has stopped".to_string()))?;
|
||||||
|
reply_rx.recv().map_err(|_| {
|
||||||
|
OverlayError::Unavailable("the netlink thread stopped mid-request".to_string())
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds and removes the limited-broadcast route on the netlink thread.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct RouteHandle {
|
||||||
|
commands: mpsc::Sender<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RouteHandle {
|
||||||
|
/// Routes `255.255.255.255/32` through `interface`, preferring `source`.
|
||||||
|
/// Replaces a route this agent added earlier, so it can be repeated.
|
||||||
|
pub(crate) fn set(&self, interface: &str, source: Ipv4Addr) -> Result<(), OverlayError> {
|
||||||
|
let interface = interface.to_string();
|
||||||
|
call_thread(&self.commands, |reply| {
|
||||||
|
Command::SetBroadcastRoute(interface, source, reply)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the route. Succeeds when it, or the interface, is gone.
|
||||||
|
pub(crate) fn clear(&self, interface: &str) -> Result<(), OverlayError> {
|
||||||
|
let interface = interface.to_string();
|
||||||
|
call_thread(&self.commands, |reply| {
|
||||||
|
Command::DelBroadcastRoute(interface, reply)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the host's default traffic through `interface`, except what the
|
||||||
|
/// user id `uid` sends. Replaces what an earlier call set up.
|
||||||
|
pub(crate) fn set_exit_client(&self, interface: &str, uid: u32) -> Result<(), OverlayError> {
|
||||||
|
let interface = interface.to_string();
|
||||||
|
call_thread(&self.commands, |reply| {
|
||||||
|
Command::SetExitClient(interface, uid, reply)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes those rules. Succeeds when none are there.
|
||||||
|
pub(crate) fn clear_exit_client(&self) -> Result<(), OverlayError> {
|
||||||
|
call_thread(&self.commands, Command::ClearExitClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for NetlinkProvisioner {
|
impl Drop for NetlinkProvisioner {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = self.commands.send(Command::Stop);
|
let _ = self.commands.send(Command::Stop);
|
||||||
@@ -273,7 +341,14 @@ fn netlink_thread(requests: mpsc::Receiver<Command>) {
|
|||||||
Command::Observe(_, reply) => {
|
Command::Observe(_, reply) => {
|
||||||
let _ = reply.send(Err(OverlayError::Unavailable(message)));
|
let _ = reply.send(Err(OverlayError::Unavailable(message)));
|
||||||
}
|
}
|
||||||
Command::Delete(_, reply) | Command::Configure(_, reply) => {
|
Command::Delete(_, reply)
|
||||||
|
| Command::Configure(_, reply)
|
||||||
|
| Command::SetBroadcastRoute(_, _, reply)
|
||||||
|
| Command::DelBroadcastRoute(_, reply)
|
||||||
|
| Command::SetExitClient(_, _, reply) => {
|
||||||
|
let _ = reply.send(Err(OverlayError::Unavailable(message)));
|
||||||
|
}
|
||||||
|
Command::ClearExitClient(reply) => {
|
||||||
let _ = reply.send(Err(OverlayError::Unavailable(message)));
|
let _ = reply.send(Err(OverlayError::Unavailable(message)));
|
||||||
}
|
}
|
||||||
Command::Stop => return,
|
Command::Stop => return,
|
||||||
@@ -306,6 +381,38 @@ fn netlink_thread(requests: mpsc::Receiver<Command>) {
|
|||||||
});
|
});
|
||||||
let _ = reply.send(result);
|
let _ = reply.send(result);
|
||||||
}
|
}
|
||||||
|
Command::SetBroadcastRoute(name, source, reply) => {
|
||||||
|
let result = NetAdmin::acquire().and_then(|guard| {
|
||||||
|
let result = runtime.block_on(set_broadcast_route(&name, source));
|
||||||
|
drop(guard);
|
||||||
|
result
|
||||||
|
});
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
|
Command::DelBroadcastRoute(name, reply) => {
|
||||||
|
let result = NetAdmin::acquire().and_then(|guard| {
|
||||||
|
let result = runtime.block_on(del_broadcast_route(&name));
|
||||||
|
drop(guard);
|
||||||
|
result
|
||||||
|
});
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
|
Command::SetExitClient(name, uid, reply) => {
|
||||||
|
let result = NetAdmin::acquire().and_then(|guard| {
|
||||||
|
let result = runtime.block_on(set_exit_client(&name, uid));
|
||||||
|
drop(guard);
|
||||||
|
result
|
||||||
|
});
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
|
Command::ClearExitClient(reply) => {
|
||||||
|
let result = NetAdmin::acquire().and_then(|guard| {
|
||||||
|
let result = runtime.block_on(clear_exit_client());
|
||||||
|
drop(guard);
|
||||||
|
result
|
||||||
|
});
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -455,6 +562,382 @@ async fn delete_link(name: &str) -> Result<(), OverlayError> {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The message that names the limited-broadcast route on an interface.
|
||||||
|
///
|
||||||
|
/// On-link scope is what the kernel requires of a route with no gateway, and
|
||||||
|
/// what `ip route add 255.255.255.255/32 dev <if>` produces. Deleting needs the
|
||||||
|
/// same scope or the kernel finds no match.
|
||||||
|
fn broadcast_route_message(
|
||||||
|
index: u32,
|
||||||
|
source: Option<Ipv4Addr>,
|
||||||
|
) -> rtnetlink::packet_route::route::RouteMessage {
|
||||||
|
let mut builder = RouteMessageBuilder::<Ipv4Addr>::new()
|
||||||
|
.destination_prefix(Ipv4Addr::BROADCAST, 32)
|
||||||
|
.output_interface(index)
|
||||||
|
.scope(RouteScope::Link);
|
||||||
|
if let Some(source) = source {
|
||||||
|
builder = builder.pref_source(source);
|
||||||
|
}
|
||||||
|
builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn link_index(handle: &rtnetlink::Handle, name: &str) -> Option<u32> {
|
||||||
|
let mut links = handle.link().get().match_name(name.to_string()).execute();
|
||||||
|
links
|
||||||
|
.try_next()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|message| message.header.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_broadcast_route(name: &str, source: Ipv4Addr) -> Result<(), OverlayError> {
|
||||||
|
with_netlink(|handle| async move {
|
||||||
|
let index = link_index(&handle, name).await.ok_or_else(|| {
|
||||||
|
OverlayError::Unavailable(format!(
|
||||||
|
"interface `{name}` disappeared before its broadcast route could be added"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
handle
|
||||||
|
.route()
|
||||||
|
.add(broadcast_route_message(index, Some(source)))
|
||||||
|
.replace()
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
OverlayError::Unavailable(format!(
|
||||||
|
"cannot route 255.255.255.255 through `{name}` from {source}: {err}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn del_broadcast_route(name: &str) -> Result<(), OverlayError> {
|
||||||
|
with_netlink(|handle| async move {
|
||||||
|
// An interface that is gone took its routes with it.
|
||||||
|
let Some(index) = link_index(&handle, name).await else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
match handle
|
||||||
|
.route()
|
||||||
|
.del(broadcast_route_message(index, None))
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
// "No such process": there was no such route, which is the goal.
|
||||||
|
Err(err) if err.to_string().contains("No such process") => Ok(()),
|
||||||
|
Err(err) => Err(OverlayError::Unavailable(format!(
|
||||||
|
"cannot remove the 255.255.255.255 route from `{name}`: {err}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The routing table that holds the exit node's default route: `tsun` in
|
||||||
|
/// ASCII-ish hex, and nothing else uses it.
|
||||||
|
const EXIT_TABLE: u32 = 0x7473;
|
||||||
|
/// The main table.
|
||||||
|
const MAIN_TABLE: u32 = 254;
|
||||||
|
/// Where the exit rules sit, after Tailscale's 5270.
|
||||||
|
const EXIT_BYPASS_PRIORITY: u32 = 5280;
|
||||||
|
const EXIT_SPECIFIC_PRIORITY: u32 = 5290;
|
||||||
|
const EXIT_DEFAULT_PRIORITY: u32 = 5300;
|
||||||
|
|
||||||
|
/// The table a rule looks up.
|
||||||
|
fn rule_table(rule: &RuleMessage) -> u32 {
|
||||||
|
rule.attributes
|
||||||
|
.iter()
|
||||||
|
.find_map(|attribute| match attribute {
|
||||||
|
RuleAttribute::Table(table) => Some(*table),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or(u32::from(rule.header.table))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rule_priority(rule: &RuleMessage) -> Option<u32> {
|
||||||
|
rule.attributes
|
||||||
|
.iter()
|
||||||
|
.find_map(|attribute| match attribute {
|
||||||
|
RuleAttribute::Priority(priority) => Some(*priority),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a rule is one of the three this agent adds for an exit node.
|
||||||
|
///
|
||||||
|
/// Recognised by shape as well as priority: those numbers are not reserved,
|
||||||
|
/// and a rule somebody else put at one of them must not be removed.
|
||||||
|
fn is_exit_rule(rule: &RuleMessage) -> bool {
|
||||||
|
let table = rule_table(rule);
|
||||||
|
match rule_priority(rule) {
|
||||||
|
Some(EXIT_BYPASS_PRIORITY) => {
|
||||||
|
table == MAIN_TABLE
|
||||||
|
&& rule
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.any(|attribute| matches!(attribute, RuleAttribute::UidRange(_)))
|
||||||
|
}
|
||||||
|
Some(EXIT_SPECIFIC_PRIORITY) => {
|
||||||
|
table == MAIN_TABLE
|
||||||
|
&& rule
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.any(|attribute| matches!(attribute, RuleAttribute::SuppressPrefixLen(0)))
|
||||||
|
}
|
||||||
|
Some(EXIT_DEFAULT_PRIORITY) => table == EXIT_TABLE,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exit_default_route(index: Option<u32>) -> rtnetlink::packet_route::route::RouteMessage {
|
||||||
|
let mut builder = RouteMessageBuilder::<Ipv4Addr>::new()
|
||||||
|
.destination_prefix(Ipv4Addr::UNSPECIFIED, 0)
|
||||||
|
.table_id(EXIT_TABLE);
|
||||||
|
if let Some(index) = index {
|
||||||
|
builder = builder.output_interface(index).scope(RouteScope::Link);
|
||||||
|
}
|
||||||
|
builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exit table's IPv6 default: nothing is reachable through it.
|
||||||
|
///
|
||||||
|
/// There is no IPv6 through an exit node, and what the ordinary route would do
|
||||||
|
/// is send it out in the clear. This turns it into an immediate refusal, which
|
||||||
|
/// is what makes applications fall back to IPv4.
|
||||||
|
fn exit_blocking_route_v6() -> rtnetlink::packet_route::route::RouteMessage {
|
||||||
|
RouteMessageBuilder::<std::net::Ipv6Addr>::new()
|
||||||
|
.destination_prefix(std::net::Ipv6Addr::UNSPECIFIED, 0)
|
||||||
|
.table_id(EXIT_TABLE)
|
||||||
|
.kind(rtnetlink::packet_route::route::RouteType::Unreachable)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A host with no IPv6 at all has nothing to block, and says so this way.
|
||||||
|
fn family_unsupported(err: &impl std::fmt::Display) -> bool {
|
||||||
|
let text = err.to_string();
|
||||||
|
text.contains("not supported") || text.contains("Address family")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the exit node's routing rules and its tables. Nothing there is
|
||||||
|
/// success: it also runs once at startup, for what a crashed run left.
|
||||||
|
async fn clear_exit_client() -> Result<(), OverlayError> {
|
||||||
|
with_netlink(|handle| async move { remove_exit_client(&handle).await }).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes this agent's exit rules of one address family.
|
||||||
|
async fn remove_exit_rules(
|
||||||
|
handle: &rtnetlink::Handle,
|
||||||
|
version: IpVersion,
|
||||||
|
) -> Result<(), OverlayError> {
|
||||||
|
let mut rules = handle.rule().get(version).execute();
|
||||||
|
let mut ours = Vec::new();
|
||||||
|
while let Some(rule) = rules
|
||||||
|
.try_next()
|
||||||
|
.await
|
||||||
|
.map_err(|err| OverlayError::Unavailable(format!("cannot read the routing rules: {err}")))?
|
||||||
|
{
|
||||||
|
if is_exit_rule(&rule) {
|
||||||
|
ours.push(rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for rule in ours {
|
||||||
|
match handle.rule().del(rule).execute().await {
|
||||||
|
Ok(()) => {}
|
||||||
|
// Gone already.
|
||||||
|
Err(err) if err.to_string().contains("No such") => {}
|
||||||
|
Err(err) => {
|
||||||
|
return Err(OverlayError::Unavailable(format!(
|
||||||
|
"cannot remove an exit node routing rule: {err}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_exit_client(handle: &rtnetlink::Handle) -> Result<(), OverlayError> {
|
||||||
|
remove_exit_rules(handle, IpVersion::V4).await?;
|
||||||
|
match handle.route().del(exit_default_route(None)).execute().await {
|
||||||
|
Ok(()) => {}
|
||||||
|
// "No such process": no such route, which is the goal.
|
||||||
|
Err(err) if err.to_string().contains("No such") => {}
|
||||||
|
Err(err) => {
|
||||||
|
return Err(OverlayError::Unavailable(format!(
|
||||||
|
"cannot remove the exit node default route: {err}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match remove_exit_rules(handle, IpVersion::V6).await {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(OverlayError::Unavailable(reason)) if family_unsupported(&reason) => return Ok(()),
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
match handle.route().del(exit_blocking_route_v6()).execute().await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) if err.to_string().contains("No such") || family_unsupported(&err) => Ok(()),
|
||||||
|
Err(err) => Err(OverlayError::Unavailable(format!(
|
||||||
|
"cannot remove the exit node IPv6 block: {err}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets up the using side of an exit node.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// ip route replace default dev <if> table 0x7473
|
||||||
|
/// ip rule add priority 5280 uidrange <uid>-<uid> lookup main
|
||||||
|
/// ip rule add priority 5290 lookup main suppress_prefixlength 0
|
||||||
|
/// ip rule add priority 5300 lookup 0x7473
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// and the same for IPv6 with `ip -6 route replace unreachable default table
|
||||||
|
/// 0x7473`, so that IPv6 is refused instead of leaving the ordinary way.
|
||||||
|
///
|
||||||
|
/// What an earlier call left is removed first, so repeating it is safe, and
|
||||||
|
/// the rules go in last, so a failure part way never leaves a rule that
|
||||||
|
/// sends traffic to a table with no route in it.
|
||||||
|
async fn set_exit_client(name: &str, uid: u32) -> Result<(), OverlayError> {
|
||||||
|
with_netlink(|handle| async move {
|
||||||
|
let index = link_index(&handle, name).await.ok_or_else(|| {
|
||||||
|
OverlayError::Unavailable(format!(
|
||||||
|
"interface `{name}` disappeared before the exit node routes could be added"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
remove_exit_client(&handle).await?;
|
||||||
|
let fail = |what: &str, err: rtnetlink::Error| {
|
||||||
|
OverlayError::Unavailable(format!("cannot add the exit node {what}: {err}"))
|
||||||
|
};
|
||||||
|
handle
|
||||||
|
.route()
|
||||||
|
.add(exit_default_route(Some(index)))
|
||||||
|
.replace()
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map_err(|err| fail("default route", err))?;
|
||||||
|
|
||||||
|
let mut bypass = handle
|
||||||
|
.rule()
|
||||||
|
.add()
|
||||||
|
.v4()
|
||||||
|
.table_id(MAIN_TABLE)
|
||||||
|
.action(RuleAction::ToTable)
|
||||||
|
.priority(EXIT_BYPASS_PRIORITY);
|
||||||
|
bypass
|
||||||
|
.message_mut()
|
||||||
|
.attributes
|
||||||
|
.push(RuleAttribute::UidRange(RuleUidRange {
|
||||||
|
start: uid,
|
||||||
|
end: uid,
|
||||||
|
}));
|
||||||
|
bypass
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map_err(|err| fail("rule for the agent's own traffic", err))?;
|
||||||
|
|
||||||
|
let mut specific = handle
|
||||||
|
.rule()
|
||||||
|
.add()
|
||||||
|
.v4()
|
||||||
|
.table_id(MAIN_TABLE)
|
||||||
|
.action(RuleAction::ToTable)
|
||||||
|
.priority(EXIT_SPECIFIC_PRIORITY);
|
||||||
|
specific
|
||||||
|
.message_mut()
|
||||||
|
.attributes
|
||||||
|
.push(RuleAttribute::SuppressPrefixLen(0));
|
||||||
|
specific
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map_err(|err| fail("rule for the specific routes", err))?;
|
||||||
|
|
||||||
|
handle
|
||||||
|
.rule()
|
||||||
|
.add()
|
||||||
|
.v4()
|
||||||
|
.table_id(EXIT_TABLE)
|
||||||
|
.action(RuleAction::ToTable)
|
||||||
|
.priority(EXIT_DEFAULT_PRIORITY)
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map_err(|err| fail("rule for the default route", err))?;
|
||||||
|
|
||||||
|
set_exit_block_v6(&handle, uid).await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The IPv6 half: an unreachable default for everything but the agent's own
|
||||||
|
/// connections and the specific routes, which keep working.
|
||||||
|
async fn set_exit_block_v6(handle: &rtnetlink::Handle, uid: u32) -> Result<(), OverlayError> {
|
||||||
|
let fail = |what: &str, err: rtnetlink::Error| {
|
||||||
|
OverlayError::Unavailable(format!("cannot add the exit node IPv6 {what}: {err}"))
|
||||||
|
};
|
||||||
|
if let Err(err) = handle
|
||||||
|
.route()
|
||||||
|
.add(exit_blocking_route_v6())
|
||||||
|
.replace()
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
// Without IPv6 there is nothing to leak.
|
||||||
|
return if family_unsupported(&err) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(fail("block", err))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bypass = handle
|
||||||
|
.rule()
|
||||||
|
.add()
|
||||||
|
.v6()
|
||||||
|
.table_id(MAIN_TABLE)
|
||||||
|
.action(RuleAction::ToTable)
|
||||||
|
.priority(EXIT_BYPASS_PRIORITY);
|
||||||
|
bypass
|
||||||
|
.message_mut()
|
||||||
|
.attributes
|
||||||
|
.push(RuleAttribute::UidRange(RuleUidRange {
|
||||||
|
start: uid,
|
||||||
|
end: uid,
|
||||||
|
}));
|
||||||
|
bypass
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map_err(|err| fail("rule for the agent's own traffic", err))?;
|
||||||
|
|
||||||
|
let mut specific = handle
|
||||||
|
.rule()
|
||||||
|
.add()
|
||||||
|
.v6()
|
||||||
|
.table_id(MAIN_TABLE)
|
||||||
|
.action(RuleAction::ToTable)
|
||||||
|
.priority(EXIT_SPECIFIC_PRIORITY);
|
||||||
|
specific
|
||||||
|
.message_mut()
|
||||||
|
.attributes
|
||||||
|
.push(RuleAttribute::SuppressPrefixLen(0));
|
||||||
|
specific
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map_err(|err| fail("rule for the specific routes", err))?;
|
||||||
|
|
||||||
|
handle
|
||||||
|
.rule()
|
||||||
|
.add()
|
||||||
|
.v6()
|
||||||
|
.table_id(EXIT_TABLE)
|
||||||
|
.action(RuleAction::ToTable)
|
||||||
|
.priority(EXIT_DEFAULT_PRIORITY)
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map_err(|err| fail("rule for the default route", err))
|
||||||
|
}
|
||||||
|
|
||||||
async fn configure_link(configure: &Configure) -> Result<(), OverlayError> {
|
async fn configure_link(configure: &Configure) -> Result<(), OverlayError> {
|
||||||
let name = configure.name.as_str();
|
let name = configure.name.as_str();
|
||||||
with_netlink(|handle| async move {
|
with_netlink(|handle| async move {
|
||||||
@@ -520,16 +1003,19 @@ async fn configure_link(configure: &Configure) -> Result<(), OverlayError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for cidr in &configure.add {
|
for cidr in &configure.add {
|
||||||
handle
|
let res = handle
|
||||||
.address()
|
.address()
|
||||||
.add(index, cidr.addr, cidr.prefix_len)
|
.add(index, cidr.addr, cidr.prefix_len)
|
||||||
.execute()
|
.execute()
|
||||||
.await
|
.await;
|
||||||
.map_err(|err| {
|
if let Err(err) = res {
|
||||||
OverlayError::Unavailable(format!(
|
let err_str = err.to_string();
|
||||||
|
if !err_str.contains("File exists") && !err_str.contains("os error 17") {
|
||||||
|
return Err(OverlayError::Unavailable(format!(
|
||||||
"cannot add {cidr} to interface `{name}`: {err}"
|
"cannot add {cidr} to interface `{name}`: {err}"
|
||||||
))
|
)));
|
||||||
})?;
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -593,6 +1079,67 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rule(priority: u32, table: u32, extra: Vec<RuleAttribute>) -> RuleMessage {
|
||||||
|
let mut message = RuleMessage::default();
|
||||||
|
message.attributes.push(RuleAttribute::Priority(priority));
|
||||||
|
if table > 255 {
|
||||||
|
message.attributes.push(RuleAttribute::Table(table));
|
||||||
|
} else {
|
||||||
|
message.header.table = table as u8;
|
||||||
|
}
|
||||||
|
message.attributes.extend(extra);
|
||||||
|
message
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_three_exit_rules_are_recognised_as_ours() {
|
||||||
|
let uid = RuleAttribute::UidRange(RuleUidRange { start: 5, end: 5 });
|
||||||
|
assert!(is_exit_rule(&rule(5280, 254, vec![uid.clone()])));
|
||||||
|
assert!(is_exit_rule(&rule(
|
||||||
|
5290,
|
||||||
|
254,
|
||||||
|
vec![RuleAttribute::SuppressPrefixLen(0)]
|
||||||
|
)));
|
||||||
|
assert!(is_exit_rule(&rule(5300, EXIT_TABLE, vec![])));
|
||||||
|
|
||||||
|
// Same priorities, somebody else's rules.
|
||||||
|
assert!(!is_exit_rule(&rule(5280, 254, vec![])));
|
||||||
|
assert!(!is_exit_rule(&rule(5290, 254, vec![])));
|
||||||
|
assert!(!is_exit_rule(&rule(5300, 52, vec![])));
|
||||||
|
assert!(!is_exit_rule(&rule(5270, 52, vec![])));
|
||||||
|
assert!(!is_exit_rule(&rule(5280, 100, vec![uid])));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_ipv6_default_of_the_exit_table_refuses_instead_of_routing() {
|
||||||
|
let route = exit_blocking_route_v6();
|
||||||
|
assert_eq!(route.header.destination_prefix_length, 0);
|
||||||
|
let debug = format!("{route:?}");
|
||||||
|
assert!(debug.contains("Unreachable"), "{debug}");
|
||||||
|
assert!(debug.contains(&format!("Table({EXIT_TABLE})")), "{debug}");
|
||||||
|
assert!(!debug.contains("Oif"), "{debug}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_host_without_ipv6_is_not_a_failure() {
|
||||||
|
assert!(family_unsupported(
|
||||||
|
&"Address family not supported by protocol"
|
||||||
|
));
|
||||||
|
assert!(family_unsupported(&"Operation not supported"));
|
||||||
|
assert!(!family_unsupported(&"Operation not permitted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_route_of_the_exit_table_names_the_table_and_the_interface() {
|
||||||
|
let route = exit_default_route(Some(7));
|
||||||
|
assert_eq!(route.header.destination_prefix_length, 0);
|
||||||
|
let debug = format!("{route:?}");
|
||||||
|
assert!(debug.contains(&format!("Table({EXIT_TABLE})")), "{debug}");
|
||||||
|
assert!(debug.contains("Oif(7)"), "{debug}");
|
||||||
|
// Deleting names no interface, so it matches whatever it was added on.
|
||||||
|
assert!(!format!("{:?}", exit_default_route(None)).contains("Oif"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_interface_that_is_not_a_tun_is_reported_foreign() {
|
fn an_interface_that_is_not_a_tun_is_reported_foreign() {
|
||||||
let message = LinkMessage::default();
|
let message = LinkMessage::default();
|
||||||
@@ -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
|
//! pure and tested on every platform, and only the execution is behind
|
||||||
//! [`InterfaceProvisioner`].
|
//! [`InterfaceProvisioner`].
|
||||||
//!
|
//!
|
||||||
//! Four implementations:
|
//! Five implementations:
|
||||||
//!
|
//!
|
||||||
//! * `NetlinkProvisioner` on Linux, which needs `CAP_NET_ADMIN`.
|
//! * `NetlinkProvisioner` on Linux, which needs `CAP_NET_ADMIN`.
|
||||||
//! * `WintunProvisioner` on Windows, a Wintun adapter configured with `netsh`,
|
//! * `WintunProvisioner` on Windows, a Wintun adapter configured with `netsh`,
|
||||||
//! which needs an elevated process.
|
//! 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.
|
//! * [`MockProvisioner`], an in-memory host used by the tests.
|
||||||
//! * [`UnsupportedProvisioner`] elsewhere, which fails with an explanation
|
//! * [`UnsupportedProvisioner`] elsewhere, which fails with an explanation
|
||||||
//! and a pointer at the manual route rather than pretending to work.
|
//! and a pointer at the manual route rather than pretending to work.
|
||||||
@@ -52,12 +54,19 @@ pub use mock::{MockHost, MockProvisioner};
|
|||||||
mod linux;
|
mod linux;
|
||||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||||
pub use linux::NetlinkProvisioner;
|
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"))]
|
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||||
mod windows;
|
mod windows;
|
||||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||||
pub use windows::WintunProvisioner;
|
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;
|
mod privilege;
|
||||||
pub use privilege::{Privilege, probe_net_admin};
|
pub use privilege::{Privilege, probe_net_admin};
|
||||||
|
|
||||||
+31
-7
@@ -15,7 +15,7 @@
|
|||||||
//! Grant it with:
|
//! Grant it with:
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```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
|
//! `+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.
|
/// How to obtain it, for a diagnostic.
|
||||||
pub fn how_to_grant(program: &str) -> String {
|
pub fn how_to_grant(program: &str) -> String {
|
||||||
format!(
|
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 \
|
and the agent manages its own interface. Without it, run with \
|
||||||
`--no-tun`: the tunnels still form, they just do not reach the \
|
`--no-tun`: the tunnels still form, they just do not reach the \
|
||||||
operating system."
|
operating system."
|
||||||
@@ -63,15 +63,20 @@ pub use linux_impl::{NetAdmin, probe_net_admin};
|
|||||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||||
pub use windows_impl::probe_net_admin;
|
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(
|
#[cfg(not(any(
|
||||||
all(feature = "tun-device", target_os = "linux"),
|
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;
|
pub use other_impl::probe_net_admin;
|
||||||
|
|
||||||
#[cfg(not(any(
|
#[cfg(not(any(
|
||||||
all(feature = "tun-device", target_os = "linux"),
|
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 {
|
mod other_impl {
|
||||||
use super::Privilege;
|
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"))]
|
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||||
mod linux_impl {
|
mod linux_impl {
|
||||||
use caps::{CapSet, Capability};
|
use caps::{CapSet, Capability};
|
||||||
@@ -147,7 +171,7 @@ mod linux_impl {
|
|||||||
caps::raise(None, CapSet::Effective, Capability::CAP_NET_ADMIN).map_err(|err| {
|
caps::raise(None, CapSet::Effective, Capability::CAP_NET_ADMIN).map_err(|err| {
|
||||||
OverlayError::Unavailable(format!(
|
OverlayError::Unavailable(format!(
|
||||||
"cannot raise CAP_NET_ADMIN: {err}. {}",
|
"cannot raise CAP_NET_ADMIN: {err}. {}",
|
||||||
Privilege::how_to_grant("tsunagi")
|
Privilege::how_to_grant("tsng")
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
Ok(Self { raised: true })
|
Ok(Self { raised: true })
|
||||||
@@ -180,8 +204,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_grant_instructions_name_the_program() {
|
fn the_grant_instructions_name_the_program() {
|
||||||
let text = Privilege::how_to_grant("/usr/local/bin/tsunagi");
|
let text = Privilege::how_to_grant("/usr/local/bin/tsng");
|
||||||
assert!(text.contains("setcap cap_net_admin+p /usr/local/bin/tsunagi"));
|
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");
|
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?;
|
set_mtu(&plan.name, mtu).await?;
|
||||||
}
|
}
|
||||||
for cidr in &changes.add {
|
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 {
|
if changes.bring_up {
|
||||||
// Best effort: a Wintun adapter with a session is up already, and a
|
// Best effort: a Wintun adapter with a session is up already, and a
|
||||||
@@ -40,9 +40,21 @@ pub struct Route {
|
|||||||
pub peer: EndpointId,
|
pub peer: EndpointId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A network's exit-node settings, as far as packets are concerned.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct ExitPolicy {
|
||||||
|
/// This agent offers itself as an exit node here: packets from members
|
||||||
|
/// for addresses outside every overlay range are handed to the host.
|
||||||
|
pub offer: bool,
|
||||||
|
/// The member all other traffic is sent to, if this device uses one.
|
||||||
|
pub via: Option<EndpointId>,
|
||||||
|
}
|
||||||
|
|
||||||
/// What one network contributes to the table.
|
/// What one network contributes to the table.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct NetworkRoutes {
|
pub struct NetworkRoutes {
|
||||||
|
/// Exit-node settings of this network.
|
||||||
|
pub exit: ExitPolicy,
|
||||||
/// Local broadcast policy and authenticated willing recipients.
|
/// Local broadcast policy and authenticated willing recipients.
|
||||||
pub broadcast: BroadcastPolicy,
|
pub broadcast: BroadcastPolicy,
|
||||||
/// The range this network allocates from, once it has agreed one.
|
/// The range this network allocates from, once it has agreed one.
|
||||||
@@ -153,6 +165,16 @@ impl RoutingTable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces only a network's exit-node settings, without changing addresses.
|
||||||
|
///
|
||||||
|
/// A network that has no entry yet (its range is not agreed) takes them
|
||||||
|
/// from [`NetworkRoutes::exit`] when it is added.
|
||||||
|
pub fn set_exit(&self, network: NetworkId, policy: ExitPolicy) {
|
||||||
|
if let Some(routes) = self.write().get_mut(&network) {
|
||||||
|
routes.exit = policy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Recognizes limited and configured subnet broadcasts before unicast lookup.
|
/// Recognizes limited and configured subnet broadcasts before unicast lookup.
|
||||||
pub fn is_broadcast(&self, destination: Ipv4Addr) -> bool {
|
pub fn is_broadcast(&self, destination: Ipv4Addr) -> bool {
|
||||||
self.broadcast.load().is_destination(destination)
|
self.broadcast.load().is_destination(destination)
|
||||||
@@ -181,6 +203,10 @@ impl RoutingTable {
|
|||||||
///
|
///
|
||||||
/// This agent's own address returns `None`: a packet for ourselves does
|
/// This agent's own address returns `None`: a packet for ourselves does
|
||||||
/// not go over a tunnel, and answering with a peer would send it to one.
|
/// not go over a tunnel, and answering with a peer would send it to one.
|
||||||
|
///
|
||||||
|
/// An address nobody holds, outside every overlay range and an ordinary
|
||||||
|
/// unicast destination, goes to this device's exit node when it has one:
|
||||||
|
/// that is what sending *all* traffic through a member means.
|
||||||
pub fn route(&self, destination: Ipv4Addr) -> Option<Route> {
|
pub fn route(&self, destination: Ipv4Addr) -> Option<Route> {
|
||||||
let networks = self.read();
|
let networks = self.read();
|
||||||
for (network, routes) in networks.iter() {
|
for (network, routes) in networks.iter() {
|
||||||
@@ -198,17 +224,92 @@ impl RoutingTable {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
// Inside an overlay range, an unowned address is nobody's, not the
|
||||||
|
// internet's.
|
||||||
|
if !is_internet_destination(destination) || in_any_range(&networks, destination) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
exit_via(&networks).map(|(network, peer)| Route { network, peer })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a peer may send from a source address.
|
/// Whether a peer may send from a source address.
|
||||||
|
///
|
||||||
|
/// A peer speaks only for the address the network agreed it holds. The
|
||||||
|
/// one exception is this device's exit node: what comes back from the
|
||||||
|
/// internet carries the internet's addresses, so from *that* peer any
|
||||||
|
/// source outside the overlay ranges is accepted.
|
||||||
pub fn may_send_from(&self, network: NetworkId, peer: EndpointId, source: Ipv4Addr) -> bool {
|
pub fn may_send_from(&self, network: NetworkId, peer: EndpointId, source: Ipv4Addr) -> bool {
|
||||||
self.read().get(&network).is_some_and(|routes| {
|
let networks = self.read();
|
||||||
routes
|
let Some(routes) = networks.get(&network) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if routes
|
||||||
.peers
|
.peers
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(address, holder)| *address == source && *holder == peer)
|
.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.
|
/// Whether an ordinary packet terminates on this host in this network.
|
||||||
@@ -230,6 +331,23 @@ impl RoutingTable {
|
|||||||
addresses
|
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.
|
/// Whether a range would collide with one another network already uses.
|
||||||
///
|
///
|
||||||
/// Asked before proposing a range rather than after: with one interface
|
/// Asked before proposing a range rather than after: with one interface
|
||||||
@@ -258,6 +376,31 @@ impl RoutingTable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an address is an ordinary unicast one: not this host, not a
|
||||||
|
/// multicast or broadcast group, not link-local, not unspecified.
|
||||||
|
fn is_internet_destination(address: Ipv4Addr) -> bool {
|
||||||
|
!(address.is_unspecified()
|
||||||
|
|| address.is_loopback()
|
||||||
|
|| address.is_multicast()
|
||||||
|
|| address.is_broadcast()
|
||||||
|
|| address.is_link_local())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an address lies in any network's range.
|
||||||
|
fn in_any_range(networks: &HashMap<NetworkId, NetworkRoutes>, address: Ipv4Addr) -> bool {
|
||||||
|
networks
|
||||||
|
.values()
|
||||||
|
.any(|routes| routes.range.is_some_and(|range| range.contains(address)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exit node in use, if any, from the lowest network that names one.
|
||||||
|
fn exit_via(networks: &HashMap<NetworkId, NetworkRoutes>) -> Option<(NetworkId, EndpointId)> {
|
||||||
|
networks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(network, routes)| Some((*network, routes.exit.via?)))
|
||||||
|
.min_by_key(|(network, _)| *network)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether two ranges share any address.
|
/// Whether two ranges share any address.
|
||||||
fn ranges_overlap(one: Ipv4Range, other: Ipv4Range) -> bool {
|
fn ranges_overlap(one: Ipv4Range, other: Ipv4Range) -> bool {
|
||||||
// A range contains the other's base, or the other way round. With
|
// A range contains the other's base, or the other way round. With
|
||||||
@@ -291,6 +434,7 @@ mod tests {
|
|||||||
|
|
||||||
fn routes() -> NetworkRoutes {
|
fn routes() -> NetworkRoutes {
|
||||||
NetworkRoutes {
|
NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: Default::default(),
|
broadcast: Default::default(),
|
||||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||||
local: Some(addr(1)),
|
local: Some(addr(1)),
|
||||||
@@ -374,6 +518,7 @@ mod tests {
|
|||||||
.set_network(
|
.set_network(
|
||||||
second,
|
second,
|
||||||
NetworkRoutes {
|
NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: Default::default(),
|
broadcast: Default::default(),
|
||||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||||
@@ -393,6 +538,66 @@ mod tests {
|
|||||||
assert_eq!(table.route(addr(2)).map(|route| route.network), Some(first));
|
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]
|
#[test]
|
||||||
fn a_range_can_be_asked_about_before_it_is_proposed() {
|
fn a_range_can_be_asked_about_before_it_is_proposed() {
|
||||||
// The point of asking first: an agent that claims an address it
|
// The point of asking first: an agent that claims an address it
|
||||||
@@ -419,6 +624,7 @@ mod tests {
|
|||||||
.set_network(
|
.set_network(
|
||||||
network("wide"),
|
network("wide"),
|
||||||
NetworkRoutes {
|
NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: Default::default(),
|
broadcast: Default::default(),
|
||||||
range: Some("10.0.0.0/8".parse().unwrap()),
|
range: Some("10.0.0.0/8".parse().unwrap()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -431,6 +637,7 @@ mod tests {
|
|||||||
.set_network(
|
.set_network(
|
||||||
network("narrow"),
|
network("narrow"),
|
||||||
NetworkRoutes {
|
NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: Default::default(),
|
broadcast: Default::default(),
|
||||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -473,6 +680,7 @@ mod tests {
|
|||||||
.set_network(
|
.set_network(
|
||||||
network("two"),
|
network("two"),
|
||||||
NetworkRoutes {
|
NetworkRoutes {
|
||||||
|
exit: Default::default(),
|
||||||
broadcast: Default::default(),
|
broadcast: Default::default(),
|
||||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||||
@@ -497,4 +705,233 @@ mod tests {
|
|||||||
assert_eq!(table.route(addr(2)), None);
|
assert_eq!(table.route(addr(2)), None);
|
||||||
assert_eq!(table.len(), 1, "the network is known, it just has nothing");
|
assert_eq!(table.len(), 1, "the network is known, it just has nothing");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn with_exit(offer: bool, via: Option<EndpointId>) -> NetworkRoutes {
|
||||||
|
NetworkRoutes {
|
||||||
|
exit: ExitPolicy { offer, via },
|
||||||
|
..routes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTERNET: Ipv4Addr = Ipv4Addr::new(93, 184, 216, 34);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_an_exit_node_the_internet_has_nowhere_to_go() {
|
||||||
|
let table = RoutingTable::new();
|
||||||
|
table.set_network(network("plain"), routes()).unwrap();
|
||||||
|
assert_eq!(table.route(INTERNET), None);
|
||||||
|
assert_eq!(table.exit_via(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_an_exit_node_every_ordinary_destination_goes_to_it() {
|
||||||
|
let table = RoutingTable::new();
|
||||||
|
let id = network("client");
|
||||||
|
table
|
||||||
|
.set_network(id, with_exit(false, Some(peer(2))))
|
||||||
|
.unwrap();
|
||||||
|
let exit = Route {
|
||||||
|
network: id,
|
||||||
|
peer: peer(2),
|
||||||
|
};
|
||||||
|
assert_eq!(table.route(INTERNET), Some(exit));
|
||||||
|
assert_eq!(table.route(Ipv4Addr::new(192, 168, 1, 10)), Some(exit));
|
||||||
|
assert_eq!(table.exit_via(), Some((id, peer(2))));
|
||||||
|
// A member's own address still goes to that member.
|
||||||
|
assert_eq!(
|
||||||
|
table.route(addr(3)),
|
||||||
|
Some(Route {
|
||||||
|
network: id,
|
||||||
|
peer: peer(3)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_exit_node_is_never_the_answer_for_what_is_not_the_internet() {
|
||||||
|
let table = RoutingTable::new();
|
||||||
|
table
|
||||||
|
.set_network(network("client"), with_exit(false, Some(peer(2))))
|
||||||
|
.unwrap();
|
||||||
|
// Ourselves, a free address inside the overlay range, and everything
|
||||||
|
// that is not an ordinary unicast destination.
|
||||||
|
for destination in [
|
||||||
|
addr(1),
|
||||||
|
addr(200),
|
||||||
|
Ipv4Addr::new(127, 0, 0, 1),
|
||||||
|
Ipv4Addr::new(224, 0, 0, 251),
|
||||||
|
Ipv4Addr::new(239, 255, 255, 250),
|
||||||
|
Ipv4Addr::BROADCAST,
|
||||||
|
Ipv4Addr::UNSPECIFIED,
|
||||||
|
Ipv4Addr::new(169, 254, 1, 1),
|
||||||
|
] {
|
||||||
|
assert_eq!(table.route(destination), None, "{destination}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn another_networks_range_is_not_the_internet_either() {
|
||||||
|
let table = RoutingTable::new();
|
||||||
|
table
|
||||||
|
.set_network(network("client"), with_exit(false, Some(peer(2))))
|
||||||
|
.unwrap();
|
||||||
|
table
|
||||||
|
.set_network(
|
||||||
|
network("other"),
|
||||||
|
NetworkRoutes {
|
||||||
|
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||||
|
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(table.route(Ipv4Addr::new(10, 99, 7, 7)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_exit_choices_resolve_to_the_lowest_network_id() {
|
||||||
|
let table = RoutingTable::new();
|
||||||
|
let (a, b) = (network("one"), network("two"));
|
||||||
|
table
|
||||||
|
.set_network(a, with_exit(false, Some(peer(2))))
|
||||||
|
.unwrap();
|
||||||
|
table
|
||||||
|
.set_network(
|
||||||
|
b,
|
||||||
|
NetworkRoutes {
|
||||||
|
exit: ExitPolicy {
|
||||||
|
offer: false,
|
||||||
|
via: Some(peer(9)),
|
||||||
|
},
|
||||||
|
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (chosen, _) = table.exit_via().unwrap();
|
||||||
|
assert_eq!(chosen, a.min(b));
|
||||||
|
assert_eq!(table.route(INTERNET).unwrap().network, a.min(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_exit_peer_may_send_from_any_internet_address_but_nobody_else_may() {
|
||||||
|
let table = RoutingTable::new();
|
||||||
|
let id = network("client");
|
||||||
|
table
|
||||||
|
.set_network(id, with_exit(false, Some(peer(2))))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// What comes back from the internet carries the internet's addresses.
|
||||||
|
assert!(table.may_send_from(id, peer(2), INTERNET));
|
||||||
|
// Another member still speaks only for its own address.
|
||||||
|
assert!(!table.may_send_from(id, peer(3), INTERNET));
|
||||||
|
assert!(table.may_send_from(id, peer(3), addr(3)));
|
||||||
|
// The exit peer cannot pass itself off as somebody else in the
|
||||||
|
// overlay, nor as this agent, nor as something that is not a source.
|
||||||
|
assert!(!table.may_send_from(id, peer(2), addr(3)));
|
||||||
|
assert!(!table.may_send_from(id, peer(2), addr(1)));
|
||||||
|
assert!(!table.may_send_from(id, peer(2), addr(200)));
|
||||||
|
for source in [
|
||||||
|
Ipv4Addr::LOCALHOST,
|
||||||
|
Ipv4Addr::UNSPECIFIED,
|
||||||
|
Ipv4Addr::new(224, 0, 0, 1),
|
||||||
|
Ipv4Addr::BROADCAST,
|
||||||
|
] {
|
||||||
|
assert!(!table.may_send_from(id, peer(2), source), "{source}");
|
||||||
|
}
|
||||||
|
// Without choosing an exit node nobody gets the exception.
|
||||||
|
let plain = RoutingTable::new();
|
||||||
|
plain.set_network(id, routes()).unwrap();
|
||||||
|
assert!(!plain.may_send_from(id, peer(2), INTERNET));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_exit_node_takes_a_members_internet_packets_only_while_it_offers() {
|
||||||
|
let table = RoutingTable::new();
|
||||||
|
let id = network("server");
|
||||||
|
table.set_network(id, with_exit(false, None)).unwrap();
|
||||||
|
assert!(!table.accepts_exit_traffic(id, INTERNET));
|
||||||
|
|
||||||
|
table.set_exit(
|
||||||
|
id,
|
||||||
|
ExitPolicy {
|
||||||
|
offer: true,
|
||||||
|
via: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(table.accepts_exit_traffic(id, INTERNET));
|
||||||
|
assert!(table.accepts_exit_traffic(id, Ipv4Addr::new(192, 168, 1, 10)));
|
||||||
|
// Never for another member, for this agent, a free overlay address,
|
||||||
|
// or anything that is not an ordinary unicast destination.
|
||||||
|
for destination in [
|
||||||
|
addr(1),
|
||||||
|
addr(2),
|
||||||
|
addr(200),
|
||||||
|
Ipv4Addr::LOCALHOST,
|
||||||
|
Ipv4Addr::new(224, 0, 0, 251),
|
||||||
|
Ipv4Addr::BROADCAST,
|
||||||
|
Ipv4Addr::UNSPECIFIED,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!table.accepts_exit_traffic(id, destination),
|
||||||
|
"{destination}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// An unknown network offers nothing.
|
||||||
|
assert!(!table.accepts_exit_traffic(network("elsewhere"), INTERNET));
|
||||||
|
|
||||||
|
table.set_exit(id, ExitPolicy::default());
|
||||||
|
assert!(!table.accepts_exit_traffic(id, INTERNET));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offers_are_listed_by_network_with_the_range_each_agreed() {
|
||||||
|
let table = RoutingTable::new();
|
||||||
|
let (a, b) = (network("a"), network("b"));
|
||||||
|
table.set_network(a, with_exit(true, None)).unwrap();
|
||||||
|
table
|
||||||
|
.set_network(
|
||||||
|
b,
|
||||||
|
NetworkRoutes {
|
||||||
|
exit: ExitPolicy {
|
||||||
|
offer: true,
|
||||||
|
via: None,
|
||||||
|
},
|
||||||
|
// No range agreed yet: nothing to masquerade.
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
table.exit_offers(),
|
||||||
|
vec![(a, "10.13.37.0/24".parse().unwrap())]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
table.exit_settings(a),
|
||||||
|
Some((
|
||||||
|
ExitPolicy {
|
||||||
|
offer: true,
|
||||||
|
via: None
|
||||||
|
},
|
||||||
|
Some("10.13.37.0/24".parse().unwrap())
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(table.exit_settings(network("none")), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setting_exit_for_a_network_with_no_entry_is_remembered_by_the_caller() {
|
||||||
|
// The runtime keeps the policy and passes it with the network when
|
||||||
|
// its range is agreed; a setter for a network the table does not know
|
||||||
|
// yet changes nothing.
|
||||||
|
let table = RoutingTable::new();
|
||||||
|
table.set_exit(
|
||||||
|
network("later"),
|
||||||
|
ExitPolicy {
|
||||||
|
offer: true,
|
||||||
|
via: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(table.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +109,14 @@ pub trait TunFactory: Send + Sync + std::fmt::Debug + 'static {
|
|||||||
Box::pin(async move { Ok(()) })
|
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.
|
/// Removes an interface this factory created.
|
||||||
///
|
///
|
||||||
/// Runs on the teardown path, so it reports rather than fails: there is
|
/// 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
|
/// enough here: the agent chose the address, so anything else holding it is a
|
||||||
/// problem in its own right.
|
/// problem in its own right.
|
||||||
pub fn address_is_local(address: std::net::IpAddr) -> bool {
|
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.
|
/// Creates [`MemoryTun`] devices.
|
||||||
@@ -385,7 +417,15 @@ mod system {
|
|||||||
/// across this call, and such a guard must not span an `await` because
|
/// across this call, and such a guard must not span an `await` because
|
||||||
/// Linux capabilities are per thread.
|
/// Linux capabilities are per thread.
|
||||||
pub(crate) fn open_tun(request: &TunRequest) -> Result<Arc<dyn TunDevice>, OverlayError> {
|
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();
|
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);
|
config.tun_name(&request.name);
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
config.platform_config(|platform| {
|
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))?;
|
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);
|
let (reader, writer) = tokio::io::split(device);
|
||||||
Ok(Arc::new(SystemTun {
|
Ok(Arc::new(SystemTun {
|
||||||
name: request.name.clone(),
|
name,
|
||||||
mtu: request.mtu,
|
mtu: request.mtu,
|
||||||
reader: Mutex::new(reader),
|
reader: Mutex::new(reader),
|
||||||
writer: Mutex::new(writer),
|
writer: Mutex::new(writer),
|
||||||
@@ -432,12 +483,15 @@ mod system {
|
|||||||
if denied {
|
if denied {
|
||||||
let hint = if cfg!(target_os = "windows") {
|
let hint = if cfg!(target_os = "windows") {
|
||||||
"Open PowerShell or Command Prompt with 'Run as administrator' and \
|
"Open PowerShell or Command Prompt with 'Run as administrator' and \
|
||||||
start `tsunagi up` there."
|
start `tsng up` there."
|
||||||
} else if cfg!(target_os = "linux") {
|
} else if cfg!(target_os = "linux") {
|
||||||
"Start `tsunagi up` as root or grant CAP_NET_ADMIN with \
|
"Start `tsng up` as root or grant CAP_NET_ADMIN with \
|
||||||
`sudo setcap cap_net_admin+p /path/to/tsunagi`."
|
`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 {
|
} 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!(
|
return OverlayError::Unavailable(format!(
|
||||||
"permission denied while creating the TUN interface `{name}`. \
|
"permission denied while creating the TUN interface `{name}`. \
|
||||||
@@ -455,7 +509,7 @@ mod system {
|
|||||||
fn open_hint() -> &'static str {
|
fn open_hint() -> &'static str {
|
||||||
#[cfg(target_os = "linux")]
|
#[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."
|
or run with `--no-tun` to keep the tunnels off the operating system."
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
@@ -464,7 +518,12 @@ mod system {
|
|||||||
run as Administrator with the DLL beside the executable, or run with `--no-tun` \
|
run as Administrator with the DLL beside the executable, or run with `--no-tun` \
|
||||||
to keep the tunnels off the operating system."
|
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."
|
"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();
|
let message = open_error("tsun0", error).to_string();
|
||||||
assert!(message.contains("permission denied"), "{message}");
|
assert!(message.contains("permission denied"), "{message}");
|
||||||
assert!(message.contains("tsun0"), "{message}");
|
assert!(message.contains("tsun0"), "{message}");
|
||||||
assert!(message.contains("tsunagi up"), "{message}");
|
assert!(message.contains("tsng up"), "{message}");
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
assert!(message.contains("Run as administrator"), "{message}");
|
assert!(message.contains("Run as administrator"), "{message}");
|
||||||
#[cfg(target_os = "linux")]
|
#[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
|
/// The version in the ALPN is the wire-compatibility version of the control
|
||||||
/// protocol. It is independent of the network identity scheme version, so
|
/// protocol. It is independent of the network identity scheme version, so
|
||||||
/// bumping it must not change any existing [`crate::NetworkId`].
|
/// bumping it must not change any existing [`crate::NetworkId`].
|
||||||
pub const ALPN: &[u8] = b"tsunagi/ctrl/3";
|
pub const ALPN: &[u8] = b"tsunagi/ctrl/4";
|
||||||
|
|
||||||
/// ALPN of the tsunagi data plane.
|
/// ALPN of the tsunagi data plane.
|
||||||
///
|
///
|
||||||
@@ -41,7 +41,7 @@ pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
|
|||||||
pub const MAX_SIGNATURE_LEN: usize = 64;
|
pub const MAX_SIGNATURE_LEN: usize = 64;
|
||||||
|
|
||||||
/// Control protocol version carried inside the handshake.
|
/// Control protocol version carried inside the handshake.
|
||||||
pub const PROTOCOL_VERSION: u16 = 3;
|
pub const PROTOCOL_VERSION: u16 = 4;
|
||||||
|
|
||||||
/// First message of the handshake, sent by the initiator.
|
/// First message of the handshake, sent by the initiator.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -75,6 +75,11 @@ pub struct AuthProof {
|
|||||||
pub struct Announcement {
|
pub struct Announcement {
|
||||||
/// Local opt-in to receive LAN broadcasts in this network.
|
/// Local opt-in to receive LAN broadcasts in this network.
|
||||||
pub broadcast: bool,
|
pub broadcast: bool,
|
||||||
|
/// This agent offers itself as an exit node in this network: members may
|
||||||
|
/// send all their internet traffic through it. Off unless its owner
|
||||||
|
/// turned it on, and only said once the rules that make it work are in
|
||||||
|
/// place.
|
||||||
|
pub exit_node: bool,
|
||||||
/// Human-readable hostname. A mutable binding, not an identity.
|
/// Human-readable hostname. A mutable binding, not an identity.
|
||||||
pub hostname: String,
|
pub hostname: String,
|
||||||
/// Announced IP plugin capabilities. Opaque to the core.
|
/// Announced IP plugin capabilities. Opaque to the core.
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -254,6 +254,18 @@ impl Storage {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persists whether this agent offers itself as an exit node in a network.
|
||||||
|
pub async fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||||
|
self.with_state(move |state| state.set_exit_node(network_id, enabled))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists the member this device sends its internet traffic through.
|
||||||
|
pub async fn set_exit_via(&self, network_id: NetworkId, via: Option<[u8; 32]>) -> Result<()> {
|
||||||
|
self.with_state(move |state| state.set_exit_via(network_id, via.as_ref()))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Updates the auto-start flag of a network.
|
/// Updates the auto-start flag of a network.
|
||||||
pub async fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
|
pub async fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
|
||||||
self.with_state(move |state| state.set_auto_start(network_id, auto_start))
|
self.with_state(move |state| state.set_auto_start(network_id, auto_start))
|
||||||
@@ -264,7 +276,8 @@ impl Storage {
|
|||||||
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
||||||
self.with_state(move |state| {
|
self.with_state(move |state| {
|
||||||
state.remove_network(network_id)?;
|
state.remove_network(network_id)?;
|
||||||
state.forget_signed_records(network_id)
|
state.forget_signed_records(network_id)?;
|
||||||
|
state.forget_peer_hostnames(network_id)
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
self.with_cache((), move |cache| cache.forget_network(network_id))
|
self.with_cache((), move |cache| cache.forget_network(network_id))
|
||||||
@@ -283,6 +296,26 @@ impl Storage {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remembers the hostname a member announced, so it can still be named
|
||||||
|
/// once it is away.
|
||||||
|
pub async fn remember_peer_hostname(
|
||||||
|
&self,
|
||||||
|
network_id: NetworkId,
|
||||||
|
endpoint_id: [u8; 32],
|
||||||
|
hostname: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.with_state(move |state| {
|
||||||
|
state.remember_peer_hostname(network_id, &endpoint_id, &hostname)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hostnames members of a network last announced.
|
||||||
|
pub async fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
|
||||||
|
self.with_state(move |state| state.peer_hostnames(network_id))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Records an address hint. Failures are non-fatal.
|
/// Records an address hint. Failures are non-fatal.
|
||||||
pub async fn record_hint(
|
pub async fn record_hint(
|
||||||
&self,
|
&self,
|
||||||
@@ -21,7 +21,7 @@ use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
|
|||||||
use crate::state::{RecordBody, SignedRecord};
|
use crate::state::{RecordBody, SignedRecord};
|
||||||
|
|
||||||
/// Schema version written by this build.
|
/// Schema version written by this build.
|
||||||
pub const SCHEMA_VERSION: i64 = 4;
|
pub const SCHEMA_VERSION: i64 = 6;
|
||||||
|
|
||||||
/// Key of the stored hostname setting.
|
/// Key of the stored hostname setting.
|
||||||
const SETTING_HOSTNAME: &str = "hostname";
|
const SETTING_HOSTNAME: &str = "hostname";
|
||||||
@@ -42,6 +42,10 @@ pub struct StoredNetwork {
|
|||||||
pub auto_start: bool,
|
pub auto_start: bool,
|
||||||
/// Local broadcast participation, enabled unless explicitly disabled.
|
/// Local broadcast participation, enabled unless explicitly disabled.
|
||||||
pub broadcast: bool,
|
pub broadcast: bool,
|
||||||
|
/// This agent offers itself as an exit node here. Off unless turned on.
|
||||||
|
pub exit_node: bool,
|
||||||
|
/// The member this device sends all its internet traffic through, if any.
|
||||||
|
pub exit_via: Option<[u8; 32]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The mandatory state store.
|
/// The mandatory state store.
|
||||||
@@ -191,13 +195,45 @@ impl StateStore {
|
|||||||
PRAGMA user_version = 4;
|
PRAGMA user_version = 4;
|
||||||
COMMIT;").map_err(|err| self.corrupt(format!("cannot migrate schema to 4: {err}")))?;
|
COMMIT;").map_err(|err| self.corrupt(format!("cannot migrate schema to 4: {err}")))?;
|
||||||
}
|
}
|
||||||
|
if found < 5 {
|
||||||
|
self.conn
|
||||||
|
.execute_batch(
|
||||||
|
"BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS peer_hostnames (
|
||||||
|
network_id BLOB NOT NULL,
|
||||||
|
endpoint_id BLOB NOT NULL,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (network_id, endpoint_id)
|
||||||
|
);
|
||||||
|
PRAGMA user_version = 5;
|
||||||
|
COMMIT;",
|
||||||
|
)
|
||||||
|
.map_err(|err| self.corrupt(format!("cannot migrate schema to 5: {err}")))?;
|
||||||
|
}
|
||||||
|
if found < 6 {
|
||||||
|
self.conn
|
||||||
|
.execute_batch(
|
||||||
|
"BEGIN;
|
||||||
|
ALTER TABLE networks ADD COLUMN exit_node INTEGER NOT NULL DEFAULT 0 CHECK (exit_node IN (0,1));
|
||||||
|
ALTER TABLE networks ADD COLUMN exit_via BLOB;
|
||||||
|
PRAGMA user_version = 6;
|
||||||
|
COMMIT;",
|
||||||
|
)
|
||||||
|
.map_err(|err| self.corrupt(format!("cannot migrate schema to 6: {err}")))?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirms the expected tables exist, so that a truncated or foreign
|
/// Confirms the expected tables exist, so that a truncated or foreign
|
||||||
/// database is reported rather than used.
|
/// database is reported rather than used.
|
||||||
fn verify_shape(&self) -> Result<()> {
|
fn verify_shape(&self) -> Result<()> {
|
||||||
for table in ["device_identity", "networks", "settings", "signed_records"] {
|
for table in [
|
||||||
|
"device_identity",
|
||||||
|
"networks",
|
||||||
|
"settings",
|
||||||
|
"signed_records",
|
||||||
|
"peer_hostnames",
|
||||||
|
] {
|
||||||
let present: Option<String> = self
|
let present: Option<String> = self
|
||||||
.conn
|
.conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -430,11 +466,56 @@ impl StateStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Saves whether this agent offers itself as an exit node in a network.
|
||||||
|
pub fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||||
|
let changed = self
|
||||||
|
.conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE networks SET exit_node = ?2 WHERE network_id = ?1",
|
||||||
|
params![network_id.as_bytes().as_slice(), enabled as i64],
|
||||||
|
)
|
||||||
|
.map_err(|err| Error::Storage(format!("cannot update exit node offer: {err}")))?;
|
||||||
|
if changed == 0 {
|
||||||
|
return Err(Error::NetworkUnknown(network_id));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves which member this device sends its internet traffic through.
|
||||||
|
///
|
||||||
|
/// One device has one default route, so choosing an exit node in one
|
||||||
|
/// network clears the choice in every other.
|
||||||
|
pub fn set_exit_via(&self, network_id: NetworkId, via: Option<&[u8; 32]>) -> Result<()> {
|
||||||
|
let tx = self
|
||||||
|
.conn
|
||||||
|
.unchecked_transaction()
|
||||||
|
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
||||||
|
let changed = tx
|
||||||
|
.execute(
|
||||||
|
"UPDATE networks SET exit_via = ?2 WHERE network_id = ?1",
|
||||||
|
params![network_id.as_bytes().as_slice(), via.map(|v| v.as_slice())],
|
||||||
|
)
|
||||||
|
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
||||||
|
if changed == 0 {
|
||||||
|
return Err(Error::NetworkUnknown(network_id));
|
||||||
|
}
|
||||||
|
if via.is_some() {
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE networks SET exit_via = NULL WHERE network_id != ?1",
|
||||||
|
params![network_id.as_bytes().as_slice()],
|
||||||
|
)
|
||||||
|
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
||||||
|
}
|
||||||
|
tx.commit()
|
||||||
|
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Lists every configured network.
|
/// Lists every configured network.
|
||||||
pub fn list_networks(&self) -> Result<Vec<StoredNetwork>> {
|
pub fn list_networks(&self) -> Result<Vec<StoredNetwork>> {
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare("SELECT network_id, name, secret, auto_start, broadcast FROM networks ORDER BY name")
|
.prepare("SELECT network_id, name, secret, auto_start, broadcast, exit_node, exit_via FROM networks ORDER BY name")
|
||||||
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
@@ -442,13 +523,21 @@ impl StateStore {
|
|||||||
let name: String = row.get(1)?;
|
let name: String = row.get(1)?;
|
||||||
let secret: Vec<u8> = row.get(2)?;
|
let secret: Vec<u8> = row.get(2)?;
|
||||||
let auto_start: i64 = row.get(3)?;
|
let auto_start: i64 = row.get(3)?;
|
||||||
Ok((id, name, secret, auto_start != 0, row.get::<_, bool>(4)?))
|
Ok((
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
secret,
|
||||||
|
auto_start != 0,
|
||||||
|
row.get::<_, bool>(4)?,
|
||||||
|
row.get::<_, bool>(5)?,
|
||||||
|
row.get::<_, Option<Vec<u8>>>(6)?,
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
||||||
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let (id, name, secret, auto_start, broadcast) =
|
let (id, name, secret, auto_start, broadcast, exit_node, exit_via) =
|
||||||
row.map_err(|err| Error::Storage(format!("cannot read network row: {err}")))?;
|
row.map_err(|err| Error::Storage(format!("cannot read network row: {err}")))?;
|
||||||
let id: [u8; 32] = id
|
let id: [u8; 32] = id
|
||||||
.as_slice()
|
.as_slice()
|
||||||
@@ -460,6 +549,8 @@ impl StateStore {
|
|||||||
secret: NetworkSecret::from_bytes(secret)?,
|
secret: NetworkSecret::from_bytes(secret)?,
|
||||||
auto_start,
|
auto_start,
|
||||||
broadcast,
|
broadcast,
|
||||||
|
exit_node,
|
||||||
|
exit_via: exit_via.and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
@@ -635,6 +726,61 @@ impl StateStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remembers the hostname a member last announced.
|
||||||
|
pub fn remember_peer_hostname(
|
||||||
|
&self,
|
||||||
|
network_id: NetworkId,
|
||||||
|
endpoint_id: &[u8; 32],
|
||||||
|
hostname: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO peer_hostnames (network_id, endpoint_id, hostname)
|
||||||
|
VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT(network_id, endpoint_id) DO UPDATE SET hostname = excluded.hostname",
|
||||||
|
params![
|
||||||
|
network_id.as_bytes().as_slice(),
|
||||||
|
endpoint_id.as_slice(),
|
||||||
|
hostname
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|err| Error::Storage(format!("cannot remember a hostname: {err}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last hostname every member of a network announced.
|
||||||
|
pub fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare("SELECT endpoint_id, hostname FROM peer_hostnames WHERE network_id = ?1")
|
||||||
|
.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![network_id.as_bytes().as_slice()], |row| {
|
||||||
|
Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})
|
||||||
|
.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let (id, hostname) =
|
||||||
|
row.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||||
|
if let Ok(id) = <[u8; 32]>::try_from(id.as_slice()) {
|
||||||
|
out.push((id, hostname));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgets every remembered hostname of a network.
|
||||||
|
pub fn forget_peer_hostnames(&self, network_id: NetworkId) -> Result<()> {
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
"DELETE FROM peer_hostnames WHERE network_id = ?1",
|
||||||
|
params![network_id.as_bytes().as_slice()],
|
||||||
|
)
|
||||||
|
.map_err(|err| Error::Storage(format!("cannot forget hostnames: {err}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads an arbitrary setting.
|
/// Reads an arbitrary setting.
|
||||||
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
||||||
self.conn
|
self.conn
|
||||||
@@ -698,6 +844,60 @@ mod broadcast_storage_tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::identity::NetworkKeys;
|
use crate::identity::NetworkKeys;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exit_node_choices_are_off_by_default_and_one_choice_clears_the_others() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = StateStore::open(dir.path().join("state.sqlite")).unwrap();
|
||||||
|
let secret = NetworkSecret::from_bytes([5; 32]).unwrap();
|
||||||
|
let make = |name: &str| {
|
||||||
|
let name = NetworkName::new(name).unwrap();
|
||||||
|
let id = NetworkKeys::derive(&name, &secret).network_id();
|
||||||
|
store.upsert_network(id, &name, &secret, true).unwrap();
|
||||||
|
id
|
||||||
|
};
|
||||||
|
let (a, b) = (make("exit-a"), make("exit-b"));
|
||||||
|
let listed = || store.list_networks().unwrap();
|
||||||
|
assert!(
|
||||||
|
listed()
|
||||||
|
.iter()
|
||||||
|
.all(|n| !n.exit_node && n.exit_via.is_none())
|
||||||
|
);
|
||||||
|
|
||||||
|
store.set_exit_node(a, true).unwrap();
|
||||||
|
store.set_exit_via(a, Some(&[7; 32])).unwrap();
|
||||||
|
store.set_exit_via(b, Some(&[8; 32])).unwrap();
|
||||||
|
let by_id = |id: NetworkId| listed().into_iter().find(|n| n.network_id == id).unwrap();
|
||||||
|
assert!(by_id(a).exit_node, "offering is per network and kept");
|
||||||
|
assert_eq!(by_id(a).exit_via, None, "one default route: a was cleared");
|
||||||
|
assert_eq!(by_id(b).exit_via, Some([8; 32]));
|
||||||
|
|
||||||
|
store.set_exit_via(b, None).unwrap();
|
||||||
|
assert_eq!(by_id(b).exit_via, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_remembered_hostname_survives_a_restart_and_follows_the_latest_name() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("state.sqlite");
|
||||||
|
let name = NetworkName::new("hostnames").unwrap();
|
||||||
|
let secret = NetworkSecret::from_bytes([9; 32]).unwrap();
|
||||||
|
let id = NetworkKeys::derive(&name, &secret).network_id();
|
||||||
|
let peer = [3u8; 32];
|
||||||
|
|
||||||
|
let store = StateStore::open(&path).unwrap();
|
||||||
|
store.remember_peer_hostname(id, &peer, "laptop").unwrap();
|
||||||
|
store.remember_peer_hostname(id, &peer, "laptop-2").unwrap();
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let store = StateStore::open(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.peer_hostnames(id).unwrap(),
|
||||||
|
vec![(peer, "laptop-2".to_string())]
|
||||||
|
);
|
||||||
|
store.forget_peer_hostnames(id).unwrap();
|
||||||
|
assert!(store.peer_hostnames(id).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
|
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -714,7 +914,12 @@ mod broadcast_storage_tests {
|
|||||||
store.set_hostname("old-host").unwrap();
|
store.set_hostname("old-host").unwrap();
|
||||||
store
|
store
|
||||||
.conn
|
.conn
|
||||||
.execute_batch("ALTER TABLE networks DROP COLUMN broadcast; PRAGMA user_version=3;")
|
.execute_batch(
|
||||||
|
"ALTER TABLE networks DROP COLUMN broadcast; \
|
||||||
|
ALTER TABLE networks DROP COLUMN exit_node; \
|
||||||
|
ALTER TABLE networks DROP COLUMN exit_via; \
|
||||||
|
PRAGMA user_version=3;",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
drop(store);
|
drop(store);
|
||||||
let store = StateStore::open(&path).unwrap();
|
let store = StateStore::open(&path).unwrap();
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,173 @@
|
|||||||
|
//! Scenario 1: deterministic network identity.
|
||||||
|
//!
|
||||||
|
//! The same name and secret must yield the same network space on different
|
||||||
|
//! devices, and nothing else — hostname, device key, restart — may change it.
|
||||||
|
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
|
|
||||||
|
use tsunagi::Agent;
|
||||||
|
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||||
|
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||||
|
use tsunagi::testing::{TestAgent, config_with, network};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derivation_is_a_pure_function_of_name_and_secret() {
|
||||||
|
let name = NetworkName::new("home").unwrap();
|
||||||
|
let other = NetworkName::new("Home").unwrap();
|
||||||
|
let secret = NetworkSecret::generate();
|
||||||
|
let other_secret = NetworkSecret::generate();
|
||||||
|
|
||||||
|
let a = NetworkKeys::derive(&name, &secret);
|
||||||
|
let b = NetworkKeys::derive(&name, &secret);
|
||||||
|
assert_eq!(a.network_id(), b.network_id());
|
||||||
|
assert_eq!(a.discovery_key(), b.discovery_key());
|
||||||
|
assert_eq!(a.descriptor(), b.descriptor());
|
||||||
|
|
||||||
|
// A different name is a different space. Names are used verbatim, so case
|
||||||
|
// matters.
|
||||||
|
assert_ne!(
|
||||||
|
a.network_id(),
|
||||||
|
NetworkKeys::derive(&other, &secret).network_id()
|
||||||
|
);
|
||||||
|
// A different secret is a different space.
|
||||||
|
assert_ne!(
|
||||||
|
a.network_id(),
|
||||||
|
NetworkKeys::derive(&name, &other_secret).network_id()
|
||||||
|
);
|
||||||
|
// Separated key material: the discovery key is not the network id.
|
||||||
|
assert_ne!(a.network_id().as_bytes(), a.discovery_key().as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn descriptor_carries_no_creator_time_or_secret() {
|
||||||
|
let name = NetworkName::new("shared").unwrap();
|
||||||
|
let secret = NetworkSecret::generate();
|
||||||
|
let first = NetworkKeys::derive(&name, &secret).descriptor();
|
||||||
|
let second = NetworkKeys::derive(&name, &secret).descriptor();
|
||||||
|
|
||||||
|
// Two independently built descriptors are byte identical: no random
|
||||||
|
// creator id, no creation timestamp, no owner signature.
|
||||||
|
assert_eq!(first.to_canonical_bytes(), second.to_canonical_bytes());
|
||||||
|
|
||||||
|
let encoded = first.to_canonical_bytes();
|
||||||
|
let secret_bytes = secret.encode();
|
||||||
|
assert!(
|
||||||
|
!encoded
|
||||||
|
.windows(secret_bytes.len())
|
||||||
|
.any(|window| window == secret_bytes.as_bytes()),
|
||||||
|
"the secret must never appear in the public descriptor"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_are_validated_not_silently_normalised() {
|
||||||
|
assert!(NetworkName::new("").is_err());
|
||||||
|
assert!(NetworkName::new(" home").is_err(), "must not be trimmed");
|
||||||
|
assert!(NetworkName::new("home ").is_err(), "must not be trimmed");
|
||||||
|
assert!(NetworkName::new("ho\nme").is_err());
|
||||||
|
assert!(NetworkName::new("a".repeat(65)).is_err());
|
||||||
|
assert_eq!(NetworkName::new("home").unwrap().as_str(), "home");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secrets_are_not_truncated_or_normalised() {
|
||||||
|
let secret = NetworkSecret::generate();
|
||||||
|
let text = secret.encode();
|
||||||
|
let round_tripped = NetworkSecret::decode(&text).unwrap();
|
||||||
|
assert_eq!(secret, round_tripped);
|
||||||
|
|
||||||
|
// Short secrets are rejected rather than stretched.
|
||||||
|
assert!(NetworkSecret::from_bytes(vec![7u8; 15]).is_err());
|
||||||
|
assert!(NetworkSecret::from_bytes(vec![7u8; 16]).is_ok());
|
||||||
|
|
||||||
|
// Debug output must not leak the secret.
|
||||||
|
let rendered = format!("{secret:?}");
|
||||||
|
assert_eq!(rendered, "NetworkSecret(<redacted>)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn different_devices_and_hostnames_agree_on_the_network_id() {
|
||||||
|
let discovery = SharedMemoryDiscovery::new();
|
||||||
|
let (name, secret) = network("agreement");
|
||||||
|
|
||||||
|
let a = TestAgent::spawn_with(|cfg| cfg.with_hostname("alpha"), &discovery)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let b = TestAgent::spawn_with(|cfg| cfg.with_hostname("beta"), &discovery)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
a.agent.endpoint_id(),
|
||||||
|
b.agent.endpoint_id(),
|
||||||
|
"different devices must have different endpoint ids"
|
||||||
|
);
|
||||||
|
assert_eq!(a.agent.hostname(), "alpha");
|
||||||
|
assert_eq!(b.agent.hostname(), "beta");
|
||||||
|
|
||||||
|
let id_a = a.agent.join_network(&name, &secret).await.unwrap();
|
||||||
|
let id_b = b.agent.join_network(&name, &secret).await.unwrap();
|
||||||
|
assert_eq!(id_a, id_b);
|
||||||
|
|
||||||
|
a.agent.shutdown().await;
|
||||||
|
b.agent.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn restart_keeps_the_device_and_network_identity() {
|
||||||
|
let discovery = SharedMemoryDiscovery::new();
|
||||||
|
let (name, secret) = network("stable");
|
||||||
|
|
||||||
|
let first = TestAgent::spawn(&discovery).await.unwrap();
|
||||||
|
let device_id = first.agent.endpoint_id();
|
||||||
|
let network_id = first.agent.join_network(&name, &secret).await.unwrap();
|
||||||
|
let dir = first.stop().await;
|
||||||
|
|
||||||
|
let reopened = Agent::spawn(config_with(dir.path(), &discovery))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
reopened.endpoint_id(),
|
||||||
|
device_id,
|
||||||
|
"restarting must not mint a new peer"
|
||||||
|
);
|
||||||
|
let networks = reopened.list_networks().await.unwrap();
|
||||||
|
assert_eq!(networks.len(), 1);
|
||||||
|
assert_eq!(networks[0].network_id, network_id);
|
||||||
|
assert!(networks[0].active, "auto-start networks come back up");
|
||||||
|
|
||||||
|
reopened.shutdown().await;
|
||||||
|
drop(reopened);
|
||||||
|
drop(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn changing_the_secret_keeps_the_device_identity() {
|
||||||
|
let discovery = SharedMemoryDiscovery::new();
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let device_id = agent.endpoint_id();
|
||||||
|
|
||||||
|
let name = NetworkName::new("rotating").unwrap();
|
||||||
|
let old = agent
|
||||||
|
.join_network(&name, &NetworkSecret::generate())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let new = agent
|
||||||
|
.join_network(&name, &NetworkSecret::generate())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_ne!(old, new, "a new secret is a new network space");
|
||||||
|
assert_eq!(
|
||||||
|
agent.endpoint_id(),
|
||||||
|
device_id,
|
||||||
|
"rotating the network secret must not change the persistent iroh id"
|
||||||
|
);
|
||||||
|
|
||||||
|
agent.shutdown().await;
|
||||||
|
drop(agent);
|
||||||
|
drop(dir);
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,41 @@
|
|||||||
|
[package]
|
||||||
|
name = "tsunagi-tcp-tls"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "TCP TLS 443 transport and E2EE tunnel protocol for tsunagi mesh networks."
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tsunagi-core = { path = "../tsunagi-core", version = "0.1.0" }
|
||||||
|
iroh.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macros"] }
|
||||||
|
rustls = "0.23"
|
||||||
|
tokio-rustls = "0.26"
|
||||||
|
rcgen = { version = "0.14", default-features = false, features = ["ring"] }
|
||||||
|
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
sha2 = "0.11"
|
||||||
|
hkdf = "0.13"
|
||||||
|
rand = "0.10"
|
||||||
|
subtle = "2.6"
|
||||||
|
zeroize = { version = "1.9", features = ["derive"] }
|
||||||
|
bytes.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
postcard.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
netdev = "0.46"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
caps = "0.5"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tsunagi-core = { path = "../tsunagi-core", features = ["testing"] }
|
||||||
|
tempfile.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
//! TLS certificate generation and verification.
|
||||||
|
//!
|
||||||
|
//! Generates a self-signed TLS certificate derived from the device's persistent
|
||||||
|
//! identity, and verifies remote certificates against the expected peer's
|
||||||
|
//! [`iroh::EndpointId`].
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use iroh::EndpointId;
|
||||||
|
use rustls::client::danger::{ServerCertVerified, ServerCertVerifier};
|
||||||
|
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||||
|
use rustls::{ClientConfig, Error as RustlsError, ServerConfig};
|
||||||
|
|
||||||
|
/// Verifies that the server certificate's public key matches the expected peer.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PinnedEndpointVerifier {
|
||||||
|
expected: EndpointId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PinnedEndpointVerifier {
|
||||||
|
/// Creates a verifier expecting `peer`.
|
||||||
|
pub fn new(peer: EndpointId) -> Self {
|
||||||
|
Self { expected: peer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerCertVerifier for PinnedEndpointVerifier {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
end_entity: &CertificateDer<'_>,
|
||||||
|
_intermediates: &[CertificateDer<'_>],
|
||||||
|
_server_name: &ServerName<'_>,
|
||||||
|
_ocsp_response: &[u8],
|
||||||
|
_now: UnixTime,
|
||||||
|
) -> Result<ServerCertVerified, RustlsError> {
|
||||||
|
// Parse the certificate's SPKI / public key
|
||||||
|
let cert_bytes = end_entity.as_ref();
|
||||||
|
let expected_bytes = self.expected.as_bytes();
|
||||||
|
|
||||||
|
// Check if the expected 32-byte Ed25519 public key is in the certificate
|
||||||
|
if cert_bytes
|
||||||
|
.windows(32)
|
||||||
|
.any(|window| window == expected_bytes)
|
||||||
|
{
|
||||||
|
Ok(ServerCertVerified::assertion())
|
||||||
|
} else {
|
||||||
|
Err(RustlsError::InvalidCertificate(
|
||||||
|
rustls::CertificateError::NotValidForName,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &CertificateDer<'_>,
|
||||||
|
_dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, RustlsError> {
|
||||||
|
// We require TLS 1.3
|
||||||
|
Err(RustlsError::PeerIncompatible(
|
||||||
|
rustls::PeerIncompatible::NoSignatureSchemesInCommon,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, RustlsError> {
|
||||||
|
rustls::crypto::verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
vec![rustls::SignatureScheme::ED25519]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default SNI domain used to disguise TLS connections.
|
||||||
|
pub const DEFAULT_SNI: &str = "cloudflare.com";
|
||||||
|
|
||||||
|
/// Generates a self-signed certificate and private key for TLS 1.3 using
|
||||||
|
/// a given 32-byte Ed25519 secret key and specified SNI domain.
|
||||||
|
pub fn generate_self_signed_cert(
|
||||||
|
secret_key_bytes: &[u8; 32],
|
||||||
|
sni: &str,
|
||||||
|
) -> Result<
|
||||||
|
(
|
||||||
|
CertificateDer<'static>,
|
||||||
|
rustls::pki_types::PrivateKeyDer<'static>,
|
||||||
|
),
|
||||||
|
Box<dyn std::error::Error + Send + Sync>,
|
||||||
|
> {
|
||||||
|
// PKCS#8 prefix for v1 Ed25519 private keys:
|
||||||
|
// 30 2e 02 01 00 30 05 06 03 2b 65 70 04 22 04 20 <32 bytes>
|
||||||
|
let mut pkcs8 = Vec::with_capacity(48);
|
||||||
|
pkcs8.extend_from_slice(&[
|
||||||
|
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04,
|
||||||
|
0x20,
|
||||||
|
]);
|
||||||
|
pkcs8.extend_from_slice(secret_key_bytes);
|
||||||
|
|
||||||
|
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(pkcs8);
|
||||||
|
let key_pair = rcgen::KeyPair::from_pkcs8_der_and_sign_algo(&key_der, &rcgen::PKCS_ED25519)?;
|
||||||
|
let mut params = rcgen::CertificateParams::new(vec![sni.to_string()])?;
|
||||||
|
params
|
||||||
|
.distinguished_name
|
||||||
|
.push(rcgen::DnType::CommonName, sni);
|
||||||
|
|
||||||
|
let cert = params.self_signed(&key_pair)?;
|
||||||
|
let cert_der = CertificateDer::from(cert.der().to_vec());
|
||||||
|
let priv_key = rustls::pki_types::PrivateKeyDer::Pkcs8(key_der);
|
||||||
|
|
||||||
|
Ok((cert_der, priv_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a `rustls::ServerConfig` for the node.
|
||||||
|
pub fn make_server_config(
|
||||||
|
cert: CertificateDer<'static>,
|
||||||
|
key: rustls::pki_types::PrivateKeyDer<'static>,
|
||||||
|
) -> Result<Arc<ServerConfig>, rustls::Error> {
|
||||||
|
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||||
|
let mut config = ServerConfig::builder_with_provider(provider)
|
||||||
|
.with_safe_default_protocol_versions()?
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(vec![cert], key)?;
|
||||||
|
config.alpn_protocols = vec![b"http/1.1".to_vec()];
|
||||||
|
Ok(Arc::new(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a `rustls::ClientConfig` expecting a specific peer's `EndpointId`.
|
||||||
|
pub fn make_client_config(expected_peer: EndpointId) -> Result<Arc<ClientConfig>, rustls::Error> {
|
||||||
|
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||||
|
let verifier = Arc::new(PinnedEndpointVerifier::new(expected_peer));
|
||||||
|
let mut config = ClientConfig::builder_with_provider(provider)
|
||||||
|
.with_safe_default_protocol_versions()?
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(verifier)
|
||||||
|
.with_no_client_auth();
|
||||||
|
config.alpn_protocols = vec![b"http/1.1".to_vec()];
|
||||||
|
Ok(Arc::new(config))
|
||||||
|
}
|
||||||
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user