Compare commits
34
Commits
No files matched your search
@@ -0,0 +1,215 @@
|
||||
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
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
asset: tsunagi-linux-aarch64
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
- 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 tooling
|
||||
if: matrix.zig
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang libclang-dev
|
||||
- name: Install zig
|
||||
if: matrix.zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
# Pin a real, archived Zig release. Left unpinned the action resolves
|
||||
# to the newest version it knows of (a yet-unreleased 0.16.0-dev),
|
||||
# whose tarball 404s on ziglang.org while the community mirrors flake.
|
||||
version: 0.14.1
|
||||
- name: Install cargo-zigbuild
|
||||
if: matrix.zig
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-zigbuild
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ matrix.zig }}" == "true" ]]; then
|
||||
# zig's linker ignores the `-O1` GNU-ld optimization flag Rust passes
|
||||
# and says so on stderr, which rustc now surfaces as a linker_messages
|
||||
# warning. It is cosmetic — the binary is correct — so quiet just that
|
||||
# one lint to keep the musl build log clean. A real link error still
|
||||
# fails the build.
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} -A linker_messages"
|
||||
cargo zigbuild --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
||||
else
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
||||
fi
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
stage="dist/pkg/${{ matrix.asset }}"
|
||||
mkdir -p "$stage"
|
||||
|
||||
bin="tsunagi"
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
|
||||
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
|
||||
|
||||
# The top-level README, plus the per-OS install notes and integration
|
||||
# files, so the archive carries what the platform needs to set up.
|
||||
cp README.md "$stage/"
|
||||
case "${{ runner.os }}" in
|
||||
Linux)
|
||||
cp dist/linux/tsunagi.service dist/linux/install.sh "$stage/"
|
||||
cp dist/linux/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
|
||||
|
||||
# @v3 on purpose: Gitea does not support the v4 artifact backend. This is
|
||||
# what makes the archive show up under the run and hands it to `publish`.
|
||||
- name: Upload the archive as a run artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: ${{ matrix.asset }}-*.*
|
||||
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' \) -print0)
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,187 @@
|
||||
name: Release
|
||||
|
||||
# GitHub version of the release pipeline. The build half mirrors the Gitea one
|
||||
# in .gitea/workflows/release.yml (static musl Linux via cargo-zigbuild, native
|
||||
# macOS and Windows); only the publish half differs: GitHub needs the v4
|
||||
# artifact backend and fills the release with `gh`.
|
||||
#
|
||||
# Every job is gated on `github.server_url == 'https://github.com'`, so this file
|
||||
# runs only on GitHub. Gitea runs its own copy in .gitea/workflows; should a
|
||||
# Gitea instance also read .github/workflows, the guard skips these jobs there
|
||||
# so they never fail on the @v4 artifact backend or `gh`.
|
||||
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
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
asset: tsunagi-linux-aarch64
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
- 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 tooling
|
||||
if: matrix.zig
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang libclang-dev
|
||||
- name: Install zig
|
||||
if: matrix.zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
# Pin a real, archived Zig release. Left unpinned the action resolves
|
||||
# to the newest version it knows of (a yet-unreleased 0.16.0-dev),
|
||||
# whose tarball 404s on ziglang.org while the community mirrors flake.
|
||||
version: 0.14.1
|
||||
- name: Install cargo-zigbuild
|
||||
if: matrix.zig
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-zigbuild
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ matrix.zig }}" == "true" ]]; then
|
||||
# zig's linker ignores the `-O1` GNU-ld optimization flag Rust passes
|
||||
# and says so on stderr, which rustc now surfaces as a linker_messages
|
||||
# warning. It is cosmetic — the binary is correct — so quiet just that
|
||||
# one lint to keep the musl build log clean. A real link error still
|
||||
# fails the build.
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} -A linker_messages"
|
||||
cargo zigbuild --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
||||
else
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
||||
fi
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
stage="dist/pkg/${{ matrix.asset }}"
|
||||
mkdir -p "$stage"
|
||||
|
||||
bin="tsunagi"
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
|
||||
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
|
||||
|
||||
# The top-level README, plus the per-OS install notes and integration
|
||||
# files, so the archive carries what the platform needs to set up.
|
||||
cp README.md "$stage/"
|
||||
case "${{ runner.os }}" in
|
||||
Linux)
|
||||
cp dist/linux/tsunagi.service dist/linux/install.sh "$stage/"
|
||||
cp dist/linux/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
|
||||
|
||||
- name: Upload the archive as a run artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: ${{ matrix.asset }}-*.*
|
||||
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@v4
|
||||
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@v4 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' \))
|
||||
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,6 +13,18 @@ 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.
|
||||
@@ -78,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.
|
||||
@@ -125,7 +143,8 @@ 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
|
||||
|
||||
@@ -138,7 +157,7 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
| `crates/tsunagi/src/net.rs` | iroh endpoint adapter and observability snapshots |
|
||||
| `crates/tsunagi/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
|
||||
| `crates/tsunagi/src/state/` | signed records that outlive a session, their merge rules and address allocation |
|
||||
| `crates/tsunagi/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks |
|
||||
| `crates/tsunagi/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks, broadcast host rules |
|
||||
| `crates/tsunagi/src/dataplane/` | the protocol contract and the authenticated packet transport |
|
||||
| `crates/tsunagi/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 |
|
||||
|
||||
Generated
+388
-3
@@ -126,6 +126,45 @@ version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure 0.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -195,12 +234,47 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "attohttpc"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"http",
|
||||
"log",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backon"
|
||||
version = "1.6.0"
|
||||
@@ -236,6 +310,15 @@ version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
@@ -357,6 +440,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -476,6 +561,15 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
@@ -722,6 +816,20 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -844,6 +952,12 @@ dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
@@ -1010,6 +1124,12 @@ dependencies = [
|
||||
"spin 0.9.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1035,6 +1155,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.34"
|
||||
@@ -1233,6 +1359,25 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -1365,6 +1510,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1548,6 +1694,26 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "igd-next"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581"
|
||||
dependencies = [
|
||||
"attohttpc",
|
||||
"bytes",
|
||||
"futures",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"rand 0.10.3",
|
||||
"tokio",
|
||||
"url",
|
||||
"xmltree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
@@ -1640,6 +1806,7 @@ dependencies = [
|
||||
"papaya",
|
||||
"pin-project",
|
||||
"portable-atomic",
|
||||
"portmapper",
|
||||
"rand 0.10.3",
|
||||
"reqwest",
|
||||
"rustc-hash",
|
||||
@@ -1856,6 +2023,16 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.105"
|
||||
@@ -2026,6 +2203,12 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.3"
|
||||
@@ -2330,6 +2513,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "noq"
|
||||
version = "1.3.0"
|
||||
@@ -2401,12 +2594,31 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2528,6 +2740,15 @@ dependencies = [
|
||||
"objc2-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -2774,6 +2995,35 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portmapper"
|
||||
version = "0.19.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca97242f016e090a25330613bc2382aab65eb22f9149dbe84d8f8e711d49a530"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"derive_more",
|
||||
"hyper-util",
|
||||
"igd-next",
|
||||
"iroh-metrics",
|
||||
"libc",
|
||||
"n0-error",
|
||||
"n0-future",
|
||||
"netwatch",
|
||||
"num_enum",
|
||||
"rand 0.10.3",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"socket2",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
@@ -2899,6 +3149,19 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"x509-parser",
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -3044,6 +3307,15 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
@@ -3063,6 +3335,7 @@ version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
@@ -3127,6 +3400,7 @@ version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
@@ -3527,6 +3801,17 @@ dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.14.0"
|
||||
@@ -3927,7 +4212,7 @@ dependencies = [
|
||||
"hmac 0.13.0",
|
||||
"iroh",
|
||||
"mainline",
|
||||
"netdev 0.45.1",
|
||||
"netdev 0.46.3",
|
||||
"netwatch",
|
||||
"postcard",
|
||||
"rand 0.10.3",
|
||||
@@ -3941,10 +4226,12 @@ dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
"tld",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tsunagi",
|
||||
"tun",
|
||||
"windows-sys 0.61.2",
|
||||
"zbus",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -3964,7 +4251,38 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tsunagi",
|
||||
"tsunagi-tcp-tls",
|
||||
"tsunagi-wg-quic",
|
||||
"tsunagi-wireguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tsunagi-tcp-tls"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"caps",
|
||||
"chacha20poly1305",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"iroh",
|
||||
"netdev 0.46.3",
|
||||
"postcard",
|
||||
"rand 0.10.3",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tsunagi",
|
||||
"x25519-dalek",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3992,6 +4310,30 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tsunagi-wireguard"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"boringtun",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"hex",
|
||||
"iroh",
|
||||
"netdev 0.46.3",
|
||||
"postcard",
|
||||
"rand 0.10.3",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tsunagi",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tun"
|
||||
version = "0.8.14"
|
||||
@@ -4631,6 +4973,49 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"lazy_static",
|
||||
"nom",
|
||||
"oid-registry",
|
||||
"ring",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xmltree"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb"
|
||||
dependencies = [
|
||||
"xml-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
@@ -4651,7 +5036,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4757,7 +5142,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ repository = "https://github.com/tsunagi-net/tsunagi"
|
||||
|
||||
[workspace.lints.rust]
|
||||
missing_docs = "warn"
|
||||
unsafe_code = "forbid"
|
||||
unsafe_code = "deny"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
unwrap_used = "warn"
|
||||
@@ -27,7 +27,7 @@ expect_used = "warn"
|
||||
panic = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
iroh = { version = "1.2", default-features = false, features = ["tls-ring"] }
|
||||
iroh = { version = "1.2", default-features = false, features = ["tls-ring", "portmapper"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
|
||||
|
||||
@@ -362,11 +362,11 @@ tsunagi id key rotate replace that key
|
||||
tsunagi network the networks this device belongs to
|
||||
tsunagi network join -n lab -s tsn1… join one; adds it to a running agent
|
||||
tsunagi network join -n lab resume one this device has, or make it on the spot
|
||||
tsunagi network stop <id> stop serving it, keeping everything
|
||||
tsunagi network start <id> serve it again, from where it left off
|
||||
tsunagi network leave <id> give up the address and name, then forget it
|
||||
tsunagi network stop lab stop serving it, keeping everything
|
||||
tsunagi network start lab serve it again, from where it left off
|
||||
tsunagi network leave lab give up the address and name, then forget it
|
||||
tsunagi network secret the secret of each joined network
|
||||
tsunagi network secret <id> just that one, for copying
|
||||
tsunagi network secret lab just that one, for copying
|
||||
tsunagi network secret generate a fresh secret for a network that does not exist yet
|
||||
|
||||
tsunagi dns whether the local resolver is serving, and what
|
||||
@@ -478,6 +478,7 @@ The original IP packet, including its DF bit, is preserved. See
|
||||
| `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 |
|
||||
| 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 |
|
||||
|
||||
On Windows, put `wintun.dll` beside the executable and start `tsunagi up`
|
||||
@@ -489,9 +490,24 @@ of claiming the agent is absent or trying to edit its locked state.
|
||||
Creating a TUN without sufficient privileges also explains how to restart
|
||||
the agent with the required permissions.
|
||||
|
||||
**Not implemented yet.** macOS has no provisioner; `--no-tun` is the way to
|
||||
run it. The control plane and tunnels are unaffected. The decision logic
|
||||
that says *what* to change is shared and tested on every platform.
|
||||
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 tsunagi 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
|
||||
|
||||
@@ -599,13 +615,17 @@ received broadcasts never trigger another fanout.
|
||||
```sh
|
||||
tsunagi join -n games --no-broadcast
|
||||
tsunagi join -n games --broadcast
|
||||
tsunagi network broadcast <network-id-or-prefix> off
|
||||
tsunagi network broadcast <network-id-or-prefix> on
|
||||
tsunagi network broadcast games off
|
||||
tsunagi 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. The game must send through
|
||||
the Tsunagi interface. Physical LAN capture/subnet sharing is not implemented.
|
||||
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
|
||||
@@ -674,7 +694,7 @@ Membership outlives a session, so it also has to be possible to end it.
|
||||
|
||||
```bash
|
||||
tsunagi network # what this device belongs to
|
||||
tsunagi network leave <network-id> # give up the address and the name, then forget it
|
||||
tsunagi network leave lab # give up the address and the name, then forget it
|
||||
tsunagi wipe --yes # remove everything and be a stranger again
|
||||
```
|
||||
|
||||
@@ -703,9 +723,12 @@ nothing can sign or send it: the command says so and refuses, and `--offline`
|
||||
drops the network locally while leaving the others holding the old claim. The
|
||||
protocol key for that network goes too — rejoining is joining, not resuming.
|
||||
|
||||
A network is named by its id, never by its name: two networks can share a
|
||||
name, and choosing between them for the user is how the wrong one gets left. A
|
||||
unique prefix is enough.
|
||||
`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
|
||||
|
||||
@@ -17,7 +17,9 @@ doc = false
|
||||
|
||||
[dependencies]
|
||||
tsunagi = { path = "../tsunagi", version = "0.1.0", features = ["tun-device", "dns-publish"] }
|
||||
tsunagi-wireguard = { path = "../tsunagi-wireguard", version = "0.1.0" }
|
||||
tsunagi-wg-quic = { path = "../tsunagi-wg-quic", version = "0.1.0" }
|
||||
tsunagi-tcp-tls = { path = "../tsunagi-tcp-tls", version = "0.1.0" }
|
||||
iroh.workspace = true
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "signal"] }
|
||||
tracing.workspace = true
|
||||
|
||||
+908
-290
File diff suppressed because it is too large.
Load diff
@@ -25,17 +25,28 @@ impl Drop for Running {
|
||||
|
||||
impl Running {
|
||||
fn run(&self, args: &[&str]) -> std::process::Output {
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
.args(args)
|
||||
.arg("--state-dir")
|
||||
.arg(self.dir.path().join("state"))
|
||||
.arg("--cache-dir")
|
||||
.arg(self.dir.path().join("cache"))
|
||||
.output()
|
||||
.expect("the agent binary runs")
|
||||
run_in(&self.dir, args)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_in(dir: &TempDir, args: &[&str]) -> std::process::Output {
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
.args(args)
|
||||
.arg("--state-dir")
|
||||
.arg(dir.path().join("state"))
|
||||
.arg("--cache-dir")
|
||||
.arg(dir.path().join("cache"))
|
||||
.output()
|
||||
.expect("the agent binary runs")
|
||||
}
|
||||
|
||||
fn read_networks(dir: &TempDir) -> Vec<tsunagi::storage::StoredNetwork> {
|
||||
tsunagi::storage::StateStore::open(dir.path().join("state/state.sqlite"))
|
||||
.unwrap()
|
||||
.list_networks()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// An agent with no network at all, the way a daemon is started before
|
||||
/// anything has been decided.
|
||||
fn start_bare(port: u16) -> Running {
|
||||
@@ -155,7 +166,7 @@ fn a_network_can_be_stopped_and_resumed_without_losing_anything() {
|
||||
.to_string();
|
||||
assert!(secret_before.starts_with("tsn1"));
|
||||
|
||||
let stopped = agent.run(&["network", "stop", &id[..10]]);
|
||||
let stopped = agent.run(&["network", "stop", "resident"]);
|
||||
assert!(
|
||||
stopped.status.success(),
|
||||
"{}",
|
||||
@@ -181,7 +192,7 @@ fn a_network_can_be_stopped_and_resumed_without_losing_anything() {
|
||||
String::from_utf8_lossy(&again.stdout)
|
||||
);
|
||||
|
||||
let started = agent.run(&["network", "start", &id[..10]]);
|
||||
let started = agent.run(&["network", "start", "resident"]);
|
||||
assert!(started.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&started.stdout).contains("started `resident`"),
|
||||
@@ -192,7 +203,7 @@ fn a_network_can_be_stopped_and_resumed_without_losing_anything() {
|
||||
assert!(listed.contains("running"), "{listed}");
|
||||
|
||||
// And nothing was given up on the way: the same network, same secret.
|
||||
let secret_after = agent.run(&["network", "secret", &id[..10]]);
|
||||
let secret_after = agent.run(&["network", "secret", "resident"]);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&secret_after.stdout).trim(),
|
||||
secret_before,
|
||||
@@ -290,7 +301,7 @@ fn broadcast_choice_is_per_network_live_persistent_and_preserved_by_rejoin() {
|
||||
.broadcast
|
||||
);
|
||||
let id = disabled.network_id.to_string();
|
||||
let change = agent.run(&["network", "broadcast", &id, "on"]);
|
||||
let change = agent.run(&["network", "broadcast", "broadcast-off", "on"]);
|
||||
assert!(
|
||||
change.status.success(),
|
||||
"{}",
|
||||
@@ -312,3 +323,98 @@ fn broadcast_choice_is_per_network_live_persistent_and_preserved_by_rejoin() {
|
||||
.success()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_by_name_removes_only_that_network_from_a_running_agent() {
|
||||
let agent = start("resident", 0);
|
||||
assert!(agent.run(&["join", "-n", "games"]).status.success());
|
||||
let resident = read_networks(&agent.dir)
|
||||
.into_iter()
|
||||
.find(|network| network.name.as_str() == "resident")
|
||||
.unwrap();
|
||||
|
||||
let left = agent.run(&["network", "leave", "games"]);
|
||||
assert!(
|
||||
left.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&left.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&left.stdout).contains("left `games`"));
|
||||
let remaining = read_networks(&agent.dir);
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].network_id, resident.network_id);
|
||||
assert_eq!(
|
||||
remaining[0].secret.encode().as_str(),
|
||||
resident.secret.encode().as_str()
|
||||
);
|
||||
let status = agent.run(&["network"]);
|
||||
assert!(status.status.success());
|
||||
let out = String::from_utf8_lossy(&status.stdout);
|
||||
assert!(out.contains("resident") && out.contains("running"), "{out}");
|
||||
assert!(!out.contains("games"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_by_name_offline_keeps_the_explicit_offline_requirement() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
for name in ["resident", "games"] {
|
||||
assert!(run_in(&dir, &["join", "-n", name]).status.success());
|
||||
}
|
||||
let refused = run_in(&dir, &["network", "leave", "games"]);
|
||||
assert!(!refused.status.success());
|
||||
assert!(String::from_utf8_lossy(&refused.stderr).contains("--offline"));
|
||||
assert_eq!(read_networks(&dir).len(), 2);
|
||||
|
||||
let left = run_in(&dir, &["network", "leave", "games", "--offline"]);
|
||||
assert!(
|
||||
left.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&left.stderr)
|
||||
);
|
||||
let remaining = read_networks(&dir);
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].name.as_str(), "resident");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_and_unknown_names_leave_every_network_untouched() {
|
||||
let agent = start_bare(0);
|
||||
for secret in [
|
||||
"first-secret-for-the-cli-test",
|
||||
"second-secret-for-the-cli-test",
|
||||
] {
|
||||
assert!(
|
||||
agent
|
||||
.run(&["join", "-n", "games", "-s", secret])
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
}
|
||||
let before = read_networks(&agent.dir);
|
||||
assert_eq!(before.len(), 2);
|
||||
for name in ["games", "missing-network"] {
|
||||
let result = agent.run(&["network", "leave", name]);
|
||||
assert!(!result.status.success());
|
||||
let err = String::from_utf8_lossy(&result.stderr);
|
||||
if name == "games" {
|
||||
assert!(err.contains("matches 2 networks"), "{err}");
|
||||
for network in &before {
|
||||
assert!(err.contains(&network.network_id.to_string()), "{err}");
|
||||
assert!(!err.contains(network.secret.encode().as_str()), "{err}");
|
||||
}
|
||||
} else {
|
||||
assert!(err.contains("no configured network"), "{err}");
|
||||
}
|
||||
assert_eq!(read_networks(&agent.dir).len(), 2);
|
||||
}
|
||||
// The suggested full id still selects exactly one of the same-name networks.
|
||||
let left = agent.run(&["network", "leave", &before[0].network_id.to_string()]);
|
||||
assert!(
|
||||
left.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&left.stderr)
|
||||
);
|
||||
let remaining = read_networks(&agent.dir);
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].network_id, before[1].network_id);
|
||||
}
|
||||
@@ -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 = { path = "../tsunagi", 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 = { path = "../tsunagi", 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());
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#![allow(missing_docs)]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use tsunagi_tcp_tls::crypto::PeerCipher;
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
#[test]
|
||||
fn test_peer_cipher_bidirectional_roundtrip() {
|
||||
let peer_a_id = iroh::SecretKey::generate().public();
|
||||
let peer_b_id = iroh::SecretKey::generate().public();
|
||||
|
||||
let secret_a = StaticSecret::from([10u8; 32]);
|
||||
let public_a = PublicKey::from(&secret_a);
|
||||
|
||||
let secret_b = StaticSecret::from([20u8; 32]);
|
||||
let public_b = PublicKey::from(&secret_b);
|
||||
|
||||
let cipher_a = PeerCipher::new(peer_a_id, peer_b_id, &secret_a, &public_b);
|
||||
let cipher_b = PeerCipher::new(peer_b_id, peer_a_id, &secret_b, &public_a);
|
||||
|
||||
// A -> B
|
||||
let msg_from_a = b"hello from node A to node B over Noise E2EE";
|
||||
let encrypted_by_a = cipher_a
|
||||
.encrypt(msg_from_a)
|
||||
.expect("encryption should succeed");
|
||||
let decrypted_by_b = cipher_b
|
||||
.decrypt(&encrypted_by_a)
|
||||
.expect("decryption should succeed");
|
||||
assert_eq!(&decrypted_by_b[..], msg_from_a);
|
||||
|
||||
// B -> A
|
||||
let msg_from_b = b"hello back from node B to node A";
|
||||
let encrypted_by_b = cipher_b
|
||||
.encrypt(msg_from_b)
|
||||
.expect("encryption should succeed");
|
||||
let decrypted_by_a = cipher_a
|
||||
.decrypt(&encrypted_by_b)
|
||||
.expect("decryption should succeed");
|
||||
assert_eq!(&decrypted_by_a[..], msg_from_b);
|
||||
|
||||
// Sequence nonces increment
|
||||
let msg2 = b"packet 2";
|
||||
let enc2 = cipher_a.encrypt(msg2).expect("encryption should succeed");
|
||||
assert_ne!(encrypted_by_a[..8], enc2[..8], "nonces should differ");
|
||||
let dec2 = cipher_b.decrypt(&enc2).expect("decryption should succeed");
|
||||
assert_eq!(&dec2[..], msg2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tampered_payload_rejected() {
|
||||
let peer_a_id = iroh::SecretKey::generate().public();
|
||||
let peer_b_id = iroh::SecretKey::generate().public();
|
||||
|
||||
let secret_a = StaticSecret::from([10u8; 32]);
|
||||
let public_a = PublicKey::from(&secret_a);
|
||||
|
||||
let secret_b = StaticSecret::from([20u8; 32]);
|
||||
let public_b = PublicKey::from(&secret_b);
|
||||
|
||||
let cipher_a = PeerCipher::new(peer_a_id, peer_b_id, &secret_a, &public_b);
|
||||
let cipher_b = PeerCipher::new(peer_b_id, peer_a_id, &secret_b, &public_a);
|
||||
|
||||
let encrypted = cipher_a.encrypt(b"secret payload").expect("encrypt");
|
||||
// Corrupt one byte of ciphertext
|
||||
let len = encrypted.len();
|
||||
let mut modified = encrypted.to_vec();
|
||||
modified[len - 1] ^= 0xff;
|
||||
|
||||
assert!(
|
||||
cipher_b.decrypt(&modified).is_err(),
|
||||
"corrupted ciphertext must fail AEAD verification"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
#![allow(missing_docs)]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::mpsc;
|
||||
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
|
||||
use tsunagi::dataplane::{IpPlugin, PacketSink, PluginContext};
|
||||
use tsunagi::identity::{DeviceIdentity, NetworkId};
|
||||
use tsunagi_tcp_tls::{TcpTlsCodec, TcpTlsPlugin, TcpTlsTransport};
|
||||
|
||||
struct TestSink {
|
||||
delivered: mpsc::Sender<(NetworkId, iroh::EndpointId, Bytes)>,
|
||||
}
|
||||
|
||||
impl PacketSink for TestSink {
|
||||
fn deliver(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
from: iroh::EndpointId,
|
||||
packet: Bytes,
|
||||
) -> tsunagi::BoxFuture<'_, ()> {
|
||||
let delivered = self.delivered.clone();
|
||||
Box::pin(async move {
|
||||
let _ = delivered.send((network, from, packet)).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tcp_tls_plugin_end_to_end() {
|
||||
let identity_a = DeviceIdentity::generate();
|
||||
let peer_a = identity_a.endpoint_id();
|
||||
|
||||
let identity_b = DeviceIdentity::generate();
|
||||
let peer_b = identity_b.endpoint_id();
|
||||
|
||||
let network = NetworkId::from_bytes([77u8; 32]);
|
||||
|
||||
// Transports
|
||||
let transport_b = Arc::new(
|
||||
TcpTlsTransport::bind(&identity_b, Some(0), None)
|
||||
.await
|
||||
.expect("bind B"),
|
||||
);
|
||||
let port_b = transport_b.bound_port();
|
||||
|
||||
let transport_a = Arc::new(
|
||||
TcpTlsTransport::bind(&identity_a, Some(0), None)
|
||||
.await
|
||||
.expect("bind A"),
|
||||
);
|
||||
|
||||
// Codecs and plugins
|
||||
let codec_a = TcpTlsCodec::new(peer_a, Arc::clone(&transport_a));
|
||||
let plugin_a = Arc::new(TcpTlsPlugin::new(codec_a));
|
||||
|
||||
let codec_b = TcpTlsCodec::new(peer_b, Arc::clone(&transport_b));
|
||||
let plugin_b = Arc::new(TcpTlsPlugin::new(codec_b));
|
||||
|
||||
// Attach sinks
|
||||
let (sink_a_tx, mut sink_a_rx) = mpsc::channel(10);
|
||||
plugin_a.attach(PluginContext::with_sink(Arc::new(TestSink {
|
||||
delivered: sink_a_tx,
|
||||
})));
|
||||
|
||||
let (sink_b_tx, mut sink_b_rx) = mpsc::channel(10);
|
||||
plugin_b.attach(PluginContext::with_sink(Arc::new(TestSink {
|
||||
delivered: sink_b_tx,
|
||||
})));
|
||||
|
||||
// Capability exchange
|
||||
let cap_a = plugin_a
|
||||
.local_capability(network)
|
||||
.expect("cap A")
|
||||
.expect("some cap A");
|
||||
let cap_b = plugin_b
|
||||
.local_capability(network)
|
||||
.expect("cap B")
|
||||
.expect("some cap B");
|
||||
|
||||
plugin_a
|
||||
.on_peer_capability(network, peer_b, &cap_b)
|
||||
.expect("A on cap B");
|
||||
plugin_b
|
||||
.on_peer_capability(network, peer_a, &cap_a)
|
||||
.expect("B on cap A");
|
||||
|
||||
// Spawn accept loop on B
|
||||
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
|
||||
let transport_b_clone = Arc::clone(&transport_b);
|
||||
tokio::spawn(async move {
|
||||
transport_b_clone
|
||||
.accept_loop(move |inbound| {
|
||||
let _ = inbound_tx.try_send(inbound);
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
// Make sure A knows B's loopback address
|
||||
transport_a.set_peer_addr(peer_b, format!("127.0.0.1:{port_b}").parse().unwrap());
|
||||
|
||||
// Connect link from A to B
|
||||
let link_a = transport_a
|
||||
.open(network, peer_b, "tcp-tls")
|
||||
.await
|
||||
.expect("open link from A");
|
||||
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
|
||||
.await
|
||||
.expect("recv timeout")
|
||||
.expect("recv inbound");
|
||||
let link_b = inbound_b.link;
|
||||
|
||||
// Install links into plugins
|
||||
plugin_a.on_peer_link(network, peer_b, link_a);
|
||||
plugin_b.on_peer_link(network, peer_a, link_b);
|
||||
|
||||
// Carry packet from A to B
|
||||
let ip_packet_a = Bytes::from_static(b"4500003c000100004011e0e8c0a80101c0a8010200000000");
|
||||
let carried_a = plugin_a.carry(network, peer_b, ip_packet_a.clone());
|
||||
assert!(carried_a, "plugin A should carry packet");
|
||||
|
||||
let (net_received, from_peer, delivered_pkt) =
|
||||
tokio::time::timeout(Duration::from_secs(3), sink_b_rx.recv())
|
||||
.await
|
||||
.expect("timeout waiting for delivery on B")
|
||||
.expect("packet on B");
|
||||
|
||||
assert_eq!(net_received, network);
|
||||
assert_eq!(from_peer, peer_a);
|
||||
assert_eq!(delivered_pkt, ip_packet_a);
|
||||
|
||||
// Carry reply packet from B to A
|
||||
let ip_packet_b = Bytes::from_static(b"reply-packet-from-b");
|
||||
let carried_b = plugin_b.carry(network, peer_a, ip_packet_b.clone());
|
||||
assert!(carried_b, "plugin B should carry packet");
|
||||
|
||||
let (net_received, from_peer, delivered_pkt) =
|
||||
tokio::time::timeout(Duration::from_secs(3), sink_a_rx.recv())
|
||||
.await
|
||||
.expect("timeout waiting for delivery on A")
|
||||
.expect("packet on A");
|
||||
|
||||
assert_eq!(net_received, network);
|
||||
assert_eq!(from_peer, peer_b);
|
||||
assert_eq!(delivered_pkt, ip_packet_b);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#![allow(missing_docs)]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::mpsc;
|
||||
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
|
||||
use tsunagi::identity::{DeviceIdentity, NetworkId};
|
||||
use tsunagi_tcp_tls::TcpTlsTransport;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tcp_tls_transport_loopback() {
|
||||
let identity_a = DeviceIdentity::generate();
|
||||
let peer_a = identity_a.endpoint_id();
|
||||
|
||||
let identity_b = DeviceIdentity::generate();
|
||||
let peer_b = identity_b.endpoint_id();
|
||||
|
||||
let network = NetworkId::from_bytes([42u8; 32]);
|
||||
|
||||
// Bind transport B on port 0 (ephemeral)
|
||||
let transport_b = Arc::new(
|
||||
TcpTlsTransport::bind(&identity_b, Some(0), Some("custom.example.com".to_string()))
|
||||
.await
|
||||
.expect("bind server should succeed"),
|
||||
);
|
||||
let port_b = transport_b.bound_port();
|
||||
assert!(port_b > 0);
|
||||
assert_eq!(transport_b.sni(), "custom.example.com");
|
||||
|
||||
// Bind transport A on port 0 (ephemeral)
|
||||
let transport_a = Arc::new(
|
||||
TcpTlsTransport::bind(&identity_a, Some(0), Some("custom.example.com".to_string()))
|
||||
.await
|
||||
.expect("bind client should succeed"),
|
||||
);
|
||||
assert_eq!(transport_a.sni(), "custom.example.com");
|
||||
|
||||
// Register B's address on transport A
|
||||
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
|
||||
transport_a.set_peer_addr(peer_b, addr_b);
|
||||
|
||||
// Spawn accept loop on B
|
||||
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
|
||||
let transport_b_clone = Arc::clone(&transport_b);
|
||||
tokio::spawn(async move {
|
||||
transport_b_clone
|
||||
.accept_loop(move |inbound| {
|
||||
let _ = inbound_tx.try_send(inbound);
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
// Open link from A to B
|
||||
let link_a = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
transport_a.open(network, peer_b, "tcp-tls"),
|
||||
)
|
||||
.await
|
||||
.expect("open should not timeout")
|
||||
.expect("open should succeed");
|
||||
|
||||
// Receive inbound link on B
|
||||
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
|
||||
.await
|
||||
.expect("accept should not timeout")
|
||||
.expect("inbound link should arrive");
|
||||
|
||||
assert_eq!(inbound_b.network, network);
|
||||
assert_eq!(inbound_b.peer, peer_a);
|
||||
assert_eq!(inbound_b.protocol, "tcp-tls");
|
||||
let link_b = inbound_b.link;
|
||||
|
||||
// Send from A to B
|
||||
let data_a_to_b = Bytes::from_static(b"packet from A to B");
|
||||
link_a.send(data_a_to_b.clone()).expect("send A->B");
|
||||
let received_on_b = tokio::time::timeout(Duration::from_secs(2), link_b.recv())
|
||||
.await
|
||||
.expect("recv B timeout")
|
||||
.expect("recv B option");
|
||||
assert_eq!(received_on_b, data_a_to_b);
|
||||
|
||||
// Send from B to A
|
||||
let data_b_to_a = Bytes::from_static(b"reply from B to A");
|
||||
link_b.send(data_b_to_a.clone()).expect("send B->A");
|
||||
let received_on_a = tokio::time::timeout(Duration::from_secs(2), link_a.recv())
|
||||
.await
|
||||
.expect("recv A timeout")
|
||||
.expect("recv A option");
|
||||
assert_eq!(received_on_a, data_b_to_a);
|
||||
|
||||
// Path descriptions
|
||||
assert!(link_a.path_description().contains("tcp-tls"));
|
||||
assert!(link_b.path_description().contains("tcp-tls"));
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "tsunagi-wireguard"
|
||||
version = "0.1.0"
|
||||
description = "Pure WireGuard direct UDP transport and protocol plugin for tsunagi mesh networks."
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tsunagi = { path = "../tsunagi", version = "0.1.0" }
|
||||
iroh.workspace = true
|
||||
tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macros"] }
|
||||
serde.workspace = true
|
||||
postcard.workspace = true
|
||||
bytes.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
data-encoding.workspace = true
|
||||
hex.workspace = true
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.9", features = ["derive"] }
|
||||
rand = "0.10"
|
||||
boringtun = { version = "0.7.1", default-features = false }
|
||||
netdev = "0.46"
|
||||
|
||||
[dev-dependencies]
|
||||
tsunagi = { path = "../tsunagi", features = ["testing"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
tempfile.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,142 @@
|
||||
//! What a pure WireGuard peer tells the network about itself.
|
||||
//!
|
||||
//! Exchanged via [`tsunagi::dataplane::PluginCapability`]. The announcement
|
||||
//! carries the WireGuard public key, the listening UDP port, and candidate IP addresses
|
||||
//! for direct UDP packet transport.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use tsunagi::dataplane::PluginError;
|
||||
use tsunagi::identity::NetworkId;
|
||||
|
||||
use crate::keys::WgPublicKey;
|
||||
|
||||
/// The wire protocol identifier for pure WireGuard.
|
||||
pub const WG_PROTOCOL: &str = "wg";
|
||||
|
||||
/// Wire version of the `wg` announcement format.
|
||||
pub const ANNOUNCEMENT_VERSION: u16 = 1;
|
||||
|
||||
/// Wire version peers compare during capability exchange.
|
||||
///
|
||||
/// This is the same as [`ANNOUNCEMENT_VERSION`]; keeping a separate name
|
||||
/// makes the intent explicit in the CLI protocol registry.
|
||||
pub const WG_WIRE_VERSION: u16 = ANNOUNCEMENT_VERSION;
|
||||
|
||||
/// Maximum number of IP candidates allowed in an announcement.
|
||||
pub const MAX_CANDIDATE_ADDRS: usize = 32;
|
||||
|
||||
/// What one participant advertises for the pure WireGuard UDP data plane.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WgAnnouncement {
|
||||
/// Announcement format version.
|
||||
pub version: u16,
|
||||
/// The peer's WireGuard public key.
|
||||
pub public_key: [u8; 32],
|
||||
/// The network this key is for.
|
||||
pub network: [u8; 32],
|
||||
/// The UDP port the peer is listening on for WireGuard packets.
|
||||
pub port: u16,
|
||||
/// Candidate IP addresses where this peer can be reached on `port`.
|
||||
#[serde(default)]
|
||||
pub addrs: Vec<std::net::IpAddr>,
|
||||
}
|
||||
|
||||
/// A peer announcement that has been validated against a specific network.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ValidatedAnnouncement {
|
||||
/// The peer's WireGuard public key.
|
||||
pub public_key: WgPublicKey,
|
||||
/// The UDP port the peer listens on.
|
||||
pub port: u16,
|
||||
/// Validated candidate IP addresses.
|
||||
pub addrs: Vec<std::net::IpAddr>,
|
||||
}
|
||||
|
||||
impl WgAnnouncement {
|
||||
/// Builds this agent's announcement.
|
||||
pub fn new(
|
||||
network: NetworkId,
|
||||
public_key: &WgPublicKey,
|
||||
port: u16,
|
||||
addrs: Vec<std::net::IpAddr>,
|
||||
) -> Self {
|
||||
Self {
|
||||
version: ANNOUNCEMENT_VERSION,
|
||||
public_key: *public_key.as_bytes(),
|
||||
network: *network.as_bytes(),
|
||||
port,
|
||||
addrs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes the announcement into the opaque capability payload.
|
||||
pub fn encode(&self) -> Result<Vec<u8>, PluginError> {
|
||||
postcard::to_allocvec(self)
|
||||
.map_err(|err| PluginError::Other(format!("cannot encode announcement: {err}")))
|
||||
}
|
||||
|
||||
/// Decodes and validates a payload received from a peer.
|
||||
pub fn decode_and_validate(
|
||||
payload: &[u8],
|
||||
network: NetworkId,
|
||||
local_key: &WgPublicKey,
|
||||
) -> Result<ValidatedAnnouncement, PluginError> {
|
||||
let announcement: Self = postcard::from_bytes(payload)
|
||||
.map_err(|_| PluginError::Rejected("malformed WireGuard announcement".into()))?;
|
||||
announcement.validate(network, local_key)
|
||||
}
|
||||
|
||||
fn validate(
|
||||
self,
|
||||
network: NetworkId,
|
||||
local_key: &WgPublicKey,
|
||||
) -> Result<ValidatedAnnouncement, PluginError> {
|
||||
if self.version != ANNOUNCEMENT_VERSION {
|
||||
return Err(PluginError::Rejected(format!(
|
||||
"peer speaks WireGuard announcement version {} but this build speaks \
|
||||
{ANNOUNCEMENT_VERSION}; one of the two needs updating",
|
||||
self.version
|
||||
)));
|
||||
}
|
||||
|
||||
let public_key = WgPublicKey::from_bytes(self.public_key);
|
||||
if public_key.is_zero() {
|
||||
return Err(PluginError::Rejected(
|
||||
"WireGuard public key is all zeroes".into(),
|
||||
));
|
||||
}
|
||||
if &public_key == local_key {
|
||||
return Err(PluginError::Rejected(
|
||||
"peer announced this agent's own WireGuard key".into(),
|
||||
));
|
||||
}
|
||||
if self.network != *network.as_bytes() {
|
||||
return Err(PluginError::Rejected(
|
||||
"announcement is for a different network".into(),
|
||||
));
|
||||
}
|
||||
if self.port == 0 {
|
||||
return Err(PluginError::Rejected("WireGuard port cannot be 0".into()));
|
||||
}
|
||||
if self.addrs.len() > MAX_CANDIDATE_ADDRS {
|
||||
return Err(PluginError::Rejected(format!(
|
||||
"too many candidate addresses: {} > {MAX_CANDIDATE_ADDRS}",
|
||||
self.addrs.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut valid_addrs = Vec::new();
|
||||
for addr in self.addrs {
|
||||
if !addr.is_unspecified() && !valid_addrs.contains(&addr) {
|
||||
valid_addrs.push(addr);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ValidatedAnnouncement {
|
||||
public_key,
|
||||
port: self.port,
|
||||
addrs: valid_addrs,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
//! Userspace WireGuard device implementation over direct UDP links.
|
||||
//!
|
||||
//! Uses [`boringtun::noise::Tunn`] state machine to encrypt and decrypt
|
||||
//! standard WireGuard protocol packets directly over a [`SharedLink`].
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use boringtun::noise::{Tunn, TunnResult};
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use tsunagi::dataplane::routing::{FlowId, flow::ip_flow};
|
||||
use tsunagi::dataplane::transport::{SharedLink, TransportError};
|
||||
use tsunagi::dataplane::{PacketSink, PluginContext, PluginError};
|
||||
use tsunagi::identity::NetworkId;
|
||||
|
||||
use crate::keys::{WgPublicKey, WgSecretKey};
|
||||
use crate::transport::WireguardTransport;
|
||||
|
||||
const TIMER_INTERVAL: Duration = Duration::from_millis(250);
|
||||
const SCRATCH: usize = 4096;
|
||||
|
||||
/// Maximum consecutive rekey timeout retransmissions before declaring a tunnel dead.
|
||||
pub const MAX_REKEY_TIMEOUTS: u32 = 3;
|
||||
|
||||
/// Overall maximum duration trying to establish or rekey a handshake before declaring it dead.
|
||||
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
struct FlowTunnel {
|
||||
tunn: Tunn,
|
||||
queued: VecDeque<FlowId>,
|
||||
handshake_started_at: Option<std::time::Instant>,
|
||||
rekey_timeouts: u32,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
fn is_data(result: &TunnResult<'_>) -> bool {
|
||||
matches!(result, TunnResult::WriteToNetwork(bytes) if bytes.starts_with(&[4, 0, 0, 0]))
|
||||
}
|
||||
|
||||
impl FlowTunnel {
|
||||
fn encapsulate<'a>(
|
||||
&mut self,
|
||||
packet: &[u8],
|
||||
scratch: &'a mut [u8],
|
||||
flow: FlowId,
|
||||
) -> (TunnResult<'a>, FlowId) {
|
||||
let result = self.tunn.encapsulate(packet, scratch);
|
||||
if is_data(&result) {
|
||||
return (result, flow);
|
||||
}
|
||||
if matches!(result, TunnResult::WriteToNetwork(ref b) if !b.is_empty() && b[0] == 1)
|
||||
&& self.handshake_started_at.is_none()
|
||||
{
|
||||
self.handshake_started_at = Some(std::time::Instant::now());
|
||||
self.rekey_timeouts = 0;
|
||||
self.failed = false;
|
||||
}
|
||||
if self.queued.len() < 256 {
|
||||
self.queued.push_back(flow);
|
||||
}
|
||||
(result, 0)
|
||||
}
|
||||
|
||||
fn decapsulate<'a>(
|
||||
&mut self,
|
||||
packet: &[u8],
|
||||
scratch: &'a mut [u8],
|
||||
) -> (TunnResult<'a>, FlowId) {
|
||||
let result = self.tunn.decapsulate(None, packet, scratch);
|
||||
if self.tunn.time_since_last_handshake().is_some() {
|
||||
self.handshake_started_at = None;
|
||||
self.rekey_timeouts = 0;
|
||||
self.failed = false;
|
||||
}
|
||||
let mut flow = 0;
|
||||
if packet.is_empty() {
|
||||
if is_data(&result) {
|
||||
flow = self.queued.pop_front().unwrap_or(0);
|
||||
} else if let Some(pending) = self.queued.pop_front() {
|
||||
self.queued.push_back(pending);
|
||||
}
|
||||
}
|
||||
(result, flow)
|
||||
}
|
||||
|
||||
fn update_timers<'a>(&mut self, scratch: &'a mut [u8]) -> (TunnResult<'a>, bool) {
|
||||
if self.failed {
|
||||
return (TunnResult::Done, false);
|
||||
}
|
||||
|
||||
let result = self.tunn.update_timers(scratch);
|
||||
if matches!(
|
||||
result,
|
||||
TunnResult::Err(boringtun::noise::errors::WireGuardError::ConnectionExpired)
|
||||
) || self.tunn.is_expired()
|
||||
{
|
||||
self.failed = true;
|
||||
self.queued.clear();
|
||||
self.handshake_started_at = None;
|
||||
self.rekey_timeouts = 0;
|
||||
return (result, true);
|
||||
}
|
||||
|
||||
if let TunnResult::WriteToNetwork(ref bytes) = result
|
||||
&& !bytes.is_empty()
|
||||
&& bytes[0] == 1
|
||||
&& self.handshake_started_at.is_none()
|
||||
{
|
||||
self.handshake_started_at = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
if let Some(started) = self.handshake_started_at
|
||||
&& started.elapsed() >= HANDSHAKE_TIMEOUT
|
||||
{
|
||||
self.failed = true;
|
||||
self.queued.clear();
|
||||
self.handshake_started_at = None;
|
||||
self.rekey_timeouts = 0;
|
||||
return (result, true);
|
||||
}
|
||||
|
||||
(result, false)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct PeerCounters {
|
||||
tx_packets: AtomicU64,
|
||||
tx_bytes: AtomicU64,
|
||||
rx_packets: AtomicU64,
|
||||
rx_bytes: AtomicU64,
|
||||
dropped_wrong_source: AtomicU64,
|
||||
dropped_oversize: AtomicU64,
|
||||
dropped_no_session: AtomicU64,
|
||||
protocol_errors: AtomicU64,
|
||||
}
|
||||
|
||||
/// A snapshot of one peer's tunnel counters.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct PeerStats {
|
||||
/// Plaintext packets encrypted and sent to this peer.
|
||||
pub tx_packets: u64,
|
||||
/// Plaintext bytes encrypted and sent to this peer.
|
||||
pub tx_bytes: u64,
|
||||
/// Plaintext packets decrypted from this peer and given to the OS.
|
||||
pub rx_packets: u64,
|
||||
/// Plaintext bytes decrypted from this peer and given to the OS.
|
||||
pub rx_bytes: u64,
|
||||
/// Packets dropped because their source was not this peer's address.
|
||||
pub dropped_wrong_source: u64,
|
||||
/// Packets dropped because they did not fit in one link datagram.
|
||||
pub dropped_oversize: u64,
|
||||
/// Packets dropped because there was no session to encrypt with yet.
|
||||
pub dropped_no_session: u64,
|
||||
/// WireGuard protocol errors, including packets that failed to decrypt.
|
||||
pub protocol_errors: u64,
|
||||
}
|
||||
|
||||
/// Whether a peer's tunnel has completed a handshake.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PeerHealth {
|
||||
/// Time since the last successful WireGuard handshake.
|
||||
pub since_handshake: Option<Duration>,
|
||||
}
|
||||
|
||||
impl PeerHealth {
|
||||
/// Whether the tunnel has ever completed a handshake.
|
||||
pub fn is_up(&self) -> bool {
|
||||
self.since_handshake.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
struct Peer {
|
||||
endpoint_id: EndpointId,
|
||||
public_key: WgPublicKey,
|
||||
overlay_v4: Mutex<Option<Ipv4Addr>>,
|
||||
tunn: Mutex<FlowTunnel>,
|
||||
link: SharedLink,
|
||||
counters: Arc<PeerCounters>,
|
||||
task: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Peer {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Peer")
|
||||
.field("peer", &self.endpoint_id.fmt_short().to_string())
|
||||
.field("public_key", &self.public_key)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Peer {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut guard) = self.task.lock()
|
||||
&& let Some(task) = guard.take()
|
||||
{
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Peer {
|
||||
fn stats(&self) -> PeerStats {
|
||||
PeerStats {
|
||||
tx_packets: self.counters.tx_packets.load(Ordering::Relaxed),
|
||||
tx_bytes: self.counters.tx_bytes.load(Ordering::Relaxed),
|
||||
rx_packets: self.counters.rx_packets.load(Ordering::Relaxed),
|
||||
rx_bytes: self.counters.rx_bytes.load(Ordering::Relaxed),
|
||||
dropped_wrong_source: self.counters.dropped_wrong_source.load(Ordering::Relaxed),
|
||||
dropped_oversize: self.counters.dropped_oversize.load(Ordering::Relaxed),
|
||||
dropped_no_session: self.counters.dropped_no_session.load(Ordering::Relaxed),
|
||||
protocol_errors: self.counters.protocol_errors.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
fn health(&self) -> PeerHealth {
|
||||
let guard = match self.tunn.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
PeerHealth {
|
||||
since_handshake: guard.tunn.time_since_last_handshake(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a peer's tunnel looks like from outside.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeerSummary {
|
||||
/// The peer's control plane identity.
|
||||
pub endpoint_id: EndpointId,
|
||||
/// The peer's WireGuard public key.
|
||||
pub public_key: WgPublicKey,
|
||||
/// The overlay address it holds, once agreed.
|
||||
pub overlay_address_v4: Option<Ipv4Addr>,
|
||||
/// Whether the tunnel has handshaken.
|
||||
pub health: PeerHealth,
|
||||
/// Traffic counters.
|
||||
pub stats: PeerStats,
|
||||
/// What the transport reports about the path carrying this tunnel.
|
||||
pub path: String,
|
||||
/// Largest datagram the link accepts.
|
||||
pub max_datagram: usize,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
network: NetworkId,
|
||||
private_key: WgSecretKey,
|
||||
peers: RwLock<HashMap<WgPublicKey, Arc<Peer>>>,
|
||||
sink: Arc<dyn PacketSink>,
|
||||
transport: Arc<WireguardTransport>,
|
||||
context: PluginContext,
|
||||
next_index: AtomicU32,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn on_tunnel_failed(&self, peer: EndpointId, public_key: WgPublicKey) {
|
||||
self.transport.close_link(self.network, peer);
|
||||
write_lock(&self.peers).remove(&public_key);
|
||||
self.context.report_peer_protocol_failure(
|
||||
self.network,
|
||||
peer,
|
||||
crate::announcement::WG_PROTOCOL,
|
||||
"WireGuard rekey timeout",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Inner {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Inner")
|
||||
.field("network", &self.network.fmt_short())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The userspace WireGuard tunnels of one network.
|
||||
#[derive(Debug)]
|
||||
pub struct WireguardDevice {
|
||||
inner: Arc<Inner>,
|
||||
tasks: Vec<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl WireguardDevice {
|
||||
/// Starts the WireGuard tunnels for one network.
|
||||
pub fn start(
|
||||
network: NetworkId,
|
||||
private_key: WgSecretKey,
|
||||
sink: Arc<dyn PacketSink>,
|
||||
transport: Arc<WireguardTransport>,
|
||||
context: PluginContext,
|
||||
) -> Self {
|
||||
let inner = Arc::new(Inner {
|
||||
network,
|
||||
private_key,
|
||||
peers: RwLock::new(HashMap::new()),
|
||||
sink,
|
||||
transport,
|
||||
context,
|
||||
next_index: AtomicU32::new(1),
|
||||
});
|
||||
|
||||
let timers = tokio::spawn(drive_timers(Arc::clone(&inner)));
|
||||
|
||||
Self {
|
||||
inner,
|
||||
tasks: vec![timers],
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypts an IP packet and sends it to a peer over its direct link.
|
||||
pub fn carry(&self, peer: EndpointId, packet: &[u8]) -> bool {
|
||||
let found = read_lock(&self.inner.peers)
|
||||
.values()
|
||||
.find(|candidate| candidate.endpoint_id == peer)
|
||||
.cloned();
|
||||
let Some(peer) = found else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut scratch = [0u8; SCRATCH];
|
||||
let flow = ip_flow(packet);
|
||||
let result = {
|
||||
let mut tunn = match peer.tunn.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
let (result, selected_flow) = tunn.encapsulate(packet, &mut scratch, flow);
|
||||
match result {
|
||||
TunnResult::WriteToNetwork(out) => Some((out.len(), selected_flow)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let Some((len, selected_flow)) = result else {
|
||||
peer.counters
|
||||
.dropped_no_session
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
return false;
|
||||
};
|
||||
|
||||
send_to_peer_flow(&peer, &scratch[..len], selected_flow);
|
||||
peer.counters.tx_packets.fetch_add(1, Ordering::Relaxed);
|
||||
peer.counters
|
||||
.tx_bytes
|
||||
.fetch_add(packet.len() as u64, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
|
||||
/// Adds or replaces a peer and starts its tunnel.
|
||||
pub fn add_peer(
|
||||
&self,
|
||||
endpoint_id: EndpointId,
|
||||
public_key: WgPublicKey,
|
||||
overlay_v4: Option<Ipv4Addr>,
|
||||
link: SharedLink,
|
||||
keepalive: Option<u16>,
|
||||
) -> Result<(), PluginError> {
|
||||
if public_key == self.inner.private_key.public() {
|
||||
return Err(PluginError::Rejected(
|
||||
"refusing to add ourselves as a WireGuard peer".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let index = self.inner.next_index.fetch_add(1, Ordering::Relaxed);
|
||||
let tunn = Tunn::new(
|
||||
self.inner.private_key.to_static_secret(),
|
||||
public_key.into_x25519(),
|
||||
None,
|
||||
keepalive,
|
||||
index,
|
||||
None,
|
||||
);
|
||||
|
||||
let peer = Arc::new(Peer {
|
||||
endpoint_id,
|
||||
public_key,
|
||||
overlay_v4: Mutex::new(overlay_v4),
|
||||
tunn: Mutex::new(FlowTunnel {
|
||||
tunn,
|
||||
queued: VecDeque::new(),
|
||||
handshake_started_at: None,
|
||||
rekey_timeouts: 0,
|
||||
failed: false,
|
||||
}),
|
||||
link,
|
||||
counters: Arc::new(PeerCounters::default()),
|
||||
task: Mutex::new(None),
|
||||
});
|
||||
|
||||
let task = tokio::spawn(read_from_link(Arc::clone(&self.inner), Arc::clone(&peer)));
|
||||
if let Ok(mut guard) = peer.task.lock() {
|
||||
*guard = Some(task);
|
||||
}
|
||||
|
||||
write_lock(&self.inner.peers).insert(public_key, Arc::clone(&peer));
|
||||
kick_handshake(&peer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a peer and stops its tunnel.
|
||||
pub fn remove_peer(&self, public_key: &WgPublicKey) {
|
||||
write_lock(&self.inner.peers).remove(public_key);
|
||||
}
|
||||
|
||||
/// Removes every peer whose key is not in `keep`.
|
||||
pub fn retain_peers(&self, keep: &[WgPublicKey]) {
|
||||
let stale: Vec<WgPublicKey> = read_lock(&self.inner.peers)
|
||||
.keys()
|
||||
.filter(|key| !keep.contains(key))
|
||||
.copied()
|
||||
.collect();
|
||||
for key in stale {
|
||||
self.remove_peer(&key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a peer's tunnel exists.
|
||||
pub fn has_peer(&self, public_key: &WgPublicKey) -> bool {
|
||||
read_lock(&self.inner.peers).contains_key(public_key)
|
||||
}
|
||||
|
||||
/// A snapshot of every peer.
|
||||
pub fn peers(&self) -> Vec<PeerSummary> {
|
||||
let mut peers: Vec<PeerSummary> = read_lock(&self.inner.peers)
|
||||
.values()
|
||||
.map(|peer| {
|
||||
let overlay_v4 = match peer.overlay_v4.lock() {
|
||||
Ok(guard) => *guard,
|
||||
Err(poisoned) => *poisoned.into_inner(),
|
||||
};
|
||||
PeerSummary {
|
||||
endpoint_id: peer.endpoint_id,
|
||||
public_key: peer.public_key,
|
||||
overlay_address_v4: overlay_v4,
|
||||
health: peer.health(),
|
||||
stats: peer.stats(),
|
||||
path: peer.link.path_description(),
|
||||
max_datagram: peer.link.max_datagram_size(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
peers.sort_by_key(|peer| peer.public_key);
|
||||
peers
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WireguardDevice {
|
||||
fn drop(&mut self) {
|
||||
for task in &self.tasks {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
|
||||
match lock.read() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
|
||||
match lock.write() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn kick_handshake(peer: &Peer) {
|
||||
let mut scratch = vec![0u8; SCRATCH];
|
||||
let len = {
|
||||
let mut tunn = match peer.tunn.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
match tunn.tunn.format_handshake_initiation(&mut scratch, false) {
|
||||
TunnResult::WriteToNetwork(out) => {
|
||||
tunn.handshake_started_at = Some(std::time::Instant::now());
|
||||
tunn.rekey_timeouts = 0;
|
||||
tunn.failed = false;
|
||||
Some(out.len())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(len) = len {
|
||||
send_to_peer(peer, &scratch[..len]);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_to_peer(peer: &Peer, payload: &[u8]) {
|
||||
send_to_peer_flow(peer, payload, 0);
|
||||
}
|
||||
|
||||
fn send_to_peer_flow(peer: &Peer, payload: &[u8], flow: FlowId) {
|
||||
match peer.link.send_flow(Bytes::copy_from_slice(payload), flow) {
|
||||
Ok(()) => {}
|
||||
Err(TransportError::TooLarge { .. }) => {
|
||||
peer.counters
|
||||
.dropped_oversize
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(TransportError::Closed) => {}
|
||||
Err(err) => {
|
||||
tracing::trace!(%err, "dropping a WireGuard packet the link refused");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
|
||||
let mut scratch = vec![0u8; SCRATCH];
|
||||
loop {
|
||||
let Some(datagram) = peer.link.recv().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut input: Option<&[u8]> = Some(&datagram);
|
||||
loop {
|
||||
let outcome = {
|
||||
let mut tunn = match peer.tunn.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
let (result, flow) = tunn.decapsulate(input.unwrap_or(&[]), &mut scratch);
|
||||
match result {
|
||||
TunnResult::WriteToNetwork(out) => Outcome::ToNetwork(out.len(), flow),
|
||||
TunnResult::WriteToTunnelV6(out, _) => Outcome::ToTunnel(out.len()),
|
||||
TunnResult::WriteToTunnelV4(out, _) => Outcome::ToTunnel(out.len()),
|
||||
TunnResult::Done => Outcome::Done,
|
||||
TunnResult::Err(err) => {
|
||||
tracing::trace!(?err, "WireGuard decapsulation failed");
|
||||
Outcome::Failed
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match outcome {
|
||||
Outcome::ToNetwork(len, flow) => {
|
||||
send_to_peer_flow(&peer, &scratch[..len], flow);
|
||||
input = None;
|
||||
continue;
|
||||
}
|
||||
Outcome::ToTunnel(len) => {
|
||||
let payload = Bytes::copy_from_slice(&scratch[..len]);
|
||||
inner
|
||||
.sink
|
||||
.deliver(inner.network, peer.endpoint_id, payload)
|
||||
.await;
|
||||
peer.counters.rx_packets.fetch_add(1, Ordering::Relaxed);
|
||||
peer.counters
|
||||
.rx_bytes
|
||||
.fetch_add(len as u64, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
Outcome::Failed => {
|
||||
peer.counters
|
||||
.protocol_errors
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
Outcome::Done => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
ToNetwork(usize, FlowId),
|
||||
ToTunnel(usize),
|
||||
Done,
|
||||
Failed,
|
||||
}
|
||||
|
||||
async fn drive_timers(inner: Arc<Inner>) {
|
||||
let mut ticker = tokio::time::interval(TIMER_INTERVAL);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let peers: Vec<Arc<Peer>> = read_lock(&inner.peers).values().cloned().collect();
|
||||
for peer in peers {
|
||||
let mut scratch = vec![0u8; SCRATCH];
|
||||
let (len, failed) = {
|
||||
let mut tunn = match peer.tunn.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
let (result, failed) = tunn.update_timers(&mut scratch);
|
||||
let len = match result {
|
||||
TunnResult::WriteToNetwork(out) => Some(out.len()),
|
||||
TunnResult::Err(err) => {
|
||||
tracing::trace!(?err, "WireGuard timer error");
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
(len, failed)
|
||||
};
|
||||
if let Some(len) = len {
|
||||
send_to_peer(&peer, &scratch[..len]);
|
||||
}
|
||||
if failed {
|
||||
tracing::warn!(
|
||||
peer = %peer.endpoint_id.fmt_short(),
|
||||
"WireGuard tunnel failed due to rekey timeout, triggering fallback"
|
||||
);
|
||||
inner.on_tunnel_failed(peer.endpoint_id, peer.public_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_flow_tunnel_rekey_timeout_triggers_failure() {
|
||||
let my_secret = WgSecretKey::generate();
|
||||
let their_secret = WgSecretKey::generate();
|
||||
let their_public = their_secret.public();
|
||||
let tunn = Tunn::new(
|
||||
my_secret.to_static_secret(),
|
||||
their_public.into_x25519(),
|
||||
None,
|
||||
None,
|
||||
1,
|
||||
None,
|
||||
);
|
||||
|
||||
let mut tunnel = FlowTunnel {
|
||||
tunn,
|
||||
queued: VecDeque::new(),
|
||||
handshake_started_at: Some(std::time::Instant::now() - Duration::from_secs(16)),
|
||||
rekey_timeouts: 0,
|
||||
failed: false,
|
||||
};
|
||||
|
||||
let mut scratch = vec![0u8; SCRATCH];
|
||||
// When handshake timeout exceeds HANDSHAKE_TIMEOUT (15s), update_timers returns true (failed)
|
||||
let (_, failed) = tunnel.update_timers(&mut scratch);
|
||||
assert!(failed, "handshake timeout should report failure");
|
||||
assert!(tunnel.handshake_started_at.is_none());
|
||||
assert!(tunnel.failed, "tunnel should be marked as failed");
|
||||
|
||||
// Subsequent timer tick must not report failure again
|
||||
let (_, failed_again) = tunnel.update_timers(&mut scratch);
|
||||
assert!(
|
||||
!failed_again,
|
||||
"subsequent timer tick should not re-trigger failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flow_tunnel_reset_on_handshake() {
|
||||
let my_secret = WgSecretKey::generate();
|
||||
let their_secret = WgSecretKey::generate();
|
||||
let their_public = their_secret.public();
|
||||
let tunn = Tunn::new(
|
||||
my_secret.to_static_secret(),
|
||||
their_public.into_x25519(),
|
||||
None,
|
||||
None,
|
||||
1,
|
||||
None,
|
||||
);
|
||||
|
||||
let mut tunnel = FlowTunnel {
|
||||
tunn,
|
||||
queued: VecDeque::new(),
|
||||
handshake_started_at: Some(std::time::Instant::now()),
|
||||
rekey_timeouts: 2,
|
||||
failed: true,
|
||||
};
|
||||
|
||||
let mut scratch = vec![0u8; SCRATCH];
|
||||
let _ = tunnel.decapsulate(&[], &mut scratch);
|
||||
// decapsulate without handshake response doesn't establish session
|
||||
assert_eq!(tunnel.rekey_timeouts, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! WireGuard key material for pure WireGuard transport.
|
||||
//!
|
||||
//! Keys belong to this protocol plugin alone. They are not derived from
|
||||
//! the iroh device key and not derived from the network secret, so compromising
|
||||
//! or rotating one does not affect the others.
|
||||
//!
|
||||
//! Keys are X25519, encoded the way WireGuard encodes them: standard base64
|
||||
//! with padding, 44 characters.
|
||||
|
||||
use boringtun::x25519;
|
||||
use data_encoding::BASE64;
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use tsunagi::dataplane::PluginError;
|
||||
|
||||
/// Length of a raw WireGuard key in bytes.
|
||||
pub const KEY_LEN: usize = 32;
|
||||
|
||||
/// Length of the base64 text form of a key.
|
||||
pub const KEY_TEXT_LEN: usize = 44;
|
||||
|
||||
/// Applies the X25519 clamping WireGuard applies to private keys.
|
||||
fn clamp(bytes: &mut [u8; KEY_LEN]) {
|
||||
bytes[0] &= 248;
|
||||
bytes[31] &= 127;
|
||||
bytes[31] |= 64;
|
||||
}
|
||||
|
||||
/// A WireGuard public key.
|
||||
///
|
||||
/// Public, safe to log, and the identity a peer is known by inside the overlay.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct WgPublicKey([u8; KEY_LEN]);
|
||||
|
||||
impl WgPublicKey {
|
||||
/// Wraps raw key bytes.
|
||||
pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
/// The raw key bytes.
|
||||
pub fn as_bytes(&self) -> &[u8; KEY_LEN] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// The key in the form the WireGuard implementation expects.
|
||||
pub(crate) fn into_x25519(self) -> x25519::PublicKey {
|
||||
x25519::PublicKey::from(self.0)
|
||||
}
|
||||
|
||||
/// Whether this is the all-zero key, which is never a valid peer.
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.0 == [0u8; KEY_LEN]
|
||||
}
|
||||
|
||||
/// The base64 form WireGuard uses.
|
||||
pub fn encode(&self) -> String {
|
||||
BASE64.encode(&self.0)
|
||||
}
|
||||
|
||||
/// Parses the base64 form WireGuard uses.
|
||||
pub fn decode(text: &str) -> Result<Self, PluginError> {
|
||||
if text.len() != KEY_TEXT_LEN {
|
||||
return Err(PluginError::Rejected(format!(
|
||||
"a WireGuard key is {KEY_TEXT_LEN} base64 characters, got {}",
|
||||
text.len()
|
||||
)));
|
||||
}
|
||||
let raw = BASE64
|
||||
.decode(text.as_bytes())
|
||||
.map_err(|_| PluginError::Rejected("key is not valid base64".into()))?;
|
||||
let bytes = <[u8; KEY_LEN]>::try_from(raw.as_slice())
|
||||
.map_err(|_| PluginError::Rejected("key is not 32 bytes".into()))?;
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
|
||||
/// A short prefix for logs and diagnostics.
|
||||
pub fn fmt_short(&self) -> String {
|
||||
self.encode().chars().take(8).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WgPublicKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.encode())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WgPublicKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "WgPublicKey({})", self.fmt_short())
|
||||
}
|
||||
}
|
||||
|
||||
/// A WireGuard private key.
|
||||
///
|
||||
/// Clamped on creation so it matches what `wg genkey` produces. Zeroized on
|
||||
/// drop, and prints as `[redacted]` in Debug so it cannot leak into logs.
|
||||
#[derive(Clone)]
|
||||
pub struct WgSecretKey {
|
||||
bytes: Zeroizing<[u8; KEY_LEN]>,
|
||||
public: WgPublicKey,
|
||||
}
|
||||
|
||||
impl WgSecretKey {
|
||||
/// Generates a fresh clamped random private key.
|
||||
pub fn generate() -> Self {
|
||||
let mut bytes = [0u8; KEY_LEN];
|
||||
rand::fill(&mut bytes);
|
||||
clamp(&mut bytes);
|
||||
let secret = x25519::StaticSecret::from(bytes);
|
||||
let public = WgPublicKey(*x25519::PublicKey::from(&secret).as_bytes());
|
||||
Self {
|
||||
bytes: Zeroizing::new(bytes),
|
||||
public,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps existing key bytes, clamping them to a valid X25519 private key.
|
||||
pub fn from_bytes(raw: &[u8; KEY_LEN]) -> Self {
|
||||
let mut bytes = *raw;
|
||||
clamp(&mut bytes);
|
||||
let secret = x25519::StaticSecret::from(bytes);
|
||||
let public = WgPublicKey(*x25519::PublicKey::from(&secret).as_bytes());
|
||||
Self {
|
||||
bytes: Zeroizing::new(bytes),
|
||||
public,
|
||||
}
|
||||
}
|
||||
|
||||
/// The matching public key.
|
||||
pub fn public(&self) -> WgPublicKey {
|
||||
self.public
|
||||
}
|
||||
|
||||
/// The key in the form the WireGuard implementation expects.
|
||||
pub(crate) fn to_static_secret(&self) -> x25519::StaticSecret {
|
||||
x25519::StaticSecret::from(*self.bytes)
|
||||
}
|
||||
|
||||
/// The raw key bytes.
|
||||
pub fn as_bytes(&self) -> &[u8; KEY_LEN] {
|
||||
&self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WgSecretKey {
|
||||
fn drop(&mut self) {
|
||||
self.bytes.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WgSecretKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WgSecretKey")
|
||||
.field("bytes", &"[redacted]")
|
||||
.field("public", &self.public)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! The pure WireGuard protocol plugin for Tsunagi: WireGuard cryptography over direct UDP.
|
||||
//!
|
||||
//! Deliberately a separate crate from the core, ensuring clear boundaries:
|
||||
//! - Direct UDP datagram transport without QUIC encapsulation or double-encryption.
|
||||
//! - Standard WireGuard cryptography via `boringtun`.
|
||||
//! - Peer discovery and key exchange managed through the Tsunagi control plane.
|
||||
|
||||
#![deny(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod announcement;
|
||||
pub mod device;
|
||||
pub mod keys;
|
||||
pub mod plugin;
|
||||
pub mod store;
|
||||
pub mod transport;
|
||||
|
||||
pub use announcement::{
|
||||
ANNOUNCEMENT_VERSION, ValidatedAnnouncement, WG_PROTOCOL, WG_WIRE_VERSION, WgAnnouncement,
|
||||
};
|
||||
pub use device::{PeerHealth, PeerStats, PeerSummary, WireguardDevice};
|
||||
pub use keys::{WgPublicKey, WgSecretKey};
|
||||
pub use plugin::{
|
||||
DEFAULT_MTU, MIN_MTU, NetworkOverview, PeerOverview, WIREGUARD_OVERHEAD, WireguardConfig,
|
||||
WireguardPlugin, local_ip_candidates,
|
||||
};
|
||||
pub use store::WgKeyStore;
|
||||
pub use transport::{DEFAULT_PORT, MAX_UDP_DATAGRAM_SIZE, WireguardLink, WireguardTransport};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,160 @@
|
||||
//! The plugin's own key store.
|
||||
//!
|
||||
//! Deliberately a separate SQLite file from the agent's `state.sqlite`: plugin
|
||||
//! keys are not the iroh identity and not the network secret, and their
|
||||
//! lifecycle is the plugin's business alone.
|
||||
//!
|
||||
//! One key per network, so a participant presents a different WireGuard
|
||||
//! identity in each network it belongs to.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use tsunagi::dataplane::PluginError;
|
||||
use tsunagi::identity::NetworkId;
|
||||
|
||||
use crate::keys::{KEY_LEN, WgSecretKey};
|
||||
|
||||
/// Schema version written by this build.
|
||||
pub const SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
/// Per-network WireGuard private keys.
|
||||
#[derive(Debug)]
|
||||
pub struct WgKeyStore {
|
||||
conn: Mutex<Connection>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl WgKeyStore {
|
||||
/// Opens, creating the file and its directory if needed.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, PluginError> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
if let Some(parent) = path.parent() {
|
||||
tsunagi::storage::create_dir(parent)
|
||||
.map_err(|err| PluginError::Other(format!("cannot create {parent:?}: {err}")))?;
|
||||
}
|
||||
let existed = path.exists();
|
||||
let conn = Connection::open(&path).map_err(|err| {
|
||||
PluginError::Other(format!("cannot open the WireGuard key store: {err}"))
|
||||
})?;
|
||||
tsunagi::storage::restrict_path_permissions(&path)
|
||||
.map_err(|err| PluginError::Other(format!("cannot secure the key store: {err}")))?;
|
||||
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||
.and_then(|()| conn.pragma_update(None, "journal_mode", "WAL"))
|
||||
.and_then(|()| conn.pragma_update(None, "synchronous", "NORMAL"))
|
||||
.map_err(|err| PluginError::Other(format!("cannot configure the key store: {err}")))?;
|
||||
|
||||
if existed {
|
||||
let integrity: String = conn
|
||||
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
|
||||
.map_err(|err| {
|
||||
PluginError::Other(format!("WireGuard key store is unusable: {err}"))
|
||||
})?;
|
||||
if integrity != "ok" {
|
||||
return Err(PluginError::Other(format!(
|
||||
"WireGuard key store at {} is corrupt and will not be recreated: {integrity}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let found: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
||||
.map_err(|err| PluginError::Other(format!("cannot read the schema version: {err}")))?;
|
||||
if found > SCHEMA_VERSION {
|
||||
return Err(PluginError::Other(format!(
|
||||
"WireGuard key store schema {found} is newer than {SCHEMA_VERSION}"
|
||||
)));
|
||||
}
|
||||
if found < SCHEMA_VERSION {
|
||||
conn.execute_batch(
|
||||
"BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS network_keys (
|
||||
network_id BLOB PRIMARY KEY,
|
||||
secret BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 1;
|
||||
COMMIT;",
|
||||
)
|
||||
.map_err(|err| PluginError::Other(format!("cannot create the schema: {err}")))?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Path of the underlying file.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Loads the stored key for `network`, or generates, stores and returns a fresh one.
|
||||
pub fn load_or_create(&self, network: NetworkId) -> Result<WgSecretKey, PluginError> {
|
||||
let guard = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| PluginError::Other("key store lock is poisoned".into()))?;
|
||||
|
||||
let existing: Option<Vec<u8>> = guard
|
||||
.query_row(
|
||||
"SELECT secret FROM network_keys WHERE network_id = ?1",
|
||||
params![network.as_bytes().as_slice()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|err| PluginError::Other(format!("cannot read the key store: {err}")))?;
|
||||
|
||||
if let Some(secret) = existing {
|
||||
let bytes = <[u8; KEY_LEN]>::try_from(secret.as_slice()).map_err(|_| {
|
||||
PluginError::Other(format!(
|
||||
"stored WireGuard key for {} is {} bytes, expected {KEY_LEN}",
|
||||
network.fmt_short(),
|
||||
secret.len()
|
||||
))
|
||||
})?;
|
||||
return Ok(WgSecretKey::from_bytes(&bytes));
|
||||
}
|
||||
|
||||
let fresh = WgSecretKey::generate();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|err| PluginError::Other(format!("system clock is broken: {err}")))?
|
||||
.as_secs() as i64;
|
||||
|
||||
guard
|
||||
.execute(
|
||||
"INSERT INTO network_keys (network_id, secret, created_at) VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
network.as_bytes().as_slice(),
|
||||
fresh.as_bytes().as_slice(),
|
||||
now
|
||||
],
|
||||
)
|
||||
.map_err(|err| PluginError::Other(format!("cannot write the key store: {err}")))?;
|
||||
|
||||
Ok(fresh)
|
||||
}
|
||||
|
||||
/// Removes the stored key for `network`.
|
||||
pub fn forget(&self, network: NetworkId) -> Result<(), PluginError> {
|
||||
let guard = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| PluginError::Other("key store lock is poisoned".into()))?;
|
||||
guard
|
||||
.execute(
|
||||
"DELETE FROM network_keys WHERE network_id = ?1",
|
||||
params![network.as_bytes().as_slice()],
|
||||
)
|
||||
.map_err(|err| {
|
||||
PluginError::Other(format!("cannot delete from the key store: {err}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
//! Pure WireGuard UDP packet transport and link implementation.
|
||||
//!
|
||||
//! Provides [`WireguardTransport`] implementing [`PacketTransport`]
|
||||
//! and [`WireguardLink`] implementing [`PacketLink`].
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::{Notify, mpsc, oneshot};
|
||||
|
||||
use tsunagi::BoxFuture;
|
||||
use tsunagi::dataplane::transport::{
|
||||
InboundLink, PacketLink, PacketTransport, SharedLink, TransportError,
|
||||
};
|
||||
use tsunagi::identity::{DeviceIdentity, NetworkId};
|
||||
|
||||
/// Default UDP port to bind and listen on for pure WireGuard.
|
||||
pub const DEFAULT_PORT: u16 = 51820;
|
||||
|
||||
/// Maximum payload length for a single framed datagram over UDP.
|
||||
pub const MAX_UDP_DATAGRAM_SIZE: usize = 65507;
|
||||
|
||||
const PROBE_MAGIC: &[u8; 19] = b"TSUNAGI_WG_PROBE_V1";
|
||||
const PROBE_TYPE_PING: u8 = 1;
|
||||
const PROBE_TYPE_PONG: u8 = 2;
|
||||
const PROBE_LEN: usize = 19 + 1 + 32 + 32 + 32 + 8; // 124 bytes
|
||||
|
||||
fn encode_probe(
|
||||
msg_type: u8,
|
||||
sender: EndpointId,
|
||||
receiver: EndpointId,
|
||||
network: NetworkId,
|
||||
cookie: u64,
|
||||
) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(PROBE_LEN);
|
||||
buf.extend_from_slice(PROBE_MAGIC);
|
||||
buf.push(msg_type);
|
||||
buf.extend_from_slice(sender.as_bytes());
|
||||
buf.extend_from_slice(receiver.as_bytes());
|
||||
buf.extend_from_slice(network.as_bytes());
|
||||
buf.extend_from_slice(&cookie.to_be_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
struct DecodedProbe {
|
||||
msg_type: u8,
|
||||
sender: EndpointId,
|
||||
receiver: EndpointId,
|
||||
network: NetworkId,
|
||||
cookie: u64,
|
||||
}
|
||||
|
||||
fn decode_probe(bytes: &[u8]) -> Option<DecodedProbe> {
|
||||
if bytes.len() < PROBE_LEN || &bytes[..19] != PROBE_MAGIC {
|
||||
return None;
|
||||
}
|
||||
let msg_type = bytes[19];
|
||||
if msg_type != PROBE_TYPE_PING && msg_type != PROBE_TYPE_PONG {
|
||||
return None;
|
||||
}
|
||||
let sender = EndpointId::from_bytes(&bytes[20..52].try_into().ok()?).ok()?;
|
||||
let receiver = EndpointId::from_bytes(&bytes[52..84].try_into().ok()?).ok()?;
|
||||
let network = NetworkId::from_bytes(bytes[84..116].try_into().ok()?);
|
||||
let cookie = u64::from_be_bytes(bytes[116..124].try_into().ok()?);
|
||||
Some(DecodedProbe {
|
||||
msg_type,
|
||||
sender,
|
||||
receiver,
|
||||
network,
|
||||
cookie,
|
||||
})
|
||||
}
|
||||
|
||||
/// An authenticated datagram link carried over direct UDP.
|
||||
#[derive(Debug)]
|
||||
pub struct WireguardLink {
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
target_addr: Arc<RwLock<SocketAddr>>,
|
||||
candidates: Arc<RwLock<Vec<SocketAddr>>>,
|
||||
confirmed: Arc<AtomicBool>,
|
||||
tx: mpsc::Sender<Bytes>,
|
||||
inbound_tx: mpsc::Sender<Bytes>,
|
||||
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
|
||||
closed_notify: Arc<Notify>,
|
||||
is_closed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl WireguardLink {
|
||||
/// Creates a new `WireguardLink` targeting `target_addr` over `socket`.
|
||||
pub fn new(
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
target_addr: SocketAddr,
|
||||
candidates: Vec<SocketAddr>,
|
||||
socket: Arc<UdpSocket>,
|
||||
) -> Self {
|
||||
let (tx_out, mut rx_out) = mpsc::channel::<Bytes>(1024);
|
||||
let (tx_in, rx_in) = mpsc::channel::<Bytes>(1024);
|
||||
|
||||
let is_closed = Arc::new(AtomicBool::new(false));
|
||||
let confirmed = Arc::new(AtomicBool::new(false));
|
||||
let closed_notify = Arc::new(Notify::new());
|
||||
|
||||
let target_holder = Arc::new(RwLock::new(target_addr));
|
||||
let candidates_holder = Arc::new(RwLock::new(candidates));
|
||||
|
||||
let socket_w = Arc::clone(&socket);
|
||||
let target_r = Arc::clone(&target_holder);
|
||||
let candidates_r = Arc::clone(&candidates_holder);
|
||||
let confirmed_r = Arc::clone(&confirmed);
|
||||
let is_closed_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_UDP_DATAGRAM_SIZE {
|
||||
continue;
|
||||
}
|
||||
// In WireGuard protocol, type 1 is Handshake Initiation (148 bytes).
|
||||
let is_handshake_init = !msg.is_empty() && msg[0] == 1;
|
||||
if confirmed_r.load(Ordering::Acquire) && !is_handshake_init {
|
||||
let target = match target_r.read() {
|
||||
Ok(g) => *g,
|
||||
Err(p) => *p.into_inner(),
|
||||
};
|
||||
if let Err(err) = socket_w.send_to(&msg, target).await {
|
||||
tracing::trace!(%err, %target, "failed to send WireGuard UDP datagram");
|
||||
}
|
||||
} else {
|
||||
let targets = match candidates_r.read() {
|
||||
Ok(g) => g.clone(),
|
||||
Err(p) => p.into_inner().clone(),
|
||||
};
|
||||
if targets.is_empty() {
|
||||
let target = match target_r.read() {
|
||||
Ok(g) => *g,
|
||||
Err(p) => *p.into_inner(),
|
||||
};
|
||||
let _ = socket_w.send_to(&msg, target).await;
|
||||
} else {
|
||||
for target in targets {
|
||||
let _ = socket_w.send_to(&msg, target).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is_closed_w.store(true, Ordering::Release);
|
||||
closed_notify_w.notify_waiters();
|
||||
});
|
||||
|
||||
Self {
|
||||
network,
|
||||
peer,
|
||||
target_addr: target_holder,
|
||||
candidates: candidates_holder,
|
||||
confirmed,
|
||||
tx: tx_out,
|
||||
inbound_tx: tx_in,
|
||||
rx: tokio::sync::Mutex::new(rx_in),
|
||||
closed_notify,
|
||||
is_closed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Delivers an inbound packet received from UDP into this link's receive queue.
|
||||
pub fn deliver_inbound(&self, payload: Bytes) -> bool {
|
||||
if self.is_closed.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
self.confirmed.store(true, Ordering::Release);
|
||||
self.inbound_tx.try_send(payload).is_ok()
|
||||
}
|
||||
|
||||
/// Marks this link as confirmed by a probe response or verified packet.
|
||||
pub fn confirm(&self) {
|
||||
self.confirmed.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
/// Whether this link has had verified two-way direct connectivity.
|
||||
pub fn is_confirmed(&self) -> bool {
|
||||
self.confirmed.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Updates candidate addresses for this peer.
|
||||
pub fn update_candidates(&self, addrs: impl IntoIterator<Item = SocketAddr>) {
|
||||
if let Ok(mut guard) = self.candidates.write() {
|
||||
let new_candidates: Vec<SocketAddr> = addrs
|
||||
.into_iter()
|
||||
.filter(|a| !a.ip().is_unspecified())
|
||||
.collect();
|
||||
if !new_candidates.is_empty() {
|
||||
*guard = new_candidates;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the target socket address (roaming / NAT hole punch update).
|
||||
pub fn update_target(&self, new_target: SocketAddr) {
|
||||
if new_target.ip().is_unspecified() {
|
||||
return;
|
||||
}
|
||||
self.confirmed.store(true, Ordering::Release);
|
||||
if let Ok(mut guard) = self.target_addr.write()
|
||||
&& *guard != new_target
|
||||
{
|
||||
tracing::debug!(peer = %self.peer.fmt_short(), old = %*guard, new = %new_target, "WireGuard endpoint roamed");
|
||||
*guard = new_target;
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly closes this link, notifying any pending readers or waiters.
|
||||
pub fn close(&self) {
|
||||
if !self.is_closed.swap(true, Ordering::AcqRel) {
|
||||
self.closed_notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current target address.
|
||||
pub fn target_addr(&self) -> SocketAddr {
|
||||
match self.target_addr.read() {
|
||||
Ok(g) => *g,
|
||||
Err(p) => *p.into_inner(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketLink for WireguardLink {
|
||||
fn network(&self) -> NetworkId {
|
||||
self.network
|
||||
}
|
||||
|
||||
fn peer(&self) -> EndpointId {
|
||||
self.peer
|
||||
}
|
||||
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
MAX_UDP_DATAGRAM_SIZE
|
||||
}
|
||||
|
||||
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
|
||||
if payload.len() > MAX_UDP_DATAGRAM_SIZE {
|
||||
return Err(TransportError::TooLarge {
|
||||
size: payload.len(),
|
||||
limit: MAX_UDP_DATAGRAM_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 {
|
||||
if self.is_closed.load(Ordering::Acquire) {
|
||||
return None;
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = self.closed_notify.notified() => None,
|
||||
msg = async {
|
||||
let mut guard = self.rx.lock().await;
|
||||
guard.recv().await
|
||||
} => msg,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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!("wireguard via {}", self.target_addr())
|
||||
}
|
||||
}
|
||||
|
||||
type InboundCallback = Box<dyn Fn(InboundLink) + Send + Sync + 'static>;
|
||||
type PendingProbes = Arc<Mutex<HashMap<(NetworkId, EndpointId, u64), oneshot::Sender<SocketAddr>>>>;
|
||||
|
||||
/// Direct UDP WireGuard packet transport.
|
||||
pub struct WireguardTransport {
|
||||
local_id: EndpointId,
|
||||
listen_port: u16,
|
||||
socket: Arc<UdpSocket>,
|
||||
peer_addresses: RwLock<HashMap<EndpointId, Vec<SocketAddr>>>,
|
||||
peer_networks: RwLock<HashMap<EndpointId, HashSet<NetworkId>>>,
|
||||
links: RwLock<HashMap<(NetworkId, EndpointId), Arc<WireguardLink>>>,
|
||||
inbound_cb: Arc<Mutex<Option<InboundCallback>>>,
|
||||
pending_probes: PendingProbes,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WireguardTransport {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WireguardTransport")
|
||||
.field("local_id", &self.local_id.fmt_short().to_string())
|
||||
.field("listen_port", &self.listen_port)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl WireguardTransport {
|
||||
/// Binds a UDP socket for pure WireGuard transport.
|
||||
///
|
||||
/// Attempts `requested_port` (default 51820). If that fails, falls back to an ephemeral port.
|
||||
pub async fn bind(
|
||||
identity: &DeviceIdentity,
|
||||
requested_port: Option<u16>,
|
||||
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let target_port = requested_port.unwrap_or(DEFAULT_PORT);
|
||||
let bind_addr = SocketAddr::from(([0, 0, 0, 0], target_port));
|
||||
|
||||
let socket = match UdpSocket::bind(bind_addr).await {
|
||||
Ok(s) => s,
|
||||
Err(err) if target_port > 0 => {
|
||||
tracing::warn!(%err, port = target_port, "failed to bind WireGuard UDP port, falling back to ephemeral port");
|
||||
UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], 0))).await?
|
||||
}
|
||||
Err(err) => return Err(Box::new(err)),
|
||||
};
|
||||
|
||||
let bound_port = socket.local_addr()?.port();
|
||||
tracing::info!(port = bound_port, "WireGuard UDP transport bound");
|
||||
|
||||
Ok(Self {
|
||||
local_id: identity.endpoint_id(),
|
||||
listen_port: bound_port,
|
||||
socket: Arc::new(socket),
|
||||
peer_addresses: RwLock::new(HashMap::new()),
|
||||
peer_networks: RwLock::new(HashMap::new()),
|
||||
links: RwLock::new(HashMap::new()),
|
||||
inbound_cb: Arc::new(Mutex::new(None)),
|
||||
pending_probes: Arc::new(Mutex::new(HashMap::new())),
|
||||
})
|
||||
}
|
||||
|
||||
/// The local port this transport is listening on.
|
||||
pub fn bound_port(&self) -> u16 {
|
||||
self.listen_port
|
||||
}
|
||||
|
||||
/// Associates a peer with a network ID.
|
||||
pub fn set_peer_network(&self, peer: EndpointId, network: NetworkId) {
|
||||
if let Ok(mut guard) = self.peer_networks.write() {
|
||||
guard.entry(peer).or_default().insert(network);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records an observed UDP target address for a peer.
|
||||
pub fn set_peer_addr(&self, peer: EndpointId, addr: SocketAddr) {
|
||||
if !addr.is_ipv4() || addr.ip().is_unspecified() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut guard) = self.peer_addresses.write() {
|
||||
let addrs = guard.entry(peer).or_default();
|
||||
addrs.retain(|a| *a != addr);
|
||||
addrs.insert(0, addr);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records multiple observed UDP target addresses for a peer.
|
||||
pub fn set_peer_addrs(&self, peer: EndpointId, addrs: impl IntoIterator<Item = SocketAddr>) {
|
||||
if let Ok(mut guard) = self.peer_addresses.write() {
|
||||
let entry = guard.entry(peer).or_default();
|
||||
for addr in addrs {
|
||||
if addr.is_ipv4() && !addr.ip().is_unspecified() && !entry.contains(&addr) {
|
||||
entry.push(addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns candidate socket addresses known for a peer.
|
||||
pub fn peer_addrs(&self, peer: EndpointId) -> Vec<SocketAddr> {
|
||||
let guard = match self.peer_addresses.read() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard.get(&peer).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Explicitly closes any active direct link for the given network and peer.
|
||||
pub fn close_link(&self, network: NetworkId, peer: EndpointId) {
|
||||
let link = {
|
||||
let guard = match self.links.read() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard.get(&(network, peer)).cloned()
|
||||
};
|
||||
if let Some(link) = link {
|
||||
link.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Notifies the transport that a peer's capability was announced.
|
||||
///
|
||||
/// When `we_accept` is true, sends a hole-punch probe to candidate addresses
|
||||
/// to open stateful NAT firewalls, but does NOT create an unverified direct link
|
||||
/// in the hub before packets actually flow.
|
||||
pub fn notify_peer_capability(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
addrs: &[SocketAddr],
|
||||
we_accept: bool,
|
||||
) {
|
||||
self.set_peer_network(peer, network);
|
||||
let ipv4_addrs: Vec<SocketAddr> = addrs.iter().copied().filter(|a| a.is_ipv4()).collect();
|
||||
self.set_peer_addrs(peer, ipv4_addrs.iter().copied());
|
||||
|
||||
// Update candidate addresses on any existing link for this peer
|
||||
if let Ok(guard) = self.links.read()
|
||||
&& let Some(link) = guard.get(&(network, peer))
|
||||
{
|
||||
link.update_candidates(ipv4_addrs.iter().copied());
|
||||
}
|
||||
|
||||
// Send hole punching probe if we accept and have candidate addresses.
|
||||
if we_accept && !ipv4_addrs.is_empty() {
|
||||
let socket = Arc::clone(&self.socket);
|
||||
let local_id = self.local_id;
|
||||
tokio::spawn(async move {
|
||||
let cookie: u64 = rand::random();
|
||||
let probe = encode_probe(PROBE_TYPE_PING, local_id, peer, network, cookie);
|
||||
for addr in ipv4_addrs {
|
||||
let _ = socket.send_to(&probe, addr).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the receive loop for incoming UDP datagrams.
|
||||
pub async fn accept_loop<F>(&self, on_inbound: F)
|
||||
where
|
||||
F: Fn(InboundLink) + Send + Sync + 'static,
|
||||
{
|
||||
{
|
||||
let mut guard = match self.inbound_cb.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
*guard = Some(Box::new(on_inbound));
|
||||
}
|
||||
|
||||
let socket = Arc::clone(&self.socket);
|
||||
let mut buf = [0u8; MAX_UDP_DATAGRAM_SIZE];
|
||||
|
||||
while let Ok((len, src_addr)) = socket.recv_from(&mut buf).await {
|
||||
if src_addr.ip().is_unspecified() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Direct UDP probe packet handling
|
||||
if len >= PROBE_LEN
|
||||
&& &buf[..19] == PROBE_MAGIC
|
||||
&& let Some(probe) = decode_probe(&buf[..len])
|
||||
{
|
||||
if probe.receiver != self.local_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
if probe.msg_type == PROBE_TYPE_PING {
|
||||
let is_known = {
|
||||
let guard = match self.peer_networks.read() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard
|
||||
.get(&probe.sender)
|
||||
.is_some_and(|nets| nets.contains(&probe.network))
|
||||
};
|
||||
if is_known {
|
||||
let pong = encode_probe(
|
||||
PROBE_TYPE_PONG,
|
||||
self.local_id,
|
||||
probe.sender,
|
||||
probe.network,
|
||||
probe.cookie,
|
||||
);
|
||||
let _ = socket.send_to(&pong, src_addr).await;
|
||||
|
||||
self.set_peer_addr(probe.sender, src_addr);
|
||||
|
||||
let link = {
|
||||
let mut guard = match self.links.write() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
if let Some(existing) = guard.get(&(probe.network, probe.sender))
|
||||
&& !existing.is_closed()
|
||||
{
|
||||
existing.update_target(src_addr);
|
||||
existing.confirm();
|
||||
None
|
||||
} else {
|
||||
let candidates = self.peer_addrs(probe.sender);
|
||||
let link = Arc::new(WireguardLink::new(
|
||||
probe.network,
|
||||
probe.sender,
|
||||
src_addr,
|
||||
candidates,
|
||||
Arc::clone(&self.socket),
|
||||
));
|
||||
link.confirm();
|
||||
guard.insert((probe.network, probe.sender), Arc::clone(&link));
|
||||
Some(link)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(link) = link {
|
||||
let cb_guard = match self.inbound_cb.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
if let Some(ref cb) = *cb_guard {
|
||||
cb(InboundLink {
|
||||
network: probe.network,
|
||||
peer: probe.sender,
|
||||
protocol: crate::announcement::WG_PROTOCOL.to_string(),
|
||||
link,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
} else if probe.msg_type == PROBE_TYPE_PONG {
|
||||
self.set_peer_addr(probe.sender, src_addr);
|
||||
let waiter = {
|
||||
let mut probes = match self.pending_probes.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
probes.remove(&(probe.network, probe.sender, probe.cookie))
|
||||
};
|
||||
if let Some(tx) = waiter {
|
||||
let _ = tx.send(src_addr);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Data / handshake packet handling
|
||||
let datagram = Bytes::copy_from_slice(&buf[..len]);
|
||||
|
||||
// Identify which peer this packet comes from
|
||||
let matched_peer = {
|
||||
let links_guard = match self.links.read() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
links_guard
|
||||
.values()
|
||||
.find(|link| link.target_addr() == src_addr)
|
||||
.map(|link| link.peer())
|
||||
.or_else(|| {
|
||||
let addrs_guard = match self.peer_addresses.read() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
addrs_guard
|
||||
.iter()
|
||||
.find(|(_, addrs)| addrs.contains(&src_addr))
|
||||
.map(|(peer, _)| *peer)
|
||||
.or_else(|| {
|
||||
links_guard
|
||||
.values()
|
||||
.find(|link| link.target_addr().ip() == src_addr.ip())
|
||||
.map(|link| link.peer())
|
||||
.or_else(|| {
|
||||
addrs_guard
|
||||
.iter()
|
||||
.find(|(_, addrs)| {
|
||||
addrs.iter().any(|a| a.ip() == src_addr.ip())
|
||||
})
|
||||
.map(|(peer, _)| *peer)
|
||||
})
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
let Some(peer) = matched_peer else {
|
||||
continue;
|
||||
};
|
||||
|
||||
self.set_peer_addr(peer, src_addr);
|
||||
|
||||
// Deliver to ALL active links for this peer (across all networks this peer shares with us)
|
||||
let peer_links: Vec<Arc<WireguardLink>> = {
|
||||
let guard = match self.links.read() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard
|
||||
.values()
|
||||
.filter(|l| l.peer() == peer && !l.is_closed())
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
|
||||
if !peer_links.is_empty() {
|
||||
for link in peer_links {
|
||||
link.update_target(src_addr);
|
||||
link.deliver_inbound(datagram.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// If no active link exists for this peer yet, create inbound links for all networks known for this peer
|
||||
let networks: Vec<NetworkId> = {
|
||||
let guard = match self.peer_networks.read() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard
|
||||
.get(&peer)
|
||||
.map(|s| s.iter().copied().collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
for network in networks {
|
||||
let link = {
|
||||
let mut guard = match self.links.write() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
if let Some(existing) = guard.get(&(network, peer))
|
||||
&& !existing.is_closed()
|
||||
{
|
||||
existing.update_target(src_addr);
|
||||
existing.deliver_inbound(datagram.clone());
|
||||
continue;
|
||||
}
|
||||
let candidates = self.peer_addrs(peer);
|
||||
let link = Arc::new(WireguardLink::new(
|
||||
network,
|
||||
peer,
|
||||
src_addr,
|
||||
candidates,
|
||||
Arc::clone(&self.socket),
|
||||
));
|
||||
link.update_target(src_addr);
|
||||
link.deliver_inbound(datagram.clone());
|
||||
guard.insert((network, peer), Arc::clone(&link));
|
||||
link
|
||||
};
|
||||
|
||||
let cb_guard = match self.inbound_cb.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
if let Some(ref cb) = *cb_guard {
|
||||
cb(InboundLink {
|
||||
network,
|
||||
peer,
|
||||
protocol: crate::announcement::WG_PROTOCOL.to_string(),
|
||||
link,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketTransport for WireguardTransport {
|
||||
fn name(&self) -> &str {
|
||||
"wireguard"
|
||||
}
|
||||
|
||||
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 {
|
||||
let mut addrs = self.peer_addrs(peer);
|
||||
if addrs.is_empty() {
|
||||
return Err(TransportError::Unreachable(format!(
|
||||
"no candidate UDP address known for peer {}",
|
||||
peer.fmt_short()
|
||||
)));
|
||||
}
|
||||
crate::plugin::prioritize_local_subnet(&mut addrs);
|
||||
|
||||
// If an existing confirmed link is alive, reuse it
|
||||
{
|
||||
let guard = match self.links.read() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
if let Some(existing) = guard.get(&(network, peer))
|
||||
&& !existing.is_closed()
|
||||
&& existing.is_confirmed()
|
||||
{
|
||||
existing.update_candidates(addrs);
|
||||
return Ok(Arc::clone(existing) as SharedLink);
|
||||
}
|
||||
}
|
||||
|
||||
// Probe candidate addresses with PING
|
||||
let cookie: u64 = rand::random();
|
||||
let (tx, rx) = oneshot::channel::<SocketAddr>();
|
||||
{
|
||||
let mut probes = match self.pending_probes.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
probes.insert((network, peer, cookie), tx);
|
||||
}
|
||||
|
||||
let probe_ping = encode_probe(PROBE_TYPE_PING, self.local_id, peer, network, cookie);
|
||||
let socket = Arc::clone(&self.socket);
|
||||
let candidates = addrs.clone();
|
||||
|
||||
let probe_task = tokio::spawn(async move {
|
||||
for i in 0..3 {
|
||||
for target in &candidates {
|
||||
let _ = socket.send_to(&probe_ping, *target).await;
|
||||
}
|
||||
if i < 2 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let confirmed_addr =
|
||||
match tokio::time::timeout(std::time::Duration::from_millis(1200), rx).await {
|
||||
Ok(Ok(addr)) => {
|
||||
probe_task.abort();
|
||||
addr
|
||||
}
|
||||
_ => {
|
||||
probe_task.abort();
|
||||
let mut probes = match self.pending_probes.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
probes.remove(&(network, peer, cookie));
|
||||
return Err(TransportError::Unreachable(format!(
|
||||
"direct UDP probe to peer {} timed out",
|
||||
peer.fmt_short()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let link = {
|
||||
let mut guard = match self.links.write() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
if let Some(existing) = guard.get(&(network, peer))
|
||||
&& !existing.is_closed()
|
||||
{
|
||||
existing.update_target(confirmed_addr);
|
||||
existing.update_candidates(addrs);
|
||||
existing.confirm();
|
||||
return Ok(Arc::clone(existing) as SharedLink);
|
||||
}
|
||||
let link = Arc::new(WireguardLink::new(
|
||||
network,
|
||||
peer,
|
||||
confirmed_addr,
|
||||
addrs,
|
||||
Arc::clone(&self.socket),
|
||||
));
|
||||
link.confirm();
|
||||
guard.insert((network, peer), Arc::clone(&link));
|
||||
link
|
||||
};
|
||||
|
||||
Ok(link as SharedLink)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#![allow(missing_docs)]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use tsunagi::identity::NetworkId;
|
||||
use tsunagi_wireguard::{ANNOUNCEMENT_VERSION, WgAnnouncement, WgPublicKey, WgSecretKey};
|
||||
|
||||
#[test]
|
||||
fn announcement_round_trip() {
|
||||
let network = NetworkId::from_bytes([42u8; 32]);
|
||||
let key = WgSecretKey::generate().public();
|
||||
let local = WgSecretKey::generate().public();
|
||||
|
||||
let addrs: Vec<IpAddr> = vec![
|
||||
"192.168.1.100".parse().unwrap(),
|
||||
"10.0.0.5".parse().unwrap(),
|
||||
];
|
||||
|
||||
let announcement = WgAnnouncement::new(network, &key, 51820, addrs.clone());
|
||||
let encoded = announcement.encode().expect("encode");
|
||||
|
||||
let validated = WgAnnouncement::decode_and_validate(&encoded, network, &local).expect("decode");
|
||||
|
||||
assert_eq!(validated.public_key, key);
|
||||
assert_eq!(validated.port, 51820);
|
||||
assert_eq!(validated.addrs, addrs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announcement_rejects_zero_key() {
|
||||
let network = NetworkId::from_bytes([42u8; 32]);
|
||||
let local = WgSecretKey::generate().public();
|
||||
let zero = WgPublicKey::from_bytes([0u8; 32]);
|
||||
|
||||
let announcement = WgAnnouncement::new(network, &zero, 51820, vec![]);
|
||||
let encoded = announcement.encode().unwrap();
|
||||
|
||||
let err = WgAnnouncement::decode_and_validate(&encoded, network, &local).unwrap_err();
|
||||
assert!(err.to_string().contains("all zeroes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announcement_rejects_own_key() {
|
||||
let network = NetworkId::from_bytes([42u8; 32]);
|
||||
let local = WgSecretKey::generate().public();
|
||||
|
||||
let announcement = WgAnnouncement::new(network, &local, 51820, vec![]);
|
||||
let encoded = announcement.encode().unwrap();
|
||||
|
||||
let err = WgAnnouncement::decode_and_validate(&encoded, network, &local).unwrap_err();
|
||||
assert!(err.to_string().contains("own WireGuard key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announcement_rejects_wrong_network() {
|
||||
let network_a = NetworkId::from_bytes([1u8; 32]);
|
||||
let network_b = NetworkId::from_bytes([2u8; 32]);
|
||||
let key = WgSecretKey::generate().public();
|
||||
let local = WgSecretKey::generate().public();
|
||||
|
||||
let announcement = WgAnnouncement::new(network_a, &key, 51820, vec![]);
|
||||
let encoded = announcement.encode().unwrap();
|
||||
|
||||
let err = WgAnnouncement::decode_and_validate(&encoded, network_b, &local).unwrap_err();
|
||||
assert!(err.to_string().contains("different network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announcement_rejects_zero_port() {
|
||||
let network = NetworkId::from_bytes([42u8; 32]);
|
||||
let key = WgSecretKey::generate().public();
|
||||
let local = WgSecretKey::generate().public();
|
||||
|
||||
let announcement = WgAnnouncement {
|
||||
version: ANNOUNCEMENT_VERSION,
|
||||
public_key: *key.as_bytes(),
|
||||
network: *network.as_bytes(),
|
||||
port: 0,
|
||||
addrs: vec![],
|
||||
};
|
||||
let encoded = announcement.encode().unwrap();
|
||||
|
||||
let err = WgAnnouncement::decode_and_validate(&encoded, network, &local).unwrap_err();
|
||||
assert!(err.to_string().contains("cannot be 0"));
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
#![allow(missing_docs)]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::mpsc;
|
||||
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
|
||||
use tsunagi::dataplane::{IpPlugin, PacketSink, PluginContext};
|
||||
use tsunagi::identity::{DeviceIdentity, NetworkId};
|
||||
use tsunagi_wireguard::{WireguardConfig, WireguardPlugin, WireguardTransport};
|
||||
|
||||
struct TestSink {
|
||||
delivered: mpsc::Sender<(NetworkId, iroh::EndpointId, Bytes)>,
|
||||
}
|
||||
|
||||
impl PacketSink for TestSink {
|
||||
fn deliver(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
from: iroh::EndpointId,
|
||||
packet: Bytes,
|
||||
) -> tsunagi::BoxFuture<'_, ()> {
|
||||
let delivered = self.delivered.clone();
|
||||
Box::pin(async move {
|
||||
let _ = delivered.send((network, from, packet)).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_ipv4_packet() -> Bytes {
|
||||
let mut packet = vec![0u8; 40];
|
||||
packet[0] = 0x45; // IPv4, IHL 5
|
||||
packet[2..4].copy_from_slice(&40u16.to_be_bytes()); // total len 40
|
||||
packet[8] = 64; // TTL
|
||||
packet[9] = 17; // UDP
|
||||
packet[12..16].copy_from_slice(&[10, 0, 0, 1]); // src 10.0.0.1
|
||||
packet[16..20].copy_from_slice(&[10, 0, 0, 2]); // dst 10.0.0.2
|
||||
Bytes::from(packet)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wireguard_plugin_end_to_end() {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter("trace")
|
||||
.with_test_writer()
|
||||
.try_init();
|
||||
|
||||
let identity_a = DeviceIdentity::generate();
|
||||
let peer_a = identity_a.endpoint_id();
|
||||
|
||||
let identity_b = DeviceIdentity::generate();
|
||||
let peer_b = identity_b.endpoint_id();
|
||||
|
||||
let network = NetworkId::from_bytes([77u8; 32]);
|
||||
|
||||
let tmp_a = tempfile::tempdir().expect("tmp A");
|
||||
let tmp_b = tempfile::tempdir().expect("tmp B");
|
||||
|
||||
// Transports
|
||||
let transport_b = Arc::new(
|
||||
WireguardTransport::bind(&identity_b, Some(0))
|
||||
.await
|
||||
.expect("bind B"),
|
||||
);
|
||||
let port_b = transport_b.bound_port();
|
||||
|
||||
let transport_a = Arc::new(
|
||||
WireguardTransport::bind(&identity_a, Some(0))
|
||||
.await
|
||||
.expect("bind A"),
|
||||
);
|
||||
let port_a = transport_a.bound_port();
|
||||
|
||||
// Plugins
|
||||
let cfg_a = WireguardConfig::new(tmp_a.path());
|
||||
let plugin_a = WireguardPlugin::open(cfg_a, Arc::clone(&transport_a))
|
||||
.await
|
||||
.expect("open plugin A");
|
||||
|
||||
let cfg_b = WireguardConfig::new(tmp_b.path());
|
||||
let plugin_b = WireguardPlugin::open(cfg_b, Arc::clone(&transport_b))
|
||||
.await
|
||||
.expect("open plugin B");
|
||||
|
||||
// Attach sinks
|
||||
let (sink_a_tx, mut sink_a_rx) = mpsc::channel(10);
|
||||
plugin_a.attach(PluginContext::with_sink(Arc::new(TestSink {
|
||||
delivered: sink_a_tx,
|
||||
})));
|
||||
|
||||
let (sink_b_tx, mut sink_b_rx) = mpsc::channel(10);
|
||||
plugin_b.attach(PluginContext::with_sink(Arc::new(TestSink {
|
||||
delivered: sink_b_tx,
|
||||
})));
|
||||
|
||||
// Capability exchange
|
||||
plugin_a.on_network_activated(network);
|
||||
plugin_b.on_network_activated(network);
|
||||
|
||||
let cap_a = loop {
|
||||
if let Some(cap) = plugin_a.local_capability(network).expect("cap A") {
|
||||
break cap;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
};
|
||||
|
||||
let cap_b = loop {
|
||||
if let Some(cap) = plugin_b.local_capability(network).expect("cap B") {
|
||||
break cap;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
};
|
||||
|
||||
plugin_a
|
||||
.on_peer_capability(network, peer_b, &cap_b)
|
||||
.expect("A on cap B");
|
||||
plugin_b
|
||||
.on_peer_capability(network, peer_a, &cap_a)
|
||||
.expect("B on cap A");
|
||||
|
||||
// Register loopback address candidates
|
||||
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
|
||||
transport_a.set_peer_addr(peer_b, addr_b);
|
||||
|
||||
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
|
||||
transport_b.set_peer_addr(peer_a, addr_a);
|
||||
|
||||
// Spawn accept loop on A
|
||||
let transport_a_clone = Arc::clone(&transport_a);
|
||||
tokio::spawn(async move {
|
||||
transport_a_clone.accept_loop(|_| {}).await;
|
||||
});
|
||||
|
||||
// Spawn accept loop on B
|
||||
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
|
||||
let transport_b_clone = Arc::clone(&transport_b);
|
||||
tokio::spawn(async move {
|
||||
transport_b_clone
|
||||
.accept_loop(move |inbound| {
|
||||
let _ = inbound_tx.try_send(inbound);
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
// Open link from A to B
|
||||
let link_a = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
transport_a.open(network, peer_b, "wg"),
|
||||
)
|
||||
.await
|
||||
.expect("open should not timeout")
|
||||
.expect("open should succeed");
|
||||
|
||||
plugin_a.on_peer_link(network, peer_b, Arc::clone(&link_a));
|
||||
|
||||
// Wait for inbound link on B and hand to plugin B
|
||||
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
|
||||
.await
|
||||
.expect("accept should not timeout")
|
||||
.expect("inbound link should arrive");
|
||||
plugin_b.on_peer_link(network, peer_a, inbound_b.link);
|
||||
|
||||
// Wait briefly for WireGuard handshake to complete
|
||||
let mut up = false;
|
||||
for _ in 0..50 {
|
||||
if let Some(overview_a) = plugin_a.overview(network)
|
||||
&& overview_a.peers.iter().any(|p| p.is_up())
|
||||
{
|
||||
up = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
assert!(up, "WireGuard handshake should complete between peers");
|
||||
|
||||
// Send IP packet from A to B
|
||||
let packet_a_to_b = sample_ipv4_packet();
|
||||
let sent = plugin_a.carry(network, peer_b, packet_a_to_b.clone());
|
||||
assert!(sent, "plugin A carry should succeed");
|
||||
|
||||
let received_b = tokio::time::timeout(Duration::from_secs(5), sink_b_rx.recv())
|
||||
.await
|
||||
.expect("recv B timeout")
|
||||
.expect("recv B option");
|
||||
|
||||
assert_eq!(received_b.0, network);
|
||||
assert_eq!(received_b.1, peer_a);
|
||||
assert_eq!(received_b.2, packet_a_to_b);
|
||||
|
||||
// Send IP packet from B to A
|
||||
let packet_b_to_a = sample_ipv4_packet();
|
||||
let sent_reply = plugin_b.carry(network, peer_a, packet_b_to_a.clone());
|
||||
assert!(sent_reply, "plugin B carry should succeed");
|
||||
|
||||
let received_a = tokio::time::timeout(Duration::from_secs(5), sink_a_rx.recv())
|
||||
.await
|
||||
.expect("recv A timeout")
|
||||
.expect("recv A option");
|
||||
|
||||
assert_eq!(received_a.0, network);
|
||||
assert_eq!(received_a.1, peer_b);
|
||||
assert_eq!(received_a.2, packet_b_to_a);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wireguard_two_networks_same_peers() {
|
||||
let identity_a = DeviceIdentity::generate();
|
||||
let peer_a = identity_a.endpoint_id();
|
||||
|
||||
let identity_b = DeviceIdentity::generate();
|
||||
let peer_b = identity_b.endpoint_id();
|
||||
|
||||
let net1 = NetworkId::from_bytes([11u8; 32]);
|
||||
let net2 = NetworkId::from_bytes([22u8; 32]);
|
||||
|
||||
let tmp_a = tempfile::tempdir().expect("tmp A");
|
||||
let tmp_b = tempfile::tempdir().expect("tmp B");
|
||||
|
||||
let transport_b = Arc::new(
|
||||
WireguardTransport::bind(&identity_b, Some(0))
|
||||
.await
|
||||
.expect("bind B"),
|
||||
);
|
||||
let port_b = transport_b.bound_port();
|
||||
|
||||
let transport_a = Arc::new(
|
||||
WireguardTransport::bind(&identity_a, Some(0))
|
||||
.await
|
||||
.expect("bind A"),
|
||||
);
|
||||
let port_a = transport_a.bound_port();
|
||||
|
||||
let plugin_a =
|
||||
WireguardPlugin::open(WireguardConfig::new(tmp_a.path()), Arc::clone(&transport_a))
|
||||
.await
|
||||
.expect("open plugin A");
|
||||
|
||||
let plugin_b =
|
||||
WireguardPlugin::open(WireguardConfig::new(tmp_b.path()), Arc::clone(&transport_b))
|
||||
.await
|
||||
.expect("open plugin B");
|
||||
|
||||
let (sink_a_tx, mut sink_a_rx) = mpsc::channel(20);
|
||||
plugin_a.attach(PluginContext::with_sink(Arc::new(TestSink {
|
||||
delivered: sink_a_tx,
|
||||
})));
|
||||
|
||||
let (sink_b_tx, mut sink_b_rx) = mpsc::channel(20);
|
||||
plugin_b.attach(PluginContext::with_sink(Arc::new(TestSink {
|
||||
delivered: sink_b_tx,
|
||||
})));
|
||||
|
||||
// Activate both networks
|
||||
for net in [net1, net2] {
|
||||
plugin_a.on_network_activated(net);
|
||||
plugin_b.on_network_activated(net);
|
||||
}
|
||||
|
||||
// Exchange capabilities for both networks
|
||||
for net in [net1, net2] {
|
||||
let cap_a = loop {
|
||||
if let Some(cap) = plugin_a.local_capability(net).expect("cap A") {
|
||||
break cap;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
};
|
||||
let cap_b = loop {
|
||||
if let Some(cap) = plugin_b.local_capability(net).expect("cap B") {
|
||||
break cap;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
};
|
||||
plugin_a
|
||||
.on_peer_capability(net, peer_b, &cap_b)
|
||||
.expect("A on cap B");
|
||||
plugin_b
|
||||
.on_peer_capability(net, peer_a, &cap_a)
|
||||
.expect("B on cap A");
|
||||
}
|
||||
|
||||
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
|
||||
transport_a.set_peer_addr(peer_b, addr_b);
|
||||
|
||||
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
|
||||
transport_b.set_peer_addr(peer_a, addr_a);
|
||||
|
||||
// Accept loops
|
||||
let transport_a_clone = Arc::clone(&transport_a);
|
||||
tokio::spawn(async move {
|
||||
transport_a_clone.accept_loop(|_| {}).await;
|
||||
});
|
||||
|
||||
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(10);
|
||||
let transport_b_clone = Arc::clone(&transport_b);
|
||||
tokio::spawn(async move {
|
||||
transport_b_clone
|
||||
.accept_loop(move |inbound| {
|
||||
let _ = inbound_tx.try_send(inbound);
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
// Open links for both networks from A to B
|
||||
for net in [net1, net2] {
|
||||
let link_a = transport_a
|
||||
.open(net, peer_b, "wg")
|
||||
.await
|
||||
.expect("open A link");
|
||||
plugin_a.on_peer_link(net, peer_b, link_a);
|
||||
}
|
||||
|
||||
// Deliver inbound links to B
|
||||
for _ in 0..2 {
|
||||
let inbound = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
|
||||
.await
|
||||
.expect("inbound timeout")
|
||||
.expect("inbound option");
|
||||
plugin_b.on_peer_link(inbound.network, peer_a, inbound.link);
|
||||
}
|
||||
|
||||
// Wait for both handshakes to complete on both networks
|
||||
for net in [net1, net2] {
|
||||
let mut up = false;
|
||||
for _ in 0..50 {
|
||||
if let Some(overview) = plugin_a.overview(net)
|
||||
&& overview.peers.iter().any(|p| p.is_up())
|
||||
{
|
||||
up = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
assert!(up, "handshake on {net:?} should complete");
|
||||
}
|
||||
|
||||
// Send on net1
|
||||
let pkt1 = sample_ipv4_packet();
|
||||
assert!(plugin_a.carry(net1, peer_b, pkt1.clone()));
|
||||
let recv1 = tokio::time::timeout(Duration::from_secs(5), sink_b_rx.recv())
|
||||
.await
|
||||
.expect("recv1 timeout")
|
||||
.expect("recv1 option");
|
||||
assert_eq!(recv1.0, net1);
|
||||
assert_eq!(recv1.2, pkt1);
|
||||
|
||||
// Send on net2 from A to B
|
||||
let mut pkt2_bytes = sample_ipv4_packet().to_vec();
|
||||
pkt2_bytes[15] = 99; // different byte
|
||||
let pkt2 = Bytes::from(pkt2_bytes);
|
||||
assert!(plugin_a.carry(net2, peer_b, pkt2.clone()));
|
||||
let recv2 = tokio::time::timeout(Duration::from_secs(5), sink_b_rx.recv())
|
||||
.await
|
||||
.expect("recv2 timeout")
|
||||
.expect("recv2 option");
|
||||
assert_eq!(recv2.0, net2);
|
||||
assert_eq!(recv2.2, pkt2);
|
||||
|
||||
// Send reply on net1 from B to A
|
||||
assert!(plugin_b.carry(net1, peer_a, pkt1.clone()));
|
||||
let reply1 = tokio::time::timeout(Duration::from_secs(5), sink_a_rx.recv())
|
||||
.await
|
||||
.expect("reply1 timeout")
|
||||
.expect("reply1 option");
|
||||
assert_eq!(reply1.0, net1);
|
||||
assert_eq!(reply1.2, pkt1);
|
||||
|
||||
// Send reply on net2 from B to A
|
||||
assert!(plugin_b.carry(net2, peer_a, pkt2.clone()));
|
||||
let reply2 = tokio::time::timeout(Duration::from_secs(5), sink_a_rx.recv())
|
||||
.await
|
||||
.expect("reply2 timeout")
|
||||
.expect("reply2 option");
|
||||
assert_eq!(reply2.0, net2);
|
||||
assert_eq!(reply2.2, pkt2);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
#![allow(missing_docs)]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::mpsc;
|
||||
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
|
||||
use tsunagi::identity::{DeviceIdentity, NetworkId};
|
||||
use tsunagi_wireguard::WireguardTransport;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wireguard_transport_loopback() {
|
||||
let identity_a = DeviceIdentity::generate();
|
||||
let peer_a = identity_a.endpoint_id();
|
||||
|
||||
let identity_b = DeviceIdentity::generate();
|
||||
let peer_b = identity_b.endpoint_id();
|
||||
|
||||
let network = NetworkId::from_bytes([42u8; 32]);
|
||||
|
||||
// Bind transport B on port 0 (ephemeral)
|
||||
let transport_b = Arc::new(
|
||||
WireguardTransport::bind(&identity_b, Some(0))
|
||||
.await
|
||||
.expect("bind server should succeed"),
|
||||
);
|
||||
let port_b = transport_b.bound_port();
|
||||
assert!(port_b > 0);
|
||||
|
||||
// Bind transport A on port 0 (ephemeral)
|
||||
let transport_a = Arc::new(
|
||||
WireguardTransport::bind(&identity_a, Some(0))
|
||||
.await
|
||||
.expect("bind client should succeed"),
|
||||
);
|
||||
let port_a = transport_a.bound_port();
|
||||
assert!(port_a > 0);
|
||||
|
||||
// Register B's address and network on transport A
|
||||
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
|
||||
transport_a.set_peer_addr(peer_b, addr_b);
|
||||
transport_a.set_peer_network(peer_b, network);
|
||||
|
||||
// Register A's address and network on transport B
|
||||
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
|
||||
transport_b.set_peer_addr(peer_a, addr_a);
|
||||
transport_b.set_peer_network(peer_a, network);
|
||||
|
||||
// Spawn accept loop on A
|
||||
let transport_a_clone = Arc::clone(&transport_a);
|
||||
tokio::spawn(async move {
|
||||
transport_a_clone.accept_loop(|_| {}).await;
|
||||
});
|
||||
|
||||
// Spawn accept loop on B
|
||||
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
|
||||
let transport_b_clone = Arc::clone(&transport_b);
|
||||
tokio::spawn(async move {
|
||||
transport_b_clone
|
||||
.accept_loop(move |inbound| {
|
||||
let _ = inbound_tx.try_send(inbound);
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
// Open link from A to B
|
||||
let link_a = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
transport_a.open(network, peer_b, "wg"),
|
||||
)
|
||||
.await
|
||||
.expect("open should not timeout")
|
||||
.expect("open should succeed");
|
||||
|
||||
// Send from A to B to trigger inbound link discovery on B
|
||||
let data_a_to_b = Bytes::from_static(b"wireguard packet from A to B");
|
||||
link_a.send(data_a_to_b.clone()).expect("send A->B");
|
||||
|
||||
// Receive inbound link on B
|
||||
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
|
||||
.await
|
||||
.expect("accept should not timeout")
|
||||
.expect("inbound link should arrive");
|
||||
|
||||
assert_eq!(inbound_b.network, network);
|
||||
assert_eq!(inbound_b.peer, peer_a);
|
||||
assert_eq!(inbound_b.protocol, "wg");
|
||||
let link_b = inbound_b.link;
|
||||
|
||||
// Check packet received on B
|
||||
let received_on_b = tokio::time::timeout(Duration::from_secs(2), link_b.recv())
|
||||
.await
|
||||
.expect("recv B timeout")
|
||||
.expect("recv B option");
|
||||
assert_eq!(received_on_b, data_a_to_b);
|
||||
|
||||
// Send from B to A
|
||||
let data_b_to_a = Bytes::from_static(b"wireguard packet from B to A");
|
||||
link_b.send(data_b_to_a.clone()).expect("send B->A");
|
||||
let received_on_a = tokio::time::timeout(Duration::from_secs(2), link_a.recv())
|
||||
.await
|
||||
.expect("recv A timeout")
|
||||
.expect("recv A option");
|
||||
assert_eq!(received_on_a, data_b_to_a);
|
||||
|
||||
// Path descriptions
|
||||
assert!(link_a.path_description().contains("wireguard via"));
|
||||
assert!(link_b.path_description().contains("wireguard via"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wireguard_transport_unreachable_probe_times_out() {
|
||||
let identity_a = DeviceIdentity::generate();
|
||||
let identity_b = DeviceIdentity::generate();
|
||||
let peer_b = identity_b.endpoint_id();
|
||||
let network = NetworkId::from_bytes([99u8; 32]);
|
||||
|
||||
let transport_a = Arc::new(
|
||||
WireguardTransport::bind(&identity_a, Some(0))
|
||||
.await
|
||||
.expect("bind client should succeed"),
|
||||
);
|
||||
|
||||
// Register a non-responsive address for peer B (TEST-NET-1 documentation IP which drops packets)
|
||||
let unreachable_addr: SocketAddr = "192.0.2.1:51820".parse().unwrap();
|
||||
transport_a.set_peer_addr(peer_b, unreachable_addr);
|
||||
transport_a.set_peer_network(peer_b, network);
|
||||
|
||||
// Spawn accept loop on A
|
||||
let transport_a_clone = Arc::clone(&transport_a);
|
||||
tokio::spawn(async move {
|
||||
transport_a_clone.accept_loop(|_| {}).await;
|
||||
});
|
||||
|
||||
// Opening to unreachable candidate must fail with Unreachable rather than returning a dead direct link
|
||||
let result = transport_a.open(network, peer_b, "wg").await;
|
||||
match result {
|
||||
Err(tsunagi::dataplane::transport::TransportError::Unreachable(msg)) => {
|
||||
assert!(msg.contains("timed out"));
|
||||
}
|
||||
other => panic!("expected Unreachable error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wireguard_link_close_and_transport_close_link() {
|
||||
let identity_a = DeviceIdentity::generate();
|
||||
let peer_a = identity_a.endpoint_id();
|
||||
let identity_b = DeviceIdentity::generate();
|
||||
let peer_b = identity_b.endpoint_id();
|
||||
let network = NetworkId::from_bytes([55u8; 32]);
|
||||
|
||||
let transport_b = Arc::new(
|
||||
WireguardTransport::bind(&identity_b, Some(0))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let port_b = transport_b.bound_port();
|
||||
|
||||
let transport_a = Arc::new(
|
||||
WireguardTransport::bind(&identity_a, Some(0))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let port_a = transport_a.bound_port();
|
||||
|
||||
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
|
||||
transport_a.set_peer_addr(peer_b, addr_b);
|
||||
transport_a.set_peer_network(peer_b, network);
|
||||
|
||||
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
|
||||
transport_b.set_peer_addr(peer_a, addr_a);
|
||||
transport_b.set_peer_network(peer_a, network);
|
||||
|
||||
let transport_a_clone = Arc::clone(&transport_a);
|
||||
tokio::spawn(async move {
|
||||
transport_a_clone.accept_loop(|_| {}).await;
|
||||
});
|
||||
|
||||
let transport_b_clone = Arc::clone(&transport_b);
|
||||
tokio::spawn(async move {
|
||||
transport_b_clone.accept_loop(|_| {}).await;
|
||||
});
|
||||
|
||||
let link_a = transport_a
|
||||
.open(network, peer_b, "wg")
|
||||
.await
|
||||
.expect("open link A");
|
||||
assert!(!link_a.is_closed());
|
||||
|
||||
// Spawn a reader task waiting on recv()
|
||||
let link_a_clone = Arc::clone(&link_a);
|
||||
let recv_task = tokio::spawn(async move { link_a_clone.recv().await });
|
||||
|
||||
// Explicitly closing via transport.close_link()
|
||||
transport_a.close_link(network, peer_b);
|
||||
assert!(link_a.is_closed());
|
||||
|
||||
// Wait on closed() notification
|
||||
tokio::time::timeout(Duration::from_secs(1), link_a.closed())
|
||||
.await
|
||||
.expect("link.closed() should resolve promptly");
|
||||
|
||||
// recv() must have returned None
|
||||
let recv_res = tokio::time::timeout(Duration::from_secs(1), recv_task)
|
||||
.await
|
||||
.expect("recv task should complete")
|
||||
.expect("task join");
|
||||
assert_eq!(recv_res, None);
|
||||
|
||||
// Send after close must return Closed error
|
||||
let send_res = link_a.send(Bytes::from_static(b"after close"));
|
||||
assert!(matches!(
|
||||
send_res,
|
||||
Err(tsunagi::dataplane::transport::TransportError::Closed)
|
||||
));
|
||||
}
|
||||
@@ -22,7 +22,6 @@ tun-device = [
|
||||
"dep:rtnetlink",
|
||||
"dep:caps",
|
||||
"dep:futures-util",
|
||||
"dep:netdev",
|
||||
]
|
||||
# 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
|
||||
@@ -38,6 +37,7 @@ 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
|
||||
@@ -66,6 +66,7 @@ directories = "6.0"
|
||||
# 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 }
|
||||
@@ -92,7 +93,11 @@ futures-util = { version = "0.3", default-features = false, optional = true }
|
||||
# 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]
|
||||
netdev = { version = "0.45", optional = true }
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Security",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Foundation",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Its own tests use the harness it publishes.
|
||||
|
||||
@@ -281,11 +281,19 @@ impl Agent {
|
||||
// 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() {
|
||||
@@ -346,6 +354,26 @@ 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
|
||||
@@ -357,10 +385,7 @@ impl Agent {
|
||||
/// 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.
|
||||
fn reserve_range(&self, network: NetworkId) -> RangePlan {
|
||||
let Some(configured) = self.inner.config.overlay_ipv4_range else {
|
||||
return RangePlan::default();
|
||||
};
|
||||
async fn reserve_range(&self, network: NetworkId) -> RangePlan {
|
||||
let reserve = |wanted: Ipv4Range| {
|
||||
let reservation = crate::overlay::NetworkRoutes {
|
||||
broadcast: Default::default(),
|
||||
@@ -371,6 +396,44 @@ impl Agent {
|
||||
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) {
|
||||
@@ -418,6 +481,7 @@ impl Agent {
|
||||
mtu: interface.mtu(),
|
||||
addresses: interface.wanted_addresses(),
|
||||
counters: interface.counters(),
|
||||
broadcast_rules: interface.broadcast_rules(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -582,7 +646,7 @@ impl Agent {
|
||||
return Err(Error::NetworkAlreadyActive(network_id));
|
||||
}
|
||||
|
||||
let range = self.reserve_range(network_id);
|
||||
let range = self.reserve_range(network_id).await;
|
||||
let mut backends = Vec::new();
|
||||
if let Some(discovery) = &self.inner.config.discovery {
|
||||
backends.push(discovery.clone());
|
||||
@@ -667,6 +731,9 @@ impl Agent {
|
||||
{
|
||||
tracing::warn!(%err, "cannot update the overlay addresses");
|
||||
}
|
||||
if let Some(interface) = self.inner.interface.get() {
|
||||
interface.sync_broadcast_rules().await;
|
||||
}
|
||||
self.inner.storage.set_auto_start(network_id, false).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1055,6 +1122,26 @@ async fn plugin_request_loop(weak: Weak<Inner>, mut requests: mpsc::Receiver<Plu
|
||||
}
|
||||
}
|
||||
}
|
||||
PluginRequest::ProtocolFailure {
|
||||
network,
|
||||
peer,
|
||||
protocol,
|
||||
reason,
|
||||
} => {
|
||||
let sender = {
|
||||
let networks = inner.networks.read().await;
|
||||
networks.get(&network).map(|handle| handle.commands.clone())
|
||||
};
|
||||
if let Some(sender) = sender {
|
||||
let _ = sender
|
||||
.send(NetCommand::ProtocolFailure {
|
||||
peer,
|
||||
protocol,
|
||||
reason,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1200,8 +1287,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;
|
||||
};
|
||||
|
||||
@@ -86,6 +86,12 @@ pub(crate) enum NetCommand {
|
||||
/// Human readable reason, free of secrets.
|
||||
reason: String,
|
||||
},
|
||||
/// An IP plugin reported that data plane connectivity or a tunnel for a peer failed.
|
||||
ProtocolFailure {
|
||||
peer: EndpointId,
|
||||
protocol: String,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NetCommand {
|
||||
@@ -104,6 +110,9 @@ impl std::fmt::Debug for NetCommand {
|
||||
NetCommand::SetHostname(_) => f.write_str("SetHostname"),
|
||||
NetCommand::Release { .. } => f.write_str("Release"),
|
||||
NetCommand::PluginError { protocol, .. } => write!(f, "PluginError({protocol})"),
|
||||
NetCommand::ProtocolFailure { peer, protocol, .. } => {
|
||||
write!(f, "ProtocolFailure({}, {protocol})", peer.fmt_short())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,6 +248,24 @@ const REACH_EXPIRY: Duration = Duration::from_secs(90);
|
||||
/// can arrive and be adopted instead.
|
||||
const RANGE_PROPOSAL_GRACE: Duration = Duration::from_secs(3);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ProtocolDialState {
|
||||
failures: u32,
|
||||
cooldown_until: std::time::Instant,
|
||||
}
|
||||
|
||||
const DATA_LINK_BASE_BACKOFF: Duration = Duration::from_secs(3);
|
||||
const DATA_LINK_MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||
|
||||
fn data_link_backoff(failures: u32) -> Duration {
|
||||
if failures == 0 {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
let shift = (failures - 1).min(5);
|
||||
let secs = (DATA_LINK_BASE_BACKOFF.as_secs() << shift).min(DATA_LINK_MAX_BACKOFF.as_secs());
|
||||
Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
struct Runtime {
|
||||
discovery_worker: Option<DiscoveryWorker>,
|
||||
discovery_candidates: mpsc::Receiver<Candidate>,
|
||||
@@ -284,6 +311,8 @@ struct Runtime {
|
||||
opening: HashSet<(EndpointId, String)>,
|
||||
link_results_tx: mpsc::Sender<LinkOutcome>,
|
||||
link_results_rx: mpsc::Receiver<LinkOutcome>,
|
||||
/// Protocol dial failure count and backoff cooldown per peer and protocol.
|
||||
protocol_dial_states: HashMap<(EndpointId, String), ProtocolDialState>,
|
||||
/// Peers already told about a protocol version that cannot match, so it
|
||||
/// is said once rather than on every announcement.
|
||||
reported_mismatch: HashSet<(EndpointId, String)>,
|
||||
@@ -346,6 +375,7 @@ impl Runtime {
|
||||
opening: HashSet::new(),
|
||||
link_results_tx,
|
||||
link_results_rx,
|
||||
protocol_dial_states: HashMap::new(),
|
||||
reported_mismatch: HashSet::new(),
|
||||
released: false,
|
||||
reported_missing: None,
|
||||
@@ -422,6 +452,7 @@ impl Runtime {
|
||||
self.session_events_rx.close();
|
||||
self.hub.close();
|
||||
self.links.clear();
|
||||
self.protocol_dial_states.clear();
|
||||
let peers: Vec<EndpointId> = self.sessions.keys().copied().collect();
|
||||
for peer in peers {
|
||||
if let Some(session) = self.sessions.remove(&peer) {
|
||||
@@ -464,6 +495,9 @@ impl Runtime {
|
||||
NetCommand::SetBroadcast { enabled, reply } => {
|
||||
self.params.broadcast = enabled;
|
||||
self.update_broadcast();
|
||||
if let Some(interface) = self.params.interface.clone() {
|
||||
interface.sync_broadcast_rules().await;
|
||||
}
|
||||
self.reannounce();
|
||||
let _ = reply.send(());
|
||||
}
|
||||
@@ -500,6 +534,26 @@ impl Runtime {
|
||||
reason,
|
||||
});
|
||||
}
|
||||
NetCommand::ProtocolFailure {
|
||||
peer,
|
||||
protocol,
|
||||
reason,
|
||||
} => {
|
||||
if self.links.remove(&(peer, protocol.clone())).is_some() {
|
||||
self.hub.clear_direct(peer, &protocol);
|
||||
}
|
||||
self.metrics.data_link_failures += 1;
|
||||
self.record_protocol_failure(peer, &protocol);
|
||||
self.emit(Event::DataLinkDown {
|
||||
network: self.network_id,
|
||||
peer,
|
||||
protocol,
|
||||
reason,
|
||||
});
|
||||
self.ensure_links();
|
||||
self.update_paths();
|
||||
self.announce_reach(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,19 +1164,40 @@ impl Runtime {
|
||||
// wrong here before. Said once per address, not every round.
|
||||
// An in-memory interface has no addresses to be missing from: with
|
||||
// `--no-tun` this is the arrangement asked for, not a fault.
|
||||
let missing = match local {
|
||||
Some(address)
|
||||
if interface.on_host()
|
||||
&& !crate::overlay::address_is_local(std::net::IpAddr::V4(address)) =>
|
||||
{
|
||||
let is_missing = if let Some(address) = local {
|
||||
if interface.on_host() {
|
||||
let mut local_found =
|
||||
crate::overlay::address_is_local(std::net::IpAddr::V4(address));
|
||||
// On Windows, duplicate address detection (DAD) and netsh/IP helper updates
|
||||
// may take a short moment before a newly assigned address is ready for binding.
|
||||
if !local_found && cfg!(target_os = "windows") {
|
||||
for _ in 0..10 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
if crate::overlay::address_is_local(std::net::IpAddr::V4(address)) {
|
||||
local_found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
!local_found
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let missing = if is_missing {
|
||||
if let Some(address) = local {
|
||||
let already = self.reported_missing == Some(address);
|
||||
self.reported_missing = Some(address);
|
||||
(!already).then_some(address)
|
||||
}
|
||||
_ => {
|
||||
self.reported_missing = None;
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
self.reported_missing = None;
|
||||
None
|
||||
};
|
||||
if let Some(address) = missing {
|
||||
self.emit(Event::PluginError {
|
||||
@@ -1139,6 +1214,12 @@ impl Runtime {
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// The broadcast route names this address as its source, so it goes in
|
||||
// only once the address is really on the interface.
|
||||
if !is_missing {
|
||||
interface.sync_broadcast_rules().await;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ data plane
|
||||
@@ -1152,6 +1233,25 @@ impl Runtime {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn record_protocol_failure(&mut self, peer: EndpointId, protocol: &str) {
|
||||
let key = (peer, protocol.to_string());
|
||||
let entry = self
|
||||
.protocol_dial_states
|
||||
.entry(key)
|
||||
.or_insert(ProtocolDialState {
|
||||
failures: 0,
|
||||
cooldown_until: std::time::Instant::now(),
|
||||
});
|
||||
entry.failures = entry.failures.saturating_add(1);
|
||||
let backoff = data_link_backoff(entry.failures);
|
||||
entry.cooldown_until = std::time::Instant::now() + backoff;
|
||||
}
|
||||
|
||||
fn record_protocol_success(&mut self, peer: EndpointId, protocol: &str) {
|
||||
self.protocol_dial_states
|
||||
.remove(&(peer, protocol.to_string()));
|
||||
}
|
||||
|
||||
/// Opens whatever data plane links are missing, and forgets dead ones.
|
||||
///
|
||||
/// Only one side dials, chosen by a rule both sides compute the same way,
|
||||
@@ -1177,6 +1277,7 @@ impl Runtime {
|
||||
// reachable through somebody is decided below; the protocol's
|
||||
// link stays either way.
|
||||
self.hub.clear_direct(peer, &protocol);
|
||||
self.record_protocol_failure(peer, &protocol);
|
||||
self.emit(Event::DataLinkDown {
|
||||
network: self.network_id,
|
||||
peer,
|
||||
@@ -1185,32 +1286,17 @@ impl Runtime {
|
||||
});
|
||||
}
|
||||
|
||||
// Both the name and the version have to match. A peer offering
|
||||
// `wg-quic` at a version this build does not speak is not a peer to
|
||||
// carry traffic with, and a link opened anyway would fail later and
|
||||
// say less about why.
|
||||
let wanted: Vec<(EndpointId, String)> = self
|
||||
.sessions
|
||||
.values()
|
||||
.flat_map(|session| {
|
||||
let peer = session.peer;
|
||||
session
|
||||
.capabilities
|
||||
.iter()
|
||||
.filter(|capability| capability.enabled)
|
||||
.map(move |capability| (peer, capability.protocol.clone(), capability.version))
|
||||
})
|
||||
.filter(|(_, protocol, version)| {
|
||||
served
|
||||
.iter()
|
||||
.any(|(name, ours)| name == protocol && ours == version)
|
||||
})
|
||||
.map(|(peer, protocol, _)| (peer, protocol))
|
||||
.collect();
|
||||
|
||||
for (peer, protocol) in wanted {
|
||||
let key = (peer, protocol.clone());
|
||||
if self.links.contains_key(&key) || self.opening.contains(&key) {
|
||||
// Choose the highest priority mutually supported protocol for each peer.
|
||||
// If a direct data link is already open or opening, do not dial another.
|
||||
// If a protocol recently failed, fall back to other mutually supported
|
||||
// protocols according to backoff cooldown.
|
||||
let now = std::time::Instant::now();
|
||||
let mut wanted: Vec<(EndpointId, String)> = Vec::new();
|
||||
for session in self.sessions.values() {
|
||||
let peer = session.peer;
|
||||
if self.links.keys().any(|(p, _)| *p == peer)
|
||||
|| self.opening.iter().any(|(p, _)| *p == peer)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if self.no_direct_data(peer) {
|
||||
@@ -1221,6 +1307,39 @@ impl Runtime {
|
||||
if self.local_id.as_bytes() >= peer.as_bytes() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let supported: Vec<String> = served
|
||||
.iter()
|
||||
.filter(|(name, ours)| {
|
||||
session
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|c| c.enabled && c.protocol == *name && c.version == *ours)
|
||||
})
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
|
||||
if supported.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let chosen = supported.iter().find(|proto| {
|
||||
match self.protocol_dial_states.get(&(peer, (*proto).clone())) {
|
||||
Some(state) => state.cooldown_until <= now,
|
||||
None => true,
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(proto) = chosen {
|
||||
wanted.push((peer, proto.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
for (peer, protocol) in wanted {
|
||||
let key = (peer, protocol.clone());
|
||||
if self.links.contains_key(&key) || self.opening.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
self.opening.insert(key);
|
||||
|
||||
let results = self.link_results_tx.clone();
|
||||
@@ -1455,6 +1574,7 @@ impl Runtime {
|
||||
self.opening.remove(&key);
|
||||
match outcome.result {
|
||||
Ok(link) => {
|
||||
self.record_protocol_success(outcome.peer, &outcome.protocol);
|
||||
self.adopt_link(outcome.peer, outcome.protocol, link);
|
||||
// A new link is both a path for us and one we can offer
|
||||
// others, so it changes what we route and what we say.
|
||||
@@ -1464,12 +1584,15 @@ impl Runtime {
|
||||
Err(reason) => {
|
||||
// A data plane that cannot be set up is reported, never fatal.
|
||||
self.metrics.data_link_failures += 1;
|
||||
self.record_protocol_failure(outcome.peer, &outcome.protocol);
|
||||
self.emit(Event::DataLinkDown {
|
||||
network: self.network_id,
|
||||
peer: outcome.peer,
|
||||
protocol: outcome.protocol,
|
||||
reason,
|
||||
});
|
||||
// Fall back to other mutually supported protocols immediately.
|
||||
self.ensure_links();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1497,6 +1620,7 @@ impl Runtime {
|
||||
if self.no_direct_data(inbound.peer) {
|
||||
return;
|
||||
}
|
||||
self.record_protocol_success(inbound.peer, &inbound.protocol);
|
||||
self.adopt_link(inbound.peer, inbound.protocol, inbound.link);
|
||||
self.update_paths();
|
||||
self.announce_reach(false);
|
||||
@@ -1544,6 +1668,7 @@ impl Runtime {
|
||||
// protocol's link goes with it.
|
||||
self.hub.remove_peer(peer);
|
||||
self.reachable.remove(&peer);
|
||||
self.protocol_dial_states.retain(|(p, _), _| *p != peer);
|
||||
let keys: Vec<(EndpointId, String)> = self
|
||||
.links
|
||||
.keys()
|
||||
@@ -2059,3 +2184,86 @@ fn merge_addr(existing: &mut iroh::EndpointAddr, incoming: &iroh::EndpointAddr)
|
||||
existing.addrs.insert(addr.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_data_link_backoff_progression() {
|
||||
assert_eq!(data_link_backoff(0), Duration::ZERO);
|
||||
assert_eq!(data_link_backoff(1), Duration::from_secs(3));
|
||||
assert_eq!(data_link_backoff(2), Duration::from_secs(6));
|
||||
assert_eq!(data_link_backoff(3), Duration::from_secs(12));
|
||||
assert_eq!(data_link_backoff(4), Duration::from_secs(24));
|
||||
assert_eq!(data_link_backoff(5), Duration::from_secs(48));
|
||||
assert_eq!(data_link_backoff(6), Duration::from_secs(60));
|
||||
assert_eq!(data_link_backoff(10), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_protocol_dial_state_cooldown_and_fallback_selection() {
|
||||
let peer = crate::identity::DeviceIdentity::generate().endpoint_id();
|
||||
let supported = ["wg", "tcp-tls", "wg-quic"];
|
||||
let mut states: HashMap<(EndpointId, String), ProtocolDialState> = HashMap::new();
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
|
||||
// 1. Without cooldown, the highest priority protocol "wg" is chosen
|
||||
let chosen =
|
||||
supported
|
||||
.iter()
|
||||
.find(|proto| match states.get(&(peer, (*proto).to_string())) {
|
||||
Some(state) => state.cooldown_until <= now,
|
||||
None => true,
|
||||
});
|
||||
assert_eq!(chosen.copied(), Some("wg"));
|
||||
|
||||
// 2. Record failure for "wg" putting it on cooldown
|
||||
states.insert(
|
||||
(peer, "wg".to_string()),
|
||||
ProtocolDialState {
|
||||
failures: 1,
|
||||
cooldown_until: now + Duration::from_secs(3),
|
||||
},
|
||||
);
|
||||
|
||||
// With "wg" on cooldown, selection must fall back to next priority "tcp-tls"
|
||||
let chosen_after_failure =
|
||||
supported
|
||||
.iter()
|
||||
.find(|proto| match states.get(&(peer, (*proto).to_string())) {
|
||||
Some(state) => state.cooldown_until <= now,
|
||||
None => true,
|
||||
});
|
||||
assert_eq!(chosen_after_failure.copied(), Some("tcp-tls"));
|
||||
|
||||
// 3. If "tcp-tls" also fails, selection falls back to "wg-quic"
|
||||
states.insert(
|
||||
(peer, "tcp-tls".to_string()),
|
||||
ProtocolDialState {
|
||||
failures: 1,
|
||||
cooldown_until: now + Duration::from_secs(3),
|
||||
},
|
||||
);
|
||||
let chosen_quic =
|
||||
supported
|
||||
.iter()
|
||||
.find(|proto| match states.get(&(peer, (*proto).to_string())) {
|
||||
Some(state) => state.cooldown_until <= now,
|
||||
None => true,
|
||||
});
|
||||
assert_eq!(chosen_quic.copied(), Some("wg-quic"));
|
||||
|
||||
// 4. Once success is recorded (entry removed), "wg" is preferred again
|
||||
states.remove(&(peer, "wg".to_string()));
|
||||
let chosen_restored =
|
||||
supported
|
||||
.iter()
|
||||
.find(|proto| match states.get(&(peer, (*proto).to_string())) {
|
||||
Some(state) => state.cooldown_until <= now,
|
||||
None => true,
|
||||
});
|
||||
assert_eq!(chosen_restored.copied(), Some("wg"));
|
||||
}
|
||||
}
|
||||
@@ -80,11 +80,12 @@ pub(crate) struct Session {
|
||||
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
|
||||
@@ -94,6 +95,7 @@ impl Session {
|
||||
conn,
|
||||
reader,
|
||||
writer,
|
||||
path_monitor,
|
||||
shutdown,
|
||||
..
|
||||
} = self;
|
||||
@@ -103,23 +105,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +161,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,
|
||||
@@ -182,6 +234,7 @@ pub(crate) fn spawn(
|
||||
bytes_received: 0,
|
||||
reader,
|
||||
writer,
|
||||
path_monitor,
|
||||
shutdown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ pub struct OverlayStatus {
|
||||
pub addresses: Vec<crate::overlay::Cidr>,
|
||||
/// What has happened on it.
|
||||
pub counters: crate::overlay::Counters,
|
||||
/// The route and firewall allowance installed for LAN broadcast, when
|
||||
/// some network has broadcast on and the interface is on the host.
|
||||
/// `None` is "none installed", not "unknown".
|
||||
pub broadcast_rules: Option<crate::overlay::BroadcastRulesReport>,
|
||||
}
|
||||
|
||||
/// A member the signed state knows about, connected or not.
|
||||
|
||||
@@ -12,9 +12,13 @@ use std::time::Duration;
|
||||
|
||||
use crate::dataplane::SharedPlugin;
|
||||
use crate::discovery::{MainlineDiscovery, NetworkDiscovery};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::error::Result;
|
||||
|
||||
/// Qualifier/organisation/application triple used for platform directories.
|
||||
///
|
||||
/// Unused on macOS, where the default paths are fixed system locations rather
|
||||
/// than per-user directories derived from this.
|
||||
#[cfg_attr(target_os = "macos", allow(dead_code))]
|
||||
const APP_NAME: &str = "tsunagi";
|
||||
|
||||
/// Rendezvous slots per network; this bounds a discovery sample, not membership.
|
||||
@@ -74,17 +78,34 @@ impl StoragePaths {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-user platform directories.
|
||||
/// The default platform directories.
|
||||
///
|
||||
/// A future system service can supply its own paths instead.
|
||||
/// Per user on Linux and Windows. On macOS the agent runs as a root
|
||||
/// LaunchDaemon (creating a utun interface needs root, and `$HOME` under
|
||||
/// `sudo` points at the wrong place), so the stores live in the system
|
||||
/// locations instead — `/var/db/tsunagi` for the mandatory state and a
|
||||
/// disposable cache beside it. A caller that wants somewhere else passes
|
||||
/// explicit paths to [`StoragePaths::new`] or [`StoragePaths::under`].
|
||||
pub fn user_default() -> Result<Self> {
|
||||
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
|
||||
Error::Storage("no valid home directory for platform config paths".into())
|
||||
})?;
|
||||
Ok(Self {
|
||||
state_dir: dirs.data_dir().to_path_buf(),
|
||||
cache_dir: dirs.cache_dir().to_path_buf(),
|
||||
})
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Ok(Self {
|
||||
state_dir: PathBuf::from("/var/db/tsunagi"),
|
||||
cache_dir: PathBuf::from("/var/db/tsunagi/cache"),
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
|
||||
crate::error::Error::Storage(
|
||||
"no valid home directory for platform config paths".into(),
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
state_dir: dirs.data_dir().to_path_buf(),
|
||||
cache_dir: dirs.cache_dir().to_path_buf(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Path of the mandatory state database.
|
||||
@@ -304,6 +325,9 @@ 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,
|
||||
@@ -345,6 +369,7 @@ impl AgentConfig {
|
||||
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,
|
||||
@@ -430,6 +455,16 @@ 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,
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
pub mod relay;
|
||||
pub mod routing;
|
||||
pub mod transport;
|
||||
pub mod tunnel;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -31,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;
|
||||
@@ -115,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.
|
||||
@@ -127,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.
|
||||
@@ -179,6 +194,29 @@ impl PluginContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// A context configured with a custom packet sink.
|
||||
pub fn with_sink(sink: Arc<dyn PacketSink>) -> Self {
|
||||
Self {
|
||||
sender: None,
|
||||
local: None,
|
||||
sink: Some(sink),
|
||||
}
|
||||
}
|
||||
|
||||
/// A context configured with a channel for plugin requests and custom sink, for tests.
|
||||
#[cfg(feature = "testing")]
|
||||
pub fn for_test(
|
||||
sender: mpsc::Sender<PluginRequest>,
|
||||
local: EndpointId,
|
||||
sink: Option<Arc<dyn PacketSink>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
sender: Some(sender),
|
||||
local: Some(local),
|
||||
sink,
|
||||
}
|
||||
}
|
||||
|
||||
/// This agent's own endpoint id, when the context is attached.
|
||||
///
|
||||
/// A plugin needs it to find itself in the agreed allocation.
|
||||
@@ -220,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 {
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
mod fragments;
|
||||
pub mod iroh_link;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
|
||||
@@ -155,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,364 @@
|
||||
//! 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,
|
||||
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,
|
||||
/// 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),
|
||||
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);
|
||||
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),
|
||||
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);
|
||||
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;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,11 @@ mod windows;
|
||||
#[cfg(all(feature = "dns-publish", target_os = "windows"))]
|
||||
pub use windows::NrptPublisher;
|
||||
|
||||
#[cfg(all(feature = "dns-publish", target_os = "macos"))]
|
||||
mod macos;
|
||||
#[cfg(all(feature = "dns-publish", target_os = "macos"))]
|
||||
pub use macos::ResolverDirPublisher;
|
||||
|
||||
mod unsupported;
|
||||
pub use unsupported::UnsupportedPublisher;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -82,6 +82,40 @@ pub fn control_socket_path(state_dir: &Path) -> PathBuf {
|
||||
state_dir.join("agent.sock")
|
||||
}
|
||||
|
||||
/// Where the default system control socket lives on this platform.
|
||||
///
|
||||
/// On Windows, the system-wide `%ProgramData%\tsunagi\agent.sock` is used so that
|
||||
/// any local user, tray application, or CLI tool can manage the running agent
|
||||
/// without needing to know which user account or service started it.
|
||||
/// On macOS the agent runs as a root LaunchDaemon, so a fixed system path under
|
||||
/// `/var/run` is used for the same reason, and it stays short enough for the
|
||||
/// Unix socket path limit. On other Unix it derives from the default per-user
|
||||
/// state directory.
|
||||
pub fn default_control_socket_path() -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(program_data) = std::env::var_os("ProgramData") {
|
||||
let pd = PathBuf::from(program_data);
|
||||
if pd.is_absolute() {
|
||||
return pd.join("tsunagi").join("agent.sock");
|
||||
}
|
||||
}
|
||||
PathBuf::from(r"C:\ProgramData\tsunagi\agent.sock")
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
PathBuf::from("/var/run/tsunagi/agent.sock")
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
if let Ok(paths) = crate::config::StoragePaths::user_default() {
|
||||
control_socket_path(&paths.state_dir)
|
||||
} else {
|
||||
PathBuf::from("/run/tsunagi/agent.sock")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a client asks for.
|
||||
///
|
||||
/// `Debug` is written by hand rather than derived: one of these carries a
|
||||
@@ -386,6 +420,21 @@ pub struct OverlayReport {
|
||||
pub multicast_packets: u64,
|
||||
/// One destination nobody owned, if there was one.
|
||||
pub unroutable_sample: Option<String>,
|
||||
/// The broadcast route and firewall allowance on this host, when the
|
||||
/// agent installed them. `None` means none are installed.
|
||||
pub broadcast_rules: Option<HostRulesReport>,
|
||||
}
|
||||
|
||||
/// What the agent installed on the host so LAN broadcast reaches the overlay.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostRulesReport {
|
||||
/// Whether both the route and the firewall allowance are in place.
|
||||
pub ok: bool,
|
||||
/// One line: `ok (route + firewall)`, or what is missing and why.
|
||||
pub detail: String,
|
||||
/// The overlay address the `255.255.255.255` route is bound to. With
|
||||
/// several networks on one interface only one address can be its source.
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// One overlay peer.
|
||||
@@ -394,7 +443,9 @@ pub struct OverlayPeerReport {
|
||||
/// The peer's control plane identity, so a tunnel can be matched to the
|
||||
/// session and the member it belongs to.
|
||||
pub endpoint_id: String,
|
||||
/// The peer's WireGuard public key.
|
||||
/// Protocol carrying overlay traffic for this peer (e.g. "tcp-tls", "wg-quic").
|
||||
pub protocol: String,
|
||||
/// The peer's WireGuard public key (if applicable).
|
||||
pub public_key: String,
|
||||
/// The overlay address the network agreed it holds.
|
||||
pub address: Option<String>,
|
||||
@@ -539,7 +590,7 @@ pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
///
|
||||
/// Bump it whenever [`Request`], [`Response`] or anything they contain
|
||||
/// changes shape.
|
||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 14]);
|
||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 15]);
|
||||
|
||||
/// Reads one request off an accepted stream, answers it, writes the response.
|
||||
///
|
||||
|
||||
@@ -18,15 +18,16 @@
|
||||
//!
|
||||
//! # Who may connect
|
||||
//!
|
||||
//! The pipe rejects clients from other machines, and takes the process's
|
||||
//! default security, under which the creating user has full access. Narrowing
|
||||
//! that further with an explicit ACL needs a raw security-descriptor call this
|
||||
//! crate forbids, so it is not attempted; on a single-user machine, and against
|
||||
//! other unprivileged users, the default is the practical equivalent of the
|
||||
//! owner-only Unix socket. There is no leftover to clean up: a pipe exists only
|
||||
//! while its server does.
|
||||
//! Commands must run as the same Windows user with the same elevation as the
|
||||
//! agent. A permission denial is reported as such, never as an absent agent.
|
||||
//! The pipe rejects clients from other machines (`reject_remote_clients(true)`).
|
||||
//! An explicit SDDL security descriptor grants Full Control to Administrators
|
||||
//! and LocalSystem, while granting Read and Write (`GRGW`) to all local users
|
||||
//! with a Medium Integrity SACL label (`S:(ML;;NW;;;ME)`). This allows unprivileged
|
||||
//! user-space tools (such as tray applications, status monitors, or CLI commands
|
||||
//! launched by regular users) to connect to the agent even when the agent was
|
||||
//! started by an elevated Administrator or as a system service.
|
||||
//! A permission denial is reported as such, never as an absent agent.
|
||||
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -50,6 +51,91 @@ const ERROR_ACCESS_DENIED: i32 = 5;
|
||||
/// is there; the client only has to wait for a free instance.
|
||||
const ERROR_PIPE_BUSY: i32 = 231;
|
||||
|
||||
/// RAII wrapper for Windows named pipe security attributes with permissive local ACL.
|
||||
///
|
||||
/// SDDL specification:
|
||||
/// - `D:(A;;GRGW;;;WD)`: DACL allows Generic Read and Generic Write to Everyone (`WD`).
|
||||
/// Full control (`GA`) remains reserved to Administrators and LocalSystem.
|
||||
/// - `(A;;GA;;;BA)`: DACL allows Generic All to Builtin Administrators (`BA`).
|
||||
/// - `(A;;GA;;;SY)`: DACL allows Generic All to LocalSystem (`SY`).
|
||||
/// - `S:(ML;;NW;;;ME)`: SACL Mandatory Label with Medium Integrity (`ME`) and No Write Up (`NW`).
|
||||
/// This allows unprivileged user-space processes (Medium Integrity) to write to the
|
||||
/// named pipe even when the agent runs as an elevated Administrator (High Integrity).
|
||||
struct PipeSecurityAttributes {
|
||||
attrs: windows_sys::Win32::Security::SECURITY_ATTRIBUTES,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PipeSecurityAttributes {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PipeSecurityAttributes")
|
||||
.field("nLength", &self.attrs.nLength)
|
||||
.field("lpSecurityDescriptor", &self.attrs.lpSecurityDescriptor)
|
||||
.field("bInheritHandle", &self.attrs.bInheritHandle)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PipeSecurityAttributes {
|
||||
fn new() -> Result<Self> {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
|
||||
const SDDL: &str = "D:(A;;GRGW;;;WD)(A;;GA;;;BA)(A;;GA;;;SY)S:(ML;;NW;;;ME)";
|
||||
let wide: Vec<u16> = OsStr::new(SDDL)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
let mut p_sd = std::ptr::null_mut();
|
||||
// SAFETY: We pass a valid null-terminated wide string, standard revision 1,
|
||||
// and a valid pointer to receive the descriptor.
|
||||
let success = unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
wide.as_ptr(),
|
||||
SDDL_REVISION_1,
|
||||
&mut p_sd,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
if success == 0 || p_sd.is_null() {
|
||||
let err = std::io::Error::last_os_error();
|
||||
return Err(Error::Storage(format!(
|
||||
"cannot create pipe security descriptor: {err}"
|
||||
)));
|
||||
}
|
||||
|
||||
let attrs = windows_sys::Win32::Security::SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<windows_sys::Win32::Security::SECURITY_ATTRIBUTES>()
|
||||
as u32,
|
||||
lpSecurityDescriptor: p_sd,
|
||||
bInheritHandle: 0,
|
||||
};
|
||||
|
||||
Ok(Self { attrs })
|
||||
}
|
||||
|
||||
fn as_raw_mut(&mut self) -> *mut std::ffi::c_void {
|
||||
&mut self.attrs as *mut _ as *mut std::ffi::c_void
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PipeSecurityAttributes {
|
||||
fn drop(&mut self) {
|
||||
if !self.attrs.lpSecurityDescriptor.is_null() {
|
||||
// SAFETY: The descriptor was allocated by ConvertStringSecurityDescriptorToSecurityDescriptorW
|
||||
// and must be freed with LocalFree.
|
||||
unsafe {
|
||||
windows_sys::Win32::Foundation::LocalFree(self.attrs.lpSecurityDescriptor);
|
||||
}
|
||||
self.attrs.lpSecurityDescriptor = std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves the local control interface on a named pipe.
|
||||
#[derive(Debug)]
|
||||
pub struct ControlSocket {
|
||||
@@ -67,11 +153,14 @@ impl ControlSocket {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let name = pipe_name(&path);
|
||||
|
||||
let server = match ServerOptions::new()
|
||||
.first_pipe_instance(true)
|
||||
.reject_remote_clients(true)
|
||||
.create(&name)
|
||||
{
|
||||
let mut sec_attrs = PipeSecurityAttributes::new()?;
|
||||
// SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES.
|
||||
let server = match unsafe {
|
||||
ServerOptions::new()
|
||||
.first_pipe_instance(true)
|
||||
.reject_remote_clients(true)
|
||||
.create_with_security_attributes_raw(&name, sec_attrs.as_raw_mut())
|
||||
} {
|
||||
Ok(server) => server,
|
||||
Err(err) if err.raw_os_error() == Some(ERROR_ACCESS_DENIED) => {
|
||||
return Err(Error::StateLocked { path });
|
||||
@@ -155,10 +244,19 @@ async fn serve(
|
||||
/// Creates the next pipe instance, or `None` if the name can no longer be
|
||||
/// served.
|
||||
fn next_instance(name: &str) -> Option<tokio::net::windows::named_pipe::NamedPipeServer> {
|
||||
match ServerOptions::new()
|
||||
.reject_remote_clients(true)
|
||||
.create(name)
|
||||
{
|
||||
let mut sec_attrs = match PipeSecurityAttributes::new() {
|
||||
Ok(attrs) => attrs,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "cannot build pipe security attributes for next instance");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES.
|
||||
match unsafe {
|
||||
ServerOptions::new()
|
||||
.reject_remote_clients(true)
|
||||
.create_with_security_attributes_raw(name, sec_attrs.as_raw_mut())
|
||||
} {
|
||||
Ok(server) => Some(server),
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "cannot create the next control pipe instance");
|
||||
|
||||
@@ -240,6 +240,57 @@ 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());
|
||||
@@ -259,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,566 @@
|
||||
//! 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)]
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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`.
|
||||
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 {
|
||||
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 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,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,330 @@
|
||||
//! 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;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
mod linux;
|
||||
#[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 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 {
|
||||
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::default()
|
||||
}
|
||||
|
||||
/// 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 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 tsunagi 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ use tokio::task::JoinHandle;
|
||||
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
use super::hostrules::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport};
|
||||
use super::packet::IpHeader;
|
||||
use super::router::{Route, RoutingTable};
|
||||
use super::tun::{TunDevice, TunFactory, TunRequest};
|
||||
@@ -152,6 +153,11 @@ pub struct Interface {
|
||||
/// re-applied on every pass.
|
||||
applied: std::sync::Mutex<Vec<Cidr>>,
|
||||
task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||
sync_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
/// The host rules for this interface, when the factory manages the host.
|
||||
host_rules: Option<Arc<dyn BroadcastHostRules>>,
|
||||
/// What those rules last reported, `None` while none are installed.
|
||||
rules_report: std::sync::Mutex<Option<BroadcastRulesReport>>,
|
||||
}
|
||||
|
||||
impl Interface {
|
||||
@@ -164,7 +170,15 @@ impl Interface {
|
||||
carrier: Arc<dyn PacketCarrier>,
|
||||
) -> Result<Self, OverlayError> {
|
||||
let name = name.into();
|
||||
let host_rules = factory.host_rules();
|
||||
let device = factory.create(TunRequest::bare(name.clone(), mtu)).await?;
|
||||
// The name the operating system actually gave the interface. On Linux
|
||||
// and Windows that is the one we asked for; on macOS the kernel assigns
|
||||
// the utun unit, so the real name differs and everything that
|
||||
// configures the host for this interface afterwards — the broadcast
|
||||
// route and firewall, the reconfigure on an address change — has to use
|
||||
// the real one, not the requested `tsun…`.
|
||||
let name = device.name().to_string();
|
||||
let tally = Arc::new(Tally::default());
|
||||
|
||||
let task = {
|
||||
@@ -228,15 +242,89 @@ impl Interface {
|
||||
tally,
|
||||
applied: std::sync::Mutex::new(Vec::new()),
|
||||
task: std::sync::Mutex::new(Some(task)),
|
||||
sync_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
host_rules,
|
||||
rules_report: std::sync::Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Brings the broadcast route and firewall allowance in line with the
|
||||
/// routing table: installed while some network has broadcast on and an
|
||||
/// address, removed when none does.
|
||||
///
|
||||
/// Called after the addresses are synced, because the route's preferred
|
||||
/// source has to be on the interface already. A plan that has not changed
|
||||
/// is not re-applied, so this is cheap to call whenever the table moves.
|
||||
/// It never fails: the outcome is kept for [`Self::broadcast_rules`].
|
||||
pub async fn sync_broadcast_rules(&self) {
|
||||
let Some(rules) = &self.host_rules else {
|
||||
return;
|
||||
};
|
||||
let _guard = self.sync_lock.lock().await;
|
||||
let desired =
|
||||
self.routes
|
||||
.broadcast_host_target()
|
||||
.map(|(source, range)| BroadcastRulesPlan {
|
||||
interface: self.name.clone(),
|
||||
source,
|
||||
range,
|
||||
});
|
||||
let current = self.current_rules_report();
|
||||
match (desired, current) {
|
||||
(None, None) => {}
|
||||
(None, Some(_)) => {
|
||||
rules.clear(&self.name).await;
|
||||
self.store_rules_report(None);
|
||||
tracing::info!(interface = %self.name, "broadcast host rules removed");
|
||||
}
|
||||
(Some(plan), Some(report)) if report.plan == plan => {}
|
||||
(Some(plan), _) => {
|
||||
let report = rules.apply(&plan).await;
|
||||
if report.is_ok() {
|
||||
tracing::info!(
|
||||
interface = %self.name,
|
||||
source = %plan.source,
|
||||
"broadcast host rules applied"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
interface = %self.name,
|
||||
"broadcast host rules are incomplete: {}",
|
||||
report.summary()
|
||||
);
|
||||
}
|
||||
self.store_rules_report(Some(report));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the broadcast host rules achieved, `None` when none are installed
|
||||
/// or this interface has no host to configure.
|
||||
pub fn broadcast_rules(&self) -> Option<BroadcastRulesReport> {
|
||||
self.current_rules_report()
|
||||
}
|
||||
|
||||
fn current_rules_report(&self) -> Option<BroadcastRulesReport> {
|
||||
match self.rules_report.lock() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn store_rules_report(&self, report: Option<BroadcastRulesReport>) {
|
||||
match self.rules_report.lock() {
|
||||
Ok(mut guard) => *guard = report,
|
||||
Err(poisoned) => *poisoned.into_inner() = report,
|
||||
}
|
||||
}
|
||||
|
||||
/// Brings the addresses on the host in line with the routing table.
|
||||
///
|
||||
/// Called whenever a network agrees a different address for this agent.
|
||||
/// The interface is never recreated for it: that would drop every tunnel
|
||||
/// riding on it for the sake of one address.
|
||||
pub async fn sync_addresses(&self) -> Result<(), OverlayError> {
|
||||
let _guard = self.sync_lock.lock().await;
|
||||
let wanted = self.wanted_addresses();
|
||||
{
|
||||
let applied = match self.applied.lock() {
|
||||
@@ -286,17 +374,24 @@ impl Interface {
|
||||
|
||||
/// Removes the interface from the host.
|
||||
pub async fn remove(&self) {
|
||||
// The route goes with the interface; the firewall rule does not, so
|
||||
// it is taken out explicitly while the interface is still known.
|
||||
if let Some(rules) = &self.host_rules {
|
||||
rules.clear(&self.name).await;
|
||||
self.store_rules_report(None);
|
||||
}
|
||||
// The packet loop holds the device open, and it ends only when the
|
||||
// device reports end of stream — which a real interface never does
|
||||
// while it exists. So it is stopped here, not left to notice.
|
||||
// while it exists. Stop it directly before destroying the interface.
|
||||
let task = match self.task.lock() {
|
||||
Ok(mut guard) => guard.take(),
|
||||
Err(poisoned) => poisoned.into_inner().take(),
|
||||
};
|
||||
self.factory.destroy(self.device.name()).await;
|
||||
if let Some(task) = task {
|
||||
crate::task::wind_down(task, crate::task::TASK_GRACE, "overlay packet loop").await;
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
self.factory.destroy(self.device.name()).await;
|
||||
}
|
||||
|
||||
/// The counters as they stand.
|
||||
@@ -427,6 +522,9 @@ mod tests {
|
||||
interface.remove().await;
|
||||
}
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
use crate::overlay::broadcast::BroadcastPolicy;
|
||||
use crate::overlay::hostrules::{MockHostRules, RuleOutcome};
|
||||
use crate::overlay::provision::{ManagedTunFactory, MockProvisioner};
|
||||
use crate::overlay::router::NetworkRoutes;
|
||||
use crate::overlay::tun::{MemoryTun, MemoryTunFactory};
|
||||
|
||||
@@ -747,4 +845,137 @@ mod tests {
|
||||
vec![Cidr::new(addr(9).into(), 24).unwrap()]
|
||||
);
|
||||
}
|
||||
|
||||
async fn managed_interface(
|
||||
rules: &MockHostRules,
|
||||
broadcast_enabled: bool,
|
||||
) -> (Interface, Arc<RoutingTable>, NetworkId) {
|
||||
let id = network("rules");
|
||||
let routes = Arc::new(RoutingTable::new());
|
||||
routes
|
||||
.set_network(
|
||||
id,
|
||||
NetworkRoutes {
|
||||
broadcast: BroadcastPolicy {
|
||||
enabled: broadcast_enabled,
|
||||
..Default::default()
|
||||
},
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
local: Some(addr(1)),
|
||||
peers: vec![(addr(2), peer(2))],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let factory = Arc::new(
|
||||
ManagedTunFactory::new(Arc::new(MockProvisioner::default()))
|
||||
.with_host_rules(Arc::new(rules.clone())),
|
||||
);
|
||||
let interface = Interface::start(
|
||||
factory as Arc<dyn TunFactory>,
|
||||
"tsunrules",
|
||||
1280,
|
||||
Arc::clone(&routes),
|
||||
Arc::new(Recorder::default()) as Arc<dyn PacketCarrier>,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(interface, routes, id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_rules_follow_broadcast_policy_address_and_interface_lifetime() {
|
||||
let rules = MockHostRules::new();
|
||||
let (interface, routes, id) = managed_interface(&rules, true).await;
|
||||
assert!(
|
||||
interface.broadcast_rules().is_none(),
|
||||
"nothing before a sync"
|
||||
);
|
||||
|
||||
interface.sync_broadcast_rules().await;
|
||||
let installed = rules.installed();
|
||||
assert_eq!(installed["tsunrules"].source, addr(1));
|
||||
assert_eq!(
|
||||
installed["tsunrules"].range,
|
||||
"10.13.37.0/24".parse().unwrap()
|
||||
);
|
||||
assert!(interface.broadcast_rules().unwrap().is_ok());
|
||||
|
||||
// Unchanged: not applied a second time.
|
||||
interface.sync_broadcast_rules().await;
|
||||
interface.sync_broadcast_rules().await;
|
||||
assert_eq!(rules.calls(), ["apply:tsunrules"]);
|
||||
|
||||
// A new address replaces the plan, it does not stack a second one.
|
||||
routes
|
||||
.set_network(
|
||||
id,
|
||||
NetworkRoutes {
|
||||
broadcast: BroadcastPolicy::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
local: Some(addr(9)),
|
||||
peers: vec![(addr(2), peer(2))],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
interface.sync_broadcast_rules().await;
|
||||
assert_eq!(rules.installed()["tsunrules"].source, addr(9));
|
||||
assert_eq!(rules.installed().len(), 1);
|
||||
|
||||
// Turning broadcast off takes the rules away.
|
||||
routes.set_broadcast(
|
||||
id,
|
||||
BroadcastPolicy {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
interface.sync_broadcast_rules().await;
|
||||
assert!(rules.installed().is_empty());
|
||||
assert!(interface.broadcast_rules().is_none());
|
||||
|
||||
// And on again puts them back; leaving the agent removes them once more.
|
||||
routes.set_broadcast(id, BroadcastPolicy::default());
|
||||
interface.sync_broadcast_rules().await;
|
||||
assert_eq!(rules.installed().len(), 1);
|
||||
interface.remove().await;
|
||||
assert!(rules.installed().is_empty(), "shutdown clears the host");
|
||||
assert!(interface.broadcast_rules().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_rules_are_not_installed_while_broadcast_is_off() {
|
||||
let rules = MockHostRules::new();
|
||||
let (interface, _routes, _id) = managed_interface(&rules, false).await;
|
||||
interface.sync_broadcast_rules().await;
|
||||
assert!(rules.installed().is_empty());
|
||||
assert!(rules.calls().is_empty(), "the host was not even asked");
|
||||
interface.remove().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_firewall_step_is_reported_and_does_not_undo_the_route() {
|
||||
let rules = MockHostRules::new();
|
||||
rules.fail_firewall("iptables not found");
|
||||
let (interface, _routes, _id) = managed_interface(&rules, true).await;
|
||||
interface.sync_broadcast_rules().await;
|
||||
let report = interface.broadcast_rules().unwrap();
|
||||
assert!(report.route.is_applied());
|
||||
assert_eq!(
|
||||
report.firewall,
|
||||
RuleOutcome::Failed("iptables not found".into())
|
||||
);
|
||||
assert!(!report.is_ok());
|
||||
// Not retried on every sync: the plan is the same.
|
||||
interface.sync_broadcast_rules().await;
|
||||
assert_eq!(rules.calls(), ["apply:tsunrules"]);
|
||||
interface.remove().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_interface_without_a_host_has_no_host_rules() {
|
||||
let (interface, _device, _routes, _carrier, _id) = interface(false).await;
|
||||
interface.sync_broadcast_rules().await;
|
||||
assert!(interface.broadcast_rules().is_none());
|
||||
interface.remove().await;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
//!
|
||||
//! * [`provision`] creates the interface and configures it, and removes it
|
||||
//! again. Platform mechanics behind one decision function.
|
||||
//! * [`hostrules`] installs the route and firewall allowance that let a
|
||||
//! game's LAN discovery reach the interface, while broadcast is on.
|
||||
//! * [`tun`] is the packet interface itself, real or in memory.
|
||||
//! * [`packet`] reads just enough of an IP header to route by it.
|
||||
//! * [`config`] names interfaces and describes addresses.
|
||||
@@ -38,6 +40,7 @@ pub enum OverlayError {
|
||||
|
||||
pub mod broadcast;
|
||||
pub mod config;
|
||||
pub mod hostrules;
|
||||
pub mod interface;
|
||||
pub mod packet;
|
||||
pub mod provision;
|
||||
@@ -45,6 +48,10 @@ pub mod router;
|
||||
pub mod tun;
|
||||
|
||||
pub use config::{Cidr, DEFAULT_INTERFACE_PREFIX, MAX_INTERFACE_NAME_LEN, interface_name};
|
||||
pub use hostrules::{
|
||||
BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, MockHostRules, RuleOutcome,
|
||||
UnsupportedHostRules,
|
||||
};
|
||||
pub use interface::{Counters, Interface, PacketCarrier, Rejected};
|
||||
pub use packet::IpHeader;
|
||||
pub use provision::{
|
||||
@@ -55,8 +62,17 @@ pub use provision::{
|
||||
pub use router::{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;
|
||||
@@ -11,6 +11,7 @@ use std::sync::Arc;
|
||||
use crate::BoxFuture;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::super::hostrules::BroadcastHostRules;
|
||||
use super::super::tun::{TunDevice, TunFactory, TunRequest};
|
||||
use super::{InterfacePlan, InterfaceProvisioner};
|
||||
|
||||
@@ -30,12 +31,23 @@ fn plan_for(request: &TunRequest) -> Result<InterfacePlan, OverlayError> {
|
||||
#[derive(Debug)]
|
||||
pub struct ManagedTunFactory {
|
||||
provisioner: Arc<dyn InterfaceProvisioner>,
|
||||
host_rules: Option<Arc<dyn BroadcastHostRules>>,
|
||||
}
|
||||
|
||||
impl ManagedTunFactory {
|
||||
/// Wraps a provisioner.
|
||||
pub fn new(provisioner: Arc<dyn InterfaceProvisioner>) -> Self {
|
||||
Self { provisioner }
|
||||
Self {
|
||||
provisioner,
|
||||
host_rules: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets the agent install the broadcast route and firewall allowance for
|
||||
/// the interface this factory manages.
|
||||
pub fn with_host_rules(mut self, rules: Arc<dyn BroadcastHostRules>) -> Self {
|
||||
self.host_rules = Some(rules);
|
||||
self
|
||||
}
|
||||
|
||||
/// The provisioner underneath.
|
||||
@@ -54,6 +66,10 @@ impl TunFactory for ManagedTunFactory {
|
||||
true
|
||||
}
|
||||
|
||||
fn host_rules(&self) -> Option<Arc<dyn BroadcastHostRules>> {
|
||||
self.host_rules.clone()
|
||||
}
|
||||
|
||||
fn create<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
//! capability is raised immediately before that call and lowered immediately
|
||||
//! after, and the connection task lives and dies inside it.
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
|
||||
use futures_util::TryStreamExt;
|
||||
@@ -47,7 +47,8 @@ use futures_util::TryStreamExt;
|
||||
// of step with the client that sends them.
|
||||
use rtnetlink::packet_route::address::{AddressAttribute, AddressMessage};
|
||||
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkFlags, LinkInfo, LinkMessage};
|
||||
use rtnetlink::{LinkMessageBuilder, LinkUnspec};
|
||||
use rtnetlink::packet_route::route::RouteScope;
|
||||
use rtnetlink::{LinkMessageBuilder, LinkUnspec, RouteMessageBuilder};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::overlay::OverlayError;
|
||||
@@ -67,6 +68,10 @@ enum Command {
|
||||
Delete(String, Reply<()>),
|
||||
/// Apply MTU, link state and addresses.
|
||||
Configure(Box<Configure>, Reply<()>),
|
||||
/// Route `255.255.255.255/32` through this interface from this address.
|
||||
SetBroadcastRoute(String, Ipv4Addr, Reply<()>),
|
||||
/// Remove that route from this interface.
|
||||
DelBroadcastRoute(String, Reply<()>),
|
||||
/// Stop the thread.
|
||||
Stop,
|
||||
}
|
||||
@@ -133,13 +138,18 @@ impl NetlinkProvisioner {
|
||||
&self,
|
||||
make: impl FnOnce(Reply<T>) -> Command,
|
||||
) -> Result<T, OverlayError> {
|
||||
let (reply_tx, reply_rx) = mpsc::channel();
|
||||
self.commands
|
||||
.send(make(reply_tx))
|
||||
.map_err(|_| OverlayError::Unavailable("the netlink thread has stopped".to_string()))?;
|
||||
reply_rx.recv().map_err(|_| {
|
||||
OverlayError::Unavailable("the netlink thread stopped mid-request".to_string())
|
||||
})?
|
||||
call_thread(&self.commands, make)
|
||||
}
|
||||
|
||||
/// The broadcast host rules for this host.
|
||||
///
|
||||
/// The route is added by this provisioner's netlink thread, because it is
|
||||
/// the only one that ever holds `CAP_NET_ADMIN`, and only for the duration
|
||||
/// of a call. Keep the provisioner alive for as long as the rules are used.
|
||||
pub fn host_rules(&self) -> crate::overlay::hostrules::LinuxHostRules {
|
||||
crate::overlay::hostrules::LinuxHostRules::new(RouteHandle {
|
||||
commands: self.commands.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_ours(&self, name: &str) -> bool {
|
||||
@@ -157,6 +167,44 @@ impl NetlinkProvisioner {
|
||||
}
|
||||
}
|
||||
|
||||
fn call_thread<T: Send + 'static>(
|
||||
commands: &mpsc::Sender<Command>,
|
||||
make: impl FnOnce(Reply<T>) -> Command,
|
||||
) -> Result<T, OverlayError> {
|
||||
let (reply_tx, reply_rx) = mpsc::channel();
|
||||
commands
|
||||
.send(make(reply_tx))
|
||||
.map_err(|_| OverlayError::Unavailable("the netlink thread has stopped".to_string()))?;
|
||||
reply_rx.recv().map_err(|_| {
|
||||
OverlayError::Unavailable("the netlink thread stopped mid-request".to_string())
|
||||
})?
|
||||
}
|
||||
|
||||
/// Adds and removes the limited-broadcast route on the netlink thread.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RouteHandle {
|
||||
commands: mpsc::Sender<Command>,
|
||||
}
|
||||
|
||||
impl RouteHandle {
|
||||
/// Routes `255.255.255.255/32` through `interface`, preferring `source`.
|
||||
/// Replaces a route this agent added earlier, so it can be repeated.
|
||||
pub(crate) fn set(&self, interface: &str, source: Ipv4Addr) -> Result<(), OverlayError> {
|
||||
let interface = interface.to_string();
|
||||
call_thread(&self.commands, |reply| {
|
||||
Command::SetBroadcastRoute(interface, source, reply)
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes the route. Succeeds when it, or the interface, is gone.
|
||||
pub(crate) fn clear(&self, interface: &str) -> Result<(), OverlayError> {
|
||||
let interface = interface.to_string();
|
||||
call_thread(&self.commands, |reply| {
|
||||
Command::DelBroadcastRoute(interface, reply)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NetlinkProvisioner {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.commands.send(Command::Stop);
|
||||
@@ -273,7 +321,10 @@ fn netlink_thread(requests: mpsc::Receiver<Command>) {
|
||||
Command::Observe(_, reply) => {
|
||||
let _ = reply.send(Err(OverlayError::Unavailable(message)));
|
||||
}
|
||||
Command::Delete(_, reply) | Command::Configure(_, reply) => {
|
||||
Command::Delete(_, reply)
|
||||
| Command::Configure(_, reply)
|
||||
| Command::SetBroadcastRoute(_, _, reply)
|
||||
| Command::DelBroadcastRoute(_, reply) => {
|
||||
let _ = reply.send(Err(OverlayError::Unavailable(message)));
|
||||
}
|
||||
Command::Stop => return,
|
||||
@@ -306,6 +357,22 @@ fn netlink_thread(requests: mpsc::Receiver<Command>) {
|
||||
});
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
Command::SetBroadcastRoute(name, source, reply) => {
|
||||
let result = NetAdmin::acquire().and_then(|guard| {
|
||||
let result = runtime.block_on(set_broadcast_route(&name, source));
|
||||
drop(guard);
|
||||
result
|
||||
});
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
Command::DelBroadcastRoute(name, reply) => {
|
||||
let result = NetAdmin::acquire().and_then(|guard| {
|
||||
let result = runtime.block_on(del_broadcast_route(&name));
|
||||
drop(guard);
|
||||
result
|
||||
});
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -455,6 +522,80 @@ async fn delete_link(name: &str) -> Result<(), OverlayError> {
|
||||
.await
|
||||
}
|
||||
|
||||
/// The message that names the limited-broadcast route on an interface.
|
||||
///
|
||||
/// On-link scope is what the kernel requires of a route with no gateway, and
|
||||
/// what `ip route add 255.255.255.255/32 dev <if>` produces. Deleting needs the
|
||||
/// same scope or the kernel finds no match.
|
||||
fn broadcast_route_message(
|
||||
index: u32,
|
||||
source: Option<Ipv4Addr>,
|
||||
) -> rtnetlink::packet_route::route::RouteMessage {
|
||||
let mut builder = RouteMessageBuilder::<Ipv4Addr>::new()
|
||||
.destination_prefix(Ipv4Addr::BROADCAST, 32)
|
||||
.output_interface(index)
|
||||
.scope(RouteScope::Link);
|
||||
if let Some(source) = source {
|
||||
builder = builder.pref_source(source);
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
async fn link_index(handle: &rtnetlink::Handle, name: &str) -> Option<u32> {
|
||||
let mut links = handle.link().get().match_name(name.to_string()).execute();
|
||||
links
|
||||
.try_next()
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|message| message.header.index)
|
||||
}
|
||||
|
||||
async fn set_broadcast_route(name: &str, source: Ipv4Addr) -> Result<(), OverlayError> {
|
||||
with_netlink(|handle| async move {
|
||||
let index = link_index(&handle, name).await.ok_or_else(|| {
|
||||
OverlayError::Unavailable(format!(
|
||||
"interface `{name}` disappeared before its broadcast route could be added"
|
||||
))
|
||||
})?;
|
||||
handle
|
||||
.route()
|
||||
.add(broadcast_route_message(index, Some(source)))
|
||||
.replace()
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
OverlayError::Unavailable(format!(
|
||||
"cannot route 255.255.255.255 through `{name}` from {source}: {err}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn del_broadcast_route(name: &str) -> Result<(), OverlayError> {
|
||||
with_netlink(|handle| async move {
|
||||
// An interface that is gone took its routes with it.
|
||||
let Some(index) = link_index(&handle, name).await else {
|
||||
return Ok(());
|
||||
};
|
||||
match handle
|
||||
.route()
|
||||
.del(broadcast_route_message(index, None))
|
||||
.execute()
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
// "No such process": there was no such route, which is the goal.
|
||||
Err(err) if err.to_string().contains("No such process") => Ok(()),
|
||||
Err(err) => Err(OverlayError::Unavailable(format!(
|
||||
"cannot remove the 255.255.255.255 route from `{name}`: {err}"
|
||||
))),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn configure_link(configure: &Configure) -> Result<(), OverlayError> {
|
||||
let name = configure.name.as_str();
|
||||
with_netlink(|handle| async move {
|
||||
@@ -520,16 +661,19 @@ async fn configure_link(configure: &Configure) -> Result<(), OverlayError> {
|
||||
}
|
||||
|
||||
for cidr in &configure.add {
|
||||
handle
|
||||
let res = handle
|
||||
.address()
|
||||
.add(index, cidr.addr, cidr.prefix_len)
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
OverlayError::Unavailable(format!(
|
||||
.await;
|
||||
if let Err(err) = res {
|
||||
let err_str = err.to_string();
|
||||
if !err_str.contains("File exists") && !err_str.contains("os error 17") {
|
||||
return Err(OverlayError::Unavailable(format!(
|
||||
"cannot add {cidr} to interface `{name}`: {err}"
|
||||
))
|
||||
})?;
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -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
|
||||
))));
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,13 @@
|
||||
//! pure and tested on every platform, and only the execution is behind
|
||||
//! [`InterfaceProvisioner`].
|
||||
//!
|
||||
//! Four implementations:
|
||||
//! Five implementations:
|
||||
//!
|
||||
//! * `NetlinkProvisioner` on Linux, which needs `CAP_NET_ADMIN`.
|
||||
//! * `WintunProvisioner` on Windows, a Wintun adapter configured with `netsh`,
|
||||
//! which needs an elevated process.
|
||||
//! * `UtunProvisioner` on macOS, a utun interface configured with `ifconfig`
|
||||
//! and `route`, which needs root.
|
||||
//! * [`MockProvisioner`], an in-memory host used by the tests.
|
||||
//! * [`UnsupportedProvisioner`] elsewhere, which fails with an explanation
|
||||
//! and a pointer at the manual route rather than pretending to work.
|
||||
@@ -52,12 +54,19 @@ pub use mock::{MockHost, MockProvisioner};
|
||||
mod linux;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use linux::NetlinkProvisioner;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub(crate) use linux::RouteHandle;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
mod windows;
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use windows::WintunProvisioner;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
mod macos;
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use macos::UtunProvisioner;
|
||||
|
||||
mod privilege;
|
||||
pub use privilege::{Privilege, probe_net_admin};
|
||||
|
||||
|
||||
@@ -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/tsunagi
|
||||
//! ```
|
||||
//!
|
||||
//! `+p` rather than `+ep`: with `+p` the capability is permitted but not
|
||||
@@ -49,7 +49,7 @@ impl Privilege {
|
||||
/// How to obtain it, for a diagnostic.
|
||||
pub fn how_to_grant(program: &str) -> String {
|
||||
format!(
|
||||
"Grant it once with `sudo setcap cap_net_admin+p {program}` \
|
||||
"Grant it once with `sudo setcap cap_net_admin,cap_net_bind_service+p {program}` \
|
||||
and the agent manages its own interface. Without it, run with \
|
||||
`--no-tun`: the tunnels still form, they just do not reach the \
|
||||
operating system."
|
||||
@@ -63,15 +63,20 @@ pub use linux_impl::{NetAdmin, probe_net_admin};
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use windows_impl::probe_net_admin;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use macos_impl::probe_net_admin;
|
||||
|
||||
#[cfg(not(any(
|
||||
all(feature = "tun-device", target_os = "linux"),
|
||||
all(feature = "tun-device", target_os = "windows")
|
||||
all(feature = "tun-device", target_os = "windows"),
|
||||
all(feature = "tun-device", target_os = "macos")
|
||||
)))]
|
||||
pub use other_impl::probe_net_admin;
|
||||
|
||||
#[cfg(not(any(
|
||||
all(feature = "tun-device", target_os = "linux"),
|
||||
all(feature = "tun-device", target_os = "windows")
|
||||
all(feature = "tun-device", target_os = "windows"),
|
||||
all(feature = "tun-device", target_os = "macos")
|
||||
)))]
|
||||
mod other_impl {
|
||||
use super::Privilege;
|
||||
@@ -101,6 +106,25 @@ mod windows_impl {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
mod macos_impl {
|
||||
use super::Privilege;
|
||||
|
||||
/// Whether this process can configure interfaces.
|
||||
///
|
||||
/// macOS has no capability to hold and lower the way Linux does: creating
|
||||
/// a utun interface simply needs the process to be root. Reading the
|
||||
/// effective uid means a libc call this crate forbids, so the probe is
|
||||
/// deliberately optimistic — it reports that the platform can manage
|
||||
/// interfaces — and the real check is left to the utun open, which fails
|
||||
/// with a precise "run with sudo" message when the process is not root.
|
||||
/// This matches how the Linux path treats the open itself as the honest
|
||||
/// answer.
|
||||
pub fn probe_net_admin() -> Privilege {
|
||||
Privilege::Available
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
mod linux_impl {
|
||||
use caps::{CapSet, Capability};
|
||||
@@ -181,7 +205,9 @@ 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"));
|
||||
assert!(
|
||||
text.contains("setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsunagi")
|
||||
);
|
||||
assert!(text.contains("--no-tun"), "the fallback is offered too");
|
||||
}
|
||||
|
||||
|
||||
@@ -262,7 +262,12 @@ async fn configure(plan: &InterfacePlan, changes: &super::Changes) -> Result<(),
|
||||
set_mtu(&plan.name, mtu).await?;
|
||||
}
|
||||
for cidr in &changes.add {
|
||||
address_add(&plan.name, *cidr).await?;
|
||||
if let Err(err) = address_add(&plan.name, *cidr).await {
|
||||
let err_str = err.to_string();
|
||||
if !err_str.contains("already exists") && !err_str.contains("0x80071392") {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
if changes.bring_up {
|
||||
// Best effort: a Wintun adapter with a session is up already, and a
|
||||
|
||||
@@ -230,6 +230,23 @@ impl RoutingTable {
|
||||
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
|
||||
@@ -393,6 +410,65 @@ mod tests {
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -109,6 +109,14 @@ pub trait TunFactory: Send + Sync + std::fmt::Debug + 'static {
|
||||
Box::pin(async move { Ok(()) })
|
||||
}
|
||||
|
||||
/// The host rules for this factory's interface, when it manages the host.
|
||||
///
|
||||
/// `None` for an in-memory device or an interface somebody else prepared:
|
||||
/// there is nothing of ours to route or to open in a firewall.
|
||||
fn host_rules(&self) -> Option<Arc<dyn super::hostrules::BroadcastHostRules>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Removes an interface this factory created.
|
||||
///
|
||||
/// Runs on the teardown path, so it reports rather than fails: there is
|
||||
@@ -221,7 +229,31 @@ impl TunDevice for MemoryTun {
|
||||
/// enough here: the agent chose the address, so anything else holding it is a
|
||||
/// problem in its own right.
|
||||
pub fn address_is_local(address: std::net::IpAddr) -> bool {
|
||||
std::net::UdpSocket::bind((address, 0)).is_ok()
|
||||
if std::net::UdpSocket::bind((address, 0)).is_ok() {
|
||||
return true;
|
||||
}
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
{
|
||||
if let Ok(interfaces) = std::panic::catch_unwind(netdev::get_interfaces) {
|
||||
for iface in interfaces {
|
||||
if iface.is_up() {
|
||||
match address {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
if iface.ipv4.iter().any(|ip| ip.addr() == v4) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
if iface.ipv6.iter().any(|ip| ip.addr() == v6) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Creates [`MemoryTun`] devices.
|
||||
@@ -385,7 +417,15 @@ mod system {
|
||||
/// across this call, and such a guard must not span an `await` because
|
||||
/// Linux capabilities are per thread.
|
||||
pub(crate) fn open_tun(request: &TunRequest) -> Result<Arc<dyn TunDevice>, OverlayError> {
|
||||
// macOS mutates neither field below (it neither names the interface nor
|
||||
// sets a platform config), so `mut` is unused only there.
|
||||
#[cfg_attr(target_os = "macos", allow(unused_mut))]
|
||||
let mut config = tun::Configuration::default();
|
||||
// macOS names its tunnels `utunN` and assigns the unit number itself.
|
||||
// A name derived from the network id (`tsun…`) is not a legal utun
|
||||
// name, so there the kernel is left to choose and the real name is read
|
||||
// back below. Linux and Windows take the requested name verbatim.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
config.tun_name(&request.name);
|
||||
#[cfg(target_os = "linux")]
|
||||
config.platform_config(|platform| {
|
||||
@@ -403,9 +443,20 @@ mod system {
|
||||
|
||||
let device = tun::create_as_async(&config).map_err(|err| open_error(&request.name, err))?;
|
||||
|
||||
// The interface name the operating system actually gave us: the
|
||||
// requested one everywhere except macOS, where the kernel chose the
|
||||
// utun unit and so the real name has to be read back off the device.
|
||||
#[cfg(target_os = "macos")]
|
||||
let name = {
|
||||
use tun::AbstractDevice;
|
||||
device.tun_name().unwrap_or_else(|_| request.name.clone())
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let name = request.name.clone();
|
||||
|
||||
let (reader, writer) = tokio::io::split(device);
|
||||
Ok(Arc::new(SystemTun {
|
||||
name: request.name.clone(),
|
||||
name,
|
||||
mtu: request.mtu,
|
||||
reader: Mutex::new(reader),
|
||||
writer: Mutex::new(writer),
|
||||
@@ -435,7 +486,10 @@ mod system {
|
||||
start `tsunagi up` there."
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"Start `tsunagi up` as root or grant CAP_NET_ADMIN with \
|
||||
`sudo setcap cap_net_admin+p /path/to/tsunagi`."
|
||||
`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 `tsunagi up` \
|
||||
with `sudo`, or install it as a root LaunchDaemon."
|
||||
} else {
|
||||
"Start `tsunagi up` with the privileges required to create a TUN interface."
|
||||
};
|
||||
@@ -455,7 +509,7 @@ mod system {
|
||||
fn open_hint() -> &'static str {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
"Creating one needs CAP_NET_ADMIN; grant it with `setcap cap_net_admin+p`, \
|
||||
"Creating one needs CAP_NET_ADMIN; grant it with `setcap cap_net_admin,cap_net_bind_service+p`, \
|
||||
or run with `--no-tun` to keep the tunnels off the operating system."
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -464,7 +518,12 @@ mod system {
|
||||
run as Administrator with the DLL beside the executable, or run with `--no-tun` \
|
||||
to keep the tunnels off the operating system."
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
"Creating a utun interface needs root; start the agent with `sudo` or as a root \
|
||||
LaunchDaemon, or run with `--no-tun` to keep the tunnels off the operating system."
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
"Run with `--no-tun` to keep the tunnels off the operating system."
|
||||
}
|
||||
|
||||
Vendored
+62
@@ -0,0 +1,62 @@
|
||||
# Running tsunagi on Linux
|
||||
|
||||
This archive holds a **static** `tsunagi` binary (musl), so it runs on any
|
||||
distribution — Arch, Alpine, NixOS, old glibc systems — with no shared-library
|
||||
dependencies. Alongside it are a systemd unit and an installer.
|
||||
|
||||
Creating the TUN interface needs `CAP_NET_ADMIN`, and configuring
|
||||
systemd-resolved is decided by polkit (by user id, not capability). There are
|
||||
three ways to satisfy that; pick one.
|
||||
|
||||
## Quick install — systemd service as root
|
||||
|
||||
```sh
|
||||
sudo ./install.sh
|
||||
sudo systemctl enable --now tsunagi
|
||||
```
|
||||
|
||||
The service runs the agent as root, so the interface, low ports and the
|
||||
resolver all just work (root is not subject to polkit). Control it **as root**,
|
||||
so the client resolves the same state directory the daemon uses:
|
||||
|
||||
```sh
|
||||
sudo tsunagi status
|
||||
sudo tsunagi join <network-name> <tsn1…secret>
|
||||
```
|
||||
|
||||
## As your own user — one capability, no root at runtime
|
||||
|
||||
```sh
|
||||
sudo install -m 0755 tsunagi /usr/local/bin/tsunagi
|
||||
sudo setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsunagi
|
||||
tsunagi up
|
||||
```
|
||||
|
||||
Nothing is left behind and nothing runs as root. The capability is lost on every
|
||||
rebuild or copy of the binary, so re-run `setcap` after replacing it. For the
|
||||
local DNS resolver, run `tsunagi dns` once: it prints the exact polkit rule that
|
||||
lets your user configure systemd-resolved, ready to paste.
|
||||
|
||||
## Without touching the OS
|
||||
|
||||
```sh
|
||||
tsunagi up --no-tun
|
||||
```
|
||||
|
||||
Tunnels form and handshake between agents, but no interface, address or route is
|
||||
created, so traffic never reaches the operating system. Needs no privileges.
|
||||
|
||||
## What the agent installs
|
||||
|
||||
Everything is tagged to its own interface, computed from local state, and
|
||||
reversible — removed when a network leaves, broadcast is turned off, or the
|
||||
agent stops:
|
||||
|
||||
- a TUN interface configured over netlink (addresses, MTU, up);
|
||||
- while broadcast is on, a `255.255.255.255` route through the interface and an
|
||||
inbound-UDP firewall allowance from the overlay range (`iptables`, tagged);
|
||||
- the systemd-resolved setting that sends the overlay zones' questions to the
|
||||
local resolver.
|
||||
|
||||
Nothing taken from a remote peer ever becomes a path, a command argument or an
|
||||
OS setting.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env sh
|
||||
# Installs the tsunagi binary and, where systemd is present, a root service.
|
||||
# Run it as root (it writes under /usr/local and /etc):
|
||||
#
|
||||
# sudo ./install.sh
|
||||
#
|
||||
# PREFIX overrides the install prefix (default /usr/local).
|
||||
set -eu
|
||||
|
||||
PREFIX="${PREFIX:-/usr/local}"
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "This installer writes to ${PREFIX}/bin and /etc; re-run it as root (sudo)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing tsunagi to ${PREFIX}/bin"
|
||||
install -d "${PREFIX}/bin"
|
||||
install -m 0755 "${here}/tsunagi" "${PREFIX}/bin/tsunagi"
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
echo "Installing the systemd service (runs the agent as root)"
|
||||
install -m 0644 "${here}/tsunagi.service" /etc/systemd/system/tsunagi.service
|
||||
systemctl daemon-reload
|
||||
cat <<EOF
|
||||
|
||||
Done. Start the agent with:
|
||||
|
||||
sudo systemctl enable --now tsunagi
|
||||
|
||||
Control it as root, so the client shares the agent's state directory:
|
||||
|
||||
sudo tsunagi status
|
||||
sudo tsunagi join <network-name> <tsn1…secret>
|
||||
EOF
|
||||
else
|
||||
cat <<EOF
|
||||
|
||||
Installed the binary; no systemd found, so no service was set up. Either:
|
||||
|
||||
* run it directly as root:
|
||||
|
||||
sudo tsunagi up
|
||||
|
||||
* or grant the capability and run it as your own user (no root at runtime):
|
||||
|
||||
sudo setcap cap_net_admin,cap_net_bind_service+p ${PREFIX}/bin/tsunagi
|
||||
tsunagi up
|
||||
|
||||
For the local DNS resolver as a non-root user, run \`tsunagi dns\` once; it
|
||||
prints the polkit rule that lets your user configure systemd-resolved.
|
||||
|
||||
* or skip the interface entirely with \`tsunagi up --no-tun\`: tunnels still
|
||||
form, they just do not reach the operating system.
|
||||
EOF
|
||||
fi
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=tsunagi mesh agent
|
||||
Documentation=https://github.com/Ultradesu/tsunagi
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/tsunagi up
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# This unit runs the agent as root, which covers everything it needs: creating
|
||||
# the TUN interface (CAP_NET_ADMIN), binding low ports, and configuring
|
||||
# systemd-resolved — root is not subject to polkit, so no polkit rule is needed.
|
||||
#
|
||||
# To run as a non-root user instead, uncomment the three lines below, set a
|
||||
# User=, and install the polkit rule that `tsunagi dns` prints (resolved decides
|
||||
# by user id, not capability, so the rule is what lets a non-root agent set the
|
||||
# resolver):
|
||||
#User=tsunagi
|
||||
#AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
#CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
# Running tsunagi on macOS
|
||||
|
||||
The agent needs **root** on macOS: creating a `utun` interface and writing
|
||||
`/etc/resolver` are both root-only, and macOS has no per-capability grant like
|
||||
Linux. There are two ways to run it.
|
||||
|
||||
## Quick start — `sudo`
|
||||
|
||||
```sh
|
||||
sudo tsunagi up
|
||||
```
|
||||
|
||||
In another terminal, the control commands talk to it over the system control
|
||||
socket and do **not** need `sudo`:
|
||||
|
||||
```sh
|
||||
tsunagi status
|
||||
tsunagi join <network-name> <tsn1…secret>
|
||||
```
|
||||
|
||||
The agent uses fixed system paths, so the commands find it regardless of whose
|
||||
shell started `sudo`:
|
||||
|
||||
| what | path |
|
||||
|---|---|
|
||||
| mandatory state | `/var/db/tsunagi` |
|
||||
| disposable cache | `/var/db/tsunagi/cache` |
|
||||
| control socket | `/var/run/tsunagi/agent.sock` |
|
||||
|
||||
## Native deployment — root LaunchDaemon
|
||||
|
||||
For an always-on agent that starts at boot, install the bundled
|
||||
[`cy.hexor.tsunagi.plist`](cy.hexor.tsunagi.plist).
|
||||
|
||||
```sh
|
||||
# Put the binary where the plist expects it (edit the plist for another path).
|
||||
sudo install -m 0755 target/release/tsunagi /usr/local/bin/tsunagi
|
||||
|
||||
# Install the daemon. launchd refuses a daemon plist that is not owned by
|
||||
# root:wheel or that is group/world-writable.
|
||||
sudo install -m 0644 -o root -g wheel \
|
||||
dist/macos/cy.hexor.tsunagi.plist \
|
||||
/Library/LaunchDaemons/cy.hexor.tsunagi.plist
|
||||
|
||||
# Load and start it.
|
||||
sudo launchctl bootstrap system /Library/LaunchDaemons/cy.hexor.tsunagi.plist
|
||||
sudo launchctl enable system/cy.hexor.tsunagi
|
||||
```
|
||||
|
||||
Manage it afterwards with:
|
||||
|
||||
```sh
|
||||
sudo launchctl kickstart -k system/cy.hexor.tsunagi # restart
|
||||
sudo launchctl bootout system/cy.hexor.tsunagi # stop and unload
|
||||
tail -f /var/log/tsunagi.log # logs
|
||||
```
|
||||
|
||||
The control commands (`status`, `join`, `network`, `dns`) work the same against
|
||||
the daemon as against a `sudo tsunagi up`, over the same socket.
|
||||
|
||||
## What the agent does to the system
|
||||
|
||||
Everything it installs is tagged to its own interface, computed from local
|
||||
state, and reversible — it is removed when a network leaves, broadcast is turned
|
||||
off, or the agent stops:
|
||||
|
||||
- a **`utun` interface**, configured with `ifconfig` (addresses, MTU, up) and a
|
||||
subnet `route` for the overlay range. The kernel assigns the `utunN` name; the
|
||||
agent adopts whatever it is given. A `utun` is torn down with the process, so
|
||||
a crash never leaves one behind.
|
||||
- `/etc/resolver/<network>` files pointing the system resolver at the overlay
|
||||
DNS server for each network's zone, with a `port` so the server stays off 53.
|
||||
Each file carries a marker line; the agent only ever replaces or removes files
|
||||
it wrote, never one you created by hand.
|
||||
- while broadcast is on, a `255.255.255.255` **route** through the overlay
|
||||
interface. The matching inbound-UDP firewall allowance is **not** implemented
|
||||
yet (it needs a `pf` anchor); `tsunagi status` reports that half as incomplete
|
||||
and names what to allow by hand if your host firewall drops discovery replies.
|
||||
|
||||
Nothing taken from a remote peer ever becomes a path, a command argument or an
|
||||
OS setting: the interface name comes from the kernel, addresses and ranges from
|
||||
signed state the agent already verified.
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
LaunchDaemon for the tsunagi agent.
|
||||
|
||||
A daemon, so it runs as root (creating a utun interface and writing
|
||||
/etc/resolver both need it) and comes up at boot before anyone logs in.
|
||||
Install it at /Library/LaunchDaemons/cy.hexor.tsunagi.plist, owned by
|
||||
root:wheel with mode 0644 — launchd refuses a daemon plist that is
|
||||
group- or world-writable. See README.md in this directory.
|
||||
|
||||
Edit the binary path if tsunagi is not at /usr/local/bin/tsunagi.
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>cy.hexor.tsunagi</string>
|
||||
|
||||
<!-- `up` starts the agent and keeps it in the foreground, which is what a
|
||||
launchd daemon expects: launchd owns the lifecycle, so the process
|
||||
must not daemonise itself. -->
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/tsunagi</string>
|
||||
<string>up</string>
|
||||
</array>
|
||||
|
||||
<!-- Start at boot and bring it back if it ever exits. -->
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
|
||||
<!-- Back off rather than spin if it fails to start repeatedly. -->
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
|
||||
<!-- Logs. launchd creates these as root; rotate them with newsyslog if you
|
||||
keep the daemon around. -->
|
||||
<key>StandardOutPath</key>
|
||||
<string>/var/log/tsunagi.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/var/log/tsunagi.log</string>
|
||||
|
||||
<!-- The agent uses the fixed macOS system paths by default:
|
||||
state /var/db/tsunagi
|
||||
socket /var/run/tsunagi/agent.sock
|
||||
so nothing here has to point at a user's home directory. -->
|
||||
</dict>
|
||||
</plist>
|
||||
Vendored
+46
@@ -0,0 +1,46 @@
|
||||
# Running tsunagi on Windows
|
||||
|
||||
This archive holds `tsunagi.exe`. The overlay interface is a Wintun adapter, so
|
||||
the agent needs two things to carry real traffic:
|
||||
|
||||
1. **`wintun.dll` beside the executable.** Download it from
|
||||
<https://www.wintun.net>, take the DLL for your architecture (`amd64`), and
|
||||
put it in the same folder as `tsunagi.exe`.
|
||||
2. **An elevated process.** Creating the adapter requires Administrator rights.
|
||||
|
||||
Open PowerShell or Command Prompt with **Run as administrator** and start the
|
||||
agent there:
|
||||
|
||||
```powershell
|
||||
.\tsunagi.exe up
|
||||
```
|
||||
|
||||
Run the commands that control it (`join`, `status`, `dns`, `network`) as the
|
||||
same user with the same elevation. The agent serves its control interface on a
|
||||
named pipe under `%ProgramData%\tsunagi`, so an elevated client finds it
|
||||
regardless of which account started the agent.
|
||||
|
||||
## Without touching the OS
|
||||
|
||||
```powershell
|
||||
.\tsunagi.exe up --no-tun
|
||||
```
|
||||
|
||||
Tunnels form and handshake between agents, but no adapter, address or route is
|
||||
created, so traffic never reaches the operating system. Needs no Wintun and no
|
||||
elevation.
|
||||
|
||||
## What the agent installs
|
||||
|
||||
Everything is tagged to its own adapter, computed from local state, and
|
||||
reversible — removed when a network leaves, broadcast is turned off, or the
|
||||
agent stops:
|
||||
|
||||
- a Wintun adapter configured with `netsh` (addresses, MTU);
|
||||
- while broadcast is on, a `255.255.255.255` route through the adapter and an
|
||||
inbound-UDP firewall allowance from the overlay range;
|
||||
- the Name Resolution Policy Table entry that sends the overlay zones' questions
|
||||
to the local resolver.
|
||||
|
||||
Nothing taken from a remote peer ever becomes a path, a command argument or an
|
||||
OS setting.
|
||||
+44
-8
@@ -8,14 +8,16 @@ control session. It is not a network-wide vote or signed shared configuration.
|
||||
```sh
|
||||
tsunagi join -n games --no-broadcast
|
||||
tsunagi join -n games --broadcast
|
||||
tsunagi network broadcast <network-id-or-prefix> off
|
||||
tsunagi network broadcast <network-id-or-prefix> on
|
||||
tsunagi network broadcast <network-id-or-prefix>
|
||||
tsunagi network broadcast games off
|
||||
tsunagi network broadcast games on
|
||||
tsunagi network broadcast games
|
||||
```
|
||||
|
||||
Joining without either flag preserves the saved choice. The `network broadcast`
|
||||
command can change a running network immediately or configure it while the
|
||||
agent is stopped. `status` reports the local setting. There is no global `up`
|
||||
agent is stopped. It accepts the exact network name, full id or a unique id
|
||||
prefix; ambiguous selectors list candidates without changing anything.
|
||||
`status` reports the local setting. There is no global `up`
|
||||
override: starting the agent preserves every network's independent policy.
|
||||
|
||||
## Delivery
|
||||
@@ -59,13 +61,47 @@ routers cannot inspect another pair's encrypted IP payload.
|
||||
|
||||
## Host behavior and verification
|
||||
|
||||
The game must send discovery through the Tsunagi interface. Traffic bound to a
|
||||
physical adapter never reaches this TUN, and this feature does not capture it.
|
||||
A game's discovery packet only reaches Tsunagi if the operating system sends it
|
||||
through the Tsunagi interface, and its reply only reaches the game if the host
|
||||
firewall admits it. While a network has broadcast on, the agent installs
|
||||
exactly two things for the interface it owns, and removes them when broadcast
|
||||
is turned off, the network is left or stopped, or the agent exits:
|
||||
|
||||
- **Route** `255.255.255.255/32` through the overlay interface, with the
|
||||
overlay address as its source (Linux: netlink, `ip route add 255.255.255.255/32
|
||||
dev tsun0 src <overlay-ip>`; Windows: `New-NetRoute`, metric 1, active store).
|
||||
Limited broadcasts from every program then go to the overlay and not to the
|
||||
physical LAN while this is installed; this is the price of the feature.
|
||||
- **Firewall allowance** for inbound **UDP only**, from the overlay range only,
|
||||
on that interface only (Linux: an `iptables -I INPUT` rule tagged
|
||||
`tsunagi:<interface>`; Windows: a Defender rule of that name). Replies to a
|
||||
broadcast come from another address than the request went to, so a stateful
|
||||
firewall never matches them to it. TCP services such as SSH stay unexposed.
|
||||
|
||||
Both are tagged, idempotent and reversible, and are never derived from a remote
|
||||
announcement. A rule left by a crashed run is replaced on the next apply. If
|
||||
`iptables` is missing (for example a host with only `nft`) or the process is not
|
||||
privileged, the route is still installed and `status` prints a
|
||||
`broadcast rules` line saying which half is missing and why. To undo it all,
|
||||
turn broadcast off for the network: `tsunagi network broadcast <network> off`.
|
||||
|
||||
**One interface, several networks.** The host has one `255.255.255.255` route
|
||||
per interface, so only one overlay address can be its source. The lowest network
|
||||
id among broadcast-enabled networks with an address is used; `status` says when
|
||||
that is not the network being shown. Directed broadcasts (`10.13.37.255`) follow
|
||||
the ordinary connected route and are not affected.
|
||||
|
||||
**What it cannot fix.** A game that binds its socket to a physical address or
|
||||
device, or sends to the physical subnet's broadcast address, still bypasses the
|
||||
TUN. The Windows implementation could not be run while it was written; its
|
||||
scripts are unit-tested only.
|
||||
|
||||
The suite tests real agents, authenticated iroh links and WireGuard encryption
|
||||
with memory TUNs: limited/directed UDP discovery, multihop fanout, single-copy
|
||||
delivery, opt-out, unicast replies, malformed input, source validation and
|
||||
network isolation. It does not launch CS 1.6 or Warcraft III or establish that
|
||||
every game/host chooses the virtual adapter automatically.
|
||||
network isolation. Host rules are tested through a mock host and a scripted
|
||||
`iptables`; the default suite changes no OS setting. It does not launch CS 1.6 or
|
||||
Warcraft III.
|
||||
|
||||
## Future exported LANs
|
||||
|
||||
|
||||
@@ -108,6 +108,11 @@ with no secret invents one, prints it in full and prints a line the other
|
||||
side can paste unchanged; a bare name this device already knows resumes
|
||||
that network instead of inventing another of the same name; and a network
|
||||
can be stopped and started again with its secret and its place intact.
|
||||
Network selection accepts exact names consistently across commands. Leave-by-name
|
||||
is tested with a live agent and with offline state, preserving other networks;
|
||||
duplicate and unknown names change nothing and full ids resolve ambiguity.
|
||||
Selector unit tests cover case-sensitive names, Unicode/ellipsis names, copied
|
||||
id prefixes and collisions between names and ids without revealing secrets.
|
||||
|
||||
`crates/tsunagi-cli/tests/dns_service.rs` runs the real binary: the resolver
|
||||
comes up with no interface to attach it to, the listener is not rebuilt on
|
||||
|
||||
Reference in new issue
Block a user