Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9c653023d | ||
|
|
9966b50407 | ||
|
|
bb38562b0a | ||
|
|
b2431d7eb1 | ||
|
|
c62d29fa14 | ||
|
|
a6d9edabee | ||
|
|
2f7f9980c7 | ||
|
|
2e178a6f9d | ||
|
|
f4dcdec662 | ||
|
|
69310108f4 | ||
|
|
e7dff34ec2 | ||
|
|
a55ea883a1 | ||
|
|
3e066f991f | ||
|
|
3752419075 | ||
|
|
f63343a6a0 | ||
|
|
d13bb00623 | ||
|
|
2a0a8abd60 | ||
|
|
db01c58a6f | ||
|
|
de56258eb1 | ||
|
|
8f5ce21ce9 | ||
|
|
0a344c1b4f | ||
|
|
2cda56e1f8 | ||
|
|
d1de61ba29 | ||
|
|
19509dc833 | ||
|
|
de8e48bee7 | ||
|
|
8c812e6874 | ||
|
|
63178574ce | ||
|
|
a3856eed3b | ||
|
|
eac54f8c79 | ||
|
|
a8dfd7017b | ||
|
|
99f4cf0220 | ||
|
|
69263b2dd9 | ||
|
|
20444013c0 | ||
|
|
2196f6635b | ||
|
|
6438982d45 | ||
|
|
21b619388c | ||
|
|
d1ab993dd2 | ||
|
|
da7ce1dca1 | ||
|
|
4d2f8a54ec | ||
|
|
c3a6201ee2 | ||
|
|
533921059a | ||
|
|
d2778f0b5a | ||
|
|
8cfece9479 | ||
|
|
3930dff2dc | ||
|
|
35acc4d231 | ||
|
|
c791102ad9 | ||
|
|
f80841da63 | ||
|
|
7070a16104 | ||
|
|
46575d0899 | ||
|
|
a89e65475d | ||
|
|
2ed447ae86 | ||
|
|
ac5a1596ef | ||
|
|
192ab25bbd | ||
|
|
c0a76f1942 | ||
|
|
510116afb9 | ||
|
|
e24548b3b0 | ||
|
|
a4e341cd66 | ||
|
|
41990c9934 | ||
|
|
254a25ebd6 | ||
|
|
5495cb5b34 | ||
|
|
fac3bd7bb9 | ||
|
|
db9015b26c | ||
|
|
c724981bfd | ||
|
|
b4f3e57c8d | ||
|
|
d1a0eca723 | ||
|
|
686c74a3bd | ||
|
|
9698f21d55 | ||
|
|
72eaf9a228 | ||
|
|
e735151d62 | ||
|
|
458051f47d | ||
|
|
83d3445b7e | ||
|
|
3581feb9b9 | ||
|
|
3044e592bd | ||
|
|
9b240672e6 | ||
|
|
1b2f050c05 | ||
|
|
637e2f74e4 | ||
|
|
415b6a6667 | ||
|
|
0f97d60854 | ||
|
|
44a799faee | ||
|
|
41604225ba | ||
|
|
fae3816bbf | ||
|
|
0b3915d52b | ||
|
|
4c84cc9e4b | ||
|
|
990b9f2e0f | ||
|
|
142fdf995c | ||
|
|
ff7e235414 | ||
|
|
7c1be332e3 | ||
|
|
9415866193 | ||
|
|
c852de2c78 | ||
|
|
a80a014f20 | ||
|
|
41d5f554c7 | ||
|
|
745bbaea06 | ||
|
|
5bee148497 | ||
|
|
60e6b263d1 | ||
|
|
ca8759c023 | ||
|
|
75b37fdda3 | ||
|
|
240e471c88 |
No files matched your search
@@ -0,0 +1,322 @@
|
||||
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"
|
||||
;;
|
||||
Windows)
|
||||
cp dist/windows/README.md "$stage/INSTALL.md"
|
||||
;;
|
||||
esac
|
||||
|
||||
archive="${{ matrix.asset }}-${version}.${{ matrix.archive }}"
|
||||
if [[ "${{ matrix.archive }}" == "zip" ]]; then
|
||||
(cd dist/pkg && 7z a "../../${archive}" "${{ matrix.asset }}")
|
||||
else
|
||||
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
|
||||
fi
|
||||
|
||||
# A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops
|
||||
# the static binary, the systemd service and the polkit rule. Built with
|
||||
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
|
||||
- name: Build the Debian package
|
||||
if: matrix.deb
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
bash dist/deb/build-deb.sh \
|
||||
"target/${{ matrix.target }}/release/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
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download every build artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Show what was collected
|
||||
run: find artifacts -type f
|
||||
|
||||
- name: Create the release and upload the archives
|
||||
shell: bash
|
||||
env:
|
||||
# Gitea injects this with access to the current repository. If the
|
||||
# release calls below return 403, the repo/runner is configured to
|
||||
# hand Actions a read-only token — grant it write access to releases.
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -u
|
||||
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
# Gitea sets GITHUB_API_URL to its own API root (…/api/v1); fall back
|
||||
# to building it from the server URL if it is absent.
|
||||
base="${GITHUB_API_URL:-${GITHUB_SERVER_URL}/api/v1}"
|
||||
api="${base}/repos/${GITHUB_REPOSITORY}"
|
||||
auth="Authorization: token ${TOKEN}"
|
||||
echo "release API: $api"
|
||||
|
||||
prerelease=false
|
||||
case "$tag" in *-*) prerelease=true ;; esac
|
||||
|
||||
# The release id for this tag, if it already exists.
|
||||
code=$(curl -sS -o resp.json -w '%{http_code}' -H "$auth" "$api/releases/tags/$tag" || true)
|
||||
echo "get release by tag: HTTP $code"
|
||||
id=$(grep -o '"id":[0-9]*' resp.json 2>/dev/null | head -n1 | cut -d: -f2 || true)
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
code=$(curl -sS -o resp.json -w '%{http_code}' -X POST -H "$auth" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"$tag\",\"name\":\"tsunagi $tag\",\"prerelease\":$prerelease}" \
|
||||
"$api/releases" || true)
|
||||
echo "create release: HTTP $code"
|
||||
id=$(grep -o '"id":[0-9]*' resp.json 2>/dev/null | head -n1 | cut -d: -f2 || true)
|
||||
if [ -z "$id" ]; then
|
||||
echo "could not create the release; response was:" >&2
|
||||
cat resp.json >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "release id: $id"
|
||||
|
||||
# download-artifact@v3 nests each artifact in a directory named after
|
||||
# its upload, with the single archive inside. The read loop runs in
|
||||
# this shell (process substitution, not a pipe) so a failed upload
|
||||
# actually fails the job.
|
||||
rc=0
|
||||
while IFS= read -r -d '' f; do
|
||||
name=$(basename "$f")
|
||||
code=$(curl -sS -o up.json -w '%{http_code}' -X POST -H "$auth" \
|
||||
-F "attachment=@${f}" \
|
||||
"$api/releases/$id/assets?name=${name}" || true)
|
||||
echo "upload $name: HTTP $code"
|
||||
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
|
||||
cat up.json >&2 || true
|
||||
rc=1
|
||||
fi
|
||||
done < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' \) -print0)
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,294 @@
|
||||
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"
|
||||
;;
|
||||
Windows)
|
||||
cp dist/windows/README.md "$stage/INSTALL.md"
|
||||
;;
|
||||
esac
|
||||
|
||||
archive="${{ matrix.asset }}-${version}.${{ matrix.archive }}"
|
||||
if [[ "${{ matrix.archive }}" == "zip" ]]; then
|
||||
(cd dist/pkg && 7z a "../../${archive}" "${{ matrix.asset }}")
|
||||
else
|
||||
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
|
||||
fi
|
||||
|
||||
# A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops
|
||||
# the static binary, the systemd service and the polkit rule. Built with
|
||||
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
|
||||
- name: Build the Debian package
|
||||
if: matrix.deb
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
bash dist/deb/build-deb.sh \
|
||||
"target/${{ matrix.target }}/release/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
|
||||
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' \))
|
||||
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
|
||||
@@ -13,23 +13,67 @@ messages. Scope and non-scope are in [README.md](README.md).
|
||||
There is no central server, no owner, no registration and no majority vote.
|
||||
Design accordingly: a majority is not a root of trust.
|
||||
|
||||
## User experience
|
||||
|
||||
User convenience is a core requirement. The CLI should feel logical and
|
||||
predictable to an experienced administrator: use consistent selectors and
|
||||
flags, sensible defaults, and actionable errors. Let users refer to configured
|
||||
networks by their exact name as well as by id or a unique id prefix; do not
|
||||
make them copy an opaque identifier when the stored name identifies one network.
|
||||
When a selector is ambiguous, list the matching names and ids and change
|
||||
nothing. Never guess which resource a destructive command should affect.
|
||||
Keep help text and examples in sync with behavior, and preserve existing
|
||||
scripts when improving the interactive workflow.
|
||||
|
||||
## Architectural boundaries
|
||||
|
||||
Keep these separate. Crossing them is the main thing to review for.
|
||||
|
||||
- **Control plane vs data plane.** The separation is **logical, not physical**.
|
||||
The control protocol in `src/proto/` knows nothing about packets, and
|
||||
`src/dataplane/` knows nothing about the control protocol; either can be
|
||||
replaced on its own. Both may ride on iroh — refusing to would throw away
|
||||
iroh's NAT traversal and force the data plane to reimplement it. They use
|
||||
different ALPNs and different connections, so a busy or broken data plane
|
||||
cannot disturb control traffic.
|
||||
The control protocol in `crates/tsunagi-core/src/proto/` knows nothing about
|
||||
packets, and a protocol crate knows nothing about the control protocol;
|
||||
either can be replaced on its own. Both may ride on iroh — refusing to
|
||||
would throw away iroh's NAT traversal and force the data plane to
|
||||
reimplement it. They use different ALPNs and different connections, so a
|
||||
busy or broken data plane cannot disturb control traffic.
|
||||
- **One agent, one interface.** It belongs to the system level, along with
|
||||
the addresses on it and the decision of whose packet is whose. Several
|
||||
protocols may be carrying traffic at once and none of them owns the thing
|
||||
they carry it for. A protocol is handed a routed packet and hands back a
|
||||
decrypted one; it never creates an interface and never picks an address.
|
||||
- **One state directory, one identity, one live agent — many networks.** A
|
||||
network is added to the agent that is already running, never by starting a
|
||||
second one on the same directory. A second agent is a second identity and
|
||||
is isolated: its own directories, its own interface, its own runtime. Do
|
||||
not add anything that lets two agents share a directory, and do not make a
|
||||
network's lifetime depend on the process that happened to start it.
|
||||
- **A protocol is a separate crate with its own version.** The version peers
|
||||
compare is the *wire* version, never the software version: two peers on
|
||||
different releases work together for as long as the bytes between them
|
||||
have not changed. Nothing negotiated may be derived from anything that
|
||||
moves with a release.
|
||||
- **A protocol never sees the network secret.** It proves who is at the other
|
||||
end of a tunnel; proving membership of a network stays in the core, which
|
||||
is why the authenticated transport does too.
|
||||
- **A relay carries bytes, never packets.** A datagram passed on for two
|
||||
other peers goes link in, link out: it is never written to the middle's
|
||||
interface or decrypted there. Multihop routing has a bounded hop limit;
|
||||
equal-cost next hops are chosen per flow. Reachability is first-hand and
|
||||
volatile: each authenticated member advertises its own protocol-specific
|
||||
links. Build routing tables on topology changes, never per packet. Transit
|
||||
must not acquire a routing mutex or wait for a protocol/TUN reader.
|
||||
- **Broadcast fanout belongs at local IP ingress.** Participation is local to
|
||||
each network, enabled by default, persisted and advertised to authenticated
|
||||
peers. Resolve one source domain and target only its willing members. Remote
|
||||
delivery never triggers another fanout. Physical LAN exports must extend
|
||||
explicit ingress and source/destination admission policies; never bypass
|
||||
ownership checks or add broadcast flooding to encrypted transit.
|
||||
- **Plugins never learn reachability.** An `IpPlugin` is handed a `PacketLink`
|
||||
per peer and moves datagrams over it. Addresses, hole punching and relays
|
||||
belong to `src/dataplane/transport/`. A plugin announcement says *who*, never
|
||||
belong to `crates/tsunagi-core/src/dataplane/transport/`. A plugin announcement says *who*, never
|
||||
*where*.
|
||||
- **The core never parses a plugin payload.** See `src/dataplane/mod.rs`. Only
|
||||
`src/dataplane/wireguard/announcement.rs` interprets WireGuard payloads, and
|
||||
- **The core never parses a plugin payload.** See `crates/tsunagi-core/src/dataplane/mod.rs`. Only
|
||||
`crates/tsunagi-wg-quic/src/announcement.rs` interprets `wg-quic` payloads, and
|
||||
only after bounding every field. A data plane failure must never stop the
|
||||
control plane.
|
||||
- **Derived, not claimed.** A peer's overlay address is derived from its public
|
||||
@@ -37,7 +81,7 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
inbound packets are dropped unless their source is the address derived for
|
||||
the peer that sent them. Never trust an address a peer announces.
|
||||
- **Signed state is the only durable agreement.** A fact that must survive a
|
||||
participant being away goes in `src/state/` as a record signed by its
|
||||
participant being away goes in `crates/tsunagi-core/src/state/` as a record signed by its
|
||||
author, never in a session. Merging is deterministic, an older version never
|
||||
rolls back a newer one, and absence from a snapshot is not deletion. Never
|
||||
add a vote or a quorum: a majority is not a trust root here, and it would
|
||||
@@ -46,6 +90,12 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
transaction, **before** the record is announced.
|
||||
- **Plugins own their system objects.** A plugin creates and removes its own
|
||||
interface and nothing else. Never touch routing, DNS or firewall settings.
|
||||
The one exception is the system level, not a plugin: while a network's
|
||||
broadcast is on, `overlay::hostrules` may install exactly a
|
||||
`255.255.255.255/32` route and an inbound UDP firewall allowance (overlay range
|
||||
only) for the interface the agent owns. Both are tagged `tsunagi:<interface>`,
|
||||
reversible, removed on broadcast off, leave and shutdown, and computed from
|
||||
local state, never from a remote announcement.
|
||||
- **Device identity vs network identity.** The iroh endpoint id is the device's
|
||||
public key. `NetworkId` is derived from name + secret only. Never conflate
|
||||
them, and never let one change the other.
|
||||
@@ -65,7 +115,7 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
normal path *and* the important failures. See [docs/testing.md](docs/testing.md).
|
||||
Do not add tests for getters or to move a coverage number.
|
||||
2. **Never change `IDENTITY_SCHEME`, the derivation labels, or the transcript
|
||||
encoding** in `src/identity/network.rs` and `src/proto/handshake.rs` without
|
||||
encoding** in `crates/tsunagi-core/src/identity/network.rs` and `crates/tsunagi-core/src/proto/handshake.rs` without
|
||||
treating it as an incompatible protocol change. Bumping the crate version or
|
||||
the control protocol version must not change an existing `NetworkId`.
|
||||
3. **Secrets never leak.** Not into logs, not into `Debug`, not into status
|
||||
@@ -76,7 +126,7 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
the library; tests opt out explicitly at the top of each file.
|
||||
5. **Bounds before allocation.** Frame lengths are checked against
|
||||
`Limits::max_frame_len` before a buffer is allocated. Every string, list and
|
||||
queue has a limit in `src/config.rs`.
|
||||
queue has a limit in `crates/tsunagi-core/src/config.rs`.
|
||||
6. **Failure is contained.** A bad signature, wrong secret, malformed packet or
|
||||
unknown version rejects one message or one session. It never stops another
|
||||
network and never stops the agent. There is no irreversible global error
|
||||
@@ -93,25 +143,35 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
up shutdown. Wind tasks down with a grace period and then abort.
|
||||
10. **Nothing from a remote announcement becomes a shell command, a filesystem
|
||||
path or an OS setting.** The agent never touches interfaces or OS settings
|
||||
it did not create.
|
||||
it did not create (the tagged broadcast host rules above are for its own
|
||||
interface).
|
||||
|
||||
## Where things live
|
||||
|
||||
| path | responsibility |
|
||||
|---------------------|----------------|
|
||||
| `src/identity/` | device identity; deterministic network space identity and derived keys |
|
||||
| `src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
|
||||
| `src/discovery.rs` | candidate sources; test and static backends |
|
||||
| `src/proto/` | framing, message formats, membership handshake |
|
||||
| `src/net.rs` | iroh endpoint adapter and observability snapshots |
|
||||
| `src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
|
||||
| `src/state/` | signed records that outlive a session, their merge rules and address allocation |
|
||||
| `src/dataplane/` | the plugin contract, the packet transport, and the WireGuard plugin |
|
||||
| `src/bin/tsunagi.rs`| the command line agent; the only place that owns a runtime, a logger and signals |
|
||||
| `crates/tsunagi-core/src/identity/` | device identity; deterministic network space identity and derived keys |
|
||||
| `crates/tsunagi-core/src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
|
||||
| `crates/tsunagi-core/src/discovery.rs` | candidate sources; test and static backends |
|
||||
| `crates/tsunagi-core/src/proto/` | framing, message formats, membership handshake |
|
||||
| `crates/tsunagi-core/src/net.rs` | iroh endpoint adapter and observability snapshots |
|
||||
| `crates/tsunagi-core/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
|
||||
| `crates/tsunagi-core/src/state/` | signed records that outlive a session, their merge rules and address allocation |
|
||||
| `crates/tsunagi-core/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks, broadcast host rules |
|
||||
| `crates/tsunagi-core/src/dataplane/` | the protocol contract and the authenticated packet transport |
|
||||
| `crates/tsunagi-core/src/dns/` | the DNS view of a network: zone, server, resolver publication |
|
||||
| `crates/tsunagi-wg-quic/` | the `wg-quic` protocol: its keys, its announcement, its tunnels |
|
||||
| `crates/tsunagi/` | the command line agent, built as `tsng`; the only place that owns a runtime, a logger and signals |
|
||||
| `tests/` | integration tests; `tests/common/` is the shared harness |
|
||||
|
||||
Add abstractions only at real substitution or testing boundaries. Do not add a
|
||||
trait per struct. Prefer one crate with clear modules over many small crates.
|
||||
trait per struct.
|
||||
|
||||
**The system level and its plugins are separate crates**, so that boundary is
|
||||
checked by the compiler and not by discipline: a plugin can reach only what
|
||||
`tsunagi` makes public, and carries its own version. Everything else stays
|
||||
one crate with clear modules — do not split further without a reason of that
|
||||
kind.
|
||||
|
||||
## Testing rules
|
||||
|
||||
@@ -133,7 +193,26 @@ trait per struct. Prefer one crate with clear modules over many small crates.
|
||||
- Running several library instances in one process is not a test of several
|
||||
system processes; do not describe it as one.
|
||||
|
||||
## Before you open a change
|
||||
## Commit every completed change
|
||||
|
||||
Agents must create a Git commit before finishing any task that changes this
|
||||
repository. Do not leave the user to make the commit. Inspect recent commit
|
||||
messages and follow their style: a short English subject stating the change,
|
||||
then a useful body explaining the problem, final behavior, architectural
|
||||
decisions, compatibility/migration effects, and checks actually performed.
|
||||
Write enough context for a future maintainer or LLM to understand why the code
|
||||
exists. Record failed or skipped checks honestly; never claim unrun tests.
|
||||
|
||||
Before committing, inspect the diff and stage only this task's changes. Preserve
|
||||
unrelated user changes and never commit secrets or generated build artifacts.
|
||||
If Git has no effective author name or email, set only the missing repository-
|
||||
local values to `AB` and `ab@hexor.cy`. Do not override an existing identity or
|
||||
change global Git configuration. Report the resulting commit hash.
|
||||
|
||||
Version bumps, release tags, and pushing commits/tags belong to the user unless
|
||||
they explicitly ask the agent to do them. Read-only tasks need no empty commit.
|
||||
|
||||
## Required validation
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
|
||||
Generated
+3555
-97
File diff suppressed because it is too large.
Load diff
+32
-72
@@ -1,80 +1,40 @@
|
||||
[package]
|
||||
name = "tsunagi"
|
||||
version = "0.1.0"
|
||||
# The system level and its plugins are separate crates, so the boundary
|
||||
# between them is checked by the compiler rather than by discipline: a plugin
|
||||
# can reach only what the core makes public, and carries its own version.
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*"]
|
||||
exclude = ["vendor/mainline"]
|
||||
|
||||
# Mainline 8.0.0 treats Windows UDP read timeouts as socket failures.
|
||||
# Keep the small receive-loop correction reproducible until an upstream fix.
|
||||
[patch.crates-io]
|
||||
mainline = { path = "vendor/mainline" }
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
rust-version = "1.91"
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "Proof-of-concept library for small private mesh networks: persistent agent identity, deterministic network spaces, iroh-based control plane."
|
||||
repository = "https://github.com/tsunagi-net/tsunagi"
|
||||
readme = "README.md"
|
||||
keywords = ["mesh", "p2p", "iroh", "networking"]
|
||||
categories = ["network-programming"]
|
||||
license = "WTFPL"
|
||||
repository = "https://github.com/house-of-vanity/tsunagi"
|
||||
|
||||
[features]
|
||||
default = ["cli"]
|
||||
# The `tsunagi` command line binary. Library users can opt out.
|
||||
cli = ["dep:clap", "dep:anstream", "dep:anstyle", "dep:tracing-subscriber", "tokio/signal", "tun-device"]
|
||||
# A real TUN device, so the WireGuard plugin can carry actual IP traffic.
|
||||
# Needs CAP_NET_ADMIN at run time; without it the plugin still runs and its
|
||||
# in-memory device can be used for tests.
|
||||
tun-device = ["dep:tun", "dep:rtnetlink", "dep:caps", "dep:futures-util"]
|
||||
|
||||
[[bin]]
|
||||
name = "tsunagi"
|
||||
path = "src/bin/tsunagi.rs"
|
||||
required-features = ["cli"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5", features = ["derive", "env"], optional = true }
|
||||
# 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 = { version = "1.0", optional = true }
|
||||
anstyle = { version = "1.0", optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
iroh = { version = "1.2", default-features = false, features = ["tls-ring"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
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"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
|
||||
data-encoding = "2.11"
|
||||
hex = "0.4"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
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"
|
||||
bytes = "1.12.1"
|
||||
boringtun = { version = "0.7.1", default-features = false }
|
||||
tun = { version = "0.8", features = ["async"], 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 }
|
||||
caps = { version = "0.5", optional = true }
|
||||
futures-util = { version = "0.3", default-features = false, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
||||
tempfile = "3.24"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[lints.rust]
|
||||
[workspace.lints.rust]
|
||||
missing_docs = "warn"
|
||||
unsafe_code = "forbid"
|
||||
unsafe_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
[workspace.lints.clippy]
|
||||
unwrap_used = "warn"
|
||||
expect_used = "warn"
|
||||
panic = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
iroh = { version = "1.2", default-features = false, features = ["tls-ring", "portmapper"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
|
||||
bytes = "1.12.1"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
hex = "0.4"
|
||||
data-encoding = "2.11"
|
||||
tempfile = "3.24"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
@@ -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.
|
||||
@@ -24,6 +24,7 @@ A working library with **real iroh connections** and integration tests:
|
||||
- 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;
|
||||
@@ -35,11 +36,11 @@ A working library with **real iroh connections** and integration tests:
|
||||
overlay with deterministically derived addresses and optional IPv4, real
|
||||
tunnels carried over iroh, and address ownership enforced rather than
|
||||
believed;
|
||||
- a **command line agent**, `tsunagi`, with a local control socket.
|
||||
- a **command line agent**, `tsng`, with a local control socket.
|
||||
|
||||
### What it deliberately does **not** do
|
||||
|
||||
Not implemented, and not pretended to be: Mainline DHT, DNS, routing through
|
||||
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
|
||||
@@ -58,7 +59,7 @@ system's and the user's responsibility, not this library's.
|
||||
|
||||
- Rust 1.91 or newer (iroh 1.2 requires it), edition 2024. Pinned dependencies
|
||||
in `Cargo.lock`.
|
||||
- No internet, no DHT, no public relay, no administrator rights and no changes
|
||||
- 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
|
||||
@@ -70,29 +71,67 @@ On the first machine:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
./target/release/tsunagi id secret generate # prints tsn1...; share it privately
|
||||
./target/release/tsunagi status # this device, the agent, and this host
|
||||
|
||||
./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard
|
||||
./target/release/tsng status # this device, the agent, and this host
|
||||
./target/release/tsng network # the networks this device belongs to
|
||||
```
|
||||
|
||||
It prints its endpoint id and then waits. On the second machine, pass that id:
|
||||
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
|
||||
./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard \
|
||||
--peer <endpoint-id-from-the-first-machine>
|
||||
# Terminal one: the agent. It prints its endpoint id and then serves.
|
||||
./target/release/tsng up
|
||||
|
||||
# Terminal two: make a network. With no --secret it invents one and prints
|
||||
# it, along with the line to send the other machine.
|
||||
./target/release/tsng join --network lab
|
||||
```
|
||||
|
||||
On the second machine, start its agent and join the same network with the
|
||||
secret that was printed:
|
||||
|
||||
```bash
|
||||
./target/release/tsng up
|
||||
./target/release/tsng 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 wireguard: Direct via Ip(…), datagram 1382
|
||||
+ 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)
|
||||
wireguard: tsunkkcp43lmdje on fd15:1d9e:fa21:f201:…/64 mtu 1280, 1/1 tunnel(s) established
|
||||
4jO4kx9Z fd15:1d9e:fa21:f201:… handshake 3s ago tx=0 rx=0 dropped=0 path=Direct via Ip(…)
|
||||
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.
|
||||
@@ -102,7 +141,7 @@ wireguard: tsunkkcp43lmdje on fd15:1d9e:fa21:f201:…/64 mtu 1280, 1/1 tunnel(s)
|
||||
From another shell on either machine:
|
||||
|
||||
```bash
|
||||
tsunagi status
|
||||
tsng status
|
||||
```
|
||||
|
||||
```text
|
||||
@@ -126,18 +165,21 @@ ping 100.65.243.53
|
||||
|
||||
`tx` and `rx` in the status should start moving.
|
||||
|
||||
IPv6 works out of the box: each member's address is derived from the network
|
||||
id and collides with essentially nothing.
|
||||
|
||||
**IPv4 addresses are allocated and then remembered.** The default range is
|
||||
**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 --network lab --secret "$SECRET" --wireguard --ipv4-range 10.44.0.0/16
|
||||
tsunagi up --network lab --secret "$SECRET" --wireguard --ipv4-range none # IPv6 only
|
||||
tsng up --ipv4-range 10.44.0.0/16
|
||||
tsng up --ipv4-range none # no data plane at all
|
||||
```
|
||||
|
||||
One agent has one interface, so two of its networks cannot both use that
|
||||
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.
|
||||
@@ -149,6 +191,123 @@ 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 `tsng up` needs
|
||||
only a network name and a secret; everything else sits under the level it
|
||||
belongs to, which `--help` shows as two sections:
|
||||
|
||||
* **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
|
||||
tsng 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 `tsng 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
|
||||
tsng up # DNS is already enabled
|
||||
dig @127.0.0.1 -p 5354 music.lab
|
||||
```
|
||||
|
||||
An explicit choice is remembered across restarts. `--no-dns` or `tsng dns off`
|
||||
disables it; `--dns` or `tsng dns on` enables it again. Existing saved opt-outs
|
||||
remain respected. The `dns on` and `dns off` commands also take effect immediately
|
||||
on a running agent. `--dns` and `--no-dns` cannot be combined.
|
||||
|
||||
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** — `tsng status` prints where it is
|
||||
listening and the exact `dig` line — so the automatic part is missing, not
|
||||
the feature. The refusal is said once rather than on every pass, and retried
|
||||
slowly, because nothing but a person will change it.
|
||||
|
||||
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
|
||||
@@ -159,7 +318,7 @@ remote peer could influence.
|
||||
That needs `CAP_NET_ADMIN`, granted once:
|
||||
|
||||
```bash
|
||||
sudo setcap cap_net_admin+p /usr/local/bin/tsunagi
|
||||
sudo setcap cap_net_admin+p /usr/local/bin/tsng
|
||||
```
|
||||
|
||||
`+p` rather than `+ep`: the capability is then *permitted* but not
|
||||
@@ -169,7 +328,7 @@ allocation changes. Everything else, including every byte from the network,
|
||||
is handled with it lowered. `+ep` works too; the agent lowers it on the way
|
||||
in.
|
||||
|
||||
`tsunagi status` says which of these applies on the host it runs on, along
|
||||
`tsng status` says which of these applies on the host it runs on, along
|
||||
with what the agent is doing. It grades each finding: **ok** for what works,
|
||||
**warn** for what the agent runs without and you can fix from the line it
|
||||
prints, **FAIL** for what it cannot work around. The words carry the grade as
|
||||
@@ -184,23 +343,81 @@ sayable — it remembers who belongs while they are gone, so the report can say
|
||||
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.
|
||||
|
||||
`id` is the other half: it shows what this device is — its signing key, the
|
||||
name it answers to, and the secret of every network it has joined — and
|
||||
changes those. 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.
|
||||
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 id secret the secret of each joined network
|
||||
tsunagi id secret generate a fresh secret for a network that does not exist yet
|
||||
tsng id everything about this device
|
||||
tsng id hostname the name it answers to
|
||||
tsng id hostname mango change it
|
||||
tsng id key the key it signs with
|
||||
tsng id key rotate replace that key
|
||||
|
||||
tsng network the networks this device belongs to
|
||||
tsng network join -n lab -s tsn1… join one; adds it to a running agent
|
||||
tsng network join -n lab resume one this device has, or make it on the spot
|
||||
tsng network stop lab stop serving it, keeping everything
|
||||
tsng network start lab serve it again, from where it left off
|
||||
tsng network leave lab give up the address and name, then forget it
|
||||
tsng network secret the secret of each joined network
|
||||
tsng network secret lab just that one, for copying
|
||||
tsng network secret generate a fresh secret for a network that does not exist yet
|
||||
|
||||
tsng dns whether the local resolver is serving, and what
|
||||
tsng dns on start it, now and after every restart
|
||||
tsng dns off stop it, now and after every restart
|
||||
```
|
||||
|
||||
Secrets appear in `id`, which is where you go to ask for one, and never in
|
||||
`status`, in a log, in a `Debug` rendering or in anything sent to a peer.
|
||||
**A network without a secret makes one.** `tsng join --network lab`
|
||||
resolves a bare name in the obvious way: if this device is already in
|
||||
exactly one network called `lab`, that one — so the name alone resumes what
|
||||
you have; if it is in none, a fresh random secret, printed in full with the
|
||||
line to send the other machine:
|
||||
|
||||
```
|
||||
joined `lab` (k2on43wadbi5x267vp6z3ogkm7nbjedfdoxyauxhtxhwqrprylba)
|
||||
secret tsn1u7c…
|
||||
|
||||
Run this on the other machine:
|
||||
|
||||
tsng join --network lab --secret tsn1u7c…
|
||||
|
||||
Its agent has to be running. If it is not:
|
||||
|
||||
tsng up --peer 91e83a6e2b7a…
|
||||
```
|
||||
|
||||
Two networks of one name is a thing that happens — a mistyped secret makes
|
||||
one — so joining says whether the network was already here, and warns when
|
||||
another configured network answers to the same name. A name is a label and
|
||||
the id is the identity.
|
||||
|
||||
That is the ad-hoc case: one person makes a network and sends the command
|
||||
round. The secret is printed *only* when the agent invented it — there is
|
||||
nowhere else to read it from — and never when it was supplied, because then
|
||||
it is already yours. Two networks of one name and no secret is the one case
|
||||
with no answer, and it says so instead of choosing.
|
||||
|
||||
A secret is printed by `network secret` and nowhere else — not by `id`, not
|
||||
by `status`, not in a log, a `Debug` rendering or anything sent to a peer.
|
||||
Asking for it is deliberate, because these reports get pasted into chats.
|
||||
|
||||
**`up` takes no network at all** if you would rather decide later: it
|
||||
brings up the agent and whatever it is already configured for, and waits.
|
||||
That is the shape of a daemon in one terminal and `tsng network join`
|
||||
in another.
|
||||
|
||||
`network join` is also the answer to a question `up` cannot: a state
|
||||
directory belongs to one live agent, so a second `tsng up` cannot add a
|
||||
network to the one already running. This adds it over the control socket and
|
||||
it starts at once. With no agent running it is written to the configuration
|
||||
and starts with the next `up`.
|
||||
|
||||
The name is part of the signed state, so changing it revokes the previous
|
||||
one: there is one record per author, a new version replaces the whole claim,
|
||||
@@ -247,13 +464,12 @@ its whole life has carrier for its whole life.
|
||||
|
||||
### The MTU is 1280
|
||||
|
||||
That is the minimum IPv6 requires (RFC 8200), and Linux enforces it by
|
||||
disabling IPv6 outright on an interface below it — the per-device
|
||||
`/proc/sys/net/ipv6` entries vanish and adding an address fails with
|
||||
`Invalid argument`. A smaller MTU cannot work at all, so the agent refuses one
|
||||
rather than letting it fail later. See
|
||||
[docs/wireguard.md](docs/wireguard.md#mtu) for the ceiling that pushes back
|
||||
from the other side.
|
||||
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
|
||||
|
||||
@@ -261,15 +477,37 @@ from the other side.
|
||||
|---|---|---|
|
||||
| `setcap cap_net_admin+p` | ordinary user, one capability | recommended: nothing to prepare, nothing left behind. Lost on every rebuild or copy of the binary. |
|
||||
| systemd service | `User=`, `AmbientCapabilities=CAP_NET_ADMIN` | the same, for an installed service |
|
||||
| `sudo tsunagi up` | root | everything works, nothing is isolated |
|
||||
| `sudo tsng up` | root | everything works, nothing is isolated |
|
||||
| macOS root LaunchDaemon | root | the native macOS deployment; see below |
|
||||
| `--no-tun` | ordinary user, no capabilities | tunnels run and handshake, traffic never reaches the OS |
|
||||
|
||||
**Not implemented yet.** macOS and Windows have no provisioner: both need
|
||||
real platform work — `utun` and `SystemConfiguration` on one, the IP Helper
|
||||
API and a Wintun adapter on the other. There the agent says so and `--no-tun`
|
||||
is the way to run it; the control plane and the tunnels are unaffected. The
|
||||
decision logic that says *what* to change is shared and tested on every
|
||||
platform, so only the execution is left to write.
|
||||
On Windows, put `wintun.dll` beside the executable and start `tsng up`
|
||||
from PowerShell or Command Prompt opened with **Run as administrator**.
|
||||
Run commands controlling that agent (`join`, `status`, `dns`, `network`)
|
||||
as the same Windows user with the same elevation. If Windows denies access
|
||||
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.
|
||||
|
||||
On macOS the agent creates a `utun` interface, configures it with `ifconfig`
|
||||
and `route`, and points the system resolver at the overlay by writing
|
||||
`/etc/resolver/<network>` files (which carry a port, so the DNS server needs no
|
||||
privileged socket). All of this needs **root**: macOS has no per-capability
|
||||
grant like Linux, and a `utun` plus `/etc/resolver` are root-only. The kernel
|
||||
assigns the interface its `utunN` name, so the name derived from the network id
|
||||
is only a request and the real one is adopted once the interface is up.
|
||||
|
||||
Run it as root — `sudo tsng up` for a quick start, or install it as a root
|
||||
**LaunchDaemon** for the native always-on deployment. As a daemon the agent runs
|
||||
unprivileged commands (`join`, `status`, `dns`, `network`) over its control
|
||||
socket like any other platform; the socket lives at `/var/run/tsunagi/agent.sock`
|
||||
and the stores at `/var/db/tsunagi`, both fixed system paths so they do not
|
||||
depend on whose shell started `sudo`. A ready-to-edit plist and install notes
|
||||
are in [dist/macos/](dist/macos/). The broadcast route is installed; the
|
||||
inbound-UDP firewall allowance (a `pf` anchor) is not yet, so `status` reports
|
||||
that half as incomplete and names what to allow by hand. The decision logic that
|
||||
says *what* to change is shared and tested on every platform.
|
||||
|
||||
## Checks
|
||||
|
||||
@@ -287,7 +525,6 @@ tests:
|
||||
|
||||
```bash
|
||||
cargo run --example two_agents # control plane only
|
||||
cargo run --example wireguard_mesh # a WireGuard overlay carrying a real packet
|
||||
```
|
||||
|
||||
Both run with no privileges and change nothing on the host.
|
||||
@@ -340,31 +577,85 @@ async fn main() -> Result<()> {
|
||||
reaches the internet by accident. Opt into `DirectOnly` or `N0Defaults`
|
||||
explicitly.
|
||||
|
||||
### Through somebody in the middle
|
||||
|
||||
Tsunagi routes the overlay in userspace. A direct data link always wins;
|
||||
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
|
||||
tsng join -n games --no-broadcast
|
||||
tsng join -n games --broadcast
|
||||
tsng network broadcast games off
|
||||
tsng network broadcast games on
|
||||
```
|
||||
|
||||
The choice persists across restarts and a plain `join`. It can be changed while
|
||||
the agent runs; `status` shows it for each network. While broadcast is on, the
|
||||
agent routes `255.255.255.255` through the Tsunagi interface and allows inbound
|
||||
UDP from the overlay range on it (Linux and Windows, needs the same privilege as
|
||||
the interface), and removes both when it is turned off or the agent stops. Games
|
||||
bound to a physical address still bypass it. Physical LAN capture/subnet sharing
|
||||
is not implemented.
|
||||
See [broadcast.md](docs/broadcast.md) for domain isolation and future LAN gateways.
|
||||
|
||||
## How peers find each other
|
||||
|
||||
Two different lookups are involved, and only one of them is this project's:
|
||||
|
||||
**1. Resolving one endpoint's address — iroh's, and it works today.**
|
||||
With `--transport relay` or `--transport direct`, iroh publishes a signed
|
||||
With `--reach relay` or `--reach direct`, iroh publishes a signed
|
||||
record of this endpoint's addresses, keyed by its endpoint id, to the public
|
||||
service run by Number 0 — "n0", the company behind iroh — at `dns.iroh.link`,
|
||||
over pkarr and DNS, and resolves other endpoints the same way. That is why `--peer <endpoint-id>` works with no address attached:
|
||||
iroh looks it up. None of that code is ours.
|
||||
|
||||
**2. Finding who is in a network — ours, and it is still manual.**
|
||||
`NetworkDiscovery` maps a secret-derived `DiscoveryKey` to a set of *candidate*
|
||||
members. Two backends exist: `StaticBootstrap` (what `--peer` feeds) and an
|
||||
in-memory one for tests. The planned Mainline DHT backend, which would let
|
||||
members find each other from the network secret alone, is **not implemented**.
|
||||
So today you bootstrap by passing one peer's id; after that the mesh is
|
||||
whatever those agents reach.
|
||||
**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. `--transport local` publishes nothing.
|
||||
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.
|
||||
@@ -380,15 +671,72 @@ paths; tests always use temporary directories.
|
||||
| `state.sqlite` | device identity, network configuration, hostname | clear error, never reset |
|
||||
| `cache.sqlite` | address hints and other recoverable data | discarded and recreated |
|
||||
|
||||
The WireGuard plugin keeps its own keys in its own `wireguard.sqlite`, wherever
|
||||
its configuration points, because plugin keys are neither the iroh identity nor
|
||||
the network secret.
|
||||
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.
|
||||
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
|
||||
— `tsng network join` adds them to the agent that is already running,
|
||||
which is why a second `tsng up` on the same directory is refused rather
|
||||
than made to work. A second agent on the same host is a second identity, and
|
||||
needs everything of its own: its own state and cache directories, its own
|
||||
interface name and its own overlay range. The library owns no globals, so
|
||||
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
|
||||
tsng network # what this device belongs to
|
||||
tsng network leave lab # give up the address and the name, then forget it
|
||||
tsng wipe --yes # remove everything and be a stranger again
|
||||
```
|
||||
|
||||
**Stopping is not leaving.** `stop` closes this network's sessions, takes
|
||||
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.
|
||||
|
||||
`leave`, `stop`, `start`, `secret` and `broadcast` accept an exact stored network
|
||||
name, its full id, or a unique id prefix (including a copied report ellipsis).
|
||||
Names are case-sensitive. If several networks share a name, or a name matches
|
||||
another network's id prefix, the command lists matching names and full ids and
|
||||
changes nothing. Use a full id to choose explicitly; a full id always takes
|
||||
precedence over a name. No secret is needed to select a configured network.
|
||||
|
||||
`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
|
||||
|
||||
@@ -398,6 +746,7 @@ lock rather than an existence check.
|
||||
- [docs/protocol.md](docs/protocol.md) — identity derivation, framing, handshake.
|
||||
- [docs/sync-model.md](docs/sync-model.md) — the planned signed-state model and
|
||||
what is deliberately not built yet.
|
||||
- [docs/exit-node.md](docs/exit-node.md) — exit nodes: sending all traffic through a member.
|
||||
- [docs/threat-model.md](docs/threat-model.md) — threat model and known limits.
|
||||
- [docs/testing.md](docs/testing.md) — what the suite covers and what it does not.
|
||||
- [AGENTS.md](AGENTS.md) — rules for anyone (human or agent) changing this repo.
|
||||
@@ -415,4 +764,4 @@ any of this.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT OR Apache-2.0.
|
||||
[WTFPL](LICENSE): do what the fuck you want to.
|
||||
@@ -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.
@@ -27,7 +27,7 @@ mod status;
|
||||
pub use events::Event;
|
||||
pub use status::{
|
||||
AgentStatus, CandidateStatus, MemberStatus, NetworkMetrics, NetworkState, NetworkStatus,
|
||||
PeerStatus,
|
||||
OverlayStatus, PeerStatus,
|
||||
};
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -37,23 +37,29 @@ use iroh::{EndpointAddr, EndpointId};
|
||||
use tokio::sync::{RwLock, broadcast, mpsc, oneshot};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::config::{AgentConfig, Limits};
|
||||
use crate::dataplane::transport::PacketTransport;
|
||||
use crate::dataplane::transport::iroh_link::{IrohTransport, TransportContext};
|
||||
use crate::dataplane::{PluginContext, PluginRequest};
|
||||
use crate::dataplane::{PacketSink, PluginContext, PluginRequest};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::{DeviceIdentity, NetworkId, NetworkKeys, NetworkName, NetworkSecret};
|
||||
use crate::net::EndpointAdapter;
|
||||
use crate::overlay::PacketCarrier;
|
||||
use crate::proto::handshake;
|
||||
use crate::proto::message::ControlMessage;
|
||||
use crate::state::Ipv4Range;
|
||||
use crate::storage::{CacheOutcome, Storage};
|
||||
|
||||
use crate::task::{TASK_GRACE, wind_down};
|
||||
use network::{InboundSession, NetCommand, NetworkHandle, RuntimeParams};
|
||||
use shutdown::Shutdown;
|
||||
|
||||
/// Summary of a configured network, whether or not it is running.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfiguredNetwork {
|
||||
/// Saved local broadcast participation.
|
||||
pub broadcast: bool,
|
||||
/// Public network identifier.
|
||||
pub network_id: NetworkId,
|
||||
/// Network name.
|
||||
@@ -87,6 +93,10 @@ struct Inner {
|
||||
accept_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||
plugin_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||
transport: std::sync::OnceLock<Arc<dyn PacketTransport>>,
|
||||
/// Who holds which overlay address, across every network.
|
||||
routes: Arc<crate::overlay::RoutingTable>,
|
||||
/// The one interface, when the agent was given a way to make one.
|
||||
interface: std::sync::OnceLock<Arc<crate::overlay::Interface>>,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
@@ -107,11 +117,69 @@ impl Inner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes an outbound packet to whichever protocol can carry it.
|
||||
///
|
||||
/// Holds a weak reference: the interface belongs to the agent, and a strong
|
||||
/// one here would keep the agent alive for as long as its own interface.
|
||||
struct Carrier(Weak<Inner>);
|
||||
|
||||
impl std::fmt::Debug for Carrier {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Carrier")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Sink {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Sink")
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketCarrier for Carrier {
|
||||
fn carry(&self, route: crate::overlay::Route, packet: bytes::Bytes) -> bool {
|
||||
let Some(inner) = self.0.upgrade() else {
|
||||
return false;
|
||||
};
|
||||
// Offered to each protocol in turn. With one configured this is a
|
||||
// single call; the shape is what allows several to be live at once,
|
||||
// each carrying the peers it has a link to.
|
||||
inner
|
||||
.config
|
||||
.plugins
|
||||
.iter()
|
||||
.any(|plugin| plugin.carry(route.network, route.peer, packet.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a packet a protocol decrypted to the interface.
|
||||
struct Sink(Weak<Inner>);
|
||||
|
||||
impl PacketSink for Sink {
|
||||
fn deliver<'a>(
|
||||
&'a self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
packet: bytes::Bytes,
|
||||
) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
let Some(inner) = self.0.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(interface) = inner.interface.get() else {
|
||||
return;
|
||||
};
|
||||
// The rejection is already counted on the interface; there is
|
||||
// nothing useful to do with it here.
|
||||
let _ = interface.deliver(network, peer, packet).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers the data plane transport's questions about the agent.
|
||||
///
|
||||
/// Holds a weak reference on purpose: the transport lives inside the agent, so
|
||||
/// a strong one would be a cycle and the agent — with its open databases and
|
||||
/// its directory lock — would never be released.
|
||||
/// Holds a weak reference for the same reason as [`Carrier`]: the transport
|
||||
/// lives inside the agent, so a strong one would be a cycle and the agent —
|
||||
/// with its open databases and its directory lock — would never be released.
|
||||
#[derive(Debug)]
|
||||
struct TransportCtx(Weak<Inner>);
|
||||
|
||||
@@ -178,17 +246,54 @@ impl Agent {
|
||||
accept_task: std::sync::Mutex::new(None),
|
||||
plugin_task: std::sync::Mutex::new(None),
|
||||
transport: std::sync::OnceLock::new(),
|
||||
routes: Arc::new(crate::overlay::RoutingTable::new()),
|
||||
interface: std::sync::OnceLock::new(),
|
||||
config,
|
||||
});
|
||||
|
||||
// One interface for the agent, if it was given a way to make one.
|
||||
// Failing to is reported and not fatal: the control plane works, and
|
||||
// so do the protocols, they just have nowhere to put packets.
|
||||
if let Some(factory) = inner.config.tun_factory.clone() {
|
||||
let carrier = Arc::new(Carrier(Arc::downgrade(&inner))) as Arc<dyn PacketCarrier>;
|
||||
match crate::overlay::Interface::start(
|
||||
factory,
|
||||
inner.config.interface_name.clone(),
|
||||
inner.config.interface_mtu,
|
||||
Arc::clone(&inner.routes),
|
||||
carrier,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(interface) => {
|
||||
let _ = inner.interface.set(Arc::new(interface));
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = inner.events.send(Event::PluginError {
|
||||
network: NetworkId::from_bytes([0u8; 32]),
|
||||
protocol: "overlay".into(),
|
||||
reason: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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(),
|
||||
Arc::clone(&inner.limits),
|
||||
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);
|
||||
|
||||
if let CacheOutcome::Reset(reason) = inner.storage.cache_outcome().clone() {
|
||||
@@ -205,7 +310,11 @@ impl Agent {
|
||||
// bound; overflow drops the request rather than stalling the plugin.
|
||||
if !inner.config.plugins.is_empty() {
|
||||
let (plugin_tx, plugin_rx) = mpsc::channel(64);
|
||||
let context = PluginContext::new(plugin_tx, inner.identity.endpoint_id());
|
||||
let sink = inner
|
||||
.interface
|
||||
.get()
|
||||
.map(|_| Arc::new(Sink(Arc::downgrade(&inner))) as Arc<dyn PacketSink>);
|
||||
let context = PluginContext::new(plugin_tx, inner.identity.endpoint_id(), sink);
|
||||
for plugin in &inner.config.plugins {
|
||||
plugin.attach(context.clone());
|
||||
}
|
||||
@@ -245,6 +354,138 @@ impl Agent {
|
||||
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.
|
||||
///
|
||||
/// Done here, where activations are serialised, rather than inside the
|
||||
/// runtime: whether a range is free depends on what the other networks
|
||||
/// took, and deciding that in a task would make the answer depend on
|
||||
/// which task ran first.
|
||||
///
|
||||
/// `None` means this agent will not propose a range for that network and
|
||||
/// 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:
|
||||
/// the lowest author's range wins, and the collision would spread.
|
||||
async fn reserve_range(&self, network: NetworkId) -> RangePlan {
|
||||
let reserve = |wanted: Ipv4Range| {
|
||||
let reservation = crate::overlay::NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some(wanted),
|
||||
local: None,
|
||||
peers: Vec::new(),
|
||||
};
|
||||
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
|
||||
// keeps the addresses it has always had.
|
||||
if reserve(configured) {
|
||||
return RangePlan {
|
||||
propose: Some(configured),
|
||||
..RangePlan::default()
|
||||
};
|
||||
}
|
||||
|
||||
// A second network on the same agent cannot have it — one agent,
|
||||
// one interface — so it falls back to the range derived from its
|
||||
// own id, which every one of its members derives identically
|
||||
// without being told.
|
||||
let derived = crate::state::derived_ipv4_range(network);
|
||||
if reserve(derived) {
|
||||
tracing::info!(
|
||||
%network,
|
||||
range = %derived,
|
||||
"another network here holds the configured range; this one uses the \
|
||||
range derived from its id unless its members settled another"
|
||||
);
|
||||
return RangePlan {
|
||||
fallback: Some(derived),
|
||||
conflict: Some(configured),
|
||||
..RangePlan::default()
|
||||
};
|
||||
}
|
||||
|
||||
tracing::info!(%network, "not proposing a range for this network");
|
||||
RangePlan {
|
||||
conflict: Some(configured),
|
||||
..RangePlan::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The overlay interface this agent owns, when it has one.
|
||||
///
|
||||
/// One agent, one interface, so this is not asked per network: a packet
|
||||
/// on it may belong to any of them.
|
||||
pub fn overlay(&self) -> Option<OverlayStatus> {
|
||||
let interface = self.inner.interface.get()?;
|
||||
Some(OverlayStatus {
|
||||
interface: interface.name().to_string(),
|
||||
on_host: interface.on_host(),
|
||||
mtu: interface.mtu(),
|
||||
addresses: interface.wanted_addresses(),
|
||||
counters: interface.counters(),
|
||||
broadcast_rules: interface.broadcast_rules(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The hostname announced to peers.
|
||||
pub fn hostname(&self) -> String {
|
||||
self.inner.read_hostname()
|
||||
@@ -315,13 +556,47 @@ impl Agent {
|
||||
&self,
|
||||
name: &NetworkName,
|
||||
secret: &NetworkSecret,
|
||||
) -> Result<NetworkId> {
|
||||
self.join_network_with_broadcast(name, secret, None).await
|
||||
}
|
||||
|
||||
/// Joins with an explicit broadcast choice; absent preserves saved policy.
|
||||
pub async fn join_network_with_broadcast(
|
||||
&self,
|
||||
name: &NetworkName,
|
||||
secret: &NetworkSecret,
|
||||
broadcast: Option<bool>,
|
||||
) -> Result<NetworkId> {
|
||||
let keys = NetworkKeys::derive(name, secret);
|
||||
let network_id = keys.network_id();
|
||||
|
||||
// A network's identity is its name *and* its secret, so the same
|
||||
// name with a different secret is a different network — and one that
|
||||
// looks identical in anything that shows a name. Almost always a
|
||||
// mistyped secret, so it is said out loud rather than left to be
|
||||
// discovered as an empty network sitting beside a working one.
|
||||
if let Ok(configured) = self.inner.storage.list_networks().await {
|
||||
for other in configured {
|
||||
if other.name == *name && other.network_id != network_id {
|
||||
tracing::warn!(
|
||||
"`{name}` is already configured with a different secret, as {}. \
|
||||
Joining with this one adds a second network under the same name; \
|
||||
they share nothing. Check the secret, or use \
|
||||
`tsng network secret` \
|
||||
to see which is which.",
|
||||
other.network_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.inner
|
||||
.storage
|
||||
.upsert_network(network_id, name.clone(), secret.clone(), true)
|
||||
.await?;
|
||||
if let Some(enabled) = broadcast {
|
||||
self.set_broadcast(network_id, enabled).await?;
|
||||
}
|
||||
match self.activate_with_keys(keys).await {
|
||||
// Already a member of exactly this network space: nothing to do.
|
||||
Ok(()) | Err(Error::NetworkAlreadyActive(_)) => Ok(network_id),
|
||||
@@ -329,6 +604,87 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Offers this agent as an exit node in a network, or stops, now and
|
||||
/// after restart. Off unless somebody turns it on.
|
||||
pub async fn set_exit_offer(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
self.inner
|
||||
.storage
|
||||
.set_exit_node(network_id, enabled)
|
||||
.await?;
|
||||
if self.is_active(network_id).await {
|
||||
let (reply, receive) = oneshot::channel();
|
||||
self.command(network_id, NetCommand::SetExitOffer { enabled, reply })
|
||||
.await?;
|
||||
receive.await.map_err(|_| Error::Stopped)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends this device's internet traffic through a member of a network,
|
||||
/// or back the ordinary way with `None`.
|
||||
///
|
||||
/// A device has one default route, so choosing in one network clears the
|
||||
/// choice in the others. The member has to be connected and offering
|
||||
/// to be an exit node. Remembered across restarts.
|
||||
pub async fn set_exit_via(&self, network_id: NetworkId, via: Option<EndpointId>) -> Result<()> {
|
||||
let active = self.is_active(network_id).await;
|
||||
if via.is_some() && !active {
|
||||
return Err(Error::NetworkNotActive(network_id));
|
||||
}
|
||||
if active {
|
||||
let (reply, receive) = oneshot::channel();
|
||||
self.command(network_id, NetCommand::SetExitVia { via, reply })
|
||||
.await?;
|
||||
receive
|
||||
.await
|
||||
.map_err(|_| Error::Stopped)?
|
||||
.map_err(|reason| Error::Rejected { reason })?;
|
||||
}
|
||||
self.inner
|
||||
.storage
|
||||
.set_exit_via(network_id, via.map(|peer| *peer.as_bytes()))
|
||||
.await?;
|
||||
if via.is_some() {
|
||||
// One default route: whatever the other networks were doing, they
|
||||
// are not any more.
|
||||
let others: Vec<NetworkId> = self
|
||||
.inner
|
||||
.networks
|
||||
.read()
|
||||
.await
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|other| *other != network_id)
|
||||
.collect();
|
||||
for other in others {
|
||||
let (reply, receive) = oneshot::channel();
|
||||
if self
|
||||
.command(other, NetCommand::SetExitVia { via: None, reply })
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let _ = receive.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Changes a network's local broadcast policy now and after restart.
|
||||
pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
self.inner
|
||||
.storage
|
||||
.set_broadcast(network_id, enabled)
|
||||
.await?;
|
||||
if self.is_active(network_id).await {
|
||||
let (reply, receive) = oneshot::channel();
|
||||
self.command(network_id, NetCommand::SetBroadcast { enabled, reply })
|
||||
.await?;
|
||||
receive.await.map_err(|_| Error::Stopped)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Activates a configured network that is currently inactive.
|
||||
///
|
||||
/// Fails with [`Error::NetworkAlreadyActive`] if it is already running.
|
||||
@@ -357,20 +713,59 @@ impl Agent {
|
||||
return Err(Error::NetworkAlreadyActive(network_id));
|
||||
}
|
||||
|
||||
let range = self.reserve_range(network_id).await;
|
||||
let mut backends = Vec::new();
|
||||
if let Some(discovery) = &self.inner.config.discovery {
|
||||
backends.push(discovery.clone());
|
||||
}
|
||||
if let Some(dht) = &self.inner.config.dht {
|
||||
backends.push(dht.for_network(&keys));
|
||||
}
|
||||
let discovery = if backends.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
Arc::new(crate::discovery::CompositeDiscovery::new(backends))
|
||||
as Arc<dyn crate::discovery::NetworkDiscovery>,
|
||||
)
|
||||
};
|
||||
let stored = self
|
||||
.inner
|
||||
.storage
|
||||
.list_networks()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|stored| stored.network_id == network_id);
|
||||
let broadcast = stored.as_ref().is_none_or(|stored| stored.broadcast);
|
||||
let exit_offer = stored.as_ref().is_some_and(|stored| stored.exit_node);
|
||||
let exit_via = stored
|
||||
.as_ref()
|
||||
.and_then(|stored| stored.exit_via)
|
||||
.and_then(|bytes| EndpointId::from_bytes(&bytes).ok());
|
||||
let handle = network::spawn(RuntimeParams {
|
||||
broadcast,
|
||||
exit_offer,
|
||||
exit_via,
|
||||
keys,
|
||||
adapter: self.inner.adapter.clone(),
|
||||
storage: self.inner.storage.clone(),
|
||||
events: self.inner.events.clone(),
|
||||
limits: Arc::clone(&self.inner.limits),
|
||||
reconnect: self.inner.config.reconnect.clone(),
|
||||
discovery: self.inner.config.discovery.clone(),
|
||||
discovery,
|
||||
discovery_interval: self.inner.config.discovery_interval,
|
||||
discovery_policy: self.inner.config.discovery_policy.clone(),
|
||||
plugins: self.inner.config.plugins.clone(),
|
||||
routes: Arc::clone(&self.inner.routes),
|
||||
interface: self.inner.interface.get().cloned(),
|
||||
hostname: self.inner.read_hostname(),
|
||||
transport: self.inner.transport.get().cloned(),
|
||||
device_secret: self.inner.identity.signing_key(),
|
||||
ipv4_range: self.inner.config.overlay_ipv4_range,
|
||||
#[cfg(feature = "testing")]
|
||||
unreachable_data_peers: Arc::clone(&self.inner.config.unreachable_data_peers),
|
||||
ipv4_range: range.propose,
|
||||
ipv4_fallback: range.fallback,
|
||||
range_conflict: range.conflict,
|
||||
});
|
||||
networks.insert(network_id, handle);
|
||||
drop(networks);
|
||||
@@ -401,17 +796,78 @@ impl Agent {
|
||||
for plugin in &self.inner.config.plugins {
|
||||
plugin.on_network_deactivated(network_id);
|
||||
}
|
||||
// The network's addresses come off the interface; the interface
|
||||
// itself stays, because the agent owns it and other networks may
|
||||
// still be using it.
|
||||
self.inner.routes.remove_network(network_id);
|
||||
if let Some(interface) = self.inner.interface.get()
|
||||
&& let Err(err) = interface.sync_addresses().await
|
||||
{
|
||||
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?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deactivates a network if it is running and removes it from the state
|
||||
/// store together with its cached hints.
|
||||
///
|
||||
/// Local and silent: nobody is told. [`Agent::leave_network`] is the one
|
||||
/// that says goodbye first, and is what a person means by leaving.
|
||||
pub async fn forget_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
if self.is_active(network_id).await {
|
||||
self.deactivate_network(network_id).await?;
|
||||
}
|
||||
self.inner.storage.remove_network(network_id).await
|
||||
self.inner.storage.remove_network(network_id).await?;
|
||||
// Whatever a protocol kept for this network goes with it. The
|
||||
// agent cannot know what that is, only that there is no longer any
|
||||
// reason to hold it.
|
||||
for plugin in &self.inner.config.plugins {
|
||||
plugin.on_network_forgotten(network_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leaves a network: gives up what was claimed, then forgets it.
|
||||
///
|
||||
/// The order matters and cannot be improved on. A signed `Release` goes
|
||||
/// out first, while there are still sessions to carry it, so the address
|
||||
/// and name this agent held are freed for somebody else rather than
|
||||
/// staying reserved to a member that has gone. Only then is the network
|
||||
/// deactivated and removed.
|
||||
///
|
||||
/// Reaching every member is not on offer and never could be: a member
|
||||
/// that is away hears the tombstone from the ones that were here, the
|
||||
/// same way it hears everything else. Leaving with nobody connected
|
||||
/// tells nobody, and says so in the outcome rather than pretending.
|
||||
///
|
||||
/// The author's version counter is deliberately **kept**. Rejoining the
|
||||
/// same network with the same device key must continue from a higher
|
||||
/// version than the release, or every replica would treat the new claim
|
||||
/// as stale and ignore it.
|
||||
pub async fn leave_network(&self, network_id: NetworkId) -> Result<LeaveOutcome> {
|
||||
let mut outcome = LeaveOutcome::default();
|
||||
if self.is_active(network_id).await {
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
self.command(network_id, NetCommand::Release { reply: reply_tx })
|
||||
.await?;
|
||||
if let Ok(peers) = reply_rx.await {
|
||||
outcome.announced = true;
|
||||
outcome.peers_told = peers;
|
||||
}
|
||||
// The tombstone is queued on each session, not yet written to
|
||||
// the wire. A short pause is the difference between peers
|
||||
// hearing it now and hearing it from somebody else much later.
|
||||
if outcome.peers_told > 0 {
|
||||
tokio::time::sleep(RELEASE_FLUSH).await;
|
||||
}
|
||||
}
|
||||
self.forget_network(network_id).await?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Whether a network is currently running.
|
||||
@@ -429,6 +885,7 @@ impl Agent {
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|stored| ConfiguredNetwork {
|
||||
broadcast: stored.broadcast,
|
||||
network_id: stored.network_id,
|
||||
name: stored.name,
|
||||
auto_start: stored.auto_start,
|
||||
@@ -503,16 +960,26 @@ impl Agent {
|
||||
}
|
||||
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
||||
networks.push(NetworkStatus {
|
||||
broadcast: stored.broadcast,
|
||||
exit_offer: stored.exit_node,
|
||||
exit_via: stored
|
||||
.exit_via
|
||||
.and_then(|bytes| EndpointId::from_bytes(&bytes).ok()),
|
||||
exit_via_online: false,
|
||||
exit_rules: Default::default(),
|
||||
descriptor: keys.descriptor(),
|
||||
name: stored.name,
|
||||
network_id: stored.network_id,
|
||||
state: NetworkState::Inactive,
|
||||
peers: Vec::new(),
|
||||
candidates: Vec::new(),
|
||||
// An inactive network has no runtime to ask; the roster comes
|
||||
// from one. Empty, not invented.
|
||||
// An inactive network has no runtime to ask; the roster and
|
||||
// the range come from one. Empty, not invented.
|
||||
members: Vec::new(),
|
||||
range: None,
|
||||
range_conflict: None,
|
||||
metrics: NetworkMetrics::default(),
|
||||
relay: Default::default(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -537,7 +1004,8 @@ impl Agent {
|
||||
self.command(network_id, NetCommand::Reannounce).await
|
||||
}
|
||||
|
||||
/// Asks one network to re-run discovery and re-evaluate dials right now.
|
||||
/// Asks one network to re-evaluate known peers and dials right now.
|
||||
/// External lookup still follows its connected/isolation policy.
|
||||
///
|
||||
/// Call this when the host's network environment changed. Platform wake-up
|
||||
/// notifications can be wired to it later.
|
||||
@@ -545,7 +1013,7 @@ impl Agent {
|
||||
self.command(network_id, NetCommand::Recheck).await
|
||||
}
|
||||
|
||||
/// Asks every running network to re-run discovery right now.
|
||||
/// Asks every running network to re-evaluate known peers right now.
|
||||
pub async fn recheck(&self) {
|
||||
let senders: Vec<mpsc::Sender<NetCommand>> = self
|
||||
.inner
|
||||
@@ -574,15 +1042,35 @@ impl Agent {
|
||||
handle.stop().await;
|
||||
}
|
||||
|
||||
self.inner.adapter.close().await;
|
||||
if let Some(dht) = &self.inner.config.dht {
|
||||
dht.shutdown().await;
|
||||
}
|
||||
|
||||
for handle in [&self.inner.accept_task, &self.inner.plugin_task] {
|
||||
// iroh's own close waits for peers to acknowledge; a peer that has
|
||||
// gone silent must not decide how long that takes.
|
||||
if tokio::time::timeout(TASK_GRACE, self.inner.adapter.close())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("the endpoint did not close in time; abandoning it");
|
||||
}
|
||||
|
||||
for (what, handle) in [
|
||||
("inbound connections", &self.inner.accept_task),
|
||||
("plugin requests", &self.inner.plugin_task),
|
||||
] {
|
||||
let task = handle.lock().ok().and_then(|mut guard| guard.take());
|
||||
if let Some(task) = task {
|
||||
let _ = task.await;
|
||||
wind_down(task, TASK_GRACE, what).await;
|
||||
}
|
||||
}
|
||||
|
||||
// The interface goes with the agent that owns it. Before the
|
||||
// plugins, so nothing is still trying to write to it.
|
||||
if let Some(interface) = self.inner.interface.get() {
|
||||
interface.remove().await;
|
||||
}
|
||||
|
||||
// Plugins remove whatever system objects they created. A plugin that
|
||||
// misbehaves here must not hold up the agent, so this is bounded.
|
||||
for plugin in &self.inner.config.plugins {
|
||||
@@ -613,6 +1101,41 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a network may propose as its overlay range, and what it cannot have.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct RangePlan {
|
||||
/// Proposed straight away: the configured range, when it is free here.
|
||||
propose: Option<Ipv4Range>,
|
||||
/// Proposed after a moment, when the configured range is another
|
||||
/// network's: the range derived from this network's own id.
|
||||
fallback: Option<Ipv4Range>,
|
||||
/// The configured range, when this network cannot have it.
|
||||
conflict: Option<Ipv4Range>,
|
||||
}
|
||||
|
||||
/// How long a release is given to reach the sessions it was queued on.
|
||||
///
|
||||
/// Short: it is one small message on a connection that is already open, and
|
||||
/// the alternative to waiting at all is tearing the sessions down underneath
|
||||
/// it.
|
||||
const RELEASE_FLUSH: std::time::Duration = std::time::Duration::from_millis(300);
|
||||
|
||||
/// What happened when an agent left a network.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct LeaveOutcome {
|
||||
/// Whether a signed release was published at all.
|
||||
///
|
||||
/// `false` when the network was not running: there was nothing to
|
||||
/// publish it from, so this was a local removal only.
|
||||
pub announced: bool,
|
||||
/// How many connected peers it was sent to.
|
||||
///
|
||||
/// Zero with `announced` true means the tombstone is in this agent's
|
||||
/// own state and nowhere else, and it is leaving with it — so nobody
|
||||
/// will learn of it.
|
||||
pub peers_told: usize,
|
||||
}
|
||||
|
||||
/// How long each plugin gets to tear itself down during agent shutdown.
|
||||
const PLUGIN_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
@@ -680,6 +1203,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -825,8 +1368,23 @@ async fn handle_inbound_data(inner: Arc<Inner>, conn: iroh::endpoint::Connection
|
||||
conn.close(5u32.into(), b"data plane not ready");
|
||||
return;
|
||||
};
|
||||
// Downcasting is avoided by keeping the accept side on the concrete type.
|
||||
let Some(iroh_transport) = transport.as_ref().as_any().downcast_ref::<IrohTransport>() else {
|
||||
let iroh_transport =
|
||||
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");
|
||||
return;
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -73,17 +73,21 @@ pub(crate) struct Session {
|
||||
pub(crate) outbound: mpsc::Sender<Vec<u8>>,
|
||||
pub(crate) hostname: Option<String>,
|
||||
pub(crate) capabilities: Vec<PluginCapability>,
|
||||
pub(crate) broadcast: bool,
|
||||
/// The peer offers itself as an exit node in this network.
|
||||
pub(crate) exit_node: bool,
|
||||
pub(crate) messages_sent: u64,
|
||||
pub(crate) messages_received: u64,
|
||||
pub(crate) bytes_sent: u64,
|
||||
pub(crate) bytes_received: u64,
|
||||
reader: JoinHandle<()>,
|
||||
writer: JoinHandle<()>,
|
||||
path_monitor: JoinHandle<()>,
|
||||
shutdown: Shutdown,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Signals both tasks to stop and waits for them, with a bounded grace
|
||||
/// Signals all tasks to stop and waits for them, with a bounded grace
|
||||
/// period.
|
||||
///
|
||||
/// A peer that stops reading must not be able to hold up shutdown, so the
|
||||
@@ -93,6 +97,7 @@ impl Session {
|
||||
conn,
|
||||
reader,
|
||||
writer,
|
||||
path_monitor,
|
||||
shutdown,
|
||||
..
|
||||
} = self;
|
||||
@@ -102,23 +107,27 @@ impl Session {
|
||||
|
||||
let reader_abort = reader.abort_handle();
|
||||
let writer_abort = writer.abort_handle();
|
||||
let path_abort = path_monitor.abort_handle();
|
||||
let joined = tokio::time::timeout(STOP_GRACE, async move {
|
||||
let _ = reader.await;
|
||||
let _ = writer.await;
|
||||
let _ = path_monitor.await;
|
||||
})
|
||||
.await;
|
||||
if joined.is_err() {
|
||||
tracing::debug!("session tasks did not wind down in time; aborting them");
|
||||
reader_abort.abort();
|
||||
writer_abort.abort();
|
||||
path_abort.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Aborts both tasks without waiting. Used on replacement.
|
||||
/// Aborts all tasks without waiting. Used on replacement.
|
||||
pub(crate) fn abort(&self) {
|
||||
self.shutdown.trigger();
|
||||
self.reader.abort();
|
||||
self.writer.abort();
|
||||
self.path_monitor.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +163,52 @@ pub(crate) fn spawn(
|
||||
parent_shutdown.clone(),
|
||||
));
|
||||
|
||||
let path_conn = conn.clone();
|
||||
let path_shutdown = shutdown.clone();
|
||||
let path_parent = parent_shutdown.clone();
|
||||
let path_monitor = tokio::spawn(async move {
|
||||
use tokio_stream::StreamExt;
|
||||
let mut events = path_conn.path_events();
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = path_shutdown.wait() => break,
|
||||
_ = path_parent.wait() => break,
|
||||
ev = events.next() => {
|
||||
match ev {
|
||||
Some(iroh::endpoint::PathEvent::Selected { remote_addr, local_addr, .. }) => {
|
||||
tracing::debug!(
|
||||
peer = %peer.fmt_short(),
|
||||
remote = %remote_addr,
|
||||
local = ?local_addr,
|
||||
"Connection path selected (direct / relay / hole punch switch)"
|
||||
);
|
||||
}
|
||||
Some(iroh::endpoint::PathEvent::Opened { remote_addr, .. }) => {
|
||||
tracing::debug!(
|
||||
peer = %peer.fmt_short(),
|
||||
remote = %remote_addr,
|
||||
"Network path opened to peer"
|
||||
);
|
||||
}
|
||||
Some(iroh::endpoint::PathEvent::Closed { remote_addr, .. }) => {
|
||||
tracing::debug!(
|
||||
peer = %peer.fmt_short(),
|
||||
remote = %remote_addr,
|
||||
"Network path closed for peer"
|
||||
);
|
||||
}
|
||||
Some(iroh::endpoint::PathEvent::Lagged { missed, .. }) => {
|
||||
tracing::trace!(peer = %peer.fmt_short(), missed, "Path events lagged");
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let reader = tokio::spawn(reader_task(
|
||||
id,
|
||||
network_id,
|
||||
@@ -174,12 +229,15 @@ pub(crate) fn spawn(
|
||||
outbound: outbound_tx,
|
||||
hostname: None,
|
||||
capabilities: Vec::new(),
|
||||
broadcast: false,
|
||||
exit_node: false,
|
||||
messages_sent: 0,
|
||||
messages_received: 0,
|
||||
bytes_sent: 0,
|
||||
bytes_received: 0,
|
||||
reader,
|
||||
writer,
|
||||
path_monitor,
|
||||
shutdown,
|
||||
}
|
||||
}
|
||||
@@ -51,3 +51,25 @@ impl Shutdown {
|
||||
// The sender is gone, which for our purposes means "stop".
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn waiting_resolves_once_triggered_and_stays_resolved() {
|
||||
let shutdown = Shutdown::new();
|
||||
assert!(!shutdown.is_triggered());
|
||||
let waiter = shutdown.clone();
|
||||
let waiting = tokio::spawn(async move { waiter.wait().await });
|
||||
shutdown.trigger();
|
||||
waiting.await.unwrap();
|
||||
assert!(shutdown.is_triggered());
|
||||
// Triggering twice is not an error, and a token cloned afterwards
|
||||
// sees the trigger that already happened.
|
||||
shutdown.trigger();
|
||||
shutdown.clone().wait().await;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,29 @@ pub struct CandidateStatus {
|
||||
pub consecutive_failures: u32,
|
||||
}
|
||||
|
||||
/// The overlay interface, as the agent sees it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OverlayStatus {
|
||||
/// The interface name the operating system gave.
|
||||
pub interface: String,
|
||||
/// Whether that interface exists on the host.
|
||||
///
|
||||
/// `false` with an in-memory device: the name is real to the agent and
|
||||
/// to nothing else. Reported, because everything that would configure
|
||||
/// the operating system for this interface must not when it is this.
|
||||
pub on_host: bool,
|
||||
/// Its MTU.
|
||||
pub mtu: u32,
|
||||
/// The addresses it should be carrying.
|
||||
pub addresses: Vec<crate::overlay::Cidr>,
|
||||
/// What has happened on it.
|
||||
pub counters: crate::overlay::Counters,
|
||||
/// The route and firewall allowance installed for LAN broadcast, when
|
||||
/// some network has broadcast on and the interface is on the host.
|
||||
/// `None` is "none installed", not "unknown".
|
||||
pub broadcast_rules: Option<crate::overlay::BroadcastRulesReport>,
|
||||
}
|
||||
|
||||
/// A member the signed state knows about, connected or not.
|
||||
///
|
||||
/// This is the durable roster: it comes from signed records, so a member that
|
||||
@@ -57,11 +80,20 @@ pub struct MemberStatus {
|
||||
/// Signed, so it is still known while the member is away — which is what
|
||||
/// lets an absent member be named rather than shown as a bare id.
|
||||
pub hostname: Option<String>,
|
||||
/// The last name this agent saw it announce, whether or not it is here.
|
||||
///
|
||||
/// Not signed, so it is a convenience for naming a member that is away
|
||||
/// and never a claim: the zone is served from `hostname`.
|
||||
pub last_hostname: Option<String>,
|
||||
}
|
||||
|
||||
/// Status of one authenticated session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeerStatus {
|
||||
/// This authenticated peer accepts network broadcasts.
|
||||
pub broadcast: bool,
|
||||
/// This authenticated peer offers itself as an exit node.
|
||||
pub exit_node: bool,
|
||||
/// Authenticated endpoint id.
|
||||
pub endpoint_id: EndpointId,
|
||||
/// Which side this agent played in the handshake.
|
||||
@@ -70,6 +102,12 @@ pub struct PeerStatus {
|
||||
///
|
||||
/// A mutable binding, not an identity.
|
||||
pub hostname: Option<String>,
|
||||
/// Protocols agreed with this peer: both sides have them at the same
|
||||
/// version.
|
||||
///
|
||||
/// Empty means no data plane with that peer, which leaves the control
|
||||
/// plane working — messages and state still flow.
|
||||
pub protocols: Vec<String>,
|
||||
/// Capabilities the peer announced. Payloads stay opaque.
|
||||
pub capabilities: Vec<PluginCapability>,
|
||||
/// How long the session has been up.
|
||||
@@ -126,6 +164,16 @@ pub struct NetworkMetrics {
|
||||
/// Status of one network.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetworkStatus {
|
||||
/// Local broadcast participation in this network.
|
||||
pub broadcast: bool,
|
||||
/// This agent offers itself as an exit node in this network.
|
||||
pub exit_offer: bool,
|
||||
/// The member this device sends all its internet traffic through.
|
||||
pub exit_via: Option<EndpointId>,
|
||||
/// Whether that member is connected and still offering to be one.
|
||||
pub exit_via_online: bool,
|
||||
/// What the host rules for the exit settings are doing.
|
||||
pub exit_rules: crate::overlay::ExitRulesReport,
|
||||
/// Immutable deterministic description of the network space.
|
||||
pub descriptor: NetworkDescriptor,
|
||||
/// Network name, for convenience.
|
||||
@@ -140,8 +188,19 @@ pub struct NetworkStatus {
|
||||
pub candidates: Vec<CandidateStatus>,
|
||||
/// Members the signed state knows about, whether connected or not.
|
||||
pub members: Vec<MemberStatus>,
|
||||
/// The overlay range this network uses, once it has one.
|
||||
pub range: Option<crate::state::Ipv4Range>,
|
||||
/// The range it could not have, because another network on this agent
|
||||
/// already holds it.
|
||||
///
|
||||
/// One agent has one interface, so an address belongs to one network.
|
||||
/// This network waits to adopt whatever its members settle on instead of
|
||||
/// proposing something it could not route.
|
||||
pub range_conflict: Option<crate::state::Ipv4Range>,
|
||||
/// Per-network counters.
|
||||
pub metrics: NetworkMetrics,
|
||||
/// What has gone through a peer in the middle, in both directions.
|
||||
pub relay: crate::dataplane::relay::RelayCounters,
|
||||
}
|
||||
|
||||
impl NetworkStatus {
|
||||
@@ -11,12 +11,42 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::dataplane::SharedPlugin;
|
||||
use crate::discovery::NetworkDiscovery;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::discovery::{MainlineDiscovery, NetworkDiscovery};
|
||||
use crate::error::Result;
|
||||
|
||||
/// Qualifier/organisation/application triple used for platform directories.
|
||||
///
|
||||
/// Unused on macOS, where the default paths are fixed system locations rather
|
||||
/// than per-user directories derived from this.
|
||||
#[cfg_attr(target_os = "macos", allow(dead_code))]
|
||||
const APP_NAME: &str = "tsunagi";
|
||||
|
||||
/// Rendezvous slots per network; this bounds a discovery sample, not membership.
|
||||
pub const DHT_SLOTS: u8 = 16;
|
||||
/// BEP44 permits 1000 bencoded bytes; a 996-byte string has a four-byte prefix.
|
||||
pub const DHT_MAX_VALUE: usize = 996;
|
||||
/// Maximum direct addresses in one rendezvous record.
|
||||
pub const DHT_MAX_ADDRS: usize = 8;
|
||||
/// Maximum relay URL bytes in a rendezvous record.
|
||||
pub const DHT_MAX_RELAY_LEN: usize = 512;
|
||||
/// Application freshness, independent of storage nodes' own expiration.
|
||||
pub const DHT_RECORD_TTL: Duration = Duration::from_secs(900);
|
||||
/// Clock skew tolerated when reading a recently published record.
|
||||
pub const DHT_CLOCK_SKEW: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Maximum reassembled data-transport payload, independent of the path MTU.
|
||||
pub const MAX_DATA_DATAGRAM: usize = 64 * 1024;
|
||||
/// Maximum incomplete datagrams held by one authenticated data link.
|
||||
pub const MAX_DATA_ASSEMBLIES: usize = 64;
|
||||
/// Maximum allocated payload bytes for incomplete datagrams on one data link.
|
||||
pub const MAX_DATA_REASSEMBLY_BYTES: usize = 256 * 1024;
|
||||
/// Maximum fragments of one logical datagram, including after a path MTU change.
|
||||
pub const MAX_DATA_FRAGMENTS: usize = 128;
|
||||
/// Time allowed to assemble a datagram; loss never stalls later datagrams.
|
||||
pub const DATA_REASSEMBLY_TTL: Duration = Duration::from_secs(5);
|
||||
/// Completed or discarded packet IDs retained to ignore late duplicate fragments.
|
||||
pub const DATA_RECENT_IDS: usize = 128;
|
||||
|
||||
/// Where the two stores live.
|
||||
///
|
||||
/// The mandatory state and the disposable cache are separate both logically and
|
||||
@@ -48,17 +78,34 @@ impl StoragePaths {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-user platform directories.
|
||||
/// The default platform directories.
|
||||
///
|
||||
/// A future system service can supply its own paths instead.
|
||||
/// Per user on Linux and Windows. On macOS the agent runs as a root
|
||||
/// LaunchDaemon (creating a utun interface needs root, and `$HOME` under
|
||||
/// `sudo` points at the wrong place), so the stores live in the system
|
||||
/// locations instead — `/var/db/tsunagi` for the mandatory state and a
|
||||
/// disposable cache beside it. A caller that wants somewhere else passes
|
||||
/// explicit paths to [`StoragePaths::new`] or [`StoragePaths::under`].
|
||||
pub fn user_default() -> Result<Self> {
|
||||
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
|
||||
Error::Storage("no valid home directory for platform config paths".into())
|
||||
})?;
|
||||
Ok(Self {
|
||||
state_dir: dirs.data_dir().to_path_buf(),
|
||||
cache_dir: dirs.cache_dir().to_path_buf(),
|
||||
})
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Ok(Self {
|
||||
state_dir: PathBuf::from("/var/db/tsunagi"),
|
||||
cache_dir: PathBuf::from("/var/db/tsunagi/cache"),
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
|
||||
crate::error::Error::Storage(
|
||||
"no valid home directory for platform config paths".into(),
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
state_dir: dirs.data_dir().to_path_buf(),
|
||||
cache_dir: dirs.cache_dir().to_path_buf(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Path of the mandatory state database.
|
||||
@@ -137,6 +184,8 @@ pub struct Limits {
|
||||
pub write_timeout: Duration,
|
||||
/// Maximum simultaneous outbound dials per network.
|
||||
pub max_concurrent_dials: usize,
|
||||
/// Maximum unverified discovery candidates retained per network.
|
||||
pub max_discovery_candidates: usize,
|
||||
/// Maximum simultaneous authenticated sessions per network.
|
||||
pub max_sessions_per_network: usize,
|
||||
/// Maximum simultaneous inbound connections being handshaken.
|
||||
@@ -163,6 +212,7 @@ impl Default for Limits {
|
||||
dial_timeout: Duration::from_secs(10),
|
||||
write_timeout: Duration::from_secs(30),
|
||||
max_concurrent_dials: 8,
|
||||
max_discovery_candidates: 16,
|
||||
max_sessions_per_network: 64,
|
||||
max_inbound_handshakes: 32,
|
||||
session_send_queue: 64,
|
||||
@@ -212,6 +262,33 @@ impl ReconnectPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scheduling of candidate lookup and independent self-publication.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveryPolicy {
|
||||
/// Mean interval between successful self-publications (20% jitter).
|
||||
pub publish_interval: Duration,
|
||||
/// Initial delay between unsuccessful bootstrap lookups.
|
||||
pub lookup_interval: Duration,
|
||||
/// Maximum delay between bootstrap lookups.
|
||||
pub max_lookup_interval: Duration,
|
||||
/// Continuous isolation before a previously connected network searches again.
|
||||
pub reconnect_delay: Duration,
|
||||
/// Deadline for one backend operation.
|
||||
pub request_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for DiscoveryPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
publish_interval: Duration::from_secs(300),
|
||||
lookup_interval: Duration::from_secs(5),
|
||||
max_lookup_interval: Duration::from_secs(30),
|
||||
reconnect_delay: Duration::from_secs(60),
|
||||
request_timeout: Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything needed to start an [`crate::Agent`].
|
||||
#[derive(Clone)]
|
||||
pub struct AgentConfig {
|
||||
@@ -223,13 +300,24 @@ pub struct AgentConfig {
|
||||
pub bind_addrs: Vec<SocketAddr>,
|
||||
/// How much external connectivity machinery the endpoint may use.
|
||||
pub transport: TransportPolicy,
|
||||
/// Real QUIC transport settings for constrained-path integration tests.
|
||||
#[cfg(feature = "testing")]
|
||||
pub test_quic_transport: Option<iroh::endpoint::QuicTransportConfig>,
|
||||
/// Peers with no direct data path, for tests. See
|
||||
/// [`AgentConfig::with_unreachable_data_peers`].
|
||||
#[cfg(feature = "testing")]
|
||||
pub unreachable_data_peers: Arc<std::sync::Mutex<std::collections::HashSet<iroh::EndpointId>>>,
|
||||
/// Hostname announced to peers. `None` keeps whatever the state store holds,
|
||||
/// falling back to the OS hostname and finally to a short endpoint id.
|
||||
pub hostname: Option<String>,
|
||||
/// Discovery backend. `None` disables discovery-driven dialling; static
|
||||
/// bootstrap candidates still work.
|
||||
/// Additional discovery backend, composed with Mainline when it is enabled.
|
||||
pub discovery: Option<Arc<dyn NetworkDiscovery>>,
|
||||
/// How often each active network re-runs discovery and re-evaluates dials.
|
||||
/// Optional Mainline client, shared by this agent's networks. The CLI enables
|
||||
/// it by default except in local-only mode; library callers opt in explicitly.
|
||||
pub dht: Option<MainlineDiscovery>,
|
||||
/// Lookup, publication and recovery scheduling.
|
||||
pub discovery_policy: DiscoveryPolicy,
|
||||
/// How often each active network maintains known peers and observes address changes.
|
||||
pub discovery_interval: Duration,
|
||||
/// Bounds applied to network input.
|
||||
pub limits: Limits,
|
||||
@@ -237,6 +325,20 @@ pub struct AgentConfig {
|
||||
pub reconnect: ReconnectPolicy,
|
||||
/// IP plugins whose capabilities are announced and dispatched.
|
||||
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.
|
||||
///
|
||||
/// One agent, one interface: it belongs here rather than to a protocol,
|
||||
/// because every protocol carries traffic for the same addresses on it.
|
||||
/// `None` means no interface — the protocols still run and their packets
|
||||
/// are discarded.
|
||||
pub tun_factory: Option<Arc<dyn crate::overlay::TunFactory>>,
|
||||
/// The name to ask the operating system for.
|
||||
pub interface_name: String,
|
||||
/// The interface MTU.
|
||||
pub interface_mtu: u32,
|
||||
/// The IPv4 overlay range this agent proposes.
|
||||
///
|
||||
/// Addresses are allocated from it and recorded in signed state, so a
|
||||
@@ -253,12 +355,24 @@ impl AgentConfig {
|
||||
paths,
|
||||
bind_addrs: Vec::new(),
|
||||
transport: TransportPolicy::default(),
|
||||
#[cfg(feature = "testing")]
|
||||
test_quic_transport: None,
|
||||
#[cfg(feature = "testing")]
|
||||
unreachable_data_peers: Arc::new(std::sync::Mutex::new(
|
||||
std::collections::HashSet::new(),
|
||||
)),
|
||||
hostname: None,
|
||||
discovery: None,
|
||||
dht: None,
|
||||
discovery_policy: DiscoveryPolicy::default(),
|
||||
discovery_interval: Duration::from_secs(5),
|
||||
limits: Limits::default(),
|
||||
reconnect: ReconnectPolicy::default(),
|
||||
plugins: Vec::new(),
|
||||
custom_transports: std::collections::HashMap::new(),
|
||||
tun_factory: None,
|
||||
interface_name: "tsun0".to_string(),
|
||||
interface_mtu: 1280,
|
||||
overlay_ipv4_range: Some(crate::state::DEFAULT_IPV4_RANGE),
|
||||
}
|
||||
}
|
||||
@@ -278,6 +392,21 @@ impl AgentConfig {
|
||||
self
|
||||
}
|
||||
|
||||
/// Peers this agent will not open or accept a **direct** data link with.
|
||||
///
|
||||
/// Test-only, and behind the `testing` feature so a release build has
|
||||
/// no such switch. The one arrangement that cannot be made on a single
|
||||
/// host is two agents that both reach a third and not each other,
|
||||
/// which is exactly the case a relay exists for.
|
||||
#[cfg(feature = "testing")]
|
||||
pub fn with_unreachable_data_peers(
|
||||
mut self,
|
||||
peers: Arc<std::sync::Mutex<std::collections::HashSet<iroh::EndpointId>>>,
|
||||
) -> Self {
|
||||
self.unreachable_data_peers = peers;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the transport policy.
|
||||
pub fn with_transport(mut self, transport: TransportPolicy) -> Self {
|
||||
self.transport = transport;
|
||||
@@ -290,7 +419,19 @@ impl AgentConfig {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets how often discovery runs.
|
||||
/// Adds Mainline discovery alongside the configured candidate backend.
|
||||
pub fn with_dht(mut self, dht: MainlineDiscovery) -> Self {
|
||||
self.dht = Some(dht);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets lookup, publication and isolation recovery timing.
|
||||
pub fn with_discovery_policy(mut self, policy: DiscoveryPolicy) -> Self {
|
||||
self.discovery_policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the known-peer maintenance and endpoint-address observation interval.
|
||||
pub fn with_discovery_interval(mut self, interval: Duration) -> Self {
|
||||
self.discovery_interval = interval;
|
||||
self
|
||||
@@ -314,6 +455,29 @@ impl AgentConfig {
|
||||
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.
|
||||
pub fn with_interface(
|
||||
mut self,
|
||||
factory: Arc<dyn crate::overlay::TunFactory>,
|
||||
name: impl Into<String>,
|
||||
mtu: u32,
|
||||
) -> Self {
|
||||
self.tun_factory = Some(factory);
|
||||
self.interface_name = name.into();
|
||||
self.interface_mtu = mtu;
|
||||
self
|
||||
}
|
||||
|
||||
/// Replaces the limits.
|
||||
pub fn with_limits(mut self, limits: Limits) -> Self {
|
||||
self.limits = limits;
|
||||
@@ -335,6 +499,8 @@ impl std::fmt::Debug for AgentConfig {
|
||||
.field("transport", &self.transport)
|
||||
.field("hostname", &self.hostname)
|
||||
.field("discovery", &self.discovery.as_ref().map(|d| d.name()))
|
||||
.field("dht", &self.dht.is_some())
|
||||
.field("discovery_policy", &self.discovery_policy)
|
||||
.field("discovery_interval", &self.discovery_interval)
|
||||
.field("limits", &self.limits)
|
||||
.field("reconnect", &self.reconnect)
|
||||
@@ -349,3 +515,5 @@ impl std::fmt::Debug for AgentConfig {
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
/// Maximum number of transport links a routed datagram may traverse.
|
||||
pub const ROUTING_HOP_LIMIT: u8 = 16;
|
||||
@@ -19,8 +19,10 @@
|
||||
//! A data plane failure never stops the daemon: errors returned here are
|
||||
//! recorded and surfaced, the control plane keeps running.
|
||||
|
||||
pub mod relay;
|
||||
pub mod routing;
|
||||
pub mod transport;
|
||||
pub mod wireguard;
|
||||
pub mod tunnel;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -30,7 +32,10 @@ use tokio::sync::mpsc;
|
||||
use crate::BoxFuture;
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
pub use transport::{PacketLink, PacketTransport, SharedLink, TransportError};
|
||||
pub use transport::{
|
||||
MultiPacketTransport, PacketLink, PacketTransport, SharedLink, TransportError,
|
||||
};
|
||||
pub use tunnel::{GenericTunnelPlugin, TunnelCodec};
|
||||
|
||||
/// Maximum length of a plugin protocol identifier.
|
||||
pub const MAX_PROTOCOL_ID_LEN: usize = 32;
|
||||
@@ -42,7 +47,7 @@ pub const MAX_PROTOCOL_ID_LEN: usize = 32;
|
||||
/// chooses to do so must validate it itself.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PluginCapability {
|
||||
/// Protocol identifier, e.g. `wireguard`. Bounded by [`MAX_PROTOCOL_ID_LEN`].
|
||||
/// Protocol identifier, e.g. `wg-quic`. Bounded by [`MAX_PROTOCOL_ID_LEN`].
|
||||
pub protocol: String,
|
||||
/// Version of the plugin's announcement format.
|
||||
pub version: u16,
|
||||
@@ -52,6 +57,51 @@ pub struct PluginCapability {
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Where a protocol hands the packets it has decrypted.
|
||||
///
|
||||
/// A protocol proves *who* sent a packet; it does not know what that member
|
||||
/// is entitled to say, because entitlement is an address claim the system
|
||||
/// level holds. So a decrypted packet goes here rather than straight to an
|
||||
/// interface, and is checked on the way.
|
||||
pub trait PacketSink: Send + Sync + 'static {
|
||||
/// Hands over one packet, attributed to the peer whose tunnel decrypted
|
||||
/// it.
|
||||
fn deliver<'a>(
|
||||
&'a self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
packet: bytes::Bytes,
|
||||
) -> BoxFuture<'a, ()>;
|
||||
}
|
||||
|
||||
/// A sink that drops everything, for a protocol running without an interface.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct DiscardPackets;
|
||||
|
||||
impl PacketSink for DiscardPackets {
|
||||
fn deliver<'a>(
|
||||
&'a self,
|
||||
_network: NetworkId,
|
||||
_peer: EndpointId,
|
||||
_packet: bytes::Bytes,
|
||||
) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {})
|
||||
}
|
||||
}
|
||||
|
||||
/// One setting a protocol accepts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProtocolOption {
|
||||
/// The key, as written in `key=value`.
|
||||
pub key: &'static str,
|
||||
/// A word for the value, for the help line: `BYTES`, `NAME`.
|
||||
pub value: &'static str,
|
||||
/// What it does.
|
||||
pub help: &'static str,
|
||||
/// What happens when it is not given.
|
||||
pub default: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Errors a plugin may return. They are recorded, never fatal for the agent.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
@@ -69,7 +119,7 @@ pub enum PluginError {
|
||||
|
||||
/// A request a plugin makes of the agent that owns it.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum PluginRequest {
|
||||
pub enum PluginRequest {
|
||||
/// Re-send this agent's announcement to every peer of a network.
|
||||
Reannounce(NetworkId),
|
||||
/// Surface a plugin error on the agent's event stream.
|
||||
@@ -81,6 +131,17 @@ pub(crate) enum PluginRequest {
|
||||
/// Human readable reason, free of secrets.
|
||||
reason: String,
|
||||
},
|
||||
/// A plugin reported that a data link or tunnel for a peer failed.
|
||||
ProtocolFailure {
|
||||
/// Network the failure occurred in.
|
||||
network: NetworkId,
|
||||
/// Peer whose protocol failed.
|
||||
peer: EndpointId,
|
||||
/// Protocol that failed.
|
||||
protocol: String,
|
||||
/// Human readable reason.
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The agent-side handle a plugin is given when it is attached.
|
||||
@@ -95,13 +156,32 @@ pub(crate) enum PluginRequest {
|
||||
pub struct PluginContext {
|
||||
sender: Option<mpsc::Sender<PluginRequest>>,
|
||||
local: Option<EndpointId>,
|
||||
/// Where decrypted packets go. Absent when nothing is carrying traffic,
|
||||
/// in which case a protocol still runs and its packets are discarded.
|
||||
sink: Option<Arc<dyn PacketSink>>,
|
||||
}
|
||||
|
||||
impl PluginContext {
|
||||
pub(crate) fn new(sender: mpsc::Sender<PluginRequest>, local: EndpointId) -> Self {
|
||||
pub(crate) fn new(
|
||||
sender: mpsc::Sender<PluginRequest>,
|
||||
local: EndpointId,
|
||||
sink: Option<Arc<dyn PacketSink>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
sender: Some(sender),
|
||||
local: Some(local),
|
||||
sink,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where to hand a decrypted packet.
|
||||
///
|
||||
/// Always answers: with no interface to write to, the packets are
|
||||
/// discarded, which is what `--no-tun` means and is not an error.
|
||||
pub fn packet_sink(&self) -> Arc<dyn PacketSink> {
|
||||
match &self.sink {
|
||||
Some(sink) => Arc::clone(sink),
|
||||
None => Arc::new(DiscardPackets),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +190,30 @@ impl PluginContext {
|
||||
Self {
|
||||
sender: None,
|
||||
local: None,
|
||||
sink: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +258,22 @@ impl PluginContext {
|
||||
reason: reason.into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Reports that data plane connectivity or a tunnel for a peer has failed.
|
||||
pub fn report_peer_protocol_failure(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
protocol: impl Into<String>,
|
||||
reason: impl Into<String>,
|
||||
) {
|
||||
self.send(PluginRequest::ProtocolFailure {
|
||||
network,
|
||||
peer,
|
||||
protocol: protocol.into(),
|
||||
reason: reason.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PluginContext {
|
||||
@@ -170,11 +290,34 @@ impl std::fmt::Debug for PluginContext {
|
||||
/// Implementations must be cheap and non-blocking: the agent calls them from
|
||||
/// its runtime tasks. Anything slow belongs in the plugin's own tasks.
|
||||
pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// Stable protocol identifier, e.g. `wireguard`.
|
||||
/// Stable protocol identifier, e.g. `wg-quic`.
|
||||
///
|
||||
/// Must be non-empty and at most [`MAX_PROTOCOL_ID_LEN`] bytes.
|
||||
fn protocol_id(&self) -> &str;
|
||||
|
||||
/// The wire version of this protocol.
|
||||
///
|
||||
/// Two peers carry traffic for each other only when they have the same
|
||||
/// protocol at the same version. There is no negotiating a middle
|
||||
/// ground: a protocol either speaks the same words at both ends or it
|
||||
/// does not, and pretending otherwise produces a link that fails later
|
||||
/// and less clearly.
|
||||
///
|
||||
/// **Not the software version.** A plugin crate has its own version and
|
||||
/// it is nobody else's business: two peers on different builds work
|
||||
/// together for as long as the wire between them has not changed. This
|
||||
/// number moves only when the bytes do, so it must never be derived from
|
||||
/// `CARGO_PKG_VERSION` or anything else that moves with a release.
|
||||
fn protocol_version(&self) -> u16;
|
||||
|
||||
/// The settings this protocol accepts, and what they mean.
|
||||
///
|
||||
/// Declared rather than documented elsewhere, so the agent can list them
|
||||
/// without knowing anything about the protocol.
|
||||
fn options(&self) -> &'static [ProtocolOption] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Called once, when the agent starts, before any network is activated.
|
||||
///
|
||||
/// The plugin keeps the context to ask for re-announcements and to report
|
||||
@@ -224,6 +367,20 @@ pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
|
||||
let _ = (network, range, allocations);
|
||||
}
|
||||
|
||||
/// Carries one packet to a peer, encrypting it however this protocol
|
||||
/// does.
|
||||
///
|
||||
/// `false` when it cannot right now — no link, no tunnel, not this
|
||||
/// protocol's peer — which the caller reports rather than treats as an
|
||||
/// error. The packet came off the one interface the agent owns, and
|
||||
/// which protocol takes it is settled by asking.
|
||||
///
|
||||
/// The default carries nothing, which is right for a plugin that only
|
||||
/// announces something.
|
||||
fn carry(&self, _network: NetworkId, _peer: EndpointId, _packet: bytes::Bytes) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// A data plane link to a peer is available for this plugin's protocol.
|
||||
///
|
||||
/// The plugin moves its packets over this link and never learns how the
|
||||
@@ -243,6 +400,19 @@ pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// The plugin is expected to remove whatever it created for that network.
|
||||
fn on_network_deactivated(&self, network: NetworkId);
|
||||
|
||||
/// Called when this agent has left a network for good.
|
||||
///
|
||||
/// Deactivation is temporary and keeps everything ready for next time;
|
||||
/// this is the other one. Whatever the plugin holds *durably* for that
|
||||
/// network — a key of its own, a file, a record — goes now, because the
|
||||
/// agent is no longer a member and keeping it is keeping a secret for a
|
||||
/// network it cannot rejoin without being told the secret again.
|
||||
///
|
||||
/// Always preceded by [`IpPlugin::on_network_deactivated`], so this is
|
||||
/// only about what outlives a session. Defaulted to nothing, for a
|
||||
/// plugin that stores nothing.
|
||||
fn on_network_forgotten(&self, _network: NetworkId) {}
|
||||
|
||||
/// Called once when the agent shuts down.
|
||||
///
|
||||
/// The plugin removes the system objects it created and stops its tasks.
|
||||
@@ -290,6 +460,10 @@ impl IpPlugin for TestCapabilityPlugin {
|
||||
&self.protocol
|
||||
}
|
||||
|
||||
fn protocol_version(&self) -> u16 {
|
||||
1
|
||||
}
|
||||
|
||||
fn local_capability(
|
||||
&self,
|
||||
_network: NetworkId,
|
||||
@@ -0,0 +1,506 @@
|
||||
//! Userspace forwarding. Topology updates publish immutable tables; the packet
|
||||
//! path takes no routing mutex, walks no graph and never inspects plugin bytes.
|
||||
//! Each transport reader forwards transit immediately, independently of the
|
||||
//! destination protocol's receive task. Only local delivery enters its inbox.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use tokio::sync::{Notify, mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use super::routing::{FlowId, PeerId, RoutingTable, envelope, mix64};
|
||||
use super::transport::{PacketLink, SharedLink, TransportError};
|
||||
use crate::BoxFuture;
|
||||
use crate::config::{MAX_DATA_DATAGRAM, ROUTING_HOP_LIMIT};
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
/// Fixed routing header, subtracted from the logical transport MTU.
|
||||
pub const RELAY_OVERHEAD: usize = envelope::HEADER;
|
||||
const INBOX: usize = 256;
|
||||
|
||||
/// Forwarding counters, shared by all protocols of one network.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct RelayCounters {
|
||||
/// Datagrams originated here and sent through another member.
|
||||
pub sent_via: u64,
|
||||
/// Transit datagrams handed to the next transport link.
|
||||
pub forwarded: u64,
|
||||
/// Local datagrams received through another member.
|
||||
pub received_via: u64,
|
||||
/// Missing or failed next hop.
|
||||
pub dropped_no_link: u64,
|
||||
/// Malformed envelope or unknown member.
|
||||
pub dropped_unknown: u64,
|
||||
/// Exhausted hop limit, including loops during topology convergence.
|
||||
pub dropped_hop_limit: u64,
|
||||
/// Full local protocol inbox. Transit does not use this queue.
|
||||
pub dropped_congested: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Tally {
|
||||
sent_via: AtomicU64,
|
||||
forwarded: AtomicU64,
|
||||
received_via: AtomicU64,
|
||||
dropped_no_link: AtomicU64,
|
||||
dropped_unknown: AtomicU64,
|
||||
dropped_hop_limit: AtomicU64,
|
||||
dropped_congested: AtomicU64,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
fn snapshot(&self) -> RelayCounters {
|
||||
RelayCounters {
|
||||
sent_via: self.sent_via.load(Ordering::Relaxed),
|
||||
forwarded: self.forwarded.load(Ordering::Relaxed),
|
||||
received_via: self.received_via.load(Ordering::Relaxed),
|
||||
dropped_no_link: self.dropped_no_link.load(Ordering::Relaxed),
|
||||
dropped_unknown: self.dropped_unknown.load(Ordering::Relaxed),
|
||||
dropped_hop_limit: self.dropped_hop_limit.load(Ordering::Relaxed),
|
||||
dropped_congested: self.dropped_congested.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NextHop {
|
||||
peer: PeerId,
|
||||
link: SharedLink,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ForwardRoute {
|
||||
hops: u8,
|
||||
next: Box<[NextHop]>,
|
||||
}
|
||||
|
||||
impl ForwardRoute {
|
||||
fn select(&self, source: &PeerId, flow: FlowId) -> Option<&NextHop> {
|
||||
if self.next.len() == 1 {
|
||||
return self.next.first().filter(|hop| !hop.link.is_closed());
|
||||
}
|
||||
let seed = u64::from_le_bytes(source[..8].try_into().ok()?);
|
||||
let start = mix64(flow ^ seed) as usize % self.next.len();
|
||||
// Stable fallback if a link closed just before its table was replaced.
|
||||
(0..self.next.len())
|
||||
.map(|i| &self.next[(start + i) % self.next.len()])
|
||||
.find(|hop| !hop.link.is_closed())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ForwardingTable {
|
||||
routes: HashMap<PeerId, ForwardRoute>,
|
||||
inboxes: HashMap<PeerId, mpsc::Sender<Bytes>>,
|
||||
members: HashSet<PeerId>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Plane {
|
||||
local: PeerId,
|
||||
table: ArcSwap<ForwardingTable>,
|
||||
tally: Arc<Tally>,
|
||||
}
|
||||
|
||||
impl Plane {
|
||||
/// Synchronous hot path: one snapshot, bounded header decode, table lookup,
|
||||
/// and transport send. An exclusively owned buffer is modified in place.
|
||||
fn receive(&self, incoming: PeerId, frame: Bytes) {
|
||||
let Some(header) = envelope::decode(&frame) else {
|
||||
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
};
|
||||
let table = self.table.load();
|
||||
if !table.members.contains(&header.source) || header.source == self.local {
|
||||
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
if header.destination == self.local {
|
||||
let Some(inbox) = table.inboxes.get(&header.source) else {
|
||||
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
};
|
||||
if inbox.try_send(frame.slice(RELAY_OVERHEAD..)).is_err() {
|
||||
self.tally.dropped_congested.fetch_add(1, Ordering::Relaxed);
|
||||
} else if incoming != header.source {
|
||||
self.tally.received_via.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if header.remaining <= 1 {
|
||||
self.tally.dropped_hop_limit.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
let hop = table
|
||||
.routes
|
||||
.get(&header.destination)
|
||||
.and_then(|route| route.select(&header.source, header.flow));
|
||||
let Some(hop) = hop else {
|
||||
self.tally.dropped_no_link.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
};
|
||||
if hop.link.send(envelope::decrement(frame)).is_ok() {
|
||||
self.tally.forwarded.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
self.tally.dropped_no_link.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Protocol {
|
||||
plane: Arc<Plane>,
|
||||
graph: HashMap<PeerId, Vec<PeerId>>,
|
||||
members: HashSet<PeerId>,
|
||||
raw: HashMap<PeerId, SharedLink>,
|
||||
readers: HashMap<PeerId, JoinHandle<()>>,
|
||||
links: HashMap<PeerId, Arc<PeerLink>>,
|
||||
}
|
||||
|
||||
impl Protocol {
|
||||
fn publish(&self) {
|
||||
let mut graph = self.graph.clone();
|
||||
graph.insert(
|
||||
self.plane.local,
|
||||
self.raw
|
||||
.iter()
|
||||
.filter(|(_, link)| !link.is_closed())
|
||||
.map(|(peer, _)| *peer)
|
||||
.collect(),
|
||||
);
|
||||
let routing = RoutingTable::build(self.plane.local, &graph, ROUTING_HOP_LIMIT);
|
||||
let routes = routing
|
||||
.iter()
|
||||
.filter_map(|(peer, route)| {
|
||||
let next: Box<[_]> = route
|
||||
.next_hops
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
self.raw.get(id).map(|link| NextHop {
|
||||
peer: *id,
|
||||
link: link.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(!next.is_empty()).then_some((
|
||||
*peer,
|
||||
ForwardRoute {
|
||||
hops: route.hops,
|
||||
next,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let mut members = self.members.clone();
|
||||
members.extend(self.raw.keys().copied());
|
||||
self.plane.table.store(Arc::new(ForwardingTable {
|
||||
routes,
|
||||
members,
|
||||
inboxes: self
|
||||
.links
|
||||
.iter()
|
||||
.map(|(peer, link)| (*peer, link.inbox_tx.clone()))
|
||||
.collect(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Protocol {
|
||||
fn drop(&mut self) {
|
||||
for task in self.readers.values() {
|
||||
task.abort();
|
||||
}
|
||||
for link in self.links.values() {
|
||||
link.close();
|
||||
}
|
||||
self.plane.table.store(Arc::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// One network's transport links and independently published protocol tables.
|
||||
#[derive(Debug)]
|
||||
pub struct RelayHub {
|
||||
network: NetworkId,
|
||||
local: PeerId,
|
||||
protocols: Mutex<HashMap<String, Protocol>>,
|
||||
tally: Arc<Tally>,
|
||||
changed: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl RelayHub {
|
||||
/// Creates the router at this network's local endpoint.
|
||||
pub fn new(network: NetworkId, local: EndpointId) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
network,
|
||||
local: *local.as_bytes(),
|
||||
protocols: Mutex::default(),
|
||||
tally: Arc::default(),
|
||||
changed: Arc::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn protocols(&self) -> std::sync::MutexGuard<'_, HashMap<String, Protocol>> {
|
||||
self.protocols
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
|
||||
fn protocol<'a>(
|
||||
&self,
|
||||
protocols: &'a mut HashMap<String, Protocol>,
|
||||
name: &str,
|
||||
) -> &'a mut Protocol {
|
||||
protocols
|
||||
.entry(name.to_owned())
|
||||
.or_insert_with(|| Protocol {
|
||||
plane: Arc::new(Plane {
|
||||
local: self.local,
|
||||
table: ArcSwap::from_pointee(ForwardingTable::default()),
|
||||
tally: self.tally.clone(),
|
||||
}),
|
||||
graph: HashMap::new(),
|
||||
members: HashSet::new(),
|
||||
raw: HashMap::new(),
|
||||
readers: HashMap::new(),
|
||||
links: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Wakes the control loop promptly when a transport reader ends.
|
||||
pub async fn changed(&self) {
|
||||
self.changed.notified().await;
|
||||
}
|
||||
|
||||
/// Current counters.
|
||||
pub fn counters(&self) -> RelayCounters {
|
||||
self.tally.snapshot()
|
||||
}
|
||||
|
||||
/// Publishes authenticated topology. Refreshing identical state is cheap.
|
||||
pub fn set_topology(
|
||||
&self,
|
||||
protocol: &str,
|
||||
graph: HashMap<PeerId, Vec<PeerId>>,
|
||||
members: HashSet<PeerId>,
|
||||
) {
|
||||
let mut protocols = self.protocols();
|
||||
let state = self.protocol(&mut protocols, protocol);
|
||||
if state.graph != graph || state.members != members {
|
||||
state.graph = graph;
|
||||
state.members = members;
|
||||
state.publish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the current table has a path to this destination.
|
||||
pub fn reachable(&self, peer: EndpointId, protocol: &str) -> bool {
|
||||
self.protocols().get(protocol).is_some_and(|state| {
|
||||
state
|
||||
.plane
|
||||
.table
|
||||
.load()
|
||||
.routes
|
||||
.contains_key(peer.as_bytes())
|
||||
})
|
||||
}
|
||||
|
||||
/// Stable logical link, preserved across topology changes.
|
||||
pub fn link(&self, peer: EndpointId, protocol: &str) -> Arc<PeerLink> {
|
||||
let mut protocols = self.protocols();
|
||||
let state = self.protocol(&mut protocols, protocol);
|
||||
if let Some(link) = state.links.get(peer.as_bytes()) {
|
||||
return link.clone();
|
||||
}
|
||||
let (inbox_tx, inbox_rx) = mpsc::channel(INBOX);
|
||||
let (closed, _) = watch::channel(false);
|
||||
let link = Arc::new(PeerLink {
|
||||
network: self.network,
|
||||
peer,
|
||||
plane: state.plane.clone(),
|
||||
inbox_tx,
|
||||
inbox_rx: tokio::sync::Mutex::new(inbox_rx),
|
||||
closed,
|
||||
});
|
||||
state.links.insert(*peer.as_bytes(), link.clone());
|
||||
state.publish();
|
||||
link
|
||||
}
|
||||
|
||||
/// Whether the protocol already owns its logical link.
|
||||
pub fn has_link(&self, peer: EndpointId, protocol: &str) -> bool {
|
||||
self.protocols()
|
||||
.get(protocol)
|
||||
.is_some_and(|state| state.links.contains_key(peer.as_bytes()))
|
||||
}
|
||||
|
||||
/// Installs a raw transport link and starts its independent ingress reader.
|
||||
pub fn set_direct(&self, peer: EndpointId, protocol: &str, raw: SharedLink) {
|
||||
if raw.network() != self.network || raw.peer() != peer {
|
||||
return;
|
||||
}
|
||||
let mut protocols = self.protocols();
|
||||
let state = self.protocol(&mut protocols, protocol);
|
||||
let id = *peer.as_bytes();
|
||||
if let Some(old) = state.readers.remove(&id) {
|
||||
old.abort();
|
||||
}
|
||||
state.raw.insert(id, raw.clone());
|
||||
state.publish();
|
||||
let plane = state.plane.clone();
|
||||
let changed = self.changed.clone();
|
||||
state.readers.insert(
|
||||
id,
|
||||
tokio::spawn(async move {
|
||||
let mut batch = 0;
|
||||
while let Some(frame) = raw.recv().await {
|
||||
plane.receive(id, frame);
|
||||
batch += 1;
|
||||
if batch == 64 {
|
||||
tokio::task::yield_now().await;
|
||||
batch = 0;
|
||||
}
|
||||
}
|
||||
changed.notify_one();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes a physical link without tearing down end-to-end protocol state.
|
||||
pub fn clear_direct(&self, peer: EndpointId, protocol: &str) {
|
||||
if let Some(state) = self.protocols().get_mut(protocol) {
|
||||
state.raw.remove(peer.as_bytes());
|
||||
if let Some(task) = state.readers.remove(peer.as_bytes()) {
|
||||
task.abort();
|
||||
}
|
||||
state.publish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Revokes all links and topology involving a departed authenticated peer.
|
||||
pub fn remove_peer(&self, peer: EndpointId) {
|
||||
for state in self.protocols().values_mut() {
|
||||
state.raw.remove(peer.as_bytes());
|
||||
if let Some(task) = state.readers.remove(peer.as_bytes()) {
|
||||
task.abort();
|
||||
}
|
||||
if let Some(link) = state.links.remove(peer.as_bytes()) {
|
||||
link.close();
|
||||
}
|
||||
state.members.remove(peer.as_bytes());
|
||||
state.graph.remove(peer.as_bytes());
|
||||
for neighbors in state.graph.values_mut() {
|
||||
neighbors.retain(|id| id != peer.as_bytes());
|
||||
}
|
||||
state.publish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops every reader and releases all transport handles.
|
||||
pub fn close(&self) {
|
||||
self.protocols().clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// A protocol's end-to-end channel, independent of its current next hop.
|
||||
#[derive(Debug)]
|
||||
pub struct PeerLink {
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
plane: Arc<Plane>,
|
||||
inbox_tx: mpsc::Sender<Bytes>,
|
||||
inbox_rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
|
||||
closed: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl PeerLink {
|
||||
fn close(&self) {
|
||||
self.closed.send_replace(true);
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketLink for PeerLink {
|
||||
fn network(&self) -> NetworkId {
|
||||
self.network
|
||||
}
|
||||
fn peer(&self) -> EndpointId {
|
||||
self.peer
|
||||
}
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
MAX_DATA_DATAGRAM - RELAY_OVERHEAD
|
||||
}
|
||||
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
|
||||
self.send_flow(payload, 0)
|
||||
}
|
||||
fn send_flow(&self, payload: Bytes, flow: FlowId) -> Result<(), TransportError> {
|
||||
if self.is_closed() {
|
||||
return Err(TransportError::Closed);
|
||||
}
|
||||
if payload.len() > self.max_datagram_size() {
|
||||
return Err(TransportError::TooLarge {
|
||||
size: payload.len(),
|
||||
limit: self.max_datagram_size(),
|
||||
});
|
||||
}
|
||||
let table = self.plane.table.load();
|
||||
let route = table
|
||||
.routes
|
||||
.get(self.peer.as_bytes())
|
||||
.ok_or(TransportError::Closed)?;
|
||||
let hop = route
|
||||
.select(&self.plane.local, flow)
|
||||
.ok_or(TransportError::Closed)?;
|
||||
hop.link.send(envelope::encode(
|
||||
self.plane.local,
|
||||
*self.peer.as_bytes(),
|
||||
flow,
|
||||
&payload,
|
||||
))?;
|
||||
if route.hops > 1 {
|
||||
self.plane.tally.sent_via.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move {
|
||||
let mut closed = self.closed.subscribe();
|
||||
if *closed.borrow_and_update() {
|
||||
return None;
|
||||
}
|
||||
let mut inbox = self.inbox_rx.lock().await;
|
||||
tokio::select! { biased; _ = closed.changed() => None, frame = inbox.recv() => frame }
|
||||
})
|
||||
}
|
||||
fn closed(&self) -> BoxFuture<'_, ()> {
|
||||
Box::pin(async move {
|
||||
let mut closed = self.closed.subscribe();
|
||||
if !*closed.borrow_and_update() {
|
||||
let _ = closed.changed().await;
|
||||
}
|
||||
})
|
||||
}
|
||||
fn is_closed(&self) -> bool {
|
||||
*self.closed.borrow()
|
||||
}
|
||||
fn path_description(&self) -> String {
|
||||
let table = self.plane.table.load();
|
||||
match table.routes.get(self.peer.as_bytes()) {
|
||||
Some(route) if route.hops == 1 => route.next[0].link.path_description(),
|
||||
Some(route) => format!(
|
||||
"relay {} hops via {} ({} equal paths)",
|
||||
route.hops,
|
||||
hex::encode(route.next[0].peer),
|
||||
route.next.len()
|
||||
),
|
||||
None => "unreachable".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "relay_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,287 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
fn network() -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new("routing-tests").unwrap(),
|
||||
&NetworkSecret::from_bytes([3; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
fn peer(seed: u8) -> EndpointId {
|
||||
iroh::SecretKey::from_bytes(&[seed; 32]).public()
|
||||
}
|
||||
fn id(seed: u8) -> PeerId {
|
||||
*peer(seed).as_bytes()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Wire {
|
||||
peer: EndpointId,
|
||||
sent: Mutex<Vec<Bytes>>,
|
||||
feed: mpsc::Sender<Bytes>,
|
||||
inbound: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
|
||||
dead: AtomicBool,
|
||||
count: AtomicU64,
|
||||
record: bool,
|
||||
}
|
||||
impl Wire {
|
||||
fn new(seed: u8, record: bool) -> Arc<Self> {
|
||||
let (feed, inbound) = mpsc::channel(32);
|
||||
Arc::new(Self {
|
||||
peer: peer(seed),
|
||||
sent: Mutex::default(),
|
||||
feed,
|
||||
inbound: tokio::sync::Mutex::new(inbound),
|
||||
dead: AtomicBool::new(false),
|
||||
count: AtomicU64::new(0),
|
||||
record,
|
||||
})
|
||||
}
|
||||
fn take(&self) -> Vec<Bytes> {
|
||||
std::mem::take(&mut self.sent.lock().unwrap())
|
||||
}
|
||||
}
|
||||
impl PacketLink for Wire {
|
||||
fn network(&self) -> NetworkId {
|
||||
network()
|
||||
}
|
||||
fn peer(&self) -> EndpointId {
|
||||
self.peer
|
||||
}
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
MAX_DATA_DATAGRAM
|
||||
}
|
||||
fn send(&self, frame: Bytes) -> Result<(), TransportError> {
|
||||
if self.is_closed() {
|
||||
return Err(TransportError::Closed);
|
||||
}
|
||||
self.count.fetch_add(1, Ordering::Relaxed);
|
||||
if self.record {
|
||||
self.sent.lock().unwrap().push(frame);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move { self.inbound.lock().await.recv().await })
|
||||
}
|
||||
fn closed(&self) -> BoxFuture<'_, ()> {
|
||||
Box::pin(std::future::pending())
|
||||
}
|
||||
fn is_closed(&self) -> bool {
|
||||
self.dead.load(Ordering::Relaxed)
|
||||
}
|
||||
fn path_description(&self) -> String {
|
||||
"test-direct".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn topology(hub: &RelayHub, protocol: &str, edges: &[(u8, &[u8])], members: &[u8]) {
|
||||
hub.set_topology(
|
||||
protocol,
|
||||
edges
|
||||
.iter()
|
||||
.map(|(a, bs)| (id(*a), bs.iter().map(|b| id(*b)).collect()))
|
||||
.collect(),
|
||||
members.iter().map(|p| id(*p)).collect(),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ecmp_is_per_flow_direct_wins_and_existing_tunnel_survives_changes() {
|
||||
let hub = RelayHub::new(network(), peer(1));
|
||||
let b = Wire::new(2, true);
|
||||
let c = Wire::new(3, true);
|
||||
hub.set_direct(peer(2), "ip", b.clone());
|
||||
hub.set_direct(peer(3), "ip", c.clone());
|
||||
topology(&hub, "ip", &[(2, &[4]), (3, &[4])], &[1, 2, 3, 4]);
|
||||
let link = hub.link(peer(4), "ip");
|
||||
let mut chosen = HashMap::new();
|
||||
for _ in 0..8 {
|
||||
for flow in 0..100 {
|
||||
link.send_flow(Bytes::from_static(b"ciphertext"), flow)
|
||||
.unwrap();
|
||||
}
|
||||
for (hop, wire) in [(2, &b), (3, &c)] {
|
||||
for frame in wire.take() {
|
||||
let header = envelope::decode(&frame).unwrap();
|
||||
assert_eq!(header.source, id(1));
|
||||
assert_eq!(header.destination, id(4));
|
||||
if let Some(old) = chosen.insert(header.flow, hop) {
|
||||
assert_eq!(old, hop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
chosen.values().copied().collect::<HashSet<_>>(),
|
||||
HashSet::from([2, 3])
|
||||
);
|
||||
let direct = Wire::new(4, true);
|
||||
hub.set_direct(peer(4), "ip", direct.clone());
|
||||
link.send_flow(Bytes::from_static(b"direct"), 8).unwrap();
|
||||
assert_eq!(direct.take().len(), 1);
|
||||
assert!(b.take().is_empty() && c.take().is_empty());
|
||||
hub.clear_direct(peer(4), "ip");
|
||||
b.dead.store(true, Ordering::Relaxed);
|
||||
for flow in 0..100 {
|
||||
link.send_flow(Bytes::from_static(b"fallback"), flow)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(c.take().len(), 100);
|
||||
hub.clear_direct(peer(2), "ip");
|
||||
topology(&hub, "ip", &[(3, &[])], &[1, 2, 3, 4]);
|
||||
assert!(link.send(Bytes::new()).is_err());
|
||||
assert!(!link.is_closed());
|
||||
assert!(Arc::ptr_eq(&link, &hub.link(peer(4), "ip")));
|
||||
hub.remove_peer(peer(4));
|
||||
assert!(link.is_closed());
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), link.closed())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(link.recv().await.is_none());
|
||||
hub.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_reader_forwards_without_any_plugin_reader_and_isolates_protocols() {
|
||||
let hub = RelayHub::new(network(), peer(2));
|
||||
let a = Wire::new(1, true);
|
||||
let c = Wire::new(3, true);
|
||||
hub.set_direct(peer(1), "ip", a.clone());
|
||||
hub.set_direct(peer(3), "ip", c.clone());
|
||||
topology(&hub, "ip", &[(3, &[4])], &[1, 2, 3, 4]);
|
||||
let frame = envelope::encode(id(1), id(4), 91, b"end-to-end encrypted");
|
||||
let pointer = frame.as_ptr();
|
||||
a.feed.send(frame).await.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
while hub.counters().forwarded == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let sent = c.take();
|
||||
assert_eq!(sent[0].as_ptr(), pointer);
|
||||
assert_eq!(
|
||||
envelope::decode(&sent[0]).unwrap().remaining,
|
||||
ROUTING_HOP_LIMIT - 1
|
||||
);
|
||||
assert_eq!(&sent[0][RELAY_OVERHEAD..], b"end-to-end encrypted");
|
||||
let isolated = hub.link(peer(4), "other-ip");
|
||||
assert!(isolated.send(Bytes::new()).is_err());
|
||||
assert_eq!(hub.counters().forwarded, 1);
|
||||
hub.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_delivery_is_bounded_and_malformed_or_unknown_sources_are_dropped() {
|
||||
let hub = RelayHub::new(network(), peer(2));
|
||||
let a = Wire::new(1, true);
|
||||
hub.set_direct(peer(1), "ip", a);
|
||||
let link = hub.link(peer(1), "ip");
|
||||
let plane = link.plane.clone();
|
||||
for _ in 0..INBOX + 1 {
|
||||
plane.receive(id(1), envelope::encode(id(1), id(2), 0, b"ip"));
|
||||
}
|
||||
assert_eq!(hub.counters().dropped_congested, 1);
|
||||
for _ in 0..INBOX {
|
||||
assert_eq!(link.recv().await.unwrap(), Bytes::from_static(b"ip"));
|
||||
}
|
||||
plane.receive(id(1), Bytes::from_static(b"broken"));
|
||||
plane.receive(id(1), envelope::encode(id(9), id(2), 0, b"unknown member"));
|
||||
assert_eq!(hub.counters().dropped_unknown, 2);
|
||||
plane.receive(
|
||||
id(1),
|
||||
envelope::encode(id(1), id(9), 0, b"unknown destination"),
|
||||
);
|
||||
assert_eq!(hub.counters().dropped_no_link, 1);
|
||||
hub.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inconsistent_tables_cannot_loop_beyond_the_hop_limit() {
|
||||
let a = RelayHub::new(network(), peer(1));
|
||||
let b = RelayHub::new(network(), peer(2));
|
||||
let ab = Wire::new(2, true);
|
||||
let ba = Wire::new(1, true);
|
||||
a.set_direct(peer(2), "ip", ab.clone());
|
||||
b.set_direct(peer(1), "ip", ba.clone());
|
||||
// Deliberately inconsistent snapshots while updates are in flight.
|
||||
topology(&a, "ip", &[(2, &[4])], &[1, 2, 3, 4]);
|
||||
topology(&b, "ip", &[(1, &[4])], &[1, 2, 3, 4]);
|
||||
let ap = a.link(peer(4), "ip").plane.clone();
|
||||
let bp = b.link(peer(4), "ip").plane.clone();
|
||||
let mut frame = envelope::encode(id(3), id(4), 7, b"transit");
|
||||
for turn in 0..ROUTING_HOP_LIMIT {
|
||||
let (plane, incoming, wire) = if turn % 2 == 0 {
|
||||
(&ap, id(2), &ab)
|
||||
} else {
|
||||
(&bp, id(1), &ba)
|
||||
};
|
||||
plane.receive(incoming, frame);
|
||||
let sent = wire.take();
|
||||
if turn == ROUTING_HOP_LIMIT - 1 {
|
||||
assert!(sent.is_empty());
|
||||
break;
|
||||
}
|
||||
assert_eq!(sent.len(), 1);
|
||||
frame = sent.into_iter().next().unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
a.counters().forwarded + b.counters().forwarded,
|
||||
u64::from(ROUTING_HOP_LIMIT - 1)
|
||||
);
|
||||
assert_eq!(
|
||||
a.counters().dropped_hop_limit + b.counters().dropped_hop_limit,
|
||||
1
|
||||
);
|
||||
a.close();
|
||||
b.close();
|
||||
}
|
||||
|
||||
/// Measures the actual synchronous transit routine, including header checks,
|
||||
/// snapshot lookup, ECMP, in-place TTL update and mock transport submission.
|
||||
/// Packet construction, encryption and socket I/O are outside this measurement.
|
||||
#[tokio::test]
|
||||
#[ignore = "manual release-mode forwarding microbenchmark"]
|
||||
async fn forwarding_benchmark() {
|
||||
let hub = RelayHub::new(network(), peer(2));
|
||||
let wire = Wire::new(3, false);
|
||||
hub.set_direct(peer(3), "ip", wire.clone());
|
||||
let alternate = Wire::new(5, false);
|
||||
hub.set_direct(peer(5), "ip", alternate.clone());
|
||||
topology(&hub, "ip", &[(3, &[4]), (5, &[4])], &[1, 2, 3, 4, 5]);
|
||||
let plane = hub.link(peer(4), "ip").plane.clone();
|
||||
let source = id(1);
|
||||
let destination = id(4);
|
||||
let incoming = id(1);
|
||||
for size in [64, 1280] {
|
||||
let mut elapsed = std::time::Duration::ZERO;
|
||||
const COUNT: usize = 10000;
|
||||
const BATCHES: usize = 100;
|
||||
for _ in 0..BATCHES {
|
||||
let frames: Vec<_> = (0..COUNT)
|
||||
.map(|flow| envelope::encode(source, destination, flow as u64, &vec![42; size]))
|
||||
.collect();
|
||||
let start = std::time::Instant::now();
|
||||
for frame in frames {
|
||||
plane.receive(incoming, std::hint::black_box(frame));
|
||||
}
|
||||
elapsed += start.elapsed();
|
||||
}
|
||||
let count = (COUNT * BATCHES) as f64;
|
||||
println!(
|
||||
"transit {size} B: {:.1} ns/packet, {:.2} Mpps (1M packets, two equal paths)",
|
||||
elapsed.as_nanos() as f64 / count,
|
||||
count / elapsed.as_secs_f64() / 1e6
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
wire.count.load(Ordering::Relaxed) + alternate.count.load(Ordering::Relaxed),
|
||||
2000000
|
||||
);
|
||||
hub.close();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Fixed header. Transit changes one byte in an owned receive buffer.
|
||||
use super::{FlowId, PeerId};
|
||||
use crate::config::{MAX_DATA_DATAGRAM, ROUTING_HOP_LIMIT};
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
|
||||
pub(crate) const HEADER: usize = 2 + 32 + 32 + 8;
|
||||
const VERSION: u8 = 1;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Header {
|
||||
pub source: PeerId,
|
||||
pub destination: PeerId,
|
||||
pub remaining: u8,
|
||||
pub flow: FlowId,
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8]) -> Option<Header> {
|
||||
if bytes.len() < HEADER
|
||||
|| bytes.len() > MAX_DATA_DATAGRAM
|
||||
|| bytes[0] != VERSION
|
||||
|| bytes[1] == 0
|
||||
|| bytes[1] > ROUTING_HOP_LIMIT
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Header {
|
||||
source: bytes[2..34].try_into().ok()?,
|
||||
destination: bytes[34..66].try_into().ok()?,
|
||||
remaining: bytes[1],
|
||||
flow: u64::from_be_bytes(bytes[66..74].try_into().ok()?),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn encode(source: PeerId, destination: PeerId, flow: FlowId, payload: &[u8]) -> Bytes {
|
||||
let mut bytes = BytesMut::with_capacity(HEADER + payload.len());
|
||||
bytes.put_u8(VERSION);
|
||||
bytes.put_u8(ROUTING_HOP_LIMIT);
|
||||
bytes.extend_from_slice(&source);
|
||||
bytes.extend_from_slice(&destination);
|
||||
bytes.put_u64(flow);
|
||||
bytes.extend_from_slice(payload);
|
||||
bytes.freeze()
|
||||
}
|
||||
|
||||
pub(crate) fn decrement(bytes: Bytes) -> Bytes {
|
||||
let mut bytes = match bytes.try_into_mut() {
|
||||
Ok(bytes) => bytes,
|
||||
Err(bytes) => BytesMut::from(bytes.as_ref()),
|
||||
};
|
||||
// Called only after decode and after excluding remaining <= 1.
|
||||
bytes[1] -= 1;
|
||||
bytes.freeze()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
#[test]
|
||||
fn transit_reuses_the_buffer_and_rejects_invalid_headers() {
|
||||
let frame = encode([1; 32], [2; 32], 17, &[42; 1280]);
|
||||
let address = frame.as_ptr();
|
||||
let frame = decrement(frame);
|
||||
assert_eq!(
|
||||
frame.as_ptr(),
|
||||
address,
|
||||
"no payload copy for an owned buffer"
|
||||
);
|
||||
assert_eq!(decode(&frame).unwrap().remaining, ROUTING_HOP_LIMIT - 1);
|
||||
assert_eq!(&frame[HEADER..], &[42; 1280]);
|
||||
for length in 0..HEADER {
|
||||
assert!(decode(&frame[..length]).is_none());
|
||||
}
|
||||
for ttl in [0, ROUTING_HOP_LIMIT + 1] {
|
||||
let mut bad = frame.to_vec();
|
||||
bad[1] = ttl;
|
||||
assert!(decode(&bad).is_none());
|
||||
}
|
||||
let mut bad = frame.to_vec();
|
||||
bad[0] = 99;
|
||||
assert!(decode(&bad).is_none());
|
||||
assert!(decode(&vec![1; MAX_DATA_DATAGRAM + 1]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Optional IP flow classifier for IP plugins, called before encryption.
|
||||
//! The router itself treats this identifier and the payload as opaque.
|
||||
|
||||
use super::FlowId;
|
||||
|
||||
fn hash(parts: &[&[u8]]) -> FlowId {
|
||||
let mut value = 0xcbf29ce484222325u64;
|
||||
for part in parts {
|
||||
for byte in *part {
|
||||
value = (value ^ u64::from(*byte)).wrapping_mul(0x100000001b3);
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// Stable directional IP flow hash, independent of TCP sequence numbers,
|
||||
/// checksums and packet contents. Fragmented traffic uses addresses/protocol
|
||||
/// instead of ports: even non-initial fragments and subsequent fragmented
|
||||
/// datagrams stay together. This deliberately coalesces fragmented flows.
|
||||
/// Malformed packets get a coarse hash; parsing is bounded and never allocates.
|
||||
pub fn ip_flow(packet: &[u8]) -> FlowId {
|
||||
match packet.first().map(|b| b >> 4) {
|
||||
Some(4) if packet.len() >= 20 => {
|
||||
let addresses = &packet[12..20];
|
||||
let protocol = &packet[9..10];
|
||||
let offset = usize::from(packet[0] & 15) * 4;
|
||||
if u16::from_be_bytes([packet[6], packet[7]]) & 0x3fff != 0 {
|
||||
return hash(&[addresses, protocol]);
|
||||
}
|
||||
if offset >= 20
|
||||
&& matches!(packet[9], 6 | 17)
|
||||
&& let Some(ports) = packet.get(offset..offset + 4)
|
||||
{
|
||||
return hash(&[addresses, protocol, ports]);
|
||||
}
|
||||
hash(&[addresses, protocol])
|
||||
}
|
||||
Some(6) if packet.len() >= 40 => {
|
||||
let addresses = &packet[8..40];
|
||||
let mut protocol = packet[6];
|
||||
let mut offset = 40;
|
||||
for _ in 0..8 {
|
||||
match protocol {
|
||||
44 => {
|
||||
if let Some(fragment) = packet.get(offset..offset + 8) {
|
||||
return hash(&[addresses, &fragment[..1]]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
0 | 43 | 60 | 51 => {
|
||||
let Some(header) = packet.get(offset..offset + 2) else {
|
||||
break;
|
||||
};
|
||||
let size = if protocol == 51 {
|
||||
(usize::from(header[1]) + 2) * 4
|
||||
} else {
|
||||
(usize::from(header[1]) + 1) * 8
|
||||
};
|
||||
protocol = header[0];
|
||||
offset += size;
|
||||
}
|
||||
6 | 17 => {
|
||||
if let Some(ports) = packet.get(offset..offset + 4) {
|
||||
return hash(&[addresses, &[protocol], ports]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
hash(&[addresses, &[protocol]])
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tcp_flow_ignores_packet_contents_but_distinguishes_ports() {
|
||||
let mut packet = [0u8; 80];
|
||||
packet[0] = 0x45;
|
||||
packet[9] = 6;
|
||||
packet[12..24].copy_from_slice(&[10, 0, 0, 1, 10, 0, 0, 2, 0, 22, 1, 2]);
|
||||
let flow = ip_flow(&packet);
|
||||
packet[4] = 123; // IP id changes for each unfragmented packet.
|
||||
packet[24..].fill(47);
|
||||
assert_eq!(flow, ip_flow(&packet));
|
||||
packet[23] = 3;
|
||||
assert_ne!(flow, ip_flow(&packet));
|
||||
packet[6] = 0x20;
|
||||
let fragment_flow = ip_flow(&packet);
|
||||
packet[6] = 0;
|
||||
packet[7] = 1;
|
||||
packet[20..].fill(99);
|
||||
assert_eq!(fragment_flow, ip_flow(&packet));
|
||||
packet[4] = 97;
|
||||
assert_eq!(fragment_flow, ip_flow(&packet));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_extensions_and_fragments_are_bounded_and_stable() {
|
||||
let mut packet = [0u8; 80];
|
||||
packet[0] = 0x60;
|
||||
packet[6] = 0;
|
||||
packet[40] = 17;
|
||||
packet[48..52].copy_from_slice(&[1, 2, 3, 4]);
|
||||
let flow = ip_flow(&packet);
|
||||
packet[52..].fill(5);
|
||||
assert_eq!(flow, ip_flow(&packet));
|
||||
packet[50] = 9;
|
||||
assert_ne!(flow, ip_flow(&packet));
|
||||
packet[6] = 44;
|
||||
packet[40] = 6;
|
||||
let flow = ip_flow(&packet);
|
||||
packet[42] = 32;
|
||||
packet[48..].fill(3);
|
||||
assert_eq!(flow, ip_flow(&packet));
|
||||
for len in 0..packet.len() {
|
||||
let _ = ip_flow(&packet[..len]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Transport-independent routing over opaque identities. Tables are built on
|
||||
//! topology changes, never on the packet path. No WireGuard or iroh APIs here.
|
||||
pub(crate) mod envelope;
|
||||
pub mod flow;
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
|
||||
/// Opaque authenticated identity; the transport adapter validates its keys.
|
||||
pub type PeerId = [u8; 32];
|
||||
/// Stable flow identifier, carried unchanged between transit routers.
|
||||
pub type FlowId = u64;
|
||||
|
||||
/// A shortest route with all equal-cost first hops in stable order.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Route {
|
||||
/// Number of transport links to the destination.
|
||||
pub hops: u8,
|
||||
/// Equal-cost first hops. Direct destinations have one entry.
|
||||
pub next_hops: Box<[PeerId]>,
|
||||
}
|
||||
|
||||
/// Immutable shortest-path table, replaced when connectivity changes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RoutingTable {
|
||||
routes: HashMap<PeerId, Route>,
|
||||
}
|
||||
|
||||
impl RoutingTable {
|
||||
/// Computes shortest directed paths bounded by `hop_limit`.
|
||||
/// The caller supplies only authenticated, fresh, compatible edges.
|
||||
pub fn build(local: PeerId, graph: &HashMap<PeerId, Vec<PeerId>>, hop_limit: u8) -> Self {
|
||||
let mut distances = HashMap::from([(local, 0u8)]);
|
||||
let mut first_hops: HashMap<PeerId, BTreeSet<PeerId>> = HashMap::new();
|
||||
let mut pending = VecDeque::from([local]);
|
||||
while let Some(node) = pending.pop_front() {
|
||||
let distance = distances[&node];
|
||||
if distance >= hop_limit {
|
||||
continue;
|
||||
}
|
||||
let inherited = first_hops.get(&node).cloned().unwrap_or_default();
|
||||
for &next in graph.get(&node).into_iter().flatten() {
|
||||
let next_distance = distance + 1;
|
||||
match distances.get(&next) {
|
||||
Some(&known) if known < next_distance => continue,
|
||||
None => {
|
||||
distances.insert(next, next_distance);
|
||||
pending.push_back(next);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let hops = first_hops.entry(next).or_default();
|
||||
if node == local {
|
||||
hops.insert(next);
|
||||
} else {
|
||||
hops.extend(inherited.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
routes: first_hops
|
||||
.into_iter()
|
||||
.map(|(peer, hops)| {
|
||||
(
|
||||
peer,
|
||||
Route {
|
||||
hops: distances[&peer],
|
||||
next_hops: hops.into_iter().collect(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
/// Precomputed route to a destination.
|
||||
pub fn get(&self, destination: &PeerId) -> Option<&Route> {
|
||||
self.routes.get(destination)
|
||||
}
|
||||
/// All reachable destinations, excluding ourselves.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&PeerId, &Route)> {
|
||||
self.routes.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable ECMP mixing, without random state or hashing the payload per hop.
|
||||
pub(crate) fn mix64(mut value: u64) -> u64 {
|
||||
value ^= value >> 30;
|
||||
value = value.wrapping_mul(0xbf58476d1ce4e5b9);
|
||||
value ^= value >> 27;
|
||||
value = value.wrapping_mul(0x94d049bb133111eb);
|
||||
value ^ (value >> 31)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
fn p(n: u8) -> PeerId {
|
||||
[n; 32]
|
||||
}
|
||||
#[test]
|
||||
fn shortest_paths_keep_equal_cost_hops_and_prefer_direct_links() {
|
||||
let mut graph = HashMap::from([
|
||||
(p(1), vec![p(3), p(2)]),
|
||||
(p(2), vec![p(4)]),
|
||||
(p(3), vec![p(4)]),
|
||||
(p(4), vec![p(5)]),
|
||||
(p(5), vec![p(1)]),
|
||||
]);
|
||||
let routes = RoutingTable::build(p(1), &graph, 16);
|
||||
assert_eq!(routes.get(&p(5)).unwrap().hops, 3);
|
||||
assert_eq!(&*routes.get(&p(5)).unwrap().next_hops, &[p(2), p(3)]);
|
||||
assert!(routes.get(&p(1)).is_none());
|
||||
assert!(routes.get(&p(9)).is_none());
|
||||
graph.get_mut(&p(1)).unwrap().push(p(5));
|
||||
assert_eq!(
|
||||
RoutingTable::build(p(1), &graph, 16).get(&p(5)).unwrap(),
|
||||
&Route {
|
||||
hops: 1,
|
||||
next_hops: Box::new([p(5)])
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn topology_removal_and_hop_budget_remove_impossible_routes() {
|
||||
let mut graph = HashMap::from([(p(1), vec![p(2)]), (p(2), vec![p(3)]), (p(3), vec![p(4)])]);
|
||||
assert!(RoutingTable::build(p(1), &graph, 2).get(&p(4)).is_none());
|
||||
assert!(RoutingTable::build(p(1), &graph, 3).get(&p(4)).is_some());
|
||||
graph.remove(&p(2));
|
||||
assert!(RoutingTable::build(p(1), &graph, 16).get(&p(4)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Bounded fragmentation of opaque transport payloads, below peer relaying.
|
||||
//!
|
||||
//! Data ALPN v3: u64 packet ID, u32 total length, u32 byte offset (big endian),
|
||||
//! followed by payload. Each QUIC connection owns its IDs and reassembly state.
|
||||
//! There is no retransmission: one missing fragment loses one datagram only.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::config::{
|
||||
DATA_REASSEMBLY_TTL, DATA_RECENT_IDS, MAX_DATA_ASSEMBLIES, MAX_DATA_DATAGRAM,
|
||||
MAX_DATA_FRAGMENTS, MAX_DATA_REASSEMBLY_BYTES,
|
||||
};
|
||||
|
||||
pub(super) const HEADER: usize = 16;
|
||||
|
||||
pub(super) fn encode(id: u64, total: usize, offset: usize, payload: &[u8]) -> Bytes {
|
||||
let mut frame = BytesMut::with_capacity(HEADER + payload.len());
|
||||
frame.put_u64(id);
|
||||
frame.put_u32(total as u32);
|
||||
frame.put_u32(offset as u32);
|
||||
frame.extend_from_slice(payload);
|
||||
frame.freeze()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Assembly {
|
||||
started: Instant,
|
||||
bytes: Vec<u8>,
|
||||
ranges: Vec<(usize, usize)>,
|
||||
received: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct Reassembler {
|
||||
packets: HashMap<u64, Assembly>,
|
||||
buffered: usize,
|
||||
recent: VecDeque<u64>,
|
||||
}
|
||||
|
||||
impl Reassembler {
|
||||
fn remember(&mut self, id: u64) {
|
||||
if self.recent.len() == DATA_RECENT_IDS {
|
||||
self.recent.pop_front();
|
||||
}
|
||||
self.recent.push_back(id);
|
||||
}
|
||||
|
||||
fn remove(&mut self, id: u64) -> Option<Assembly> {
|
||||
let packet = self.packets.remove(&id)?;
|
||||
self.buffered -= packet.bytes.len();
|
||||
self.remember(id);
|
||||
Some(packet)
|
||||
}
|
||||
|
||||
pub(super) fn expire(&mut self, now: Instant) {
|
||||
let expired: Vec<_> = self
|
||||
.packets
|
||||
.iter()
|
||||
.filter(|(_, p)| now.duration_since(p.started) >= DATA_REASSEMBLY_TTL)
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
for id in expired {
|
||||
self.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push(&mut self, frame: Bytes, now: Instant) -> Option<Bytes> {
|
||||
self.expire(now);
|
||||
let id = u64::from_be_bytes(frame.get(..8)?.try_into().ok()?);
|
||||
let total = u32::from_be_bytes(frame.get(8..12)?.try_into().ok()?) as usize;
|
||||
let offset = u32::from_be_bytes(frame.get(12..HEADER)?.try_into().ok()?) as usize;
|
||||
let payload = frame.get(HEADER..)?;
|
||||
let end = offset.checked_add(payload.len())?;
|
||||
if total > MAX_DATA_DATAGRAM
|
||||
|| end > total
|
||||
|| (payload.is_empty() && total != 0)
|
||||
|| self.recent.contains(&id)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if !self.packets.contains_key(&id) {
|
||||
if offset == 0 && end == total {
|
||||
self.remember(id);
|
||||
return Some(frame.slice(HEADER..));
|
||||
}
|
||||
// The length is validated before allocating. Drop the oldest
|
||||
// incomplete packet when the per-link byte or entry budget is full.
|
||||
while self.packets.len() >= MAX_DATA_ASSEMBLIES
|
||||
|| self.buffered + total > MAX_DATA_REASSEMBLY_BYTES
|
||||
{
|
||||
let oldest = self
|
||||
.packets
|
||||
.iter()
|
||||
.min_by_key(|(_, p)| p.started)
|
||||
.map(|(id, _)| *id)?;
|
||||
self.remove(oldest);
|
||||
}
|
||||
self.packets.insert(
|
||||
id,
|
||||
Assembly {
|
||||
started: now,
|
||||
bytes: vec![0; total],
|
||||
ranges: Vec::new(),
|
||||
received: 0,
|
||||
},
|
||||
);
|
||||
self.buffered += total;
|
||||
}
|
||||
let packet = self.packets.get_mut(&id)?;
|
||||
if packet.bytes.len() != total {
|
||||
self.remove(id);
|
||||
return None;
|
||||
}
|
||||
for &(start, stop) in &packet.ranges {
|
||||
if start == offset && stop == end && packet.bytes[offset..end] == *payload {
|
||||
return None; // a duplicate does not reset the expiry time
|
||||
}
|
||||
if offset < stop && end > start {
|
||||
self.remove(id); // conflicting or overlapping fragments
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if packet.ranges.len() == MAX_DATA_FRAGMENTS {
|
||||
self.remove(id);
|
||||
return None;
|
||||
}
|
||||
packet.bytes[offset..end].copy_from_slice(payload);
|
||||
packet.ranges.push((offset, end));
|
||||
packet.received += payload.len();
|
||||
if packet.received == total {
|
||||
return self.remove(id).map(|p| Bytes::from(p.bytes));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reordered_duplicates_and_changing_fragment_sizes_reassemble_once() {
|
||||
let mut rx = Reassembler::default();
|
||||
let now = Instant::now();
|
||||
let packet: Vec<u8> = (0..9000).map(|i| (i % 251) as u8).collect();
|
||||
let mut parts = Vec::new();
|
||||
let mut offset = 0;
|
||||
for size in [1100, 700, 1100, 1100, 700, 1100, 1100, 1100, 1000] {
|
||||
parts.push(encode(
|
||||
7,
|
||||
packet.len(),
|
||||
offset,
|
||||
&packet[offset..offset + size],
|
||||
));
|
||||
offset += size;
|
||||
}
|
||||
assert_eq!(offset, packet.len());
|
||||
for part in parts[1..].iter().rev() {
|
||||
assert!(rx.push(part.clone(), now).is_none());
|
||||
assert!(rx.push(part.clone(), now).is_none());
|
||||
}
|
||||
assert_eq!(rx.push(parts[0].clone(), now).unwrap().as_ref(), packet);
|
||||
assert!(rx.push(parts[0].clone(), now).is_none());
|
||||
assert_eq!(rx.buffered, 0);
|
||||
assert!(rx.packets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_fragment_never_blocks_the_next_packet_and_expires() {
|
||||
let mut rx = Reassembler::default();
|
||||
let now = Instant::now();
|
||||
assert!(rx.push(encode(1, 1280, 0, &[1; 800]), now).is_none());
|
||||
assert_eq!(
|
||||
rx.push(encode(2, 4, 0, b"next"), now).unwrap(),
|
||||
&b"next"[..]
|
||||
);
|
||||
rx.expire(now + DATA_REASSEMBLY_TTL);
|
||||
assert!(rx.packets.is_empty());
|
||||
assert_eq!(rx.buffered, 0);
|
||||
assert!(
|
||||
rx.push(encode(1, 1280, 800, &[1; 480]), now + DATA_REASSEMBLY_TTL)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_conflicting_and_oversized_fragments_are_bounded() {
|
||||
let now = Instant::now();
|
||||
let mut rx = Reassembler::default();
|
||||
for len in 0..HEADER {
|
||||
assert!(rx.push(Bytes::from(vec![0xff; len]), now).is_none());
|
||||
}
|
||||
assert!(
|
||||
rx.push(encode(1, MAX_DATA_DATAGRAM + 1, 0, b"x"), now)
|
||||
.is_none()
|
||||
);
|
||||
assert!(rx.push(encode(2, 100, 100, b"x"), now).is_none());
|
||||
assert!(rx.push(encode(3, 100, 0, b""), now).is_none());
|
||||
assert_eq!(rx.buffered, 0);
|
||||
for (id, bad) in [
|
||||
(4, encode(4, 100, 0, b"different")),
|
||||
(5, encode(5, 100, 3, b"overlap")),
|
||||
(6, encode(6, 101, 9, b"changed total")),
|
||||
] {
|
||||
assert!(rx.push(encode(id, 100, 0, b"123456789"), now).is_none());
|
||||
assert!(rx.push(bad, now).is_none());
|
||||
assert_eq!(rx.buffered, 0);
|
||||
}
|
||||
for id in 100..400 {
|
||||
assert!(
|
||||
rx.push(encode(id, MAX_DATA_DATAGRAM, 0, b"x"), now)
|
||||
.is_none()
|
||||
);
|
||||
assert!(rx.buffered <= MAX_DATA_REASSEMBLY_BYTES);
|
||||
assert!(rx.packets.len() <= MAX_DATA_ASSEMBLIES);
|
||||
assert!(rx.recent.len() <= DATA_RECENT_IDS);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excessive_fragment_counts_are_discarded() {
|
||||
let mut rx = Reassembler::default();
|
||||
let now = Instant::now();
|
||||
for offset in 0..=MAX_DATA_FRAGMENTS {
|
||||
assert!(rx.push(encode(1, 1000, offset, b"x"), now).is_none());
|
||||
}
|
||||
assert!(rx.packets.is_empty());
|
||||
assert_eq!(rx.buffered, 0);
|
||||
}
|
||||
}
|
||||
+86
-14
@@ -20,13 +20,14 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use iroh::endpoint::{Connection, RecvStream, SendStream};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::config::Limits;
|
||||
use crate::config::{DATA_REASSEMBLY_TTL, Limits, MAX_DATA_DATAGRAM, MAX_DATA_FRAGMENTS};
|
||||
use crate::error::ProtocolError;
|
||||
use crate::identity::{NetworkId, NetworkKeys};
|
||||
use crate::net::EndpointAdapter;
|
||||
@@ -36,6 +37,7 @@ use crate::proto::message::{
|
||||
};
|
||||
use crate::proto::{read_frame, write_frame};
|
||||
|
||||
use super::fragments::{self, Reassembler};
|
||||
use super::{InboundLink, PacketLink, PacketTransport, SharedLink, TransportError};
|
||||
|
||||
/// What the iroh transport needs from the agent.
|
||||
@@ -61,6 +63,8 @@ pub struct IrohLink {
|
||||
peer: EndpointId,
|
||||
conn: Connection,
|
||||
max_datagram: usize,
|
||||
next_packet: AtomicU64,
|
||||
reassembly: tokio::sync::Mutex<Reassembler>,
|
||||
// Kept alive so the peer sees the channel as open; the connection closes
|
||||
// when the link is dropped.
|
||||
_send: tokio::sync::Mutex<SendStream>,
|
||||
@@ -76,14 +80,16 @@ impl IrohLink {
|
||||
send: SendStream,
|
||||
recv: RecvStream,
|
||||
) -> Self {
|
||||
let local_limit = conn.max_datagram_size().unwrap_or(0);
|
||||
// Both ends must agree, so the smaller limit wins.
|
||||
let max_datagram = local_limit.min(peer_limit);
|
||||
// The negotiated limit is for reassembled payloads, never a snapshot
|
||||
// of the initial path (which may still be a relay or another VPN).
|
||||
let max_datagram = MAX_DATA_DATAGRAM.min(peer_limit);
|
||||
Self {
|
||||
network,
|
||||
peer,
|
||||
conn,
|
||||
max_datagram,
|
||||
next_packet: AtomicU64::new(0),
|
||||
reassembly: tokio::sync::Mutex::new(Reassembler::default()),
|
||||
_send: tokio::sync::Mutex::new(send),
|
||||
_recv: tokio::sync::Mutex::new(recv),
|
||||
}
|
||||
@@ -110,17 +116,65 @@ impl PacketLink for IrohLink {
|
||||
limit: self.max_datagram,
|
||||
});
|
||||
}
|
||||
self.conn.send_datagram(payload).map_err(|err| {
|
||||
use iroh::endpoint::SendDatagramError;
|
||||
match err {
|
||||
SendDatagramError::ConnectionLost(_) => TransportError::Closed,
|
||||
other => TransportError::Other(other.to_string()),
|
||||
let id = self.next_packet.fetch_add(1, Ordering::Relaxed);
|
||||
let mut offset: usize = 0;
|
||||
let mut attempts = 0;
|
||||
// Re-read the current path's capacity for every fragment. A migration
|
||||
// can shrink it even between two send_datagram calls.
|
||||
for _ in 0..MAX_DATA_FRAGMENTS {
|
||||
loop {
|
||||
let capacity = self
|
||||
.conn
|
||||
.max_datagram_size()
|
||||
.unwrap_or(0)
|
||||
.checked_sub(fragments::HEADER)
|
||||
.filter(|n| *n > 0)
|
||||
.ok_or_else(|| {
|
||||
TransportError::Other("QUIC path cannot carry data fragments".into())
|
||||
})?;
|
||||
let end = offset.saturating_add(capacity).min(payload.len());
|
||||
let frame = fragments::encode(id, payload.len(), offset, &payload[offset..end]);
|
||||
match self.conn.send_datagram(frame) {
|
||||
Ok(()) => {
|
||||
offset = end;
|
||||
break;
|
||||
}
|
||||
Err(iroh::endpoint::SendDatagramError::TooLarge) if attempts < 3 => {
|
||||
attempts += 1;
|
||||
}
|
||||
Err(iroh::endpoint::SendDatagramError::ConnectionLost(_)) => {
|
||||
return Err(TransportError::Closed);
|
||||
}
|
||||
Err(err) => return Err(TransportError::Other(err.to_string())),
|
||||
}
|
||||
}
|
||||
})
|
||||
if offset == payload.len() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(TransportError::Other(
|
||||
"QUIC path needs too many fragments".into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move { self.conn.read_datagram().await.ok() })
|
||||
Box::pin(async move {
|
||||
let mut reassembly = self.reassembly.lock().await;
|
||||
loop {
|
||||
match tokio::time::timeout(DATA_REASSEMBLY_TTL, self.conn.read_datagram()).await {
|
||||
Ok(Ok(frame)) => {
|
||||
if let Some(packet) = reassembly.push(frame, tokio::time::Instant::now()) {
|
||||
return Some(packet);
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
*reassembly = Reassembler::default();
|
||||
return None;
|
||||
}
|
||||
Err(_) => reassembly.expire(tokio::time::Instant::now()),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn closed(&self) -> BoxFuture<'_, ()> {
|
||||
@@ -208,10 +262,14 @@ impl IrohTransport {
|
||||
}
|
||||
|
||||
let serves = self.lookup.serves(outcome.network_id, &open.protocol).await;
|
||||
let max_datagram = conn.max_datagram_size().unwrap_or(0);
|
||||
if open.max_datagram == 0 || conn.max_datagram_size().is_none() {
|
||||
return Err(TransportError::Other(
|
||||
"data channel has no datagram support".into(),
|
||||
));
|
||||
}
|
||||
let ack = DataOpenAck {
|
||||
accepted: serves,
|
||||
max_datagram: max_datagram as u32,
|
||||
max_datagram: MAX_DATA_DATAGRAM as u32,
|
||||
};
|
||||
write_frame(
|
||||
&mut send,
|
||||
@@ -226,7 +284,14 @@ impl IrohTransport {
|
||||
return Err(TransportError::Declined(open.protocol));
|
||||
}
|
||||
|
||||
let link = IrohLink::new(outcome.network_id, peer, conn, usize::MAX, send, recv);
|
||||
let link = IrohLink::new(
|
||||
outcome.network_id,
|
||||
peer,
|
||||
conn,
|
||||
open.max_datagram as usize,
|
||||
send,
|
||||
recv,
|
||||
);
|
||||
Ok(InboundLink {
|
||||
network: outcome.network_id,
|
||||
peer,
|
||||
@@ -289,6 +354,7 @@ impl PacketTransport for IrohTransport {
|
||||
|
||||
let open = DataOpen {
|
||||
protocol: protocol.to_string(),
|
||||
max_datagram: MAX_DATA_DATAGRAM as u32,
|
||||
};
|
||||
write_frame(
|
||||
&mut send,
|
||||
@@ -310,6 +376,12 @@ impl PacketTransport for IrohTransport {
|
||||
return Err(TransportError::Declined(protocol.to_string()));
|
||||
}
|
||||
|
||||
if ack.max_datagram == 0 || conn.max_datagram_size().is_none() {
|
||||
return Err(TransportError::Other(
|
||||
"data channel has no datagram support".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let link = IrohLink::new(network, peer, conn, ack.max_datagram as usize, send, recv);
|
||||
Ok(Arc::new(link) as SharedLink)
|
||||
})
|
||||
+63
-2
@@ -29,8 +29,11 @@
|
||||
//! encrypted by the transport, and scoped to exactly one network, one peer and
|
||||
//! one plugin protocol.
|
||||
|
||||
mod fragments;
|
||||
pub mod iroh_link;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
|
||||
@@ -77,8 +80,8 @@ pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
|
||||
|
||||
/// The largest datagram this link can carry, in bytes.
|
||||
///
|
||||
/// A plugin must size its own packets to fit, because there is no
|
||||
/// fragmentation here.
|
||||
/// This is the logical payload limit. A transport can fragment underneath
|
||||
/// it so path MTU changes do not force a plugin to resize the host interface.
|
||||
fn max_datagram_size(&self) -> usize;
|
||||
|
||||
/// Sends one datagram.
|
||||
@@ -87,6 +90,16 @@ pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// handed to the transport, nothing more.
|
||||
fn send(&self, payload: Bytes) -> Result<(), TransportError>;
|
||||
|
||||
/// Sends a datagram with an opaque stable flow identifier. Plugins derive
|
||||
/// this before encryption; transports that do not route can ignore it.
|
||||
fn send_flow(
|
||||
&self,
|
||||
payload: Bytes,
|
||||
_flow: super::routing::FlowId,
|
||||
) -> Result<(), TransportError> {
|
||||
self.send(payload)
|
||||
}
|
||||
|
||||
/// Receives the next datagram, or `None` once the link is finished.
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>>;
|
||||
|
||||
@@ -144,3 +157,51 @@ pub trait PacketTransport: Send + Sync + std::fmt::Debug + 'static {
|
||||
protocol: &'a str,
|
||||
) -> BoxFuture<'a, Result<SharedLink, TransportError>>;
|
||||
}
|
||||
|
||||
/// A composite packet transport that dispatches requests by protocol id.
|
||||
#[derive(Debug)]
|
||||
pub struct MultiPacketTransport {
|
||||
default: Arc<dyn PacketTransport>,
|
||||
by_protocol: std::collections::HashMap<String, Arc<dyn PacketTransport>>,
|
||||
}
|
||||
|
||||
impl MultiPacketTransport {
|
||||
/// Creates a new composite transport with a default fallback transport.
|
||||
pub fn new(
|
||||
default: Arc<dyn PacketTransport>,
|
||||
by_protocol: std::collections::HashMap<String, Arc<dyn PacketTransport>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
default,
|
||||
by_protocol,
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the default underlying transport.
|
||||
pub fn default_transport(&self) -> &Arc<dyn PacketTransport> {
|
||||
&self.default
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketTransport for MultiPacketTransport {
|
||||
fn 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;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,7 @@
|
||||
//!
|
||||
//! * *Finding members of a network* — [`NetworkDiscovery::resolve`], keyed by
|
||||
//! the secret-derived [`DiscoveryKey`]. That is what lives here, and today
|
||||
//! it is [`StaticBootstrap`] plus a test backend; a DHT backend is future
|
||||
//! work.
|
||||
//! it includes [`StaticBootstrap`], [`MainlineDiscovery`] and a test backend.
|
||||
//! * *Resolving the address of one iroh endpoint* — **iroh's job, not ours**.
|
||||
//! With [`crate::config::TransportPolicy::N0Defaults`] or `DirectOnly`, iroh
|
||||
//! publishes and resolves endpoint addresses through Number 0's public
|
||||
@@ -23,14 +22,18 @@
|
||||
//! No empty result ever proves a network is empty. It only means "nobody found
|
||||
//! yet".
|
||||
//!
|
||||
//! Mainline DHT discovery is future work and is not implemented here.
|
||||
//! Mainline rendezvous is opt-in for library callers; the command line enables it.
|
||||
|
||||
mod mainline;
|
||||
pub(crate) mod worker;
|
||||
pub use mainline::MainlineDiscovery;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use iroh::{EndpointAddr, EndpointId};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::DiscoveryKey;
|
||||
|
||||
pub use crate::BoxFuture;
|
||||
@@ -44,6 +47,18 @@ pub enum CandidateSource {
|
||||
Discovery,
|
||||
/// An address hint restored from the disposable cache.
|
||||
Cache,
|
||||
/// An author of a signed record: somebody who belongs to this network,
|
||||
/// learned from state that reached us through anybody.
|
||||
///
|
||||
/// It carries no address of its own — the endpoint's own discovery has
|
||||
/// to resolve it — but knowing that a member exists is what turns a
|
||||
/// star around whoever was named on the command line into a mesh.
|
||||
Member,
|
||||
/// Passed on by a member we are talking to.
|
||||
///
|
||||
/// A candidate like any other: an introduction is not a vouching, and
|
||||
/// membership is still decided by the handshake.
|
||||
Introduced,
|
||||
}
|
||||
|
||||
/// An unverified candidate peer.
|
||||
@@ -91,6 +106,23 @@ pub trait NetworkDiscovery: Send + Sync + std::fmt::Debug + 'static {
|
||||
|
||||
/// Returns the candidates currently known for `key`.
|
||||
fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>>;
|
||||
|
||||
/// Delivers candidates as they arrive. The default adapts a batch backend;
|
||||
/// remote backends can override this so the first dial need not wait for all lookups.
|
||||
fn resolve_into<'a>(
|
||||
&'a self,
|
||||
key: DiscoveryKey,
|
||||
candidates: tokio::sync::mpsc::Sender<Candidate>,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
for candidate in self.resolve(key).await? {
|
||||
if candidates.send(candidate).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A statically configured list of bootstrap candidates.
|
||||
@@ -261,13 +293,25 @@ impl NetworkDiscovery for CompositeDiscovery {
|
||||
|
||||
fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
for backend in &self.backends {
|
||||
// One failing backend must not stop the others.
|
||||
if let Err(err) = backend.publish(key, addr.clone()).await {
|
||||
tracing::debug!(backend = backend.name(), %err, "publish failed");
|
||||
let backend = backend.clone();
|
||||
let addr = addr.clone();
|
||||
tasks.spawn(async move { backend.publish(key, addr).await });
|
||||
}
|
||||
let mut failed = false;
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
if !matches!(result, Ok(Ok(()))) {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
if failed {
|
||||
Err(Error::Discovery(
|
||||
"a discovery publication failed; will retry".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -277,11 +321,12 @@ impl NetworkDiscovery for CompositeDiscovery {
|
||||
endpoint: EndpointId,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
for backend in &self.backends {
|
||||
if let Err(err) = backend.unpublish(key, endpoint).await {
|
||||
tracing::debug!(backend = backend.name(), %err, "unpublish failed");
|
||||
}
|
||||
let backend = backend.clone();
|
||||
tasks.spawn(async move { backend.unpublish(key, endpoint).await });
|
||||
}
|
||||
while tasks.join_next().await.is_some() {}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -300,4 +345,25 @@ impl NetworkDiscovery for CompositeDiscovery {
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_into<'a>(
|
||||
&'a self,
|
||||
key: DiscoveryKey,
|
||||
candidates: tokio::sync::mpsc::Sender<Candidate>,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
for backend in &self.backends {
|
||||
let backend = backend.clone();
|
||||
let candidates = candidates.clone();
|
||||
tasks.spawn(async move {
|
||||
if let Err(err) = backend.resolve_into(key, candidates).await {
|
||||
tracing::debug!(backend = backend.name(), %err, "resolve failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
while tasks.join_next().await.is_some() {}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
//! Mainline BEP44 rendezvous. Slots are a changing sample, not a membership list.
|
||||
|
||||
use ::mainline::{Dht, MutableItem, SigningKey, async_dht::AsyncDht};
|
||||
use futures_lite::StreamExt;
|
||||
use iroh::{EndpointAddr, EndpointId};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::{Mutex as AsyncMutex, mpsc};
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::timeout;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::{BoxFuture, Candidate, CandidateSource, NetworkDiscovery};
|
||||
use crate::config::{
|
||||
DHT_CLOCK_SKEW, DHT_MAX_ADDRS, DHT_MAX_RELAY_LEN, DHT_MAX_VALUE, DHT_RECORD_TTL, DHT_SLOTS,
|
||||
};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::{DiscoveryKey, NetworkKeys};
|
||||
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
const MAGIC: &[u8; 5] = b"TSND\x01";
|
||||
|
||||
/// One lazily started Mainline client, shared by all networks on one agent.
|
||||
/// No socket is opened until an active network publishes or looks up candidates.
|
||||
/// Agent shutdown closes this client and all its clones; a new agent needs a new client.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MainlineDiscovery {
|
||||
client: Arc<AsyncMutex<ClientState>>,
|
||||
allow_loopback: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
enum ClientState {
|
||||
#[default]
|
||||
Pending,
|
||||
Ready(AsyncDht),
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl MainlineDiscovery {
|
||||
/// Uses a caller-supplied Mainline node instead of public bootstrap defaults.
|
||||
pub fn from_dht(dht: Dht) -> Self {
|
||||
Self {
|
||||
client: Arc::new(AsyncMutex::new(ClientState::Ready(dht.as_async()))),
|
||||
allow_loopback: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a loopback-only client for a local Mainline Testnet.
|
||||
#[cfg(feature = "testing")]
|
||||
pub fn local_testnet(bootstrap: &[String]) -> Result<Self> {
|
||||
// Reject anything that could resolve or send outside loopback.
|
||||
if bootstrap.is_empty()
|
||||
|| bootstrap.iter().any(|s| {
|
||||
s.parse::<SocketAddr>()
|
||||
.map_or(true, |a| !a.ip().is_loopback())
|
||||
})
|
||||
{
|
||||
return Err(Error::Discovery(
|
||||
"test bootstrap must contain loopback sockets".into(),
|
||||
));
|
||||
}
|
||||
let dht = Dht::builder()
|
||||
.bootstrap(bootstrap)
|
||||
.bind_address(Ipv4Addr::LOCALHOST)
|
||||
.port(0)
|
||||
.request_timeout(Duration::from_millis(200))
|
||||
.build()
|
||||
.map_err(|_| Error::Discovery("cannot bind local DHT".into()))?;
|
||||
Ok(Self {
|
||||
allow_loopback: true,
|
||||
..Self::from_dht(dht)
|
||||
})
|
||||
}
|
||||
|
||||
/// Binds this client's rendezvous backend to one network's independent keys.
|
||||
pub fn for_network(&self, keys: &NetworkKeys) -> Arc<dyn NetworkDiscovery> {
|
||||
Arc::new(NetworkDht {
|
||||
client: self.clone(),
|
||||
key: keys.discovery_key(),
|
||||
seed: Zeroizing::new(*keys.dht_write_key()),
|
||||
observed: Mutex::new(VecDeque::new()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn client(&self) -> Result<AsyncDht> {
|
||||
let mut client = self.client.lock().await;
|
||||
match &*client {
|
||||
ClientState::Ready(dht) => return Ok(dht.clone()),
|
||||
ClientState::Stopped => return Err(Error::Discovery("DHT client is stopped".into())),
|
||||
ClientState::Pending => {}
|
||||
}
|
||||
// Construction may resolve bootstrap hostnames. Keep it off Tokio's
|
||||
// executor; a construction failure is retried on the next operation.
|
||||
let dht = tokio::task::spawn_blocking(|| Dht::builder().port(0).build())
|
||||
.await
|
||||
.map_err(|_| Error::Discovery("DHT startup task failed".into()))?
|
||||
.map(Dht::as_async)
|
||||
.map_err(|_| Error::Discovery("cannot start DHT client".into()))?;
|
||||
*client = ClientState::Ready(dht.clone());
|
||||
Ok(dht)
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(&self) {
|
||||
*self.client.lock().await = ClientState::Stopped;
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkDht {
|
||||
client: MainlineDiscovery,
|
||||
key: DiscoveryKey,
|
||||
seed: Zeroizing<[u8; 32]>,
|
||||
// Records encountered while publishing. Only an explicit lookup consumes
|
||||
// these hints: publication never causes dials on a connected network.
|
||||
observed: Mutex<VecDeque<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NetworkDht {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("NetworkDht(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
fn salt(slot: u8) -> Vec<u8> {
|
||||
let mut salt = b"tsunagi-rendezvous-v1".to_vec();
|
||||
salt.push(slot);
|
||||
salt
|
||||
}
|
||||
|
||||
fn slots_for(id: EndpointId) -> [u8; 2] {
|
||||
let hash = Sha256::digest(id.as_bytes());
|
||||
let first = hash[0] % DHT_SLOTS;
|
||||
[first, (first + 1 + hash[1] % (DHT_SLOTS - 1)) % DHT_SLOTS]
|
||||
}
|
||||
|
||||
fn now() -> Result<u64> {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.map_err(|_| Error::Discovery("system clock predates Unix epoch".into()))
|
||||
}
|
||||
|
||||
impl NetworkDht {
|
||||
async fn publish_slot(&self, dht: &AsyncDht, slot: u8, value: &[u8]) -> Result<()> {
|
||||
let salt = salt(slot);
|
||||
let signer = SigningKey::from_bytes(&self.seed);
|
||||
let public = signer.verifying_key().to_bytes();
|
||||
for attempt in 0..3 {
|
||||
let recent = timeout(
|
||||
QUERY_TIMEOUT,
|
||||
dht.get_mutable_most_recent(&public, Some(&salt)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| Error::Discovery("DHT read timed out".into()))?;
|
||||
if let Some(item) = &recent
|
||||
&& item.value().len() <= DHT_MAX_VALUE
|
||||
&& let Ok(mut observed) = self.observed.lock()
|
||||
{
|
||||
if observed.len() == DHT_SLOTS as usize {
|
||||
observed.pop_front();
|
||||
}
|
||||
observed.push_back(item.value().to_vec());
|
||||
}
|
||||
let seq = recent
|
||||
.as_ref()
|
||||
.map_or(0, MutableItem::seq)
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| Error::Discovery("DHT sequence exhausted".into()))?;
|
||||
let item = MutableItem::new(signer.clone(), value, seq, Some(&salt));
|
||||
match timeout(
|
||||
QUERY_TIMEOUT,
|
||||
dht.put_mutable(item, recent.as_ref().map(MutableItem::seq)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(outcome)) if outcome.stored_at > 0 => return Ok(()),
|
||||
_ if attempt < 2 => {
|
||||
// CAS is per storage node, not a global lock. Read again on
|
||||
// conflicts; another writer's entry is an acceptable sample.
|
||||
tokio::time::sleep(Duration::from_millis(rand::random_range(30..150))).await;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Err(Error::Discovery(
|
||||
"DHT publication was not acknowledged".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkDiscovery for NetworkDht {
|
||||
fn name(&self) -> &str {
|
||||
"mainline"
|
||||
}
|
||||
|
||||
fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
if key != self.key {
|
||||
return Err(Error::Discovery("wrong rendezvous network".into()));
|
||||
}
|
||||
let value = encode_record(&addr, now()?, self.client.allow_loopback)?;
|
||||
let dht = self.client.client().await?;
|
||||
let [first, second] = slots_for(addr.id);
|
||||
let (a, b) = tokio::join!(
|
||||
self.publish_slot(&dht, first, &value),
|
||||
self.publish_slot(&dht, second, &value)
|
||||
);
|
||||
a.and(b)
|
||||
})
|
||||
}
|
||||
|
||||
fn unpublish<'a>(&'a self, _: DiscoveryKey, _: EndpointId) -> BoxFuture<'a, Result<()>> {
|
||||
// Stopping publication lets freshness expire. Deleting a shared slot
|
||||
// could erase a different writer, and BEP44 has no delete operation.
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
|
||||
Box::pin(async move {
|
||||
let (tx, mut rx) = mpsc::channel(DHT_SLOTS as usize);
|
||||
let collect = async move {
|
||||
let mut found = Vec::new();
|
||||
while let Some(candidate) = rx.recv().await {
|
||||
found.push(candidate);
|
||||
}
|
||||
found
|
||||
};
|
||||
let (result, found) = tokio::join!(self.resolve_into(key, tx), collect);
|
||||
result?;
|
||||
Ok(found)
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_into<'a>(
|
||||
&'a self,
|
||||
key: DiscoveryKey,
|
||||
candidates: mpsc::Sender<Candidate>,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
if key != self.key {
|
||||
return Err(Error::Discovery("wrong rendezvous network".into()));
|
||||
}
|
||||
let dht = self.client.client().await?;
|
||||
let public = SigningKey::from_bytes(&self.seed)
|
||||
.verifying_key()
|
||||
.to_bytes();
|
||||
let observed = self
|
||||
.observed
|
||||
.lock()
|
||||
.map(|mut values| std::mem::take(&mut *values))
|
||||
.unwrap_or_default();
|
||||
let mut seen = HashSet::new();
|
||||
for value in observed {
|
||||
if let Some(addr) = decode_record(&value, now()?, self.client.allow_loopback)
|
||||
&& seen.insert(addr.id)
|
||||
&& candidates
|
||||
.send(Candidate::new(addr, CandidateSource::Discovery))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let mut slots: Vec<_> = (0..DHT_SLOTS).collect();
|
||||
for i in (1..slots.len()).rev() {
|
||||
slots.swap(i, rand::random_range(0..=i));
|
||||
}
|
||||
let mut queries = JoinSet::new();
|
||||
loop {
|
||||
if seen.len() >= DHT_SLOTS as usize {
|
||||
break;
|
||||
}
|
||||
while queries.len() < 4 {
|
||||
let Some(slot) = slots.pop() else {
|
||||
break;
|
||||
};
|
||||
let dht = dht.clone();
|
||||
let allow_loopback = self.client.allow_loopback;
|
||||
queries.spawn(async move {
|
||||
timeout(QUERY_TIMEOUT, async move {
|
||||
let salt = salt(slot);
|
||||
let target = MutableItem::target_from_key(&public, Some(&salt));
|
||||
let mut items = dht.get_mutable(&public, Some(&salt), None);
|
||||
while let Some(item) = items.next().await {
|
||||
// Mainline verifies the signature; also bind the
|
||||
// returned item to the key and slot we requested.
|
||||
if item.key() != &public || item.target() != &target {
|
||||
continue;
|
||||
}
|
||||
if let Some(addr) =
|
||||
decode_record(item.value(), now().ok()?, allow_loopback)
|
||||
{
|
||||
return Some(addr);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
});
|
||||
}
|
||||
let Some(result) = queries.join_next().await else {
|
||||
break;
|
||||
};
|
||||
if let Ok(Some(addr)) = result
|
||||
&& seen.insert(addr.id)
|
||||
&& candidates
|
||||
.send(Candidate::new(addr, CandidateSource::Discovery))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn usable(addr: &SocketAddr, allow_loopback: bool) -> bool {
|
||||
addr.port() != 0
|
||||
&& !addr.ip().is_unspecified()
|
||||
&& !addr.ip().is_multicast()
|
||||
&& (allow_loopback || !addr.ip().is_loopback())
|
||||
&& match addr.ip() {
|
||||
IpAddr::V4(ip) => !ip.is_broadcast() && !ip.is_link_local(),
|
||||
IpAddr::V6(ip) => !ip.is_unicast_link_local(),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_record(addr: &EndpointAddr, timestamp: u64, allow_loopback: bool) -> Result<Vec<u8>> {
|
||||
let ips: Vec<_> = addr
|
||||
.ip_addrs()
|
||||
.filter(|ip| usable(ip, allow_loopback))
|
||||
.take(DHT_MAX_ADDRS)
|
||||
.collect();
|
||||
let relay = addr
|
||||
.relay_urls()
|
||||
.next()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_default();
|
||||
if relay.len() > DHT_MAX_RELAY_LEN || (ips.is_empty() && relay.is_empty()) {
|
||||
return Err(Error::Discovery("no encodable endpoint address yet".into()));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(DHT_MAX_VALUE);
|
||||
bytes.extend_from_slice(MAGIC);
|
||||
bytes.extend_from_slice(×tamp.to_be_bytes());
|
||||
bytes.extend_from_slice(addr.id.as_bytes());
|
||||
bytes.extend_from_slice(&(relay.len() as u16).to_be_bytes());
|
||||
bytes.extend_from_slice(relay.as_bytes());
|
||||
bytes.push(ips.len() as u8);
|
||||
for ip in ips {
|
||||
match ip.ip() {
|
||||
IpAddr::V4(v) => {
|
||||
bytes.push(4);
|
||||
bytes.extend_from_slice(&v.octets());
|
||||
}
|
||||
IpAddr::V6(v) => {
|
||||
bytes.push(6);
|
||||
bytes.extend_from_slice(&v.octets());
|
||||
}
|
||||
}
|
||||
bytes.extend_from_slice(&ip.port().to_be_bytes());
|
||||
}
|
||||
if bytes.len() > DHT_MAX_VALUE {
|
||||
return Err(Error::Discovery("DHT record too large".into()));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn take<'a>(input: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
|
||||
let (head, rest) = input.split_at_checked(n)?;
|
||||
*input = rest;
|
||||
Some(head)
|
||||
}
|
||||
|
||||
fn decode_record(mut bytes: &[u8], now: u64, allow_loopback: bool) -> Option<EndpointAddr> {
|
||||
if bytes.len() > DHT_MAX_VALUE || take(&mut bytes, MAGIC.len())? != MAGIC {
|
||||
return None;
|
||||
}
|
||||
let timestamp = u64::from_be_bytes(take(&mut bytes, 8)?.try_into().ok()?);
|
||||
if timestamp > now.saturating_add(DHT_CLOCK_SKEW.as_secs())
|
||||
|| now.saturating_sub(timestamp) >= DHT_RECORD_TTL.as_secs()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let id = EndpointId::from_bytes(take(&mut bytes, 32)?.try_into().ok()?).ok()?;
|
||||
let mut addr = EndpointAddr::new(id);
|
||||
let len = u16::from_be_bytes(take(&mut bytes, 2)?.try_into().ok()?) as usize;
|
||||
if len > DHT_MAX_RELAY_LEN {
|
||||
return None;
|
||||
}
|
||||
let relay = std::str::from_utf8(take(&mut bytes, len)?).ok()?;
|
||||
if !relay.is_empty() {
|
||||
addr = addr.with_relay_url(relay.parse().ok()?);
|
||||
}
|
||||
let count = *take(&mut bytes, 1)?.first()? as usize;
|
||||
if count > DHT_MAX_ADDRS {
|
||||
return None;
|
||||
}
|
||||
for _ in 0..count {
|
||||
let ip = match *take(&mut bytes, 1)?.first()? {
|
||||
4 => IpAddr::V4(Ipv4Addr::from(
|
||||
<[u8; 4]>::try_from(take(&mut bytes, 4)?).ok()?,
|
||||
)),
|
||||
6 => IpAddr::V6(Ipv6Addr::from(
|
||||
<[u8; 16]>::try_from(take(&mut bytes, 16)?).ok()?,
|
||||
)),
|
||||
_ => return None,
|
||||
};
|
||||
let port = u16::from_be_bytes(take(&mut bytes, 2)?.try_into().ok()?);
|
||||
let socket = SocketAddr::new(ip, port);
|
||||
if usable(&socket, allow_loopback) {
|
||||
addr = addr.with_ip_addr(socket);
|
||||
}
|
||||
}
|
||||
(bytes.is_empty() && !addr.addrs.is_empty()).then_some(addr)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::testing::network;
|
||||
|
||||
fn address() -> EndpointAddr {
|
||||
EndpointAddr::new(iroh::SecretKey::generate().public())
|
||||
.with_ip_addr("127.0.0.1:12345".parse().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_reject_expiration_future_dates_truncation_and_oversize() {
|
||||
let addr = address();
|
||||
let bytes = encode_record(&addr, 1_000, true).unwrap();
|
||||
assert_eq!(decode_record(&bytes, 1_100, true).unwrap(), addr);
|
||||
assert!(decode_record(&bytes, 1_901, true).is_none());
|
||||
assert!(decode_record(&bytes, 100, true).is_none());
|
||||
for n in 0..bytes.len() {
|
||||
assert!(decode_record(&bytes[..n], 1_100, true).is_none());
|
||||
}
|
||||
assert!(decode_record(&vec![0; DHT_MAX_VALUE + 1], 1_100, true).is_none());
|
||||
assert!(encode_record(&addr, 1_000, false).is_err());
|
||||
assert!(decode_record(&bytes, 1_100, false).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stopped_custom_client_never_falls_back_to_public_bootstrap() {
|
||||
let net = mainline::Testnet::builder(3).build().unwrap();
|
||||
let client = MainlineDiscovery::local_testnet(&net.bootstrap).unwrap();
|
||||
let (name, secret) = network("mainline-shutdown");
|
||||
let keys = NetworkKeys::derive(&name, &secret);
|
||||
let backend = client.for_network(&keys);
|
||||
client.shutdown().await;
|
||||
assert!(backend.resolve(keys.discovery_key()).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_writers_share_slots_and_other_secrets_find_nothing() {
|
||||
let net = mainline::Testnet::builder(5).build().unwrap();
|
||||
let client_a = MainlineDiscovery::local_testnet(&net.bootstrap).unwrap();
|
||||
let client_b = MainlineDiscovery::local_testnet(&net.bootstrap).unwrap();
|
||||
let (name, secret) = network("mainline-slots");
|
||||
let keys = NetworkKeys::derive(&name, &secret);
|
||||
let a = client_a.for_network(&keys);
|
||||
let b = client_b.for_network(&keys);
|
||||
let first = address();
|
||||
let second = loop {
|
||||
let next = address();
|
||||
if slots_for(next.id) == slots_for(first.id) {
|
||||
break next;
|
||||
}
|
||||
};
|
||||
let key = keys.discovery_key();
|
||||
let (ra, rb) = tokio::join!(
|
||||
a.publish(key, first.clone()),
|
||||
b.publish(key, second.clone())
|
||||
);
|
||||
assert!(ra.is_ok() || rb.is_ok());
|
||||
let (found_a, found_b) = tokio::join!(a.resolve(key), b.resolve(key));
|
||||
let found_a = found_a.unwrap();
|
||||
let found_b = found_b.unwrap();
|
||||
assert!(
|
||||
found_a.iter().any(|c| c.addr == second) || found_b.iter().any(|c| c.addr == first),
|
||||
"at least one writer must discover the other despite colliding in both slots"
|
||||
);
|
||||
assert!(
|
||||
found_a
|
||||
.iter()
|
||||
.chain(&found_b)
|
||||
.all(|c| c.addr == first || c.addr == second)
|
||||
);
|
||||
let other = NetworkKeys::derive(&name, &crate::identity::NetworkSecret::generate());
|
||||
assert!(
|
||||
client_a
|
||||
.for_network(&other)
|
||||
.resolve(other.discovery_key())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
// Restarting the publisher must read seq from DHT instead of starting at 1.
|
||||
let restarted = client_a.for_network(&keys);
|
||||
restarted.publish(key, first.clone()).await.unwrap();
|
||||
assert!(
|
||||
b.resolve(key)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c.addr == first)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Per-network discovery tasks. Dropping the owner cancels all backend futures.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::{Instant, timeout};
|
||||
|
||||
use super::{Candidate, NetworkDiscovery};
|
||||
use crate::config::DiscoveryPolicy;
|
||||
use crate::identity::DiscoveryKey;
|
||||
use crate::net::EndpointAdapter;
|
||||
|
||||
pub(crate) struct DiscoveryWorker {
|
||||
connected: watch::Sender<Connectivity>,
|
||||
tasks: JoinSet<()>,
|
||||
backend: Arc<dyn NetworkDiscovery>,
|
||||
key: DiscoveryKey,
|
||||
endpoint: iroh::EndpointId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Connectivity {
|
||||
Initial,
|
||||
Connected,
|
||||
Isolated(Instant),
|
||||
}
|
||||
|
||||
impl DiscoveryWorker {
|
||||
pub(crate) fn spawn(
|
||||
backend: Arc<dyn NetworkDiscovery>,
|
||||
key: DiscoveryKey,
|
||||
adapter: EndpointAdapter,
|
||||
policy: DiscoveryPolicy,
|
||||
address_check: Duration,
|
||||
candidates: mpsc::Sender<Candidate>,
|
||||
) -> Self {
|
||||
let (connected, receiver) = watch::channel(Connectivity::Initial);
|
||||
let mut tasks = JoinSet::new();
|
||||
tasks.spawn(publish_loop(
|
||||
backend.clone(),
|
||||
key,
|
||||
adapter.clone(),
|
||||
policy.clone(),
|
||||
address_check,
|
||||
));
|
||||
tasks.spawn(lookup_loop(
|
||||
backend.clone(),
|
||||
key,
|
||||
policy,
|
||||
receiver,
|
||||
candidates,
|
||||
));
|
||||
Self {
|
||||
connected,
|
||||
tasks,
|
||||
backend,
|
||||
key,
|
||||
endpoint: adapter.endpoint_id(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_connected(&self, connected: bool) {
|
||||
self.connected.send_if_modified(|current| {
|
||||
if matches!(*current, Connectivity::Connected) == connected {
|
||||
return false;
|
||||
}
|
||||
*current = if connected {
|
||||
Connectivity::Connected
|
||||
} else {
|
||||
Connectivity::Isolated(Instant::now())
|
||||
};
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) async fn stop(mut self) {
|
||||
self.tasks.abort_all();
|
||||
while self.tasks.join_next().await.is_some() {}
|
||||
// BEP44 has no deletion. Memory/static backends can withdraw promptly;
|
||||
// an unresponsive backend must not extend agent shutdown.
|
||||
let _ = timeout(
|
||||
Duration::from_millis(250),
|
||||
self.backend.unpublish(self.key, self.endpoint),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn jitter(delay: Duration) -> Duration {
|
||||
delay
|
||||
.mul_f64(0.8 + 0.4 * rand::random::<f64>())
|
||||
.max(Duration::from_millis(10))
|
||||
}
|
||||
|
||||
async fn publish_loop(
|
||||
backend: Arc<dyn NetworkDiscovery>,
|
||||
key: DiscoveryKey,
|
||||
adapter: EndpointAdapter,
|
||||
policy: DiscoveryPolicy,
|
||||
address_check: Duration,
|
||||
) {
|
||||
let mut previous = None;
|
||||
let mut due = Instant::now();
|
||||
let mut ticker = tokio::time::interval(address_check.max(Duration::from_millis(10)));
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let mut addr = adapter.addr();
|
||||
addr.addrs.extend(adapter.loopback_addr().addrs);
|
||||
if previous.as_ref() != Some(&addr) {
|
||||
previous = Some(addr.clone());
|
||||
due = Instant::now();
|
||||
}
|
||||
if Instant::now() < due {
|
||||
continue;
|
||||
}
|
||||
let result = timeout(policy.request_timeout, backend.publish(key, addr.clone())).await;
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
due = Instant::now() + jitter(policy.publish_interval);
|
||||
}
|
||||
result => {
|
||||
tracing::debug!(?result, "discovery publication failed; will retry");
|
||||
due = Instant::now() + jitter(policy.lookup_interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn lookup_loop(
|
||||
backend: Arc<dyn NetworkDiscovery>,
|
||||
key: DiscoveryKey,
|
||||
policy: DiscoveryPolicy,
|
||||
mut connected: watch::Receiver<Connectivity>,
|
||||
candidates: mpsc::Sender<Candidate>,
|
||||
) {
|
||||
let mut last_isolation = None;
|
||||
let mut due = Instant::now();
|
||||
let mut delay = policy.lookup_interval;
|
||||
loop {
|
||||
let state = *connected.borrow_and_update();
|
||||
if matches!(state, Connectivity::Connected) {
|
||||
if connected.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Connectivity::Isolated(since) = state
|
||||
&& last_isolation != Some(since)
|
||||
{
|
||||
// Preserve the transition time even if a rapid reconnect/disconnect
|
||||
// overwrote a watch value before this task had a chance to run.
|
||||
due = since + policy.reconnect_delay;
|
||||
delay = policy.lookup_interval;
|
||||
last_isolation = Some(since);
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
changed = connected.changed() => {
|
||||
if changed.is_err() { break; }
|
||||
continue;
|
||||
}
|
||||
_ = tokio::time::sleep_until(due) => {}
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
changed = connected.changed() => {
|
||||
if changed.is_err() { break; }
|
||||
continue;
|
||||
}
|
||||
result = timeout(policy.request_timeout, backend.resolve_into(key, candidates.clone())) => {
|
||||
if !matches!(result, Ok(Ok(()))) {
|
||||
tracing::debug!(?result, "discovery lookup failed; will retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
due = Instant::now() + jitter(delay);
|
||||
delay = delay.saturating_mul(2).min(policy.max_lookup_interval);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::Result;
|
||||
use crate::discovery::BoxFuture;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Reads(AtomicUsize);
|
||||
impl NetworkDiscovery for Reads {
|
||||
fn name(&self) -> &str {
|
||||
"reads"
|
||||
}
|
||||
fn publish<'a>(
|
||||
&'a self,
|
||||
_: DiscoveryKey,
|
||||
_: iroh::EndpointAddr,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
fn unpublish<'a>(
|
||||
&'a self,
|
||||
_: DiscoveryKey,
|
||||
_: iroh::EndpointId,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
fn resolve<'a>(&'a self, _: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Box::pin(async { Ok(Vec::new()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn recovery_requires_a_full_minute_and_reconnection_restarts_that_minute() {
|
||||
let reads = Arc::new(Reads::default());
|
||||
let (tx, rx) = watch::channel(Connectivity::Connected);
|
||||
let (candidates, _receiver) = mpsc::channel(16);
|
||||
let mut tasks = JoinSet::new();
|
||||
tasks.spawn(lookup_loop(
|
||||
reads.clone(),
|
||||
DiscoveryKey::from_bytes([0; 32]),
|
||||
DiscoveryPolicy::default(),
|
||||
rx,
|
||||
candidates,
|
||||
));
|
||||
tokio::task::yield_now().await;
|
||||
tx.send(Connectivity::Isolated(Instant::now())).unwrap();
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(Duration::from_secs(59)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(reads.0.load(Ordering::SeqCst), 0);
|
||||
tx.send(Connectivity::Connected).unwrap();
|
||||
tx.send(Connectivity::Isolated(Instant::now())).unwrap();
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(Duration::from_secs(59)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(reads.0.load(Ordering::SeqCst), 0);
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(reads.0.load(Ordering::SeqCst), 1);
|
||||
tx.send(Connectivity::Connected).unwrap();
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(Duration::from_secs(600)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(reads.0.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! A DNS view of the overlay.
|
||||
//!
|
||||
//! The names and addresses of a network, served to the host that runs the
|
||||
//! agent, so members can be reached by name. Answered from signed state, so a
|
||||
//! member that is switched off still resolves.
|
||||
//!
|
||||
//! Three parts, kept apart on purpose:
|
||||
//!
|
||||
//! * [`zone`] decides what the answer is. Pure, and knows nothing about
|
||||
//! packets or sockets.
|
||||
//! * [`server`] puts that on the wire.
|
||||
//! * `publish` tells the operating system where to send its questions,
|
||||
//! which is the only part that differs between platforms.
|
||||
|
||||
pub mod publish;
|
||||
pub mod server;
|
||||
pub mod zone;
|
||||
|
||||
pub use publish::{DnsPublisher, PublishError, Published};
|
||||
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
|
||||
/// Where the server listens, one list per address family.
|
||||
///
|
||||
/// Loopback, and only loopback. The zones are a view *for the host running
|
||||
/// the agent*: it is that host's resolver that is pointed at them. Binding
|
||||
/// an overlay address instead would put them in front of the whole mesh —
|
||||
/// and an agent in several networks would then answer one network's
|
||||
/// questions about another's names, which is precisely what separate
|
||||
/// networks are for.
|
||||
///
|
||||
/// Both families, and independently: a question arrives over whichever one
|
||||
/// the resolver happens to use, and on a host with one of them switched off
|
||||
/// the other must still answer. So neither list failing says anything about
|
||||
/// the other, and a listener is opened from each.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListenPlan {
|
||||
/// IPv4 candidates, best first.
|
||||
pub v4: Vec<SocketAddr>,
|
||||
/// IPv6 candidates, best first.
|
||||
pub v6: Vec<SocketAddr>,
|
||||
}
|
||||
|
||||
impl ListenPlan {
|
||||
/// The two lists, so a caller can bind one listener from each.
|
||||
pub fn families(&self) -> [&[SocketAddr]; 2] {
|
||||
[&self.v4, &self.v6]
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the listen plan for one port.
|
||||
pub fn listen_plan(port: u16) -> ListenPlan {
|
||||
ListenPlan {
|
||||
v4: vec![SocketAddr::from((Ipv4Addr::LOCALHOST, port))],
|
||||
v6: vec![SocketAddr::from((Ipv6Addr::LOCALHOST, port))],
|
||||
}
|
||||
}
|
||||
|
||||
pub use server::{DnsServer, SharedZone};
|
||||
pub use zone::{Answer, Query, Zone, ZoneError, ZoneName, Zones};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn both_families_are_planned_for_and_neither_is_reachable_off_this_host() {
|
||||
let plan = listen_plan(5354);
|
||||
assert_eq!(
|
||||
plan.v4,
|
||||
vec!["127.0.0.1:5354".parse::<SocketAddr>().unwrap()]
|
||||
);
|
||||
assert_eq!(plan.v6, vec!["[::1]:5354".parse::<SocketAddr>().unwrap()]);
|
||||
assert_eq!(plan.families().len(), 2);
|
||||
// Nothing here is reachable from another member: the zones are a
|
||||
// view for this host, and an agent in two networks must not answer
|
||||
// one network's questions about the other's names.
|
||||
for address in plan.families().concat() {
|
||||
assert!(address.ip().is_loopback(), "{address} is not loopback");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! A pretend resolver, so the wiring is tested without touching this host's.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published};
|
||||
|
||||
/// Records what it was asked to do, and can be told to refuse.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MockPublisher {
|
||||
applied: Arc<Mutex<Option<Published>>>,
|
||||
failure: Option<PublishError>,
|
||||
}
|
||||
|
||||
impl MockPublisher {
|
||||
/// A publisher that accepts everything.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// A publisher that refuses everything, with this reason.
|
||||
pub fn failing(error: PublishError) -> Self {
|
||||
Self {
|
||||
applied: Arc::new(Mutex::new(None)),
|
||||
failure: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// What is currently applied, if anything.
|
||||
pub fn applied(&self) -> Option<Published> {
|
||||
match self.applied.lock() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsPublisher for MockPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
if let Some(failure) = &self.failure {
|
||||
return Err(failure.clone());
|
||||
}
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = Some(published.clone()),
|
||||
Err(poisoned) => *poisoned.into_inner() = Some(published.clone()),
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = None,
|
||||
Err(poisoned) => *poisoned.into_inner() = None,
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn published() -> Published {
|
||||
Published {
|
||||
interface: "tsundemo".into(),
|
||||
servers: vec![
|
||||
"10.13.37.69:5354".parse().unwrap(),
|
||||
"[::1]:5354".parse().unwrap(),
|
||||
],
|
||||
domains: vec!["lab".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn applying_and_reverting_are_both_recorded() {
|
||||
let publisher = MockPublisher::new();
|
||||
assert!(publisher.applied().is_none());
|
||||
|
||||
publisher.apply(&published()).await.unwrap();
|
||||
assert_eq!(publisher.applied(), Some(published()));
|
||||
|
||||
publisher.revert().await.unwrap();
|
||||
assert!(publisher.applied().is_none());
|
||||
// Reverting twice is not an error: shutdown must not fail here.
|
||||
publisher.revert().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refusal_leaves_nothing_applied() {
|
||||
let publisher = MockPublisher::failing(PublishError::Refused("polkit said no".into()));
|
||||
assert!(publisher.apply(&published()).await.is_err());
|
||||
assert!(publisher.applied().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Telling the operating system where to send its questions.
|
||||
//!
|
||||
//! The server answers whether or not this works. That is the whole reason it
|
||||
//! is a separate thing: if the resolver cannot be configured — no
|
||||
//! systemd-resolved, an unwilling polkit, a platform nobody has written this
|
||||
//! for — the port is still up and the user can point something at it by
|
||||
//! hand. A failure here is a degraded overlay, not a broken one.
|
||||
//!
|
||||
//! Only the *mechanics* differ between systems. What has to be arranged is
|
||||
//! the same everywhere: send questions for these suffixes to this address,
|
||||
//! through this interface, and do not make it the resolver for anything
|
||||
//! else. That is [`Published`]; the rest is behind [`DnsPublisher`].
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
mod mock;
|
||||
pub use mock::MockPublisher;
|
||||
|
||||
#[cfg(all(feature = "dns-publish", target_os = "linux"))]
|
||||
mod resolved;
|
||||
#[cfg(all(feature = "dns-publish", target_os = "linux"))]
|
||||
pub use resolved::ResolvedPublisher;
|
||||
|
||||
#[cfg(all(feature = "dns-publish", target_os = "windows"))]
|
||||
mod windows;
|
||||
#[cfg(all(feature = "dns-publish", target_os = "windows"))]
|
||||
pub use windows::NrptPublisher;
|
||||
|
||||
#[cfg(all(feature = "dns-publish", target_os = "macos"))]
|
||||
mod macos;
|
||||
#[cfg(all(feature = "dns-publish", target_os = "macos"))]
|
||||
pub use macos::ResolverDirPublisher;
|
||||
|
||||
mod unsupported;
|
||||
pub use unsupported::UnsupportedPublisher;
|
||||
|
||||
/// What the operating system is asked to do.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Published {
|
||||
/// The interface questions should be sent through.
|
||||
///
|
||||
/// The server listens on an overlay address, which is only reachable
|
||||
/// over the overlay interface, so the two travel together. It must be an
|
||||
/// interface this agent created: configuring one it did not is
|
||||
/// configuring somebody else's.
|
||||
pub interface: String,
|
||||
/// Every address the server is listening on, best first.
|
||||
///
|
||||
/// One per address family, so a resolver reaches it over whichever it
|
||||
/// uses. All of them are published together and none is preferred by
|
||||
/// this: which one a resolver picks is its business.
|
||||
pub servers: Vec<SocketAddr>,
|
||||
/// The suffixes that belong to this server.
|
||||
///
|
||||
/// Routing suffixes only: they say *which questions* come here, never
|
||||
/// that this is the resolver for anything else.
|
||||
pub domains: Vec<String>,
|
||||
}
|
||||
|
||||
/// Why the resolver could not be told.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum PublishError {
|
||||
/// There is nothing here that can be configured this way.
|
||||
#[error("{0}")]
|
||||
Unavailable(String),
|
||||
/// Something is there, and it declined.
|
||||
///
|
||||
/// Separate from a plain failure because the answer is different: this
|
||||
/// one is about who the agent is running as, not about whether the thing
|
||||
/// works.
|
||||
#[error("{0}")]
|
||||
Refused(String),
|
||||
/// It was there, it accepted the request, and it went wrong anyway.
|
||||
#[error("{0}")]
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl PublishError {
|
||||
/// Whether waiting will fix it.
|
||||
///
|
||||
/// A refusal will not change on its own — somebody has to grant
|
||||
/// permission — so retrying it at the pace of everything else is just
|
||||
/// noise. Anything else might be a service still starting.
|
||||
pub fn needs_a_human(&self) -> bool {
|
||||
matches!(self, PublishError::Refused(_))
|
||||
}
|
||||
|
||||
/// One line for a status table.
|
||||
pub fn remedy(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
PublishError::Refused(_) => Some(
|
||||
"grant this user the `org.freedesktop.resolve1.set-*` actions in \
|
||||
/etc/polkit-1/rules.d, or run the agent as a system service",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The polkit rule that lets this user configure the resolver.
|
||||
///
|
||||
/// Printed in full rather than described, because the point of this feature
|
||||
/// is that the user has as little to do as possible, and "write a polkit
|
||||
/// rule" is a great deal more work than pasting one.
|
||||
///
|
||||
/// It grants exactly the four actions this agent calls and nothing else.
|
||||
/// polkit decides by user id — a capability does not help here — so there is
|
||||
/// no way to do this from inside the process.
|
||||
pub fn polkit_recipe(user: &str) -> String {
|
||||
format!(
|
||||
"sudo tee /etc/polkit-1/rules.d/50-tsunagi-resolved.rules > /dev/null <<'RULE'\n\
|
||||
polkit.addRule(function(action, subject) {{\n\
|
||||
\x20 var allowed = [\n\
|
||||
\x20 \"org.freedesktop.resolve1.set-dns-servers\",\n\
|
||||
\x20 \"org.freedesktop.resolve1.set-domains\",\n\
|
||||
\x20 \"org.freedesktop.resolve1.set-default-route\",\n\
|
||||
\x20 \"org.freedesktop.resolve1.revert\"\n\
|
||||
\x20 ];\n\
|
||||
\x20 if (allowed.indexOf(action.id) >= 0 && subject.user == \"{user}\") {{\n\
|
||||
\x20 return polkit.Result.YES;\n\
|
||||
\x20 }}\n\
|
||||
}});\n\
|
||||
RULE"
|
||||
)
|
||||
}
|
||||
|
||||
/// Arranges for the operating system to ask this server.
|
||||
pub trait DnsPublisher: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// A short name used in diagnostics.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Applies the setting, replacing whatever this publisher set before.
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>>;
|
||||
|
||||
/// Undoes it.
|
||||
///
|
||||
/// Reverting something that was never applied succeeds: this runs on the
|
||||
/// shutdown path, where the setting being gone is the point.
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>>;
|
||||
}
|
||||
|
||||
/// The kernel's index for an interface.
|
||||
///
|
||||
/// From sysfs, which needs no privileges and no netlink round trip.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn interface_index(name: &str) -> Option<u32> {
|
||||
std::fs::read_to_string(format!("/sys/class/net/{name}/ifindex"))
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// The kernel's index for an interface.
|
||||
///
|
||||
/// Only the systemd-resolved publisher needs one, so off Linux there is
|
||||
/// nothing to look up and this is always `None`.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn interface_index(_name: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_a_refusal_suggests_what_to_do_about_it() {
|
||||
// The other two are conditions of the host, not of the user.
|
||||
assert!(PublishError::Refused("no".into()).remedy().is_some());
|
||||
assert!(PublishError::Unavailable("none".into()).remedy().is_none());
|
||||
assert!(PublishError::Failed("bang".into()).remedy().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_refusal_waits_for_a_person() {
|
||||
// The rest may come right on their own, so they are worth retrying
|
||||
// at the ordinary pace; a refusal is not.
|
||||
assert!(PublishError::Refused("no".into()).needs_a_human());
|
||||
assert!(!PublishError::Unavailable("none".into()).needs_a_human());
|
||||
assert!(!PublishError::Failed("bang".into()).needs_a_human());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_polkit_recipe_grants_what_is_called_and_no_more() {
|
||||
let recipe = polkit_recipe("ab");
|
||||
for action in [
|
||||
"set-dns-servers",
|
||||
"set-domains",
|
||||
"set-default-route",
|
||||
"revert",
|
||||
] {
|
||||
assert!(recipe.contains(action), "{action} missing from:\n{recipe}");
|
||||
}
|
||||
// Nothing beyond what the agent calls: a rule that granted the lot
|
||||
// would be handing out more than this feature needs.
|
||||
for other in ["set-dnssec", "set-mdns", "register-service", "set-llmnr"] {
|
||||
assert!(!recipe.contains(other), "{other} should not be granted");
|
||||
}
|
||||
assert!(recipe.contains("subject.user == \"ab\""));
|
||||
// ES5: the rules engine is duktape and has no `startsWith`.
|
||||
assert!(!recipe.contains("startsWith"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn an_interface_index_is_read_from_the_running_kernel() {
|
||||
assert_eq!(interface_index("lo"), Some(1));
|
||||
assert_eq!(interface_index("tsunagi-no-such-interface"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Telling systemd-resolved to send some questions here.
|
||||
//!
|
||||
//! Three calls on `org.freedesktop.resolve1.Manager`, all scoped to the
|
||||
//! overlay interface:
|
||||
//!
|
||||
//! * `SetLinkDNSEx` — where to send them. The `Ex` form carries a **port**,
|
||||
//! which is why this server does not have to sit on 53 and the agent needs
|
||||
//! no `CAP_NET_BIND_SERVICE`. Systems before systemd 247 have only
|
||||
//! `SetLinkDNS`, which has no port; there the fallback only works if the
|
||||
//! server did get port 53, and it says so rather than appearing to work.
|
||||
//! * `SetLinkDomains` with `routing_only` set — a *routing* suffix, the
|
||||
//! `~domain` form. It says which questions come here and claims nothing
|
||||
//! else.
|
||||
//! * `SetLinkDefaultRoute(false)` — so this never becomes the resolver for
|
||||
//! anything outside those suffixes. Without it resolved may fall back to
|
||||
//! this link for ordinary names, and this server refuses those.
|
||||
//!
|
||||
//! # It cleans up by itself
|
||||
//!
|
||||
//! resolved keys all of this to the interface, and drops it when the
|
||||
//! interface goes. The overlay interface belongs to a file descriptor the
|
||||
//! agent holds, so it goes when the agent does — however the agent goes. The
|
||||
//! explicit `RevertLink` on shutdown only makes that immediate.
|
||||
//!
|
||||
//! # Privilege
|
||||
//!
|
||||
//! resolved asks polkit, and polkit decides by user id, not by capability.
|
||||
//! So `CAP_NET_ADMIN` does not help here: an ordinary user is prompted or
|
||||
//! refused, while a system service running as root is not. That refusal is
|
||||
//! reported as its own kind of error, because the answer to it is different
|
||||
//! from the answer to "resolved is not installed".
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published, interface_index};
|
||||
|
||||
/// `AF_INET`, as resolved wants it.
|
||||
const AF_INET: i32 = 2;
|
||||
/// `AF_INET6`.
|
||||
const AF_INET6: i32 = 10;
|
||||
|
||||
#[zbus::proxy(
|
||||
interface = "org.freedesktop.resolve1.Manager",
|
||||
default_service = "org.freedesktop.resolve1",
|
||||
default_path = "/org/freedesktop/resolve1"
|
||||
)]
|
||||
trait Resolved {
|
||||
/// Servers for a link, with a port and a name. systemd 247 and later.
|
||||
#[zbus(name = "SetLinkDNSEx")]
|
||||
fn set_link_dns_ex(
|
||||
&self,
|
||||
ifindex: i32,
|
||||
addresses: &[(i32, Vec<u8>, u16, String)],
|
||||
) -> zbus::Result<()>;
|
||||
|
||||
/// Servers for a link, without a port. Always port 53.
|
||||
#[zbus(name = "SetLinkDNS")]
|
||||
fn set_link_dns(&self, ifindex: i32, addresses: &[(i32, Vec<u8>)]) -> zbus::Result<()>;
|
||||
|
||||
/// Suffixes for a link. The flag makes one routing-only.
|
||||
#[zbus(name = "SetLinkDomains")]
|
||||
fn set_link_domains(&self, ifindex: i32, domains: &[(String, bool)]) -> zbus::Result<()>;
|
||||
|
||||
/// Whether this link may answer for names outside its suffixes.
|
||||
#[zbus(name = "SetLinkDefaultRoute")]
|
||||
fn set_link_default_route(&self, ifindex: i32, enable: bool) -> zbus::Result<()>;
|
||||
|
||||
/// Forgets everything set for a link.
|
||||
#[zbus(name = "RevertLink")]
|
||||
fn revert_link(&self, ifindex: i32) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
/// Configures systemd-resolved over D-Bus.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ResolvedPublisher {
|
||||
/// The link last configured, so shutdown knows what to undo.
|
||||
applied: std::sync::Mutex<Option<u32>>,
|
||||
}
|
||||
|
||||
impl ResolvedPublisher {
|
||||
/// Creates the publisher. Nothing is contacted until [`Self::apply`].
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn proxy() -> Result<ResolvedProxy<'static>, PublishError> {
|
||||
let connection = zbus::Connection::system().await.map_err(|err| {
|
||||
PublishError::Unavailable(format!("no system D-Bus to talk to resolved on: {err}"))
|
||||
})?;
|
||||
ResolvedProxy::new(&connection).await.map_err(|err| {
|
||||
PublishError::Unavailable(format!("systemd-resolved is not answering: {err}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a D-Bus failure into the kind of failure it actually is.
|
||||
fn classify(err: zbus::Error, what: &str) -> PublishError {
|
||||
let name = match &err {
|
||||
zbus::Error::MethodError(name, _, _) => name.as_str().to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
match name.as_str() {
|
||||
"org.freedesktop.DBus.Error.InteractiveAuthorizationRequired"
|
||||
| "org.freedesktop.DBus.Error.AccessDenied" => {
|
||||
PublishError::Refused(format!("systemd-resolved refused {what}: {err}"))
|
||||
}
|
||||
"org.freedesktop.DBus.Error.UnknownMethod"
|
||||
| "org.freedesktop.DBus.Error.ServiceUnknown" => {
|
||||
PublishError::Unavailable(format!("systemd-resolved cannot do {what}: {err}"))
|
||||
}
|
||||
_ => PublishError::Failed(format!("systemd-resolved failed {what}: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The address in the shape resolved wants: a family and raw octets.
|
||||
fn wire_address(address: IpAddr) -> (i32, Vec<u8>) {
|
||||
match address {
|
||||
IpAddr::V4(address) => (AF_INET, address.octets().to_vec()),
|
||||
IpAddr::V6(address) => (AF_INET6, address.octets().to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsPublisher for ResolvedPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"systemd-resolved"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
let ifindex = interface_index(&published.interface).ok_or_else(|| {
|
||||
PublishError::Unavailable(format!(
|
||||
"interface `{}` is not on this host, so there is no link to configure",
|
||||
published.interface
|
||||
))
|
||||
})?;
|
||||
if published.servers.is_empty() {
|
||||
return Err(PublishError::Unavailable(
|
||||
"there is no listening address to send questions to".to_string(),
|
||||
));
|
||||
}
|
||||
let proxy = Self::proxy().await?;
|
||||
let index = ifindex as i32;
|
||||
// Every family in one call, because this replaces the link's
|
||||
// whole list: sending them one at a time would leave only the
|
||||
// last. A resolver then picks whichever it can reach.
|
||||
let servers: Vec<(i32, Vec<u8>, u16, String)> = published
|
||||
.servers
|
||||
.iter()
|
||||
.map(|server| {
|
||||
let (family, octets) = wire_address(server.ip());
|
||||
(family, octets, server.port(), String::new())
|
||||
})
|
||||
.collect();
|
||||
|
||||
match proxy.set_link_dns_ex(index, &servers).await {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
let classified = classify(err, "setting the link's DNS servers");
|
||||
// Older systemd has no `Ex` form, and the plain one is
|
||||
// always port 53. Falling back to it when the server is
|
||||
// somewhere else would point resolved at nothing.
|
||||
if !matches!(classified, PublishError::Unavailable(_)) {
|
||||
return Err(classified);
|
||||
}
|
||||
if let Some(elsewhere) =
|
||||
published.servers.iter().find(|server| server.port() != 53)
|
||||
{
|
||||
return Err(PublishError::Unavailable(format!(
|
||||
"this systemd-resolved cannot be given a port, and the server is on \
|
||||
{}. Run the server on port 53, or point your resolver at \
|
||||
{elsewhere} yourself.",
|
||||
elsewhere.port()
|
||||
)));
|
||||
}
|
||||
let plain: Vec<(i32, Vec<u8>)> = servers
|
||||
.into_iter()
|
||||
.map(|(family, octets, _, _)| (family, octets))
|
||||
.collect();
|
||||
proxy
|
||||
.set_link_dns(index, &plain)
|
||||
.await
|
||||
.map_err(|err| classify(err, "setting the link's DNS servers"))?;
|
||||
}
|
||||
}
|
||||
|
||||
let domains: Vec<(String, bool)> = published
|
||||
.domains
|
||||
.iter()
|
||||
.map(|domain| (domain.clone(), true))
|
||||
.collect();
|
||||
proxy
|
||||
.set_link_domains(index, &domains)
|
||||
.await
|
||||
.map_err(|err| classify(err, "setting the link's search domains"))?;
|
||||
|
||||
// Last, and deliberately: until this is off, resolved may send
|
||||
// ordinary names here, and this server refuses them.
|
||||
proxy
|
||||
.set_link_default_route(index, false)
|
||||
.await
|
||||
.map_err(|err| classify(err, "clearing the link's default route"))?;
|
||||
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = Some(ifindex),
|
||||
Err(poisoned) => *poisoned.into_inner() = Some(ifindex),
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
let applied = match self.applied.lock() {
|
||||
Ok(mut guard) => guard.take(),
|
||||
Err(poisoned) => poisoned.into_inner().take(),
|
||||
};
|
||||
let Some(ifindex) = applied else {
|
||||
return Ok(());
|
||||
};
|
||||
let proxy = Self::proxy().await?;
|
||||
match proxy.revert_link(ifindex as i32).await {
|
||||
Ok(()) => Ok(()),
|
||||
// The link going away takes the setting with it, so this is
|
||||
// the outcome asked for rather than a failure.
|
||||
Err(zbus::Error::MethodError(name, _, _))
|
||||
if name.as_str().contains("NoSuchLink") =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(classify(err, "reverting the link")),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
#[test]
|
||||
fn an_address_is_encoded_the_way_resolved_expects() {
|
||||
assert_eq!(
|
||||
wire_address(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 69))),
|
||||
(AF_INET, vec![10, 13, 37, 69])
|
||||
);
|
||||
let (family, octets) = wire_address("fd55::1".parse().unwrap());
|
||||
assert_eq!(family, AF_INET6);
|
||||
assert_eq!(octets.len(), 16);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_interface_that_is_not_here_is_unavailable_not_a_failure() {
|
||||
// Nothing is contacted: there is no link to configure, and saying so
|
||||
// is the honest answer without bothering the bus.
|
||||
let publisher = ResolvedPublisher::new();
|
||||
let published = Published {
|
||||
interface: "tsunagi-no-such-interface".into(),
|
||||
servers: vec![SocketAddr::from(([10, 13, 37, 69], 5354))],
|
||||
domains: vec!["lab".into()],
|
||||
};
|
||||
let err = publisher.apply(&published).await.unwrap_err();
|
||||
assert!(matches!(err, PublishError::Unavailable(_)), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reverting_without_having_applied_does_nothing_and_succeeds() {
|
||||
// The shutdown path must not fail because there was nothing to undo.
|
||||
ResolvedPublisher::new().revert().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! The publisher for systems nobody has written one for.
|
||||
//!
|
||||
//! macOS needs `SystemConfiguration` and Windows the IP Helper API, and
|
||||
//! neither is written. Saying so is more use than appearing to work: the
|
||||
//! server is already answering, so all that is missing is the last step, and
|
||||
//! the user can take it by hand once they know that is what is needed.
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published};
|
||||
|
||||
/// Refuses to configure anything, with an explanation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnsupportedPublisher {
|
||||
platform: &'static str,
|
||||
}
|
||||
|
||||
impl Default for UnsupportedPublisher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl UnsupportedPublisher {
|
||||
/// A publisher naming the platform it stands in for.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
platform: std::env::consts::OS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsPublisher for UnsupportedPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"unsupported"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, _published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
Err(PublishError::Unavailable(format!(
|
||||
"configuring the system resolver is not implemented on {} yet",
|
||||
self.platform
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
// Nothing was set, so there is nothing to undo and no reason to fail
|
||||
// a shutdown over it.
|
||||
Box::pin(async move { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_names_the_platform_and_still_reverts_quietly() {
|
||||
let publisher = UnsupportedPublisher::new();
|
||||
let published = Published {
|
||||
interface: "tsundemo".into(),
|
||||
servers: vec!["10.0.0.1:5354".parse().unwrap()],
|
||||
domains: vec!["lab".into()],
|
||||
};
|
||||
let err = publisher.apply(&published).await.unwrap_err();
|
||||
assert!(matches!(err, PublishError::Unavailable(_)));
|
||||
assert!(err.to_string().contains(std::env::consts::OS));
|
||||
publisher.revert().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! Telling the Windows DNS client to send some questions here, through the
|
||||
//! Name Resolution Policy Table.
|
||||
//!
|
||||
//! The NRPT is how Windows does split DNS: a rule says "names under this
|
||||
//! suffix are resolved by these servers", and names outside every rule are
|
||||
//! resolved the ordinary way. That is exactly the contract of [`Published`] —
|
||||
//! route these suffixes here and claim nothing else — so one NRPT rule per
|
||||
//! suffix is the whole of it. The rules are keyed by namespace, not by
|
||||
//! interface, so the interface name in [`Published`] is not needed here.
|
||||
//!
|
||||
//! # Why PowerShell
|
||||
//!
|
||||
//! The NRPT lives in the registry, but a rule only takes effect once the DNS
|
||||
//! client is told to reload its policy, and that notification is an RPC with
|
||||
//! no safe wrapper this crate may call — it forbids `unsafe`. The
|
||||
//! `DnsClient` PowerShell module does the write *and* the reload, so it is
|
||||
//! used by absolute path from `%SystemRoot%`. Every value passed to it is one
|
||||
//! this agent produced: the suffixes are validated zone names (letters,
|
||||
//! digits, `-`, `_`, `.` only) and the servers are addresses it is listening
|
||||
//! on. They are still single-quoted and escaped when built into the script,
|
||||
//! so nothing could be read as PowerShell rather than as data.
|
||||
//!
|
||||
//! # The port
|
||||
//!
|
||||
//! The Windows DNS client always asks on port 53; it cannot be told another.
|
||||
//! So a server on a different port cannot be reached this way, and rather than
|
||||
//! configure a rule that points at nothing, this says so — the same honest
|
||||
//! failure the systemd-resolved path gives for an old resolver with no port.
|
||||
//!
|
||||
//! # Privilege
|
||||
//!
|
||||
//! Writing an NRPT rule needs an elevated process. An ordinary user is
|
||||
//! refused, which is reported as its own kind of error because the answer to
|
||||
//! it — run elevated, or as a service — differs from "this is not Windows".
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published};
|
||||
|
||||
/// The port the Windows DNS client is fixed to.
|
||||
const DNS_PORT: u16 = 53;
|
||||
|
||||
/// Configures the Name Resolution Policy Table through PowerShell.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NrptPublisher {
|
||||
/// The namespaces last configured, so shutdown knows what to undo.
|
||||
applied: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl NrptPublisher {
|
||||
/// Creates the publisher. Nothing is contacted until [`Self::apply`].
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A suffix as the NRPT wants it: a leading dot means "this and everything
|
||||
/// under it".
|
||||
fn namespace(domain: &str) -> String {
|
||||
let trimmed = domain.trim_matches('.');
|
||||
format!(".{trimmed}")
|
||||
}
|
||||
|
||||
/// A PowerShell single-quoted literal, with any embedded quote doubled.
|
||||
fn ps_literal(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
/// A PowerShell array literal, `@('a','b')`, or `@()` when empty.
|
||||
fn ps_array(values: &[String]) -> String {
|
||||
let items: Vec<String> = values.iter().map(|value| ps_literal(value)).collect();
|
||||
format!("@({})", items.join(","))
|
||||
}
|
||||
|
||||
/// The script that clears our rules for these namespaces and then adds them.
|
||||
fn apply_script(namespaces: &[String], servers: &[String]) -> String {
|
||||
format!(
|
||||
"$ErrorActionPreference = 'Stop'\n\
|
||||
$ns = {ns}\n\
|
||||
$srv = {srv}\n\
|
||||
foreach ($n in $ns) {{ Get-DnsClientNrptRule | Where-Object {{ $_.Namespace -eq $n }} | \
|
||||
ForEach-Object {{ Remove-DnsClientNrptRule -Name $_.Name -Force }} }}\n\
|
||||
foreach ($n in $ns) {{ Add-DnsClientNrptRule -Namespace $n -NameServers $srv }}\n",
|
||||
ns = ps_array(namespaces),
|
||||
srv = ps_array(servers),
|
||||
)
|
||||
}
|
||||
|
||||
/// The script that clears our rules for these namespaces.
|
||||
fn revert_script(namespaces: &[String]) -> String {
|
||||
format!(
|
||||
"$ErrorActionPreference = 'Stop'\n\
|
||||
$ns = {ns}\n\
|
||||
foreach ($n in $ns) {{ Get-DnsClientNrptRule | Where-Object {{ $_.Namespace -eq $n }} | \
|
||||
ForEach-Object {{ Remove-DnsClientNrptRule -Name $_.Name -Force }} }}\n",
|
||||
ns = ps_array(namespaces),
|
||||
)
|
||||
}
|
||||
|
||||
/// Turns what PowerShell said on failure into the kind of failure it is.
|
||||
fn classify(text: &str) -> PublishError {
|
||||
let lower = text.to_ascii_lowercase();
|
||||
if lower.contains("access is denied")
|
||||
|| lower.contains("requires elevation")
|
||||
|| lower.contains("run as administrator")
|
||||
|| lower.contains("administrator privilege")
|
||||
|| lower.contains("permissiondenied")
|
||||
{
|
||||
PublishError::Refused(format!("the Windows DNS client refused the change: {text}"))
|
||||
} else if lower.contains("is not recognized")
|
||||
|| lower.contains("commandnotfoundexception")
|
||||
|| lower.contains("not recognized as the name of a cmdlet")
|
||||
{
|
||||
PublishError::Unavailable(format!(
|
||||
"the DnsClient PowerShell module is not available: {text}"
|
||||
))
|
||||
} else {
|
||||
PublishError::Failed(format!("the Windows DNS client failed the change: {text}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a PowerShell script from `%SystemRoot%` and maps a failure to a
|
||||
/// [`PublishError`].
|
||||
async fn powershell(script: String) -> Result<(), PublishError> {
|
||||
let program = powershell_path();
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
std::process::Command::new(program)
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
&script,
|
||||
])
|
||||
.output()
|
||||
})
|
||||
.await
|
||||
.map_err(|err| PublishError::Failed(format!("could not run PowerShell: {err}")))?
|
||||
.map_err(|err| PublishError::Unavailable(format!("could not run PowerShell: {err}")))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
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(classify(if text.is_empty() { "no output" } else { &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")
|
||||
}
|
||||
|
||||
impl DnsPublisher for NrptPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"windows-nrpt"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
if published.domains.is_empty() {
|
||||
return Err(PublishError::Unavailable(
|
||||
"there are no suffixes to route, so there is no rule to write".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Windows can only ask on port 53, so a server anywhere else is
|
||||
// unreachable this way. Keep the ones it can use.
|
||||
let servers: BTreeSet<IpAddr> = published
|
||||
.servers
|
||||
.iter()
|
||||
.filter(|server| server.port() == DNS_PORT)
|
||||
.map(|server| server.ip())
|
||||
.collect();
|
||||
if servers.is_empty() {
|
||||
let elsewhere = published
|
||||
.servers
|
||||
.iter()
|
||||
.map(|server| server.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
return Err(PublishError::Unavailable(format!(
|
||||
"the Windows DNS client only asks on port {DNS_PORT}, and the server is on \
|
||||
{elsewhere}. Run the server on port {DNS_PORT}, or point your resolver at it \
|
||||
yourself."
|
||||
)));
|
||||
}
|
||||
|
||||
let namespaces: Vec<String> = published.domains.iter().map(|d| namespace(d)).collect();
|
||||
let servers: Vec<String> = servers.iter().map(|ip| ip.to_string()).collect();
|
||||
|
||||
powershell(apply_script(&namespaces, &servers)).await?;
|
||||
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = namespaces,
|
||||
Err(poisoned) => *poisoned.into_inner() = namespaces,
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
let namespaces = match self.applied.lock() {
|
||||
Ok(mut guard) => std::mem::take(&mut *guard),
|
||||
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
|
||||
};
|
||||
if namespaces.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
powershell(revert_script(&namespaces)).await
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[test]
|
||||
fn a_suffix_gets_a_leading_dot() {
|
||||
assert_eq!(namespace("lab"), ".lab");
|
||||
assert_eq!(namespace(".lab"), ".lab");
|
||||
assert_eq!(namespace("a.b"), ".a.b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quote_in_a_value_cannot_escape_the_literal() {
|
||||
// Zone names never contain a quote, but the escaping is what makes
|
||||
// that a guarantee rather than a hope.
|
||||
assert_eq!(ps_literal("a'b"), "'a''b'");
|
||||
assert_eq!(ps_array(&["x".into(), "y".into()]), "@('x','y')");
|
||||
assert_eq!(ps_array(&[]), "@()");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_apply_script_removes_before_it_adds() {
|
||||
let script = apply_script(&[".lab".into()], &["10.13.37.69".into()]);
|
||||
let remove = script.find("Remove-DnsClientNrptRule").unwrap();
|
||||
let add = script.find("Add-DnsClientNrptRule").unwrap();
|
||||
assert!(
|
||||
remove < add,
|
||||
"a stale rule must go before the new one:\n{script}"
|
||||
);
|
||||
assert!(script.contains("@('.lab')"));
|
||||
assert!(script.contains("@('10.13.37.69')"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_access_denied_is_a_refusal_a_missing_cmdlet_is_unavailable() {
|
||||
assert!(matches!(
|
||||
classify("Access is denied"),
|
||||
PublishError::Refused(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify("The term 'Add-DnsClientNrptRule' is not recognized"),
|
||||
PublishError::Unavailable(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify("something else"),
|
||||
PublishError::Failed(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_server_only_on_a_nonstandard_port_is_unavailable_not_a_failure() {
|
||||
// Nothing is run: Windows cannot ask there, and saying so is the
|
||||
// honest answer without touching the DNS client.
|
||||
let publisher = NrptPublisher::new();
|
||||
let published = Published {
|
||||
interface: "tsun0".into(),
|
||||
servers: vec![SocketAddr::from(([10, 13, 37, 69], 5354))],
|
||||
domains: vec!["lab".into()],
|
||||
};
|
||||
let err = publisher.apply(&published).await.unwrap_err();
|
||||
assert!(matches!(err, PublishError::Unavailable(_)), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reverting_without_having_applied_does_nothing_and_succeeds() {
|
||||
NrptPublisher::new().revert().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
//! Answering DNS questions about the overlay, over UDP and TCP.
|
||||
//!
|
||||
//! Authoritative for one zone and nothing else. There is no recursion, no
|
||||
//! forwarding and no cache: a question this agent cannot answer from the
|
||||
//! signed roster is refused rather than passed anywhere, so pointing a
|
||||
//! resolver at this server can never make it a path to the outside.
|
||||
//!
|
||||
//! The decision of what to answer lives in [`super::zone`]; this module is
|
||||
//! only the wire format and the sockets. [`respond`] sits between them and
|
||||
//! takes bytes to bytes, so everything the server does to a packet is
|
||||
//! testable without opening a socket.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use simple_dns::rdata::{A, PTR, RData, SOA};
|
||||
use simple_dns::{Name, PacketFlag, QCLASS, QTYPE, RCODE, ResourceRecord, TYPE};
|
||||
|
||||
use super::zone::{Answer, Query, Zone, Zones};
|
||||
|
||||
/// How long an answer may be cached.
|
||||
///
|
||||
/// Short, because the roster changes when members come and go and a stale
|
||||
/// answer is worse than another question.
|
||||
pub const TTL: u32 = 30;
|
||||
|
||||
/// The largest question this server will read.
|
||||
///
|
||||
/// A DNS message is 512 bytes without EDNS and 4096 with it; anything past
|
||||
/// that is not a question worth answering.
|
||||
pub const MAX_MESSAGE_LEN: usize = 4096;
|
||||
|
||||
/// The largest answer sent over UDP without the client offering EDNS.
|
||||
const CLASSIC_UDP_LIMIT: usize = 512;
|
||||
|
||||
/// How long a TCP client may take over one question.
|
||||
const TCP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// How many TCP questions may be in flight at once.
|
||||
const MAX_TCP_CONNECTIONS: usize = 32;
|
||||
|
||||
/// The zones the server answers from, swapped as the rosters change.
|
||||
///
|
||||
/// Shared rather than copied into the server so that a roster change — or a
|
||||
/// network joining or leaving — is one write, not a restart: rebinding the
|
||||
/// socket would drop questions in flight for no reason.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SharedZone(Arc<RwLock<Arc<Zones>>>);
|
||||
|
||||
impl SharedZone {
|
||||
/// Wraps a set of zones.
|
||||
pub fn new(zones: Zones) -> Self {
|
||||
Self(Arc::new(RwLock::new(Arc::new(zones))))
|
||||
}
|
||||
|
||||
/// Wraps a single zone, which is the common case in a test.
|
||||
pub fn one(zone: Zone) -> Self {
|
||||
Self::new(Zones::new([zone]))
|
||||
}
|
||||
|
||||
/// Replaces them.
|
||||
pub fn set(&self, zones: Zones) {
|
||||
match self.0.write() {
|
||||
Ok(mut guard) => *guard = Arc::new(zones),
|
||||
Err(poisoned) => *poisoned.into_inner() = Arc::new(zones),
|
||||
}
|
||||
}
|
||||
|
||||
/// The zones as they are now.
|
||||
pub fn get(&self) -> Arc<Zones> {
|
||||
match self.0.read() {
|
||||
Ok(guard) => Arc::clone(&guard),
|
||||
Err(poisoned) => Arc::clone(&poisoned.into_inner()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the answer to one question.
|
||||
///
|
||||
/// `None` means say nothing at all: the message was not a question this
|
||||
/// server should reply to, and replying anyway would make this a useful
|
||||
/// amplifier for somebody spoofing a source address.
|
||||
pub fn respond(zones: &Zones, query: &[u8]) -> Option<Vec<u8>> {
|
||||
let packet = simple_dns::Packet::parse(query).ok()?;
|
||||
if packet.has_flags(PacketFlag::RESPONSE) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut reply = simple_dns::Packet::new_reply(packet.id());
|
||||
// Recursion is not available here and the flag says so honestly; the
|
||||
// desired bit is echoed because a resolver compares it.
|
||||
if packet.has_flags(PacketFlag::RECURSION_DESIRED) {
|
||||
reply.set_flags(PacketFlag::RECURSION_DESIRED);
|
||||
}
|
||||
|
||||
if packet.opcode() != simple_dns::OPCODE::StandardQuery {
|
||||
*reply.rcode_mut() = RCODE::NotImplemented;
|
||||
return reply.build_bytes_vec().ok();
|
||||
}
|
||||
|
||||
// Exactly one question. Zero is nothing to answer; more than one has no
|
||||
// agreed meaning and every real server rejects it.
|
||||
let [question] = packet.questions.as_slice() else {
|
||||
*reply.rcode_mut() = RCODE::FormatError;
|
||||
return reply.build_bytes_vec().ok();
|
||||
};
|
||||
if !matches!(question.qclass, QCLASS::CLASS(simple_dns::CLASS::IN)) {
|
||||
*reply.rcode_mut() = RCODE::Refused;
|
||||
return reply.build_bytes_vec().ok();
|
||||
}
|
||||
|
||||
let qname = question.qname.to_string();
|
||||
// Which zone answers is part of the answer: a denial is bounded by the
|
||||
// SOA of the zone that denied it, and this server may hold several.
|
||||
let (zone, answer) = match zones.lookup(&qname, query_kind(question.qtype)) {
|
||||
Some(answered) => answered,
|
||||
None => {
|
||||
reply.questions.push(question.clone());
|
||||
*reply.rcode_mut() = RCODE::Refused;
|
||||
return reply.build_bytes_vec_compressed().ok();
|
||||
}
|
||||
};
|
||||
reply.questions.push(question.clone());
|
||||
|
||||
let name = Name::new(&qname).ok()?;
|
||||
match answer {
|
||||
Answer::NotOurs => *reply.rcode_mut() = RCODE::Refused,
|
||||
Answer::Addresses(addresses) => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
for address in addresses {
|
||||
reply.answers.push(ResourceRecord::new(
|
||||
name.clone(),
|
||||
simple_dns::CLASS::IN,
|
||||
TTL,
|
||||
RData::A(A::from(address)),
|
||||
));
|
||||
}
|
||||
}
|
||||
Answer::Name(target) => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
let target = Name::new(&target).ok()?.into_owned();
|
||||
reply.answers.push(ResourceRecord::new(
|
||||
name.clone(),
|
||||
simple_dns::CLASS::IN,
|
||||
TTL,
|
||||
RData::PTR(PTR(target)),
|
||||
));
|
||||
}
|
||||
Answer::Soa => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
reply.answers.push(soa_record(zone)?);
|
||||
}
|
||||
Answer::NoData => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
// The authority section carries the SOA so a resolver knows how
|
||||
// long it may remember that there is nothing here.
|
||||
reply.name_servers.push(soa_record(zone)?);
|
||||
}
|
||||
Answer::NoSuchName => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
*reply.rcode_mut() = RCODE::NameError;
|
||||
reply.name_servers.push(soa_record(zone)?);
|
||||
}
|
||||
}
|
||||
|
||||
reply.build_bytes_vec_compressed().ok()
|
||||
}
|
||||
|
||||
/// The zone's start of authority.
|
||||
fn soa_record(zone: &Zone) -> Option<ResourceRecord<'static>> {
|
||||
let origin = Name::new(zone.origin().as_str()).ok()?.into_owned();
|
||||
Some(ResourceRecord::new(
|
||||
origin.clone(),
|
||||
simple_dns::CLASS::IN,
|
||||
TTL,
|
||||
RData::SOA(SOA {
|
||||
mname: origin.clone(),
|
||||
// There is no mailbox behind this zone and inventing one would
|
||||
// be a fiction; the origin itself is the honest answer.
|
||||
rname: origin,
|
||||
serial: zone.serial(),
|
||||
refresh: TTL as i32,
|
||||
retry: TTL as i32,
|
||||
expire: 86_400,
|
||||
minimum: TTL,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn query_kind(qtype: QTYPE) -> Query {
|
||||
match qtype {
|
||||
QTYPE::TYPE(TYPE::A) => Query::A,
|
||||
QTYPE::TYPE(TYPE::PTR) => Query::Ptr,
|
||||
QTYPE::TYPE(TYPE::SOA) => Query::Soa,
|
||||
QTYPE::TYPE(TYPE::NS) => Query::Ns,
|
||||
// ANY is answered as an address question rather than by dumping the
|
||||
// zone: an ANY that returns everything is an amplification gift.
|
||||
QTYPE::ANY => Query::A,
|
||||
_ => Query::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the client offered EDNS, and so how large an answer it will take.
|
||||
fn udp_limit(query: &[u8]) -> usize {
|
||||
simple_dns::Packet::parse(query)
|
||||
.ok()
|
||||
.and_then(|packet| packet.opt().map(|opt| opt.udp_packet_size as usize))
|
||||
.unwrap_or(CLASSIC_UDP_LIMIT as u16 as usize)
|
||||
.clamp(CLASSIC_UDP_LIMIT, MAX_MESSAGE_LEN)
|
||||
}
|
||||
|
||||
/// Cuts an answer down to what the client said it would take.
|
||||
///
|
||||
/// The records are dropped and the truncated bit set, which tells a resolver
|
||||
/// to ask again over TCP. Sending a reply it cannot reassemble would just
|
||||
/// look like packet loss.
|
||||
fn truncate_for_udp(query: &[u8], reply: Vec<u8>) -> Vec<u8> {
|
||||
let limit = udp_limit(query);
|
||||
if reply.len() <= limit {
|
||||
return reply;
|
||||
}
|
||||
let Ok(parsed) = simple_dns::Packet::parse(&reply) else {
|
||||
return reply;
|
||||
};
|
||||
let mut short = simple_dns::Packet::new_reply(parsed.id());
|
||||
short.set_flags(PacketFlag::TRUNCATION | PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
*short.rcode_mut() = parsed.rcode();
|
||||
for question in &parsed.questions {
|
||||
short.questions.push(question.clone());
|
||||
}
|
||||
short.build_bytes_vec().unwrap_or(reply)
|
||||
}
|
||||
|
||||
/// A running DNS server.
|
||||
#[derive(Debug)]
|
||||
pub struct DnsServer {
|
||||
local_addr: SocketAddr,
|
||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl DnsServer {
|
||||
/// Binds and starts answering.
|
||||
///
|
||||
/// Both transports on the same address and port, as a resolver expects:
|
||||
/// it falls back to TCP when an answer does not fit, and a server that
|
||||
/// only listened on UDP would leave it with nowhere to go.
|
||||
pub async fn bind(addr: SocketAddr, zone: SharedZone) -> std::io::Result<Self> {
|
||||
let udp = tokio::net::UdpSocket::bind(addr).await?;
|
||||
let local_addr = udp.local_addr()?;
|
||||
let tcp = tokio::net::TcpListener::bind(local_addr).await?;
|
||||
|
||||
let udp_zone = zone.clone();
|
||||
let udp_task = tokio::spawn(async move {
|
||||
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
|
||||
loop {
|
||||
let (read, from) = match udp.recv_from(&mut buffer).await {
|
||||
Ok(pair) => pair,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "dns udp receive failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let query = &buffer[..read];
|
||||
let Some(reply) = respond(&udp_zone.get(), query) else {
|
||||
continue;
|
||||
};
|
||||
let reply = truncate_for_udp(query, reply);
|
||||
if let Err(err) = udp.send_to(&reply, from).await {
|
||||
tracing::debug!(%err, "dns udp reply failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let tcp_zone = zone.clone();
|
||||
let tcp_task = tokio::spawn(async move {
|
||||
let permits = Arc::new(tokio::sync::Semaphore::new(MAX_TCP_CONNECTIONS));
|
||||
loop {
|
||||
let (stream, _) = match tcp.accept().await {
|
||||
Ok(pair) => pair,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "dns tcp accept failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Ok(permit) = Arc::clone(&permits).acquire_owned().await else {
|
||||
return;
|
||||
};
|
||||
let zone = tcp_zone.clone();
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
// Bounded, so a client that connects and says nothing
|
||||
// cannot hold a slot open.
|
||||
let _ = tokio::time::timeout(TCP_TIMEOUT, serve_tcp(stream, zone)).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
local_addr,
|
||||
tasks: vec![udp_task, tcp_task],
|
||||
})
|
||||
}
|
||||
|
||||
/// The address it is answering on.
|
||||
pub fn local_addr(&self) -> SocketAddr {
|
||||
self.local_addr
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DnsServer {
|
||||
fn drop(&mut self) {
|
||||
for task in &self.tasks {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_tcp(mut stream: tokio::net::TcpStream, zone: SharedZone) -> std::io::Result<()> {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
loop {
|
||||
let mut header = [0u8; 2];
|
||||
if stream.read_exact(&mut header).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
// Checked before the buffer is allocated, as everywhere else that
|
||||
// reads a length off a wire.
|
||||
let len = u16::from_be_bytes(header) as usize;
|
||||
if len == 0 || len > MAX_MESSAGE_LEN {
|
||||
return Ok(());
|
||||
}
|
||||
let mut query = vec![0u8; len];
|
||||
stream.read_exact(&mut query).await?;
|
||||
|
||||
let Some(reply) = respond(&zone.get(), &query) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(len) = u16::try_from(reply.len()) else {
|
||||
return Ok(());
|
||||
};
|
||||
stream.write_all(&len.to_be_bytes()).await?;
|
||||
stream.write_all(&reply).await?;
|
||||
stream.flush().await?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use crate::dns::zone::ZoneName;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn zone() -> Zones {
|
||||
Zones::new([one_zone()])
|
||||
}
|
||||
|
||||
fn one_zone() -> Zone {
|
||||
Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[
|
||||
("music".to_string(), Ipv4Addr::new(10, 13, 37, 237)),
|
||||
("ai".to_string(), Ipv4Addr::new(10, 13, 37, 69)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn ask(name: &str, qtype: TYPE) -> Vec<u8> {
|
||||
let mut packet = simple_dns::Packet::new_query(0x1234);
|
||||
packet.questions.push(simple_dns::Question::new(
|
||||
Name::new(name).unwrap(),
|
||||
qtype.into(),
|
||||
QCLASS::CLASS(simple_dns::CLASS::IN),
|
||||
false,
|
||||
));
|
||||
packet.build_bytes_vec().unwrap()
|
||||
}
|
||||
|
||||
/// The bytes of a reply. Parsed by each caller, because a parsed packet
|
||||
/// borrows from them.
|
||||
fn answer(query: &[u8]) -> Vec<u8> {
|
||||
respond(&zone(), query).expect("a reply")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_is_answered_authoritatively() {
|
||||
let bytes = answer(&ask("music.lab", TYPE::A));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NoError);
|
||||
assert!(reply.has_flags(PacketFlag::RESPONSE));
|
||||
assert!(reply.has_flags(PacketFlag::AUTHORITATIVE_ANSWER));
|
||||
assert!(!reply.has_flags(PacketFlag::RECURSION_AVAILABLE));
|
||||
assert_eq!(reply.answers.len(), 1);
|
||||
assert_eq!(reply.questions.len(), 1, "the question is echoed");
|
||||
match &reply.answers[0].rdata {
|
||||
RData::A(a) => assert_eq!(Ipv4Addr::from(a.address), Ipv4Addr::new(10, 13, 37, 237)),
|
||||
other => panic!("expected an A record, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_name_is_denied_with_a_soa_to_cache_the_denial() {
|
||||
let bytes = answer(&ask("nobody.lab", TYPE::A));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NameError);
|
||||
assert!(reply.answers.is_empty());
|
||||
assert_eq!(
|
||||
reply.name_servers.len(),
|
||||
1,
|
||||
"a SOA bounds the negative cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_exists_without_that_record_is_not_denied() {
|
||||
// NODATA, not NXDOMAIN: denying the name would stop a resolver
|
||||
// asking for the A record it could have had.
|
||||
let bytes = answer(&ask("music.lab", TYPE::AAAA));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NoError);
|
||||
assert!(reply.answers.is_empty());
|
||||
assert_eq!(reply.name_servers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_outside_the_zone_is_refused_and_never_forwarded() {
|
||||
for name in ["example.com", "evillab", "google.com"] {
|
||||
let bytes = answer(&ask(name, TYPE::A));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::Refused, "{name}");
|
||||
assert!(reply.answers.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_is_answered_backwards() {
|
||||
let bytes = answer(&ask("237.37.13.10.in-addr.arpa", TYPE::PTR));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NoError);
|
||||
match &reply.answers[0].rdata {
|
||||
RData::PTR(ptr) => assert_eq!(ptr.0.to_string(), "music.lab"),
|
||||
other => panic!("expected a PTR record, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_is_never_sent_to_something_that_was_not_a_question() {
|
||||
// Answering a response would make this a reflector for anyone who
|
||||
// can spoof a source address.
|
||||
let mut packet = simple_dns::Packet::new_reply(1);
|
||||
packet.set_flags(PacketFlag::RESPONSE);
|
||||
assert!(respond(&zone(), &packet.build_bytes_vec().unwrap()).is_none());
|
||||
assert!(respond(&zone(), b"").is_none());
|
||||
assert!(respond(&zone(), b"not a dns packet at all").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_question_with_no_question_in_it_is_a_format_error() {
|
||||
let packet = simple_dns::Packet::new_query(7);
|
||||
let bytes = respond(&zone(), &packet.build_bytes_vec().unwrap()).unwrap();
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::FormatError);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_class_other_than_internet_is_refused() {
|
||||
let mut packet = simple_dns::Packet::new_query(9);
|
||||
packet.questions.push(simple_dns::Question::new(
|
||||
Name::new("music.lab").unwrap(),
|
||||
TYPE::A.into(),
|
||||
QCLASS::CLASS(simple_dns::CLASS::CH),
|
||||
false,
|
||||
));
|
||||
let bytes = respond(&zone(), &packet.build_bytes_vec().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
simple_dns::Packet::parse(&bytes).unwrap().rcode(),
|
||||
RCODE::Refused
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_too_large_for_udp_is_truncated_rather_than_dropped() {
|
||||
// A resolver that gets a truncated reply asks again over TCP; one
|
||||
// that gets nothing back just waits.
|
||||
let many: Vec<(String, Ipv4Addr)> = (0..200)
|
||||
.map(|i| ("host".to_string(), Ipv4Addr::new(10, 13, 37, i as u8)))
|
||||
.collect();
|
||||
let wide = Zones::new([Zone::new(ZoneName::new("lab").unwrap(), many)]);
|
||||
let query = ask("host.lab", TYPE::A);
|
||||
let full = respond(&wide, &query).unwrap();
|
||||
assert!(
|
||||
full.len() > CLASSIC_UDP_LIMIT,
|
||||
"the test needs a big answer"
|
||||
);
|
||||
|
||||
let short = truncate_for_udp(&query, full);
|
||||
assert!(short.len() <= CLASSIC_UDP_LIMIT);
|
||||
let parsed = simple_dns::Packet::parse(&short).unwrap();
|
||||
assert!(parsed.has_flags(PacketFlag::TRUNCATION));
|
||||
assert_eq!(parsed.questions.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_server_answers_over_udp_and_tcp_on_one_address() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let shared = SharedZone::new(zone());
|
||||
let server = DnsServer::bind("127.0.0.1:0".parse().unwrap(), shared.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = server.local_addr();
|
||||
|
||||
let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
client
|
||||
.send_to(&ask("music.lab", TYPE::A), addr)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
|
||||
let read = client.recv(&mut buffer).await.unwrap();
|
||||
let reply = simple_dns::Packet::parse(&buffer[..read]).unwrap();
|
||||
assert_eq!(reply.answers.len(), 1);
|
||||
|
||||
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
let query = ask("ai.lab", TYPE::A);
|
||||
stream
|
||||
.write_all(&(query.len() as u16).to_be_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
stream.write_all(&query).await.unwrap();
|
||||
let mut header = [0u8; 2];
|
||||
stream.read_exact(&mut header).await.unwrap();
|
||||
let mut body = vec![0u8; u16::from_be_bytes(header) as usize];
|
||||
stream.read_exact(&mut body).await.unwrap();
|
||||
let reply = simple_dns::Packet::parse(&body).unwrap();
|
||||
assert_eq!(reply.answers.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_same_zone_is_answered_over_ipv6_as_over_ipv4() {
|
||||
// A question arrives over whichever family the resolver chooses, so
|
||||
// a listener is opened from each list in the plan and both have to
|
||||
// answer the same thing. Answering one family only leaves the other
|
||||
// timing out, which looks like a broken overlay.
|
||||
let shared = SharedZone::new(zone());
|
||||
let plan = crate::dns::listen_plan(0);
|
||||
let mut answered = 0;
|
||||
|
||||
for family in plan.families() {
|
||||
let candidate = family[0];
|
||||
let server = match DnsServer::bind(candidate, shared.clone()).await {
|
||||
Ok(server) => server,
|
||||
// A host with IPv6 switched off in the kernel cannot bind
|
||||
// `::1`, and the agent copes with that by design; so does
|
||||
// this. The other family still has to work.
|
||||
Err(err) if candidate.is_ipv6() => {
|
||||
eprintln!("no IPv6 loopback on this host ({err}); skipping that family");
|
||||
continue;
|
||||
}
|
||||
Err(err) => panic!("cannot listen on {candidate}: {err}"),
|
||||
};
|
||||
let addr = server.local_addr();
|
||||
assert_eq!(addr.is_ipv6(), candidate.is_ipv6());
|
||||
|
||||
let client = tokio::net::UdpSocket::bind(if addr.is_ipv6() {
|
||||
"[::1]:0"
|
||||
} else {
|
||||
"127.0.0.1:0"
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.send_to(&ask("music.lab", TYPE::A), addr)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
|
||||
let read = client.recv(&mut buffer).await.unwrap();
|
||||
let reply = simple_dns::Packet::parse(&buffer[..read]).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NoError, "over {addr}");
|
||||
match &reply.answers[0].rdata {
|
||||
// The same IPv4 answer either way: the family a question
|
||||
// travelled over says nothing about what the answer is.
|
||||
RData::A(a) => {
|
||||
assert_eq!(Ipv4Addr::from(a.address), Ipv4Addr::new(10, 13, 37, 237))
|
||||
}
|
||||
other => panic!("expected an A record over {addr}, got {other:?}"),
|
||||
}
|
||||
answered += 1;
|
||||
}
|
||||
|
||||
assert!(answered > 0, "at least one family must have answered");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacing_the_zone_changes_what_the_running_server_answers() {
|
||||
let shared = SharedZone::one(Zone::new(ZoneName::new("lab").unwrap(), []));
|
||||
let server = DnsServer::bind("127.0.0.1:0".parse().unwrap(), shared.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = server.local_addr();
|
||||
let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
|
||||
|
||||
client
|
||||
.send_to(&ask("music.lab", TYPE::A), addr)
|
||||
.await
|
||||
.unwrap();
|
||||
let read = client.recv(&mut buffer).await.unwrap();
|
||||
assert_eq!(
|
||||
simple_dns::Packet::parse(&buffer[..read]).unwrap().rcode(),
|
||||
RCODE::NameError
|
||||
);
|
||||
|
||||
// A member joins: no rebind, no dropped socket.
|
||||
shared.set(zone());
|
||||
client
|
||||
.send_to(&ask("music.lab", TYPE::A), addr)
|
||||
.await
|
||||
.unwrap();
|
||||
let read = client.recv(&mut buffer).await.unwrap();
|
||||
let reply = simple_dns::Packet::parse(&buffer[..read]).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NoError);
|
||||
assert_eq!(reply.answers.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
//! What the overlay answers to, as a DNS zone.
|
||||
//!
|
||||
//! Nothing here touches a socket or the operating system, and it does not
|
||||
//! depend on a DNS wire library either: it takes a roster and a question and
|
||||
//! says what the answer is. That is what makes the interesting parts — which
|
||||
//! names exist, what "does not exist" means as against "exists with nothing
|
||||
//! of that type", and what is outside the zone entirely — testable on their
|
||||
//! own.
|
||||
//!
|
||||
//! # Where the names come from
|
||||
//!
|
||||
//! From signed state, which is why a member that is switched off still
|
||||
//! resolves. Its claim outlived the session, so the name and the address are
|
||||
//! both still there to answer with. Nothing is invented for a member that
|
||||
//! claimed neither.
|
||||
//!
|
||||
//! Only IPv4 is served. The IPv6 overlay address is derived from a
|
||||
//! WireGuard key that travels in live announcements and is not in signed
|
||||
//! state, so it cannot be answered for a member that is away — and answering
|
||||
//! for some members and not others depending on whether they happen to be
|
||||
//! online is worse than not answering at all.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// Longest a DNS name may be, in the presentation form used here.
|
||||
const MAX_NAME_LEN: usize = 253;
|
||||
/// Longest one label may be.
|
||||
const MAX_LABEL_LEN: usize = 63;
|
||||
|
||||
/// Why a zone name cannot be used.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ZoneError {
|
||||
/// The name was empty, or became empty once normalised.
|
||||
#[error("a zone name must have at least one label")]
|
||||
Empty,
|
||||
/// One label was unusable.
|
||||
#[error("`{label}` is not a usable DNS label: {reason}")]
|
||||
Label {
|
||||
/// The offending label.
|
||||
label: String,
|
||||
/// What is wrong with it.
|
||||
reason: &'static str,
|
||||
},
|
||||
/// The whole name is too long.
|
||||
#[error("a zone name must be at most {MAX_NAME_LEN} characters")]
|
||||
TooLong,
|
||||
}
|
||||
|
||||
/// A validated, canonical zone name.
|
||||
///
|
||||
/// Held without a trailing dot and lower-cased, so comparison is a plain
|
||||
/// string comparison rather than a special case at every use.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ZoneName(String);
|
||||
|
||||
impl ZoneName {
|
||||
/// Validates and normalises a zone name.
|
||||
pub fn new(raw: &str) -> Result<Self, ZoneError> {
|
||||
let trimmed = raw.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ZoneError::Empty);
|
||||
}
|
||||
if trimmed.len() > MAX_NAME_LEN {
|
||||
return Err(ZoneError::TooLong);
|
||||
}
|
||||
for label in trimmed.split('.') {
|
||||
let reason = if label.is_empty() {
|
||||
Some("it is empty")
|
||||
} else if label.len() > MAX_LABEL_LEN {
|
||||
Some("it is longer than 63 characters")
|
||||
} else if !label
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
{
|
||||
Some("only letters, digits, `-` and `_` are allowed")
|
||||
} else if label.starts_with('-') || label.ends_with('-') {
|
||||
Some("a label may not start or end with `-`")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
return Err(ZoneError::Label {
|
||||
label: label.to_string(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self(trimmed))
|
||||
}
|
||||
|
||||
/// The name, without a trailing dot.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// The last label, which is what would collide with a real top-level
|
||||
/// domain.
|
||||
pub fn top_label(&self) -> &str {
|
||||
self.0.rsplit('.').next().unwrap_or(&self.0)
|
||||
}
|
||||
|
||||
/// What is worrying about this zone name, if anything.
|
||||
///
|
||||
/// A warning and never a refusal: the name is the user's to choose, and
|
||||
/// a private zone that shadows a public one is a decision, not a
|
||||
/// mistake. Saying nothing would let it be an accident.
|
||||
pub fn collision(&self) -> Option<String> {
|
||||
let top = self.top_label();
|
||||
// Reserved for exactly this use and never delegated, so nothing to
|
||||
// say. See RFC 6761 and RFC 8375.
|
||||
const RESERVED: &[&str] = &[
|
||||
"internal",
|
||||
"home",
|
||||
"test",
|
||||
"example",
|
||||
"invalid",
|
||||
"localhost",
|
||||
];
|
||||
if RESERVED.contains(&top) {
|
||||
return None;
|
||||
}
|
||||
if top == "local" {
|
||||
return Some(
|
||||
"`.local` belongs to multicast DNS: on a host running Avahi or \
|
||||
systemd-resolved's mDNS, names under it are resolved by that and \
|
||||
not by this agent"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
// A real top-level domain is the whole of it. A name that is not
|
||||
// delegated today might be one day — and saying so fires on every
|
||||
// private name anybody ever picks, which is a warning about
|
||||
// nothing that teaches people to ignore warnings.
|
||||
tld::exist_case_insensitive(top).then(|| {
|
||||
format!(
|
||||
"`.{top}` is a real top-level domain, so every public name under it \
|
||||
becomes unreachable from this host while the overlay is up"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `name` is this zone or sits under it.
|
||||
///
|
||||
/// Compared label-wise, so `evilzone` does not count as being under
|
||||
/// `zone`.
|
||||
pub fn covers(&self, name: &str) -> bool {
|
||||
let name = name.trim_end_matches('.').to_ascii_lowercase();
|
||||
name == self.0
|
||||
|| name
|
||||
.strip_suffix(&self.0)
|
||||
.is_some_and(|rest| rest.ends_with('.'))
|
||||
}
|
||||
|
||||
/// The part of `name` below this zone, if it is under it.
|
||||
fn relative(&self, name: &str) -> Option<String> {
|
||||
let name = name.trim_end_matches('.').to_ascii_lowercase();
|
||||
if name == self.0 {
|
||||
return Some(String::new());
|
||||
}
|
||||
let rest = name.strip_suffix(&self.0)?;
|
||||
let rest = rest.strip_suffix('.')?;
|
||||
(!rest.is_empty()).then(|| rest.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds of question this zone knows how to answer.
|
||||
///
|
||||
/// Its own enum rather than the wire library's, so the decision of what to
|
||||
/// answer does not depend on how a packet is encoded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Query {
|
||||
/// An IPv4 address.
|
||||
A,
|
||||
/// A name for an address.
|
||||
Ptr,
|
||||
/// The zone's start of authority.
|
||||
Soa,
|
||||
/// The zone's name servers.
|
||||
Ns,
|
||||
/// Anything else, including AAAA.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// What the zone has to say.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Answer {
|
||||
/// Addresses for the name asked about.
|
||||
Addresses(Vec<Ipv4Addr>),
|
||||
/// A name for the address asked about.
|
||||
Name(String),
|
||||
/// The zone's start of authority.
|
||||
Soa,
|
||||
/// The name exists here but has nothing of the type asked for.
|
||||
///
|
||||
/// Distinct from [`Answer::NoSuchName`] because the two are different
|
||||
/// answers on the wire: this one is a success with no records, and a
|
||||
/// resolver must not take it as proof the name is absent. Getting them
|
||||
/// the wrong way round teaches a resolver to cache the wrong thing.
|
||||
NoData,
|
||||
/// No such name in this zone.
|
||||
NoSuchName,
|
||||
/// Not a name this zone is responsible for.
|
||||
NotOurs,
|
||||
}
|
||||
|
||||
/// The names and addresses of one network, ready to answer questions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Zone {
|
||||
origin: ZoneName,
|
||||
/// Name relative to the origin, to the addresses it answers to.
|
||||
hosts: BTreeMap<String, Vec<Ipv4Addr>>,
|
||||
/// Address to the name that holds it.
|
||||
names: BTreeMap<Ipv4Addr, String>,
|
||||
/// Changes whenever the contents do.
|
||||
serial: u32,
|
||||
}
|
||||
|
||||
impl Zone {
|
||||
/// Builds a zone from the members that have both a name and an address.
|
||||
///
|
||||
/// A member with one but not the other contributes nothing: a name with
|
||||
/// no address cannot be answered, and an address with no name has nothing
|
||||
/// to be asked about.
|
||||
pub fn new(origin: ZoneName, members: impl IntoIterator<Item = (String, Ipv4Addr)>) -> Self {
|
||||
let mut hosts: BTreeMap<String, Vec<Ipv4Addr>> = BTreeMap::new();
|
||||
let mut names: BTreeMap<Ipv4Addr, String> = BTreeMap::new();
|
||||
for (hostname, address) in members {
|
||||
let hostname = hostname.trim_matches('.').to_ascii_lowercase();
|
||||
if hostname.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let addresses = hosts.entry(hostname.clone()).or_default();
|
||||
if !addresses.contains(&address) {
|
||||
addresses.push(address);
|
||||
}
|
||||
// First name wins, and the map is ordered, so the reverse answer
|
||||
// is the same on every replica rather than depending on the order
|
||||
// records happened to arrive in.
|
||||
names.entry(address).or_insert(hostname);
|
||||
}
|
||||
for addresses in hosts.values_mut() {
|
||||
addresses.sort();
|
||||
}
|
||||
|
||||
let serial = content_serial(&hosts);
|
||||
Self {
|
||||
origin,
|
||||
hosts,
|
||||
names,
|
||||
serial,
|
||||
}
|
||||
}
|
||||
|
||||
/// The zone's origin.
|
||||
pub fn origin(&self) -> &ZoneName {
|
||||
&self.origin
|
||||
}
|
||||
|
||||
/// A number that changes whenever the contents do.
|
||||
pub fn serial(&self) -> u32 {
|
||||
self.serial
|
||||
}
|
||||
|
||||
/// How many names it answers for.
|
||||
pub fn len(&self) -> usize {
|
||||
self.hosts.len()
|
||||
}
|
||||
|
||||
/// Whether it answers for nothing.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.hosts.is_empty()
|
||||
}
|
||||
|
||||
/// Every name it answers for, with its addresses.
|
||||
pub fn entries(&self) -> impl Iterator<Item = (&str, &[Ipv4Addr])> {
|
||||
self.hosts
|
||||
.iter()
|
||||
.map(|(name, addresses)| (name.as_str(), addresses.as_slice()))
|
||||
}
|
||||
|
||||
/// The reverse zones this zone is authoritative for.
|
||||
///
|
||||
/// Only when the range lands on an octet boundary. Claiming a reverse
|
||||
/// zone larger than the range would shadow reverse lookups for addresses
|
||||
/// that are nothing to do with us, which is worse than not answering.
|
||||
pub fn reverse_origin(base: Ipv4Addr, prefix_len: u8) -> Option<String> {
|
||||
let octets = base.octets();
|
||||
match prefix_len {
|
||||
8 => Some(format!("{}.in-addr.arpa", octets[0])),
|
||||
16 => Some(format!("{}.{}.in-addr.arpa", octets[1], octets[0])),
|
||||
24 => Some(format!(
|
||||
"{}.{}.{}.in-addr.arpa",
|
||||
octets[2], octets[1], octets[0]
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers one question.
|
||||
pub fn lookup(&self, qname: &str, query: Query) -> Answer {
|
||||
if let Some(address) = reverse_address(qname) {
|
||||
return match self.names.get(&address) {
|
||||
Some(name) if query == Query::Ptr => {
|
||||
Answer::Name(format!("{name}.{}", self.origin.as_str()))
|
||||
}
|
||||
Some(_) => Answer::NoData,
|
||||
None => Answer::NoSuchName,
|
||||
};
|
||||
}
|
||||
|
||||
let Some(relative) = self.origin.relative(qname) else {
|
||||
return Answer::NotOurs;
|
||||
};
|
||||
|
||||
// The apex: the zone itself exists whether or not anybody is in it.
|
||||
if relative.is_empty() {
|
||||
return match query {
|
||||
Query::Soa => Answer::Soa,
|
||||
Query::Ns => Answer::NoData,
|
||||
_ => Answer::NoData,
|
||||
};
|
||||
}
|
||||
|
||||
match self.hosts.get(&relative) {
|
||||
Some(addresses) if query == Query::A => Answer::Addresses(addresses.clone()),
|
||||
// The name is here, it just has no AAAA and never will while
|
||||
// only IPv4 is served. Saying "no such name" instead would tell
|
||||
// a resolver to stop asking for the A record too.
|
||||
Some(_) => Answer::NoData,
|
||||
None => Answer::NoSuchName,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every zone one agent serves.
|
||||
///
|
||||
/// One agent has one identity and as many networks as it likes, and each of
|
||||
/// them is a zone of its own named after the network. They are served
|
||||
/// together because they arrive at the same socket: a question is not
|
||||
/// labelled with the network it belongs to, only with a name, and the
|
||||
/// suffix is what decides. Nothing is shared between them — a member of one
|
||||
/// network is not a name in another.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Zones {
|
||||
zones: Vec<Zone>,
|
||||
}
|
||||
|
||||
impl Zones {
|
||||
/// Collects zones, dropping any that repeats an origin already taken.
|
||||
///
|
||||
/// Two networks can be given the same name, and then only one of them
|
||||
/// can own the suffix. First wins, deterministically, rather than one
|
||||
/// shadowing the other depending on the order they happened to start.
|
||||
pub fn new(zones: impl IntoIterator<Item = Zone>) -> Self {
|
||||
let mut taken: Vec<Zone> = Vec::new();
|
||||
for zone in zones {
|
||||
if taken.iter().any(|other| other.origin() == zone.origin()) {
|
||||
continue;
|
||||
}
|
||||
taken.push(zone);
|
||||
}
|
||||
Self { zones: taken }
|
||||
}
|
||||
|
||||
/// The zones, in the order they will be consulted.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &Zone> {
|
||||
self.zones.iter()
|
||||
}
|
||||
|
||||
/// How many zones are served.
|
||||
pub fn len(&self) -> usize {
|
||||
self.zones.len()
|
||||
}
|
||||
|
||||
/// Whether nothing is served.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.zones.is_empty()
|
||||
}
|
||||
|
||||
/// How many names are answered for, across every zone.
|
||||
pub fn names(&self) -> usize {
|
||||
self.zones.iter().map(Zone::len).sum()
|
||||
}
|
||||
|
||||
/// Answers one question, and says which zone answered it.
|
||||
///
|
||||
/// The answer has to travel with its zone because the SOA that bounds a
|
||||
/// denial belongs to the zone that denied it, and serving several means
|
||||
/// that is no longer a foregone conclusion.
|
||||
///
|
||||
/// A reverse question carries no suffix to match on, so every zone is
|
||||
/// asked and the one that holds the address answers; if none does, the
|
||||
/// denial comes from whichever is authoritative for that reverse zone,
|
||||
/// and from nowhere at all when none is.
|
||||
pub fn lookup(&self, qname: &str, query: Query) -> Option<(&Zone, Answer)> {
|
||||
let mut denial: Option<(&Zone, Answer)> = None;
|
||||
for zone in &self.zones {
|
||||
match zone.lookup(qname, query) {
|
||||
// Not this zone's question. Another may still own it.
|
||||
Answer::NotOurs => continue,
|
||||
// A zone that owns the suffix but not the name can still be
|
||||
// overruled by one that has an answer: only reverse
|
||||
// questions reach more than one zone, and there the zone
|
||||
// holding the address is the one that knows.
|
||||
answer @ (Answer::NoSuchName | Answer::NoData) => {
|
||||
denial.get_or_insert((zone, answer));
|
||||
}
|
||||
answer => return Some((zone, answer)),
|
||||
}
|
||||
}
|
||||
denial
|
||||
}
|
||||
}
|
||||
|
||||
/// The address a reverse name asks about, if it is one.
|
||||
fn reverse_address(qname: &str) -> Option<Ipv4Addr> {
|
||||
let name = qname.trim_end_matches('.').to_ascii_lowercase();
|
||||
let rest = name.strip_suffix(".in-addr.arpa")?;
|
||||
let mut octets = [0u8; 4];
|
||||
let mut seen = 0;
|
||||
for (index, part) in rest.split('.').enumerate() {
|
||||
if index >= 4 {
|
||||
return None;
|
||||
}
|
||||
octets[3 - index] = part.parse().ok()?;
|
||||
seen += 1;
|
||||
}
|
||||
(seen == 4).then(|| Ipv4Addr::from(octets))
|
||||
}
|
||||
|
||||
/// A serial that changes with the contents and not otherwise.
|
||||
///
|
||||
/// Derived rather than counted, so two agents holding the same roster agree,
|
||||
/// and a restart does not go backwards.
|
||||
fn content_serial(hosts: &BTreeMap<String, Vec<Ipv4Addr>>) -> u32 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
for (name, addresses) in hosts {
|
||||
name.hash(&mut hasher);
|
||||
for address in addresses {
|
||||
address.octets().hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
// Never zero: a zero serial is legal but reads like "unset" in a log.
|
||||
(hasher.finish() as u32).max(1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn zone() -> Zone {
|
||||
Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[
|
||||
("music".to_string(), Ipv4Addr::new(10, 13, 37, 237)),
|
||||
("ai".to_string(), Ipv4Addr::new(10, 13, 37, 69)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zone_name_is_normalised_and_checked() {
|
||||
assert_eq!(ZoneName::new("LAB.").unwrap().as_str(), "lab");
|
||||
assert_eq!(
|
||||
ZoneName::new(" lab.internal ").unwrap().as_str(),
|
||||
"lab.internal"
|
||||
);
|
||||
assert_eq!(
|
||||
ZoneName::new("lab.internal").unwrap().top_label(),
|
||||
"internal"
|
||||
);
|
||||
|
||||
assert_eq!(ZoneName::new("").unwrap_err(), ZoneError::Empty);
|
||||
assert_eq!(ZoneName::new(".").unwrap_err(), ZoneError::Empty);
|
||||
assert!(matches!(
|
||||
ZoneName::new("a..b").unwrap_err(),
|
||||
ZoneError::Label { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
ZoneName::new("-lab").unwrap_err(),
|
||||
ZoneError::Label { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
ZoneName::new("la b").unwrap_err(),
|
||||
ZoneError::Label { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
ZoneName::new(&"x".repeat(64)).unwrap_err(),
|
||||
ZoneError::Label { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zone_name_that_shadows_a_public_one_is_flagged_but_allowed() {
|
||||
// Flagged, never refused: shadowing is the user's decision to make.
|
||||
let ru = ZoneName::new("ru").unwrap();
|
||||
let warning = ru.collision().expect("a real TLD is worth mentioning");
|
||||
assert!(warning.contains("real top-level domain"), "{warning}");
|
||||
|
||||
assert!(ZoneName::new("com").unwrap().collision().is_some());
|
||||
assert!(ZoneName::new("lab.com").unwrap().collision().is_some());
|
||||
|
||||
// Reserved for private use, so nothing to say.
|
||||
for quiet in ["internal", "lab.internal", "home", "test", "invalid"] {
|
||||
assert_eq!(ZoneName::new(quiet).unwrap().collision(), None, "{quiet}");
|
||||
}
|
||||
|
||||
// `.local` is not a delegated TLD, but it is not free either.
|
||||
let local = ZoneName::new("local").unwrap().collision().unwrap();
|
||||
assert!(local.contains("multicast DNS"), "{local}");
|
||||
|
||||
// A name that collides with nothing is said nothing about. It
|
||||
// might be delegated one day, and warning about that fires on
|
||||
// every private name anybody picks — a warning about nothing,
|
||||
// which is how people learn to ignore warnings.
|
||||
for quiet in ["lab", "a.lab", "pidar", "twar", "kitchen-table"] {
|
||||
assert_eq!(ZoneName::new(quiet).unwrap().collision(), None, "{quiet}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neighbouring_name_is_not_inside_the_zone() {
|
||||
// `evillab` ends with `lab`, and a suffix comparison that forgot the
|
||||
// label boundary would hand it to us.
|
||||
let origin = ZoneName::new("lab").unwrap();
|
||||
assert!(origin.covers("lab"));
|
||||
assert!(origin.covers("music.lab."));
|
||||
assert!(origin.covers("a.b.lab"));
|
||||
assert!(!origin.covers("evillab"));
|
||||
assert!(!origin.covers("lab.example.com"));
|
||||
assert!(!origin.covers("example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_resolves_by_name() {
|
||||
let zone = zone();
|
||||
assert_eq!(
|
||||
zone.lookup("music.lab", Query::A),
|
||||
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 237)])
|
||||
);
|
||||
// Case and a trailing dot are the same question.
|
||||
assert_eq!(
|
||||
zone.lookup("MUSIC.LAB.", Query::A),
|
||||
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 237)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_with_no_record_of_that_type_is_not_a_missing_name() {
|
||||
// AAAA for a member that exists must be NODATA, not NXDOMAIN: an
|
||||
// NXDOMAIN would tell the resolver the name is absent and stop it
|
||||
// asking for the A record.
|
||||
let zone = zone();
|
||||
assert_eq!(zone.lookup("music.lab", Query::Other), Answer::NoData);
|
||||
assert_eq!(zone.lookup("nobody.lab", Query::A), Answer::NoSuchName);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn questions_outside_the_zone_are_refused_rather_than_denied() {
|
||||
// Denying them would be a lie: this server knows nothing about them.
|
||||
let zone = zone();
|
||||
assert_eq!(zone.lookup("example.com", Query::A), Answer::NotOurs);
|
||||
assert_eq!(zone.lookup("evillab", Query::A), Answer::NotOurs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_apex_exists_even_with_nobody_in_the_network() {
|
||||
let empty = Zone::new(ZoneName::new("lab").unwrap(), []);
|
||||
assert!(empty.is_empty());
|
||||
assert_eq!(empty.lookup("lab", Query::Soa), Answer::Soa);
|
||||
assert_eq!(empty.lookup("lab", Query::A), Answer::NoData);
|
||||
assert_eq!(empty.lookup("music.lab", Query::A), Answer::NoSuchName);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_resolves_back_to_its_name() {
|
||||
let zone = zone();
|
||||
assert_eq!(
|
||||
zone.lookup("237.37.13.10.in-addr.arpa", Query::Ptr),
|
||||
Answer::Name("music.lab".into())
|
||||
);
|
||||
assert_eq!(
|
||||
zone.lookup("9.37.13.10.in-addr.arpa", Query::Ptr),
|
||||
Answer::NoSuchName
|
||||
);
|
||||
// A reverse name asked for the wrong type is still a name we know.
|
||||
assert_eq!(
|
||||
zone.lookup("237.37.13.10.in-addr.arpa", Query::A),
|
||||
Answer::NoData
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reverse_zone_is_claimed_only_when_it_is_exactly_ours() {
|
||||
// Claiming a reverse zone wider than the range would shadow lookups
|
||||
// for addresses that have nothing to do with this network.
|
||||
assert_eq!(
|
||||
Zone::reverse_origin(Ipv4Addr::new(10, 13, 37, 0), 24).as_deref(),
|
||||
Some("37.13.10.in-addr.arpa")
|
||||
);
|
||||
assert_eq!(
|
||||
Zone::reverse_origin(Ipv4Addr::new(10, 13, 0, 0), 16).as_deref(),
|
||||
Some("13.10.in-addr.arpa")
|
||||
);
|
||||
assert_eq!(Zone::reverse_origin(Ipv4Addr::new(10, 13, 37, 0), 25), None);
|
||||
assert_eq!(Zone::reverse_origin(Ipv4Addr::new(100, 64, 0, 0), 10), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_with_nothing_to_say_are_left_out() {
|
||||
let zone = Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[(String::new(), Ipv4Addr::new(10, 0, 0, 1))],
|
||||
);
|
||||
assert!(zone.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_members_sharing_a_name_both_answer_and_the_order_is_stable() {
|
||||
// The state layer resolves name ownership; if two records still
|
||||
// reach here, answering with both beats picking one at random.
|
||||
let build = |flip: bool| {
|
||||
let members = if flip {
|
||||
vec![
|
||||
("music".to_string(), Ipv4Addr::new(10, 0, 0, 2)),
|
||||
("music".to_string(), Ipv4Addr::new(10, 0, 0, 1)),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
("music".to_string(), Ipv4Addr::new(10, 0, 0, 1)),
|
||||
("music".to_string(), Ipv4Addr::new(10, 0, 0, 2)),
|
||||
]
|
||||
};
|
||||
Zone::new(ZoneName::new("lab").unwrap(), members)
|
||||
};
|
||||
assert_eq!(
|
||||
build(false).lookup("music.lab", Query::A),
|
||||
build(true).lookup("music.lab", Query::A)
|
||||
);
|
||||
assert_eq!(build(false).serial(), build(true).serial());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_serial_follows_the_contents() {
|
||||
let a = zone();
|
||||
let b = zone();
|
||||
assert_eq!(a.serial(), b.serial(), "the same roster, the same serial");
|
||||
|
||||
let changed = Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 238))],
|
||||
);
|
||||
assert_ne!(a.serial(), changed.serial());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_network_answers_only_for_its_own_zone() {
|
||||
// One agent, several networks, one socket. A question carries a
|
||||
// name and not the network it belongs to, so the suffix is what
|
||||
// decides — and a member of one network is not a name in another.
|
||||
let zones = Zones::new([
|
||||
Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))],
|
||||
),
|
||||
Zone::new(
|
||||
ZoneName::new("home").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 20, 0, 2))],
|
||||
),
|
||||
]);
|
||||
assert_eq!(zones.len(), 2);
|
||||
assert_eq!(zones.names(), 2);
|
||||
|
||||
let (zone, answer) = zones.lookup("music.lab", Query::A).unwrap();
|
||||
assert_eq!(zone.origin().as_str(), "lab");
|
||||
assert_eq!(
|
||||
answer,
|
||||
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 2)])
|
||||
);
|
||||
|
||||
let (zone, answer) = zones.lookup("music.home", Query::A).unwrap();
|
||||
assert_eq!(zone.origin().as_str(), "home");
|
||||
assert_eq!(answer, Answer::Addresses(vec![Ipv4Addr::new(10, 20, 0, 2)]));
|
||||
|
||||
// A name under neither is nobody's business here.
|
||||
assert!(zones.lookup("music.example.com", Query::A).is_none());
|
||||
assert!(Zones::default().lookup("music.lab", Query::A).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_denial_comes_from_the_zone_that_owns_the_name() {
|
||||
// The SOA that bounds a negative answer belongs to the zone that
|
||||
// denied it; with several served that is no longer a given.
|
||||
let zones = Zones::new([
|
||||
Zone::new(ZoneName::new("lab").unwrap(), []),
|
||||
Zone::new(
|
||||
ZoneName::new("home").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 20, 0, 2))],
|
||||
),
|
||||
]);
|
||||
let (zone, answer) = zones.lookup("nobody.lab", Query::A).unwrap();
|
||||
assert_eq!(zone.origin().as_str(), "lab");
|
||||
assert_eq!(answer, Answer::NoSuchName);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reverse_question_is_answered_by_whichever_zone_holds_the_address() {
|
||||
// It carries no suffix to match on, so every zone is asked.
|
||||
let zones = Zones::new([
|
||||
Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))],
|
||||
),
|
||||
Zone::new(
|
||||
ZoneName::new("home").unwrap(),
|
||||
[("kitchen".to_string(), Ipv4Addr::new(10, 20, 0, 2))],
|
||||
),
|
||||
]);
|
||||
let (_, answer) = zones.lookup("2.0.20.10.in-addr.arpa", Query::Ptr).unwrap();
|
||||
assert_eq!(answer, Answer::Name("kitchen.home".to_string()));
|
||||
let (_, answer) = zones.lookup("2.37.13.10.in-addr.arpa", Query::Ptr).unwrap();
|
||||
assert_eq!(answer, Answer::Name("music.lab".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_networks_of_one_name_do_not_shadow_each_other_at_random() {
|
||||
// Exactly the two-`LAB` case. Only one can own the suffix; which
|
||||
// one must not depend on the order they happened to start in.
|
||||
let first = Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))],
|
||||
);
|
||||
let second = Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 99, 0, 2))],
|
||||
);
|
||||
let zones = Zones::new([first, second]);
|
||||
assert_eq!(zones.len(), 1, "one suffix, one zone");
|
||||
let (_, answer) = zones.lookup("music.lab", Query::A).unwrap();
|
||||
assert_eq!(
|
||||
answer,
|
||||
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 2)])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,23 @@ pub enum Error {
|
||||
#[error("agent is stopped")]
|
||||
Stopped,
|
||||
|
||||
/// Something that had to answer in bounded time did not.
|
||||
///
|
||||
/// Reported instead of waited out: an agent that has wedged must not
|
||||
/// leave a command hanging with nothing on screen and no way out.
|
||||
#[error("{what} did not answer in time")]
|
||||
Timeout {
|
||||
/// What was asked, phrased so the message stands on its own.
|
||||
what: String,
|
||||
},
|
||||
|
||||
/// The request is understood and cannot be done as things stand.
|
||||
#[error("{reason}")]
|
||||
Rejected {
|
||||
/// Why, phrased so the message stands on its own.
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// Discovery backend failure. Never fatal for the agent.
|
||||
#[error("discovery error: {0}")]
|
||||
Discovery(String),
|
||||
@@ -49,11 +49,16 @@ impl DeviceIdentity {
|
||||
self.secret.public()
|
||||
}
|
||||
|
||||
/// The raw secret key bytes, for persistence only.
|
||||
pub(crate) fn secret_bytes(&self) -> [u8; 32] {
|
||||
/// The raw secret key bytes, for persistence and transport certificate generation.
|
||||
pub fn secret_bytes(&self) -> [u8; 32] {
|
||||
self.secret.to_bytes()
|
||||
}
|
||||
|
||||
/// Signs a message with the device's persistent secret key.
|
||||
pub fn sign(&self, message: &[u8]) -> iroh::Signature {
|
||||
self.secret.sign(message)
|
||||
}
|
||||
|
||||
/// A clone of the iroh secret key, for endpoint construction only.
|
||||
pub(crate) fn secret_key(&self) -> SecretKey {
|
||||
self.secret.clone()
|
||||
@@ -340,6 +340,8 @@ pub struct NetworkKeys {
|
||||
network_id: NetworkId,
|
||||
discovery_key: DiscoveryKey,
|
||||
auth_key: Zeroizing<[u8; 32]>,
|
||||
// Separate from the semi-public DiscoveryKey. Existing derivations stay frozen.
|
||||
dht_write_key: Zeroizing<[u8; 32]>,
|
||||
name: NetworkName,
|
||||
}
|
||||
|
||||
@@ -373,6 +375,7 @@ impl NetworkKeys {
|
||||
network_id: NetworkId(expand("network-id")),
|
||||
discovery_key: DiscoveryKey(expand("discovery-key")),
|
||||
auth_key: Zeroizing::new(expand("handshake-auth")),
|
||||
dht_write_key: Zeroizing::new(expand("mainline-rendezvous-write-v1")),
|
||||
name: name.clone(),
|
||||
}
|
||||
}
|
||||
@@ -405,6 +408,11 @@ impl NetworkKeys {
|
||||
pub(crate) fn auth_key(&self) -> &[u8; 32] {
|
||||
&self.auth_key
|
||||
}
|
||||
|
||||
/// Secret signing material for network rendezvous; never exported or logged.
|
||||
pub(crate) fn dht_write_key(&self) -> &[u8; 32] {
|
||||
&self.dht_write_key
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NetworkKeys {
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,227 @@
|
||||
//! A Unix socket adapter for the local control interface.
|
||||
//!
|
||||
//! One of the per-platform adapters; see [`super`]. It provides only what is
|
||||
//! particular to a Unix socket — binding a listener, connecting a client, and
|
||||
//! the owner-only permissions — and hands every accepted connection to the
|
||||
//! shared [`serve_connection`](super::serve_connection). The request framing,
|
||||
//! the dispatch and the client wrappers are the same on every platform and
|
||||
//! live in [`super`], re-exported here so `ipc::unix::request_status` and its
|
||||
//! siblings keep resolving.
|
||||
//!
|
||||
//! It is reachable only by a process that can open a file inside the agent's
|
||||
//! owner-only state directory, and the socket itself is created mode `0600`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
// The transport-agnostic surface, re-exported so this module is a complete
|
||||
// view of the local control interface on its own.
|
||||
pub use super::{
|
||||
CONTROL_PROTOCOL, ControlSocketAccess, EXCHANGE_TIMEOUT, ReportSource, is_serving,
|
||||
join_network, leave_network, request_status, set_active, set_dns, set_hostname,
|
||||
};
|
||||
|
||||
/// Serves the local control interface on a Unix socket.
|
||||
#[derive(Debug)]
|
||||
pub struct ControlSocket {
|
||||
path: PathBuf,
|
||||
task: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ControlSocket {
|
||||
/// Binds the socket and starts serving.
|
||||
///
|
||||
/// A socket file left behind by a crashed agent is replaced, but only
|
||||
/// after checking that nothing is listening on it, so two live agents
|
||||
/// never fight over one path.
|
||||
pub async fn bind(
|
||||
path: impl AsRef<Path>,
|
||||
source: Arc<dyn ReportSource>,
|
||||
access: ControlSocketAccess,
|
||||
) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
create_parent(parent, &access)?;
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
if is_serving(&path).await {
|
||||
return Err(Error::StateLocked { path: path.clone() });
|
||||
}
|
||||
// Nothing is listening, so the file is a leftover.
|
||||
std::fs::remove_file(&path).map_err(|source| Error::Io {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(&path).map_err(|source| Error::Io {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
apply_access(&path, &access)?;
|
||||
|
||||
let task = tokio::spawn(serve(listener, source));
|
||||
Ok(Self {
|
||||
path,
|
||||
task: Some(task),
|
||||
})
|
||||
}
|
||||
|
||||
/// The path being served.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Stops serving and removes the socket file.
|
||||
pub async fn shutdown(mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ControlSocket {
|
||||
fn drop(&mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the socket's parent directory.
|
||||
///
|
||||
/// A group-shared socket must sit in a directory the group can traverse, so it
|
||||
/// is `0755`; a private socket keeps the owner-only state-directory default.
|
||||
fn create_parent(parent: &Path, access: &ControlSocketAccess) -> Result<()> {
|
||||
match access {
|
||||
ControlSocketAccess::Private => crate::storage::create_dir(parent),
|
||||
ControlSocketAccess::Group(_) => {
|
||||
std::fs::create_dir_all(parent).map_err(|source| Error::Io {
|
||||
path: parent.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
set_mode(parent, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the access policy to the bound socket.
|
||||
///
|
||||
/// Private is owner-only `0600`. Group sets the socket's group to the gid and
|
||||
/// the mode to `0660`; if the group cannot be set — the agent is not a member,
|
||||
/// say — it falls back to owner-only rather than leaving the group wrong, so
|
||||
/// the socket is never more open than intended.
|
||||
fn apply_access(path: &Path, access: &ControlSocketAccess) -> Result<()> {
|
||||
match access {
|
||||
ControlSocketAccess::Private => set_mode(path, 0o600),
|
||||
ControlSocketAccess::Group(gid) => {
|
||||
if let Err(source) = std::os::unix::fs::chown(path, None, Some(*gid)) {
|
||||
tracing::warn!(
|
||||
path = %path.display(), gid, %source,
|
||||
"cannot set the control socket group; keeping it owner-only"
|
||||
);
|
||||
return set_mode(path, 0o600);
|
||||
}
|
||||
set_mode(path, 0o660)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mode(path: &Path, mode: u32) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|source| {
|
||||
Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn serve(listener: UnixListener, source: Arc<dyn ReportSource>) {
|
||||
loop {
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
continue;
|
||||
};
|
||||
let source = Arc::clone(&source);
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = super::serve_connection(stream, source).await {
|
||||
tracing::debug!(%err, "local control request failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects a client to the socket at `path`.
|
||||
pub(crate) async fn connect(path: &Path) -> Result<UnixStream> {
|
||||
UnixStream::connect(path).await.map_err(|source| Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether an agent is listening on the socket at `path`.
|
||||
pub(crate) async fn probe(path: &Path) -> bool {
|
||||
UnixStream::connect(path).await.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::super::{Request, Response, StatusReport, exchange};
|
||||
use super::*;
|
||||
use crate::BoxFuture;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_silent_agent_is_reported_rather_than_waited_out() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("control.sock");
|
||||
// A socket that accepts and then says nothing, like a wedged agent:
|
||||
// the connection succeeds, the answer never comes. Unbounded, this
|
||||
// call would never return.
|
||||
let _listener = UnixListener::bind(&path).unwrap();
|
||||
|
||||
let error = exchange(&path, &Request::Status, Duration::from_millis(100))
|
||||
.await
|
||||
.expect_err("a silent agent cannot be reported as healthy");
|
||||
|
||||
match error {
|
||||
Error::Timeout { what } => assert!(
|
||||
what.contains("control.sock"),
|
||||
"the message names what did not answer: {what}"
|
||||
),
|
||||
other => panic!("expected a timeout, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_answered_request_is_not_affected_by_the_bound() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("control.sock");
|
||||
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
||||
Box::pin(async { StatusReport::default() })
|
||||
});
|
||||
let control = ControlSocket::bind(&path, source, ControlSocketAccess::Private)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(answer, Response::Status(_)));
|
||||
|
||||
control.shutdown().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//! A named-pipe adapter for the local control interface.
|
||||
//!
|
||||
//! The Windows counterpart of [`super::unix`]. It provides only what is
|
||||
//! particular to a named pipe — creating the server instance, opening a client
|
||||
//! and turning the state directory path into a pipe name — and hands every
|
||||
//! accepted connection to the shared [`serve_connection`](super::serve_connection).
|
||||
//! The framing, the dispatch and the client wrappers are the same on every
|
||||
//! platform and live in [`super`], re-exported here so `ipc::windows::request_status`
|
||||
//! and its siblings resolve just as their Unix equivalents do.
|
||||
//!
|
||||
//! # From a path to a pipe
|
||||
//!
|
||||
//! The rest of the agent addresses the control interface by a filesystem path,
|
||||
//! the same one a Unix socket would live at. A named pipe has no filesystem
|
||||
//! path, so the path is hashed into a stable name under `\\.\pipe\`. The agent
|
||||
//! and a client derive it the same way from the same path, so neither has to
|
||||
//! be told where the other put it.
|
||||
//!
|
||||
//! # Who may connect
|
||||
//!
|
||||
//! The pipe rejects clients from other machines (`reject_remote_clients(true)`).
|
||||
//! An explicit SDDL security descriptor grants Full Control to Administrators
|
||||
//! and LocalSystem, while granting Read and Write (`GRGW`) to all local users
|
||||
//! with a Medium Integrity SACL label (`S:(ML;;NW;;;ME)`). This allows unprivileged
|
||||
//! user-space tools (such as tray applications, status monitors, or CLI commands
|
||||
//! launched by regular users) to connect to the agent even when the agent was
|
||||
//! started by an elevated Administrator or as a system service.
|
||||
//! A permission denial is reported as such, never as an absent agent.
|
||||
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient, ServerOptions};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
pub use super::{
|
||||
CONTROL_PROTOCOL, ControlSocketAccess, EXCHANGE_TIMEOUT, ReportSource, is_serving,
|
||||
join_network, leave_network, request_status, set_active, set_dns, set_hostname,
|
||||
};
|
||||
|
||||
/// `ERROR_ACCESS_DENIED`: creating a first instance finds an existing pipe,
|
||||
/// or a client cannot access an existing pipe with its current privileges.
|
||||
const ERROR_ACCESS_DENIED: i32 = 5;
|
||||
/// `ERROR_PIPE_BUSY`: every instance is serving a client right now. A server
|
||||
/// is there; the client only has to wait for a free instance.
|
||||
const ERROR_PIPE_BUSY: i32 = 231;
|
||||
|
||||
/// RAII wrapper for Windows named pipe security attributes with permissive local ACL.
|
||||
///
|
||||
/// SDDL specification:
|
||||
/// - `D:(A;;GRGW;;;WD)`: DACL allows Generic Read and Generic Write to Everyone (`WD`).
|
||||
/// Full control (`GA`) remains reserved to Administrators and LocalSystem.
|
||||
/// - `(A;;GA;;;BA)`: DACL allows Generic All to Builtin Administrators (`BA`).
|
||||
/// - `(A;;GA;;;SY)`: DACL allows Generic All to LocalSystem (`SY`).
|
||||
/// - `S:(ML;;NW;;;ME)`: SACL Mandatory Label with Medium Integrity (`ME`) and No Write Up (`NW`).
|
||||
/// This allows unprivileged user-space processes (Medium Integrity) to write to the
|
||||
/// named pipe even when the agent runs as an elevated Administrator (High Integrity).
|
||||
struct PipeSecurityAttributes {
|
||||
attrs: windows_sys::Win32::Security::SECURITY_ATTRIBUTES,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PipeSecurityAttributes {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PipeSecurityAttributes")
|
||||
.field("nLength", &self.attrs.nLength)
|
||||
.field("lpSecurityDescriptor", &self.attrs.lpSecurityDescriptor)
|
||||
.field("bInheritHandle", &self.attrs.bInheritHandle)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PipeSecurityAttributes {
|
||||
fn new() -> Result<Self> {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
|
||||
const SDDL: &str = "D:(A;;GRGW;;;WD)(A;;GA;;;BA)(A;;GA;;;SY)S:(ML;;NW;;;ME)";
|
||||
let wide: Vec<u16> = OsStr::new(SDDL)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
let mut p_sd = std::ptr::null_mut();
|
||||
// SAFETY: We pass a valid null-terminated wide string, standard revision 1,
|
||||
// and a valid pointer to receive the descriptor.
|
||||
let success = unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
wide.as_ptr(),
|
||||
SDDL_REVISION_1,
|
||||
&mut p_sd,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
if success == 0 || p_sd.is_null() {
|
||||
let err = std::io::Error::last_os_error();
|
||||
return Err(Error::Storage(format!(
|
||||
"cannot create pipe security descriptor: {err}"
|
||||
)));
|
||||
}
|
||||
|
||||
let attrs = windows_sys::Win32::Security::SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<windows_sys::Win32::Security::SECURITY_ATTRIBUTES>()
|
||||
as u32,
|
||||
lpSecurityDescriptor: p_sd,
|
||||
bInheritHandle: 0,
|
||||
};
|
||||
|
||||
Ok(Self { attrs })
|
||||
}
|
||||
|
||||
fn as_raw_mut(&mut self) -> *mut std::ffi::c_void {
|
||||
&mut self.attrs as *mut _ as *mut std::ffi::c_void
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PipeSecurityAttributes {
|
||||
fn drop(&mut self) {
|
||||
if !self.attrs.lpSecurityDescriptor.is_null() {
|
||||
// SAFETY: The descriptor was allocated by ConvertStringSecurityDescriptorToSecurityDescriptorW
|
||||
// and must be freed with LocalFree.
|
||||
unsafe {
|
||||
windows_sys::Win32::Foundation::LocalFree(self.attrs.lpSecurityDescriptor);
|
||||
}
|
||||
self.attrs.lpSecurityDescriptor = std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves the local control interface on a named pipe.
|
||||
#[derive(Debug)]
|
||||
pub struct ControlSocket {
|
||||
path: PathBuf,
|
||||
task: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ControlSocket {
|
||||
/// Binds the pipe and starts serving.
|
||||
///
|
||||
/// Claiming the first instance of the name is what detects a second agent:
|
||||
/// if the name already exists, the create is refused and that is reported
|
||||
/// as the state being locked, so two live agents never share one pipe.
|
||||
///
|
||||
/// `_access` is accepted for parity with the Unix socket; granting a group
|
||||
/// here means a pipe security descriptor and is a separate follow-up, so for
|
||||
/// now the pipe keeps its default (creator plus the built-in access the
|
||||
/// `PipeSecurityAttributes` set).
|
||||
pub async fn bind(
|
||||
path: impl AsRef<Path>,
|
||||
source: Arc<dyn ReportSource>,
|
||||
_access: ControlSocketAccess,
|
||||
) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let name = pipe_name(&path);
|
||||
|
||||
let mut sec_attrs = PipeSecurityAttributes::new()?;
|
||||
// SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES.
|
||||
let server = match unsafe {
|
||||
ServerOptions::new()
|
||||
.first_pipe_instance(true)
|
||||
.reject_remote_clients(true)
|
||||
.create_with_security_attributes_raw(&name, sec_attrs.as_raw_mut())
|
||||
} {
|
||||
Ok(server) => server,
|
||||
Err(err) if err.raw_os_error() == Some(ERROR_ACCESS_DENIED) => {
|
||||
return Err(Error::StateLocked { path });
|
||||
}
|
||||
Err(source) => return Err(Error::Io { path, source }),
|
||||
};
|
||||
|
||||
let task = tokio::spawn(serve(name, server, source));
|
||||
Ok(Self {
|
||||
path,
|
||||
task: Some(task),
|
||||
})
|
||||
}
|
||||
|
||||
/// The path the pipe name was derived from.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Stops serving. The pipe goes with the server, so there is nothing to
|
||||
/// remove.
|
||||
pub async fn shutdown(mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ControlSocket {
|
||||
fn drop(&mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts one client at a time, keeping a fresh instance ready for the next.
|
||||
///
|
||||
/// A named pipe server instance serves a single client, so a new instance is
|
||||
/// created as soon as one is taken — otherwise a second `tsng status` while
|
||||
/// the first is mid-flight would find nothing listening.
|
||||
async fn serve(
|
||||
name: String,
|
||||
first: tokio::net::windows::named_pipe::NamedPipeServer,
|
||||
source: Arc<dyn ReportSource>,
|
||||
) {
|
||||
let mut server = first;
|
||||
loop {
|
||||
if server.connect().await.is_err() {
|
||||
match next_instance(&name) {
|
||||
Some(next) => {
|
||||
server = next;
|
||||
continue;
|
||||
}
|
||||
None => return,
|
||||
}
|
||||
}
|
||||
let connected = server;
|
||||
match next_instance(&name) {
|
||||
Some(next) => server = next,
|
||||
None => {
|
||||
// Nothing left to accept the next client on, but the one in
|
||||
// hand is still answered before the loop ends.
|
||||
let source = Arc::clone(&source);
|
||||
tokio::spawn(async move {
|
||||
let _ = super::serve_connection(connected, source).await;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
let source = Arc::clone(&source);
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = super::serve_connection(connected, source).await {
|
||||
tracing::debug!(%err, "local control request failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the next pipe instance, or `None` if the name can no longer be
|
||||
/// served.
|
||||
fn next_instance(name: &str) -> Option<tokio::net::windows::named_pipe::NamedPipeServer> {
|
||||
let mut sec_attrs = match PipeSecurityAttributes::new() {
|
||||
Ok(attrs) => attrs,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "cannot build pipe security attributes for next instance");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES.
|
||||
match unsafe {
|
||||
ServerOptions::new()
|
||||
.reject_remote_clients(true)
|
||||
.create_with_security_attributes_raw(name, sec_attrs.as_raw_mut())
|
||||
} {
|
||||
Ok(server) => Some(server),
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "cannot create the next control pipe instance");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects a client to the pipe for `path`, waiting out a busy pipe.
|
||||
///
|
||||
/// The wait is bounded by the caller's exchange timeout, so a pipe that is
|
||||
/// busy forever is given up on rather than spun on.
|
||||
pub(crate) async fn connect(path: &Path) -> Result<NamedPipeClient> {
|
||||
let name = pipe_name(path);
|
||||
loop {
|
||||
match ClientOptions::new().open(&name) {
|
||||
Ok(client) => return Ok(client),
|
||||
Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY) => {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
return Err(Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source: std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"the agent's control pipe exists, but Windows denied access. \
|
||||
Run this command as the same Windows user and with the same \
|
||||
elevation as `tsng up` (use an administrator terminal if \
|
||||
the agent is elevated). Windows error: {err}"
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
Err(source) => {
|
||||
return Err(Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an agent is serving the pipe for `path`.
|
||||
///
|
||||
/// A busy or inaccessible pipe still means a server is there. In particular,
|
||||
/// access denied must not send a mutating command down the offline path,
|
||||
/// where it would only produce a misleading state-directory lock error.
|
||||
pub(crate) async fn probe(path: &Path) -> bool {
|
||||
let name = pipe_name(path);
|
||||
match ClientOptions::new().open(&name) {
|
||||
Ok(_) => true,
|
||||
Err(err) => {
|
||||
err.raw_os_error() == Some(ERROR_PIPE_BUSY)
|
||||
|| err.kind() == std::io::ErrorKind::PermissionDenied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pipe name for a control-socket path.
|
||||
///
|
||||
/// A hash rather than the path itself, because a pipe name may not contain the
|
||||
/// separators and drive letters a path does, and because two agents with
|
||||
/// different state directories must never collide.
|
||||
fn pipe_name(path: &Path) -> String {
|
||||
let digest = Sha256::digest(path.as_os_str().as_encoded_bytes());
|
||||
format!(r"\\.\pipe\tsunagi-{}", hex::encode(&digest[..16]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::super::{Request, Response, StatusReport, exchange};
|
||||
use super::*;
|
||||
use crate::BoxFuture;
|
||||
|
||||
#[test]
|
||||
fn one_path_maps_to_one_name_and_two_paths_do_not_collide() {
|
||||
let a = pipe_name(Path::new(r"C:\a\agent.sock"));
|
||||
let b = pipe_name(Path::new(r"C:\b\agent.sock"));
|
||||
assert!(a.starts_with(r"\\.\pipe\tsunagi-"), "{a}");
|
||||
assert_eq!(a, pipe_name(Path::new(r"C:\a\agent.sock")));
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_request_makes_the_round_trip_over_a_real_pipe() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("agent.sock");
|
||||
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
||||
Box::pin(async { StatusReport::default() })
|
||||
});
|
||||
|
||||
assert!(!is_serving(&path).await, "nothing serves the pipe yet");
|
||||
let control = ControlSocket::bind(&path, source).await.unwrap();
|
||||
assert!(is_serving(&path).await, "the agent serves it now");
|
||||
|
||||
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(answer, Response::Status(_)));
|
||||
|
||||
control.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_inaccessible_agent_is_not_mistaken_for_an_absent_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("agent.sock");
|
||||
// An outbound-only pipe denies the client's duplex open even when
|
||||
// both processes have the same privileges. This exercises the same
|
||||
// Windows error as a client unable to access an elevated agent,
|
||||
// without requiring elevation or changing any ACLs.
|
||||
let _server = ServerOptions::new()
|
||||
.first_pipe_instance(true)
|
||||
.access_inbound(false)
|
||||
.create(pipe_name(&path))
|
||||
.unwrap();
|
||||
|
||||
assert!(is_serving(&path).await, "access denied is not absence");
|
||||
for error in [
|
||||
request_status(&path).await.unwrap_err(),
|
||||
join_network(&path, "test", "local-ipc-test-secret")
|
||||
.await
|
||||
.unwrap_err(),
|
||||
set_dns(&path, false, None).await.unwrap_err(),
|
||||
] {
|
||||
let Error::Io { source, .. } = error else {
|
||||
panic!("expected an access error, got {error}");
|
||||
};
|
||||
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
let message = source.to_string();
|
||||
assert!(message.contains("same Windows user"), "{message}");
|
||||
assert!(message.contains("administrator"), "{message}");
|
||||
assert!(!message.contains("local-ipc-test-secret"), "{message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_silent_agent_is_reported_rather_than_waited_out() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("agent.sock");
|
||||
// A server instance that accepts and then never answers, like a
|
||||
// wedged agent. Unbounded, the exchange below would never return.
|
||||
let name = pipe_name(&path);
|
||||
let server = ServerOptions::new()
|
||||
.first_pipe_instance(true)
|
||||
.create(&name)
|
||||
.unwrap();
|
||||
let _accept = tokio::spawn(async move {
|
||||
let _ = server.connect().await;
|
||||
// Hold the connection open, answering nothing.
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
});
|
||||
|
||||
let error = exchange(&path, &Request::Status, Duration::from_millis(200))
|
||||
.await
|
||||
.expect_err("a silent agent cannot be reported as healthy");
|
||||
assert!(matches!(error, Error::Timeout { .. }), "{error:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_second_agent_on_the_same_pipe_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("agent.sock");
|
||||
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
||||
Box::pin(async { StatusReport::default() })
|
||||
});
|
||||
|
||||
let first = ControlSocket::bind(&path, Arc::clone(&source))
|
||||
.await
|
||||
.unwrap();
|
||||
let second = ControlSocket::bind(&path, source).await;
|
||||
assert!(
|
||||
matches!(second, Err(Error::StateLocked { .. })),
|
||||
"{second:?}"
|
||||
);
|
||||
|
||||
first.shutdown().await;
|
||||
}
|
||||
}
|
||||
@@ -39,13 +39,18 @@ pub mod agent;
|
||||
pub mod config;
|
||||
pub mod dataplane;
|
||||
pub mod discovery;
|
||||
pub mod dns;
|
||||
pub mod error;
|
||||
pub mod identity;
|
||||
pub mod ipc;
|
||||
pub mod net;
|
||||
pub mod overlay;
|
||||
pub mod proto;
|
||||
pub mod state;
|
||||
pub mod storage;
|
||||
pub mod task;
|
||||
#[cfg(feature = "testing")]
|
||||
pub mod testing;
|
||||
|
||||
pub use agent::{Agent, AgentStatus, Event, NetworkStatus, PeerStatus};
|
||||
pub use config::{AgentConfig, Limits, ReconnectPolicy, StoragePaths, TransportPolicy};
|
||||
@@ -240,6 +240,62 @@ impl EndpointAdapter {
|
||||
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")]
|
||||
if let Some(transport) = &config.test_quic_transport {
|
||||
builder = builder.transport_config(transport.clone());
|
||||
}
|
||||
|
||||
if !config.bind_addrs.is_empty() {
|
||||
builder = builder.clear_ip_transports();
|
||||
for addr in &config.bind_addrs {
|
||||
@@ -254,6 +310,91 @@ impl EndpointAdapter {
|
||||
.await
|
||||
.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 })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Scoped LAN discovery fanout at IP ingress. A domain is one logical network;
|
||||
//! routed copies use ordinary encrypted peer links and are never reflooded.
|
||||
//! Future subnet exporters must supply explicitly authorized ingress domains
|
||||
//! here, not teach the encrypted transit router to inspect application bytes.
|
||||
|
||||
use super::router::{NetworkRoutes, Route};
|
||||
use crate::NetworkId;
|
||||
use iroh::EndpointId;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Local participation and the currently authenticated participants in a domain.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BroadcastPolicy {
|
||||
/// Whether this agent originates and accepts LAN broadcasts in this network.
|
||||
pub enabled: bool,
|
||||
/// Live members explicitly advertising that they accept broadcasts.
|
||||
pub peers: HashSet<EndpointId>,
|
||||
}
|
||||
impl Default for BroadcastPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
peers: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Domain {
|
||||
network: NetworkId,
|
||||
directed: Ipv4Addr,
|
||||
enabled: bool,
|
||||
recipients: Arc<[Route]>,
|
||||
members: HashSet<EndpointId>,
|
||||
}
|
||||
|
||||
/// Immutable ingress-domain index rebuilt with address/policy changes.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct BroadcastTable {
|
||||
sources: HashMap<Ipv4Addr, Arc<Domain>>,
|
||||
networks: HashMap<NetworkId, Arc<Domain>>,
|
||||
destinations: HashSet<Ipv4Addr>,
|
||||
}
|
||||
impl BroadcastTable {
|
||||
pub fn build(networks: &HashMap<NetworkId, NetworkRoutes>) -> Self {
|
||||
let mut table = Self::default();
|
||||
for (&network, routes) in networks {
|
||||
let (Some(range), Some(local)) = (routes.range, routes.local) else {
|
||||
continue;
|
||||
};
|
||||
if range.prefix_len > 30 {
|
||||
continue;
|
||||
}
|
||||
let directed = Ipv4Addr::from(u32::from(range.base) | (u32::MAX >> range.prefix_len));
|
||||
let mut recipients: Vec<_> = routes
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|(_, peer)| routes.broadcast.peers.contains(peer))
|
||||
.map(|(_, peer)| Route {
|
||||
network,
|
||||
peer: *peer,
|
||||
})
|
||||
.collect();
|
||||
recipients.sort_by_key(|route| route.peer);
|
||||
recipients.dedup();
|
||||
let domain = Arc::new(Domain {
|
||||
network,
|
||||
directed,
|
||||
enabled: routes.broadcast.enabled,
|
||||
members: recipients.iter().map(|route| route.peer).collect(),
|
||||
recipients: recipients.into(),
|
||||
});
|
||||
table.sources.insert(local, domain.clone());
|
||||
table.networks.insert(network, domain);
|
||||
table.destinations.insert(directed);
|
||||
}
|
||||
table
|
||||
}
|
||||
pub fn is_destination(&self, destination: Ipv4Addr) -> bool {
|
||||
destination.is_broadcast() || self.destinations.contains(&destination)
|
||||
}
|
||||
pub fn outgoing(&self, source: Ipv4Addr, destination: Ipv4Addr) -> Option<Arc<[Route]>> {
|
||||
let domain = self.sources.get(&source)?;
|
||||
(domain.enabled && (destination.is_broadcast() || destination == domain.directed))
|
||||
.then(|| domain.recipients.clone())
|
||||
}
|
||||
pub fn accepts(&self, network: NetworkId, peer: EndpointId, destination: Ipv4Addr) -> bool {
|
||||
self.networks.get(&network).is_some_and(|domain| {
|
||||
domain.network == network
|
||||
&& domain.enabled
|
||||
&& domain.members.contains(&peer)
|
||||
&& (destination.is_broadcast() || destination == domain.directed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates IPv4/UDP lengths before broadcast fanout. No game-specific ports,
|
||||
/// payload rewriting or checksum changes. IP fragments retain their bytes and
|
||||
/// are reassembled by the destination OS; even later fragments have protocol 17.
|
||||
pub(crate) fn valid_udp(packet: &[u8]) -> bool {
|
||||
if packet.len() < 20 || packet[0] >> 4 != 4 || packet[9] != 17 {
|
||||
return false;
|
||||
}
|
||||
let header = usize::from(packet[0] & 15) * 4;
|
||||
let total = usize::from(u16::from_be_bytes([packet[2], packet[3]]));
|
||||
if header < 20 || total <= header || total > packet.len() {
|
||||
return false;
|
||||
}
|
||||
let fragment = u16::from_be_bytes([packet[6], packet[7]]);
|
||||
if fragment & 0x1fff != 0 {
|
||||
return true;
|
||||
}
|
||||
if total < header + 8 {
|
||||
return false;
|
||||
}
|
||||
let udp = usize::from(u16::from_be_bytes([packet[header + 4], packet[header + 5]]));
|
||||
udp >= 8
|
||||
&& if fragment & 0x2000 != 0 {
|
||||
udp >= total - header
|
||||
} else {
|
||||
udp == total - header
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
fn id(name: &str) -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new(name).unwrap(),
|
||||
&NetworkSecret::from_bytes([4; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
#[test]
|
||||
fn limited_and_directed_broadcasts_never_cross_domains_or_target_disabled_peers() {
|
||||
let peer = iroh::SecretKey::from_bytes(&[8; 32]).public();
|
||||
let a = NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
range: Some("10.0.0.0/24".parse().unwrap()),
|
||||
local: Some("10.0.0.1".parse().unwrap()),
|
||||
peers: vec![("10.0.0.2".parse().unwrap(), peer)],
|
||||
broadcast: BroadcastPolicy {
|
||||
enabled: true,
|
||||
peers: HashSet::from([peer]),
|
||||
},
|
||||
};
|
||||
let b = NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
range: Some("10.1.0.0/24".parse().unwrap()),
|
||||
local: Some("10.1.0.1".parse().unwrap()),
|
||||
peers: vec![("10.1.0.2".parse().unwrap(), peer)],
|
||||
broadcast: BroadcastPolicy::default(),
|
||||
};
|
||||
let table = BroadcastTable::build(&HashMap::from([(id("a"), a.clone()), (id("b"), b)]));
|
||||
let routes = table
|
||||
.outgoing(a.local.unwrap(), Ipv4Addr::BROADCAST)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&*routes,
|
||||
&[Route {
|
||||
network: id("a"),
|
||||
peer
|
||||
}]
|
||||
);
|
||||
assert!(
|
||||
table
|
||||
.outgoing(a.local.unwrap(), "10.1.0.255".parse().unwrap())
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
table
|
||||
.outgoing("192.168.1.2".parse().unwrap(), Ipv4Addr::BROADCAST)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
table
|
||||
.outgoing("10.1.0.1".parse().unwrap(), Ipv4Addr::BROADCAST)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(!table.accepts(id("a"), peer, "10.1.0.255".parse().unwrap()));
|
||||
assert!(!table.accepts(id("b"), peer, Ipv4Addr::BROADCAST));
|
||||
assert!(table.accepts(id("a"), peer, Ipv4Addr::BROADCAST));
|
||||
let disabled = NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: BroadcastPolicy {
|
||||
enabled: false,
|
||||
..a.broadcast.clone()
|
||||
},
|
||||
..a.clone()
|
||||
};
|
||||
let table = BroadcastTable::build(&HashMap::from([(id("a"), disabled)]));
|
||||
assert!(
|
||||
table
|
||||
.outgoing(a.local.unwrap(), Ipv4Addr::BROADCAST)
|
||||
.is_none()
|
||||
);
|
||||
assert!(!table.accepts(id("a"), peer, Ipv4Addr::BROADCAST));
|
||||
}
|
||||
#[test]
|
||||
fn malformed_udp_is_not_amplified_but_ipv4_fragments_are_supported() {
|
||||
let mut packet = vec![0u8; 36];
|
||||
packet[0] = 0x45;
|
||||
packet[3] = 36;
|
||||
packet[9] = 17;
|
||||
packet[25] = 16;
|
||||
assert!(valid_udp(&packet));
|
||||
for length in 0..36 {
|
||||
assert!(!valid_udp(&packet[..length]));
|
||||
}
|
||||
packet[9] = 6;
|
||||
assert!(!valid_udp(&packet));
|
||||
packet[9] = 17;
|
||||
packet[0] = 0x44;
|
||||
assert!(!valid_udp(&packet));
|
||||
packet[0] = 0x45;
|
||||
packet[25] = 8;
|
||||
assert!(!valid_udp(&packet));
|
||||
packet[6] = 0x20;
|
||||
packet[25] = 64;
|
||||
assert!(valid_udp(&packet));
|
||||
packet[6] = 0;
|
||||
packet[7] = 2;
|
||||
assert!(valid_udp(&packet));
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,11 @@
|
||||
|
||||
use std::net::{IpAddr, Ipv6Addr};
|
||||
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::identity::NetworkId;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::overlay::OVERLAY_HOST_PREFIX_LEN;
|
||||
/// Prefix length of a single IPv6 host address.
|
||||
const HOST_PREFIX_LEN: u8 = 128;
|
||||
|
||||
/// Longest interface name Linux accepts, excluding the terminating NUL.
|
||||
pub const MAX_INTERFACE_NAME_LEN: usize = 15;
|
||||
@@ -33,13 +34,13 @@ pub struct Cidr {
|
||||
|
||||
impl Cidr {
|
||||
/// Builds a CIDR, rejecting an impossible prefix length.
|
||||
pub fn new(addr: IpAddr, prefix_len: u8) -> Result<Self, PluginError> {
|
||||
pub fn new(addr: IpAddr, prefix_len: u8) -> Result<Self, OverlayError> {
|
||||
let max = match addr {
|
||||
IpAddr::V4(_) => 32,
|
||||
IpAddr::V6(_) => 128,
|
||||
};
|
||||
if prefix_len > max {
|
||||
return Err(PluginError::Other(format!(
|
||||
return Err(OverlayError::Other(format!(
|
||||
"prefix length /{prefix_len} is impossible for {addr}"
|
||||
)));
|
||||
}
|
||||
@@ -50,7 +51,7 @@ impl Cidr {
|
||||
pub fn host(addr: Ipv6Addr) -> Self {
|
||||
Self {
|
||||
addr: IpAddr::V6(addr),
|
||||
prefix_len: OVERLAY_HOST_PREFIX_LEN,
|
||||
prefix_len: HOST_PREFIX_LEN,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,9 +67,9 @@ impl std::fmt::Display for Cidr {
|
||||
/// The name is stable across restarts and short enough for the platform. Two
|
||||
/// agents on the same host in the same network must be given different
|
||||
/// prefixes, or they would derive the same name.
|
||||
pub fn interface_name(prefix: &str, network: NetworkId) -> Result<String, PluginError> {
|
||||
pub fn interface_name(prefix: &str, network: NetworkId) -> Result<String, OverlayError> {
|
||||
if prefix.is_empty() {
|
||||
return Err(PluginError::Other(
|
||||
return Err(OverlayError::Other(
|
||||
"interface prefix must not be empty".into(),
|
||||
));
|
||||
}
|
||||
@@ -76,12 +77,12 @@ pub fn interface_name(prefix: &str, network: NetworkId) -> Result<String, Plugin
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
{
|
||||
return Err(PluginError::Other(
|
||||
return Err(OverlayError::Other(
|
||||
"interface prefix must be lowercase ASCII letters and digits".into(),
|
||||
));
|
||||
}
|
||||
if prefix.len() >= MAX_INTERFACE_NAME_LEN {
|
||||
return Err(PluginError::Other(format!(
|
||||
return Err(OverlayError::Other(format!(
|
||||
"interface prefix must be shorter than {MAX_INTERFACE_NAME_LEN} characters"
|
||||
)));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Exit-node host rules: what makes this device an exit node for others, and
|
||||
//! what sends this device's own traffic through one.
|
||||
//!
|
||||
//! The packet side lives in [`super::router`]; this is the side the operating
|
||||
//! system sees. It is kept apart from the broadcast rules, which have a
|
||||
//! different lifetime and different tags.
|
||||
|
||||
/// How one set of host rules went.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RuleSetReport {
|
||||
/// Whether every step took.
|
||||
pub ok: bool,
|
||||
/// One line: what is in place, or what is missing and why.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// What the host rules of one network's exit-node settings are doing.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ExitRulesReport {
|
||||
/// Masquerading and forwarding for members' traffic, when this agent
|
||||
/// offers itself as an exit node here. `None` means none are wanted.
|
||||
pub offer: Option<RuleSetReport>,
|
||||
/// Whether the kernel forwards packets for the overlay interface.
|
||||
///
|
||||
/// `Some(false)` means the rules are in place but nothing passes until
|
||||
/// forwarding is turned on, which the agent never does itself. `None`
|
||||
/// when it could not be read.
|
||||
pub forwarding: Option<bool>,
|
||||
/// The routes that send this device's traffic through its exit node.
|
||||
/// `None` means it uses none through this network.
|
||||
pub client: Option<RuleSetReport>,
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Host rules for exit nodes: what the operating system needs for this agent
|
||||
//! to carry other members' internet traffic, and for this device's own
|
||||
//! traffic to go through a member.
|
||||
//!
|
||||
//! Two independent halves, planned together because one interface carries
|
||||
//! both:
|
||||
//!
|
||||
//! * **Offering** — one set of rules per network that offers this agent as an
|
||||
//! exit node, chosen by that network's range: masquerade the range out of
|
||||
//! every other interface and let it through the forward chain. A network
|
||||
//! that does not offer has no rules, so its members' packets are dropped by
|
||||
//! the firewall as well as by the agent.
|
||||
//! * **Using** — policy routing that sends everything the host does not have
|
||||
//! a more specific route for into the overlay interface, while the agent's
|
||||
//! own sockets keep the ordinary route (see `linux_exit`).
|
||||
//!
|
||||
//! Nothing a peer announces becomes a host setting. Offering is the owner's
|
||||
//! switch; using is the user's choice of a member. Every object is tagged,
|
||||
//! reversible, and removed when it is no longer wanted, when the network
|
||||
//! leaves or stops, and when the agent stops.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::state::Ipv4Range;
|
||||
|
||||
use super::RuleOutcome;
|
||||
|
||||
/// What the host should hold for one interface's exit settings.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ExitHostPlan {
|
||||
/// The overlay interface.
|
||||
pub interface: String,
|
||||
/// The ranges of every network that offers this agent as an exit node.
|
||||
pub offer: Vec<Ipv4Range>,
|
||||
/// Whether this device sends its internet traffic through a member.
|
||||
pub client: bool,
|
||||
}
|
||||
|
||||
impl ExitHostPlan {
|
||||
/// Whether there is nothing to hold.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.offer.is_empty() && !self.client
|
||||
}
|
||||
}
|
||||
|
||||
/// What applying a plan achieved.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ExitHostReport {
|
||||
/// One outcome per offered range: its masquerade and forward rules.
|
||||
pub offer: Vec<(Ipv4Range, RuleOutcome)>,
|
||||
/// Whether the kernel forwards packets for the interface. `None` when it
|
||||
/// could not be read. The agent only reads this: turning forwarding on is
|
||||
/// the owner's decision.
|
||||
pub forwarding: Option<bool>,
|
||||
/// The routes and rules that send this device's traffic through the
|
||||
/// interface, when the plan asked for them.
|
||||
pub client: Option<RuleOutcome>,
|
||||
}
|
||||
|
||||
/// Installs and removes exit-node rules for an overlay interface.
|
||||
///
|
||||
/// Like the broadcast rules, no call fails as a whole: a step that cannot be
|
||||
/// done is in the report, because the caller's only useful response is to
|
||||
/// say so.
|
||||
pub trait ExitHostRules: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// Makes the host match the plan, replacing what this agent put there
|
||||
/// for the interface before.
|
||||
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport>;
|
||||
|
||||
/// Removes everything this agent put there for the interface. Succeeds
|
||||
/// when nothing is there: it runs on the shutdown path, and once at
|
||||
/// startup to take away what a crashed run left.
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>;
|
||||
}
|
||||
|
||||
/// The tag every firewall object of one offered range carries.
|
||||
pub fn exit_tag(interface: &str, range: &Ipv4Range) -> String {
|
||||
format!("{}{range}", exit_tag_prefix(interface))
|
||||
}
|
||||
|
||||
/// The start of every tag for an interface; what cleanup selects by.
|
||||
pub fn exit_tag_prefix(interface: &str) -> String {
|
||||
format!("tsunagi-exit:{interface}:")
|
||||
}
|
||||
|
||||
/// The one-line detail for a set of offer outcomes.
|
||||
pub fn offer_detail(outcome: &RuleOutcome, range: &Ipv4Range) -> String {
|
||||
match outcome {
|
||||
RuleOutcome::Applied => format!("masquerade + forward for {range} in place"),
|
||||
RuleOutcome::Failed(reason) => reason.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-memory host for exit rules, for tests: nothing is touched.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MockExitRules {
|
||||
state: Arc<Mutex<Option<ExitHostPlan>>>,
|
||||
calls: Arc<Mutex<Vec<String>>>,
|
||||
forwarding: Arc<Mutex<Option<bool>>>,
|
||||
failure: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl MockExitRules {
|
||||
/// An empty host with forwarding turned on.
|
||||
pub fn new() -> Self {
|
||||
let rules = Self::default();
|
||||
*lock(&rules.forwarding) = Some(true);
|
||||
rules
|
||||
}
|
||||
|
||||
/// What the host reports for kernel forwarding from now on.
|
||||
pub fn set_forwarding(&self, forwarding: Option<bool>) {
|
||||
*lock(&self.forwarding) = forwarding;
|
||||
}
|
||||
|
||||
/// Makes every step fail with this reason from now on.
|
||||
pub fn fail_with(&self, reason: Option<String>) {
|
||||
*lock(&self.failure) = reason;
|
||||
}
|
||||
|
||||
/// What is installed now.
|
||||
pub fn installed(&self) -> Option<ExitHostPlan> {
|
||||
lock(&self.state).clone()
|
||||
}
|
||||
|
||||
/// Every `apply:<interface>` / `clear:<interface>` call, in order.
|
||||
pub fn calls(&self) -> Vec<String> {
|
||||
lock(&self.calls).clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ExitHostRules for MockExitRules {
|
||||
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
|
||||
Box::pin(async move {
|
||||
lock(&self.calls).push(format!("apply:{}", plan.interface));
|
||||
let outcome = match lock(&self.failure).clone() {
|
||||
Some(reason) => RuleOutcome::Failed(reason),
|
||||
None => {
|
||||
*lock(&self.state) = Some(plan.clone());
|
||||
RuleOutcome::Applied
|
||||
}
|
||||
};
|
||||
ExitHostReport {
|
||||
offer: plan
|
||||
.offer
|
||||
.iter()
|
||||
.map(|range| (*range, outcome.clone()))
|
||||
.collect(),
|
||||
forwarding: *lock(&self.forwarding),
|
||||
client: plan.client.then_some(outcome),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
lock(&self.calls).push(format!("clear:{interface}"));
|
||||
*lock(&self.state) = None;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn range() -> Ipv4Range {
|
||||
"10.13.37.0/24".parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_name_the_interface_and_the_range() {
|
||||
assert_eq!(
|
||||
exit_tag("tsun0", &range()),
|
||||
"tsunagi-exit:tsun0:10.13.37.0/24"
|
||||
);
|
||||
assert!(exit_tag("tsun0", &range()).starts_with(&exit_tag_prefix("tsun0")));
|
||||
// Another interface's tag never matches this one's prefix.
|
||||
assert!(!exit_tag("tsun01", &range()).starts_with(&exit_tag_prefix("tsun0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_plan_is_nothing_to_hold() {
|
||||
let mut plan = ExitHostPlan::default();
|
||||
assert!(plan.is_empty());
|
||||
plan.client = true;
|
||||
assert!(!plan.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_mock_applies_reports_and_clears() {
|
||||
let rules = MockExitRules::new();
|
||||
let plan = ExitHostPlan {
|
||||
interface: "tsun0".into(),
|
||||
offer: vec![range()],
|
||||
client: true,
|
||||
};
|
||||
let report = rules.apply(&plan).await;
|
||||
assert_eq!(report.offer[0].1, RuleOutcome::Applied);
|
||||
assert_eq!(report.forwarding, Some(true));
|
||||
assert_eq!(report.client, Some(RuleOutcome::Applied));
|
||||
assert_eq!(rules.installed(), Some(plan));
|
||||
|
||||
rules.set_forwarding(Some(false));
|
||||
rules.fail_with(Some("denied".into()));
|
||||
let report = rules
|
||||
.apply(&ExitHostPlan {
|
||||
interface: "tsun0".into(),
|
||||
offer: vec![range()],
|
||||
client: false,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(report.forwarding, Some(false));
|
||||
assert!(matches!(report.offer[0].1, RuleOutcome::Failed(_)));
|
||||
assert_eq!(report.client, None);
|
||||
|
||||
rules.clear("tsun0").await;
|
||||
assert_eq!(rules.installed(), None);
|
||||
assert_eq!(rules.calls(), ["apply:tsun0", "apply:tsun0", "clear:tsun0"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
//! 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.
|
||||
fn rule_spec(plan: &BroadcastRulesPlan) -> Vec<String> {
|
||||
[
|
||||
"-i",
|
||||
plan.interface.as_str(),
|
||||
"-p",
|
||||
"udp",
|
||||
"-s",
|
||||
&plan.range.to_string(),
|
||||
"-m",
|
||||
"comment",
|
||||
"--comment",
|
||||
&plan.tag(),
|
||||
"-j",
|
||||
"ACCEPT",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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) -> Vec<String> {
|
||||
locked(
|
||||
["-C".to_string(), CHAIN.to_string()]
|
||||
.into_iter()
|
||||
.chain(rule_spec(plan)),
|
||||
)
|
||||
}
|
||||
|
||||
fn insert_args(plan: &BroadcastRulesPlan) -> Vec<String> {
|
||||
locked(
|
||||
["-I".to_string(), CHAIN.to_string(), "1".to_string()]
|
||||
.into_iter()
|
||||
.chain(rule_spec(plan)),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// The delete commands for every rule in an `iptables -S INPUT` listing that
|
||||
/// carries exactly this tag. Anything else in the chain 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);
|
||||
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 present = match run(&check_args(plan)) {
|
||||
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)) {
|
||||
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()));
|
||||
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())).starts_with("-w 5 -I INPUT 1 -i tsun0"));
|
||||
assert!(words(&check_args(&plan())).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());
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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,728 @@
|
||||
//! Exit-node host rules on Linux: `iptables` for the offering side, policy
|
||||
//! routing over netlink for the using side.
|
||||
//!
|
||||
//! # Offering
|
||||
//!
|
||||
//! For every network that offers this agent as an exit node, three rules,
|
||||
//! selected by that network's range and carrying the tag
|
||||
//! `tsunagi-exit:<if>:<range>`:
|
||||
//!
|
||||
//! ```text
|
||||
//! -t nat POSTROUTING -s <range> ! -o <if> -j MASQUERADE
|
||||
//! -t filter FORWARD -i <if> -s <range> ! -o <if> -j ACCEPT
|
||||
//! -t filter FORWARD -o <if> -d <range> -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
//! ```
|
||||
//!
|
||||
//! `! -o <if>` is the egress without naming it: whatever interface the
|
||||
//! default route uses, now or after it changes. Kernel forwarding is only
|
||||
//! *read*. Turning it on changes how the whole host behaves, which is the
|
||||
//! owner's decision, so when it is off the rules are installed anyway and the
|
||||
//! report says so.
|
||||
//!
|
||||
//! # Using
|
||||
//!
|
||||
//! Policy routing, with no `/1` routes and nothing in the main table:
|
||||
//!
|
||||
//! ```text
|
||||
//! table 0x7473: default dev <if>
|
||||
//! 5280: from all uidrange <euid>-<euid> lookup main
|
||||
//! 5290: from all lookup main suppress_prefixlength 0
|
||||
//! 5300: from all lookup 0x7473
|
||||
//! ```
|
||||
//!
|
||||
//! The first keeps the agent's *own* sockets off the tunnel: its QUIC, relay,
|
||||
//! DHT and DNS traffic would otherwise be routed into the interface it is
|
||||
//! carrying, a loop. The second lets every specific route — the LAN, another
|
||||
//! VPN, a tunnel to somewhere else — keep working, because only the default
|
||||
//! route is suppressed. The priorities sit after Tailscale's 5270.
|
||||
//!
|
||||
//! The first rule exempts a *user id*, so it is only right when the agent has
|
||||
//! one of its own, as the packaged service does. An agent started from a login
|
||||
//! session would take that user's own applications out of the tunnel with it,
|
||||
//! and the exit node would silently do nothing for them. That is refused, not
|
||||
//! guessed at: see [`client_refusal`].
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::state::Ipv4Range;
|
||||
|
||||
use super::RuleOutcome;
|
||||
use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules, exit_tag, exit_tag_prefix};
|
||||
use super::linux::{LinuxHostRules, Ran, Run, failure, locked, run_iptables, split_rule_line};
|
||||
|
||||
/// The chains the offering rules live in: `(table, chain)`.
|
||||
const CHAINS: [(&str, &str); 2] = [("nat", "POSTROUTING"), ("filter", "FORWARD")];
|
||||
|
||||
/// One firewall rule: where it goes, and its match and target.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Rule {
|
||||
table: &'static str,
|
||||
chain: &'static str,
|
||||
spec: Vec<String>,
|
||||
}
|
||||
|
||||
fn owned(words: &[&str]) -> Vec<String> {
|
||||
words.iter().map(|word| word.to_string()).collect()
|
||||
}
|
||||
|
||||
/// The three rules of one offered range.
|
||||
fn offer_rules(interface: &str, range: &Ipv4Range) -> Vec<Rule> {
|
||||
let range_text = range.to_string();
|
||||
let tag = exit_tag(interface, range);
|
||||
let comment = ["-m", "comment", "--comment", tag.as_str()];
|
||||
let spec = |head: &[&str], tail: &[&str]| -> Vec<String> {
|
||||
owned(head)
|
||||
.into_iter()
|
||||
.chain(owned(&comment))
|
||||
.chain(owned(tail))
|
||||
.collect()
|
||||
};
|
||||
vec![
|
||||
Rule {
|
||||
table: "nat",
|
||||
chain: "POSTROUTING",
|
||||
spec: spec(
|
||||
&["-s", &range_text, "!", "-o", interface],
|
||||
&["-j", "MASQUERADE"],
|
||||
),
|
||||
},
|
||||
Rule {
|
||||
table: "filter",
|
||||
chain: "FORWARD",
|
||||
spec: spec(
|
||||
&["-i", interface, "-s", &range_text, "!", "-o", interface],
|
||||
&["-j", "ACCEPT"],
|
||||
),
|
||||
},
|
||||
Rule {
|
||||
table: "filter",
|
||||
chain: "FORWARD",
|
||||
spec: spec(
|
||||
&[
|
||||
"-o",
|
||||
interface,
|
||||
"-d",
|
||||
&range_text,
|
||||
"-m",
|
||||
"conntrack",
|
||||
"--ctstate",
|
||||
"RELATED,ESTABLISHED",
|
||||
],
|
||||
&["-j", "ACCEPT"],
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn check_args(rule: &Rule) -> Vec<String> {
|
||||
locked(
|
||||
owned(&["-t", rule.table, "-C", rule.chain])
|
||||
.into_iter()
|
||||
.chain(rule.spec.iter().cloned()),
|
||||
)
|
||||
}
|
||||
|
||||
fn insert_args(rule: &Rule) -> Vec<String> {
|
||||
locked(
|
||||
owned(&["-t", rule.table, "-I", rule.chain, "1"])
|
||||
.into_iter()
|
||||
.chain(rule.spec.iter().cloned()),
|
||||
)
|
||||
}
|
||||
|
||||
fn list_args(table: &str, chain: &str) -> Vec<String> {
|
||||
locked(owned(&["-t", table, "-S", chain]))
|
||||
}
|
||||
|
||||
/// A rule found in a listing whose comment starts with our prefix.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Found {
|
||||
/// The whole tag.
|
||||
tag: String,
|
||||
/// The words after `-A <chain>`, which is what `-D` needs.
|
||||
rest: Vec<String>,
|
||||
}
|
||||
|
||||
/// Every rule in an `iptables -S <chain>` listing carrying a tag that starts
|
||||
/// with `prefix`. Anything else in the chain is left alone.
|
||||
fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec<Found> {
|
||||
listing
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let words = split_rule_line(line);
|
||||
let [append, found_chain, rest @ ..] = words.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
if append != "-A" || found_chain != chain {
|
||||
return None;
|
||||
}
|
||||
let tag = rest
|
||||
.windows(2)
|
||||
.find(|pair| pair[0] == "--comment" && pair[1].starts_with(prefix))
|
||||
.map(|pair| pair[1].clone())?;
|
||||
Some(Found {
|
||||
tag,
|
||||
rest: rest.to_vec(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn delete_args(table: &str, chain: &str, found: &Found) -> Vec<String> {
|
||||
locked(
|
||||
owned(&["-t", table, "-D", chain])
|
||||
.into_iter()
|
||||
.chain(found.rest.iter().cloned()),
|
||||
)
|
||||
}
|
||||
|
||||
fn missing_iptables(range: &Ipv4Range, interface: &str) -> String {
|
||||
format!(
|
||||
"iptables not found; install it (the iptables-nft package is enough), or masquerade \
|
||||
{range} out of every interface but `{interface}` and forward it yourself"
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads one chain, or says why not.
|
||||
fn read_chain(run: Run<'_>, table: &str, chain: &str) -> Result<String, Failure> {
|
||||
match run(&list_args(table, chain)) {
|
||||
Ran::Missing => Err(Failure::Missing),
|
||||
Ran::Exited {
|
||||
success: true,
|
||||
stdout,
|
||||
..
|
||||
} => Ok(stdout),
|
||||
Ran::Exited { stderr, .. } => Err(Failure::Failed(failure(&stderr))),
|
||||
}
|
||||
}
|
||||
|
||||
enum Failure {
|
||||
Missing,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Deletes found rules; the first failure ends it.
|
||||
fn delete_all(
|
||||
run: Run<'_>,
|
||||
table: &str,
|
||||
chain: &str,
|
||||
found: &[Found],
|
||||
keep: impl Fn(&Found) -> bool,
|
||||
) -> Result<(), String> {
|
||||
for rule in found.iter().filter(|rule| !keep(rule)) {
|
||||
if let Ran::Exited {
|
||||
success: false,
|
||||
stderr,
|
||||
..
|
||||
} = run(&delete_args(table, chain, rule))
|
||||
{
|
||||
return Err(failure(&stderr));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Makes the firewall hold exactly the offering rules of these ranges for the
|
||||
/// interface, and no others of ours.
|
||||
fn apply_offer(
|
||||
run: Run<'_>,
|
||||
interface: &str,
|
||||
ranges: &[Ipv4Range],
|
||||
) -> Vec<(Ipv4Range, RuleOutcome)> {
|
||||
let fail_all = |reason: String| -> Vec<(Ipv4Range, RuleOutcome)> {
|
||||
ranges
|
||||
.iter()
|
||||
.map(|range| (*range, RuleOutcome::Failed(reason.clone())))
|
||||
.collect()
|
||||
};
|
||||
let prefix = exit_tag_prefix(interface);
|
||||
let wanted: Vec<String> = ranges.iter().map(|r| exit_tag(interface, r)).collect();
|
||||
|
||||
// What is there now, per chain.
|
||||
let mut present: Vec<(&str, &str, Vec<Found>)> = Vec::new();
|
||||
for (table, chain) in CHAINS {
|
||||
match read_chain(run, table, chain) {
|
||||
Ok(listing) => present.push((table, chain, tagged_rules(&listing, chain, &prefix))),
|
||||
Err(Failure::Missing) => {
|
||||
return match ranges.first() {
|
||||
Some(range) => fail_all(missing_iptables(range, interface)),
|
||||
None => Vec::new(),
|
||||
};
|
||||
}
|
||||
Err(Failure::Failed(reason)) => return fail_all(reason),
|
||||
}
|
||||
}
|
||||
|
||||
// Rules for a range no longer offered, or left by an earlier run.
|
||||
for (table, chain, found) in &present {
|
||||
if let Err(reason) = delete_all(run, table, chain, found, |rule| wanted.contains(&rule.tag))
|
||||
{
|
||||
return fail_all(reason);
|
||||
}
|
||||
}
|
||||
|
||||
ranges
|
||||
.iter()
|
||||
.map(|range| {
|
||||
let rules = offer_rules(interface, range);
|
||||
let tag = exit_tag(interface, range);
|
||||
let in_place = rules
|
||||
.iter()
|
||||
.all(|rule| matches!(run(&check_args(rule)), Ran::Exited { success: true, .. }))
|
||||
&& present.iter().all(|(_, chain, found)| {
|
||||
let have = found.iter().filter(|rule| rule.tag == tag).count();
|
||||
have == rules.iter().filter(|rule| rule.chain == *chain).count()
|
||||
});
|
||||
if in_place {
|
||||
return (*range, RuleOutcome::Applied);
|
||||
}
|
||||
// Start this range from a clean slate: a half-installed set, or
|
||||
// one that differs from the plan, is replaced as a whole.
|
||||
for (table, chain, found) in &present {
|
||||
if let Err(reason) = delete_all(run, table, chain, found, |rule| rule.tag != tag) {
|
||||
return (*range, RuleOutcome::Failed(reason));
|
||||
}
|
||||
}
|
||||
for rule in &rules {
|
||||
match run(&insert_args(rule)) {
|
||||
Ran::Missing => {
|
||||
return (
|
||||
*range,
|
||||
RuleOutcome::Failed(missing_iptables(range, interface)),
|
||||
);
|
||||
}
|
||||
Ran::Exited { success: true, .. } => {}
|
||||
Ran::Exited { stderr, .. } => {
|
||||
return (*range, RuleOutcome::Failed(failure(&stderr)));
|
||||
}
|
||||
}
|
||||
}
|
||||
(*range, RuleOutcome::Applied)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Removes every offering rule of ours for the interface. Best effort.
|
||||
fn clear_offer(run: Run<'_>, interface: &str) {
|
||||
let prefix = exit_tag_prefix(interface);
|
||||
for (table, chain) in CHAINS {
|
||||
let Ok(listing) = read_chain(run, table, chain) else {
|
||||
continue;
|
||||
};
|
||||
let found = tagged_rules(&listing, chain, &prefix);
|
||||
if let Err(reason) = delete_all(run, table, chain, &found, |_| false) {
|
||||
tracing::warn!(
|
||||
interface,
|
||||
"cannot remove an exit node firewall rule: {reason}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kernel's forwarding switch for packets arriving on the interface,
|
||||
/// read from the text of the sysctl files. Never written.
|
||||
fn forwarding_state(read: impl Fn(&str) -> Option<String>, interface: &str) -> Option<bool> {
|
||||
let flag = |text: String| match text.trim() {
|
||||
"1" => Some(true),
|
||||
"0" => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
// The name is derived here, but it ends up in a path.
|
||||
if !interface.is_empty() && !interface.contains(['/', '\0']) && interface != ".." {
|
||||
let per_interface = format!("/proc/sys/net/ipv4/conf/{interface}/forwarding");
|
||||
if let Some(state) = read(&per_interface).and_then(flag) {
|
||||
return Some(state);
|
||||
}
|
||||
}
|
||||
read("/proc/sys/net/ipv4/ip_forward").and_then(flag)
|
||||
}
|
||||
|
||||
fn read_sysctl(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
/// The effective user id from the text of `/proc/self/status`.
|
||||
fn parse_euid(status: &str) -> Option<u32> {
|
||||
status
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("Uid:"))
|
||||
.and_then(|rest| rest.split_whitespace().nth(1))
|
||||
.and_then(|effective| effective.parse().ok())
|
||||
}
|
||||
|
||||
/// Why the using side must not be installed for this process, if it must not.
|
||||
///
|
||||
/// The bypass rule exempts this process's *user id* from the tunnel. That is
|
||||
/// right for a service with an id of its own and wrong for a process started
|
||||
/// from somebody's login session, which shares its id with every application
|
||||
/// that person runs. A login session is recognised by `XDG_RUNTIME_DIR`,
|
||||
/// which `systemd --user` and the login managers set and a system service
|
||||
/// does not. Crude on purpose: refusing a service that was started oddly is
|
||||
/// a message, and exempting a user's applications is a silent failure.
|
||||
fn client_refusal(xdg_runtime_dir: Option<&str>) -> Option<String> {
|
||||
xdg_runtime_dir.filter(|dir| !dir.is_empty()).map(|_| {
|
||||
"the exit node needs the agent to run as a system service with a user of its own: \
|
||||
this agent was started from a login session, so routing everything through the \
|
||||
overlay would take that user's own applications out of the tunnel with it"
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
impl LinuxHostRules {
|
||||
async fn blocking<T: Send + 'static>(
|
||||
work: impl FnOnce() -> T + Send + 'static,
|
||||
) -> Result<T, String> {
|
||||
tokio::task::spawn_blocking(work)
|
||||
.await
|
||||
.map_err(|err| format!("the task failed: {err}"))
|
||||
}
|
||||
|
||||
async fn apply_client(&self, interface: &str) -> RuleOutcome {
|
||||
let xdg = std::env::var("XDG_RUNTIME_DIR").ok();
|
||||
if let Some(reason) = client_refusal(xdg.as_deref()) {
|
||||
// Whatever an earlier run left must not stay in force.
|
||||
self.clear_client().await;
|
||||
return RuleOutcome::Failed(reason);
|
||||
}
|
||||
let Some(euid) = std::fs::read_to_string("/proc/self/status")
|
||||
.ok()
|
||||
.and_then(|status| parse_euid(&status))
|
||||
else {
|
||||
return RuleOutcome::Failed("cannot tell which user this agent runs as".to_string());
|
||||
};
|
||||
let route = self.route.clone();
|
||||
let interface = interface.to_string();
|
||||
match Self::blocking(move || route.set_exit_client(&interface, euid)).await {
|
||||
Ok(Ok(())) => RuleOutcome::Applied,
|
||||
Ok(Err(err)) => RuleOutcome::Failed(err.to_string()),
|
||||
Err(reason) => RuleOutcome::Failed(reason),
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_client(&self) {
|
||||
let route = self.route.clone();
|
||||
if let Ok(Err(err)) = Self::blocking(move || route.clear_exit_client()).await {
|
||||
tracing::warn!(%err, "cannot remove the exit node routing rules");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExitHostRules for LinuxHostRules {
|
||||
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
|
||||
Box::pin(async move {
|
||||
let interface = plan.interface.clone();
|
||||
let ranges = plan.offer.clone();
|
||||
let offer = {
|
||||
let (job_interface, job_ranges) = (interface.clone(), ranges.clone());
|
||||
Self::blocking(move || apply_offer(&run_iptables, &job_interface, &job_ranges))
|
||||
.await
|
||||
.unwrap_or_else(|reason| {
|
||||
ranges
|
||||
.iter()
|
||||
.map(|range| (*range, RuleOutcome::Failed(reason.clone())))
|
||||
.collect()
|
||||
})
|
||||
};
|
||||
let forwarding = if ranges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
forwarding_state(read_sysctl, &interface)
|
||||
};
|
||||
let client = if plan.client {
|
||||
Some(self.apply_client(&interface).await)
|
||||
} else {
|
||||
self.clear_client().await;
|
||||
None
|
||||
};
|
||||
ExitHostReport {
|
||||
offer,
|
||||
forwarding,
|
||||
client,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
let name = interface.to_string();
|
||||
let _ = Self::blocking(move || clear_offer(&run_iptables, &name)).await;
|
||||
self.clear_client().await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn range() -> Ipv4Range {
|
||||
"10.13.37.0/24".parse().unwrap()
|
||||
}
|
||||
|
||||
fn words(args: &[String]) -> String {
|
||||
args.join(" ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rules_masquerade_the_range_out_of_every_other_interface_and_forward_it() {
|
||||
let rules = offer_rules("tsun0", &range());
|
||||
assert_eq!(rules.len(), 3);
|
||||
assert_eq!(
|
||||
words(&rules[0].spec),
|
||||
"-s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j MASQUERADE"
|
||||
);
|
||||
assert_eq!((rules[0].table, rules[0].chain), ("nat", "POSTROUTING"));
|
||||
assert_eq!(
|
||||
words(&rules[1].spec),
|
||||
"-i tsun0 -s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT"
|
||||
);
|
||||
assert_eq!((rules[1].table, rules[1].chain), ("filter", "FORWARD"));
|
||||
assert_eq!(
|
||||
words(&rules[2].spec),
|
||||
"-o tsun0 -d 10.13.37.0/24 -m conntrack --ctstate RELATED,ESTABLISHED -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT"
|
||||
);
|
||||
// Nothing may widen to every source or to the other direction.
|
||||
for rule in &rules {
|
||||
assert!(!words(&rule.spec).contains("0.0.0.0/0"));
|
||||
}
|
||||
assert!(words(&insert_args(&rules[1])).starts_with("-w 5 -t filter -I FORWARD 1 -i tsun0"));
|
||||
assert!(words(&check_args(&rules[0])).starts_with("-w 5 -t nat -C POSTROUTING -s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_rules_with_our_tag_for_this_interface_are_selected() {
|
||||
let listing = "\
|
||||
-P FORWARD DROP
|
||||
-A FORWARD -i tsun0 -s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT
|
||||
-A FORWARD -i tsun1 -m comment --comment tsunagi-exit:tsun1:10.99.0.0/24 -j ACCEPT
|
||||
-A FORWARD -i tsun01 -m comment --comment tsunagi-exit:tsun01:10.5.0.0/24 -j ACCEPT
|
||||
-A FORWARD -m comment --comment \"allow tsunagi-exit:tsun0:x\" -j ACCEPT
|
||||
-A FORWARD -m comment --comment tsunagi:tsun0 -j ACCEPT
|
||||
-A INPUT -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT
|
||||
-A FORWARD -j ACCEPT
|
||||
";
|
||||
let found = tagged_rules(listing, "FORWARD", &exit_tag_prefix("tsun0"));
|
||||
assert_eq!(found.len(), 1, "{found:?}");
|
||||
assert_eq!(found[0].tag, "tsunagi-exit:tsun0:10.13.37.0/24");
|
||||
assert_eq!(
|
||||
words(&delete_args("filter", "FORWARD", &found[0])),
|
||||
"-w 5 -t filter -D FORWARD -i tsun0 -s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT"
|
||||
);
|
||||
assert!(tagged_rules("", "FORWARD", "tsunagi-exit:tsun0:").is_empty());
|
||||
}
|
||||
|
||||
/// A scripted `iptables` that remembers which rules it was given.
|
||||
struct Fake {
|
||||
calls: RefCell<Vec<String>>,
|
||||
/// Rules present, as `-S` lines per `(table, chain)`.
|
||||
listing: RefCell<Vec<(String, String)>>,
|
||||
missing: bool,
|
||||
check_ok: bool,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn new(check_ok: bool) -> Self {
|
||||
Self {
|
||||
calls: RefCell::new(Vec::new()),
|
||||
listing: RefCell::new(Vec::new()),
|
||||
missing: false,
|
||||
check_ok,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_rule(self, table: &str, line: &str) -> Self {
|
||||
self.listing
|
||||
.borrow_mut()
|
||||
.push((table.to_string(), line.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
fn run(&self, args: &[String]) -> Ran {
|
||||
if self.missing {
|
||||
return Ran::Missing;
|
||||
}
|
||||
let line = args.join(" ");
|
||||
self.calls.borrow_mut().push(line.clone());
|
||||
let table = args
|
||||
.iter()
|
||||
.position(|word| word == "-t")
|
||||
.map(|at| args[at + 1].clone())
|
||||
.unwrap_or_default();
|
||||
let ok = |stdout: String| Ran::Exited {
|
||||
success: true,
|
||||
stdout,
|
||||
stderr: String::new(),
|
||||
};
|
||||
if line.contains(" -S ") {
|
||||
let listing: Vec<String> = self
|
||||
.listing
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|(t, _)| *t == table)
|
||||
.map(|(_, l)| l.clone())
|
||||
.collect();
|
||||
return ok(listing.join("\n"));
|
||||
}
|
||||
if line.contains(" -C ") {
|
||||
return Ran::Exited {
|
||||
success: self.check_ok,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
};
|
||||
}
|
||||
ok(String::new())
|
||||
}
|
||||
|
||||
fn verbs(&self, verb: &str) -> Vec<String> {
|
||||
self.calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|call| call.contains(&format!(" {verb} ")))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_host_gets_the_three_rules() {
|
||||
let fake = Fake::new(false);
|
||||
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
|
||||
assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]);
|
||||
let inserted = fake.verbs("-I");
|
||||
assert_eq!(inserted.len(), 3, "{inserted:?}");
|
||||
assert!(inserted[0].contains("-t nat -I POSTROUTING 1"));
|
||||
assert!(fake.verbs("-D").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_already_in_place_are_left_alone() {
|
||||
let tag = exit_tag("tsun0", &range());
|
||||
let fake = Fake::new(true)
|
||||
.with_rule(
|
||||
"nat",
|
||||
&format!("-A POSTROUTING -s 10.13.37.0/24 ! -o tsun0 -m comment --comment {tag} -j MASQUERADE"),
|
||||
)
|
||||
.with_rule("filter", &format!("-A FORWARD -i tsun0 -m comment --comment {tag} -j ACCEPT"))
|
||||
.with_rule("filter", &format!("-A FORWARD -o tsun0 -m comment --comment {tag} -j ACCEPT"));
|
||||
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
|
||||
assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]);
|
||||
assert!(fake.verbs("-I").is_empty());
|
||||
assert!(fake.verbs("-D").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_range_is_removed_and_a_half_installed_set_is_replaced() {
|
||||
let old: Ipv4Range = "10.99.0.0/24".parse().unwrap();
|
||||
let old_tag = exit_tag("tsun0", &old);
|
||||
let tag = exit_tag("tsun0", &range());
|
||||
let fake = Fake::new(true)
|
||||
.with_rule(
|
||||
"nat",
|
||||
&format!(
|
||||
"-A POSTROUTING -s 10.99.0.0/24 -m comment --comment {old_tag} -j MASQUERADE"
|
||||
),
|
||||
)
|
||||
// Only one of the three for the current range.
|
||||
.with_rule(
|
||||
"filter",
|
||||
&format!("-A FORWARD -i tsun0 -m comment --comment {tag} -j ACCEPT"),
|
||||
)
|
||||
// Somebody else's rule, which must survive.
|
||||
.with_rule("filter", "-A FORWARD -i eth0 -j ACCEPT");
|
||||
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
|
||||
assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]);
|
||||
let deleted = fake.verbs("-D");
|
||||
assert!(
|
||||
deleted.iter().any(|call| call.contains(&old_tag)),
|
||||
"{deleted:?}"
|
||||
);
|
||||
assert!(
|
||||
deleted.iter().any(|call| call.contains(&tag)),
|
||||
"{deleted:?}"
|
||||
);
|
||||
assert!(
|
||||
deleted.iter().all(|call| !call.contains("eth0")),
|
||||
"{deleted:?}"
|
||||
);
|
||||
assert_eq!(fake.verbs("-I").len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_removes_only_our_rules_for_the_interface() {
|
||||
let tag = exit_tag("tsun0", &range());
|
||||
let other = exit_tag("tsun1", &range());
|
||||
let fake = Fake::new(true)
|
||||
.with_rule(
|
||||
"nat",
|
||||
&format!("-A POSTROUTING -m comment --comment {tag} -j MASQUERADE"),
|
||||
)
|
||||
.with_rule(
|
||||
"nat",
|
||||
&format!("-A POSTROUTING -m comment --comment {other} -j MASQUERADE"),
|
||||
)
|
||||
.with_rule("filter", "-A FORWARD -j ACCEPT");
|
||||
clear_offer(&|args| fake.run(args), "tsun0");
|
||||
let deleted = fake.verbs("-D");
|
||||
assert_eq!(deleted.len(), 1, "{deleted:?}");
|
||||
assert!(deleted[0].contains(&tag));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_iptables_is_a_failure_that_says_what_to_do() {
|
||||
let mut fake = Fake::new(false);
|
||||
fake.missing = true;
|
||||
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
|
||||
let RuleOutcome::Failed(reason) = &outcome[0].1 else {
|
||||
panic!("{outcome:?}");
|
||||
};
|
||||
assert!(reason.contains("iptables not found"), "{reason}");
|
||||
assert!(reason.contains("10.13.37.0/24"), "{reason}");
|
||||
// Nothing wanted and nothing to run is not a failure of anything.
|
||||
assert!(apply_offer(&|args| fake.run(args), "tsun0", &[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_is_read_per_interface_then_globally_and_never_written() {
|
||||
let files = |per: Option<&'static str>, global: Option<&'static str>| {
|
||||
move |path: &str| match path {
|
||||
"/proc/sys/net/ipv4/conf/tsun0/forwarding" => per.map(str::to_string),
|
||||
"/proc/sys/net/ipv4/ip_forward" => global.map(str::to_string),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
forwarding_state(files(Some("1\n"), Some("0\n")), "tsun0"),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
forwarding_state(files(Some("0\n"), Some("1\n")), "tsun0"),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
forwarding_state(files(None, Some("1\n")), "tsun0"),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(forwarding_state(files(None, None), "tsun0"), None);
|
||||
assert_eq!(forwarding_state(files(Some("junk"), None), "tsun0"), None);
|
||||
// A name that would leave the directory is not looked up.
|
||||
assert_eq!(forwarding_state(files(Some("1"), None), "../../x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_effective_uid_is_the_second_field() {
|
||||
let status = "Name:\ttsunagi\nUid:\t1001\t1002\t1001\t1001\nGid:\t5\t5\t5\t5\n";
|
||||
assert_eq!(parse_euid(status), Some(1002));
|
||||
assert_eq!(parse_euid("Name:\tx\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_login_session_is_refused_and_a_service_is_not() {
|
||||
let reason = client_refusal(Some("/run/user/1000")).unwrap();
|
||||
assert!(reason.contains("system service"), "{reason}");
|
||||
assert!(client_refusal(None).is_none());
|
||||
assert!(client_refusal(Some("")).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! 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 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.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MacosHostRules;
|
||||
|
||||
impl MacosHostRules {
|
||||
/// Creates the host rules.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 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 BroadcastHostRules for MacosHostRules {
|
||||
fn name(&self) -> &str {
|
||||
"route"
|
||||
}
|
||||
|
||||
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,360 @@
|
||||
//! 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(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use windows::WindowsHostRules;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
mod macos;
|
||||
#[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,272 @@
|
||||
//! 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::{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> {
|
||||
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(());
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
impl BroadcastHostRules for WindowsHostRules {
|
||||
fn name(&self) -> &str {
|
||||
"windows-netsecurity"
|
||||
}
|
||||
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,80 @@
|
||||
//! The overlay interface: the one network interface an agent owns.
|
||||
//!
|
||||
//! One agent, one interface. It belongs to the system level rather than to
|
||||
//! any protocol, and that is the whole point: a packet leaving it is routed
|
||||
//! to whichever peer owns its destination address, over whichever protocol
|
||||
//! currently has a link to that peer. Several protocols can be live at once
|
||||
//! without arguing over who holds the address, because neither of them
|
||||
//! holds it — the agent does.
|
||||
//!
|
||||
//! What lives here:
|
||||
//!
|
||||
//! * [`provision`] creates the interface and configures it, and removes it
|
||||
//! again. Platform mechanics behind one decision function.
|
||||
//! * [`hostrules`] installs the route and firewall allowance that let a
|
||||
//! game's LAN discovery reach the interface, while broadcast is on.
|
||||
//! * [`tun`] is the packet interface itself, real or in memory.
|
||||
//! * [`packet`] reads just enough of an IP header to route by it.
|
||||
//! * [`config`] names interfaces and describes addresses.
|
||||
//!
|
||||
//! What does not live here: encryption, peer discovery, and how a packet
|
||||
//! actually reaches another machine. Those belong to a protocol plugin,
|
||||
//! which this level hands packets to and takes packets from.
|
||||
|
||||
/// Why the overlay interface cannot be brought into the state asked for.
|
||||
///
|
||||
/// Its own error rather than the plugin one it used to borrow: this level
|
||||
/// owns the interface now, and a plugin failing is a different event from
|
||||
/// the interface failing.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum OverlayError {
|
||||
/// The interface cannot be created or configured right now, with the
|
||||
/// reason and, where there is one, what to do about it.
|
||||
#[error("overlay unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
/// Anything else.
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
pub mod broadcast;
|
||||
pub mod config;
|
||||
pub mod exit;
|
||||
pub mod hostrules;
|
||||
pub mod interface;
|
||||
pub mod packet;
|
||||
pub mod provision;
|
||||
pub mod router;
|
||||
pub mod tun;
|
||||
|
||||
pub use config::{Cidr, DEFAULT_INTERFACE_PREFIX, MAX_INTERFACE_NAME_LEN, interface_name};
|
||||
pub use exit::{ExitRulesReport, RuleSetReport};
|
||||
pub use hostrules::{
|
||||
BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, ExitHostPlan, ExitHostReport,
|
||||
ExitHostRules, MockExitRules, MockHostRules, RuleOutcome, UnsupportedHostRules,
|
||||
};
|
||||
pub use interface::{Counters, Interface, PacketCarrier, Rejected};
|
||||
pub use packet::IpHeader;
|
||||
pub use provision::{
|
||||
Changes, InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, ManagedTunFactory,
|
||||
MockHost, MockProvisioner, Privilege, Provisioned, UnsupportedProvisioner, plan_changes,
|
||||
probe_net_admin,
|
||||
};
|
||||
pub use router::{ExitPolicy, NetworkRoutes, Route, RouteError, RoutingTable};
|
||||
pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest, address_is_local};
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use hostrules::LinuxHostRules;
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use hostrules::MacosHostRules;
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use hostrules::WindowsHostRules;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use provision::NetlinkProvisioner;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use provision::UtunProvisioner;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use provision::WintunProvisioner;
|
||||
File renamed without changes.
+88
-18
@@ -9,22 +9,21 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::super::config::Cidr;
|
||||
use super::super::hostrules::BroadcastHostRules;
|
||||
use super::super::tun::{TunDevice, TunFactory, TunRequest};
|
||||
use super::{InterfacePlan, InterfaceProvisioner};
|
||||
|
||||
/// Turns a [`TunRequest`] into the plan for a host interface.
|
||||
fn plan_for(request: &TunRequest) -> Result<InterfacePlan, PluginError> {
|
||||
let mut addresses = vec![Cidr::new(request.address.into(), request.prefix_len)?];
|
||||
if let Some(address) = request.address_v4 {
|
||||
addresses.push(Cidr::new(address.into(), request.prefix_len_v4)?);
|
||||
}
|
||||
fn plan_for(request: &TunRequest) -> Result<InterfacePlan, OverlayError> {
|
||||
// Every address, because the plan is exhaustive: the provisioner adds
|
||||
// what is missing and removes what is not in it. Passing one network's
|
||||
// address would take every other network's off the host.
|
||||
Ok(InterfacePlan::new(
|
||||
request.name.clone(),
|
||||
request.mtu,
|
||||
addresses,
|
||||
request.addresses.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -32,12 +31,23 @@ fn plan_for(request: &TunRequest) -> Result<InterfacePlan, PluginError> {
|
||||
#[derive(Debug)]
|
||||
pub struct ManagedTunFactory {
|
||||
provisioner: Arc<dyn InterfaceProvisioner>,
|
||||
host_rules: Option<Arc<dyn BroadcastHostRules>>,
|
||||
}
|
||||
|
||||
impl ManagedTunFactory {
|
||||
/// Wraps a provisioner.
|
||||
pub fn new(provisioner: Arc<dyn InterfaceProvisioner>) -> Self {
|
||||
Self { provisioner }
|
||||
Self {
|
||||
provisioner,
|
||||
host_rules: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets the agent install the broadcast route and firewall allowance for
|
||||
/// the interface this factory manages.
|
||||
pub fn with_host_rules(mut self, rules: Arc<dyn BroadcastHostRules>) -> Self {
|
||||
self.host_rules = Some(rules);
|
||||
self
|
||||
}
|
||||
|
||||
/// The provisioner underneath.
|
||||
@@ -51,10 +61,19 @@ impl TunFactory for ManagedTunFactory {
|
||||
self.provisioner.name()
|
||||
}
|
||||
|
||||
/// It creates the interface on the host and holds it open.
|
||||
fn on_host(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn host_rules(&self) -> Option<Arc<dyn BroadcastHostRules>> {
|
||||
self.host_rules.clone()
|
||||
}
|
||||
|
||||
fn create<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, PluginError>> {
|
||||
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>> {
|
||||
Box::pin(async move {
|
||||
let plan = plan_for(&request)?;
|
||||
let provisioned = self.provisioner.reconcile(&plan).await?;
|
||||
@@ -67,7 +86,7 @@ impl TunFactory for ManagedTunFactory {
|
||||
// Reaching here would mean the interface already existed and
|
||||
// was held open by us, which cannot be true on the path that
|
||||
// creates a device.
|
||||
PluginError::Other(format!(
|
||||
OverlayError::Other(format!(
|
||||
"interface `{}` was reconciled but no device came back",
|
||||
plan.name
|
||||
))
|
||||
@@ -75,7 +94,7 @@ impl TunFactory for ManagedTunFactory {
|
||||
})
|
||||
}
|
||||
|
||||
fn reconfigure<'a>(&'a self, request: TunRequest) -> BoxFuture<'a, Result<(), PluginError>> {
|
||||
fn reconfigure<'a>(&'a self, request: TunRequest) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move {
|
||||
let plan = plan_for(&request)?;
|
||||
let provisioned = self.provisioner.reconcile(&plan).await?;
|
||||
@@ -106,18 +125,23 @@ impl TunFactory for ManagedTunFactory {
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use super::super::super::config::Cidr;
|
||||
use super::super::{LinkKind, MockHost, MockProvisioner};
|
||||
use super::*;
|
||||
|
||||
fn request(v4: Option<Ipv4Addr>) -> TunRequest {
|
||||
addressed(v4.into_iter().collect())
|
||||
}
|
||||
|
||||
fn addressed(v4: Vec<Ipv4Addr>) -> TunRequest {
|
||||
TunRequest {
|
||||
name: "tsunfactory".into(),
|
||||
address: Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1),
|
||||
prefix_len: 64,
|
||||
address_v4: v4,
|
||||
prefix_len_v4: 24,
|
||||
addresses: v4
|
||||
.into_iter()
|
||||
.map(|address| Cidr::new(address.into(), 24).unwrap())
|
||||
.collect(),
|
||||
mtu: 1280,
|
||||
}
|
||||
}
|
||||
@@ -137,7 +161,7 @@ mod tests {
|
||||
let state = provisioner.host().get("tsunfactory").unwrap();
|
||||
assert_eq!(state.kind, LinkKind::Tun);
|
||||
assert_eq!(state.mtu, 1280);
|
||||
assert_eq!(state.addresses.len(), 2, "both families are assigned");
|
||||
assert_eq!(state.addresses.len(), 1, "the overlay address is assigned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -198,4 +222,50 @@ mod tests {
|
||||
let err = factory.create(request(None)).await.unwrap_err();
|
||||
assert!(err.to_string().contains("bridge"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_network_s_address_reaches_the_host_not_just_the_first() {
|
||||
// One agent has one interface and a network apiece on it. Carrying
|
||||
// only the first address is carrying only the first network: the
|
||||
// rest have addresses the operating system has never heard of, and
|
||||
// their traffic goes nowhere while the status says all is well.
|
||||
let provisioner = Arc::new(MockProvisioner::default());
|
||||
let factory = ManagedTunFactory::new(provisioner.clone());
|
||||
factory
|
||||
.create(addressed(vec![
|
||||
Ipv4Addr::new(10, 13, 37, 69),
|
||||
Ipv4Addr::new(10, 156, 200, 116),
|
||||
]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = provisioner.host().get("tsunfactory").unwrap();
|
||||
let addresses: Vec<String> = state
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|entry| entry.to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
addresses.contains(&"10.13.37.69/24".to_string()),
|
||||
"{addresses:?}"
|
||||
);
|
||||
assert!(
|
||||
addresses.contains(&"10.156.200.116/24".to_string()),
|
||||
"{addresses:?}"
|
||||
);
|
||||
|
||||
// And leaving one network takes its address off, because the plan
|
||||
// is what the interface should carry and nothing else.
|
||||
factory
|
||||
.reconfigure(addressed(vec![Ipv4Addr::new(10, 13, 37, 69)]))
|
||||
.await
|
||||
.unwrap();
|
||||
let state = provisioner.host().get("tsunfactory").unwrap();
|
||||
let addresses: Vec<String> = state
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|entry| entry.to_string())
|
||||
.collect();
|
||||
assert_eq!(addresses, vec!["10.13.37.69/24".to_string()]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,531 @@
|
||||
//! Managing the overlay interface on macOS, through utun and `ifconfig`/`route`.
|
||||
//!
|
||||
//! The shape is the same as the Linux and Windows provisioners: observe what
|
||||
//! is there, [`plan_changes`](super::plan_changes) to decide what to do, apply
|
||||
//! the difference. Only the mechanisms differ.
|
||||
//!
|
||||
//! # Creating the interface
|
||||
//!
|
||||
//! The interface is a `utun` device, created through the `tun` dependency's
|
||||
//! safe wrapper — [`open_tun`](super::super::tun) — over the in-kernel
|
||||
//! `com.apple.net.utun_control` control socket. No kernel extension and no
|
||||
//! Network Extension entitlement is involved; what it needs is root. The
|
||||
//! device is not made persistent, so closing the last file descriptor removes
|
||||
//! the interface — it goes away when the agent does, however the agent ends.
|
||||
//!
|
||||
//! # The kernel names the interface, not us
|
||||
//!
|
||||
//! Unlike Linux and Windows, a utun interface cannot be given an arbitrary
|
||||
//! name: it is always `utunN`, and the unit number is assigned by the kernel.
|
||||
//! So the name derived from the network id (`tsun…`) is only ever a *request*;
|
||||
//! the real name is read back off the device after it is opened, and
|
||||
//! everything downstream — the host rules, the resolver publication — uses the
|
||||
//! real one. The agent adopts it as soon as the device is created (see
|
||||
//! [`Interface`](crate::overlay::Interface)).
|
||||
//!
|
||||
//! # Configuring it
|
||||
//!
|
||||
//! Addresses, MTU and routes are applied with `ifconfig` and `route`. On Linux
|
||||
//! the same work is done in process over a netlink socket; here there is no
|
||||
//! equivalent this crate may call, because the in-process route is raw `ioctl`
|
||||
//! and `PF_ROUTE` and that is `unsafe`. The tools are invoked by absolute path
|
||||
//! from `/sbin`, so nothing on `PATH` can stand in for them, and every value
|
||||
//! handed to them is one this agent derived — an interface name chosen by the
|
||||
//! kernel, an address it allocated — never anything a peer said.
|
||||
//!
|
||||
//! # Observing it
|
||||
//!
|
||||
//! The interface table is read through `netdev`, a safe wrapper over
|
||||
//! `getifaddrs`. An interface is matched by its real `utunN` name, which only
|
||||
//! matters on reconfiguration: the first create always makes a fresh device.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::super::config::Cidr;
|
||||
use super::super::tun::{TunDevice, TunRequest, open_tun};
|
||||
use super::privilege::{Privilege, probe_net_admin};
|
||||
use super::{
|
||||
InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, Provisioned, plan_changes,
|
||||
};
|
||||
|
||||
/// Manages the overlay interface with utun and `ifconfig`/`route`.
|
||||
#[derive(Debug)]
|
||||
pub struct UtunProvisioner {
|
||||
/// Interfaces this process created, by their real `utunN` name, so a
|
||||
/// reconfiguration adjusts them rather than trying to create them again.
|
||||
ours: Mutex<Vec<String>>,
|
||||
/// Devices kept alive for as long as the interface should exist. Dropping
|
||||
/// one closes the last descriptor, which removes the utin interface.
|
||||
held: Mutex<Vec<(String, Arc<dyn TunDevice>)>>,
|
||||
}
|
||||
|
||||
impl UtunProvisioner {
|
||||
/// Creates the provisioner, after checking the platform supports one.
|
||||
///
|
||||
/// Whether the process is actually root is not checked here — reading the
|
||||
/// effective uid needs a libc call this crate forbids — so a missing
|
||||
/// privilege surfaces at utun creation with a precise message, the same
|
||||
/// way the Linux and Windows paths treat the open itself as the honest
|
||||
/// answer.
|
||||
pub fn new() -> Result<Self, OverlayError> {
|
||||
match probe_net_admin() {
|
||||
Privilege::Available => {}
|
||||
Privilege::Missing(reason) => return Err(OverlayError::Unavailable(reason)),
|
||||
Privilege::Unsupported => {
|
||||
return Err(OverlayError::Unavailable(
|
||||
"interface management is not compiled in".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
ours: Mutex::new(Vec::new()),
|
||||
held: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_ours(&self, name: &str) -> bool {
|
||||
lock(&self.ours).iter().any(|owned| owned == name)
|
||||
}
|
||||
|
||||
/// Opens the utun device, which is what creates it. The kernel assigns the
|
||||
/// name, so the returned device reports a `utunN` that is not `plan.name`.
|
||||
fn create_device(&self, plan: &InterfacePlan) -> Result<Arc<dyn TunDevice>, OverlayError> {
|
||||
let request = TunRequest::bare(plan.name.clone(), plan.mtu);
|
||||
open_tun(&request)
|
||||
}
|
||||
}
|
||||
|
||||
impl InterfaceProvisioner for UtunProvisioner {
|
||||
fn name(&self) -> &str {
|
||||
"utun"
|
||||
}
|
||||
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
|
||||
Box::pin(async move {
|
||||
// A reconfiguration: the plan carries the real `utunN` name the
|
||||
// agent adopted after the first create, so the interface is there
|
||||
// and only the addresses and MTU need bringing into line.
|
||||
if self.is_ours(&plan.name) {
|
||||
let current = observe(&plan.name).await;
|
||||
let changes = plan_changes(¤t, plan, true)?;
|
||||
configure(&plan.name, &changes).await?;
|
||||
return Ok(Provisioned {
|
||||
changes,
|
||||
device: None,
|
||||
});
|
||||
}
|
||||
|
||||
// The first create. The kernel assigns the unit, so the device is
|
||||
// opened first and its real name used for everything after.
|
||||
let device = self.create_device(plan)?;
|
||||
let real = device.name().to_string();
|
||||
|
||||
// A fresh interface starts with nothing on it, so the whole plan is
|
||||
// applied. `plan_changes` against an absent interface is exactly
|
||||
// that decision; its `create_link` flag is already satisfied by the
|
||||
// open above, so `configure` ignores it and only applies the rest.
|
||||
let changes = plan_changes(&InterfaceState::absent(), plan, false)?;
|
||||
|
||||
// A failure here leaves an interface that exists but cannot carry
|
||||
// traffic. Drop the device so the utun closes, and report, rather
|
||||
// than leave a trap behind.
|
||||
if let Err(err) = configure(&real, &changes).await {
|
||||
drop(device);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
lock(&self.ours).push(real.clone());
|
||||
lock(&self.held).push((real, Arc::clone(&device)));
|
||||
Ok(Provisioned {
|
||||
changes,
|
||||
device: Some(device),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move {
|
||||
// Dropping the device closes the last descriptor, which removes the
|
||||
// utun interface and the routes that were bound to it. There is
|
||||
// nothing to flush: a utun never outlives the process that holds it.
|
||||
lock(&self.held).retain(|(held, _)| held != name);
|
||||
lock(&self.ours).retain(|owned| owned != name);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `fe80::/10` and `169.254.0.0/16`, which macOS assigns on its own.
|
||||
fn is_link_local(addr: IpAddr) -> bool {
|
||||
match addr {
|
||||
IpAddr::V4(addr) => addr.is_link_local(),
|
||||
IpAddr::V6(addr) => (addr.segments()[0] & 0xffc0) == 0xfe80,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the interface named `name` currently looks like.
|
||||
///
|
||||
/// Never fails: a name nothing is using reads as [`InterfaceState::absent`],
|
||||
/// which is what the plan then acts on.
|
||||
async fn observe(name: &str) -> InterfaceState {
|
||||
let name = name.to_string();
|
||||
let found = tokio::task::spawn_blocking(move || {
|
||||
netdev::get_interfaces()
|
||||
.into_iter()
|
||||
.find(|iface| iface.name == name)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let Some(iface) = found else {
|
||||
return InterfaceState::absent();
|
||||
};
|
||||
|
||||
let mut addresses: Vec<Cidr> = Vec::new();
|
||||
for net in &iface.ipv4 {
|
||||
if let Ok(cidr) = Cidr::new(IpAddr::V4(net.addr()), net.prefix_len())
|
||||
&& !is_link_local(cidr.addr)
|
||||
{
|
||||
addresses.push(cidr);
|
||||
}
|
||||
}
|
||||
for net in &iface.ipv6 {
|
||||
if let Ok(cidr) = Cidr::new(IpAddr::V6(net.addr()), net.prefix_len())
|
||||
&& !is_link_local(cidr.addr)
|
||||
{
|
||||
addresses.push(cidr);
|
||||
}
|
||||
}
|
||||
addresses.sort();
|
||||
|
||||
InterfaceState {
|
||||
// Only ever reached for an interface this agent created (see
|
||||
// `reconcile`), so it is ours and a TUN by construction.
|
||||
kind: LinkKind::Tun,
|
||||
attached: iface.is_up(),
|
||||
up: iface.is_up(),
|
||||
mtu: iface.mtu.unwrap_or(0),
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies MTU, link state, addresses and the on-link routes they imply.
|
||||
async fn configure(name: &str, changes: &super::Changes) -> Result<(), OverlayError> {
|
||||
if let Some(mtu) = changes.set_mtu {
|
||||
set_mtu(name, mtu).await?;
|
||||
}
|
||||
for cidr in &changes.remove {
|
||||
// Best effort: an address that is already gone is the outcome wanted.
|
||||
if let Err(err) = address_del(name, *cidr).await {
|
||||
tracing::debug!(interface = %name, %cidr, %err, "could not remove address");
|
||||
}
|
||||
if let Err(err) = route_del(name, *cidr).await {
|
||||
tracing::debug!(interface = %name, %cidr, %err, "could not remove route");
|
||||
}
|
||||
}
|
||||
for cidr in &changes.add {
|
||||
address_add(name, *cidr).await?;
|
||||
// The subnet route makes the whole overlay range reach the interface;
|
||||
// a point-to-point utun does not install one from the address alone. A
|
||||
// host address (/32 or /128) needs none. Best effort: a route that is
|
||||
// already there is the outcome wanted.
|
||||
if let Err(err) = route_add(name, *cidr).await {
|
||||
tracing::debug!(interface = %name, %cidr, %err, "could not add route");
|
||||
}
|
||||
}
|
||||
if changes.bring_up {
|
||||
// Best effort: a utun with a descriptor open is up already, and a
|
||||
// failure to nudge it is not a reason to fail the whole reconcile.
|
||||
if let Err(err) = set_up(name).await {
|
||||
tracing::debug!(interface = %name, %err, "could not enable the interface");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The network address of a CIDR, with the host bits cleared.
|
||||
fn network_of(cidr: Cidr) -> IpAddr {
|
||||
match cidr.addr {
|
||||
IpAddr::V4(addr) => {
|
||||
let bits = u32::from(addr);
|
||||
let mask = if cidr.prefix_len == 0 {
|
||||
0
|
||||
} else {
|
||||
u32::MAX << (32 - u32::from(cidr.prefix_len))
|
||||
};
|
||||
IpAddr::V4(Ipv4Addr::from(bits & mask))
|
||||
}
|
||||
IpAddr::V6(addr) => {
|
||||
let bits = u128::from(addr);
|
||||
let mask = if cidr.prefix_len == 0 {
|
||||
0
|
||||
} else {
|
||||
u128::MAX << (128 - u32::from(cidr.prefix_len))
|
||||
};
|
||||
IpAddr::V6(Ipv6Addr::from(bits & mask))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a CIDR is a single host address, which needs no subnet route.
|
||||
fn is_host_route(cidr: Cidr) -> bool {
|
||||
match cidr.addr {
|
||||
IpAddr::V4(_) => cidr.prefix_len >= 32,
|
||||
IpAddr::V6(_) => cidr.prefix_len >= 128,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ifconfig` arguments that add an address to a utun interface.
|
||||
///
|
||||
/// A utun is point-to-point, so the IPv4 form names the address as its own
|
||||
/// peer; `alias` lets several coexist on the one interface.
|
||||
fn address_add_args(name: &str, cidr: Cidr) -> Vec<String> {
|
||||
match cidr.addr {
|
||||
IpAddr::V4(addr) => vec![
|
||||
name.into(),
|
||||
"inet".into(),
|
||||
format!("{addr}/{}", cidr.prefix_len),
|
||||
addr.to_string(),
|
||||
"alias".into(),
|
||||
],
|
||||
IpAddr::V6(addr) => vec![
|
||||
name.into(),
|
||||
"inet6".into(),
|
||||
format!("{addr}/{}", cidr.prefix_len),
|
||||
"alias".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ifconfig` arguments that remove an address from a utun interface.
|
||||
fn address_del_args(name: &str, cidr: Cidr) -> Vec<String> {
|
||||
let family = match cidr.addr {
|
||||
IpAddr::V4(_) => "inet",
|
||||
IpAddr::V6(_) => "inet6",
|
||||
};
|
||||
vec![
|
||||
name.into(),
|
||||
family.into(),
|
||||
cidr.addr.to_string(),
|
||||
"-alias".into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The `route` arguments that point a CIDR's subnet at the interface.
|
||||
fn route_add_args(name: &str, cidr: Cidr) -> Vec<String> {
|
||||
let family = match cidr.addr {
|
||||
IpAddr::V4(_) => "-inet",
|
||||
IpAddr::V6(_) => "-inet6",
|
||||
};
|
||||
vec![
|
||||
"-q".into(),
|
||||
"-n".into(),
|
||||
"add".into(),
|
||||
family.into(),
|
||||
"-net".into(),
|
||||
format!("{}/{}", network_of(cidr), cidr.prefix_len),
|
||||
"-interface".into(),
|
||||
name.into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The `route` arguments that remove that subnet route.
|
||||
fn route_del_args(name: &str, cidr: Cidr) -> Vec<String> {
|
||||
let family = match cidr.addr {
|
||||
IpAddr::V4(_) => "-inet",
|
||||
IpAddr::V6(_) => "-inet6",
|
||||
};
|
||||
vec![
|
||||
"-q".into(),
|
||||
"-n".into(),
|
||||
"delete".into(),
|
||||
family.into(),
|
||||
"-net".into(),
|
||||
format!("{}/{}", network_of(cidr), cidr.prefix_len),
|
||||
"-interface".into(),
|
||||
name.into(),
|
||||
]
|
||||
}
|
||||
|
||||
async fn address_add(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
||||
ifconfig(address_add_args(name, cidr)).await
|
||||
}
|
||||
|
||||
async fn address_del(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
||||
ifconfig(address_del_args(name, cidr)).await
|
||||
}
|
||||
|
||||
async fn route_add(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
||||
if is_host_route(cidr) {
|
||||
return Ok(());
|
||||
}
|
||||
route(route_add_args(name, cidr)).await
|
||||
}
|
||||
|
||||
async fn route_del(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
||||
if is_host_route(cidr) {
|
||||
return Ok(());
|
||||
}
|
||||
route(route_del_args(name, cidr)).await
|
||||
}
|
||||
|
||||
async fn set_mtu(name: &str, mtu: u32) -> Result<(), OverlayError> {
|
||||
ifconfig(vec![name.into(), "mtu".into(), mtu.to_string()]).await
|
||||
}
|
||||
|
||||
async fn set_up(name: &str) -> Result<(), OverlayError> {
|
||||
ifconfig(vec![name.into(), "up".into()]).await
|
||||
}
|
||||
|
||||
/// Runs `/sbin/ifconfig`, turning a non-zero exit into an error with its text.
|
||||
async fn ifconfig(args: Vec<String>) -> Result<(), OverlayError> {
|
||||
run("/sbin/ifconfig", args).await
|
||||
}
|
||||
|
||||
/// Runs `/sbin/route`, turning a non-zero exit into an error with its text.
|
||||
async fn route(args: Vec<String>) -> Result<(), OverlayError> {
|
||||
run("/sbin/route", args).await
|
||||
}
|
||||
|
||||
/// Runs a system tool by absolute path, so nothing on `PATH` can stand in for
|
||||
/// it, and turns a non-zero exit into an error carrying what it said.
|
||||
async fn run(program: &'static str, args: Vec<String>) -> Result<(), OverlayError> {
|
||||
let display = format!("{program} {}", args.join(" "));
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
std::process::Command::new(program).args(&args).output()
|
||||
})
|
||||
.await
|
||||
.map_err(|err| OverlayError::Other(format!("could not run {display}: {err}")))?
|
||||
.map_err(|err| OverlayError::Unavailable(format!("could not run {display}: {err}")))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let message = {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let text = if stderr.trim().is_empty() {
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
} else {
|
||||
stderr.trim().to_string()
|
||||
};
|
||||
if text.is_empty() {
|
||||
format!("exit code {}", output.status)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
};
|
||||
Err(OverlayError::Unavailable(format!(
|
||||
"{display} failed: {message}"
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn v4(last: u8, prefix: u8) -> Cidr {
|
||||
Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, last)), prefix).unwrap()
|
||||
}
|
||||
|
||||
fn v6(last: u16, prefix: u8) -> Cidr {
|
||||
Cidr::new(
|
||||
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, last)),
|
||||
prefix,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v4_address_is_added_point_to_point_with_an_alias() {
|
||||
let args = address_add_args("utun5", v4(69, 24));
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["utun5", "inet", "10.13.37.69/24", "10.13.37.69", "alias"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v6_address_is_added_with_a_prefix_and_an_alias() {
|
||||
let args = address_add_args("utun5", v6(1, 128));
|
||||
assert_eq!(args, vec!["utun5", "inet6", "fd00::1/128", "alias"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_an_address_names_the_family_and_removes_the_alias() {
|
||||
let args = address_del_args("utun5", v4(69, 24));
|
||||
assert_eq!(args, vec!["utun5", "inet", "10.13.37.69", "-alias"]);
|
||||
let args = address_del_args("utun5", v6(1, 128));
|
||||
assert_eq!(args, vec!["utun5", "inet6", "fd00::1", "-alias"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subnet_route_points_the_masked_network_at_the_interface() {
|
||||
let args = route_add_args("utun5", v4(69, 24));
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-q",
|
||||
"-n",
|
||||
"add",
|
||||
"-inet",
|
||||
"-net",
|
||||
"10.13.37.0/24",
|
||||
"-interface",
|
||||
"utun5"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_address_installs_no_subnet_route() {
|
||||
assert!(is_host_route(v4(69, 32)));
|
||||
assert!(is_host_route(v6(1, 128)));
|
||||
assert!(!is_host_route(v4(69, 24)));
|
||||
assert!(!is_host_route(v6(1, 64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_network_address_clears_the_host_bits() {
|
||||
assert_eq!(
|
||||
network_of(v4(69, 24)),
|
||||
IpAddr::V4(Ipv4Addr::new(10, 13, 37, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
network_of(Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 69)), 16).unwrap()),
|
||||
IpAddr::V4(Ipv4Addr::new(10, 13, 0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
network_of(v6(0x1234, 64)),
|
||||
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_local_addresses_are_not_ours_to_manage() {
|
||||
assert!(is_link_local(IpAddr::V6(Ipv6Addr::new(
|
||||
0xfe80, 0, 0, 0, 0, 0, 0, 1
|
||||
))));
|
||||
assert!(is_link_local(IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1))));
|
||||
assert!(!is_link_local(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 1))));
|
||||
assert!(!is_link_local(IpAddr::V6(Ipv6Addr::new(
|
||||
0xfd00, 0, 0, 0, 0, 0, 0, 1
|
||||
))));
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -9,7 +9,7 @@ use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::super::config::Cidr;
|
||||
use super::super::tun::{MemoryTun, MemoryTunFactory, TunFactory, TunRequest};
|
||||
@@ -125,9 +125,9 @@ impl MockProvisioner {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&self, plan: &InterfacePlan) -> Result<Changes, PluginError> {
|
||||
fn apply(&self, plan: &InterfacePlan) -> Result<Changes, OverlayError> {
|
||||
if let Some(reason) = &self.failure {
|
||||
return Err(PluginError::Unavailable(reason.clone()));
|
||||
return Err(OverlayError::Unavailable(reason.clone()));
|
||||
}
|
||||
|
||||
let ours = self.owned().iter().any(|name| name == &plan.name);
|
||||
@@ -179,7 +179,7 @@ impl InterfaceProvisioner for MockProvisioner {
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, PluginError>> {
|
||||
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
|
||||
Box::pin(async move {
|
||||
let changes = self.apply(plan)?;
|
||||
let device = if changes.create_link {
|
||||
@@ -195,7 +195,7 @@ impl InterfaceProvisioner for MockProvisioner {
|
||||
})
|
||||
}
|
||||
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), PluginError>> {
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move {
|
||||
self.host.lock().remove(name);
|
||||
self.owned().retain(|owned| owned != name);
|
||||
+23
-7
@@ -20,9 +20,13 @@
|
||||
//! pure and tested on every platform, and only the execution is behind
|
||||
//! [`InterfaceProvisioner`].
|
||||
//!
|
||||
//! Three implementations:
|
||||
//! Five implementations:
|
||||
//!
|
||||
//! * `NetlinkProvisioner` on Linux, which needs `CAP_NET_ADMIN`.
|
||||
//! * `WintunProvisioner` on Windows, a Wintun adapter configured with `netsh`,
|
||||
//! which needs an elevated process.
|
||||
//! * `UtunProvisioner` on macOS, a utun interface configured with `ifconfig`
|
||||
//! and `route`, which needs root.
|
||||
//! * [`MockProvisioner`], an in-memory host used by the tests.
|
||||
//! * [`UnsupportedProvisioner`] elsewhere, which fails with an explanation
|
||||
//! and a pointer at the manual route rather than pretending to work.
|
||||
@@ -38,7 +42,7 @@ use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::config::Cidr;
|
||||
use super::tun::TunDevice;
|
||||
@@ -50,6 +54,18 @@ pub use mock::{MockHost, MockProvisioner};
|
||||
mod linux;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use linux::NetlinkProvisioner;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub(crate) use linux::RouteHandle;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
mod windows;
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use windows::WintunProvisioner;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
mod macos;
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use macos::UtunProvisioner;
|
||||
|
||||
mod privilege;
|
||||
pub use privilege::{Privilege, probe_net_admin};
|
||||
@@ -203,19 +219,19 @@ pub fn plan_changes(
|
||||
current: &InterfaceState,
|
||||
plan: &InterfacePlan,
|
||||
ours: bool,
|
||||
) -> Result<Changes, PluginError> {
|
||||
) -> Result<Changes, OverlayError> {
|
||||
let mut changes = Changes::default();
|
||||
|
||||
match ¤t.kind {
|
||||
LinkKind::Foreign(kind) => {
|
||||
return Err(PluginError::Unavailable(format!(
|
||||
return Err(OverlayError::Unavailable(format!(
|
||||
"`{}` already exists and is a {kind} interface, not one of ours. \
|
||||
Refusing to touch it. Run with a different interface prefix.",
|
||||
plan.name
|
||||
)));
|
||||
}
|
||||
LinkKind::Tun if !ours && current.attached => {
|
||||
return Err(PluginError::Unavailable(format!(
|
||||
return Err(OverlayError::Unavailable(format!(
|
||||
"`{}` already exists and another process is attached to it. \
|
||||
That is most likely a second agent on this host in the same \
|
||||
network; give one of them a different interface prefix.",
|
||||
@@ -293,13 +309,13 @@ pub trait InterfaceProvisioner: Send + Sync + std::fmt::Debug + 'static {
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, PluginError>>;
|
||||
) -> BoxFuture<'a, Result<Provisioned, OverlayError>>;
|
||||
|
||||
/// Removes an interface this provisioner created.
|
||||
///
|
||||
/// Removing one that is already gone succeeds: this runs on the shutdown
|
||||
/// path, where the interface having vanished is the desired outcome.
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), PluginError>>;
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
+63
-11
@@ -15,7 +15,7 @@
|
||||
//! Grant it with:
|
||||
//!
|
||||
//! ```text
|
||||
//! sudo setcap cap_net_admin+p /usr/local/bin/tsunagi
|
||||
//! sudo setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsng
|
||||
//! ```
|
||||
//!
|
||||
//! `+p` rather than `+ep`: with `+p` the capability is permitted but not
|
||||
@@ -49,7 +49,7 @@ impl Privilege {
|
||||
/// How to obtain it, for a diagnostic.
|
||||
pub fn how_to_grant(program: &str) -> String {
|
||||
format!(
|
||||
"Grant it once with `sudo setcap cap_net_admin+p {program}` \
|
||||
"Grant it once with `sudo setcap cap_net_admin,cap_net_bind_service+p {program}` \
|
||||
and the agent manages its own interface. Without it, run with \
|
||||
`--no-tun`: the tunnels still form, they just do not reach the \
|
||||
operating system."
|
||||
@@ -60,10 +60,24 @@ impl Privilege {
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use linux_impl::{NetAdmin, probe_net_admin};
|
||||
|
||||
#[cfg(not(all(feature = "tun-device", target_os = "linux")))]
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use windows_impl::probe_net_admin;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use macos_impl::probe_net_admin;
|
||||
|
||||
#[cfg(not(any(
|
||||
all(feature = "tun-device", target_os = "linux"),
|
||||
all(feature = "tun-device", target_os = "windows"),
|
||||
all(feature = "tun-device", target_os = "macos")
|
||||
)))]
|
||||
pub use other_impl::probe_net_admin;
|
||||
|
||||
#[cfg(not(all(feature = "tun-device", target_os = "linux")))]
|
||||
#[cfg(not(any(
|
||||
all(feature = "tun-device", target_os = "linux"),
|
||||
all(feature = "tun-device", target_os = "windows"),
|
||||
all(feature = "tun-device", target_os = "macos")
|
||||
)))]
|
||||
mod other_impl {
|
||||
use super::Privilege;
|
||||
|
||||
@@ -73,12 +87,50 @@ mod other_impl {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
mod windows_impl {
|
||||
use super::Privilege;
|
||||
|
||||
/// Whether this process can configure interfaces.
|
||||
///
|
||||
/// Windows has no capability to hold and lower the way Linux does:
|
||||
/// creating an adapter simply needs the process to be elevated. Reading
|
||||
/// whether it *is* elevated means inspecting the process token through a
|
||||
/// raw 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 Wintun's adapter creation, which fails with a precise
|
||||
/// message when the process is not elevated. 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 = "macos"))]
|
||||
mod macos_impl {
|
||||
use super::Privilege;
|
||||
|
||||
/// Whether this process can configure interfaces.
|
||||
///
|
||||
/// macOS has no capability to hold and lower the way Linux does: creating
|
||||
/// a utun interface simply needs the process to be root. Reading the
|
||||
/// effective uid means a libc call this crate forbids, so the probe is
|
||||
/// deliberately optimistic — it reports that the platform can manage
|
||||
/// interfaces — and the real check is left to the utun open, which fails
|
||||
/// with a precise "run with sudo" message when the process is not root.
|
||||
/// This matches how the Linux path treats the open itself as the honest
|
||||
/// answer.
|
||||
pub fn probe_net_admin() -> Privilege {
|
||||
Privilege::Available
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
mod linux_impl {
|
||||
use caps::{CapSet, Capability};
|
||||
|
||||
use super::Privilege;
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
/// Whether this thread holds `CAP_NET_ADMIN` in its permitted set.
|
||||
pub fn probe_net_admin() -> Privilege {
|
||||
@@ -108,18 +160,18 @@ mod linux_impl {
|
||||
|
||||
impl NetAdmin {
|
||||
/// Raises `CAP_NET_ADMIN` into the effective set.
|
||||
pub fn acquire() -> Result<Self, PluginError> {
|
||||
pub fn acquire() -> Result<Self, OverlayError> {
|
||||
let already = caps::has_cap(None, CapSet::Effective, Capability::CAP_NET_ADMIN)
|
||||
.map_err(|err| {
|
||||
PluginError::Unavailable(format!("cannot read capabilities: {err}"))
|
||||
OverlayError::Unavailable(format!("cannot read capabilities: {err}"))
|
||||
})?;
|
||||
if already {
|
||||
return Ok(Self { raised: false });
|
||||
}
|
||||
caps::raise(None, CapSet::Effective, Capability::CAP_NET_ADMIN).map_err(|err| {
|
||||
PluginError::Unavailable(format!(
|
||||
OverlayError::Unavailable(format!(
|
||||
"cannot raise CAP_NET_ADMIN: {err}. {}",
|
||||
Privilege::how_to_grant("tsunagi")
|
||||
Privilege::how_to_grant("tsng")
|
||||
))
|
||||
})?;
|
||||
Ok(Self { raised: true })
|
||||
@@ -152,8 +204,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_grant_instructions_name_the_program() {
|
||||
let text = Privilege::how_to_grant("/usr/local/bin/tsunagi");
|
||||
assert!(text.contains("setcap cap_net_admin+p /usr/local/bin/tsunagi"));
|
||||
let text = Privilege::how_to_grant("/usr/local/bin/tsng");
|
||||
assert!(text.contains("setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsng"));
|
||||
assert!(text.contains("--no-tun"), "the fallback is offered too");
|
||||
}
|
||||
|
||||
+5
-5
@@ -7,7 +7,7 @@
|
||||
//! of provisioning and says what to do instead.
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::{InterfacePlan, InterfaceProvisioner, Provisioned};
|
||||
|
||||
@@ -31,8 +31,8 @@ impl UnsupportedProvisioner {
|
||||
}
|
||||
}
|
||||
|
||||
fn refusal(&self) -> PluginError {
|
||||
PluginError::Unavailable(format!(
|
||||
fn refusal(&self) -> OverlayError {
|
||||
OverlayError::Unavailable(format!(
|
||||
"managing the overlay interface is not implemented on {} yet. \
|
||||
Run with `--no-tun` until it is: the tunnels still form, they just \
|
||||
do not reach the operating system.",
|
||||
@@ -49,11 +49,11 @@ impl InterfaceProvisioner for UnsupportedProvisioner {
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
_plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, PluginError>> {
|
||||
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
|
||||
Box::pin(async move { Err(self.refusal()) })
|
||||
}
|
||||
|
||||
fn remove<'a>(&'a self, _name: &'a str) -> BoxFuture<'a, Result<(), PluginError>> {
|
||||
fn remove<'a>(&'a self, _name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
// Nothing was ever created, so there is nothing to clean up and no
|
||||
// reason to fail a shutdown path.
|
||||
Box::pin(async move { Ok(()) })
|
||||
@@ -0,0 +1,513 @@
|
||||
//! Managing the overlay interface on Windows, through Wintun and `netsh`.
|
||||
//!
|
||||
//! The shape is the same as the Linux provisioner: observe what is there,
|
||||
//! [`plan_changes`](super::plan_changes) to decide what to do, apply the
|
||||
//! difference. Only the two mechanisms differ.
|
||||
//!
|
||||
//! # Creating the interface
|
||||
//!
|
||||
//! The interface is a Wintun adapter, created through the `tun` dependency's
|
||||
//! safe wrapper — [`open_tun`](super::super::tun) — because loading
|
||||
//! `wintun.dll` is an `unsafe` call and this crate forbids `unsafe`. The
|
||||
//! adapter is created, not made persistent: closing the last handle to it
|
||||
//! removes it, so it goes away when the agent does, however the agent ends.
|
||||
//! The reader and writer inside the device hold that handle, so the adapter
|
||||
//! lives exactly as long as the device does.
|
||||
//!
|
||||
//! # Configuring it
|
||||
//!
|
||||
//! Addresses and MTU are applied with `netsh`. On Linux the same work is done
|
||||
//! in process over a netlink socket; here there is no equivalent that this
|
||||
//! crate may call, because the in-process route is the IP Helper API and that
|
||||
//! is `unsafe`. `netsh` is the supported tool for the job, it is invoked by
|
||||
//! absolute path from `%SystemRoot%` so nothing on `PATH` can stand in for it,
|
||||
//! and every value handed to it is one this agent derived — an interface name
|
||||
//! from the network id, an address it allocated — never anything a peer said.
|
||||
//!
|
||||
//! # Observing it
|
||||
//!
|
||||
//! The interface table is read through `netdev`, whose enumeration is a safe
|
||||
//! wrapper over the same IP Helper API. An adapter is matched by the friendly
|
||||
//! name it was created with. A match that carries a default gateway is treated
|
||||
//! as foreign and left alone: a tsunagi overlay interface never has one, so a
|
||||
//! gateway is the mark of a real device that happens to share the name.
|
||||
//!
|
||||
//! # What Windows cannot do that Linux can
|
||||
//!
|
||||
//! Wintun exposes no way to delete an adapter this process did not create, so
|
||||
//! a leftover from a run that was killed is *reused* rather than replaced: its
|
||||
//! addresses are flushed and the planned ones applied, which reaches the same
|
||||
//! end state. A reused adapter is not removed on exit, only closed — that is a
|
||||
//! Wintun limitation, and the common case of a clean start and a clean stop is
|
||||
//! unaffected.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
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 Wintun and `netsh`.
|
||||
#[derive(Debug)]
|
||||
pub struct WintunProvisioner {
|
||||
/// Interfaces this process created, so they are adjusted rather than
|
||||
/// replaced. See [`plan_changes`].
|
||||
ours: Mutex<Vec<String>>,
|
||||
/// Devices kept alive for as long as the interface should exist. Dropping
|
||||
/// one closes the adapter, which removes it if this process created it.
|
||||
held: Mutex<Vec<(String, Arc<dyn TunDevice>)>>,
|
||||
}
|
||||
|
||||
impl WintunProvisioner {
|
||||
/// Creates the provisioner, after checking the platform supports one.
|
||||
///
|
||||
/// Whether the process is actually elevated is not checked here — that
|
||||
/// cannot be read without an `unsafe` call — so a missing privilege
|
||||
/// surfaces at adapter creation with a precise message, the same way the
|
||||
/// Linux path treats 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.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Opens the Wintun adapter, which is what creates it.
|
||||
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 WintunProvisioner {
|
||||
fn name(&self) -> &str {
|
||||
"wintun"
|
||||
}
|
||||
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
|
||||
Box::pin(async move {
|
||||
let current = observe(&plan.name).await;
|
||||
let changes = plan_changes(¤t, plan, self.is_ours(&plan.name))?;
|
||||
|
||||
// Wintun cannot delete an adapter this process did not create, so a
|
||||
// "delete then create" from `plan_changes` becomes "flush then
|
||||
// reuse": clear the stale addresses off the interface that is
|
||||
// there, and let the create step below reopen the same adapter.
|
||||
// The end state — an adapter carrying exactly the plan's addresses
|
||||
// — is identical.
|
||||
let flush: Vec<Cidr> = if changes.delete_link {
|
||||
tracing::info!(
|
||||
interface = %plan.name,
|
||||
"reusing an abandoned interface left by an earlier run"
|
||||
);
|
||||
current.addresses.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
for cidr in flush.iter().chain(changes.remove.iter()) {
|
||||
// Best effort: an address that is already gone is the outcome
|
||||
// wanted, and refusing to start over one is not worth it.
|
||||
if let Err(err) = address_del(&plan.name, *cidr).await {
|
||||
tracing::debug!(interface = %plan.name, %cidr, %err, "could not remove address");
|
||||
}
|
||||
}
|
||||
|
||||
let device = if changes.create_link {
|
||||
let device = self.create_device(plan)?;
|
||||
lock(&self.ours).push(plan.name.clone());
|
||||
lock(&self.held).push((plan.name.clone(), Arc::clone(&device)));
|
||||
Some(device)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// A failure past this point leaves an interface that exists but
|
||||
// cannot carry traffic. Undo the bookkeeping for one we just
|
||||
// created so it is not mistaken for a working interface, and drop
|
||||
// the device so the adapter closes.
|
||||
let configured = configure(plan, &changes).await;
|
||||
if let Err(err) = configured {
|
||||
if changes.create_link {
|
||||
lock(&self.held).retain(|(held, _)| held != &plan.name);
|
||||
lock(&self.ours).retain(|owned| owned != &plan.name);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(Provisioned { changes, device })
|
||||
})
|
||||
}
|
||||
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move {
|
||||
// Dropping the device closes the adapter, which removes it if this
|
||||
// process created it. A reused adapter is only closed, not removed;
|
||||
// flushing its addresses first keeps no stale configuration behind.
|
||||
let current = observe(name).await;
|
||||
for cidr in ¤t.addresses {
|
||||
if let Err(err) = address_del(name, *cidr).await {
|
||||
tracing::debug!(interface = %name, %cidr, %err, "could not remove address");
|
||||
}
|
||||
}
|
||||
lock(&self.held).retain(|(held, _)| !held.eq_ignore_ascii_case(name));
|
||||
lock(&self.ours).retain(|owned| !owned.eq_ignore_ascii_case(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 Windows 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| match &iface.friendly_name {
|
||||
Some(friendly) => friendly.eq_ignore_ascii_case(&name),
|
||||
None => false,
|
||||
})
|
||||
})
|
||||
.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();
|
||||
|
||||
// A tsunagi overlay interface only ever carries the addresses it was given
|
||||
// and no gateway. One that has a gateway is a real device sharing the name,
|
||||
// so it is foreign and never touched.
|
||||
let kind = if iface.gateway.is_some() {
|
||||
LinkKind::Foreign("gatewayed".to_string())
|
||||
} else {
|
||||
LinkKind::Tun
|
||||
};
|
||||
|
||||
InterfaceState {
|
||||
kind,
|
||||
// Wintun reports an adapter with a live session as up and an abandoned
|
||||
// one as down, so "up" doubles as "some process is holding it".
|
||||
attached: iface.is_up(),
|
||||
up: iface.is_up(),
|
||||
mtu: iface.mtu.unwrap_or(0),
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies MTU, link state and addresses.
|
||||
async fn configure(plan: &InterfacePlan, changes: &super::Changes) -> Result<(), OverlayError> {
|
||||
if let Some(mtu) = changes.set_mtu {
|
||||
set_mtu(&plan.name, mtu).await?;
|
||||
}
|
||||
for cidr in &changes.add {
|
||||
if let Err(err) = address_add(&plan.name, *cidr).await {
|
||||
let err_str = err.to_string();
|
||||
if !err_str.contains("already exists") && !err_str.contains("0x80071392") {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
if changes.bring_up {
|
||||
// Best effort: a Wintun adapter with a session is up already, and a
|
||||
// failure to nudge it is not a reason to fail the whole reconcile.
|
||||
if let Err(err) = set_up(&plan.name).await {
|
||||
tracing::debug!(interface = %plan.name, %err, "could not enable the interface");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The dotted netmask for an IPv4 prefix length.
|
||||
fn ipv4_mask(prefix_len: u8) -> Ipv4Addr {
|
||||
if prefix_len == 0 {
|
||||
Ipv4Addr::UNSPECIFIED
|
||||
} else {
|
||||
Ipv4Addr::from(u32::MAX << (32 - u32::from(prefix_len)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The `netsh` arguments that add an address to an interface.
|
||||
fn address_add_args(name: &str, cidr: Cidr) -> Vec<String> {
|
||||
match cidr.addr {
|
||||
IpAddr::V4(addr) => vec![
|
||||
"interface".into(),
|
||||
"ipv4".into(),
|
||||
"add".into(),
|
||||
"address".into(),
|
||||
format!("name={name}"),
|
||||
format!("address={addr}"),
|
||||
format!("mask={}", ipv4_mask(cidr.prefix_len)),
|
||||
"store=active".into(),
|
||||
],
|
||||
IpAddr::V6(addr) => vec![
|
||||
"interface".into(),
|
||||
"ipv6".into(),
|
||||
"add".into(),
|
||||
"address".into(),
|
||||
format!("interface={name}"),
|
||||
format!("address={addr}/{}", cidr.prefix_len),
|
||||
"store=active".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The `netsh` arguments that remove an address from an interface.
|
||||
fn address_del_args(name: &str, cidr: Cidr) -> Vec<String> {
|
||||
match cidr.addr {
|
||||
IpAddr::V4(addr) => vec![
|
||||
"interface".into(),
|
||||
"ipv4".into(),
|
||||
"delete".into(),
|
||||
"address".into(),
|
||||
format!("name={name}"),
|
||||
format!("address={addr}"),
|
||||
"store=active".into(),
|
||||
],
|
||||
IpAddr::V6(addr) => vec![
|
||||
"interface".into(),
|
||||
"ipv6".into(),
|
||||
"delete".into(),
|
||||
"address".into(),
|
||||
format!("interface={name}"),
|
||||
format!("address={addr}"),
|
||||
"store=active".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The `netsh` arguments that set an interface's MTU, one call per family.
|
||||
fn mtu_args(name: &str, family: &str, mtu: u32) -> Vec<String> {
|
||||
vec![
|
||||
"interface".into(),
|
||||
family.into(),
|
||||
"set".into(),
|
||||
"subinterface".into(),
|
||||
name.into(),
|
||||
format!("mtu={mtu}"),
|
||||
"store=active".into(),
|
||||
]
|
||||
}
|
||||
|
||||
async fn address_add(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
||||
netsh(address_add_args(name, cidr)).await
|
||||
}
|
||||
|
||||
async fn address_del(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
||||
netsh(address_del_args(name, cidr)).await
|
||||
}
|
||||
|
||||
/// Sets the MTU on both families.
|
||||
///
|
||||
/// A family that is turned off on the adapter cannot take an MTU, so this
|
||||
/// fails only when *neither* would — one family carrying the plan's MTU is
|
||||
/// enough, and refusing the whole interface because the other is disabled
|
||||
/// would be wrong.
|
||||
async fn set_mtu(name: &str, mtu: u32) -> Result<(), OverlayError> {
|
||||
let v4 = netsh(mtu_args(name, "ipv4", mtu)).await;
|
||||
let v6 = netsh(mtu_args(name, "ipv6", mtu)).await;
|
||||
match (&v4, &v6) {
|
||||
(Err(_), Err(_)) => v4,
|
||||
_ => {
|
||||
if let Err(err) = &v4 {
|
||||
tracing::debug!(interface = %name, %err, "could not set the IPv4 MTU");
|
||||
}
|
||||
if let Err(err) = &v6 {
|
||||
tracing::debug!(interface = %name, %err, "could not set the IPv6 MTU");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_up(name: &str) -> Result<(), OverlayError> {
|
||||
netsh(vec![
|
||||
"interface".into(),
|
||||
"set".into(),
|
||||
"interface".into(),
|
||||
format!("name={name}"),
|
||||
"admin=enabled".into(),
|
||||
])
|
||||
.await
|
||||
}
|
||||
|
||||
/// Runs `netsh` from `%SystemRoot%\System32`, so nothing on `PATH` can stand
|
||||
/// in for it, and turns a non-zero exit into an error carrying what it said.
|
||||
async fn netsh(args: Vec<String>) -> Result<(), OverlayError> {
|
||||
let program = system32("netsh.exe");
|
||||
let display = format!("netsh {}", 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}"
|
||||
)))
|
||||
}
|
||||
|
||||
/// The absolute path to a program in `System32`.
|
||||
fn system32(exe: &str) -> 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(exe)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::net::Ipv6Addr;
|
||||
|
||||
fn v4(last: u8, prefix: u8) -> Cidr {
|
||||
Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, last)), prefix).unwrap()
|
||||
}
|
||||
|
||||
fn v6(last: u16) -> Cidr {
|
||||
Cidr::new(
|
||||
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, last)),
|
||||
64,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prefix_length_becomes_the_right_dotted_mask() {
|
||||
assert_eq!(ipv4_mask(24), Ipv4Addr::new(255, 255, 255, 0));
|
||||
assert_eq!(ipv4_mask(16), Ipv4Addr::new(255, 255, 0, 0));
|
||||
assert_eq!(ipv4_mask(32), Ipv4Addr::new(255, 255, 255, 255));
|
||||
assert_eq!(ipv4_mask(0), Ipv4Addr::UNSPECIFIED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v4_address_is_added_by_name_with_a_dotted_mask() {
|
||||
let args = address_add_args("tsun0", v4(69, 24));
|
||||
assert!(args.contains(&"ipv4".to_string()));
|
||||
assert!(args.contains(&"add".to_string()));
|
||||
assert!(args.contains(&"name=tsun0".to_string()));
|
||||
assert!(args.contains(&"address=10.13.37.69".to_string()));
|
||||
assert!(args.contains(&"mask=255.255.255.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v6_address_is_added_by_interface_with_a_prefix() {
|
||||
let args = address_add_args("tsun0", v6(1));
|
||||
assert!(args.contains(&"ipv6".to_string()));
|
||||
assert!(args.contains(&"interface=tsun0".to_string()));
|
||||
assert!(args.contains(&"address=fd00::1/64".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_an_address_names_no_mask() {
|
||||
let args = address_del_args("tsun0", v4(69, 24));
|
||||
assert!(args.contains(&"delete".to_string()));
|
||||
assert!(args.contains(&"address=10.13.37.69".to_string()));
|
||||
assert!(!args.iter().any(|arg| arg.starts_with("mask=")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mtu_is_set_per_family_on_the_subinterface() {
|
||||
let args = mtu_args("tsun0", "ipv4", 1280);
|
||||
assert!(args.contains(&"subinterface".to_string()));
|
||||
assert!(args.contains(&"tsun0".to_string()));
|
||||
assert!(args.contains(&"mtu=1280".to_string()));
|
||||
}
|
||||
|
||||
#[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
|
||||
))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system32_is_absolute_and_ends_with_the_program() {
|
||||
let path = system32("netsh.exe");
|
||||
assert!(path.is_absolute(), "{path:?}");
|
||||
assert!(path.ends_with("netsh.exe"), "{path:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
//! Which peer a packet is for, and whether a peer may have sent it.
|
||||
//!
|
||||
//! One agent has one interface, so a packet coming off it could belong to any
|
||||
//! network the agent is in and to any protocol currently carrying traffic.
|
||||
//! Deciding which is this table's job, and it is the reason the interface
|
||||
//! belongs to the system level: the answer comes from signed state, which no
|
||||
//! protocol owns.
|
||||
//!
|
||||
//! Two questions, and they are not the same one:
|
||||
//!
|
||||
//! * **Outbound**: this destination address — whose is it? A packet for
|
||||
//! nobody is dropped rather than broadcast.
|
||||
//! * **Inbound**: this peer decrypted a packet with this source address —
|
||||
//! is that address actually its? A peer may not speak for anybody else,
|
||||
//! and the check consults the signed claim rather than anything the peer
|
||||
//! said.
|
||||
//!
|
||||
//! Nothing here is derived from a protocol's key. An address is allocated at
|
||||
//! the system level and signed by the member that holds it, so every protocol
|
||||
//! carries traffic for the same addresses and the table is the same whichever
|
||||
//! one is in use.
|
||||
|
||||
use super::broadcast::{BroadcastPolicy, BroadcastTable};
|
||||
use arc_swap::ArcSwap;
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use iroh::EndpointId;
|
||||
|
||||
use crate::identity::NetworkId;
|
||||
use crate::state::Ipv4Range;
|
||||
|
||||
/// Where an outbound packet is going.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Route {
|
||||
/// The network the destination belongs to.
|
||||
pub network: NetworkId,
|
||||
/// The member that holds the destination address.
|
||||
pub peer: EndpointId,
|
||||
}
|
||||
|
||||
/// A network's exit-node settings, as far as packets are concerned.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct ExitPolicy {
|
||||
/// This agent offers itself as an exit node here: packets from members
|
||||
/// for addresses outside every overlay range are handed to the host.
|
||||
pub offer: bool,
|
||||
/// The member all other traffic is sent to, if this device uses one.
|
||||
pub via: Option<EndpointId>,
|
||||
}
|
||||
|
||||
/// What one network contributes to the table.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct NetworkRoutes {
|
||||
/// Exit-node settings of this network.
|
||||
pub exit: ExitPolicy,
|
||||
/// Local broadcast policy and authenticated willing recipients.
|
||||
pub broadcast: BroadcastPolicy,
|
||||
/// The range this network allocates from, once it has agreed one.
|
||||
pub range: Option<Ipv4Range>,
|
||||
/// This agent's own address in the network.
|
||||
pub local: Option<Ipv4Addr>,
|
||||
/// Every other member's address, as the signed state has it.
|
||||
pub peers: Vec<(Ipv4Addr, EndpointId)>,
|
||||
}
|
||||
|
||||
/// Why a network cannot be added to the table.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum RouteError {
|
||||
/// Two networks would answer for the same addresses.
|
||||
#[error(
|
||||
"network {network} uses {range}, which overlaps {other_range} already in use by \
|
||||
{other}. One agent has one interface, so an address can belong to only one of \
|
||||
them: give one network a different range."
|
||||
)]
|
||||
Overlap {
|
||||
/// The network being added.
|
||||
network: NetworkId,
|
||||
/// The range it wants.
|
||||
range: Ipv4Range,
|
||||
/// The network already using an overlapping range.
|
||||
other: NetworkId,
|
||||
/// That network's range.
|
||||
other_range: Ipv4Range,
|
||||
},
|
||||
}
|
||||
|
||||
/// Address ownership across every network this agent is in.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RoutingTable {
|
||||
broadcast: ArcSwap<BroadcastTable>,
|
||||
networks: RwLock<HashMap<NetworkId, NetworkRoutes>>,
|
||||
}
|
||||
|
||||
impl RoutingTable {
|
||||
/// An empty table.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn read(&self) -> std::sync::RwLockReadGuard<'_, HashMap<NetworkId, NetworkRoutes>> {
|
||||
match self.networks.read() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self) -> std::sync::RwLockWriteGuard<'_, HashMap<NetworkId, NetworkRoutes>> {
|
||||
match self.networks.write() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces what one network contributes.
|
||||
///
|
||||
/// Rejected when its range overlaps another network's. With one
|
||||
/// interface an address can belong to only one network, and guessing
|
||||
/// which would send somebody's traffic to a stranger.
|
||||
pub fn set_network(&self, network: NetworkId, routes: NetworkRoutes) -> Result<(), RouteError> {
|
||||
let mut networks = self.write();
|
||||
if let Some(range) = routes.range {
|
||||
for (other, existing) in networks.iter() {
|
||||
if *other == network {
|
||||
continue;
|
||||
}
|
||||
let Some(other_range) = existing.range else {
|
||||
continue;
|
||||
};
|
||||
if ranges_overlap(range, other_range) {
|
||||
return Err(RouteError::Overlap {
|
||||
network,
|
||||
range,
|
||||
other: *other,
|
||||
other_range,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
networks.insert(network, routes);
|
||||
self.broadcast
|
||||
.store(Arc::new(BroadcastTable::build(&networks)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forgets a network.
|
||||
pub fn remove_network(&self, network: NetworkId) {
|
||||
let mut networks = self.write();
|
||||
networks.remove(&network);
|
||||
self.broadcast
|
||||
.store(Arc::new(BroadcastTable::build(&networks)));
|
||||
}
|
||||
|
||||
/// Replaces only a network's broadcast participation, without changing addresses.
|
||||
pub fn set_broadcast(&self, network: NetworkId, policy: BroadcastPolicy) {
|
||||
let mut networks = self.write();
|
||||
if let Some(routes) = networks.get_mut(&network) {
|
||||
if routes.broadcast == policy {
|
||||
return;
|
||||
}
|
||||
routes.broadcast = policy;
|
||||
self.broadcast
|
||||
.store(Arc::new(BroadcastTable::build(&networks)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces only a network's exit-node settings, without changing addresses.
|
||||
///
|
||||
/// A network that has no entry yet (its range is not agreed) takes them
|
||||
/// from [`NetworkRoutes::exit`] when it is added.
|
||||
pub fn set_exit(&self, network: NetworkId, policy: ExitPolicy) {
|
||||
if let Some(routes) = self.write().get_mut(&network) {
|
||||
routes.exit = policy;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognizes limited and configured subnet broadcasts before unicast lookup.
|
||||
pub fn is_broadcast(&self, destination: Ipv4Addr) -> bool {
|
||||
self.broadcast.load().is_destination(destination)
|
||||
}
|
||||
|
||||
/// A precomputed domain-scoped recipient list; no per-packet graph search.
|
||||
pub fn broadcast_recipients(
|
||||
&self,
|
||||
source: Ipv4Addr,
|
||||
destination: Ipv4Addr,
|
||||
) -> Option<Arc<[Route]>> {
|
||||
self.broadcast.load().outgoing(source, destination)
|
||||
}
|
||||
|
||||
/// Local admission after the plugin authenticated and decrypted the sender.
|
||||
pub fn accepts_broadcast(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
destination: Ipv4Addr,
|
||||
) -> bool {
|
||||
self.broadcast.load().accepts(network, peer, destination)
|
||||
}
|
||||
|
||||
/// The peer that holds a destination address, if anybody does.
|
||||
///
|
||||
/// This agent's own address returns `None`: a packet for ourselves does
|
||||
/// not go over a tunnel, and answering with a peer would send it to one.
|
||||
///
|
||||
/// An address nobody holds, outside every overlay range and an ordinary
|
||||
/// unicast destination, goes to this device's exit node when it has one:
|
||||
/// that is what sending *all* traffic through a member means.
|
||||
pub fn route(&self, destination: Ipv4Addr) -> Option<Route> {
|
||||
let networks = self.read();
|
||||
for (network, routes) in networks.iter() {
|
||||
if routes.local == Some(destination) {
|
||||
return None;
|
||||
}
|
||||
if let Some((_, peer)) = routes
|
||||
.peers
|
||||
.iter()
|
||||
.find(|(address, _)| *address == destination)
|
||||
{
|
||||
return Some(Route {
|
||||
network: *network,
|
||||
peer: *peer,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Inside an overlay range, an unowned address is nobody's, not the
|
||||
// internet's.
|
||||
if !is_internet_destination(destination) || in_any_range(&networks, destination) {
|
||||
return None;
|
||||
}
|
||||
exit_via(&networks).map(|(network, peer)| Route { network, peer })
|
||||
}
|
||||
|
||||
/// Whether a peer may send from a source address.
|
||||
///
|
||||
/// A peer speaks only for the address the network agreed it holds. The
|
||||
/// one exception is this device's exit node: what comes back from the
|
||||
/// internet carries the internet's addresses, so from *that* peer any
|
||||
/// source outside the overlay ranges is accepted.
|
||||
pub fn may_send_from(&self, network: NetworkId, peer: EndpointId, source: Ipv4Addr) -> bool {
|
||||
let networks = self.read();
|
||||
let Some(routes) = networks.get(&network) else {
|
||||
return false;
|
||||
};
|
||||
if routes
|
||||
.peers
|
||||
.iter()
|
||||
.any(|(address, holder)| *address == source && *holder == peer)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
routes.exit.via == Some(peer)
|
||||
&& is_internet_destination(source)
|
||||
&& !in_any_range(&networks, source)
|
||||
}
|
||||
|
||||
/// Whether this agent, as an exit node of `network`, takes a member's
|
||||
/// packet for `destination` to the host.
|
||||
///
|
||||
/// Only an ordinary unicast address outside every overlay range, so a
|
||||
/// packet for another member, for this agent or for the local network
|
||||
/// segment never leaves by this door. The sender's own source address is
|
||||
/// checked separately, by [`Self::may_send_from`].
|
||||
pub fn accepts_exit_traffic(&self, network: NetworkId, destination: Ipv4Addr) -> bool {
|
||||
let networks = self.read();
|
||||
networks
|
||||
.get(&network)
|
||||
.is_some_and(|routes| routes.exit.offer)
|
||||
&& is_internet_destination(destination)
|
||||
&& !in_any_range(&networks, destination)
|
||||
}
|
||||
|
||||
/// The network and member this device sends its internet traffic through.
|
||||
///
|
||||
/// One default route, so one answer; if several networks claim one (they
|
||||
/// should not) the lowest network id decides, so two runs agree.
|
||||
pub fn exit_via(&self) -> Option<(NetworkId, EndpointId)> {
|
||||
exit_via(&self.read())
|
||||
}
|
||||
|
||||
/// Networks that offer this agent as an exit node, with the range each
|
||||
/// agreed. A network that has not agreed one yet has nothing to
|
||||
/// masquerade and is left out.
|
||||
pub fn exit_offers(&self) -> Vec<(NetworkId, Ipv4Range)> {
|
||||
let mut offers: Vec<(NetworkId, Ipv4Range)> = self
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, routes)| routes.exit.offer)
|
||||
.filter_map(|(network, routes)| Some((*network, routes.range?)))
|
||||
.collect();
|
||||
offers.sort_by_key(|(network, _)| *network);
|
||||
offers
|
||||
}
|
||||
|
||||
/// One network's exit-node settings and its range, if it is in the table.
|
||||
pub fn exit_settings(&self, network: NetworkId) -> Option<(ExitPolicy, Option<Ipv4Range>)> {
|
||||
self.read()
|
||||
.get(&network)
|
||||
.map(|routes| (routes.exit, routes.range))
|
||||
}
|
||||
|
||||
/// Whether an ordinary packet terminates on this host in this network.
|
||||
/// Exported LAN subnets must extend this admission policy explicitly later.
|
||||
pub fn is_local_destination(&self, network: NetworkId, destination: Ipv4Addr) -> bool {
|
||||
self.read()
|
||||
.get(&network)
|
||||
.is_some_and(|routes| routes.local == Some(destination))
|
||||
}
|
||||
|
||||
/// Every address this agent should answer to, with its prefix length.
|
||||
pub fn local_addresses(&self) -> Vec<(Ipv4Addr, u8)> {
|
||||
let mut addresses: Vec<(Ipv4Addr, u8)> = self
|
||||
.read()
|
||||
.values()
|
||||
.filter_map(|routes| Some((routes.local?, routes.range?.prefix_len)))
|
||||
.collect();
|
||||
addresses.sort();
|
||||
addresses
|
||||
}
|
||||
|
||||
/// The one network whose address the limited broadcast route is bound to.
|
||||
///
|
||||
/// The host has a single `255.255.255.255` route per interface, and one
|
||||
/// interface carries every network, so only one overlay address can be its
|
||||
/// source. The choice is the lowest [`NetworkId`] among networks that have
|
||||
/// broadcast enabled, a range and a local address: deterministic, so two
|
||||
/// runs with the same networks pick the same one.
|
||||
pub fn broadcast_host_target(&self) -> Option<(Ipv4Addr, Ipv4Range)> {
|
||||
let networks = self.read();
|
||||
networks
|
||||
.iter()
|
||||
.filter(|(_, routes)| routes.broadcast.enabled)
|
||||
.filter_map(|(network, routes)| Some((*network, routes.local?, routes.range?)))
|
||||
.min_by_key(|(network, _, _)| *network)
|
||||
.map(|(_, local, range)| (local, range))
|
||||
}
|
||||
|
||||
/// Whether a range would collide with one another network already uses.
|
||||
///
|
||||
/// Asked before proposing a range rather than after: with one interface
|
||||
/// an agent that claimed an address it could not route would also be
|
||||
/// telling everybody else to use that range, and "the range of the
|
||||
/// lowest author wins" would spread the collision instead of containing
|
||||
/// it.
|
||||
pub fn would_overlap(&self, network: NetworkId, range: Ipv4Range) -> Option<Ipv4Range> {
|
||||
self.read().iter().find_map(|(other, existing)| {
|
||||
if *other == network {
|
||||
return None;
|
||||
}
|
||||
let other_range = existing.range?;
|
||||
ranges_overlap(range, other_range).then_some(other_range)
|
||||
})
|
||||
}
|
||||
|
||||
/// How many networks the table covers.
|
||||
pub fn len(&self) -> usize {
|
||||
self.read().len()
|
||||
}
|
||||
|
||||
/// Whether it covers none.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.read().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an address is an ordinary unicast one: not this host, not a
|
||||
/// multicast or broadcast group, not link-local, not unspecified.
|
||||
fn is_internet_destination(address: Ipv4Addr) -> bool {
|
||||
!(address.is_unspecified()
|
||||
|| address.is_loopback()
|
||||
|| address.is_multicast()
|
||||
|| address.is_broadcast()
|
||||
|| address.is_link_local())
|
||||
}
|
||||
|
||||
/// Whether an address lies in any network's range.
|
||||
fn in_any_range(networks: &HashMap<NetworkId, NetworkRoutes>, address: Ipv4Addr) -> bool {
|
||||
networks
|
||||
.values()
|
||||
.any(|routes| routes.range.is_some_and(|range| range.contains(address)))
|
||||
}
|
||||
|
||||
/// The exit node in use, if any, from the lowest network that names one.
|
||||
fn exit_via(networks: &HashMap<NetworkId, NetworkRoutes>) -> Option<(NetworkId, EndpointId)> {
|
||||
networks
|
||||
.iter()
|
||||
.filter_map(|(network, routes)| Some((*network, routes.exit.via?)))
|
||||
.min_by_key(|(network, _)| *network)
|
||||
}
|
||||
|
||||
/// Whether two ranges share any address.
|
||||
fn ranges_overlap(one: Ipv4Range, other: Ipv4Range) -> bool {
|
||||
// A range contains the other's base, or the other way round. With
|
||||
// prefix-aligned ranges that is the whole of it: two blocks either nest
|
||||
// or are disjoint.
|
||||
one.contains(other.base) || other.contains(one.base)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
|
||||
fn network(name: &str) -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new(name).unwrap(),
|
||||
&NetworkSecret::from_bytes(vec![4u8; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
|
||||
fn peer(seed: u8) -> EndpointId {
|
||||
iroh::SecretKey::from_bytes(&[seed; 32]).public()
|
||||
}
|
||||
|
||||
fn addr(last: u8) -> Ipv4Addr {
|
||||
Ipv4Addr::new(10, 13, 37, last)
|
||||
}
|
||||
|
||||
fn routes() -> NetworkRoutes {
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
local: Some(addr(1)),
|
||||
peers: vec![(addr(2), peer(2)), (addr(3), peer(3))],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packet_goes_to_the_member_that_holds_its_destination() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("routing");
|
||||
table.set_network(id, routes()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
table.route(addr(2)),
|
||||
Some(Route {
|
||||
network: id,
|
||||
peer: peer(2)
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
table.route(addr(3)),
|
||||
Some(Route {
|
||||
network: id,
|
||||
peer: peer(3)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packet_for_nobody_has_nowhere_to_go() {
|
||||
// Dropped rather than sent to everybody: a tunnel is not a broadcast
|
||||
// domain, and flooding would hand one member another's traffic.
|
||||
let table = RoutingTable::new();
|
||||
table.set_network(network("routing"), routes()).unwrap();
|
||||
assert_eq!(table.route(addr(200)), None);
|
||||
assert_eq!(table.route(Ipv4Addr::new(192, 0, 2, 1)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packet_for_ourselves_is_not_sent_to_a_peer() {
|
||||
let table = RoutingTable::new();
|
||||
table.set_network(network("routing"), routes()).unwrap();
|
||||
assert_eq!(table.route(addr(1)), None, "that address is ours");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peer_may_only_send_from_the_address_it_holds() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("ownership");
|
||||
table.set_network(id, routes()).unwrap();
|
||||
|
||||
assert!(table.may_send_from(id, peer(2), addr(2)));
|
||||
// Somebody else's address, and its own network's.
|
||||
assert!(!table.may_send_from(id, peer(2), addr(3)));
|
||||
assert!(!table.may_send_from(id, peer(2), addr(1)));
|
||||
assert!(!table.may_send_from(id, peer(2), addr(99)));
|
||||
// A peer that holds nothing here.
|
||||
assert!(!table.may_send_from(id, peer(9), addr(2)));
|
||||
// And the question is per network, not global.
|
||||
assert!(!table.may_send_from(network("elsewhere"), peer(2), addr(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_networks_with_overlapping_ranges_are_refused() {
|
||||
// One interface means an address belongs to one network. Accepting
|
||||
// both and guessing would send somebody's traffic to a stranger.
|
||||
let table = RoutingTable::new();
|
||||
let first = network("first");
|
||||
let second = network("second");
|
||||
table.set_network(first, routes()).unwrap();
|
||||
|
||||
let err = table.set_network(second, routes()).unwrap_err();
|
||||
let RouteError::Overlap { other, .. } = err;
|
||||
assert_eq!(other, first);
|
||||
assert!(err.to_string().contains("different range"), "{err}");
|
||||
assert_eq!(table.len(), 1, "the overlapping network is not added");
|
||||
|
||||
// A range of its own is fine.
|
||||
table
|
||||
.set_network(
|
||||
second,
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||
peers: vec![(Ipv4Addr::new(10, 99, 0, 2), peer(4))],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(table.len(), 2);
|
||||
assert_eq!(
|
||||
table.route(Ipv4Addr::new(10, 99, 0, 2)),
|
||||
Some(Route {
|
||||
network: second,
|
||||
peer: peer(4)
|
||||
})
|
||||
);
|
||||
// And the first network still answers for its own.
|
||||
assert_eq!(table.route(addr(2)).map(|route| route.network), Some(first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_broadcast_target_is_the_lowest_enabled_network_with_an_address() {
|
||||
let table = RoutingTable::new();
|
||||
assert_eq!(table.broadcast_host_target(), None);
|
||||
|
||||
let (low, high) = {
|
||||
let (a, b) = (network("first"), network("second"));
|
||||
if a < b { (a, b) } else { (b, a) }
|
||||
};
|
||||
let mk = |enabled: bool, base: [u8; 4], local: Option<Ipv4Addr>| NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: BroadcastPolicy {
|
||||
enabled,
|
||||
..Default::default()
|
||||
},
|
||||
range: Some(
|
||||
format!("{}.{}.{}.0/24", base[0], base[1], base[2])
|
||||
.parse()
|
||||
.unwrap(),
|
||||
),
|
||||
local,
|
||||
peers: Vec::new(),
|
||||
};
|
||||
let a = Ipv4Addr::new(10, 1, 0, 1);
|
||||
let b = Ipv4Addr::new(10, 2, 0, 1);
|
||||
|
||||
// The lower id has broadcast off, so the higher one is chosen.
|
||||
table
|
||||
.set_network(low, mk(false, [10, 1, 0, 0], Some(a)))
|
||||
.unwrap();
|
||||
table
|
||||
.set_network(high, mk(true, [10, 2, 0, 0], Some(b)))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
table.broadcast_host_target(),
|
||||
Some((b, "10.2.0.0/24".parse().unwrap()))
|
||||
);
|
||||
|
||||
// Both enabled: the lowest id wins, whatever the insertion order.
|
||||
table
|
||||
.set_network(low, mk(true, [10, 1, 0, 0], Some(a)))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
table.broadcast_host_target(),
|
||||
Some((a, "10.1.0.0/24".parse().unwrap()))
|
||||
);
|
||||
|
||||
// No local address yet: it cannot be the route's source.
|
||||
table
|
||||
.set_network(low, mk(true, [10, 1, 0, 0], None))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
table.broadcast_host_target(),
|
||||
Some((b, "10.2.0.0/24".parse().unwrap()))
|
||||
);
|
||||
|
||||
table.remove_network(high);
|
||||
assert_eq!(table.broadcast_host_target(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_can_be_asked_about_before_it_is_proposed() {
|
||||
// The point of asking first: an agent that claims an address it
|
||||
// cannot route also tells everybody else to use that range.
|
||||
let table = RoutingTable::new();
|
||||
let first = network("first");
|
||||
let second = network("second");
|
||||
table.set_network(first, routes()).unwrap();
|
||||
|
||||
let mine: Ipv4Range = "10.13.37.0/24".parse().unwrap();
|
||||
assert_eq!(table.would_overlap(second, mine), Some(mine));
|
||||
// Its own range is not a collision with itself.
|
||||
assert_eq!(table.would_overlap(first, mine), None);
|
||||
assert_eq!(
|
||||
table.would_overlap(second, "10.99.0.0/16".parse().unwrap()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_range_counts_as_overlapping() {
|
||||
let table = RoutingTable::new();
|
||||
table
|
||||
.set_network(
|
||||
network("wide"),
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.0.0.0/8".parse().unwrap()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// Inside the /8, in either direction.
|
||||
assert!(
|
||||
table
|
||||
.set_network(
|
||||
network("narrow"),
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_network_does_not_count_as_overlapping_itself() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("refresh");
|
||||
table.set_network(id, routes()).unwrap();
|
||||
|
||||
let mut updated = routes();
|
||||
updated.peers.push((addr(4), peer(4)));
|
||||
table.set_network(id, updated).unwrap();
|
||||
assert_eq!(table.len(), 1);
|
||||
assert!(table.route(addr(4)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_a_network_takes_its_addresses_with_it() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("leaving");
|
||||
table.set_network(id, routes()).unwrap();
|
||||
table.remove_network(id);
|
||||
|
||||
assert!(table.is_empty());
|
||||
assert_eq!(table.route(addr(2)), None);
|
||||
assert!(!table.may_send_from(id, peer(2), addr(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_local_addresses_are_what_the_interface_should_carry() {
|
||||
let table = RoutingTable::new();
|
||||
table.set_network(network("one"), routes()).unwrap();
|
||||
table
|
||||
.set_network(
|
||||
network("two"),
|
||||
NetworkRoutes {
|
||||
exit: Default::default(),
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||
peers: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
table.local_addresses(),
|
||||
vec![(addr(1), 24), (Ipv4Addr::new(10, 99, 0, 1), 16)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_with_no_address_yet_contributes_nothing() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("waiting");
|
||||
table.set_network(id, NetworkRoutes::default()).unwrap();
|
||||
|
||||
assert!(table.local_addresses().is_empty());
|
||||
assert_eq!(table.route(addr(2)), None);
|
||||
assert_eq!(table.len(), 1, "the network is known, it just has nothing");
|
||||
}
|
||||
|
||||
fn with_exit(offer: bool, via: Option<EndpointId>) -> NetworkRoutes {
|
||||
NetworkRoutes {
|
||||
exit: ExitPolicy { offer, via },
|
||||
..routes()
|
||||
}
|
||||
}
|
||||
|
||||
const INTERNET: Ipv4Addr = Ipv4Addr::new(93, 184, 216, 34);
|
||||
|
||||
#[test]
|
||||
fn without_an_exit_node_the_internet_has_nowhere_to_go() {
|
||||
let table = RoutingTable::new();
|
||||
table.set_network(network("plain"), routes()).unwrap();
|
||||
assert_eq!(table.route(INTERNET), None);
|
||||
assert_eq!(table.exit_via(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_an_exit_node_every_ordinary_destination_goes_to_it() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("client");
|
||||
table
|
||||
.set_network(id, with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
let exit = Route {
|
||||
network: id,
|
||||
peer: peer(2),
|
||||
};
|
||||
assert_eq!(table.route(INTERNET), Some(exit));
|
||||
assert_eq!(table.route(Ipv4Addr::new(192, 168, 1, 10)), Some(exit));
|
||||
assert_eq!(table.exit_via(), Some((id, peer(2))));
|
||||
// A member's own address still goes to that member.
|
||||
assert_eq!(
|
||||
table.route(addr(3)),
|
||||
Some(Route {
|
||||
network: id,
|
||||
peer: peer(3)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_exit_node_is_never_the_answer_for_what_is_not_the_internet() {
|
||||
let table = RoutingTable::new();
|
||||
table
|
||||
.set_network(network("client"), with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
// Ourselves, a free address inside the overlay range, and everything
|
||||
// that is not an ordinary unicast destination.
|
||||
for destination in [
|
||||
addr(1),
|
||||
addr(200),
|
||||
Ipv4Addr::new(127, 0, 0, 1),
|
||||
Ipv4Addr::new(224, 0, 0, 251),
|
||||
Ipv4Addr::new(239, 255, 255, 250),
|
||||
Ipv4Addr::BROADCAST,
|
||||
Ipv4Addr::UNSPECIFIED,
|
||||
Ipv4Addr::new(169, 254, 1, 1),
|
||||
] {
|
||||
assert_eq!(table.route(destination), None, "{destination}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_networks_range_is_not_the_internet_either() {
|
||||
let table = RoutingTable::new();
|
||||
table
|
||||
.set_network(network("client"), with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
table
|
||||
.set_network(
|
||||
network("other"),
|
||||
NetworkRoutes {
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(table.route(Ipv4Addr::new(10, 99, 7, 7)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_exit_choices_resolve_to_the_lowest_network_id() {
|
||||
let table = RoutingTable::new();
|
||||
let (a, b) = (network("one"), network("two"));
|
||||
table
|
||||
.set_network(a, with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
table
|
||||
.set_network(
|
||||
b,
|
||||
NetworkRoutes {
|
||||
exit: ExitPolicy {
|
||||
offer: false,
|
||||
via: Some(peer(9)),
|
||||
},
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (chosen, _) = table.exit_via().unwrap();
|
||||
assert_eq!(chosen, a.min(b));
|
||||
assert_eq!(table.route(INTERNET).unwrap().network, a.min(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_exit_peer_may_send_from_any_internet_address_but_nobody_else_may() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("client");
|
||||
table
|
||||
.set_network(id, with_exit(false, Some(peer(2))))
|
||||
.unwrap();
|
||||
|
||||
// What comes back from the internet carries the internet's addresses.
|
||||
assert!(table.may_send_from(id, peer(2), INTERNET));
|
||||
// Another member still speaks only for its own address.
|
||||
assert!(!table.may_send_from(id, peer(3), INTERNET));
|
||||
assert!(table.may_send_from(id, peer(3), addr(3)));
|
||||
// The exit peer cannot pass itself off as somebody else in the
|
||||
// overlay, nor as this agent, nor as something that is not a source.
|
||||
assert!(!table.may_send_from(id, peer(2), addr(3)));
|
||||
assert!(!table.may_send_from(id, peer(2), addr(1)));
|
||||
assert!(!table.may_send_from(id, peer(2), addr(200)));
|
||||
for source in [
|
||||
Ipv4Addr::LOCALHOST,
|
||||
Ipv4Addr::UNSPECIFIED,
|
||||
Ipv4Addr::new(224, 0, 0, 1),
|
||||
Ipv4Addr::BROADCAST,
|
||||
] {
|
||||
assert!(!table.may_send_from(id, peer(2), source), "{source}");
|
||||
}
|
||||
// Without choosing an exit node nobody gets the exception.
|
||||
let plain = RoutingTable::new();
|
||||
plain.set_network(id, routes()).unwrap();
|
||||
assert!(!plain.may_send_from(id, peer(2), INTERNET));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exit_node_takes_a_members_internet_packets_only_while_it_offers() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("server");
|
||||
table.set_network(id, with_exit(false, None)).unwrap();
|
||||
assert!(!table.accepts_exit_traffic(id, INTERNET));
|
||||
|
||||
table.set_exit(
|
||||
id,
|
||||
ExitPolicy {
|
||||
offer: true,
|
||||
via: None,
|
||||
},
|
||||
);
|
||||
assert!(table.accepts_exit_traffic(id, INTERNET));
|
||||
assert!(table.accepts_exit_traffic(id, Ipv4Addr::new(192, 168, 1, 10)));
|
||||
// Never for another member, for this agent, a free overlay address,
|
||||
// or anything that is not an ordinary unicast destination.
|
||||
for destination in [
|
||||
addr(1),
|
||||
addr(2),
|
||||
addr(200),
|
||||
Ipv4Addr::LOCALHOST,
|
||||
Ipv4Addr::new(224, 0, 0, 251),
|
||||
Ipv4Addr::BROADCAST,
|
||||
Ipv4Addr::UNSPECIFIED,
|
||||
] {
|
||||
assert!(
|
||||
!table.accepts_exit_traffic(id, destination),
|
||||
"{destination}"
|
||||
);
|
||||
}
|
||||
// An unknown network offers nothing.
|
||||
assert!(!table.accepts_exit_traffic(network("elsewhere"), INTERNET));
|
||||
|
||||
table.set_exit(id, ExitPolicy::default());
|
||||
assert!(!table.accepts_exit_traffic(id, INTERNET));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offers_are_listed_by_network_with_the_range_each_agreed() {
|
||||
let table = RoutingTable::new();
|
||||
let (a, b) = (network("a"), network("b"));
|
||||
table.set_network(a, with_exit(true, None)).unwrap();
|
||||
table
|
||||
.set_network(
|
||||
b,
|
||||
NetworkRoutes {
|
||||
exit: ExitPolicy {
|
||||
offer: true,
|
||||
via: None,
|
||||
},
|
||||
// No range agreed yet: nothing to masquerade.
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
table.exit_offers(),
|
||||
vec![(a, "10.13.37.0/24".parse().unwrap())]
|
||||
);
|
||||
assert_eq!(
|
||||
table.exit_settings(a),
|
||||
Some((
|
||||
ExitPolicy {
|
||||
offer: true,
|
||||
via: None
|
||||
},
|
||||
Some("10.13.37.0/24".parse().unwrap())
|
||||
))
|
||||
);
|
||||
assert_eq!(table.exit_settings(network("none")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_exit_for_a_network_with_no_entry_is_remembered_by_the_caller() {
|
||||
// The runtime keeps the policy and passes it with the network when
|
||||
// its range is agreed; a setter for a network the table does not know
|
||||
// yet changes nothing.
|
||||
let table = RoutingTable::new();
|
||||
table.set_exit(
|
||||
network("later"),
|
||||
ExitPolicy {
|
||||
offer: true,
|
||||
via: None,
|
||||
},
|
||||
);
|
||||
assert!(table.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
//! The boundary to the operating system's packet interface.
|
||||
//!
|
||||
//! One agent has one of these, and it belongs to the agent rather than to
|
||||
//! any protocol: a packet arriving here is routed to whichever peer owns its
|
||||
//! destination, over whichever protocol has a link to that peer.
|
||||
//!
|
||||
//! Two implementations:
|
||||
//!
|
||||
//! * [`MemoryTun`] keeps packets in memory. It needs no privileges at all and
|
||||
//! is what the test suite uses, so the entire data plane — handshake,
|
||||
//! encryption, routing — is exercised without touching the host.
|
||||
//! * `SystemTun`, behind the `tun-device` feature, is a real TUN interface.
|
||||
//! Creating one needs `CAP_NET_ADMIN`, and it is
|
||||
//! [`provision`](super::provision) that holds that and creates it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
/// What a device should look like once created.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TunRequest {
|
||||
/// Interface name to ask for.
|
||||
pub name: String,
|
||||
/// Every overlay address this host answers to, and no others.
|
||||
///
|
||||
/// One per network the agent is in — one agent has one interface, and
|
||||
/// it carries them all. A list rather than one address because
|
||||
/// carrying only the first is carrying only the first network: the
|
||||
/// others have addresses the operating system has never heard of, and
|
||||
/// their traffic goes nowhere.
|
||||
///
|
||||
/// Allocated and signed at the system level, never derived from a
|
||||
/// protocol's key: every protocol carries traffic for the same
|
||||
/// addresses.
|
||||
pub addresses: Vec<super::Cidr>,
|
||||
/// Interface MTU.
|
||||
pub mtu: u32,
|
||||
}
|
||||
|
||||
impl TunRequest {
|
||||
/// A request carrying nothing but a name and an MTU.
|
||||
///
|
||||
/// Used where the addresses have already been applied to the host, so the
|
||||
/// device itself only needs opening.
|
||||
pub fn bare(name: impl Into<String>, mtu: u32) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
addresses: Vec::new(),
|
||||
mtu,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A packet interface.
|
||||
///
|
||||
/// `recv` yields packets the operating system wants sent; `send` delivers
|
||||
/// packets that arrived from a peer.
|
||||
pub trait TunDevice: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// The interface name the operating system actually gave us.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// The interface MTU.
|
||||
fn mtu(&self) -> u32;
|
||||
|
||||
/// The next packet the operating system wants to send, or `None` once the
|
||||
/// device is gone.
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>>;
|
||||
|
||||
/// Delivers a packet to the operating system.
|
||||
fn send<'a>(&'a self, packet: Bytes) -> BoxFuture<'a, Result<(), OverlayError>>;
|
||||
}
|
||||
|
||||
/// Creates packet interfaces.
|
||||
pub trait TunFactory: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// A short name used in diagnostics.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Whether what this factory creates exists on the host.
|
||||
///
|
||||
/// `false` for an in-memory device: it has a name and an MTU and nothing
|
||||
/// else — no link, no addresses, nothing for the operating system to
|
||||
/// route through or to attach a resolver setting to. Stated rather than
|
||||
/// guessed from the name, because a name is not evidence: an in-memory
|
||||
/// `tsun0` and a real `tsun0` belonging to another agent look identical
|
||||
/// from here, and configuring the operating system for the first would
|
||||
/// land on the second.
|
||||
fn on_host(&self) -> bool;
|
||||
|
||||
/// Creates a device.
|
||||
fn create<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>>;
|
||||
|
||||
/// Applies a changed request to an interface that already exists.
|
||||
///
|
||||
/// The overlay IPv4 address is allocated at run time, so it can change
|
||||
/// while the agent runs. A factory that manages the host applies that to
|
||||
/// the live interface, without recreating it: recreating would drop every
|
||||
/// tunnel riding on it.
|
||||
///
|
||||
/// The default does nothing, which is right for a factory that only
|
||||
/// attaches to an interface somebody else prepared.
|
||||
fn reconfigure<'a>(&'a self, _request: TunRequest) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move { Ok(()) })
|
||||
}
|
||||
|
||||
/// The host rules for this factory's interface, when it manages the host.
|
||||
///
|
||||
/// `None` for an in-memory device or an interface somebody else prepared:
|
||||
/// there is nothing of ours to route or to open in a firewall.
|
||||
fn host_rules(&self) -> Option<Arc<dyn super::hostrules::BroadcastHostRules>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Removes an interface this factory created.
|
||||
///
|
||||
/// Runs on the teardown path, so it reports rather than fails: there is
|
||||
/// nothing useful to do about a failure at that point, and an interface
|
||||
/// that is already gone is the desired outcome anyway.
|
||||
fn destroy<'a>(&'a self, _name: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {})
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-memory packet interface.
|
||||
///
|
||||
/// Nothing reaches the operating system. Packets the device "sends" can be
|
||||
/// read back with [`MemoryTun::pop_to_os`], and packets can be injected as if
|
||||
/// the operating system produced them with [`MemoryTun::push_from_os`].
|
||||
#[derive(Debug)]
|
||||
pub struct MemoryTun {
|
||||
name: String,
|
||||
mtu: u32,
|
||||
from_os_tx: tokio::sync::mpsc::UnboundedSender<Bytes>,
|
||||
from_os_rx: tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<Bytes>>,
|
||||
to_os_tx: tokio::sync::mpsc::UnboundedSender<Bytes>,
|
||||
to_os_rx: tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<Bytes>>,
|
||||
/// Set once the device is gone, so a reader stops rather than waiting
|
||||
/// for a packet that will never come.
|
||||
///
|
||||
/// A `watch` rather than a `Notify`: closing must be seen by a reader
|
||||
/// that has not started waiting yet as well as by one already waiting —
|
||||
/// a device can be removed before the loop reading it has been polled
|
||||
/// even once. The receiver is kept for the same reason: a `watch` send
|
||||
/// with nobody subscribed does nothing at all.
|
||||
closed: tokio::sync::watch::Sender<bool>,
|
||||
closed_rx: tokio::sync::watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
impl MemoryTun {
|
||||
/// Creates a device with the given name and MTU.
|
||||
pub fn new(name: impl Into<String>, mtu: u32) -> Arc<Self> {
|
||||
let (from_os_tx, from_os_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (to_os_tx, to_os_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (closed, closed_rx) = tokio::sync::watch::channel(false);
|
||||
Arc::new(Self {
|
||||
name: name.into(),
|
||||
mtu,
|
||||
from_os_tx,
|
||||
from_os_rx: tokio::sync::Mutex::new(from_os_rx),
|
||||
to_os_tx,
|
||||
to_os_rx: tokio::sync::Mutex::new(to_os_rx),
|
||||
closed,
|
||||
closed_rx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Marks the device gone, so the packet loop reading it ends.
|
||||
///
|
||||
/// A real interface reports end of stream when it is removed; this is
|
||||
/// how that happens here. Without it teardown waits out the whole grace
|
||||
/// period for a reader that had no way of knowing.
|
||||
pub fn close(&self) {
|
||||
let _ = self.closed.send(true);
|
||||
}
|
||||
|
||||
/// Injects a packet as if the operating system had produced it.
|
||||
pub fn push_from_os(&self, packet: Bytes) {
|
||||
let _ = self.from_os_tx.send(packet);
|
||||
}
|
||||
|
||||
/// Takes the next packet the device delivered to the operating system.
|
||||
pub async fn pop_to_os(&self) -> Option<Bytes> {
|
||||
self.to_os_rx.lock().await.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
impl TunDevice for MemoryTun {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn mtu(&self) -> u32 {
|
||||
self.mtu
|
||||
}
|
||||
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move {
|
||||
let mut closed = self.closed_rx.clone();
|
||||
if *closed.borrow_and_update() {
|
||||
return None;
|
||||
}
|
||||
let mut queue = self.from_os_rx.lock().await;
|
||||
tokio::select! {
|
||||
packet = queue.recv() => packet,
|
||||
_ = closed.changed() => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn send<'a>(&'a self, packet: Bytes) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move {
|
||||
let _ = self.to_os_tx.send(packet);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an address is assigned to some interface on this host.
|
||||
///
|
||||
/// Binding a UDP socket to a specific address only succeeds when the address
|
||||
/// is local, which makes this a cheap check that needs no privileges and no
|
||||
/// platform-specific code. It does not say *which* interface has it, which is
|
||||
/// enough here: the agent chose the address, so anything else holding it is a
|
||||
/// problem in its own right.
|
||||
pub fn address_is_local(address: std::net::IpAddr) -> bool {
|
||||
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.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MemoryTunFactory {
|
||||
created: Arc<std::sync::Mutex<Vec<Arc<MemoryTun>>>>,
|
||||
}
|
||||
|
||||
impl MemoryTunFactory {
|
||||
/// Creates a factory.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The device created for an interface name, if any.
|
||||
pub fn device(&self, name: &str) -> Option<Arc<MemoryTun>> {
|
||||
let guard = match self.created.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
guard
|
||||
.iter()
|
||||
.find(|device| device.name() == name)
|
||||
.map(Arc::clone)
|
||||
}
|
||||
|
||||
/// Every device created so far.
|
||||
pub fn devices(&self) -> Vec<Arc<MemoryTun>> {
|
||||
let guard = match self.created.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
guard.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl TunFactory for MemoryTunFactory {
|
||||
fn name(&self) -> &str {
|
||||
"memory"
|
||||
}
|
||||
|
||||
/// Nothing here is on the host, which is the whole point of it.
|
||||
fn on_host(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Closes the device so whatever is reading it stops.
|
||||
///
|
||||
/// The device itself is kept, because a test asks what went through it
|
||||
/// after the agent that owned it has gone.
|
||||
fn destroy<'a>(&'a self, name: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
if let Some(device) = self.device(name) {
|
||||
device.close();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>> {
|
||||
Box::pin(async move {
|
||||
let device = MemoryTun::new(request.name, request.mtu);
|
||||
let mut guard = match self.created.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
guard.push(Arc::clone(&device));
|
||||
Ok(device as Arc<dyn TunDevice>)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "tun-device")]
|
||||
pub(crate) use system::open_tun;
|
||||
|
||||
#[cfg(feature = "tun-device")]
|
||||
mod system {
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::{TunDevice, TunRequest};
|
||||
use crate::BoxFuture;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
/// A real TUN interface.
|
||||
///
|
||||
/// On Linux it is created by opening `/dev/net/tun`, which needs
|
||||
/// `CAP_NET_ADMIN`; on Windows it is a Wintun adapter, which needs the
|
||||
/// process to be elevated and `wintun.dll` to be reachable. Either way
|
||||
/// [`open_tun`] is only ever called from
|
||||
/// [`provision`](super::super::provision), which holds whatever privilege
|
||||
/// the platform requires for the length of the call and no longer.
|
||||
///
|
||||
/// It is deliberately **not** made persistent, so the operating system
|
||||
/// removes the interface when this value is dropped — however the process
|
||||
/// ends. (A Wintun adapter created this way is likewise torn down when the
|
||||
/// last handle to it closes, which the reader and writer below hold.)
|
||||
pub struct SystemTun {
|
||||
name: String,
|
||||
mtu: u32,
|
||||
reader: Mutex<tokio::io::ReadHalf<tun::AsyncDevice>>,
|
||||
writer: Mutex<tokio::io::WriteHalf<tun::AsyncDevice>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SystemTun {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SystemTun")
|
||||
.field("name", &self.name)
|
||||
.field("mtu", &self.mtu)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TunDevice for SystemTun {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn mtu(&self) -> u32 {
|
||||
self.mtu
|
||||
}
|
||||
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut buffer = vec![0u8; self.mtu as usize + 64];
|
||||
let mut reader = self.reader.lock().await;
|
||||
match reader.read(&mut buffer).await {
|
||||
Ok(0) => None,
|
||||
Ok(read) => {
|
||||
buffer.truncate(read);
|
||||
Some(Bytes::from(buffer))
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "tun read failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn send<'a>(&'a self, packet: Bytes) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut writer = self.writer.lock().await;
|
||||
writer
|
||||
.write_all(&packet)
|
||||
.await
|
||||
.map_err(|err| OverlayError::Other(format!("tun write failed: {err}")))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the TUN interface by opening it.
|
||||
///
|
||||
/// Synchronous, and deliberately so: the caller holds a capability guard
|
||||
/// across this call, and such a guard must not span an `await` because
|
||||
/// Linux capabilities are per thread.
|
||||
pub(crate) fn open_tun(request: &TunRequest) -> Result<Arc<dyn TunDevice>, OverlayError> {
|
||||
// macOS mutates neither field below (it neither names the interface nor
|
||||
// sets a platform config), so `mut` is unused only there.
|
||||
#[cfg_attr(target_os = "macos", allow(unused_mut))]
|
||||
let mut config = tun::Configuration::default();
|
||||
// macOS names its tunnels `utunN` and assigns the unit number itself.
|
||||
// A name derived from the network id (`tsun…`) is not a legal utun
|
||||
// name, so there the kernel is left to choose and the real name is read
|
||||
// back below. Linux and Windows take the requested name verbatim.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
config.tun_name(&request.name);
|
||||
#[cfg(target_os = "linux")]
|
||||
config.platform_config(|platform| {
|
||||
// The crate's own root check is not the check we want: this holds
|
||||
// CAP_NET_ADMIN without being root. Whether the open succeeds is
|
||||
// the honest answer.
|
||||
platform.ensure_root_privileges(false);
|
||||
});
|
||||
// Packet information stays off, so reads and writes are raw IP
|
||||
// packets. `ip tuntap add ... mode tun` also defaults to no packet
|
||||
// information, so the flags match when attaching to one. The address
|
||||
// and MTU are left off the configuration on purpose: the provisioner
|
||||
// applies those to the interface separately, the same way on every
|
||||
// platform, so the plan stays the single source of what it carries.
|
||||
|
||||
let device = tun::create_as_async(&config).map_err(|err| open_error(&request.name, err))?;
|
||||
|
||||
// The interface name the operating system actually gave us: the
|
||||
// requested one everywhere except macOS, where the kernel chose the
|
||||
// utun unit and so the real name has to be read back off the device.
|
||||
#[cfg(target_os = "macos")]
|
||||
let name = {
|
||||
use tun::AbstractDevice;
|
||||
device.tun_name().unwrap_or_else(|_| request.name.clone())
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let name = request.name.clone();
|
||||
|
||||
let (reader, writer) = tokio::io::split(device);
|
||||
Ok(Arc::new(SystemTun {
|
||||
name,
|
||||
mtu: request.mtu,
|
||||
reader: Mutex::new(reader),
|
||||
writer: Mutex::new(writer),
|
||||
}) as Arc<dyn TunDevice>)
|
||||
}
|
||||
|
||||
fn open_error(name: &str, error: tun::Error) -> OverlayError {
|
||||
// Wintun preserves the Windows code in its I/O variant. Normalize it
|
||||
// before testing permissions; matching localized error text would
|
||||
// miss the same failure on another Windows language.
|
||||
#[cfg(target_os = "windows")]
|
||||
let error = match error {
|
||||
tun::Error::WintunError(error) => tun::Error::Io(error.into()),
|
||||
error => error,
|
||||
};
|
||||
let denied = match &error {
|
||||
tun::Error::Io(error) => {
|
||||
error.kind() == std::io::ErrorKind::PermissionDenied
|
||||
|| (cfg!(target_os = "windows")
|
||||
&& matches!(error.raw_os_error(), Some(5 | 740 | 1314)))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if denied {
|
||||
let hint = if cfg!(target_os = "windows") {
|
||||
"Open PowerShell or Command Prompt with 'Run as administrator' and \
|
||||
start `tsng up` there."
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"Start `tsng up` as root or grant CAP_NET_ADMIN with \
|
||||
`sudo setcap cap_net_admin,cap_net_bind_service+p /path/to/tsunagi`."
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"Creating a utun interface needs root on macOS; start `tsng up` \
|
||||
with `sudo`, or install it as a root LaunchDaemon."
|
||||
} else {
|
||||
"Start `tsng up` with the privileges required to create a TUN interface."
|
||||
};
|
||||
return OverlayError::Unavailable(format!(
|
||||
"permission denied while creating the TUN interface `{name}`. \
|
||||
{hint} OS error: {error}"
|
||||
));
|
||||
}
|
||||
OverlayError::Unavailable(format!(
|
||||
"cannot create the TUN interface `{name}`: {error}. {}",
|
||||
open_hint()
|
||||
))
|
||||
}
|
||||
|
||||
/// The platform-appropriate tail of a "cannot create the interface" error:
|
||||
/// what privilege it needs and the fallback that always works.
|
||||
fn open_hint() -> &'static str {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
"Creating one needs CAP_NET_ADMIN; grant it with `setcap cap_net_admin,cap_net_bind_service+p`, \
|
||||
or run with `--no-tun` to keep the tunnels off the operating system."
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
"Creating one needs an elevated process and `wintun.dll` on the search path; \
|
||||
run as Administrator with the DLL beside the executable, or run with `--no-tun` \
|
||||
to keep the tunnels off the operating system."
|
||||
}
|
||||
#[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."
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_tun_permission_error_explains_how_to_start_the_agent() {
|
||||
let error = tun::Error::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
|
||||
let message = open_error("tsun0", error).to_string();
|
||||
assert!(message.contains("permission denied"), "{message}");
|
||||
assert!(message.contains("tsun0"), "{message}");
|
||||
assert!(message.contains("tsng up"), "{message}");
|
||||
#[cfg(target_os = "windows")]
|
||||
assert!(message.contains("Run as administrator"), "{message}");
|
||||
#[cfg(target_os = "linux")]
|
||||
assert!(message.contains("CAP_NET_ADMIN"), "{message}");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn wintun_access_and_elevation_errors_get_the_permission_hint() {
|
||||
for code in [5, 740, 1314] {
|
||||
let error = tun::Error::WintunError(std::io::Error::from_raw_os_error(code).into());
|
||||
let message = open_error("tsun0", error).to_string();
|
||||
assert!(message.contains("permission denied"), "{message}");
|
||||
assert!(message.contains("Run as administrator"), "{message}");
|
||||
assert!(message.contains(&format!("os error {code}")), "{message}");
|
||||
assert!(!message.contains("wintun.dll"), "{message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_tun_errors_keep_their_cause_without_claiming_access_was_denied() {
|
||||
let message = open_error("tsun0", tun::Error::InvalidName).to_string();
|
||||
assert!(message.contains("invalid device tun name"), "{message}");
|
||||
assert!(!message.contains("permission denied"), "{message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -20,7 +20,7 @@ use crate::error::ProtocolError;
|
||||
/// The version in the ALPN is the wire-compatibility version of the control
|
||||
/// protocol. It is independent of the network identity scheme version, so
|
||||
/// bumping it must not change any existing [`crate::NetworkId`].
|
||||
pub const ALPN: &[u8] = b"tsunagi/ctrl/1";
|
||||
pub const ALPN: &[u8] = b"tsunagi/ctrl/4";
|
||||
|
||||
/// ALPN of the tsunagi data plane.
|
||||
///
|
||||
@@ -28,7 +28,11 @@ pub const ALPN: &[u8] = b"tsunagi/ctrl/1";
|
||||
/// They carry one IP plugin's packets for one network and nothing else, so a
|
||||
/// saturated or broken data plane cannot disturb control traffic, and the
|
||||
/// transport underneath can be replaced without touching the control protocol.
|
||||
pub const DATA_ALPN: &[u8] = b"tsunagi/data/1";
|
||||
///
|
||||
/// Version 4 adds source, destination, hop limit and stable flow id above
|
||||
/// transport fragmentation. All members must upgrade together. Persistent
|
||||
/// identities and network configuration do not change.
|
||||
pub const DATA_ALPN: &[u8] = b"tsunagi/data/4";
|
||||
|
||||
/// Largest plugin protocol identifier accepted when opening a data channel.
|
||||
pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
|
||||
@@ -37,7 +41,7 @@ pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
|
||||
pub const MAX_SIGNATURE_LEN: usize = 64;
|
||||
|
||||
/// Control protocol version carried inside the handshake.
|
||||
pub const PROTOCOL_VERSION: u16 = 1;
|
||||
pub const PROTOCOL_VERSION: u16 = 4;
|
||||
|
||||
/// First message of the handshake, sent by the initiator.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -69,6 +73,13 @@ pub struct AuthProof {
|
||||
/// What this agent tells a peer about itself.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Announcement {
|
||||
/// Local opt-in to receive LAN broadcasts in this network.
|
||||
pub broadcast: bool,
|
||||
/// This agent offers itself as an exit node in this network: members may
|
||||
/// send all their internet traffic through it. Off unless its owner
|
||||
/// turned it on, and only said once the rules that make it work are in
|
||||
/// place.
|
||||
pub exit_node: bool,
|
||||
/// Human-readable hostname. A mutable binding, not an identity.
|
||||
pub hostname: String,
|
||||
/// Announced IP plugin capabilities. Opaque to the core.
|
||||
@@ -81,6 +92,8 @@ pub struct Announcement {
|
||||
pub struct DataOpen {
|
||||
/// Which IP plugin's packets this channel will carry.
|
||||
pub protocol: String,
|
||||
/// Maximum reassembled payload the initiator is willing to receive.
|
||||
pub max_datagram: u32,
|
||||
}
|
||||
|
||||
/// The responder's answer to [`DataOpen`].
|
||||
@@ -124,6 +137,32 @@ pub enum ControlMessage {
|
||||
/// The records. Bounded by [`crate::config::Limits::max_state_records`].
|
||||
records: Vec<crate::state::SignedRecord>,
|
||||
},
|
||||
/// Members this sender knows of, and where it has seen them.
|
||||
///
|
||||
/// An introduction, not a vouching: everything here is an unverified
|
||||
/// candidate, and membership is still decided by the handshake. It is
|
||||
/// what turns a star around whoever was named on the command line into
|
||||
/// a mesh — a device that was told about one member ends up talking to
|
||||
/// all of them — and it is the same shape a lookup in a distributed
|
||||
/// hash table would return.
|
||||
Peers {
|
||||
/// The members, with whatever addresses the sender has for them.
|
||||
peers: Vec<PeerHint>,
|
||||
},
|
||||
/// Which peers this sender has a live data link with, right now.
|
||||
///
|
||||
/// First-hand and nothing else: a sender speaks only for itself, never
|
||||
/// about what somebody else can reach. That is what makes it usable
|
||||
/// without weighing hearsay — the claim is proved or disproved by
|
||||
/// sending through it.
|
||||
///
|
||||
/// A snapshot rather than a change, because a snapshot is idempotent
|
||||
/// and repairs itself; and volatile, so it lives here and not in
|
||||
/// signed state, which is for what has to survive a member being away.
|
||||
Reachable {
|
||||
/// Direct links, scoped to the plugin protocol they actually carry.
|
||||
links: Vec<ReachableLink>,
|
||||
},
|
||||
/// Graceful goodbye.
|
||||
///
|
||||
/// A peer going away is not a revocation of anything.
|
||||
@@ -133,6 +172,38 @@ pub enum ControlMessage {
|
||||
},
|
||||
}
|
||||
|
||||
/// Somewhere a member has been seen.
|
||||
///
|
||||
/// Addresses are in iroh's own text form, which is what the endpoint takes
|
||||
/// back: this is a hint to try, never a fact about where anybody is.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PeerHint {
|
||||
/// The member's endpoint key.
|
||||
pub endpoint: [u8; 32],
|
||||
/// Where the sender has seen it, as `ip:<socket>` or `relay:<url>`.
|
||||
///
|
||||
/// The same spelling the disposable cache uses, so one decoder serves
|
||||
/// both and neither can drift from the other. An empty list is still
|
||||
/// worth sending: an id alone is enough for a receiver whose endpoint
|
||||
/// can resolve it.
|
||||
pub addrs: Vec<String>,
|
||||
}
|
||||
|
||||
/// One directly observed edge, advertised only by its authenticated source.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ReachableLink {
|
||||
/// The other end of this transport link.
|
||||
pub peer: [u8; 32],
|
||||
/// Protocol carried by the link.
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
/// Longest address text accepted in a hint.
|
||||
pub const MAX_HINT_ADDR_LEN: usize = 128;
|
||||
|
||||
/// Most addresses accepted for one member in a hint.
|
||||
pub const MAX_HINT_ADDRS: usize = 8;
|
||||
|
||||
/// A control message together with the network it belongs to.
|
||||
///
|
||||
/// Every session is bound to exactly one network at handshake time. The
|
||||
@@ -191,6 +262,28 @@ pub fn validate_capability(
|
||||
/// network, the other networks or the agent.
|
||||
pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), ProtocolError> {
|
||||
match message {
|
||||
ControlMessage::Peers { peers } => {
|
||||
check_len("peers", peers.len(), limits.max_state_records)?;
|
||||
for hint in peers {
|
||||
check_len("peers.addrs", hint.addrs.len(), MAX_HINT_ADDRS)?;
|
||||
for addr in &hint.addrs {
|
||||
check_len("peers.addr", addr.len(), MAX_HINT_ADDR_LEN)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
ControlMessage::Reachable { links } => {
|
||||
check_len("reachable.links", links.len(), limits.max_state_records)?;
|
||||
for link in links {
|
||||
if link.protocol.is_empty() {
|
||||
return Err(ProtocolError::Malformed("empty routing protocol"));
|
||||
}
|
||||
check_len(
|
||||
"reachable.protocol",
|
||||
link.protocol.len(),
|
||||
MAX_PROTOCOL_ID_LEN,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
ControlMessage::Announce(announcement) => {
|
||||
check_len(
|
||||
"announce.hostname",
|
||||
@@ -229,6 +322,8 @@ pub fn kind(message: &ControlMessage) -> &'static str {
|
||||
ControlMessage::Ping { .. } => "ping",
|
||||
ControlMessage::Pong { .. } => "pong",
|
||||
ControlMessage::State { .. } => "state",
|
||||
ControlMessage::Reachable { .. } => "reachable",
|
||||
ControlMessage::Peers { .. } => "peers",
|
||||
ControlMessage::Bye { .. } => "bye",
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -120,6 +120,35 @@ pub const DEFAULT_IPV4_RANGE: Ipv4Range = Ipv4Range {
|
||||
prefix_len: 24,
|
||||
};
|
||||
|
||||
/// A range derived from a network's own identity.
|
||||
///
|
||||
/// One agent has one interface, so two of its networks cannot both use the
|
||||
/// configured range. The second needs another one — and it cannot be picked
|
||||
/// locally, because every member has to arrive at the same answer without
|
||||
/// being told. So it comes from the network id, which every member already
|
||||
/// has and nobody can choose: the same network derives the same range on
|
||||
/// every device.
|
||||
///
|
||||
/// Inside 10/8 like the default, and never equal to it, so the first
|
||||
/// network keeps what it has always had.
|
||||
pub fn derived_ipv4_range(network: NetworkId) -> Ipv4Range {
|
||||
let bytes = network.as_bytes();
|
||||
let second = bytes[0];
|
||||
let third = bytes[1];
|
||||
let candidate = Ipv4Addr::new(10, second, third, 0);
|
||||
// The default is somebody's already. One step along is still derived
|
||||
// from the id and still the same everywhere.
|
||||
let base = if candidate == DEFAULT_IPV4_RANGE.base {
|
||||
Ipv4Addr::new(10, second, third.wrapping_add(1), 0)
|
||||
} else {
|
||||
candidate
|
||||
};
|
||||
Ipv4Range {
|
||||
base,
|
||||
prefix_len: 24,
|
||||
}
|
||||
}
|
||||
|
||||
/// Frozen domain separator for the bytes a record signature covers.
|
||||
pub const RECORD_DOMAIN: &str = "tsunagi-signed-record-v2";
|
||||
|
||||
@@ -1053,4 +1082,32 @@ mod tests {
|
||||
SignedRecord::canonical_bytes(network("other"), author, 1, &claim("10.13.37.5"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_derived_range_is_the_same_wherever_it_is_derived() {
|
||||
// Every member has to arrive at it without being told, so it comes
|
||||
// from the one thing they all already agree on.
|
||||
let network = NetworkKeys::derive(
|
||||
&NetworkName::new("second").unwrap(),
|
||||
&NetworkSecret::from_bytes([7u8; 32]).unwrap(),
|
||||
)
|
||||
.network_id();
|
||||
let once = derived_ipv4_range(network);
|
||||
assert_eq!(once, derived_ipv4_range(network));
|
||||
assert_eq!(once.prefix_len, 24);
|
||||
assert_eq!(once.base.octets()[0], 10, "inside 10/8 like the default");
|
||||
assert_ne!(
|
||||
once, DEFAULT_IPV4_RANGE,
|
||||
"the default belongs to whichever network asked first"
|
||||
);
|
||||
|
||||
// A different network derives a different range, which is the
|
||||
// whole point of deriving it.
|
||||
let other = NetworkKeys::derive(
|
||||
&NetworkName::new("third").unwrap(),
|
||||
&NetworkSecret::from_bytes([9u8; 32]).unwrap(),
|
||||
)
|
||||
.network_id();
|
||||
assert_ne!(once, derived_ipv4_range(other));
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -56,8 +56,13 @@ fn restrict_permissions(_file: &std::fs::File, _path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restricts a file to the user that owns it.
|
||||
///
|
||||
/// Part of what the system level offers a protocol: a plugin keeping keys of
|
||||
/// its own on disk has the same obligation as the agent, and should not have
|
||||
/// to work out the platform details again to meet it.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn restrict_path_permissions(path: &Path) -> Result<()> {
|
||||
pub fn restrict_path_permissions(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
|
||||
Error::Io {
|
||||
@@ -67,13 +72,15 @@ pub(crate) fn restrict_path_permissions(path: &Path) -> Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Restricts a file to the user that owns it. A no-op off Unix.
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) fn restrict_path_permissions(_path: &Path) -> Result<()> {
|
||||
pub fn restrict_path_permissions(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restricts a directory to the user that owns it.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn restrict_dir_permissions(path: &Path) -> Result<()> {
|
||||
pub fn restrict_dir_permissions(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| {
|
||||
Error::Io {
|
||||
@@ -83,12 +90,14 @@ pub(crate) fn restrict_dir_permissions(path: &Path) -> Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Restricts a directory to the user that owns it. A no-op off Unix.
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) fn restrict_dir_permissions(_path: &Path) -> Result<()> {
|
||||
pub fn restrict_dir_permissions(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn create_dir(path: &Path) -> Result<()> {
|
||||
/// Creates a directory and every parent, restricted to this user.
|
||||
pub fn create_dir(path: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(path).map_err(|source| Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
@@ -239,6 +248,24 @@ impl Storage {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persists local broadcast participation for one configured network.
|
||||
pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
self.with_state(move |state| state.set_broadcast(network_id, enabled))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persists whether this agent offers itself as an exit node in a network.
|
||||
pub async fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
self.with_state(move |state| state.set_exit_node(network_id, enabled))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persists the member this device sends its internet traffic through.
|
||||
pub async fn set_exit_via(&self, network_id: NetworkId, via: Option<[u8; 32]>) -> Result<()> {
|
||||
self.with_state(move |state| state.set_exit_via(network_id, via.as_ref()))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Updates the auto-start flag of a network.
|
||||
pub async fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
|
||||
self.with_state(move |state| state.set_auto_start(network_id, auto_start))
|
||||
@@ -249,7 +276,8 @@ impl Storage {
|
||||
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.with_state(move |state| {
|
||||
state.remove_network(network_id)?;
|
||||
state.forget_signed_records(network_id)
|
||||
state.forget_signed_records(network_id)?;
|
||||
state.forget_peer_hostnames(network_id)
|
||||
})
|
||||
.await?;
|
||||
self.with_cache((), move |cache| cache.forget_network(network_id))
|
||||
@@ -268,6 +296,26 @@ impl Storage {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Remembers the hostname a member announced, so it can still be named
|
||||
/// once it is away.
|
||||
pub async fn remember_peer_hostname(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
endpoint_id: [u8; 32],
|
||||
hostname: String,
|
||||
) -> Result<()> {
|
||||
self.with_state(move |state| {
|
||||
state.remember_peer_hostname(network_id, &endpoint_id, &hostname)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The hostnames members of a network last announced.
|
||||
pub async fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
|
||||
self.with_state(move |state| state.peer_hostnames(network_id))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Records an address hint. Failures are non-fatal.
|
||||
pub async fn record_hint(
|
||||
&self,
|
||||
@@ -341,3 +389,137 @@ impl Storage {
|
||||
guard.take();
|
||||
}
|
||||
}
|
||||
|
||||
/// What a wipe covers: every entry in the two directories.
|
||||
///
|
||||
/// Reported before anything is removed, and again afterwards, because a
|
||||
/// command whose whole job is to destroy state has to be able to say exactly
|
||||
/// what it is about to destroy.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct WipePlan {
|
||||
/// Entries in the state directory, which is the mandatory state and
|
||||
/// anything a plugin keeps beside it.
|
||||
pub state: Vec<std::path::PathBuf>,
|
||||
/// Entries in the cache directory.
|
||||
pub cache: Vec<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl WipePlan {
|
||||
/// Whether there is anything to remove at all.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.state.is_empty() && self.cache.is_empty()
|
||||
}
|
||||
|
||||
/// Every entry, state first.
|
||||
pub fn entries(&self) -> impl Iterator<Item = &std::path::Path> {
|
||||
self.state
|
||||
.iter()
|
||||
.chain(self.cache.iter())
|
||||
.map(std::path::PathBuf::as_path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists what [`wipe`] would remove, without removing anything.
|
||||
pub fn wipe_plan(paths: &StoragePaths) -> Result<WipePlan> {
|
||||
Ok(WipePlan {
|
||||
state: removable(&paths.state_dir, &paths.state_db())?,
|
||||
cache: removable(&paths.cache_dir, &paths.cache_db())?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes everything in both directories, leaving this device a stranger.
|
||||
///
|
||||
/// The device identity, every network it belongs to, every signed record and
|
||||
/// everything a plugin kept beside them. The next start generates a new
|
||||
/// identity and knows nothing, which is the point: after this there is no
|
||||
/// membership left to be surprised by.
|
||||
///
|
||||
/// What it is **not** is a goodbye. Other members keep the claims this device
|
||||
/// signed, because signed state has no expiry and there is nobody left here
|
||||
/// to sign a release. Leaving each network first with
|
||||
/// [`crate::Agent::leave_network`] is what frees the address and the name.
|
||||
///
|
||||
/// Refuses while an agent owns the state directory, and refuses a directory
|
||||
/// with no `state.sqlite` in it — a wipe pointed at the wrong place by a
|
||||
/// mistyped `--state-dir` would otherwise remove somebody's documents.
|
||||
pub fn wipe(paths: &StoragePaths) -> Result<WipePlan> {
|
||||
let plan = wipe_plan(paths)?;
|
||||
// Held across the removal, so an agent cannot start into a directory
|
||||
// that is half gone. The lock file is itself removed at the end: on
|
||||
// Unix the lock lives on the open handle, not on the name.
|
||||
let lock = DirectoryLock::acquire(paths.lock_file())?;
|
||||
|
||||
for entry in plan.entries() {
|
||||
remove_entry(entry)?;
|
||||
}
|
||||
drop(lock);
|
||||
// Created by acquiring the lock a moment ago, so it is ours to remove
|
||||
// and nothing is left behind claiming the directory.
|
||||
let _ = std::fs::remove_file(paths.lock_file());
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// Every entry of `dir`, once `marker` proves it is one of ours.
|
||||
///
|
||||
/// An empty or missing directory is nothing to remove rather than an error:
|
||||
/// wiping twice must be as ordinary as wiping once.
|
||||
fn removable(dir: &Path, marker: &Path) -> Result<Vec<std::path::PathBuf>> {
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let read = std::fs::read_dir(dir).map_err(|source| Error::Io {
|
||||
path: dir.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
for entry in read {
|
||||
let entry = entry.map_err(|source| Error::Io {
|
||||
path: dir.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
entries.push(entry.path());
|
||||
}
|
||||
if entries.is_empty() {
|
||||
return Ok(entries);
|
||||
}
|
||||
|
||||
// The marker is what tells a state directory from a directory that
|
||||
// merely got named as one. The lock file counts too: an agent that
|
||||
// crashed before writing anything still leaves that.
|
||||
let ours = marker.exists() || dir.join("state.lock").exists();
|
||||
if !ours {
|
||||
return Err(Error::Storage(format!(
|
||||
"{} does not look like a tsunagi directory: no {} in it. Nothing was removed.",
|
||||
dir.display(),
|
||||
marker.file_name().map_or_else(
|
||||
|| marker.display().to_string(),
|
||||
|name| name.to_string_lossy().into_owned()
|
||||
)
|
||||
)));
|
||||
}
|
||||
entries.sort();
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn remove_entry(path: &Path) -> Result<()> {
|
||||
let metadata = match std::fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
// Gone between the plan and the removal is the outcome asked for.
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(source) => {
|
||||
return Err(Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
};
|
||||
let removed = if metadata.is_dir() {
|
||||
std::fs::remove_dir_all(path)
|
||||
} else {
|
||||
std::fs::remove_file(path)
|
||||
};
|
||||
removed.map_err(|source| Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
|
||||
use crate::state::{RecordBody, SignedRecord};
|
||||
|
||||
/// Schema version written by this build.
|
||||
pub const SCHEMA_VERSION: i64 = 3;
|
||||
pub const SCHEMA_VERSION: i64 = 6;
|
||||
|
||||
/// Key of the stored hostname setting.
|
||||
const SETTING_HOSTNAME: &str = "hostname";
|
||||
@@ -40,6 +40,12 @@ pub struct StoredNetwork {
|
||||
pub secret: NetworkSecret,
|
||||
/// Whether the network is activated automatically at agent startup.
|
||||
pub auto_start: bool,
|
||||
/// Local broadcast participation, enabled unless explicitly disabled.
|
||||
pub broadcast: bool,
|
||||
/// This agent offers itself as an exit node here. Off unless turned on.
|
||||
pub exit_node: bool,
|
||||
/// The member this device sends all its internet traffic through, if any.
|
||||
pub exit_via: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// The mandatory state store.
|
||||
@@ -183,13 +189,51 @@ impl StateStore {
|
||||
.execute_batch(SIGNED_RECORDS_SCHEMA)
|
||||
.map_err(|err| self.corrupt(format!("cannot create schema: {err}")))?;
|
||||
}
|
||||
if found < 4 {
|
||||
self.conn.execute_batch("BEGIN;
|
||||
ALTER TABLE networks ADD COLUMN broadcast INTEGER NOT NULL DEFAULT 1 CHECK (broadcast IN (0,1));
|
||||
PRAGMA user_version = 4;
|
||||
COMMIT;").map_err(|err| self.corrupt(format!("cannot migrate schema to 4: {err}")))?;
|
||||
}
|
||||
if found < 5 {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
"BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS peer_hostnames (
|
||||
network_id BLOB NOT NULL,
|
||||
endpoint_id BLOB NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
PRIMARY KEY (network_id, endpoint_id)
|
||||
);
|
||||
PRAGMA user_version = 5;
|
||||
COMMIT;",
|
||||
)
|
||||
.map_err(|err| self.corrupt(format!("cannot migrate schema to 5: {err}")))?;
|
||||
}
|
||||
if found < 6 {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
"BEGIN;
|
||||
ALTER TABLE networks ADD COLUMN exit_node INTEGER NOT NULL DEFAULT 0 CHECK (exit_node IN (0,1));
|
||||
ALTER TABLE networks ADD COLUMN exit_via BLOB;
|
||||
PRAGMA user_version = 6;
|
||||
COMMIT;",
|
||||
)
|
||||
.map_err(|err| self.corrupt(format!("cannot migrate schema to 6: {err}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confirms the expected tables exist, so that a truncated or foreign
|
||||
/// database is reported rather than used.
|
||||
fn verify_shape(&self) -> Result<()> {
|
||||
for table in ["device_identity", "networks", "settings", "signed_records"] {
|
||||
for table in [
|
||||
"device_identity",
|
||||
"networks",
|
||||
"settings",
|
||||
"signed_records",
|
||||
"peer_hostnames",
|
||||
] {
|
||||
let present: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
@@ -396,6 +440,21 @@ impl StateStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves local participation without changing identity, membership or auto-start.
|
||||
pub fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
let changed = self
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE networks SET broadcast = ?2 WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice(), enabled as i64],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot update broadcast policy: {err}")))?;
|
||||
if changed == 0 {
|
||||
return Err(Error::NetworkUnknown(network_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a network configuration entirely.
|
||||
pub fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.conn
|
||||
@@ -407,11 +466,56 @@ impl StateStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves whether this agent offers itself as an exit node in a network.
|
||||
pub fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
let changed = self
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE networks SET exit_node = ?2 WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice(), enabled as i64],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot update exit node offer: {err}")))?;
|
||||
if changed == 0 {
|
||||
return Err(Error::NetworkUnknown(network_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves which member this device sends its internet traffic through.
|
||||
///
|
||||
/// One device has one default route, so choosing an exit node in one
|
||||
/// network clears the choice in every other.
|
||||
pub fn set_exit_via(&self, network_id: NetworkId, via: Option<&[u8; 32]>) -> Result<()> {
|
||||
let tx = self
|
||||
.conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
||||
let changed = tx
|
||||
.execute(
|
||||
"UPDATE networks SET exit_via = ?2 WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice(), via.map(|v| v.as_slice())],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
||||
if changed == 0 {
|
||||
return Err(Error::NetworkUnknown(network_id));
|
||||
}
|
||||
if via.is_some() {
|
||||
tx.execute(
|
||||
"UPDATE networks SET exit_via = NULL WHERE network_id != ?1",
|
||||
params![network_id.as_bytes().as_slice()],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lists every configured network.
|
||||
pub fn list_networks(&self) -> Result<Vec<StoredNetwork>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT network_id, name, secret, auto_start FROM networks ORDER BY name")
|
||||
.prepare("SELECT network_id, name, secret, auto_start, broadcast, exit_node, exit_via FROM networks ORDER BY name")
|
||||
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
@@ -419,13 +523,21 @@ impl StateStore {
|
||||
let name: String = row.get(1)?;
|
||||
let secret: Vec<u8> = row.get(2)?;
|
||||
let auto_start: i64 = row.get(3)?;
|
||||
Ok((id, name, secret, auto_start != 0))
|
||||
Ok((
|
||||
id,
|
||||
name,
|
||||
secret,
|
||||
auto_start != 0,
|
||||
row.get::<_, bool>(4)?,
|
||||
row.get::<_, bool>(5)?,
|
||||
row.get::<_, Option<Vec<u8>>>(6)?,
|
||||
))
|
||||
})
|
||||
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, secret, auto_start) =
|
||||
let (id, name, secret, auto_start, broadcast, exit_node, exit_via) =
|
||||
row.map_err(|err| Error::Storage(format!("cannot read network row: {err}")))?;
|
||||
let id: [u8; 32] = id
|
||||
.as_slice()
|
||||
@@ -436,6 +548,9 @@ impl StateStore {
|
||||
name: NetworkName::new(name)?,
|
||||
secret: NetworkSecret::from_bytes(secret)?,
|
||||
auto_start,
|
||||
broadcast,
|
||||
exit_node,
|
||||
exit_via: exit_via.and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -611,6 +726,61 @@ impl StateStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remembers the hostname a member last announced.
|
||||
pub fn remember_peer_hostname(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
endpoint_id: &[u8; 32],
|
||||
hostname: &str,
|
||||
) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO peer_hostnames (network_id, endpoint_id, hostname)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(network_id, endpoint_id) DO UPDATE SET hostname = excluded.hostname",
|
||||
params![
|
||||
network_id.as_bytes().as_slice(),
|
||||
endpoint_id.as_slice(),
|
||||
hostname
|
||||
],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot remember a hostname: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The last hostname every member of a network announced.
|
||||
pub fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT endpoint_id, hostname FROM peer_hostnames WHERE network_id = ?1")
|
||||
.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||
let rows = stmt
|
||||
.query_map(params![network_id.as_bytes().as_slice()], |row| {
|
||||
Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, hostname) =
|
||||
row.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||
if let Ok(id) = <[u8; 32]>::try_from(id.as_slice()) {
|
||||
out.push((id, hostname));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Forgets every remembered hostname of a network.
|
||||
pub fn forget_peer_hostnames(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM peer_hostnames WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice()],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot forget hostnames: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads an arbitrary setting.
|
||||
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
||||
self.conn
|
||||
@@ -668,3 +838,117 @@ pub(crate) fn now_unix() -> i64 {
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod broadcast_storage_tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::identity::NetworkKeys;
|
||||
|
||||
#[test]
|
||||
fn exit_node_choices_are_off_by_default_and_one_choice_clears_the_others() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = StateStore::open(dir.path().join("state.sqlite")).unwrap();
|
||||
let secret = NetworkSecret::from_bytes([5; 32]).unwrap();
|
||||
let make = |name: &str| {
|
||||
let name = NetworkName::new(name).unwrap();
|
||||
let id = NetworkKeys::derive(&name, &secret).network_id();
|
||||
store.upsert_network(id, &name, &secret, true).unwrap();
|
||||
id
|
||||
};
|
||||
let (a, b) = (make("exit-a"), make("exit-b"));
|
||||
let listed = || store.list_networks().unwrap();
|
||||
assert!(
|
||||
listed()
|
||||
.iter()
|
||||
.all(|n| !n.exit_node && n.exit_via.is_none())
|
||||
);
|
||||
|
||||
store.set_exit_node(a, true).unwrap();
|
||||
store.set_exit_via(a, Some(&[7; 32])).unwrap();
|
||||
store.set_exit_via(b, Some(&[8; 32])).unwrap();
|
||||
let by_id = |id: NetworkId| listed().into_iter().find(|n| n.network_id == id).unwrap();
|
||||
assert!(by_id(a).exit_node, "offering is per network and kept");
|
||||
assert_eq!(by_id(a).exit_via, None, "one default route: a was cleared");
|
||||
assert_eq!(by_id(b).exit_via, Some([8; 32]));
|
||||
|
||||
store.set_exit_via(b, None).unwrap();
|
||||
assert_eq!(by_id(b).exit_via, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remembered_hostname_survives_a_restart_and_follows_the_latest_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("state.sqlite");
|
||||
let name = NetworkName::new("hostnames").unwrap();
|
||||
let secret = NetworkSecret::from_bytes([9; 32]).unwrap();
|
||||
let id = NetworkKeys::derive(&name, &secret).network_id();
|
||||
let peer = [3u8; 32];
|
||||
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
store.remember_peer_hostname(id, &peer, "laptop").unwrap();
|
||||
store.remember_peer_hostname(id, &peer, "laptop-2").unwrap();
|
||||
drop(store);
|
||||
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
store.peer_hostnames(id).unwrap(),
|
||||
vec![(peer, "laptop-2".to_string())]
|
||||
);
|
||||
store.forget_peer_hostnames(id).unwrap();
|
||||
assert!(store.peer_hostnames(id).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("state.sqlite");
|
||||
let name = NetworkName::new("migration-broadcast").unwrap();
|
||||
let secret = NetworkSecret::from_bytes([7; 32]).unwrap();
|
||||
let id = NetworkKeys::derive(&name, &secret).network_id();
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
let identity = store
|
||||
.load_or_create_device_identity()
|
||||
.unwrap()
|
||||
.endpoint_id();
|
||||
store.upsert_network(id, &name, &secret, false).unwrap();
|
||||
store.set_hostname("old-host").unwrap();
|
||||
store
|
||||
.conn
|
||||
.execute_batch(
|
||||
"ALTER TABLE networks DROP COLUMN broadcast; \
|
||||
ALTER TABLE networks DROP COLUMN exit_node; \
|
||||
ALTER TABLE networks DROP COLUMN exit_via; \
|
||||
PRAGMA user_version=3;",
|
||||
)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
let networks = store.list_networks().unwrap();
|
||||
assert_eq!(networks.len(), 1);
|
||||
assert_eq!(networks[0].network_id, id);
|
||||
assert!(!networks[0].auto_start);
|
||||
assert!(networks[0].broadcast);
|
||||
assert_eq!(store.hostname().unwrap().as_deref(), Some("old-host"));
|
||||
assert_eq!(
|
||||
store
|
||||
.load_or_create_device_identity()
|
||||
.unwrap()
|
||||
.endpoint_id(),
|
||||
identity
|
||||
);
|
||||
store.set_broadcast(id, false).unwrap();
|
||||
store.upsert_network(id, &name, &secret, true).unwrap();
|
||||
drop(store);
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
assert!(
|
||||
!store.list_networks().unwrap()[0].broadcast,
|
||||
"rejoin and restart preserve opt-out"
|
||||
);
|
||||
store.remove_network(id).unwrap();
|
||||
store.upsert_network(id, &name, &secret, true).unwrap();
|
||||
assert!(
|
||||
store.list_networks().unwrap()[0].broadcast,
|
||||
"forgotten network gets defaults"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Stopping the tasks an agent started.
|
||||
//!
|
||||
//! Shutdown is cooperative first and bounded always: every loop watches a
|
||||
//! cancellation token, and anything still running when its grace period ends
|
||||
//! is aborted. Nothing an agent owns may outlive the agent, and no peer may
|
||||
//! hold shutdown open by refusing to read.
|
||||
//!
|
||||
//! Public because a protocol crate spawns tasks of its own and is held to
|
||||
//! the same rule: the agent gives each plugin a grace period, but abandoning
|
||||
//! the future it is awaiting does not stop the task behind it.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
/// How long a task gets to notice cancellation before it is cut off.
|
||||
///
|
||||
/// Winding down is cooperative first: a task in the middle of a write to a
|
||||
/// peer gets a moment to finish it. After that it is aborted, because a peer
|
||||
/// that stops reading must not be able to hold shutdown open.
|
||||
pub const TASK_GRACE: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Waits for `task` to finish, aborting it if it outlasts `grace`.
|
||||
///
|
||||
/// Returns whether it finished on its own. `what` names it in the warning,
|
||||
/// which is the only signal that something was cut off rather than stopped.
|
||||
pub async fn wind_down(task: JoinHandle<()>, grace: Duration, what: &str) -> bool {
|
||||
let abort = task.abort_handle();
|
||||
if tokio::time::timeout(grace, task).await.is_err() {
|
||||
tracing::warn!(
|
||||
task = what,
|
||||
grace_secs = grace.as_secs_f32(),
|
||||
"task did not wind down in time; aborting it"
|
||||
);
|
||||
abort.abort();
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_task_that_stops_on_its_own_is_not_aborted() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
let task = tokio::spawn(async move {
|
||||
tx.send(()).await.ok();
|
||||
});
|
||||
assert!(wind_down(task, Duration::from_secs(5), "cooperative").await);
|
||||
assert_eq!(rx.recv().await, Some(()), "it ran to completion");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_task_that_ignores_the_grace_is_aborted() {
|
||||
// A stand-in for a task blocked writing to a peer that stopped
|
||||
// reading: it will never finish by itself.
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
let task = tokio::spawn(async move {
|
||||
let _held = tx;
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
|
||||
let finished = wind_down(task, Duration::from_millis(50), "wedged").await;
|
||||
assert!(!finished, "it had to be cut off");
|
||||
|
||||
// Aborted for real: the task is gone, so what it held is dropped.
|
||||
let closed = tokio::time::timeout(Duration::from_secs(5), rx.recv()).await;
|
||||
assert_eq!(closed, Ok(None), "the aborted task dropped its sender");
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,31 @@
|
||||
//! Shared helpers for the integration tests.
|
||||
//! A harness for testing against a real agent.
|
||||
//!
|
||||
//! Every test uses real iroh endpoints on loopback, its own temporary SQLite
|
||||
//! files and independent agent instances. Only discovery is substituted; iroh,
|
||||
//! the handshake, message passing and persistent storage are not.
|
||||
//! Behind the `testing` feature, because it is only of use to a test and
|
||||
//! pulls in `tempfile` and a tracing subscriber. It lives here rather than
|
||||
//! being copied into each crate's `tests/` directory: a protocol plugin in
|
||||
//! its own crate needs exactly the same harness, and two copies of it would
|
||||
//! drift.
|
||||
//!
|
||||
//! Synchronisation is always "wait for a specific event or condition, under one
|
||||
//! overall deadline", never a fixed multi-second sleep.
|
||||
//! Everything here builds agents that touch nothing outside a temporary
|
||||
//! directory: loopback only, no relay, no address publication.
|
||||
|
||||
#![allow(dead_code, clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
// A harness fails a test loudly and at once; that is the whole job. The
|
||||
// crate forbids these elsewhere for the opposite reason — nothing off the
|
||||
// network may panic — and this module never sees anything off the network.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::agent::Event;
|
||||
use crate::config::{AgentConfig, StoragePaths, TransportPolicy};
|
||||
use crate::discovery::SharedMemoryDiscovery;
|
||||
use crate::identity::{NetworkName, NetworkSecret};
|
||||
use crate::{Agent, Result};
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::{NetworkName, NetworkSecret};
|
||||
use tsunagi::{Agent, Result};
|
||||
|
||||
/// Installs a tracing subscriber when `TSUNAGI_TEST_LOG` is set.
|
||||
///
|
||||
@@ -50,7 +55,7 @@ const POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
/// port mapping, so the suite needs neither the internet nor privileges.
|
||||
/// Timeouts are shortened so that failure paths finish quickly.
|
||||
pub fn local_config(dir: &Path) -> AgentConfig {
|
||||
let limits = tsunagi::Limits {
|
||||
let limits = crate::Limits {
|
||||
dial_timeout: Duration::from_millis(1500),
|
||||
handshake_timeout: Duration::from_secs(5),
|
||||
..Default::default()
|
||||
@@ -69,7 +74,9 @@ pub fn config_with(dir: &Path, discovery: &SharedMemoryDiscovery) -> AgentConfig
|
||||
|
||||
/// A temporary directory plus the agent running on it.
|
||||
pub struct TestAgent {
|
||||
/// The directory it keeps its state in, removed when this is dropped.
|
||||
pub dir: TempDir,
|
||||
/// The agent itself.
|
||||
pub agent: Agent,
|
||||
}
|
||||
|
||||
@@ -148,14 +155,22 @@ where
|
||||
{
|
||||
let deadline = Instant::now() + DEADLINE;
|
||||
loop {
|
||||
if let Some(value) = probe().await {
|
||||
return value;
|
||||
}
|
||||
let remaining = deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
!remaining.is_zero(),
|
||||
"timed out waiting for condition: {what}"
|
||||
);
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
|
||||
// The probe is bounded too. A deadline checked only between probes is
|
||||
// no deadline at all: one call into a wedged agent that never answers
|
||||
// would hang the test process for ever instead of failing it.
|
||||
match tokio::time::timeout(remaining, probe()).await {
|
||||
Ok(Some(value)) => return value,
|
||||
Ok(None) => tokio::time::sleep(POLL_INTERVAL).await,
|
||||
Err(_) => panic!("timed out waiting for condition: {what}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +185,7 @@ pub async fn settle() {
|
||||
/// Waits until `agent` has `count` authenticated peers in `network`.
|
||||
pub async fn wait_for_peers(
|
||||
agent: &Agent,
|
||||
network: tsunagi::NetworkId,
|
||||
network: crate::NetworkId,
|
||||
count: usize,
|
||||
) -> Vec<iroh::EndpointId> {
|
||||
wait_until(&format!("{count} peers in {network}"), || async move {
|
||||
@@ -183,3 +198,20 @@ pub async fn wait_for_peers(
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[should_panic(expected = "timed out waiting for condition")]
|
||||
async fn a_probe_that_never_answers_fails_the_deadline_instead_of_hanging() {
|
||||
// Virtual time, so the deadline arrives at once rather than in thirty
|
||||
// real seconds — and the deadline does arrive, which is the point: a
|
||||
// probe that never returns used to hang the process instead.
|
||||
wait_until::<(), _, _>("an answer that never comes", || async {
|
||||
std::future::pending::<Option<()>>().await
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,6 @@
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{TestAgent, network, settle, wait_event};
|
||||
use iroh::endpoint::{Connection, PortmapperConfig, RecvStream, SendStream, presets};
|
||||
use iroh::{Endpoint, EndpointAddr, RelayMode};
|
||||
use tsunagi::agent::Event;
|
||||
@@ -22,6 +19,7 @@ use tsunagi::proto::message::{
|
||||
};
|
||||
use tsunagi::proto::{read_frame, write_frame};
|
||||
use tsunagi::test_support;
|
||||
use tsunagi::testing::{TestAgent, network, settle, wait_event};
|
||||
|
||||
/// A bare iroh endpoint with no tsunagi agent behind it.
|
||||
async fn raw_endpoint() -> Endpoint {
|
||||
@@ -2,14 +2,12 @@
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use common::{TestAgent, config_with, local_config, network, wait_for_peers};
|
||||
use tsunagi::config::StoragePaths;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::storage::CacheOutcome;
|
||||
use tsunagi::testing::{TestAgent, config_with, local_config, network, wait_for_peers};
|
||||
use tsunagi::{Agent, Error};
|
||||
|
||||
/// Overwrites a file with bytes that are definitely not a SQLite database.
|
||||
@@ -284,3 +282,85 @@ async fn secrets_never_appear_in_status_or_debug_output() {
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wipe_removes_everything_and_the_next_start_is_a_stranger() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("wiped");
|
||||
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let before = agent.endpoint_id();
|
||||
agent.join_network(&name, &secret).await.unwrap();
|
||||
// Something a plugin keeps beside the state, which a wipe must take too.
|
||||
std::fs::create_dir_all(paths.state_dir.join("wireguard")).unwrap();
|
||||
std::fs::write(paths.state_dir.join("wireguard/keys.sqlite"), b"key").unwrap();
|
||||
|
||||
// Not while an agent owns the directory: a half-wiped state under a
|
||||
// running agent is worse than no wipe at all.
|
||||
let refused = tsunagi::storage::wipe(&paths).unwrap_err();
|
||||
assert!(matches!(refused, Error::StateLocked { .. }), "{refused}");
|
||||
agent.shutdown().await;
|
||||
|
||||
let plan = tsunagi::storage::wipe_plan(&paths).unwrap();
|
||||
assert!(
|
||||
plan.entries().any(|path| path.ends_with("state.sqlite")),
|
||||
"{plan:?}"
|
||||
);
|
||||
assert!(
|
||||
plan.entries().any(|path| path.ends_with("wireguard")),
|
||||
"what a plugin kept is state too: {plan:?}"
|
||||
);
|
||||
|
||||
let wiped = tsunagi::storage::wipe(&paths).unwrap();
|
||||
assert_eq!(wiped, plan);
|
||||
assert!(!paths.state_db().exists());
|
||||
assert!(!paths.state_dir.join("wireguard").exists());
|
||||
assert!(!paths.lock_file().exists(), "no lock is left claiming it");
|
||||
|
||||
// A stranger: new identity, no networks, nothing to be surprised by.
|
||||
let fresh = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(fresh.endpoint_id(), before);
|
||||
assert!(fresh.list_networks().await.unwrap().is_empty());
|
||||
fresh.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wiping_twice_is_as_ordinary_as_wiping_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
agent.shutdown().await;
|
||||
|
||||
assert!(!tsunagi::storage::wipe(&paths).unwrap().is_empty());
|
||||
let again = tsunagi::storage::wipe(&paths).unwrap();
|
||||
assert!(again.is_empty(), "nothing left to remove: {again:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_directory_that_is_not_ours_is_refused_rather_than_emptied() {
|
||||
// The mistyped `--state-dir` that would otherwise remove somebody's
|
||||
// documents. A marker decides, not the name of the directory.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let paths = StoragePaths::new(dir.path(), dir.path().join("cache"));
|
||||
std::fs::write(dir.path().join("thesis.txt"), b"years of work").unwrap();
|
||||
|
||||
let err = tsunagi::storage::wipe(&paths).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("does not look like a tsunagi"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(
|
||||
dir.path().join("thesis.txt").exists(),
|
||||
"nothing was removed"
|
||||
);
|
||||
}
|
||||
@@ -8,9 +8,6 @@
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{config_with, network, wait_until};
|
||||
use tempfile::TempDir;
|
||||
use tsunagi::Agent;
|
||||
use tsunagi::config::StoragePaths;
|
||||
@@ -18,6 +15,7 @@ use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
use tsunagi::state::{RecordBody, StateSet};
|
||||
use tsunagi::storage::StateStore;
|
||||
use tsunagi::testing::{config_with, network, wait_until};
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_changed_name_reaches_peers_and_replaces_the_old_claim() {
|
||||
@@ -3,16 +3,14 @@
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::{TestAgent, local_config, network, settle, wait_for_peers};
|
||||
use tsunagi::Agent;
|
||||
use tsunagi::discovery::{
|
||||
CandidateSource, CompositeDiscovery, NetworkDiscovery, SharedMemoryDiscovery, StaticBootstrap,
|
||||
};
|
||||
use tsunagi::identity::NetworkKeys;
|
||||
use tsunagi::testing::{TestAgent, local_config, network, settle, wait_for_peers};
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_static_bootstrap_candidate_is_enough_to_join() {
|
||||
@@ -139,3 +137,63 @@ async fn forgetting_a_network_removes_it_from_the_state_store() {
|
||||
drop(restarted);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn being_told_about_one_member_is_enough_to_meet_them_all() {
|
||||
// A network is a mesh, and which member happened to be named on a
|
||||
// command line must not decide who a device ends up talking to. The
|
||||
// newcomer is given one address and nothing else; everybody else is
|
||||
// learned from the members it meets.
|
||||
let (name, secret) = network("introductions");
|
||||
|
||||
// Three that already know each other, and nothing that resolves by
|
||||
// network: no shared discovery here, only what members pass on.
|
||||
let first_dir = tempfile::TempDir::new().unwrap();
|
||||
let first = Agent::spawn(local_config(first_dir.path())).await.unwrap();
|
||||
let network_id = first.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let second_dir = tempfile::TempDir::new().unwrap();
|
||||
let second = Agent::spawn(
|
||||
local_config(second_dir.path())
|
||||
.with_discovery(Arc::new(StaticBootstrap::new([first.local_addr()]))),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
second.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&first, network_id, 1).await;
|
||||
|
||||
let third_dir = tempfile::TempDir::new().unwrap();
|
||||
let third = Agent::spawn(
|
||||
local_config(third_dir.path())
|
||||
.with_discovery(Arc::new(StaticBootstrap::new([first.local_addr()]))),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
third.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// Each of the two newcomers was given only the first one's address,
|
||||
// and each ends up with both of the others.
|
||||
wait_for_peers(&second, network_id, 2).await;
|
||||
wait_for_peers(&third, network_id, 2).await;
|
||||
wait_for_peers(&first, network_id, 2).await;
|
||||
|
||||
// And the one they were not told about is there as an introduction:
|
||||
// a candidate like any other, still authenticated by the handshake.
|
||||
let status = second.network_status(network_id).await.unwrap();
|
||||
let introduced = status
|
||||
.candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.endpoint_id == third.endpoint_id())
|
||||
.map(|candidate| candidate.source);
|
||||
assert!(
|
||||
matches!(
|
||||
introduced,
|
||||
Some(CandidateSource::Introduced) | Some(CandidateSource::Member)
|
||||
),
|
||||
"the third should have arrived from the others: {introduced:?}"
|
||||
);
|
||||
|
||||
second.shutdown().await;
|
||||
third.shutdown().await;
|
||||
first.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Discovery is a cancellable bootstrap job, never the network's event loop.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use iroh::{EndpointAddr, EndpointId};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tsunagi::config::DiscoveryPolicy;
|
||||
use tsunagi::discovery::{BoxFuture, Candidate, NetworkDiscovery, SharedMemoryDiscovery};
|
||||
use tsunagi::identity::DiscoveryKey;
|
||||
use tsunagi::testing::{local_config, network, wait_for_peers, wait_until};
|
||||
use tsunagi::{Agent, Result};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Counted {
|
||||
table: SharedMemoryDiscovery,
|
||||
reads: AtomicUsize,
|
||||
writes: AtomicUsize,
|
||||
}
|
||||
|
||||
impl NetworkDiscovery for Counted {
|
||||
fn name(&self) -> &str {
|
||||
"counted"
|
||||
}
|
||||
fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>> {
|
||||
self.writes.fetch_add(1, Ordering::SeqCst);
|
||||
self.table.publish(key, addr)
|
||||
}
|
||||
fn unpublish<'a>(&'a self, key: DiscoveryKey, id: EndpointId) -> BoxFuture<'a, Result<()>> {
|
||||
self.table.unpublish(key, id)
|
||||
}
|
||||
fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
|
||||
self.reads.fetch_add(1, Ordering::SeqCst);
|
||||
self.table.resolve(key)
|
||||
}
|
||||
}
|
||||
|
||||
fn policy() -> DiscoveryPolicy {
|
||||
DiscoveryPolicy {
|
||||
publish_interval: Duration::from_millis(100),
|
||||
lookup_interval: Duration::from_millis(50),
|
||||
max_lookup_interval: Duration::from_millis(100),
|
||||
reconnect_delay: Duration::from_millis(300),
|
||||
request_timeout: Duration::from_secs(2),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connected_peers_only_publish_and_isolated_peers_resume_search() {
|
||||
let discovery = Arc::new(Counted::default());
|
||||
let first_dir = tempfile::tempdir().unwrap();
|
||||
let second_dir = tempfile::tempdir().unwrap();
|
||||
let config = |path: &std::path::Path| {
|
||||
local_config(path)
|
||||
.with_discovery(discovery.clone())
|
||||
.with_discovery_policy(policy())
|
||||
};
|
||||
let first = Agent::spawn(config(first_dir.path())).await.unwrap();
|
||||
let second = Agent::spawn(config(second_dir.path())).await.unwrap();
|
||||
let (name, secret) = network("discovery-lifecycle");
|
||||
let id = first.join_network(&name, &secret).await.unwrap();
|
||||
second.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&first, id, 1).await;
|
||||
wait_for_peers(&second, id, 1).await;
|
||||
let reads = discovery.reads.load(Ordering::SeqCst);
|
||||
let writes = discovery.writes.load(Ordering::SeqCst);
|
||||
wait_until("publications continue while connected", || async {
|
||||
(discovery.writes.load(Ordering::SeqCst) >= writes + 6).then_some(())
|
||||
})
|
||||
.await;
|
||||
assert_eq!(discovery.reads.load(Ordering::SeqCst), reads);
|
||||
second.shutdown().await;
|
||||
wait_for_peers(&first, id, 0).await;
|
||||
wait_until("isolation resumes discovery", || async {
|
||||
(discovery.reads.load(Ordering::SeqCst) > reads).then_some(())
|
||||
})
|
||||
.await;
|
||||
first.shutdown().await;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Stalled;
|
||||
impl NetworkDiscovery for Stalled {
|
||||
fn name(&self) -> &str {
|
||||
"stalled"
|
||||
}
|
||||
fn publish<'a>(&'a self, _: DiscoveryKey, _: EndpointAddr) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(std::future::pending())
|
||||
}
|
||||
fn unpublish<'a>(&'a self, _: DiscoveryKey, _: EndpointId) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(std::future::pending())
|
||||
}
|
||||
fn resolve<'a>(&'a self, _: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
|
||||
Box::pin(std::future::pending())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stalled_discovery_does_not_block_status_or_shutdown() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let agent = Agent::spawn(local_config(dir.path()).with_discovery(Arc::new(Stalled)))
|
||||
.await
|
||||
.unwrap();
|
||||
let (name, secret) = network("stalled-discovery");
|
||||
let id = agent.join_network(&name, &secret).await.unwrap();
|
||||
// A recheck schedules work; it must not await the unreachable backend.
|
||||
agent.recheck_network(id).await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), agent.network_status(id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), agent.shutdown())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
@@ -3,12 +3,10 @@
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{TestAgent, network, wait_event, wait_for_peers};
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::proto::ControlMessage;
|
||||
use tsunagi::testing::{TestAgent, network, wait_event, wait_for_peers};
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_agents_authenticate_and_exchange_messages() {
|
||||
@@ -5,12 +5,10 @@
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{TestAgent, config_with, network};
|
||||
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() {
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Scenario 11: leaving a network, and what the others are left holding.
|
||||
//!
|
||||
//! Leaving is not deactivating and not forgetting. It is a signed statement
|
||||
//! that this member gives up what it claimed, published while there is still
|
||||
//! somebody to hear it, because signed state has no expiry and nothing else
|
||||
//! will ever free the address.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::testing::{TestAgent, network, wait_for_peers, wait_until};
|
||||
|
||||
#[tokio::test]
|
||||
async fn leaving_frees_the_address_for_everyone_still_there() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("leaving-frees");
|
||||
|
||||
let leaver = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let stayer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = leaver.agent.join_network(&name, &secret).await.unwrap();
|
||||
stayer.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&stayer.agent, network_id, 1).await;
|
||||
|
||||
let leaver_id = leaver.agent.endpoint_id();
|
||||
let address = wait_until("the one leaving holds an address", || async {
|
||||
let status = stayer.agent.network_status(network_id).await.ok()?;
|
||||
status
|
||||
.members
|
||||
.iter()
|
||||
.find(|member| member.endpoint_id == leaver_id)?
|
||||
.overlay_address_v4
|
||||
})
|
||||
.await;
|
||||
|
||||
let outcome = leaver.agent.leave_network(network_id).await.unwrap();
|
||||
assert!(outcome.announced, "there was a session to announce it on");
|
||||
assert_eq!(outcome.peers_told, 1);
|
||||
|
||||
// The one still there drops it from the roster. The tombstone stays in
|
||||
// the record set — it has to, or a replica that never heard of it would
|
||||
// reinstate the old claim — but a member that gave everything up is not
|
||||
// a member, and listing it as one makes leaving look like a fault.
|
||||
wait_until("the member is gone from the roster", || async {
|
||||
let status = stayer.agent.network_status(network_id).await.ok()?;
|
||||
status
|
||||
.members
|
||||
.iter()
|
||||
.all(|member| member.endpoint_id != leaver_id)
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
let taken = stayer.agent.network_status(network_id).await.unwrap();
|
||||
assert!(
|
||||
!taken
|
||||
.members
|
||||
.iter()
|
||||
.any(|member| member.overlay_address_v4 == Some(address)),
|
||||
"the address is free for somebody else"
|
||||
);
|
||||
|
||||
// And locally there is no membership left to be surprised by.
|
||||
assert!(
|
||||
leaver.agent.list_networks().await.unwrap().is_empty(),
|
||||
"the network is gone from the store"
|
||||
);
|
||||
assert!(!leaver.agent.is_active(network_id).await);
|
||||
|
||||
leaver.agent.shutdown().await;
|
||||
stayer.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leaving_with_nobody_connected_says_so_rather_than_pretending() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("leaving-alone");
|
||||
|
||||
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// Nobody is here, so the tombstone reaches nobody. The network is still
|
||||
// left — the caller asked — but the outcome does not claim an audience
|
||||
// there was not one for.
|
||||
let outcome = agent.agent.leave_network(network_id).await.unwrap();
|
||||
assert!(outcome.announced, "it was published locally");
|
||||
assert_eq!(outcome.peers_told, 0);
|
||||
assert!(agent.agent.list_networks().await.unwrap().is_empty());
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leaving_a_network_that_is_not_running_tells_nobody() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("leaving-inactive");
|
||||
|
||||
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
agent.agent.deactivate_network(network_id).await.unwrap();
|
||||
|
||||
// There is no runtime to sign and send from, so nothing was announced
|
||||
// and the outcome says exactly that instead of a quiet success.
|
||||
let outcome = agent.agent.leave_network(network_id).await.unwrap();
|
||||
assert!(!outcome.announced);
|
||||
assert_eq!(outcome.peers_told, 0);
|
||||
assert!(agent.agent.list_networks().await.unwrap().is_empty());
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejoining_after_leaving_is_not_taken_for_a_stale_record() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("leaving-rejoin");
|
||||
|
||||
let returner = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let stayer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = returner.agent.join_network(&name, &secret).await.unwrap();
|
||||
stayer.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&stayer.agent, network_id, 1).await;
|
||||
|
||||
let returner_id = returner.agent.endpoint_id();
|
||||
wait_until("the one leaving holds an address", || async {
|
||||
stayer
|
||||
.agent
|
||||
.network_status(network_id)
|
||||
.await
|
||||
.ok()?
|
||||
.members
|
||||
.iter()
|
||||
.find(|member| member.endpoint_id == returner_id)?
|
||||
.overlay_address_v4
|
||||
})
|
||||
.await;
|
||||
|
||||
returner.agent.leave_network(network_id).await.unwrap();
|
||||
wait_until("the release reached the other one", || async {
|
||||
let status = stayer.agent.network_status(network_id).await.ok()?;
|
||||
status
|
||||
.members
|
||||
.iter()
|
||||
.all(|member| member.endpoint_id != returner_id)
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
|
||||
// The same device, the same key, the same network. The version counter
|
||||
// survived leaving on purpose: a claim numbered below the release would
|
||||
// be ignored by every replica that already has the release, and this
|
||||
// member would be invisible for good.
|
||||
returner.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&stayer.agent, network_id, 1).await;
|
||||
let again = wait_until("the returning member is seen again", || async {
|
||||
stayer
|
||||
.agent
|
||||
.network_status(network_id)
|
||||
.await
|
||||
.ok()?
|
||||
.members
|
||||
.iter()
|
||||
.find(|member| member.endpoint_id == returner_id)?
|
||||
.overlay_address_v4
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
tsunagi::state::DEFAULT_IPV4_RANGE.contains(again),
|
||||
"an address in the network's range: {again}"
|
||||
);
|
||||
|
||||
returner.agent.shutdown().await;
|
||||
stayer.agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Local Mainline Testnet, real iroh authentication, and durable agent state.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::time::Duration;
|
||||
use tsunagi::testing::{local_config, network, wait_for_peers};
|
||||
use tsunagi::{Agent, config::DiscoveryPolicy, discovery::MainlineDiscovery};
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "uses public Mainline DHT and iroh relay services"]
|
||||
async fn public_dht_finds_and_authenticates_two_agents() {
|
||||
tsunagi::testing::init_tracing();
|
||||
let a_dir = tempfile::tempdir().unwrap();
|
||||
let b_dir = tempfile::tempdir().unwrap();
|
||||
let config = |path: &std::path::Path| {
|
||||
tsunagi::config::AgentConfig::new(tsunagi::config::StoragePaths::under(path))
|
||||
.with_transport(tsunagi::config::TransportPolicy::N0Defaults)
|
||||
.with_dht(MainlineDiscovery::default())
|
||||
};
|
||||
let a = Agent::spawn(config(a_dir.path())).await.unwrap();
|
||||
let b = Agent::spawn(config(b_dir.path())).await.unwrap();
|
||||
let (name, secret) = network("mainline-public-smoke");
|
||||
let id = a.join_network(&name, &secret).await.unwrap();
|
||||
b.join_network(&name, &secret).await.unwrap();
|
||||
let found = tokio::time::timeout(Duration::from_secs(180), async {
|
||||
loop {
|
||||
if !a.network_status(id).await.unwrap().peers.is_empty()
|
||||
&& !b.network_status(id).await.unwrap().peers.is_empty()
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
a.shutdown().await;
|
||||
b.shutdown().await;
|
||||
assert!(
|
||||
found.is_ok(),
|
||||
"public DHT/relay discovery did not connect within three minutes"
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn two_agents_restore_after_all_dht_records_and_addresses_are_gone() {
|
||||
let first_dir = tempfile::tempdir().unwrap();
|
||||
let second_dir = tempfile::tempdir().unwrap();
|
||||
let (name, secret) = network("dht-restart");
|
||||
let mut identities = None;
|
||||
for _ in 0..2 {
|
||||
if identities.is_some() {
|
||||
for path in [first_dir.path(), second_dir.path()] {
|
||||
let cache = tsunagi::config::StoragePaths::under(path).cache_dir;
|
||||
if cache.exists() {
|
||||
std::fs::remove_dir_all(cache).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
// A fresh Testnet means no old rendezvous record survives. The second
|
||||
// pass restores the networks from SQLite without another join command.
|
||||
let net = mainline::Testnet::builder(5).build().unwrap();
|
||||
let config = |path: &std::path::Path| {
|
||||
local_config(path)
|
||||
.with_dht(MainlineDiscovery::local_testnet(&net.bootstrap).unwrap())
|
||||
.with_discovery_policy(DiscoveryPolicy {
|
||||
lookup_interval: Duration::from_millis(100),
|
||||
max_lookup_interval: Duration::from_millis(300),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let (a, b) = tokio::join!(
|
||||
Agent::spawn(config(first_dir.path())),
|
||||
Agent::spawn(config(second_dir.path()))
|
||||
);
|
||||
let a = a.unwrap();
|
||||
let b = b.unwrap();
|
||||
let id = tsunagi::identity::NetworkKeys::derive(&name, &secret).network_id();
|
||||
if let Some(old) = identities {
|
||||
assert_eq!((a.endpoint_id(), b.endpoint_id()), old);
|
||||
} else {
|
||||
identities = Some((a.endpoint_id(), b.endpoint_id()));
|
||||
let (ra, rb) = tokio::join!(
|
||||
a.join_network(&name, &secret),
|
||||
b.join_network(&name, &secret)
|
||||
);
|
||||
assert_eq!(ra.unwrap(), id);
|
||||
assert_eq!(rb.unwrap(), id);
|
||||
}
|
||||
wait_for_peers(&a, id, 1).await;
|
||||
wait_for_peers(&b, id, 1).await;
|
||||
a.shutdown().await;
|
||||
b.shutdown().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! The real Mainline UDP receive loop must be quiet while idle on Windows too.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
struct SocketWarnings(Arc<AtomicUsize>);
|
||||
|
||||
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for SocketWarnings {
|
||||
fn on_event(&self, event: &tracing::Event<'_>, _: tracing_subscriber::layer::Context<'_, S>) {
|
||||
if event.metadata().target() == "mainline::rpc::socket"
|
||||
&& *event.metadata().level() <= tracing::Level::WARN
|
||||
{
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_idle_dht_socket_stays_quiet_and_still_answers_packets() {
|
||||
// Mainline owns an OS thread, so a thread-local subscriber cannot see its
|
||||
// events. This integration-test binary has just this test and installs
|
||||
// its own subscriber; it cannot affect the application or other binaries.
|
||||
let warnings = Arc::new(AtomicUsize::new(0));
|
||||
tracing::subscriber::set_global_default(
|
||||
tracing_subscriber::registry().with(SocketWarnings(Arc::clone(&warnings))),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let dht = mainline::Dht::builder()
|
||||
.no_bootstrap()
|
||||
.server_mode()
|
||||
.bind_address(Ipv4Addr::LOCALHOST)
|
||||
.port(0)
|
||||
.build()
|
||||
.unwrap()
|
||||
.as_async();
|
||||
|
||||
let address = dht.info().await.local_addr();
|
||||
// Each info response comes from the actor after another receive-loop
|
||||
// iteration. With no peers or packets, these exercise real socket
|
||||
// read timeouts, without an arbitrary sleep or public bootstrap.
|
||||
for _ in 0..8 {
|
||||
assert_eq!(dht.info().await.local_addr(), address);
|
||||
}
|
||||
assert_eq!(warnings.load(Ordering::Relaxed), 0, "idle receive warnings");
|
||||
|
||||
let client = tokio::net::UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
let ping = b"d1:ad2:id20:abcdefghijklmnopqrste1:q4:ping1:t4:ping1:y1:qe";
|
||||
client.send_to(ping, address).await.unwrap();
|
||||
let mut response = [0u8; 2048];
|
||||
let (len, from) = client.recv_from(&mut response).await.unwrap();
|
||||
assert_eq!(from, std::net::SocketAddr::V4(address));
|
||||
let response = &response[..len];
|
||||
assert!(response.windows(9).any(|field| field == b"1:t4:ping"));
|
||||
assert!(response.windows(6).any(|field| field == b"1:y1:r"));
|
||||
assert_eq!(warnings.load(Ordering::Relaxed), 0);
|
||||
})
|
||||
.await
|
||||
.expect("the DHT receive loop keeps making progress");
|
||||
}
|
||||
@@ -3,16 +3,14 @@
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::{TestAgent, network, wait_event, wait_for_peers, wait_until};
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::dataplane::TestCapabilityPlugin;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::proto::ControlMessage;
|
||||
use tsunagi::testing::{TestAgent, network, wait_event, wait_for_peers, wait_until};
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_agents_form_a_mesh_and_exchange_distinguishable_messages() {
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{TestAgent, network, settle, wait_event, wait_for_peers};
|
||||
use iroh::endpoint::{PortmapperConfig, presets};
|
||||
use iroh::{Endpoint, RelayMode};
|
||||
use tsunagi::agent::Event;
|
||||
@@ -16,6 +13,7 @@ use tsunagi::proto::message::{
|
||||
};
|
||||
use tsunagi::proto::{read_frame, write_frame};
|
||||
use tsunagi::test_support;
|
||||
use tsunagi::testing::{TestAgent, network, settle, wait_event, wait_for_peers, wait_until};
|
||||
|
||||
const LIMIT: usize = 64 * 1024;
|
||||
|
||||
@@ -264,3 +262,73 @@ async fn deactivating_one_network_leaves_the_agent_and_others_running() {
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_networks_on_one_agent_each_get_a_range_of_their_own() {
|
||||
// One agent, one interface: the configured range can only belong to one
|
||||
// of them. The second does not go without — it takes the range derived
|
||||
// from its own id, which every one of its members derives identically,
|
||||
// so it is an agreement and not a local invention.
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (first, first_secret) = network("two-ranges-one");
|
||||
let (second, second_secret) = network("two-ranges-two");
|
||||
|
||||
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let one = agent
|
||||
.agent
|
||||
.join_network(&first, &first_secret)
|
||||
.await
|
||||
.unwrap();
|
||||
let two = agent
|
||||
.agent
|
||||
.join_network(&second, &second_secret)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let own = agent.agent.endpoint_id();
|
||||
let addresses = wait_until("both networks allocate an address", || {
|
||||
let handle = agent.agent.clone();
|
||||
async move {
|
||||
let mine = async |network| {
|
||||
handle
|
||||
.network_status(network)
|
||||
.await
|
||||
.ok()?
|
||||
.members
|
||||
.into_iter()
|
||||
.find(|member| member.endpoint_id == own)?
|
||||
.overlay_address_v4
|
||||
};
|
||||
Some((mine(one).await?, mine(two).await?))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_ne!(
|
||||
addresses.0, addresses.1,
|
||||
"different networks, different addresses"
|
||||
);
|
||||
assert!(
|
||||
tsunagi::state::DEFAULT_IPV4_RANGE.contains(addresses.0),
|
||||
"the first keeps the configured range: {}",
|
||||
addresses.0
|
||||
);
|
||||
assert!(
|
||||
tsunagi::state::derived_ipv4_range(two).contains(addresses.1),
|
||||
"the second is in the range derived from its id: {}",
|
||||
addresses.1
|
||||
);
|
||||
assert!(
|
||||
!tsunagi::state::DEFAULT_IPV4_RANGE.contains(addresses.1),
|
||||
"and not in the one the first network holds"
|
||||
);
|
||||
|
||||
// And the ranges do not overlap, which is what lets one interface
|
||||
// carry both without a packet being ambiguous.
|
||||
assert!(
|
||||
!tsunagi::state::derived_ipv4_range(two).contains(addresses.0),
|
||||
"the two ranges must not overlap"
|
||||
);
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
@@ -3,18 +3,18 @@
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{TestAgent, config_with, local_config, network, settle, wait_event, wait_for_peers};
|
||||
use iroh::{EndpointAddr, SecretKey};
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::config::ReconnectPolicy;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::NetworkKeys;
|
||||
use tsunagi::proto::ControlMessage;
|
||||
use tsunagi::testing::{
|
||||
TestAgent, config_with, local_config, network, settle, wait_event, wait_for_peers,
|
||||
};
|
||||
use tsunagi::{Agent, Error};
|
||||
|
||||
/// An endpoint id nobody is listening for, at an address nothing answers on.
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{TestAgent, config_with, network, settle, wait_event, wait_for_peers};
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::{NetworkName, NetworkSecret};
|
||||
use tsunagi::proto::ControlMessage;
|
||||
use tsunagi::testing::{TestAgent, config_with, network, settle, wait_event, wait_for_peers};
|
||||
use tsunagi::{Agent, Error};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! End-to-end packet encryption for `tcp-tls`.
|
||||
//!
|
||||
//! Uses X25519 Diffie-Hellman key exchange and ChaCha20-Poly1305 AEAD.
|
||||
//! This ensures that even when traffic passes through intermediate relay nodes,
|
||||
//! the intermediate nodes cannot inspect or tamper with the payload.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
|
||||
use hkdf::Hkdf;
|
||||
use iroh::EndpointId;
|
||||
use sha2::Sha256;
|
||||
use tsunagi::dataplane::PluginError;
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
/// Length of the X25519 public key.
|
||||
pub const KEY_LEN: usize = 32;
|
||||
|
||||
/// Domain separator for session key derivation.
|
||||
const HKDF_SALT: &[u8] = b"tsunagi/e2ee/noise/v1";
|
||||
|
||||
/// A session cipher for communicating with one peer.
|
||||
pub struct PeerCipher {
|
||||
tx_cipher: ChaCha20Poly1305,
|
||||
rx_cipher: ChaCha20Poly1305,
|
||||
tx_nonce: AtomicU64,
|
||||
}
|
||||
|
||||
impl PeerCipher {
|
||||
/// Establishes a new cipher between `local_id` and `remote_id` given
|
||||
/// our local secret and their public key.
|
||||
pub fn new(
|
||||
local_id: EndpointId,
|
||||
remote_id: EndpointId,
|
||||
local_secret: &StaticSecret,
|
||||
remote_public: &PublicKey,
|
||||
) -> Self {
|
||||
let shared = local_secret.diffie_hellman(remote_public);
|
||||
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), shared.as_bytes());
|
||||
|
||||
let mut key_a_to_b = [0u8; 32];
|
||||
let mut key_b_to_a = [0u8; 32];
|
||||
let _ = hk.expand(b"tsunagi-key-a-to-b", &mut key_a_to_b);
|
||||
let _ = hk.expand(b"tsunagi-key-b-to-a", &mut key_b_to_a);
|
||||
|
||||
// Deterministic role: smaller EndpointId is party A.
|
||||
let is_party_a = local_id.as_bytes() < remote_id.as_bytes();
|
||||
let (tx_key, rx_key) = if is_party_a {
|
||||
(key_a_to_b, key_b_to_a)
|
||||
} else {
|
||||
(key_b_to_a, key_a_to_b)
|
||||
};
|
||||
|
||||
let tx_cipher = ChaCha20Poly1305::new(&tx_key.into());
|
||||
let rx_cipher = ChaCha20Poly1305::new(&rx_key.into());
|
||||
|
||||
Self {
|
||||
tx_cipher,
|
||||
rx_cipher,
|
||||
tx_nonce: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypts an outbound packet. Prefixes the ciphertext with the 8-byte nonce.
|
||||
pub fn encrypt(&self, packet: &[u8]) -> Result<bytes::Bytes, PluginError> {
|
||||
let counter = self.tx_nonce.fetch_add(1, Ordering::Relaxed);
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
nonce_bytes[4..12].copy_from_slice(&counter.to_be_bytes());
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = self
|
||||
.tx_cipher
|
||||
.encrypt(nonce, packet)
|
||||
.map_err(|err| PluginError::Other(format!("encryption failed: {err}")))?;
|
||||
|
||||
let mut out = Vec::with_capacity(8 + ciphertext.len());
|
||||
out.extend_from_slice(&counter.to_be_bytes());
|
||||
out.extend_from_slice(&ciphertext);
|
||||
Ok(bytes::Bytes::from(out))
|
||||
}
|
||||
|
||||
/// Decrypts an inbound packet prefixed with the 8-byte nonce.
|
||||
pub fn decrypt(&self, payload: &[u8]) -> Result<bytes::Bytes, PluginError> {
|
||||
if payload.len() < 8 + 16 {
|
||||
return Err(PluginError::Other("payload too short".into()));
|
||||
}
|
||||
|
||||
let counter_bytes: [u8; 8] = match payload[..8].try_into() {
|
||||
Ok(b) => b,
|
||||
Err(_) => return Err(PluginError::Other("invalid nonce".into())),
|
||||
};
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
nonce_bytes[4..12].copy_from_slice(&counter_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let plaintext = self
|
||||
.rx_cipher
|
||||
.decrypt(nonce, &payload[8..])
|
||||
.map_err(|err| PluginError::Other(format!("decryption failed: {err}")))?;
|
||||
|
||||
Ok(bytes::Bytes::from(plaintext))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! The `tcp-tls` protocol: TCP with TLS 1.3 on port 443 with Noise E2EE.
|
||||
|
||||
pub mod cert;
|
||||
pub mod crypto;
|
||||
pub mod plugin;
|
||||
pub mod transport;
|
||||
|
||||
pub use plugin::{
|
||||
TCP_TLS_PROTOCOL, TCP_TLS_VERSION, TcpTlsCodec, TcpTlsConfig, TcpTlsPlugin, local_ip_candidates,
|
||||
};
|
||||
pub use transport::TcpTlsTransport;
|
||||
@@ -0,0 +1,530 @@
|
||||
//! The `tcp-tls` IP plugin implementation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
use tsunagi::dataplane::tunnel::{GenericTunnelPlugin, TunnelCodec};
|
||||
use tsunagi::dataplane::{PluginError, ProtocolOption};
|
||||
use tsunagi::identity::NetworkId;
|
||||
|
||||
use crate::crypto::PeerCipher;
|
||||
use crate::transport::TcpTlsTransport;
|
||||
|
||||
/// The wire protocol identifier for `tcp-tls`.
|
||||
pub const TCP_TLS_PROTOCOL: &str = "tcp-tls";
|
||||
|
||||
/// Wire version for `tcp-tls`.
|
||||
pub const TCP_TLS_VERSION: u16 = 1;
|
||||
|
||||
/// Configuration for the `tcp-tls` protocol plugin.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TcpTlsConfig {
|
||||
/// Requested port to bind.
|
||||
pub port: Option<u16>,
|
||||
/// Custom SNI domain to disguise TLS connections.
|
||||
pub sni: Option<String>,
|
||||
}
|
||||
|
||||
impl TcpTlsConfig {
|
||||
/// Creates a configuration with default settings.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Sets the TCP listen port.
|
||||
pub fn with_port(mut self, port: u16) -> Self {
|
||||
self.port = Some(port);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the SNI domain used to disguise TLS connections.
|
||||
pub fn with_sni(mut self, sni: impl Into<String>) -> Self {
|
||||
self.sni = Some(sni.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Announcement payload exchanged between peers for `tcp-tls`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TcpTlsAnnouncement {
|
||||
/// X25519 public key for E2EE noise encryption.
|
||||
pub noise_public: [u8; 32],
|
||||
/// TCP port the peer is listening on.
|
||||
pub port: u16,
|
||||
/// Candidate IP addresses where this peer can be reached on `port`.
|
||||
#[serde(default)]
|
||||
pub addrs: Vec<std::net::IpAddr>,
|
||||
}
|
||||
|
||||
/// Discovers local network interface IPs to announce to peers.
|
||||
pub fn local_ip_candidates() -> Vec<std::net::IpAddr> {
|
||||
let mut candidates = Vec::new();
|
||||
for iface in netdev::get_interfaces() {
|
||||
if !is_candidate_interface(&iface) {
|
||||
continue;
|
||||
}
|
||||
for ip in &iface.ipv4 {
|
||||
let addr = ip.addr();
|
||||
if is_candidate_ipv4(addr) {
|
||||
let priority = ip_priority(&iface, std::net::IpAddr::V4(addr));
|
||||
candidates.push((priority, std::net::IpAddr::V4(addr)));
|
||||
}
|
||||
}
|
||||
for ip in &iface.ipv6 {
|
||||
let addr = ip.addr();
|
||||
if is_candidate_ip(std::net::IpAddr::V6(addr)) {
|
||||
let priority = ip_priority(&iface, std::net::IpAddr::V6(addr));
|
||||
candidates.push((priority, std::net::IpAddr::V6(addr)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort descending by priority so best interface IP is candidate #0
|
||||
candidates.sort_by_key(|b| std::cmp::Reverse(b.0));
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut ips = Vec::new();
|
||||
for (_, ip) in candidates {
|
||||
if seen.insert(ip) {
|
||||
ips.push(ip);
|
||||
}
|
||||
}
|
||||
ips
|
||||
}
|
||||
|
||||
pub(crate) fn is_candidate_interface(iface: &netdev::Interface) -> bool {
|
||||
if !iface.is_up() || iface.is_loopback() {
|
||||
return false;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Skip Tsunagi / Wintun overlay interfaces
|
||||
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")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip container, VM, and virtual bridge interfaces
|
||||
if name.starts_with("docker")
|
||||
|| name.starts_with("br-")
|
||||
|| name.starts_with("veth")
|
||||
|| name.starts_with("virbr")
|
||||
|| name.starts_with("cni")
|
||||
|| name.starts_with("flannel")
|
||||
|| name.starts_with("cali")
|
||||
|| name.starts_with("kube")
|
||||
|| name.starts_with("dummy")
|
||||
|| name.starts_with("lxc")
|
||||
|| name.starts_with("podman")
|
||||
|| friendly.contains("vethernet")
|
||||
|| friendly.contains("vboxnet")
|
||||
|| friendly.contains("vmnet")
|
||||
|| desc.contains("virtualbox")
|
||||
|| desc.contains("hyper-v")
|
||||
|| desc.contains("vmware")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn is_candidate_ipv4(addr: std::net::Ipv4Addr) -> bool {
|
||||
if addr.is_loopback() || addr.is_unspecified() || addr.is_link_local() || addr.is_broadcast() {
|
||||
return false;
|
||||
}
|
||||
if addr.is_documentation() {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn is_candidate_ip(addr: std::net::IpAddr) -> bool {
|
||||
match addr {
|
||||
std::net::IpAddr::V4(v4) => is_candidate_ipv4(v4),
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
if v6.is_loopback() || v6.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
let segments = v6.segments();
|
||||
// Filter link-local fe80::/10
|
||||
if (segments[0] & 0xffc0) == 0xfe80 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ip_priority(iface: &netdev::Interface, ip: std::net::IpAddr) -> u32 {
|
||||
let has_gateway = iface.gateway.is_some();
|
||||
let base = if has_gateway { 100 } else { 0 };
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
let octets = v4.octets();
|
||||
// Tailscale: 100.64.0.0/10
|
||||
let is_tailscale = octets[0] == 100 && (octets[1] & 0xc0) == 64;
|
||||
let is_private = v4.is_private();
|
||||
if !is_private && !is_tailscale {
|
||||
base + 50
|
||||
} else if has_gateway && is_private {
|
||||
base + 40
|
||||
} else if is_tailscale {
|
||||
base + 30
|
||||
} else {
|
||||
base + 10
|
||||
}
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
let segments = v6.segments();
|
||||
let is_global = (segments[0] & 0xe000) == 0x2000;
|
||||
if is_global { base + 45 } else { base + 20 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The codec implementing cryptographic transformation and announcements for `tcp-tls`.
|
||||
pub struct TcpTlsCodec {
|
||||
local_id: EndpointId,
|
||||
static_secret: StaticSecret,
|
||||
public_key: PublicKey,
|
||||
transport: Arc<TcpTlsTransport>,
|
||||
ciphers: RwLock<HashMap<(NetworkId, EndpointId), Arc<PeerCipher>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TcpTlsCodec {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("TcpTlsCodec")
|
||||
.field("local_id", &self.local_id.fmt_short().to_string())
|
||||
.field("port", &self.transport.bound_port())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TcpTlsCodec {
|
||||
/// Supported options for `tcp-tls`.
|
||||
pub const OPTIONS: &'static [ProtocolOption] = &[
|
||||
ProtocolOption {
|
||||
key: "port",
|
||||
value: "PORT",
|
||||
help: "TCP port to listen on for TLS 1.3 connections",
|
||||
default: Some("443"),
|
||||
},
|
||||
ProtocolOption {
|
||||
key: "sni",
|
||||
value: "DOMAIN",
|
||||
help: "Server Name Indication (SNI) domain to disguise TLS connections",
|
||||
default: Some(crate::cert::DEFAULT_SNI),
|
||||
},
|
||||
];
|
||||
|
||||
/// Creates a new `TcpTlsCodec`.
|
||||
pub fn new(local_id: EndpointId, transport: Arc<TcpTlsTransport>) -> Self {
|
||||
let mut secret_bytes = [0u8; 32];
|
||||
rand::fill(&mut secret_bytes);
|
||||
let static_secret = StaticSecret::from(secret_bytes);
|
||||
let public_key = PublicKey::from(&static_secret);
|
||||
|
||||
Self {
|
||||
local_id,
|
||||
static_secret,
|
||||
public_key,
|
||||
transport,
|
||||
ciphers: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures settings from parsed `-o` options.
|
||||
pub fn configure(
|
||||
mut config: TcpTlsConfig,
|
||||
options: &[(String, String)],
|
||||
) -> Result<TcpTlsConfig, String> {
|
||||
for (key, value) in options {
|
||||
match key.as_str() {
|
||||
"port" => {
|
||||
let port: u16 = value
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid port `{value}`"))?;
|
||||
config.port = Some(port);
|
||||
}
|
||||
"sni" => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("sni domain cannot be empty".into());
|
||||
}
|
||||
config.sni = Some(trimmed.to_string());
|
||||
}
|
||||
other => return Err(format!("unknown option `{other}` for tcp-tls")),
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
impl TunnelCodec for TcpTlsCodec {
|
||||
fn protocol_id(&self) -> &str {
|
||||
TCP_TLS_PROTOCOL
|
||||
}
|
||||
|
||||
fn protocol_version(&self) -> u16 {
|
||||
TCP_TLS_VERSION
|
||||
}
|
||||
|
||||
fn options(&self) -> &'static [ProtocolOption] {
|
||||
Self::OPTIONS
|
||||
}
|
||||
|
||||
fn local_capability_data(&self, _network: NetworkId) -> Result<Vec<u8>, PluginError> {
|
||||
let announcement = TcpTlsAnnouncement {
|
||||
noise_public: *self.public_key.as_bytes(),
|
||||
port: self.transport.bound_port(),
|
||||
addrs: local_ip_candidates(),
|
||||
};
|
||||
postcard::to_allocvec(&announcement)
|
||||
.map_err(|err| PluginError::Other(format!("encode announcement failed: {err}")))
|
||||
}
|
||||
|
||||
fn on_peer_capability_data(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
data: &[u8],
|
||||
) -> Result<(), PluginError> {
|
||||
let announcement: TcpTlsAnnouncement = postcard::from_bytes(data)
|
||||
.map_err(|err| PluginError::Rejected(format!("invalid announcement: {err}")))?;
|
||||
|
||||
for ip in &announcement.addrs {
|
||||
if is_candidate_ip(*ip) {
|
||||
self.transport
|
||||
.set_peer_addr(peer, std::net::SocketAddr::new(*ip, announcement.port));
|
||||
}
|
||||
}
|
||||
|
||||
let peer_noise_pubkey = PublicKey::from(announcement.noise_public);
|
||||
let cipher = Arc::new(PeerCipher::new(
|
||||
self.local_id,
|
||||
peer,
|
||||
&self.static_secret,
|
||||
&peer_noise_pubkey,
|
||||
));
|
||||
|
||||
let mut guard = match self.ciphers.write() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard.insert((network, peer), cipher);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_peer_down(&self, network: NetworkId, peer: EndpointId) {
|
||||
let mut guard = match self.ciphers.write() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard.remove(&(network, peer));
|
||||
}
|
||||
|
||||
fn encrypt(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
packet: &[u8],
|
||||
) -> Result<Bytes, PluginError> {
|
||||
let cipher = {
|
||||
let guard = match self.ciphers.read() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard.get(&(network, peer)).cloned()
|
||||
};
|
||||
|
||||
let Some(cipher) = cipher else {
|
||||
return Err(PluginError::Unavailable("no cipher for peer".into()));
|
||||
};
|
||||
|
||||
cipher.encrypt(packet)
|
||||
}
|
||||
|
||||
fn decrypt(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
payload: &[u8],
|
||||
) -> Result<Bytes, PluginError> {
|
||||
let cipher = {
|
||||
let guard = match self.ciphers.read() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard.get(&(network, peer)).cloned()
|
||||
};
|
||||
|
||||
let Some(cipher) = cipher else {
|
||||
return Err(PluginError::Unavailable("no cipher for peer".into()));
|
||||
};
|
||||
|
||||
cipher.decrypt(payload)
|
||||
}
|
||||
}
|
||||
|
||||
/// The complete `tcp-tls` plugin.
|
||||
pub type TcpTlsPlugin = GenericTunnelPlugin<TcpTlsCodec>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
#[test]
|
||||
fn test_is_candidate_interface_filters_overlay_and_bridges() {
|
||||
let mut iface = netdev::Interface::dummy();
|
||||
iface.flags = 1; // UP
|
||||
|
||||
// Standard Ethernet - should pass
|
||||
iface.name = "eth0".to_string();
|
||||
assert!(is_candidate_interface(&iface));
|
||||
|
||||
// Windows Ethernet with GUID name - should pass
|
||||
iface.name = "{B6149D2C-1234-5678-9ABC-DEF012345678}".to_string();
|
||||
iface.friendly_name = Some("Ethernet".to_string());
|
||||
iface.description = Some("Intel(R) Ethernet Connection".to_string());
|
||||
assert!(is_candidate_interface(&iface));
|
||||
|
||||
// Linux tsun0 overlay - must be filtered
|
||||
iface.name = "tsun0".to_string();
|
||||
iface.friendly_name = None;
|
||||
iface.description = None;
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Windows Wintun overlay (GUID name with tsun0 friendly name) - must be filtered!
|
||||
iface.name = "{72D85D76-F1A5-42AC-BC49-2169527CDDDF}".to_string();
|
||||
iface.friendly_name = Some("tsun0".to_string());
|
||||
iface.description = Some("Wintun Userspace Tunnel".to_string());
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Windows Wintun overlay with description containing Wintun - must be filtered!
|
||||
iface.friendly_name = Some("Local Area Connection 2".to_string());
|
||||
iface.description = Some("Wintun Userspace Tunnel".to_string());
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Docker bridge - must be filtered
|
||||
iface.name = "docker0".to_string();
|
||||
iface.friendly_name = None;
|
||||
iface.description = None;
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Docker network bridge br-* - must be filtered
|
||||
iface.name = "br-4f6d3a9b1c2e".to_string();
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Virtual ethernet veth* - must be filtered
|
||||
iface.name = "veth12345".to_string();
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Libvirt NAT bridge virbr* - must be filtered
|
||||
iface.name = "virbr0".to_string();
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// CNI bridge - must be filtered
|
||||
iface.name = "cni0".to_string();
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Hyper-V / WSL vEthernet switch on Windows - must be filtered
|
||||
iface.name = "{A1B2C3D4-E5F6-7890-1234-567890ABCDEF}".to_string();
|
||||
iface.friendly_name = Some("vEthernet (WSL)".to_string());
|
||||
iface.description = Some("Hyper-V Virtual Ethernet Adapter".to_string());
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Down interface - must be filtered
|
||||
let mut down_iface = netdev::Interface::dummy();
|
||||
down_iface.flags = 0; // Not UP
|
||||
down_iface.name = "eth0".to_string();
|
||||
assert!(!is_candidate_interface(&down_iface));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_candidate_ip() {
|
||||
// Valid LAN IP
|
||||
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||
192, 168, 1, 117
|
||||
))));
|
||||
// Valid Tailscale IP
|
||||
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||
100, 77, 155, 120
|
||||
))));
|
||||
// Valid Public IP
|
||||
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||
1, 1, 1, 1
|
||||
))));
|
||||
// Valid IPv6 GUA
|
||||
assert!(is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::new(
|
||||
0x2a01, 0x4b00, 0xb8e3, 0x4e00, 0, 0, 0, 1
|
||||
))));
|
||||
|
||||
// Loopback - must be rejected
|
||||
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||
127, 0, 0, 1
|
||||
))));
|
||||
assert!(!is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::LOCALHOST)));
|
||||
|
||||
// Unspecified - must be rejected
|
||||
assert!(!is_candidate_ip(std::net::IpAddr::V4(
|
||||
Ipv4Addr::UNSPECIFIED
|
||||
)));
|
||||
assert!(!is_candidate_ip(std::net::IpAddr::V6(
|
||||
Ipv6Addr::UNSPECIFIED
|
||||
)));
|
||||
|
||||
// Link-local - must be rejected
|
||||
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||
169, 254, 1, 1
|
||||
))));
|
||||
assert!(!is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::new(
|
||||
0xfe80, 0, 0, 0, 0, 0x1ff, 0xfe00, 1
|
||||
))));
|
||||
|
||||
// Broadcast - must be rejected
|
||||
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::BROADCAST)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ip_priority_ordering() {
|
||||
let mut iface_lan = netdev::Interface::dummy();
|
||||
iface_lan.flags = 1;
|
||||
iface_lan.gateway = Some(netdev::net::device::NetworkDevice::new());
|
||||
|
||||
let mut iface_vpn = netdev::Interface::dummy();
|
||||
iface_vpn.flags = 1;
|
||||
iface_vpn.name = "tailscale0".to_string();
|
||||
|
||||
let lan_ip = std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 1, 117));
|
||||
let tailscale_ip = std::net::IpAddr::V4(Ipv4Addr::new(100, 77, 155, 120));
|
||||
|
||||
let p_lan = ip_priority(&iface_lan, lan_ip);
|
||||
let p_ts = ip_priority(&iface_vpn, tailscale_ip);
|
||||
|
||||
// LAN IP with gateway should score higher than VPN IP without gateway
|
||||
assert!(p_lan > p_ts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
//! TCP TLS 1.3 transport implementation for Tsunagi.
|
||||
//!
|
||||
//! Provides [`TcpTlsTransport`] implementing [`PacketTransport`]
|
||||
//! and [`TcpTlsLink`] implementing [`PacketLink`].
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::{EndpointId, Signature};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{Notify, mpsc};
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
use tsunagi::BoxFuture;
|
||||
use tsunagi::dataplane::transport::{
|
||||
InboundLink, PacketLink, PacketTransport, SharedLink, TransportError,
|
||||
};
|
||||
use tsunagi::identity::{DeviceIdentity, NetworkId};
|
||||
|
||||
use crate::cert::{generate_self_signed_cert, make_client_config, make_server_config};
|
||||
|
||||
/// Default TCP port to bind and listen on.
|
||||
pub const DEFAULT_PORT: u16 = 443;
|
||||
|
||||
/// Fallback port if 443 is unprivileged on Linux and denied.
|
||||
pub const FALLBACK_PORT: u16 = 8443;
|
||||
|
||||
/// Maximum payload length for a single framed datagram over TLS.
|
||||
pub const MAX_FRAME_SIZE: usize = 65535;
|
||||
|
||||
/// Initial handshake message on established TLS stream (128 bytes).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LinkHandshake {
|
||||
/// The overlay network ID.
|
||||
pub network: NetworkId,
|
||||
/// The connecting client's endpoint ID.
|
||||
pub client_id: EndpointId,
|
||||
/// Ed25519 signature over `tsunagi-tcp-tls-link-v1` + network + server ID.
|
||||
pub signature: [u8; 64],
|
||||
}
|
||||
|
||||
impl LinkHandshake {
|
||||
/// Total encoded length in bytes.
|
||||
pub const LEN: usize = 32 + 32 + 64; // 128
|
||||
|
||||
/// Serializes to fixed-size byte array.
|
||||
pub fn to_bytes(&self) -> [u8; Self::LEN] {
|
||||
let mut buf = [0u8; Self::LEN];
|
||||
buf[0..32].copy_from_slice(self.network.as_bytes());
|
||||
buf[32..64].copy_from_slice(self.client_id.as_bytes());
|
||||
buf[64..128].copy_from_slice(&self.signature);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Deserializes from byte slice.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
|
||||
if bytes.len() != Self::LEN {
|
||||
return None;
|
||||
}
|
||||
let network = NetworkId::from_bytes(bytes[0..32].try_into().ok()?);
|
||||
let client_id = EndpointId::from_bytes(bytes[32..64].try_into().ok()?).ok()?;
|
||||
let signature: [u8; 64] = bytes[64..128].try_into().ok()?;
|
||||
Some(Self {
|
||||
network,
|
||||
client_id,
|
||||
signature,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Acknowledgement message on established TLS stream (1 byte).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LinkAck {
|
||||
/// Whether the link was accepted by the server.
|
||||
pub accepted: bool,
|
||||
}
|
||||
|
||||
impl LinkAck {
|
||||
/// Total encoded length in bytes.
|
||||
pub const LEN: usize = 1;
|
||||
|
||||
/// Serializes to fixed-size byte array.
|
||||
pub fn to_bytes(&self) -> [u8; 1] {
|
||||
[if self.accepted { 1 } else { 0 }]
|
||||
}
|
||||
|
||||
/// Deserializes from byte slice.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
|
||||
if bytes.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
accepted: bytes[0] == 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn link_handshake_bytes(network: NetworkId, server_id: EndpointId) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity(23 + 32 + 32);
|
||||
bytes.extend_from_slice(b"tsunagi-tcp-tls-link-v1");
|
||||
bytes.extend_from_slice(network.as_bytes());
|
||||
bytes.extend_from_slice(server_id.as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// An authenticated datagram link carried over a TCP TLS 1.3 stream.
|
||||
#[derive(Debug)]
|
||||
pub struct TcpTlsLink {
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
tx: mpsc::Sender<Bytes>,
|
||||
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
|
||||
closed_notify: Arc<Notify>,
|
||||
is_closed: Arc<AtomicBool>,
|
||||
remote_addr: String,
|
||||
}
|
||||
|
||||
impl TcpTlsLink {
|
||||
/// Creates a new `TcpTlsLink` from a stream that has already passed the TLS handshake.
|
||||
pub fn new<S>(network: NetworkId, peer: EndpointId, stream: S, remote_addr: String) -> Self
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static,
|
||||
{
|
||||
let (mut reader, mut writer) = tokio::io::split(stream);
|
||||
|
||||
let (tx_out, mut rx_out) = mpsc::channel::<Bytes>(256);
|
||||
let (tx_in, rx_in) = mpsc::channel::<Bytes>(256);
|
||||
|
||||
let is_closed = Arc::new(AtomicBool::new(false));
|
||||
let closed_notify = Arc::new(Notify::new());
|
||||
|
||||
let closed_flag_w = Arc::clone(&is_closed);
|
||||
let closed_notify_w = Arc::clone(&closed_notify);
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = rx_out.recv().await {
|
||||
if msg.len() > MAX_FRAME_SIZE {
|
||||
continue;
|
||||
}
|
||||
let len = msg.len() as u16;
|
||||
if writer.write_all(&len.to_be_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if writer.write_all(&msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if writer.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
closed_flag_w.store(true, Ordering::Release);
|
||||
closed_notify_w.notify_waiters();
|
||||
});
|
||||
|
||||
let closed_flag_r = Arc::clone(&is_closed);
|
||||
let closed_notify_r = Arc::clone(&closed_notify);
|
||||
tokio::spawn(async move {
|
||||
let mut len_buf = [0u8; 2];
|
||||
loop {
|
||||
if reader.read_exact(&mut len_buf).await.is_err() {
|
||||
break;
|
||||
}
|
||||
let len = u16::from_be_bytes(len_buf) as usize;
|
||||
if len > MAX_FRAME_SIZE {
|
||||
break;
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
if reader.read_exact(&mut buf).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if tx_in.send(Bytes::from(buf)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
closed_flag_r.store(true, Ordering::Release);
|
||||
closed_notify_r.notify_waiters();
|
||||
});
|
||||
|
||||
Self {
|
||||
network,
|
||||
peer,
|
||||
tx: tx_out,
|
||||
rx: tokio::sync::Mutex::new(rx_in),
|
||||
closed_notify,
|
||||
is_closed,
|
||||
remote_addr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketLink for TcpTlsLink {
|
||||
fn network(&self) -> NetworkId {
|
||||
self.network
|
||||
}
|
||||
|
||||
fn peer(&self) -> EndpointId {
|
||||
self.peer
|
||||
}
|
||||
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
MAX_FRAME_SIZE
|
||||
}
|
||||
|
||||
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
|
||||
if payload.len() > MAX_FRAME_SIZE {
|
||||
return Err(TransportError::TooLarge {
|
||||
size: payload.len(),
|
||||
limit: MAX_FRAME_SIZE,
|
||||
});
|
||||
}
|
||||
if self.is_closed.load(Ordering::Acquire) {
|
||||
return Err(TransportError::Closed);
|
||||
}
|
||||
self.tx
|
||||
.try_send(payload)
|
||||
.map_err(|_| TransportError::Closed)
|
||||
}
|
||||
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move {
|
||||
let mut guard = self.rx.lock().await;
|
||||
guard.recv().await
|
||||
})
|
||||
}
|
||||
|
||||
fn closed(&self) -> BoxFuture<'_, ()> {
|
||||
Box::pin(async move {
|
||||
if self.is_closed.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
self.closed_notify.notified().await;
|
||||
})
|
||||
}
|
||||
|
||||
fn is_closed(&self) -> bool {
|
||||
self.is_closed.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn path_description(&self) -> String {
|
||||
format!("tcp-tls via {}", self.remote_addr)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `tcp-tls` packet transport.
|
||||
pub struct TcpTlsTransport {
|
||||
identity: DeviceIdentity,
|
||||
local_id: EndpointId,
|
||||
listen_port: u16,
|
||||
sni: String,
|
||||
peer_addresses: std::sync::RwLock<HashMap<EndpointId, Vec<SocketAddr>>>,
|
||||
server_config: Arc<rustls::ServerConfig>,
|
||||
listener: Option<Arc<TcpListener>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TcpTlsTransport {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("TcpTlsTransport")
|
||||
.field("local_id", &self.local_id.fmt_short().to_string())
|
||||
.field("listen_port", &self.listen_port)
|
||||
.field("sni", &self.sni)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
struct NetBindServiceGuard {
|
||||
raised: bool,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl NetBindServiceGuard {
|
||||
fn acquire() -> Self {
|
||||
let already = caps::has_cap(
|
||||
None,
|
||||
caps::CapSet::Effective,
|
||||
caps::Capability::CAP_NET_BIND_SERVICE,
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if already {
|
||||
return Self { raised: false };
|
||||
}
|
||||
|
||||
let permitted = caps::has_cap(
|
||||
None,
|
||||
caps::CapSet::Permitted,
|
||||
caps::Capability::CAP_NET_BIND_SERVICE,
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if permitted
|
||||
&& caps::raise(
|
||||
None,
|
||||
caps::CapSet::Effective,
|
||||
caps::Capability::CAP_NET_BIND_SERVICE,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
Self { raised: true }
|
||||
} else {
|
||||
Self { raised: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drop for NetBindServiceGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.raised {
|
||||
let _ = caps::drop(
|
||||
None,
|
||||
caps::CapSet::Effective,
|
||||
caps::Capability::CAP_NET_BIND_SERVICE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TcpTlsTransport {
|
||||
/// Binds a new TCP TLS transport using the device identity, configured port, and SNI domain.
|
||||
pub async fn bind(
|
||||
identity: &DeviceIdentity,
|
||||
requested_port: Option<u16>,
|
||||
requested_sni: Option<String>,
|
||||
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let sni = requested_sni
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| crate::cert::DEFAULT_SNI.to_string());
|
||||
let (cert, key) = generate_self_signed_cert(&identity.secret_bytes(), &sni)?;
|
||||
let server_config = make_server_config(cert, key)?;
|
||||
|
||||
let target_port = requested_port.unwrap_or(DEFAULT_PORT);
|
||||
let bind_addr = SocketAddr::from(([0, 0, 0, 0], target_port));
|
||||
|
||||
let bind_result = {
|
||||
#[cfg(target_os = "linux")]
|
||||
let _guard = if target_port < 1024 {
|
||||
Some(NetBindServiceGuard::acquire())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
std::net::TcpListener::bind(bind_addr)
|
||||
};
|
||||
|
||||
let listener = match bind_result {
|
||||
Ok(std_listener) => {
|
||||
std_listener.set_nonblocking(true)?;
|
||||
let l = TcpListener::from_std(std_listener)?;
|
||||
tracing::info!(port = target_port, "TCP TLS listening");
|
||||
Some(Arc::new(l))
|
||||
}
|
||||
Err(err) if requested_port.is_none() => {
|
||||
let exe = std::env::current_exe()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|_| "tsunagi".into());
|
||||
|
||||
if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
tracing::warn!(
|
||||
port = target_port,
|
||||
%err,
|
||||
"Cannot bind to default port {target_port} without privileges. \
|
||||
To allow port 443 and TUN on Linux, grant capabilities once with:\n \
|
||||
sudo setcap cap_net_admin,cap_net_bind_service+p {exe}\n\
|
||||
Falling back to port {FALLBACK_PORT}."
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
port = target_port,
|
||||
%err,
|
||||
"Cannot bind to default port {target_port}: {err}. \
|
||||
Falling back to port {FALLBACK_PORT}."
|
||||
);
|
||||
}
|
||||
|
||||
let fallback_addr = SocketAddr::from(([0, 0, 0, 0], FALLBACK_PORT));
|
||||
match TcpListener::bind(fallback_addr).await {
|
||||
Ok(l) => {
|
||||
tracing::info!(port = FALLBACK_PORT, "TCP TLS listening on fallback port");
|
||||
Some(Arc::new(l))
|
||||
}
|
||||
Err(fallback_err) => {
|
||||
tracing::warn!(
|
||||
port = FALLBACK_PORT,
|
||||
err = %fallback_err,
|
||||
"Cannot bind to fallback port {FALLBACK_PORT}: {fallback_err}. \
|
||||
Falling back to ephemeral port."
|
||||
);
|
||||
let ephemeral_addr = SocketAddr::from(([0, 0, 0, 0], 0));
|
||||
let l = TcpListener::bind(ephemeral_addr).await?;
|
||||
let bound = l.local_addr()?.port();
|
||||
tracing::info!(port = bound, "TCP TLS listening on ephemeral port");
|
||||
Some(Arc::new(l))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let exe = std::env::current_exe()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|_| "tsunagi".into());
|
||||
tracing::warn!(
|
||||
port = target_port,
|
||||
%err,
|
||||
"Cannot bind to requested port {target_port}: {err}. \
|
||||
To allow port {target_port} and TUN on Linux, grant capabilities once with:\n \
|
||||
sudo setcap cap_net_admin,cap_net_bind_service+p {exe}"
|
||||
);
|
||||
return Err(Box::new(err));
|
||||
}
|
||||
};
|
||||
|
||||
let bound_port = listener
|
||||
.as_ref()
|
||||
.and_then(|l| l.local_addr().ok())
|
||||
.map(|a| a.port())
|
||||
.unwrap_or(target_port);
|
||||
|
||||
Ok(Self {
|
||||
identity: identity.clone(),
|
||||
local_id: identity.endpoint_id(),
|
||||
listen_port: bound_port,
|
||||
sni,
|
||||
peer_addresses: std::sync::RwLock::new(HashMap::new()),
|
||||
server_config,
|
||||
listener,
|
||||
})
|
||||
}
|
||||
|
||||
/// The port this transport is listening on.
|
||||
pub fn bound_port(&self) -> u16 {
|
||||
self.listen_port
|
||||
}
|
||||
|
||||
/// The SNI domain this transport advertises during TLS handshake.
|
||||
pub fn sni(&self) -> &str {
|
||||
&self.sni
|
||||
}
|
||||
|
||||
/// Records an observed TCP target address for a peer.
|
||||
pub fn set_peer_addr(&self, peer: EndpointId, addr: SocketAddr) {
|
||||
if let Ok(mut guard) = self.peer_addresses.write() {
|
||||
let addrs = guard.entry(peer).or_default();
|
||||
if !addrs.contains(&addr) {
|
||||
addrs.push(addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the accept loop for inbound TCP TLS connections.
|
||||
pub async fn accept_loop<F>(&self, on_inbound: F)
|
||||
where
|
||||
F: Fn(InboundLink) + Send + Sync + Clone + 'static,
|
||||
{
|
||||
let Some(listener) = self.listener.as_ref().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::clone(&self.server_config));
|
||||
let local_id = self.local_id;
|
||||
|
||||
while let Ok((socket, remote_addr)) = listener.accept().await {
|
||||
let acceptor = acceptor.clone();
|
||||
let on_inbound = on_inbound.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut tls_stream = match acceptor.accept(socket).await {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "inbound TLS handshake failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut hello_buf = [0u8; LinkHandshake::LEN];
|
||||
if tls_stream.read_exact(&mut hello_buf).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let Some(hello) = LinkHandshake::from_bytes(&hello_buf) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let msg = link_handshake_bytes(hello.network, local_id);
|
||||
let sig = Signature::from_bytes(&hello.signature);
|
||||
if hello.client_id.verify(&msg, &sig).is_err() {
|
||||
tracing::debug!("LinkHandshake signature verification failed");
|
||||
return;
|
||||
}
|
||||
|
||||
let ack = LinkAck { accepted: true };
|
||||
let ack_bytes = ack.to_bytes();
|
||||
if tls_stream.write_all(&ack_bytes).await.is_err() {
|
||||
return;
|
||||
}
|
||||
if tls_stream.flush().await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let link = TcpTlsLink::new(
|
||||
hello.network,
|
||||
hello.client_id,
|
||||
tls_stream,
|
||||
remote_addr.to_string(),
|
||||
);
|
||||
on_inbound(InboundLink {
|
||||
network: hello.network,
|
||||
peer: hello.client_id,
|
||||
protocol: "tcp-tls".into(),
|
||||
link: Arc::new(link),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketTransport for TcpTlsTransport {
|
||||
fn name(&self) -> &str {
|
||||
"tcp-tls"
|
||||
}
|
||||
|
||||
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>> {
|
||||
Box::pin(async move {
|
||||
if protocol != "tcp-tls" {
|
||||
return Err(TransportError::Declined(protocol.to_string()));
|
||||
}
|
||||
|
||||
let target_addrs = {
|
||||
let guard = self
|
||||
.peer_addresses
|
||||
.read()
|
||||
.map_err(|_| TransportError::Other("lock poisoned".into()))?;
|
||||
guard.get(&peer).cloned().unwrap_or_default()
|
||||
};
|
||||
|
||||
if target_addrs.is_empty() {
|
||||
return Err(TransportError::Unreachable(
|
||||
"no known TCP address for peer".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut connected_socket = None;
|
||||
let mut connected_addr = None;
|
||||
for addr in &target_addrs {
|
||||
if addr.ip().is_loopback() && addr.port() == self.listen_port {
|
||||
continue;
|
||||
}
|
||||
match tokio::time::timeout(Duration::from_secs(3), TcpStream::connect(*addr)).await
|
||||
{
|
||||
Ok(Ok(socket)) => {
|
||||
connected_socket = Some(socket);
|
||||
connected_addr = Some(*addr);
|
||||
break;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
let (socket, addr) = match (connected_socket, connected_addr) {
|
||||
(Some(s), Some(a)) => (s, a),
|
||||
_ => {
|
||||
return Err(TransportError::Unreachable(
|
||||
"cannot connect to any TCP address for peer".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let client_config =
|
||||
make_client_config(peer).map_err(|err| TransportError::Other(err.to_string()))?;
|
||||
let connector = TlsConnector::from(client_config);
|
||||
|
||||
let server_name = rustls::pki_types::ServerName::try_from(self.sni.as_str())
|
||||
.map_err(|err| TransportError::Other(err.to_string()))?
|
||||
.to_owned();
|
||||
|
||||
let mut tls_stream = connector
|
||||
.connect(server_name, socket)
|
||||
.await
|
||||
.map_err(|err| TransportError::Other(format!("TLS handshake failed: {err}")))?;
|
||||
|
||||
// Send LinkHandshake (fixed 128 bytes)
|
||||
let msg = link_handshake_bytes(network, peer);
|
||||
let sig = self.identity.sign(&msg);
|
||||
let hello = LinkHandshake {
|
||||
network,
|
||||
client_id: self.local_id,
|
||||
signature: sig.to_bytes(),
|
||||
};
|
||||
let hello_bytes = hello.to_bytes();
|
||||
tls_stream
|
||||
.write_all(&hello_bytes)
|
||||
.await
|
||||
.map_err(|err| TransportError::Other(err.to_string()))?;
|
||||
tls_stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|err| TransportError::Other(err.to_string()))?;
|
||||
|
||||
// Read LinkAck (fixed 1 byte)
|
||||
let mut ack_buf = [0u8; LinkAck::LEN];
|
||||
tls_stream
|
||||
.read_exact(&mut ack_buf)
|
||||
.await
|
||||
.map_err(|err| TransportError::Other(err.to_string()))?;
|
||||
let ack = LinkAck::from_bytes(&ack_buf)
|
||||
.ok_or_else(|| TransportError::Other("invalid link ack".into()))?;
|
||||
|
||||
if !ack.accepted {
|
||||
return Err(TransportError::Declined("link declined".into()));
|
||||
}
|
||||
|
||||
let link = TcpTlsLink::new(network, peer, tls_stream, addr.to_string());
|
||||
Ok(Arc::new(link) as SharedLink)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#![allow(missing_docs)]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use rustls::client::danger::ServerCertVerifier;
|
||||
use rustls::pki_types::{ServerName, UnixTime};
|
||||
use tsunagi::identity::DeviceIdentity;
|
||||
use tsunagi_tcp_tls::cert::{PinnedEndpointVerifier, generate_self_signed_cert};
|
||||
|
||||
#[test]
|
||||
fn test_generate_and_pin_certificate() {
|
||||
let identity = DeviceIdentity::generate();
|
||||
let peer_id = identity.endpoint_id();
|
||||
|
||||
let (cert, _key) =
|
||||
generate_self_signed_cert(&identity.secret_bytes(), tsunagi_tcp_tls::cert::DEFAULT_SNI)
|
||||
.expect("cert generation should succeed");
|
||||
|
||||
let verifier = PinnedEndpointVerifier::new(peer_id);
|
||||
let server_name = ServerName::try_from(tsunagi_tcp_tls::cert::DEFAULT_SNI).unwrap();
|
||||
|
||||
let verified = verifier.verify_server_cert(&cert, &[], &server_name, &[], UnixTime::now());
|
||||
|
||||
assert!(
|
||||
verified.is_ok(),
|
||||
"certificate matching peer endpoint id must verify"
|
||||
);
|
||||
|
||||
let other_identity = DeviceIdentity::generate();
|
||||
let wrong_verifier = PinnedEndpointVerifier::new(other_identity.endpoint_id());
|
||||
|
||||
let wrong_verified =
|
||||
wrong_verifier.verify_server_cert(&cert, &[], &server_name, &[], UnixTime::now());
|
||||
|
||||
assert!(
|
||||
wrong_verified.is_err(),
|
||||
"certificate with wrong endpoint id must fail verification"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_client_and_server_config() {
|
||||
let identity = DeviceIdentity::generate();
|
||||
let peer_id = identity.endpoint_id();
|
||||
|
||||
let (cert, key) = generate_self_signed_cert(&identity.secret_bytes(), "custom.example.com")
|
||||
.expect("cert generation should succeed");
|
||||
|
||||
let server_config = tsunagi_tcp_tls::cert::make_server_config(cert, key);
|
||||
assert!(server_config.is_ok());
|
||||
|
||||
let client_config = tsunagi_tcp_tls::cert::make_client_config(peer_id);
|
||||
assert!(client_config.is_ok());
|
||||
}
|
||||
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user