Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3856eed3b | ||
|
|
eac54f8c79 | ||
|
|
a8dfd7017b | ||
|
|
99f4cf0220 | ||
|
|
69263b2dd9 | ||
|
|
20444013c0 | ||
|
|
2196f6635b | ||
|
|
6438982d45 | ||
|
|
21b619388c | ||
|
|
d1ab993dd2 | ||
|
|
da7ce1dca1 | ||
|
|
4d2f8a54ec | ||
|
|
c3a6201ee2 | ||
|
|
533921059a | ||
|
|
d2778f0b5a | ||
|
|
8cfece9479 | ||
|
|
3930dff2dc | ||
|
|
35acc4d231 | ||
|
|
c791102ad9 | ||
|
|
f80841da63 | ||
|
|
7070a16104 | ||
|
|
46575d0899 | ||
|
|
a89e65475d | ||
|
|
2ed447ae86 | ||
|
|
ac5a1596ef | ||
|
|
192ab25bbd | ||
|
|
c0a76f1942 | ||
|
|
510116afb9 | ||
|
|
e24548b3b0 | ||
|
|
a4e341cd66 | ||
|
|
41990c9934 | ||
|
|
254a25ebd6 | ||
|
|
5495cb5b34 | ||
|
|
fac3bd7bb9 | ||
|
|
db9015b26c | ||
|
|
c724981bfd | ||
|
|
b4f3e57c8d |
No files matched your search
@@ -0,0 +1,237 @@
|
||||
name: Release
|
||||
|
||||
# Build the agent for every supported target on a version tag, leave each build
|
||||
# as a run artifact, and then — only after every target has built — attach the
|
||||
# archives to the release. Linux is a fully static musl binary (built with
|
||||
# cargo-zigbuild) so one artifact runs on any distro and can be repackaged
|
||||
# however a distro prefers.
|
||||
#
|
||||
# Targeted at Gitea Actions: artifacts use @v3 (the v4 backend is not available
|
||||
# on Gitea/GHES), and the release is filled through Gitea's own API with curl
|
||||
# rather than `gh`, which speaks only to github.com.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_NET_RETRY: "10"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.asset }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
asset: tsunagi-linux-x86_64
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
deb: true
|
||||
deb_arch: amd64
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
asset: tsunagi-linux-aarch64
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
deb: true
|
||||
deb_arch: arm64
|
||||
- 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
|
||||
|
||||
# A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops
|
||||
# the static binary, the systemd service and the polkit rule. Built with
|
||||
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
|
||||
- name: Build the Debian package
|
||||
if: matrix.deb
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
bash dist/deb/build-deb.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.deb_arch }}" \
|
||||
"$version" \
|
||||
"tsunagi_${version}_${{ matrix.deb_arch }}.deb"
|
||||
|
||||
# @v3 on purpose: Gitea does not support the v4 artifact backend. This is
|
||||
# what makes the archive (and the .deb, where built) show up under the run
|
||||
# and hands it to `publish`.
|
||||
- name: Upload the build outputs as run artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: |
|
||||
${{ matrix.asset }}-*.*
|
||||
*.deb
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download every build artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Show what was collected
|
||||
run: find artifacts -type f
|
||||
|
||||
- name: Create the release and upload the archives
|
||||
shell: bash
|
||||
env:
|
||||
# Gitea injects this with access to the current repository. If the
|
||||
# release calls below return 403, the repo/runner is configured to
|
||||
# hand Actions a read-only token — grant it write access to releases.
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -u
|
||||
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
# Gitea sets GITHUB_API_URL to its own API root (…/api/v1); fall back
|
||||
# to building it from the server URL if it is absent.
|
||||
base="${GITHUB_API_URL:-${GITHUB_SERVER_URL}/api/v1}"
|
||||
api="${base}/repos/${GITHUB_REPOSITORY}"
|
||||
auth="Authorization: token ${TOKEN}"
|
||||
echo "release API: $api"
|
||||
|
||||
prerelease=false
|
||||
case "$tag" in *-*) prerelease=true ;; esac
|
||||
|
||||
# The release id for this tag, if it already exists.
|
||||
code=$(curl -sS -o resp.json -w '%{http_code}' -H "$auth" "$api/releases/tags/$tag" || true)
|
||||
echo "get release by tag: HTTP $code"
|
||||
id=$(grep -o '"id":[0-9]*' resp.json 2>/dev/null | head -n1 | cut -d: -f2 || true)
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
code=$(curl -sS -o resp.json -w '%{http_code}' -X POST -H "$auth" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"$tag\",\"name\":\"tsunagi $tag\",\"prerelease\":$prerelease}" \
|
||||
"$api/releases" || true)
|
||||
echo "create release: HTTP $code"
|
||||
id=$(grep -o '"id":[0-9]*' resp.json 2>/dev/null | head -n1 | cut -d: -f2 || true)
|
||||
if [ -z "$id" ]; then
|
||||
echo "could not create the release; response was:" >&2
|
||||
cat resp.json >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "release id: $id"
|
||||
|
||||
# download-artifact@v3 nests each artifact in a directory named after
|
||||
# its upload, with the single archive inside. The read loop runs in
|
||||
# this shell (process substitution, not a pipe) so a failed upload
|
||||
# actually fails the job.
|
||||
rc=0
|
||||
while IFS= read -r -d '' f; do
|
||||
name=$(basename "$f")
|
||||
code=$(curl -sS -o up.json -w '%{http_code}' -X POST -H "$auth" \
|
||||
-F "attachment=@${f}" \
|
||||
"$api/releases/$id/assets?name=${name}" || true)
|
||||
echo "upload $name: HTTP $code"
|
||||
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
|
||||
cat up.json >&2 || true
|
||||
rc=1
|
||||
fi
|
||||
done < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' \) -print0)
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,208 @@
|
||||
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
|
||||
deb: true
|
||||
deb_arch: amd64
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
asset: tsunagi-linux-aarch64
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
deb: true
|
||||
deb_arch: arm64
|
||||
- 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
|
||||
|
||||
# A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops
|
||||
# the static binary, the systemd service and the polkit rule. Built with
|
||||
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
|
||||
- name: Build the Debian package
|
||||
if: matrix.deb
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
bash dist/deb/build-deb.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.deb_arch }}" \
|
||||
"$version" \
|
||||
"tsunagi_${version}_${{ matrix.deb_arch }}.deb"
|
||||
|
||||
- name: Upload the build outputs as run artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: |
|
||||
${{ matrix.asset }}-*.*
|
||||
*.deb
|
||||
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' -o -name '*.deb' \))
|
||||
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.
|
||||
@@ -45,9 +57,17 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
is why the authenticated transport does too.
|
||||
- **A relay carries bytes, never packets.** A datagram passed on for two
|
||||
other peers goes link in, link out: it is never written to the middle's
|
||||
interface, never decrypted there, and never relayed twice. Reachability
|
||||
that decides a route is first-hand and volatile — a live announcement
|
||||
over the control plane, never a signed record and never second-hand.
|
||||
interface or decrypted there. Multihop routing has a bounded hop limit;
|
||||
equal-cost next hops are chosen per flow. Reachability is first-hand and
|
||||
volatile: each authenticated member advertises its own protocol-specific
|
||||
links. Build routing tables on topology changes, never per packet. Transit
|
||||
must not acquire a routing mutex or wait for a protocol/TUN reader.
|
||||
- **Broadcast fanout belongs at local IP ingress.** Participation is local to
|
||||
each network, enabled by default, persisted and advertised to authenticated
|
||||
peers. Resolve one source domain and target only its willing members. Remote
|
||||
delivery never triggers another fanout. Physical LAN exports must extend
|
||||
explicit ingress and source/destination admission policies; never bypass
|
||||
ownership checks or add broadcast flooding to encrypted transit.
|
||||
- **Plugins never learn reachability.** An `IpPlugin` is handed a `PacketLink`
|
||||
per peer and moves datagrams over it. Addresses, hole punching and relays
|
||||
belong to `crates/tsunagi/src/dataplane/transport/`. A plugin announcement says *who*, never
|
||||
@@ -70,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.
|
||||
@@ -117,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
|
||||
|
||||
@@ -130,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 |
|
||||
@@ -166,7 +193,26 @@ kind.
|
||||
- Running several library instances in one process is not a test of several
|
||||
system processes; do not describe it as one.
|
||||
|
||||
## Before you open a change
|
||||
## Commit every completed change
|
||||
|
||||
Agents must create a Git commit before finishing any task that changes this
|
||||
repository. Do not leave the user to make the commit. Inspect recent commit
|
||||
messages and follow their style: a short English subject stating the change,
|
||||
then a useful body explaining the problem, final behavior, architectural
|
||||
decisions, compatibility/migration effects, and checks actually performed.
|
||||
Write enough context for a future maintainer or LLM to understand why the code
|
||||
exists. Record failed or skipped checks honestly; never claim unrun tests.
|
||||
|
||||
Before committing, inspect the diff and stage only this task's changes. Preserve
|
||||
unrelated user changes and never commit secrets or generated build artifacts.
|
||||
If Git has no effective author name or email, set only the missing repository-
|
||||
local values to `AB` and `ab@hexor.cy`. Do not override an existing identity or
|
||||
change global Git configuration. Report the resulting commit hash.
|
||||
|
||||
Version bumps, release tags, and pushing commits/tags belong to the user unless
|
||||
they explicitly ask the agent to do them. Read-only tasks need no empty commit.
|
||||
|
||||
## Required validation
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
|
||||
Generated
+389
-5
@@ -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"
|
||||
@@ -1985,8 +2162,6 @@ checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
|
||||
[[package]]
|
||||
name = "mainline"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d32eaee3dcba6e0bbbefe8bd896a8bd6039d5e74b199c0fe248e9feb547c2a26"
|
||||
dependencies = [
|
||||
"crc",
|
||||
"document-features",
|
||||
@@ -2028,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"
|
||||
@@ -2332,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"
|
||||
@@ -2403,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"
|
||||
@@ -2530,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"
|
||||
@@ -2776,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"
|
||||
@@ -2901,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"
|
||||
@@ -3046,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"
|
||||
@@ -3065,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",
|
||||
@@ -3129,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",
|
||||
@@ -3529,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"
|
||||
@@ -3915,6 +4198,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
name = "tsunagi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bytes",
|
||||
"caps",
|
||||
"data-encoding",
|
||||
@@ -3928,7 +4212,7 @@ dependencies = [
|
||||
"hmac 0.13.0",
|
||||
"iroh",
|
||||
"mainline",
|
||||
"netdev 0.45.1",
|
||||
"netdev 0.46.3",
|
||||
"netwatch",
|
||||
"postcard",
|
||||
"rand 0.10.3",
|
||||
@@ -3942,10 +4226,12 @@ dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
"tld",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tsunagi",
|
||||
"tun",
|
||||
"windows-sys 0.61.2",
|
||||
"zbus",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -3965,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]]
|
||||
@@ -3993,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"
|
||||
@@ -4632,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"
|
||||
@@ -4652,7 +5036,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4758,7 +5142,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+8
-2
@@ -4,6 +4,12 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*"]
|
||||
exclude = ["vendor/mainline"]
|
||||
|
||||
# Mainline 8.0.0 treats Windows UDP read timeouts as socket failures.
|
||||
# Keep the small receive-loop correction reproducible until an upstream fix.
|
||||
[patch.crates-io]
|
||||
mainline = { path = "vendor/mainline" }
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
@@ -13,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"
|
||||
@@ -21,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
|
||||
|
||||
@@ -563,26 +579,54 @@ explicitly.
|
||||
|
||||
### Through somebody in the middle
|
||||
|
||||
Everybody tries everybody first: the mesh is pairwise, and a relay is only
|
||||
for the pair that cannot manage it. Two members can both reach a third and
|
||||
not each other: a blocked path, a
|
||||
relay that is unavailable, a network only reachable from inside somebody
|
||||
else's building. When that happens the pair is routed through a member that
|
||||
has both.
|
||||
Tsunagi routes the overlay in userspace. A direct data link always wins;
|
||||
otherwise the shortest available path can pass through several members.
|
||||
Each authenticated member advertises only its own live, protocol-specific
|
||||
transport links. The control plane collects these into a graph and replaces
|
||||
the routing table when connectivity changes; announcements expire after
|
||||
90 seconds without refresh.
|
||||
|
||||
Nothing is agreed and nothing is elected. Each member says only which peers
|
||||
*it* has a live link with, first-hand, over the control plane and one hop
|
||||
only; everyone picks their own way through from that, deterministically, and
|
||||
drops it the moment a direct link exists. There is no routing protocol, no
|
||||
second-hand claim to weigh, and a relayed datagram is never relayed again —
|
||||
so a loop cannot form.
|
||||
Transit goes transport → router → transport, without entering WireGuard or
|
||||
the intermediate host's TUN. The payload stays encrypted between the original
|
||||
endpoints. TUN connects only the local OS to Tsunagi; the kernel needs only
|
||||
the overlay route through that interface, with no IP forwarding configuration.
|
||||
|
||||
The one in the middle carries **bytes it cannot read**: the tunnel stays end
|
||||
to end between the two ends, and a relayed datagram never touches the middle
|
||||
host's interface, so no forwarding, routing or firewall setting of that host
|
||||
is involved. `status` says `via <peer>` on a path that goes through
|
||||
somebody, and counts what this device has carried for others — it is their
|
||||
traffic on your uplink, and that should not be invisible.
|
||||
A 16-hop limit bounds loops while topology updates converge. Equal shortest
|
||||
paths use a stable flow hash derived before encryption, so packets of a TCP
|
||||
or UDP flow keep their path while topology is unchanged. Physical link changes
|
||||
do not replace end-to-end WireGuard tunnels or change the 1280-byte default MTU.
|
||||
`status` reports the hop count and first next hop for relayed paths.
|
||||
|
||||
The hot path reads an immutable routing snapshot and sends directly to a cached
|
||||
transport handle. It takes no routing mutex, walks no graph and does not parse
|
||||
the encrypted payload. See [routing.md](docs/routing.md) for the architecture,
|
||||
limits and reproducible forwarding microbenchmark.
|
||||
|
||||
This wire format requires all members to upgrade together (control ALPN 3,
|
||||
data ALPN 4); saved identities, network names, secrets and addresses survive.
|
||||
|
||||
### LAN game discovery
|
||||
|
||||
IPv4 UDP broadcast relay is enabled by default for each network. The IP router
|
||||
sends one encrypted copy to each participating peer, including through multihop
|
||||
paths. It supports `255.255.255.255` and the overlay subnet's broadcast address;
|
||||
received broadcasts never trigger another fanout.
|
||||
|
||||
```sh
|
||||
tsunagi join -n games --no-broadcast
|
||||
tsunagi join -n games --broadcast
|
||||
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. 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
|
||||
|
||||
@@ -650,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
|
||||
```
|
||||
|
||||
@@ -679,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
|
||||
|
||||
+1005
-293
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,
|
||||
@@ -250,3 +261,160 @@ fn up_is_the_agent_and_takes_no_network_at_all() {
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn broadcast_choice_is_per_network_live_persistent_and_preserved_by_rejoin() {
|
||||
let agent = start_bare(0);
|
||||
for args in [
|
||||
vec!["join", "-n", "broadcast-default"],
|
||||
vec!["join", "-n", "broadcast-off", "--no-broadcast"],
|
||||
] {
|
||||
let result = agent.run(&args);
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
}
|
||||
let read = || {
|
||||
tsunagi::storage::StateStore::open(agent.dir.path().join("state/state.sqlite"))
|
||||
.unwrap()
|
||||
.list_networks()
|
||||
.unwrap()
|
||||
};
|
||||
let networks = read();
|
||||
let enabled = networks
|
||||
.iter()
|
||||
.find(|n| n.name.as_str() == "broadcast-default")
|
||||
.unwrap();
|
||||
let disabled = networks
|
||||
.iter()
|
||||
.find(|n| n.name.as_str() == "broadcast-off")
|
||||
.unwrap();
|
||||
assert!(enabled.broadcast);
|
||||
assert!(!disabled.broadcast);
|
||||
assert!(agent.run(&["join", "-n", "broadcast-off"]).status.success());
|
||||
assert!(
|
||||
!read()
|
||||
.iter()
|
||||
.find(|n| n.name.as_str() == "broadcast-off")
|
||||
.unwrap()
|
||||
.broadcast
|
||||
);
|
||||
let id = disabled.network_id.to_string();
|
||||
let change = agent.run(&["network", "broadcast", "broadcast-off", "on"]);
|
||||
assert!(
|
||||
change.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&change.stderr)
|
||||
);
|
||||
assert!(read().iter().all(|n| n.broadcast));
|
||||
let change = agent.run(&["join", "-n", "broadcast-off", "--no-broadcast"]);
|
||||
assert!(
|
||||
change.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&change.stderr)
|
||||
);
|
||||
let status = agent.run(&["network", "broadcast", &id]);
|
||||
assert!(String::from_utf8_lossy(&status.stdout).contains("broadcast off"));
|
||||
assert!(
|
||||
!agent
|
||||
.run(&["join", "-n", "conflict", "--broadcast", "--no-broadcast"])
|
||||
.status
|
||||
.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"));
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
//! system level and signed by its holder, and that is what is compared
|
||||
//! against.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
@@ -39,6 +39,7 @@ 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, PluginError};
|
||||
use tsunagi::identity::NetworkId;
|
||||
@@ -54,6 +55,66 @@ const TIMER_INTERVAL: Duration = Duration::from_millis(250);
|
||||
/// Scratch space for one encapsulate or decapsulate call.
|
||||
const SCRATCH: usize = 4096;
|
||||
|
||||
/// Mirrors boringtun 0.7's bounded pending-packet FIFO with opaque flow tags.
|
||||
/// All three operations share the same mutex as the cryptographic state.
|
||||
struct FlowTunnel {
|
||||
tunn: Tunn,
|
||||
queued: VecDeque<FlowId>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// MAX_QUEUE_DEPTH in boringtun 0.7: the newest packet is dropped at 256.
|
||||
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);
|
||||
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() {
|
||||
// Without a session, draining pops the oldest IP packet and
|
||||
// encapsulate queues it again at the back.
|
||||
self.queued.push_back(pending);
|
||||
}
|
||||
}
|
||||
(result, flow)
|
||||
}
|
||||
|
||||
fn update_timers<'a>(&mut self, scratch: &'a mut [u8]) -> TunnResult<'a> {
|
||||
let result = self.tunn.update_timers(scratch);
|
||||
if matches!(
|
||||
result,
|
||||
TunnResult::Err(boringtun::noise::errors::WireGuardError::ConnectionExpired)
|
||||
) {
|
||||
self.queued.clear();
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Counters for one peer's tunnel.
|
||||
#[derive(Debug, Default)]
|
||||
struct PeerCounters {
|
||||
@@ -120,7 +181,7 @@ struct Peer {
|
||||
/// allocates it, the peer signs the claim, and every protocol carries
|
||||
/// traffic for the same address.
|
||||
overlay_v4: Mutex<Option<Ipv4Addr>>,
|
||||
tunn: Mutex<Tunn>,
|
||||
tunn: Mutex<FlowTunnel>,
|
||||
link: SharedLink,
|
||||
counters: Arc<PeerCounters>,
|
||||
task: Mutex<Option<JoinHandle<()>>>,
|
||||
@@ -165,7 +226,7 @@ impl Peer {
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
PeerHealth {
|
||||
since_handshake: guard.time_since_last_handshake(),
|
||||
since_handshake: guard.tunn.time_since_last_handshake(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,32 +315,34 @@ impl WireguardDevice {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut scratch = vec![0u8; SCRATCH];
|
||||
let mut scratch = [0u8; SCRATCH];
|
||||
let flow = ip_flow(packet);
|
||||
// The encryption is the whole of what this protocol contributes, so
|
||||
// it happens here rather than anywhere the packet passes through.
|
||||
// The lock is released before the send: a slow link must not hold up
|
||||
// the tunnel's timers.
|
||||
let len = {
|
||||
let result = {
|
||||
let mut tunn = match peer.tunn.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
match tunn.encapsulate(packet, &mut scratch) {
|
||||
TunnResult::WriteToNetwork(out) => Some(out.len()),
|
||||
let (result, selected_flow) = tunn.encapsulate(packet, &mut scratch, flow);
|
||||
match result {
|
||||
TunnResult::WriteToNetwork(out) => Some((out.len(), selected_flow)),
|
||||
// No session yet, so nothing to send. Counted as dropped
|
||||
// rather than reported: the handshake is in flight and the
|
||||
// next packet will go.
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let Some(len) = len else {
|
||||
let Some((len, selected_flow)) = result else {
|
||||
peer.counters
|
||||
.dropped_no_session
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
return false;
|
||||
};
|
||||
|
||||
send_to_peer(&peer, &scratch[..len]);
|
||||
send_to_peer_flow(&peer, &scratch[..len], selected_flow);
|
||||
peer.counters.tx_packets.fetch_add(1, Ordering::Relaxed);
|
||||
peer.counters
|
||||
.tx_bytes
|
||||
@@ -320,7 +383,10 @@ impl WireguardDevice {
|
||||
endpoint_id,
|
||||
public_key,
|
||||
overlay_v4: Mutex::new(overlay_v4),
|
||||
tunn: Mutex::new(tunn),
|
||||
tunn: Mutex::new(FlowTunnel {
|
||||
tunn,
|
||||
queued: VecDeque::new(),
|
||||
}),
|
||||
link,
|
||||
counters: Arc::new(PeerCounters::default()),
|
||||
task: Mutex::new(None),
|
||||
@@ -412,9 +478,7 @@ fn write_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
|
||||
|
||||
/// Asks boringtun for a handshake initiation and sends it.
|
||||
///
|
||||
/// Encapsulating an empty packet is how the protocol state machine is told
|
||||
/// "there is something to say"; with no session yet it answers with the
|
||||
/// handshake initiation.
|
||||
/// This does not enqueue an empty IP packet in boringtun's pending queue.
|
||||
fn kick_handshake(peer: &Peer) {
|
||||
let mut scratch = vec![0u8; SCRATCH];
|
||||
let len = {
|
||||
@@ -422,7 +486,7 @@ fn kick_handshake(peer: &Peer) {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
match tunn.encapsulate(&[], &mut scratch) {
|
||||
match tunn.tunn.format_handshake_initiation(&mut scratch, false) {
|
||||
TunnResult::WriteToNetwork(out) => Some(out.len()),
|
||||
_ => None,
|
||||
}
|
||||
@@ -434,7 +498,11 @@ fn kick_handshake(peer: &Peer) {
|
||||
|
||||
/// Sends whatever boringtun produced, without holding the tunnel lock.
|
||||
fn send_to_peer(peer: &Peer, payload: &[u8]) {
|
||||
match peer.link.send(Bytes::copy_from_slice(payload)) {
|
||||
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
|
||||
@@ -449,12 +517,12 @@ fn send_to_peer(peer: &Peer, payload: &[u8]) {
|
||||
}
|
||||
|
||||
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 scratch = vec![0u8; SCRATCH];
|
||||
// boringtun may need several passes: a handshake reply first, then
|
||||
// any packets that were queued while the session was coming up.
|
||||
let mut input: Option<&[u8]> = Some(&datagram);
|
||||
@@ -464,8 +532,9 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
match tunn.decapsulate(None, input.unwrap_or(&[]), &mut scratch) {
|
||||
TunnResult::WriteToNetwork(out) => Outcome::ToNetwork(out.len()),
|
||||
let (result, flow) = tunn.decapsulate(input.unwrap_or(&[]), &mut scratch);
|
||||
match result {
|
||||
TunnResult::WriteToNetwork(out) => Outcome::ToNetwork(out.len(), flow),
|
||||
// The source boringtun reports is not consulted here:
|
||||
// whether the peer may use it is checked where the
|
||||
// claims live.
|
||||
@@ -480,8 +549,8 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
|
||||
};
|
||||
|
||||
match outcome {
|
||||
Outcome::ToNetwork(len) => {
|
||||
send_to_peer(&peer, &scratch[..len]);
|
||||
Outcome::ToNetwork(len, flow) => {
|
||||
send_to_peer_flow(&peer, &scratch[..len], flow);
|
||||
// Keep draining with an empty datagram, as boringtun asks.
|
||||
input = None;
|
||||
continue;
|
||||
@@ -515,7 +584,7 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
ToNetwork(usize),
|
||||
ToNetwork(usize, FlowId),
|
||||
ToTunnel(usize),
|
||||
Done,
|
||||
Failed,
|
||||
@@ -550,3 +619,73 @@ async fn drive_timers(inner: Arc<Inner>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod flow_tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
|
||||
fn tunnel(ours: &WgSecretKey, theirs: &WgSecretKey) -> FlowTunnel {
|
||||
FlowTunnel {
|
||||
tunn: Tunn::new(
|
||||
ours.to_static_secret(),
|
||||
theirs.public().into_x25519(),
|
||||
None,
|
||||
None,
|
||||
1,
|
||||
None,
|
||||
),
|
||||
queued: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
fn wire(result: TunnResult<'_>) -> Vec<u8> {
|
||||
match result {
|
||||
TunnResult::WriteToNetwork(bytes) => bytes.to_vec(),
|
||||
_ => panic!("expected WireGuard frame"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_tags_survive_pending_queue_overflow_handshake_and_encryption() {
|
||||
let ak = WgSecretKey::from_bytes(&[1; 32]);
|
||||
let bk = WgSecretKey::from_bytes(&[2; 32]);
|
||||
let mut a = tunnel(&ak, &bk);
|
||||
let mut b = tunnel(&bk, &ak);
|
||||
let mut scratch = [0u8; SCRATCH];
|
||||
let mut packet = [0u8; 40];
|
||||
packet[0] = 0x45;
|
||||
packet[2..4].copy_from_slice(&40u16.to_be_bytes());
|
||||
packet[9] = 6;
|
||||
packet[12..20].copy_from_slice(&[10, 0, 0, 1, 10, 0, 0, 2]);
|
||||
let (result, flow) = a.encapsulate(&packet, &mut scratch, 100);
|
||||
assert_eq!(
|
||||
flow, 0,
|
||||
"handshake does not masquerade as application traffic"
|
||||
);
|
||||
let hello = wire(result);
|
||||
for flow in 101..400 {
|
||||
let _ = a.encapsulate(&packet, &mut scratch, flow);
|
||||
}
|
||||
assert_eq!(a.queued.len(), 256);
|
||||
let response = wire(b.decapsulate(&hello, &mut scratch).0);
|
||||
let keepalive = wire(a.decapsulate(&response, &mut scratch).0);
|
||||
let _ = b.decapsulate(&keepalive, &mut scratch);
|
||||
for expected in 100..356 {
|
||||
let (result, flow) = a.decapsulate(&[], &mut scratch);
|
||||
assert_eq!(flow, expected);
|
||||
let ciphertext = wire(result);
|
||||
match b.decapsulate(&ciphertext, &mut scratch).0 {
|
||||
TunnResult::WriteToTunnelV4(bytes, _) => assert_eq!(bytes, packet),
|
||||
_ => panic!("queued IP packet must decrypt"),
|
||||
}
|
||||
}
|
||||
assert!(a.queued.is_empty());
|
||||
assert!(matches!(
|
||||
a.decapsulate(&[], &mut scratch).0,
|
||||
TunnResult::Done
|
||||
));
|
||||
let (result, flow) = a.encapsulate(&packet, &mut scratch, 12345);
|
||||
assert!(is_data(&result));
|
||||
assert_eq!(flow, 12345);
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,7 @@ impl tsunagi::ipc::ReportSource for Control {
|
||||
&self,
|
||||
name: String,
|
||||
secret: String,
|
||||
_broadcast: Option<bool>,
|
||||
) -> BoxFuture<'_, Result<tsunagi::ipc::JoinedReport, String>> {
|
||||
Box::pin(async move {
|
||||
let name = tsunagi::identity::NetworkName::new(&name).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -1510,3 +1510,237 @@ async fn a_peer_with_no_direct_path_is_reached_through_one_that_has_both() {
|
||||
b.shutdown().await;
|
||||
middle.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_chain_routes_through_two_transit_peers_without_touching_their_tuns() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("wg-multihop");
|
||||
let mut agents = Vec::new();
|
||||
let mut blocked = Vec::new();
|
||||
for index in 0..4 {
|
||||
let cuts = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
|
||||
agents.push(WgAgent::spawn_cut_off(&discovery, &format!("mh{index}"), cuts.clone()).await);
|
||||
blocked.push(cuts);
|
||||
}
|
||||
// The authenticated control plane stays connected. The only available
|
||||
// data links are A--B--C--D; A and D need two transit routers.
|
||||
for (i, cuts) in blocked.iter().enumerate() {
|
||||
for (j, agent) in agents.iter().enumerate() {
|
||||
if i.abs_diff(j) > 1 {
|
||||
cuts.lock().unwrap().insert(agent.endpoint_id());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut id = None;
|
||||
for agent in &agents {
|
||||
id = Some(agent.agent.join_network(&name, &secret).await.unwrap());
|
||||
}
|
||||
let id = id.unwrap();
|
||||
for agent in &agents {
|
||||
wait_for_peers(&agent.agent, id, 3).await;
|
||||
agent.wait_for_tunnels(id, 3).await;
|
||||
}
|
||||
let a = &agents[0];
|
||||
let d = &agents[3];
|
||||
let a_addr = a.overlay(id).await;
|
||||
let d_addr = d.overlay(id).await;
|
||||
let a_tun = a.tun(id).await;
|
||||
let d_tun = d.tun(id).await;
|
||||
for (from, to, source, destination) in [
|
||||
(&a_tun, &d_tun, a_addr, d_addr),
|
||||
(&d_tun, &a_tun, d_addr, a_addr),
|
||||
] {
|
||||
let packet = tcp_packet(source, destination, 1280);
|
||||
from.push_from_os(packet.clone());
|
||||
let received = tokio::time::timeout(tsunagi::testing::DEADLINE, to.pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(received, packet);
|
||||
}
|
||||
for middle in &agents[1..3] {
|
||||
assert!(
|
||||
middle
|
||||
.agent
|
||||
.network_status(id)
|
||||
.await
|
||||
.unwrap()
|
||||
.relay
|
||||
.forwarded
|
||||
> 0
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), middle.tun(id).await.pop_to_os())
|
||||
.await
|
||||
.is_err(),
|
||||
"transit ciphertext must never enter the intermediate host's TUN"
|
||||
);
|
||||
}
|
||||
// A direct shortcut wins immediately; losing it restores the chain using
|
||||
// the same end-to-end tunnels and overlay addresses.
|
||||
for allow_direct in [true, false] {
|
||||
for (from, to) in [(0, 3), (3, 0)] {
|
||||
if allow_direct {
|
||||
blocked[from]
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&agents[to].endpoint_id());
|
||||
} else {
|
||||
blocked[from]
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(agents[to].endpoint_id());
|
||||
}
|
||||
agents[from].agent.recheck_network(id).await.unwrap();
|
||||
}
|
||||
for (from, to) in [(a, d), (d, a)] {
|
||||
wait_until("route follows the changed topology", || async {
|
||||
let view = from.plugin.overview(id)?;
|
||||
let tunnel = view
|
||||
.peers
|
||||
.iter()
|
||||
.find(|peer| peer.endpoint_id == to.endpoint_id())?
|
||||
.tunnel
|
||||
.as_ref()?;
|
||||
let relayed = tunnel.path.contains("relay 3 hops");
|
||||
(tunnel.health.is_up()
|
||||
&& if allow_direct {
|
||||
!tunnel.path.contains("relay") && !tunnel.path.contains("unreachable")
|
||||
} else {
|
||||
relayed
|
||||
})
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let packet = tcp_packet(a_addr, d_addr, 1280);
|
||||
a_tun.push_from_os(packet.clone());
|
||||
assert_eq!(
|
||||
tokio::time::timeout(tsunagi::testing::DEADLINE, d_tun.pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
packet
|
||||
);
|
||||
}
|
||||
for agent in agents {
|
||||
agent.shutdown().await;
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn lan_broadcast_fanout_is_scoped_opt_in_and_does_not_reflood() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("lan-broadcast");
|
||||
let cuts = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
|
||||
let a = WgAgent::spawn_cut_off(&discovery, "bca", cuts.clone()).await;
|
||||
let b = WgAgent::spawn(&discovery, "bcb").await;
|
||||
let c = WgAgent::spawn(&discovery, "bcc").await;
|
||||
cuts.lock().unwrap().insert(c.endpoint_id());
|
||||
let id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
c.agent.join_network(&name, &secret).await.unwrap();
|
||||
for agent in [&a, &b, &c] {
|
||||
agent.wait_for_tunnels(id, 2).await;
|
||||
}
|
||||
let addr = a.overlay(id).await;
|
||||
let at = a.tun(id).await;
|
||||
let bt = b.tun(id).await;
|
||||
let ct = c.tun(id).await;
|
||||
// Valid UDP discovery queries with game-like ports and an unchanged body.
|
||||
fn query(source: std::net::Ipv4Addr, destination: std::net::Ipv4Addr) -> Bytes {
|
||||
let mut bytes = vec![0u8; 36];
|
||||
bytes[0] = 0x45;
|
||||
bytes[2..4].copy_from_slice(&36u16.to_be_bytes());
|
||||
bytes[8] = 1;
|
||||
bytes[9] = 17;
|
||||
bytes[12..16].copy_from_slice(&source.octets());
|
||||
bytes[16..20].copy_from_slice(&destination.octets());
|
||||
bytes[20..22].copy_from_slice(&27015u16.to_be_bytes());
|
||||
bytes[22..24].copy_from_slice(&6112u16.to_be_bytes());
|
||||
bytes[24..26].copy_from_slice(&16u16.to_be_bytes());
|
||||
bytes[28..].copy_from_slice(b"LAN GAME");
|
||||
// UDP checksum zero is valid for IPv4.
|
||||
let sum: u32 = bytes[..20]
|
||||
.chunks_exact(2)
|
||||
.map(|w| u16::from_be_bytes([w[0], w[1]]) as u32)
|
||||
.sum();
|
||||
let checksum = !((sum & 0xffff) + (sum >> 16)) as u16;
|
||||
bytes[10..12].copy_from_slice(&checksum.to_be_bytes());
|
||||
Bytes::from(bytes)
|
||||
}
|
||||
let range = a.agent.network_status(id).await.unwrap().range.unwrap();
|
||||
let directed = std::net::Ipv4Addr::from(u32::from(range.base) | (u32::MAX >> range.prefix_len));
|
||||
for destination in [std::net::Ipv4Addr::BROADCAST, directed] {
|
||||
let packet = query(addr, destination);
|
||||
at.push_from_os(packet.clone());
|
||||
for tun in [&bt, &ct] {
|
||||
assert_eq!(
|
||||
tokio::time::timeout(tsunagi::testing::DEADLINE, tun.pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
packet
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), tun.pop_to_os())
|
||||
.await
|
||||
.is_err(),
|
||||
"each recipient gets one copy"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), at.pop_to_os())
|
||||
.await
|
||||
.is_err(),
|
||||
"never reflect to the origin"
|
||||
);
|
||||
}
|
||||
c.agent.set_broadcast(id, false).await.unwrap();
|
||||
wait_until("broadcast opt-out reaches sender", || async {
|
||||
a.agent
|
||||
.network_status(id)
|
||||
.await
|
||||
.ok()?
|
||||
.peers
|
||||
.iter()
|
||||
.find(|peer| peer.endpoint_id == c.endpoint_id())
|
||||
.filter(|peer| !peer.broadcast)
|
||||
.map(|_| ())
|
||||
})
|
||||
.await;
|
||||
let packet = query(addr, std::net::Ipv4Addr::BROADCAST);
|
||||
at.push_from_os(packet.clone());
|
||||
assert_eq!(
|
||||
tokio::time::timeout(tsunagi::testing::DEADLINE, bt.pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
packet
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(150), ct.pop_to_os())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
// Disabling origin participation also stops outgoing copies.
|
||||
a.agent.set_broadcast(id, false).await.unwrap();
|
||||
at.push_from_os(packet);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(150), bt.pop_to_os())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
// Discovery's unicast reply still crosses the same multihop route.
|
||||
let reply = tcp_packet(c.overlay(id).await, addr, 1280);
|
||||
ct.push_from_os(reply.clone());
|
||||
assert_eq!(
|
||||
tokio::time::timeout(tsunagi::testing::DEADLINE, at.pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
reply
|
||||
);
|
||||
for agent in [a, b, c] {
|
||||
agent.shutdown().await;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -33,10 +32,12 @@ dns-publish = ["dep:zbus"]
|
||||
testing = ["dep:tempfile", "dep:tracing-subscriber"]
|
||||
|
||||
[dependencies]
|
||||
arc-swap = "1.9"
|
||||
iroh.workspace = true
|
||||
# `net` for the local control interface: a Unix socket on Unix, a named pipe
|
||||
# on Windows. Both live behind the same `net` feature.
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
tokio-stream = "0.1"
|
||||
serde.workspace = true
|
||||
postcard.workspace = true
|
||||
bytes.workspace = true
|
||||
@@ -65,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 }
|
||||
@@ -91,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.
|
||||
|
||||
@@ -58,6 +58,8 @@ use shutdown::Shutdown;
|
||||
/// Summary of a configured network, whether or not it is running.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfiguredNetwork {
|
||||
/// Saved local broadcast participation.
|
||||
pub broadcast: bool,
|
||||
/// Public network identifier.
|
||||
pub network_id: NetworkId,
|
||||
/// Network name.
|
||||
@@ -279,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() {
|
||||
@@ -344,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
|
||||
@@ -355,12 +385,10 @@ 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(),
|
||||
range: Some(wanted),
|
||||
local: None,
|
||||
peers: Vec::new(),
|
||||
@@ -368,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) {
|
||||
@@ -415,6 +481,7 @@ impl Agent {
|
||||
mtu: interface.mtu(),
|
||||
addresses: interface.wanted_addresses(),
|
||||
counters: interface.counters(),
|
||||
broadcast_rules: interface.broadcast_rules(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -488,6 +555,16 @@ impl Agent {
|
||||
&self,
|
||||
name: &NetworkName,
|
||||
secret: &NetworkSecret,
|
||||
) -> Result<NetworkId> {
|
||||
self.join_network_with_broadcast(name, secret, None).await
|
||||
}
|
||||
|
||||
/// Joins with an explicit broadcast choice; absent preserves saved policy.
|
||||
pub async fn join_network_with_broadcast(
|
||||
&self,
|
||||
name: &NetworkName,
|
||||
secret: &NetworkSecret,
|
||||
broadcast: Option<bool>,
|
||||
) -> Result<NetworkId> {
|
||||
let keys = NetworkKeys::derive(name, secret);
|
||||
let network_id = keys.network_id();
|
||||
@@ -516,6 +593,9 @@ impl Agent {
|
||||
.storage
|
||||
.upsert_network(network_id, name.clone(), secret.clone(), true)
|
||||
.await?;
|
||||
if let Some(enabled) = broadcast {
|
||||
self.set_broadcast(network_id, enabled).await?;
|
||||
}
|
||||
match self.activate_with_keys(keys).await {
|
||||
// Already a member of exactly this network space: nothing to do.
|
||||
Ok(()) | Err(Error::NetworkAlreadyActive(_)) => Ok(network_id),
|
||||
@@ -523,6 +603,21 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes a network's local broadcast policy now and after restart.
|
||||
pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
self.inner
|
||||
.storage
|
||||
.set_broadcast(network_id, enabled)
|
||||
.await?;
|
||||
if self.is_active(network_id).await {
|
||||
let (reply, receive) = oneshot::channel();
|
||||
self.command(network_id, NetCommand::SetBroadcast { enabled, reply })
|
||||
.await?;
|
||||
receive.await.map_err(|_| Error::Stopped)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Activates a configured network that is currently inactive.
|
||||
///
|
||||
/// Fails with [`Error::NetworkAlreadyActive`] if it is already running.
|
||||
@@ -551,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());
|
||||
@@ -567,7 +662,16 @@ impl Agent {
|
||||
as Arc<dyn crate::discovery::NetworkDiscovery>,
|
||||
)
|
||||
};
|
||||
let broadcast = self
|
||||
.inner
|
||||
.storage
|
||||
.list_networks()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|stored| stored.network_id == network_id)
|
||||
.is_none_or(|stored| stored.broadcast);
|
||||
let handle = network::spawn(RuntimeParams {
|
||||
broadcast,
|
||||
keys,
|
||||
adapter: self.inner.adapter.clone(),
|
||||
storage: self.inner.storage.clone(),
|
||||
@@ -627,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(())
|
||||
}
|
||||
@@ -703,6 +810,7 @@ impl Agent {
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|stored| ConfiguredNetwork {
|
||||
broadcast: stored.broadcast,
|
||||
network_id: stored.network_id,
|
||||
name: stored.name,
|
||||
auto_start: stored.auto_start,
|
||||
@@ -777,6 +885,7 @@ impl Agent {
|
||||
}
|
||||
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
||||
networks.push(NetworkStatus {
|
||||
broadcast: stored.broadcast,
|
||||
descriptor: keys.descriptor(),
|
||||
name: stored.name,
|
||||
network_id: stored.network_id,
|
||||
@@ -1013,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1158,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;
|
||||
};
|
||||
|
||||
+376
-144
@@ -62,6 +62,10 @@ pub(crate) enum NetCommand {
|
||||
reply: oneshot::Sender<Box<NetworkStatus>>,
|
||||
},
|
||||
Recheck,
|
||||
SetBroadcast {
|
||||
enabled: bool,
|
||||
reply: oneshot::Sender<()>,
|
||||
},
|
||||
/// Resend this agent's announcement to every peer of this network.
|
||||
Reannounce,
|
||||
/// Answer to a different name from now on.
|
||||
@@ -82,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 {
|
||||
@@ -95,10 +105,14 @@ impl std::fmt::Debug for NetCommand {
|
||||
NetCommand::Broadcast { message, .. } => write!(f, "Broadcast({})", kind(message)),
|
||||
NetCommand::Status { .. } => f.write_str("Status"),
|
||||
NetCommand::Recheck => f.write_str("Recheck"),
|
||||
NetCommand::SetBroadcast { enabled, .. } => write!(f, "SetBroadcast({enabled})"),
|
||||
NetCommand::Reannounce => f.write_str("Reannounce"),
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +136,7 @@ impl NetworkHandle {
|
||||
|
||||
/// Everything a network runtime needs to run.
|
||||
pub(crate) struct RuntimeParams {
|
||||
pub(crate) broadcast: bool,
|
||||
pub(crate) keys: NetworkKeys,
|
||||
pub(crate) adapter: EndpointAdapter,
|
||||
pub(crate) storage: Storage,
|
||||
@@ -233,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>,
|
||||
@@ -264,14 +297,22 @@ struct Runtime {
|
||||
/// else, so there is no hearsay to weigh and nothing to poison. Soft
|
||||
/// state — an entry that stops being refreshed expires, which is how a
|
||||
/// relay that has gone stops being chosen without anybody revoking it.
|
||||
reachable: HashMap<EndpointId, (HashSet<EndpointId>, std::time::Instant)>,
|
||||
reachable: HashMap<
|
||||
EndpointId,
|
||||
(
|
||||
HashSet<crate::proto::message::ReachableLink>,
|
||||
std::time::Instant,
|
||||
),
|
||||
>,
|
||||
/// What this agent last told its peers it could reach, so it is only
|
||||
/// said again when it changed.
|
||||
announced_reach: HashSet<EndpointId>,
|
||||
announced_reach: HashSet<crate::proto::message::ReachableLink>,
|
||||
/// Links currently being opened, so we do not start two.
|
||||
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)>,
|
||||
@@ -328,12 +369,13 @@ impl Runtime {
|
||||
dial_results_tx,
|
||||
dial_results_rx,
|
||||
links: HashMap::new(),
|
||||
hub: crate::dataplane::relay::RelayHub::new(network_id),
|
||||
hub: crate::dataplane::relay::RelayHub::new(network_id, local_id),
|
||||
reachable: HashMap::new(),
|
||||
announced_reach: HashSet::new(),
|
||||
opening: HashSet::new(),
|
||||
link_results_tx,
|
||||
link_results_rx,
|
||||
protocol_dial_states: HashMap::new(),
|
||||
reported_mismatch: HashSet::new(),
|
||||
released: false,
|
||||
reported_missing: None,
|
||||
@@ -360,6 +402,11 @@ impl Runtime {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = self.shutdown.wait() => break,
|
||||
_ = self.hub.changed() => {
|
||||
self.ensure_links();
|
||||
self.update_paths();
|
||||
self.announce_reach(false);
|
||||
}
|
||||
command = commands.recv() => match command {
|
||||
Some(command) => self.handle_command(command).await,
|
||||
None => break,
|
||||
@@ -387,6 +434,8 @@ impl Runtime {
|
||||
_ = ticker.tick() => {
|
||||
self.discovery_round().await;
|
||||
self.ensure_links();
|
||||
self.update_paths();
|
||||
self.announce_reach(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -401,7 +450,9 @@ impl Runtime {
|
||||
// Stop accepting session events first: nothing is going to act on them
|
||||
// any more, and a sender blocked on a full queue would stall shutdown.
|
||||
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) {
|
||||
@@ -441,6 +492,15 @@ impl Runtime {
|
||||
NetCommand::Status { reply } => {
|
||||
let _ = reply.send(Box::new(self.status()));
|
||||
}
|
||||
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(());
|
||||
}
|
||||
NetCommand::Recheck => self.discovery_round().await,
|
||||
NetCommand::Reannounce => self.reannounce(),
|
||||
NetCommand::Release { reply } => {
|
||||
@@ -474,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1003,6 +1083,24 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
fn broadcast_policy(&self) -> crate::overlay::broadcast::BroadcastPolicy {
|
||||
crate::overlay::broadcast::BroadcastPolicy {
|
||||
enabled: self.params.broadcast,
|
||||
peers: self
|
||||
.sessions
|
||||
.values()
|
||||
.filter(|session| session.broadcast)
|
||||
.map(|session| session.peer)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_broadcast(&self) {
|
||||
self.params
|
||||
.routes
|
||||
.set_broadcast(self.network_id, self.broadcast_policy());
|
||||
}
|
||||
|
||||
/// Tells the plugins who holds which overlay address.
|
||||
async fn publish_allocations(&mut self) {
|
||||
let Some(range) = self.effective_range() else {
|
||||
@@ -1025,6 +1123,7 @@ impl Runtime {
|
||||
// a packet for ourselves does not go over a tunnel.
|
||||
let local = self.state.address_of(&self.local_id);
|
||||
let routes = crate::overlay::NetworkRoutes {
|
||||
broadcast: self.broadcast_policy(),
|
||||
range: Some(range),
|
||||
local,
|
||||
peers: allocations
|
||||
@@ -1065,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 {
|
||||
@@ -1094,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
|
||||
@@ -1107,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,
|
||||
@@ -1122,7 +1267,7 @@ impl Runtime {
|
||||
|
||||
let mut dead: Vec<(EndpointId, String)> = Vec::new();
|
||||
for (key, link) in &self.links {
|
||||
if link.is_closed() {
|
||||
if link.is_closed() || self.no_direct_data(key.0) {
|
||||
dead.push(key.clone());
|
||||
}
|
||||
}
|
||||
@@ -1132,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,
|
||||
@@ -1140,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) {
|
||||
@@ -1176,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();
|
||||
@@ -1307,28 +1471,28 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells peers which peers this agent has a live link with, when that
|
||||
/// has changed.
|
||||
///
|
||||
/// Only what it can see for itself, and only to the peers it is
|
||||
/// talking to: one hop, no flooding, nothing second-hand. A peer that
|
||||
/// hears this is the only one that can use it, because it is also the
|
||||
/// only one that could route through this agent.
|
||||
/// Publishes only locally observed, protocol-specific transport edges.
|
||||
fn announce_reach(&mut self, force: bool) {
|
||||
let live: HashSet<EndpointId> = self
|
||||
let live: HashSet<_> = self
|
||||
.links
|
||||
.iter()
|
||||
.filter(|(_, link)| !link.is_closed())
|
||||
.map(|((peer, _), _)| *peer)
|
||||
.map(
|
||||
|((peer, protocol), _)| crate::proto::message::ReachableLink {
|
||||
peer: *peer.as_bytes(),
|
||||
protocol: protocol.clone(),
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
if !force && live == self.announced_reach {
|
||||
return;
|
||||
}
|
||||
self.announced_reach = live.clone();
|
||||
let message = ControlMessage::Reachable {
|
||||
peers: live.iter().map(|peer| *peer.as_bytes()).collect(),
|
||||
};
|
||||
let peers: Vec<EndpointId> = self.sessions.keys().copied().collect();
|
||||
let mut links: Vec<_> = live.into_iter().collect();
|
||||
links.sort_by(|a, b| (&a.protocol, a.peer).cmp(&(&b.protocol, b.peer)));
|
||||
links.truncate(self.params.limits.max_state_records);
|
||||
let message = ControlMessage::Reachable { links };
|
||||
let peers: Vec<_> = self.sessions.keys().copied().collect();
|
||||
for peer in peers {
|
||||
if let Err(err) = self.send_to(peer, message.clone()) {
|
||||
tracing::debug!(%err, "could not queue a reachability announcement");
|
||||
@@ -1336,99 +1500,72 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chooses, for every peer with no direct link, somebody to go through.
|
||||
///
|
||||
/// Local and deterministic: the lowest endpoint id among the peers
|
||||
/// that both this agent has a link with and that say they have a link
|
||||
/// with the destination. Nothing is agreed with anybody — two agents
|
||||
/// may well pick differently, and traffic each way finds its own path.
|
||||
/// Builds the graph off the packet path. Every remote row comes from that
|
||||
/// member's authenticated control session; stale or incompatible rows never
|
||||
/// enter the graph. The hub replaces an immutable table only on changes.
|
||||
fn update_paths(&mut self) {
|
||||
self.reachable
|
||||
.retain(|_, (_, heard)| heard.elapsed() < REACH_EXPIRY);
|
||||
|
||||
let served = self.served_protocols();
|
||||
let live: HashSet<EndpointId> = self
|
||||
.links
|
||||
.iter()
|
||||
.filter(|(_, link)| !link.is_closed())
|
||||
.map(|((peer, _), _)| *peer)
|
||||
.collect();
|
||||
|
||||
// Every peer this agent would carry traffic with: a session says
|
||||
// who it is and what it speaks, which is also what a protocol
|
||||
// needs before it can have a link at all.
|
||||
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 {
|
||||
if live.contains(&peer) {
|
||||
// There is a direct link; a hop is only for when there is
|
||||
// not, and holding a stale one would keep a peer that has
|
||||
// gone looking reachable.
|
||||
if self.hub.has_link(peer, &protocol) {
|
||||
self.hub.set_hop(peer, &protocol, None);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let hop = self
|
||||
self.reachable.retain(|peer, (_, heard)| {
|
||||
heard.elapsed() < REACH_EXPIRY && self.sessions.contains_key(peer)
|
||||
});
|
||||
for (protocol, version) in self.served_protocols() {
|
||||
let peers: Vec<_> = self
|
||||
.sessions
|
||||
.values()
|
||||
.filter(|session| {
|
||||
session.capabilities.iter().any(|capability| {
|
||||
capability.enabled
|
||||
&& capability.protocol == protocol
|
||||
&& capability.version == version
|
||||
})
|
||||
})
|
||||
.map(|session| session.peer)
|
||||
.collect();
|
||||
let mut members: HashSet<_> = peers.iter().map(|peer| *peer.as_bytes()).collect();
|
||||
members.insert(*self.local_id.as_bytes());
|
||||
let graph = self
|
||||
.reachable
|
||||
.iter()
|
||||
.filter(|(middle, (reaches, _))| {
|
||||
live.contains(*middle) && reaches.contains(&peer) && **middle != peer
|
||||
.filter(|(peer, _)| members.contains(peer.as_bytes()))
|
||||
.map(|(peer, (links, _))| {
|
||||
let mut neighbors: Vec<_> = links
|
||||
.iter()
|
||||
.filter(|link| {
|
||||
link.protocol == protocol
|
||||
&& members.contains(&link.peer)
|
||||
&& link.peer != *peer.as_bytes()
|
||||
})
|
||||
.map(|link| link.peer)
|
||||
.collect();
|
||||
neighbors.sort_unstable();
|
||||
(*peer.as_bytes(), neighbors)
|
||||
})
|
||||
.map(|(middle, _)| *middle)
|
||||
.min_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
|
||||
|
||||
let known = self.hub.has_link(peer, &protocol);
|
||||
if !known && hop.is_none() {
|
||||
// Nothing to reach it with and nothing to offer a
|
||||
// protocol; inventing a link object here would only look
|
||||
// like connectivity.
|
||||
continue;
|
||||
.collect();
|
||||
self.hub.set_topology(&protocol, graph, members);
|
||||
for peer in peers {
|
||||
if self.hub.has_link(peer, &protocol) || !self.hub.reachable(peer, &protocol) {
|
||||
continue;
|
||||
}
|
||||
let Some(plugin) = self
|
||||
.params
|
||||
.plugins
|
||||
.iter()
|
||||
.find(|plugin| plugin.protocol_id() == protocol)
|
||||
.cloned()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let link = self.hub.link(peer, &protocol);
|
||||
let path = link.path_description();
|
||||
let max_datagram = link.max_datagram_size();
|
||||
plugin.on_peer_link(self.network_id, peer, link);
|
||||
self.emit(Event::DataLinkUp {
|
||||
network: self.network_id,
|
||||
peer,
|
||||
protocol: protocol.clone(),
|
||||
path,
|
||||
max_datagram,
|
||||
});
|
||||
}
|
||||
self.hub.set_hop(peer, &protocol, hop);
|
||||
if known {
|
||||
continue;
|
||||
}
|
||||
// A peer reachable only through somebody still gets a link, so
|
||||
// its tunnel can be built: the protocol is not told how its
|
||||
// datagrams travel, only that this is the way to that peer.
|
||||
let Some(plugin) = self
|
||||
.params
|
||||
.plugins
|
||||
.iter()
|
||||
.find(|plugin| plugin.protocol_id() == protocol)
|
||||
.cloned()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let link = self.hub.link(peer, &protocol);
|
||||
let path = link.path_description();
|
||||
let max_datagram = link.max_datagram_size();
|
||||
plugin.on_peer_link(self.network_id, peer, link);
|
||||
self.emit(Event::DataLinkUp {
|
||||
network: self.network_id,
|
||||
peer,
|
||||
protocol,
|
||||
path,
|
||||
max_datagram,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1437,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.
|
||||
@@ -1446,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1479,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);
|
||||
@@ -1526,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()
|
||||
@@ -1641,6 +1784,7 @@ impl Runtime {
|
||||
}
|
||||
capabilities.truncate(self.params.limits.max_capabilities);
|
||||
Announcement {
|
||||
broadcast: self.params.broadcast,
|
||||
hostname: self.params.hostname.clone(),
|
||||
capabilities,
|
||||
}
|
||||
@@ -1718,6 +1862,9 @@ impl Runtime {
|
||||
}
|
||||
self.metrics.disconnects += 1;
|
||||
self.drop_links_for(peer);
|
||||
self.update_broadcast();
|
||||
self.update_paths();
|
||||
self.announce_reach(false);
|
||||
for plugin in &self.params.plugins {
|
||||
plugin.on_peer_gone(self.network_id, peer);
|
||||
}
|
||||
@@ -1744,10 +1891,13 @@ impl Runtime {
|
||||
let capabilities = announcement.capabilities.clone();
|
||||
if let Some(session) = self.sessions.get_mut(&peer) {
|
||||
session.hostname = Some(announcement.hostname.clone());
|
||||
session.broadcast = announcement.broadcast;
|
||||
session.capabilities = capabilities.clone();
|
||||
}
|
||||
self.update_broadcast();
|
||||
self.dispatch_capabilities(peer, &capabilities);
|
||||
self.ensure_links();
|
||||
self.update_paths();
|
||||
}
|
||||
ControlMessage::Ping { seq, payload } => {
|
||||
let pong = ControlMessage::Pong {
|
||||
@@ -1772,11 +1922,8 @@ impl Runtime {
|
||||
}
|
||||
// What that peer can reach, from that peer. Kept with the time
|
||||
// it was heard, so it can go stale on its own.
|
||||
ControlMessage::Reachable { peers } => {
|
||||
let heard: HashSet<EndpointId> = peers
|
||||
.iter()
|
||||
.filter_map(|raw| EndpointId::from_bytes(raw).ok())
|
||||
.collect();
|
||||
ControlMessage::Reachable { links } => {
|
||||
let heard = links.iter().cloned().collect();
|
||||
self.reachable
|
||||
.insert(peer, (heard, std::time::Instant::now()));
|
||||
self.update_paths();
|
||||
@@ -1844,6 +1991,7 @@ impl Runtime {
|
||||
.map(|session| {
|
||||
let snapshot = snapshot_connection(&session.conn);
|
||||
PeerStatus {
|
||||
broadcast: session.broadcast,
|
||||
endpoint_id: session.peer,
|
||||
role: session.role,
|
||||
hostname: session.hostname.clone(),
|
||||
@@ -1911,6 +2059,7 @@ impl Runtime {
|
||||
members.dedup_by(|a, b| a.endpoint_id == b.endpoint_id);
|
||||
|
||||
NetworkStatus {
|
||||
broadcast: self.params.broadcast,
|
||||
descriptor: self.params.keys.descriptor(),
|
||||
name: self.params.keys.name().clone(),
|
||||
network_id: self.network_id,
|
||||
@@ -2035,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"));
|
||||
}
|
||||
}
|
||||
@@ -73,17 +73,19 @@ pub(crate) struct Session {
|
||||
pub(crate) outbound: mpsc::Sender<Vec<u8>>,
|
||||
pub(crate) hostname: Option<String>,
|
||||
pub(crate) capabilities: Vec<PluginCapability>,
|
||||
pub(crate) broadcast: bool,
|
||||
pub(crate) messages_sent: u64,
|
||||
pub(crate) messages_received: u64,
|
||||
pub(crate) bytes_sent: u64,
|
||||
pub(crate) bytes_received: u64,
|
||||
reader: JoinHandle<()>,
|
||||
writer: JoinHandle<()>,
|
||||
path_monitor: JoinHandle<()>,
|
||||
shutdown: Shutdown,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Signals both tasks to stop and waits for them, with a bounded grace
|
||||
/// Signals all tasks to stop and waits for them, with a bounded grace
|
||||
/// period.
|
||||
///
|
||||
/// A peer that stops reading must not be able to hold up shutdown, so the
|
||||
@@ -93,6 +95,7 @@ impl Session {
|
||||
conn,
|
||||
reader,
|
||||
writer,
|
||||
path_monitor,
|
||||
shutdown,
|
||||
..
|
||||
} = self;
|
||||
@@ -102,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,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,
|
||||
@@ -174,12 +227,14 @@ pub(crate) fn spawn(
|
||||
outbound: outbound_tx,
|
||||
hostname: None,
|
||||
capabilities: Vec::new(),
|
||||
broadcast: false,
|
||||
messages_sent: 0,
|
||||
messages_received: 0,
|
||||
bytes_sent: 0,
|
||||
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.
|
||||
@@ -81,6 +85,8 @@ pub struct MemberStatus {
|
||||
/// Status of one authenticated session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeerStatus {
|
||||
/// This authenticated peer accepts network broadcasts.
|
||||
pub broadcast: bool,
|
||||
/// Authenticated endpoint id.
|
||||
pub endpoint_id: EndpointId,
|
||||
/// Which side this agent played in the handshake.
|
||||
@@ -151,6 +157,8 @@ pub struct NetworkMetrics {
|
||||
/// Status of one network.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetworkStatus {
|
||||
/// Local broadcast participation in this network.
|
||||
pub broadcast: bool,
|
||||
/// Immutable deterministic description of the network space.
|
||||
pub descriptor: NetworkDescriptor,
|
||||
/// Network name, for convenience.
|
||||
|
||||
@@ -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,
|
||||
@@ -480,3 +515,5 @@ impl std::fmt::Debug for AgentConfig {
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
/// Maximum number of transport links a routed datagram may traverse.
|
||||
pub const ROUTING_HOP_LIMIT: u8 = 16;
|
||||
@@ -20,7 +20,9 @@
|
||||
//! recorded and surfaced, the control plane keeps running.
|
||||
|
||||
pub mod relay;
|
||||
pub mod routing;
|
||||
pub mod transport;
|
||||
pub mod tunnel;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -30,7 +32,10 @@ use tokio::sync::mpsc;
|
||||
use crate::BoxFuture;
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
pub use transport::{PacketLink, PacketTransport, SharedLink, TransportError};
|
||||
pub use transport::{
|
||||
MultiPacketTransport, PacketLink, PacketTransport, SharedLink, TransportError,
|
||||
};
|
||||
pub use tunnel::{GenericTunnelPlugin, TunnelCodec};
|
||||
|
||||
/// Maximum length of a plugin protocol identifier.
|
||||
pub const MAX_PROTOCOL_ID_LEN: usize = 32;
|
||||
@@ -114,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.
|
||||
@@ -126,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.
|
||||
@@ -178,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.
|
||||
@@ -219,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 {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,287 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
fn network() -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new("routing-tests").unwrap(),
|
||||
&NetworkSecret::from_bytes([3; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
fn peer(seed: u8) -> EndpointId {
|
||||
iroh::SecretKey::from_bytes(&[seed; 32]).public()
|
||||
}
|
||||
fn id(seed: u8) -> PeerId {
|
||||
*peer(seed).as_bytes()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Wire {
|
||||
peer: EndpointId,
|
||||
sent: Mutex<Vec<Bytes>>,
|
||||
feed: mpsc::Sender<Bytes>,
|
||||
inbound: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
|
||||
dead: AtomicBool,
|
||||
count: AtomicU64,
|
||||
record: bool,
|
||||
}
|
||||
impl Wire {
|
||||
fn new(seed: u8, record: bool) -> Arc<Self> {
|
||||
let (feed, inbound) = mpsc::channel(32);
|
||||
Arc::new(Self {
|
||||
peer: peer(seed),
|
||||
sent: Mutex::default(),
|
||||
feed,
|
||||
inbound: tokio::sync::Mutex::new(inbound),
|
||||
dead: AtomicBool::new(false),
|
||||
count: AtomicU64::new(0),
|
||||
record,
|
||||
})
|
||||
}
|
||||
fn take(&self) -> Vec<Bytes> {
|
||||
std::mem::take(&mut self.sent.lock().unwrap())
|
||||
}
|
||||
}
|
||||
impl PacketLink for Wire {
|
||||
fn network(&self) -> NetworkId {
|
||||
network()
|
||||
}
|
||||
fn peer(&self) -> EndpointId {
|
||||
self.peer
|
||||
}
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
MAX_DATA_DATAGRAM
|
||||
}
|
||||
fn send(&self, frame: Bytes) -> Result<(), TransportError> {
|
||||
if self.is_closed() {
|
||||
return Err(TransportError::Closed);
|
||||
}
|
||||
self.count.fetch_add(1, Ordering::Relaxed);
|
||||
if self.record {
|
||||
self.sent.lock().unwrap().push(frame);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move { self.inbound.lock().await.recv().await })
|
||||
}
|
||||
fn closed(&self) -> BoxFuture<'_, ()> {
|
||||
Box::pin(std::future::pending())
|
||||
}
|
||||
fn is_closed(&self) -> bool {
|
||||
self.dead.load(Ordering::Relaxed)
|
||||
}
|
||||
fn path_description(&self) -> String {
|
||||
"test-direct".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn topology(hub: &RelayHub, protocol: &str, edges: &[(u8, &[u8])], members: &[u8]) {
|
||||
hub.set_topology(
|
||||
protocol,
|
||||
edges
|
||||
.iter()
|
||||
.map(|(a, bs)| (id(*a), bs.iter().map(|b| id(*b)).collect()))
|
||||
.collect(),
|
||||
members.iter().map(|p| id(*p)).collect(),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ecmp_is_per_flow_direct_wins_and_existing_tunnel_survives_changes() {
|
||||
let hub = RelayHub::new(network(), peer(1));
|
||||
let b = Wire::new(2, true);
|
||||
let c = Wire::new(3, true);
|
||||
hub.set_direct(peer(2), "ip", b.clone());
|
||||
hub.set_direct(peer(3), "ip", c.clone());
|
||||
topology(&hub, "ip", &[(2, &[4]), (3, &[4])], &[1, 2, 3, 4]);
|
||||
let link = hub.link(peer(4), "ip");
|
||||
let mut chosen = HashMap::new();
|
||||
for _ in 0..8 {
|
||||
for flow in 0..100 {
|
||||
link.send_flow(Bytes::from_static(b"ciphertext"), flow)
|
||||
.unwrap();
|
||||
}
|
||||
for (hop, wire) in [(2, &b), (3, &c)] {
|
||||
for frame in wire.take() {
|
||||
let header = envelope::decode(&frame).unwrap();
|
||||
assert_eq!(header.source, id(1));
|
||||
assert_eq!(header.destination, id(4));
|
||||
if let Some(old) = chosen.insert(header.flow, hop) {
|
||||
assert_eq!(old, hop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
chosen.values().copied().collect::<HashSet<_>>(),
|
||||
HashSet::from([2, 3])
|
||||
);
|
||||
let direct = Wire::new(4, true);
|
||||
hub.set_direct(peer(4), "ip", direct.clone());
|
||||
link.send_flow(Bytes::from_static(b"direct"), 8).unwrap();
|
||||
assert_eq!(direct.take().len(), 1);
|
||||
assert!(b.take().is_empty() && c.take().is_empty());
|
||||
hub.clear_direct(peer(4), "ip");
|
||||
b.dead.store(true, Ordering::Relaxed);
|
||||
for flow in 0..100 {
|
||||
link.send_flow(Bytes::from_static(b"fallback"), flow)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(c.take().len(), 100);
|
||||
hub.clear_direct(peer(2), "ip");
|
||||
topology(&hub, "ip", &[(3, &[])], &[1, 2, 3, 4]);
|
||||
assert!(link.send(Bytes::new()).is_err());
|
||||
assert!(!link.is_closed());
|
||||
assert!(Arc::ptr_eq(&link, &hub.link(peer(4), "ip")));
|
||||
hub.remove_peer(peer(4));
|
||||
assert!(link.is_closed());
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), link.closed())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(link.recv().await.is_none());
|
||||
hub.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_reader_forwards_without_any_plugin_reader_and_isolates_protocols() {
|
||||
let hub = RelayHub::new(network(), peer(2));
|
||||
let a = Wire::new(1, true);
|
||||
let c = Wire::new(3, true);
|
||||
hub.set_direct(peer(1), "ip", a.clone());
|
||||
hub.set_direct(peer(3), "ip", c.clone());
|
||||
topology(&hub, "ip", &[(3, &[4])], &[1, 2, 3, 4]);
|
||||
let frame = envelope::encode(id(1), id(4), 91, b"end-to-end encrypted");
|
||||
let pointer = frame.as_ptr();
|
||||
a.feed.send(frame).await.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
while hub.counters().forwarded == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let sent = c.take();
|
||||
assert_eq!(sent[0].as_ptr(), pointer);
|
||||
assert_eq!(
|
||||
envelope::decode(&sent[0]).unwrap().remaining,
|
||||
ROUTING_HOP_LIMIT - 1
|
||||
);
|
||||
assert_eq!(&sent[0][RELAY_OVERHEAD..], b"end-to-end encrypted");
|
||||
let isolated = hub.link(peer(4), "other-ip");
|
||||
assert!(isolated.send(Bytes::new()).is_err());
|
||||
assert_eq!(hub.counters().forwarded, 1);
|
||||
hub.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_delivery_is_bounded_and_malformed_or_unknown_sources_are_dropped() {
|
||||
let hub = RelayHub::new(network(), peer(2));
|
||||
let a = Wire::new(1, true);
|
||||
hub.set_direct(peer(1), "ip", a);
|
||||
let link = hub.link(peer(1), "ip");
|
||||
let plane = link.plane.clone();
|
||||
for _ in 0..INBOX + 1 {
|
||||
plane.receive(id(1), envelope::encode(id(1), id(2), 0, b"ip"));
|
||||
}
|
||||
assert_eq!(hub.counters().dropped_congested, 1);
|
||||
for _ in 0..INBOX {
|
||||
assert_eq!(link.recv().await.unwrap(), Bytes::from_static(b"ip"));
|
||||
}
|
||||
plane.receive(id(1), Bytes::from_static(b"broken"));
|
||||
plane.receive(id(1), envelope::encode(id(9), id(2), 0, b"unknown member"));
|
||||
assert_eq!(hub.counters().dropped_unknown, 2);
|
||||
plane.receive(
|
||||
id(1),
|
||||
envelope::encode(id(1), id(9), 0, b"unknown destination"),
|
||||
);
|
||||
assert_eq!(hub.counters().dropped_no_link, 1);
|
||||
hub.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inconsistent_tables_cannot_loop_beyond_the_hop_limit() {
|
||||
let a = RelayHub::new(network(), peer(1));
|
||||
let b = RelayHub::new(network(), peer(2));
|
||||
let ab = Wire::new(2, true);
|
||||
let ba = Wire::new(1, true);
|
||||
a.set_direct(peer(2), "ip", ab.clone());
|
||||
b.set_direct(peer(1), "ip", ba.clone());
|
||||
// Deliberately inconsistent snapshots while updates are in flight.
|
||||
topology(&a, "ip", &[(2, &[4])], &[1, 2, 3, 4]);
|
||||
topology(&b, "ip", &[(1, &[4])], &[1, 2, 3, 4]);
|
||||
let ap = a.link(peer(4), "ip").plane.clone();
|
||||
let bp = b.link(peer(4), "ip").plane.clone();
|
||||
let mut frame = envelope::encode(id(3), id(4), 7, b"transit");
|
||||
for turn in 0..ROUTING_HOP_LIMIT {
|
||||
let (plane, incoming, wire) = if turn % 2 == 0 {
|
||||
(&ap, id(2), &ab)
|
||||
} else {
|
||||
(&bp, id(1), &ba)
|
||||
};
|
||||
plane.receive(incoming, frame);
|
||||
let sent = wire.take();
|
||||
if turn == ROUTING_HOP_LIMIT - 1 {
|
||||
assert!(sent.is_empty());
|
||||
break;
|
||||
}
|
||||
assert_eq!(sent.len(), 1);
|
||||
frame = sent.into_iter().next().unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
a.counters().forwarded + b.counters().forwarded,
|
||||
u64::from(ROUTING_HOP_LIMIT - 1)
|
||||
);
|
||||
assert_eq!(
|
||||
a.counters().dropped_hop_limit + b.counters().dropped_hop_limit,
|
||||
1
|
||||
);
|
||||
a.close();
|
||||
b.close();
|
||||
}
|
||||
|
||||
/// Measures the actual synchronous transit routine, including header checks,
|
||||
/// snapshot lookup, ECMP, in-place TTL update and mock transport submission.
|
||||
/// Packet construction, encryption and socket I/O are outside this measurement.
|
||||
#[tokio::test]
|
||||
#[ignore = "manual release-mode forwarding microbenchmark"]
|
||||
async fn forwarding_benchmark() {
|
||||
let hub = RelayHub::new(network(), peer(2));
|
||||
let wire = Wire::new(3, false);
|
||||
hub.set_direct(peer(3), "ip", wire.clone());
|
||||
let alternate = Wire::new(5, false);
|
||||
hub.set_direct(peer(5), "ip", alternate.clone());
|
||||
topology(&hub, "ip", &[(3, &[4]), (5, &[4])], &[1, 2, 3, 4, 5]);
|
||||
let plane = hub.link(peer(4), "ip").plane.clone();
|
||||
let source = id(1);
|
||||
let destination = id(4);
|
||||
let incoming = id(1);
|
||||
for size in [64, 1280] {
|
||||
let mut elapsed = std::time::Duration::ZERO;
|
||||
const COUNT: usize = 10000;
|
||||
const BATCHES: usize = 100;
|
||||
for _ in 0..BATCHES {
|
||||
let frames: Vec<_> = (0..COUNT)
|
||||
.map(|flow| envelope::encode(source, destination, flow as u64, &vec![42; size]))
|
||||
.collect();
|
||||
let start = std::time::Instant::now();
|
||||
for frame in frames {
|
||||
plane.receive(incoming, std::hint::black_box(frame));
|
||||
}
|
||||
elapsed += start.elapsed();
|
||||
}
|
||||
let count = (COUNT * BATCHES) as f64;
|
||||
println!(
|
||||
"transit {size} B: {:.1} ns/packet, {:.2} Mpps (1M packets, two equal paths)",
|
||||
elapsed.as_nanos() as f64 / count,
|
||||
count / elapsed.as_secs_f64() / 1e6
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
wire.count.load(Ordering::Relaxed) + alternate.count.load(Ordering::Relaxed),
|
||||
2000000
|
||||
);
|
||||
hub.close();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Fixed header. Transit changes one byte in an owned receive buffer.
|
||||
use super::{FlowId, PeerId};
|
||||
use crate::config::{MAX_DATA_DATAGRAM, ROUTING_HOP_LIMIT};
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
|
||||
pub(crate) const HEADER: usize = 2 + 32 + 32 + 8;
|
||||
const VERSION: u8 = 1;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Header {
|
||||
pub source: PeerId,
|
||||
pub destination: PeerId,
|
||||
pub remaining: u8,
|
||||
pub flow: FlowId,
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8]) -> Option<Header> {
|
||||
if bytes.len() < HEADER
|
||||
|| bytes.len() > MAX_DATA_DATAGRAM
|
||||
|| bytes[0] != VERSION
|
||||
|| bytes[1] == 0
|
||||
|| bytes[1] > ROUTING_HOP_LIMIT
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Header {
|
||||
source: bytes[2..34].try_into().ok()?,
|
||||
destination: bytes[34..66].try_into().ok()?,
|
||||
remaining: bytes[1],
|
||||
flow: u64::from_be_bytes(bytes[66..74].try_into().ok()?),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn encode(source: PeerId, destination: PeerId, flow: FlowId, payload: &[u8]) -> Bytes {
|
||||
let mut bytes = BytesMut::with_capacity(HEADER + payload.len());
|
||||
bytes.put_u8(VERSION);
|
||||
bytes.put_u8(ROUTING_HOP_LIMIT);
|
||||
bytes.extend_from_slice(&source);
|
||||
bytes.extend_from_slice(&destination);
|
||||
bytes.put_u64(flow);
|
||||
bytes.extend_from_slice(payload);
|
||||
bytes.freeze()
|
||||
}
|
||||
|
||||
pub(crate) fn decrement(bytes: Bytes) -> Bytes {
|
||||
let mut bytes = match bytes.try_into_mut() {
|
||||
Ok(bytes) => bytes,
|
||||
Err(bytes) => BytesMut::from(bytes.as_ref()),
|
||||
};
|
||||
// Called only after decode and after excluding remaining <= 1.
|
||||
bytes[1] -= 1;
|
||||
bytes.freeze()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
#[test]
|
||||
fn transit_reuses_the_buffer_and_rejects_invalid_headers() {
|
||||
let frame = encode([1; 32], [2; 32], 17, &[42; 1280]);
|
||||
let address = frame.as_ptr();
|
||||
let frame = decrement(frame);
|
||||
assert_eq!(
|
||||
frame.as_ptr(),
|
||||
address,
|
||||
"no payload copy for an owned buffer"
|
||||
);
|
||||
assert_eq!(decode(&frame).unwrap().remaining, ROUTING_HOP_LIMIT - 1);
|
||||
assert_eq!(&frame[HEADER..], &[42; 1280]);
|
||||
for length in 0..HEADER {
|
||||
assert!(decode(&frame[..length]).is_none());
|
||||
}
|
||||
for ttl in [0, ROUTING_HOP_LIMIT + 1] {
|
||||
let mut bad = frame.to_vec();
|
||||
bad[1] = ttl;
|
||||
assert!(decode(&bad).is_none());
|
||||
}
|
||||
let mut bad = frame.to_vec();
|
||||
bad[0] = 99;
|
||||
assert!(decode(&bad).is_none());
|
||||
assert!(decode(&vec![1; MAX_DATA_DATAGRAM + 1]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Optional IP flow classifier for IP plugins, called before encryption.
|
||||
//! The router itself treats this identifier and the payload as opaque.
|
||||
|
||||
use super::FlowId;
|
||||
|
||||
fn hash(parts: &[&[u8]]) -> FlowId {
|
||||
let mut value = 0xcbf29ce484222325u64;
|
||||
for part in parts {
|
||||
for byte in *part {
|
||||
value = (value ^ u64::from(*byte)).wrapping_mul(0x100000001b3);
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// Stable directional IP flow hash, independent of TCP sequence numbers,
|
||||
/// checksums and packet contents. Fragmented traffic uses addresses/protocol
|
||||
/// instead of ports: even non-initial fragments and subsequent fragmented
|
||||
/// datagrams stay together. This deliberately coalesces fragmented flows.
|
||||
/// Malformed packets get a coarse hash; parsing is bounded and never allocates.
|
||||
pub fn ip_flow(packet: &[u8]) -> FlowId {
|
||||
match packet.first().map(|b| b >> 4) {
|
||||
Some(4) if packet.len() >= 20 => {
|
||||
let addresses = &packet[12..20];
|
||||
let protocol = &packet[9..10];
|
||||
let offset = usize::from(packet[0] & 15) * 4;
|
||||
if u16::from_be_bytes([packet[6], packet[7]]) & 0x3fff != 0 {
|
||||
return hash(&[addresses, protocol]);
|
||||
}
|
||||
if offset >= 20
|
||||
&& matches!(packet[9], 6 | 17)
|
||||
&& let Some(ports) = packet.get(offset..offset + 4)
|
||||
{
|
||||
return hash(&[addresses, protocol, ports]);
|
||||
}
|
||||
hash(&[addresses, protocol])
|
||||
}
|
||||
Some(6) if packet.len() >= 40 => {
|
||||
let addresses = &packet[8..40];
|
||||
let mut protocol = packet[6];
|
||||
let mut offset = 40;
|
||||
for _ in 0..8 {
|
||||
match protocol {
|
||||
44 => {
|
||||
if let Some(fragment) = packet.get(offset..offset + 8) {
|
||||
return hash(&[addresses, &fragment[..1]]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
0 | 43 | 60 | 51 => {
|
||||
let Some(header) = packet.get(offset..offset + 2) else {
|
||||
break;
|
||||
};
|
||||
let size = if protocol == 51 {
|
||||
(usize::from(header[1]) + 2) * 4
|
||||
} else {
|
||||
(usize::from(header[1]) + 1) * 8
|
||||
};
|
||||
protocol = header[0];
|
||||
offset += size;
|
||||
}
|
||||
6 | 17 => {
|
||||
if let Some(ports) = packet.get(offset..offset + 4) {
|
||||
return hash(&[addresses, &[protocol], ports]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
hash(&[addresses, &[protocol]])
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tcp_flow_ignores_packet_contents_but_distinguishes_ports() {
|
||||
let mut packet = [0u8; 80];
|
||||
packet[0] = 0x45;
|
||||
packet[9] = 6;
|
||||
packet[12..24].copy_from_slice(&[10, 0, 0, 1, 10, 0, 0, 2, 0, 22, 1, 2]);
|
||||
let flow = ip_flow(&packet);
|
||||
packet[4] = 123; // IP id changes for each unfragmented packet.
|
||||
packet[24..].fill(47);
|
||||
assert_eq!(flow, ip_flow(&packet));
|
||||
packet[23] = 3;
|
||||
assert_ne!(flow, ip_flow(&packet));
|
||||
packet[6] = 0x20;
|
||||
let fragment_flow = ip_flow(&packet);
|
||||
packet[6] = 0;
|
||||
packet[7] = 1;
|
||||
packet[20..].fill(99);
|
||||
assert_eq!(fragment_flow, ip_flow(&packet));
|
||||
packet[4] = 97;
|
||||
assert_eq!(fragment_flow, ip_flow(&packet));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_extensions_and_fragments_are_bounded_and_stable() {
|
||||
let mut packet = [0u8; 80];
|
||||
packet[0] = 0x60;
|
||||
packet[6] = 0;
|
||||
packet[40] = 17;
|
||||
packet[48..52].copy_from_slice(&[1, 2, 3, 4]);
|
||||
let flow = ip_flow(&packet);
|
||||
packet[52..].fill(5);
|
||||
assert_eq!(flow, ip_flow(&packet));
|
||||
packet[50] = 9;
|
||||
assert_ne!(flow, ip_flow(&packet));
|
||||
packet[6] = 44;
|
||||
packet[40] = 6;
|
||||
let flow = ip_flow(&packet);
|
||||
packet[42] = 32;
|
||||
packet[48..].fill(3);
|
||||
assert_eq!(flow, ip_flow(&packet));
|
||||
for len in 0..packet.len() {
|
||||
let _ = ip_flow(&packet[..len]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Transport-independent routing over opaque identities. Tables are built on
|
||||
//! topology changes, never on the packet path. No WireGuard or iroh APIs here.
|
||||
pub(crate) mod envelope;
|
||||
pub mod flow;
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
|
||||
/// Opaque authenticated identity; the transport adapter validates its keys.
|
||||
pub type PeerId = [u8; 32];
|
||||
/// Stable flow identifier, carried unchanged between transit routers.
|
||||
pub type FlowId = u64;
|
||||
|
||||
/// A shortest route with all equal-cost first hops in stable order.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Route {
|
||||
/// Number of transport links to the destination.
|
||||
pub hops: u8,
|
||||
/// Equal-cost first hops. Direct destinations have one entry.
|
||||
pub next_hops: Box<[PeerId]>,
|
||||
}
|
||||
|
||||
/// Immutable shortest-path table, replaced when connectivity changes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RoutingTable {
|
||||
routes: HashMap<PeerId, Route>,
|
||||
}
|
||||
|
||||
impl RoutingTable {
|
||||
/// Computes shortest directed paths bounded by `hop_limit`.
|
||||
/// The caller supplies only authenticated, fresh, compatible edges.
|
||||
pub fn build(local: PeerId, graph: &HashMap<PeerId, Vec<PeerId>>, hop_limit: u8) -> Self {
|
||||
let mut distances = HashMap::from([(local, 0u8)]);
|
||||
let mut first_hops: HashMap<PeerId, BTreeSet<PeerId>> = HashMap::new();
|
||||
let mut pending = VecDeque::from([local]);
|
||||
while let Some(node) = pending.pop_front() {
|
||||
let distance = distances[&node];
|
||||
if distance >= hop_limit {
|
||||
continue;
|
||||
}
|
||||
let inherited = first_hops.get(&node).cloned().unwrap_or_default();
|
||||
for &next in graph.get(&node).into_iter().flatten() {
|
||||
let next_distance = distance + 1;
|
||||
match distances.get(&next) {
|
||||
Some(&known) if known < next_distance => continue,
|
||||
None => {
|
||||
distances.insert(next, next_distance);
|
||||
pending.push_back(next);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let hops = first_hops.entry(next).or_default();
|
||||
if node == local {
|
||||
hops.insert(next);
|
||||
} else {
|
||||
hops.extend(inherited.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
routes: first_hops
|
||||
.into_iter()
|
||||
.map(|(peer, hops)| {
|
||||
(
|
||||
peer,
|
||||
Route {
|
||||
hops: distances[&peer],
|
||||
next_hops: hops.into_iter().collect(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
/// Precomputed route to a destination.
|
||||
pub fn get(&self, destination: &PeerId) -> Option<&Route> {
|
||||
self.routes.get(destination)
|
||||
}
|
||||
/// All reachable destinations, excluding ourselves.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&PeerId, &Route)> {
|
||||
self.routes.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable ECMP mixing, without random state or hashing the payload per hop.
|
||||
pub(crate) fn mix64(mut value: u64) -> u64 {
|
||||
value ^= value >> 30;
|
||||
value = value.wrapping_mul(0xbf58476d1ce4e5b9);
|
||||
value ^= value >> 27;
|
||||
value = value.wrapping_mul(0x94d049bb133111eb);
|
||||
value ^ (value >> 31)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
fn p(n: u8) -> PeerId {
|
||||
[n; 32]
|
||||
}
|
||||
#[test]
|
||||
fn shortest_paths_keep_equal_cost_hops_and_prefer_direct_links() {
|
||||
let mut graph = HashMap::from([
|
||||
(p(1), vec![p(3), p(2)]),
|
||||
(p(2), vec![p(4)]),
|
||||
(p(3), vec![p(4)]),
|
||||
(p(4), vec![p(5)]),
|
||||
(p(5), vec![p(1)]),
|
||||
]);
|
||||
let routes = RoutingTable::build(p(1), &graph, 16);
|
||||
assert_eq!(routes.get(&p(5)).unwrap().hops, 3);
|
||||
assert_eq!(&*routes.get(&p(5)).unwrap().next_hops, &[p(2), p(3)]);
|
||||
assert!(routes.get(&p(1)).is_none());
|
||||
assert!(routes.get(&p(9)).is_none());
|
||||
graph.get_mut(&p(1)).unwrap().push(p(5));
|
||||
assert_eq!(
|
||||
RoutingTable::build(p(1), &graph, 16).get(&p(5)).unwrap(),
|
||||
&Route {
|
||||
hops: 1,
|
||||
next_hops: Box::new([p(5)])
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn topology_removal_and_hop_budget_remove_impossible_routes() {
|
||||
let mut graph = HashMap::from([(p(1), vec![p(2)]), (p(2), vec![p(3)]), (p(3), vec![p(4)])]);
|
||||
assert!(RoutingTable::build(p(1), &graph, 2).get(&p(4)).is_none());
|
||||
assert!(RoutingTable::build(p(1), &graph, 3).get(&p(4)).is_some());
|
||||
graph.remove(&p(2));
|
||||
assert!(RoutingTable::build(p(1), &graph, 16).get(&p(4)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@
|
||||
mod fragments;
|
||||
pub mod iroh_link;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
|
||||
@@ -88,6 +90,16 @@ pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// handed to the transport, nothing more.
|
||||
fn send(&self, payload: Bytes) -> Result<(), TransportError>;
|
||||
|
||||
/// Sends a datagram with an opaque stable flow identifier. Plugins derive
|
||||
/// this before encryption; transports that do not route can ignore it.
|
||||
fn send_flow(
|
||||
&self,
|
||||
payload: Bytes,
|
||||
_flow: super::routing::FlowId,
|
||||
) -> Result<(), TransportError> {
|
||||
self.send(payload)
|
||||
}
|
||||
|
||||
/// Receives the next datagram, or `None` once the link is finished.
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>>;
|
||||
|
||||
@@ -145,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
|
||||
@@ -92,6 +126,13 @@ pub fn control_socket_path(state_dir: &Path) -> PathBuf {
|
||||
pub enum Request {
|
||||
/// Report what the agent is doing.
|
||||
Status,
|
||||
/// Set local broadcast participation in one configured network.
|
||||
SetBroadcast {
|
||||
/// Public network identifier.
|
||||
network_id: String,
|
||||
/// Whether to originate and accept broadcasts.
|
||||
enabled: bool,
|
||||
},
|
||||
/// Answer to a different name from now on.
|
||||
///
|
||||
/// Applied by the running agent rather than written behind its back, so
|
||||
@@ -135,6 +176,9 @@ pub enum Request {
|
||||
/// It travels over a socket only its owner can open, to the agent
|
||||
/// that stores it anyway, and never appears in `Debug`.
|
||||
secret: String,
|
||||
/// Explicit choice, or preserve the saved setting.
|
||||
#[serde(default)]
|
||||
broadcast: Option<bool>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -142,6 +186,10 @@ impl std::fmt::Debug for Request {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Request::Status => f.write_str("Status"),
|
||||
Request::SetBroadcast {
|
||||
network_id,
|
||||
enabled,
|
||||
} => write!(f, "SetBroadcast {{ {network_id}, enabled: {enabled} }}"),
|
||||
Request::SetHostname(name) => write!(f, "SetHostname({name})"),
|
||||
Request::Leave(network) => write!(f, "Leave({network})"),
|
||||
Request::SetActive { network_id, active } => {
|
||||
@@ -162,6 +210,8 @@ impl std::fmt::Debug for Request {
|
||||
pub enum Response {
|
||||
/// A status report.
|
||||
Status(Box<StatusReport>),
|
||||
/// Accepted local broadcast participation.
|
||||
Broadcast(bool),
|
||||
/// The name the agent now answers to, after reducing it to canonical form.
|
||||
Hostname(String),
|
||||
/// A network was left.
|
||||
@@ -274,6 +324,9 @@ pub struct DnsReport {
|
||||
/// One network.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NetworkReport {
|
||||
/// Whether this agent participates in LAN broadcasts in this network.
|
||||
#[serde(default)]
|
||||
pub broadcast: bool,
|
||||
/// Network name.
|
||||
pub name: String,
|
||||
/// Public network identifier.
|
||||
@@ -367,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.
|
||||
@@ -375,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>,
|
||||
@@ -455,10 +525,20 @@ pub trait ReportSource: Send + Sync + 'static {
|
||||
&self,
|
||||
_name: String,
|
||||
_secret: String,
|
||||
_broadcast: Option<bool>,
|
||||
) -> BoxFuture<'_, std::result::Result<JoinedReport, String>> {
|
||||
Box::pin(async move { Err("this agent cannot join a network".to_string()) })
|
||||
}
|
||||
|
||||
/// Persists and applies local broadcast participation.
|
||||
fn set_broadcast(
|
||||
&self,
|
||||
_network_id: String,
|
||||
_enabled: bool,
|
||||
) -> BoxFuture<'_, std::result::Result<bool, String>> {
|
||||
Box::pin(async move { Err("this agent cannot change broadcast participation".into()) })
|
||||
}
|
||||
|
||||
/// Stops serving a network, or starts serving it again.
|
||||
///
|
||||
/// Defaulted to a refusal, like the others.
|
||||
@@ -510,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', 13]);
|
||||
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.
|
||||
///
|
||||
@@ -535,6 +615,13 @@ where
|
||||
};
|
||||
let response = match request {
|
||||
Request::Status => Response::Status(Box::new(source.report().await)),
|
||||
Request::SetBroadcast {
|
||||
network_id,
|
||||
enabled,
|
||||
} => match source.set_broadcast(network_id, enabled).await {
|
||||
Ok(enabled) => Response::Broadcast(enabled),
|
||||
Err(error) => Response::Error(error),
|
||||
},
|
||||
Request::SetHostname(hostname) => match source.set_hostname(hostname).await {
|
||||
Ok(accepted) => Response::Hostname(accepted),
|
||||
Err(reason) => Response::Error(reason),
|
||||
@@ -543,7 +630,11 @@ where
|
||||
Ok(report) => Response::Left(report),
|
||||
Err(reason) => Response::Error(reason),
|
||||
},
|
||||
Request::Join { name, secret } => match source.join(name, secret).await {
|
||||
Request::Join {
|
||||
name,
|
||||
secret,
|
||||
broadcast,
|
||||
} => match source.join(name, secret, broadcast).await {
|
||||
Ok(report) => Response::Joined(report),
|
||||
Err(reason) => Response::Error(reason),
|
||||
},
|
||||
@@ -636,9 +727,21 @@ pub async fn join_network(
|
||||
path: impl AsRef<Path>,
|
||||
name: &str,
|
||||
secret: &str,
|
||||
) -> Result<JoinedReport> {
|
||||
let path = path.as_ref();
|
||||
join_network_with_broadcast(path, name, secret, None).await
|
||||
}
|
||||
|
||||
/// Joins with an explicit per-network broadcast choice.
|
||||
pub async fn join_network_with_broadcast(
|
||||
path: impl AsRef<Path>,
|
||||
name: &str,
|
||||
secret: &str,
|
||||
broadcast: Option<bool>,
|
||||
) -> Result<JoinedReport> {
|
||||
let path = path.as_ref();
|
||||
let request = Request::Join {
|
||||
broadcast,
|
||||
name: name.to_string(),
|
||||
secret: secret.to_string(),
|
||||
};
|
||||
@@ -649,6 +752,24 @@ pub async fn join_network(
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes one network's broadcast participation while the agent runs.
|
||||
pub async fn set_broadcast(
|
||||
path: impl AsRef<Path>,
|
||||
network_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<bool> {
|
||||
let path = path.as_ref();
|
||||
let request = Request::SetBroadcast {
|
||||
network_id: network_id.to_owned(),
|
||||
enabled,
|
||||
};
|
||||
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
|
||||
Response::Broadcast(enabled) => Ok(enabled),
|
||||
Response::Error(message) => Err(Error::Storage(message)),
|
||||
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks a running agent to stop serving a network, or to serve it again.
|
||||
pub async fn set_active(
|
||||
path: impl AsRef<Path>,
|
||||
|
||||
@@ -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,228 @@
|
||||
//! Scoped LAN discovery fanout at IP ingress. A domain is one logical network;
|
||||
//! routed copies use ordinary encrypted peer links and are never reflooded.
|
||||
//! Future subnet exporters must supply explicitly authorized ingress domains
|
||||
//! here, not teach the encrypted transit router to inspect application bytes.
|
||||
|
||||
use super::router::{NetworkRoutes, Route};
|
||||
use crate::NetworkId;
|
||||
use iroh::EndpointId;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Local participation and the currently authenticated participants in a domain.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BroadcastPolicy {
|
||||
/// Whether this agent originates and accepts LAN broadcasts in this network.
|
||||
pub enabled: bool,
|
||||
/// Live members explicitly advertising that they accept broadcasts.
|
||||
pub peers: HashSet<EndpointId>,
|
||||
}
|
||||
impl Default for BroadcastPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
peers: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Domain {
|
||||
network: NetworkId,
|
||||
directed: Ipv4Addr,
|
||||
enabled: bool,
|
||||
recipients: Arc<[Route]>,
|
||||
members: HashSet<EndpointId>,
|
||||
}
|
||||
|
||||
/// Immutable ingress-domain index rebuilt with address/policy changes.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct BroadcastTable {
|
||||
sources: HashMap<Ipv4Addr, Arc<Domain>>,
|
||||
networks: HashMap<NetworkId, Arc<Domain>>,
|
||||
destinations: HashSet<Ipv4Addr>,
|
||||
}
|
||||
impl BroadcastTable {
|
||||
pub fn build(networks: &HashMap<NetworkId, NetworkRoutes>) -> Self {
|
||||
let mut table = Self::default();
|
||||
for (&network, routes) in networks {
|
||||
let (Some(range), Some(local)) = (routes.range, routes.local) else {
|
||||
continue;
|
||||
};
|
||||
if range.prefix_len > 30 {
|
||||
continue;
|
||||
}
|
||||
let directed = Ipv4Addr::from(u32::from(range.base) | (u32::MAX >> range.prefix_len));
|
||||
let mut recipients: Vec<_> = routes
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|(_, peer)| routes.broadcast.peers.contains(peer))
|
||||
.map(|(_, peer)| Route {
|
||||
network,
|
||||
peer: *peer,
|
||||
})
|
||||
.collect();
|
||||
recipients.sort_by_key(|route| route.peer);
|
||||
recipients.dedup();
|
||||
let domain = Arc::new(Domain {
|
||||
network,
|
||||
directed,
|
||||
enabled: routes.broadcast.enabled,
|
||||
members: recipients.iter().map(|route| route.peer).collect(),
|
||||
recipients: recipients.into(),
|
||||
});
|
||||
table.sources.insert(local, domain.clone());
|
||||
table.networks.insert(network, domain);
|
||||
table.destinations.insert(directed);
|
||||
}
|
||||
table
|
||||
}
|
||||
pub fn is_destination(&self, destination: Ipv4Addr) -> bool {
|
||||
destination.is_broadcast() || self.destinations.contains(&destination)
|
||||
}
|
||||
pub fn outgoing(&self, source: Ipv4Addr, destination: Ipv4Addr) -> Option<Arc<[Route]>> {
|
||||
let domain = self.sources.get(&source)?;
|
||||
(domain.enabled && (destination.is_broadcast() || destination == domain.directed))
|
||||
.then(|| domain.recipients.clone())
|
||||
}
|
||||
pub fn accepts(&self, network: NetworkId, peer: EndpointId, destination: Ipv4Addr) -> bool {
|
||||
self.networks.get(&network).is_some_and(|domain| {
|
||||
domain.network == network
|
||||
&& domain.enabled
|
||||
&& domain.members.contains(&peer)
|
||||
&& (destination.is_broadcast() || destination == domain.directed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates IPv4/UDP lengths before broadcast fanout. No game-specific ports,
|
||||
/// payload rewriting or checksum changes. IP fragments retain their bytes and
|
||||
/// are reassembled by the destination OS; even later fragments have protocol 17.
|
||||
pub(crate) fn valid_udp(packet: &[u8]) -> bool {
|
||||
if packet.len() < 20 || packet[0] >> 4 != 4 || packet[9] != 17 {
|
||||
return false;
|
||||
}
|
||||
let header = usize::from(packet[0] & 15) * 4;
|
||||
let total = usize::from(u16::from_be_bytes([packet[2], packet[3]]));
|
||||
if header < 20 || total <= header || total > packet.len() {
|
||||
return false;
|
||||
}
|
||||
let fragment = u16::from_be_bytes([packet[6], packet[7]]);
|
||||
if fragment & 0x1fff != 0 {
|
||||
return true;
|
||||
}
|
||||
if total < header + 8 {
|
||||
return false;
|
||||
}
|
||||
let udp = usize::from(u16::from_be_bytes([packet[header + 4], packet[header + 5]]));
|
||||
udp >= 8
|
||||
&& if fragment & 0x2000 != 0 {
|
||||
udp >= total - header
|
||||
} else {
|
||||
udp == total - header
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
fn id(name: &str) -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new(name).unwrap(),
|
||||
&NetworkSecret::from_bytes([4; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
#[test]
|
||||
fn limited_and_directed_broadcasts_never_cross_domains_or_target_disabled_peers() {
|
||||
let peer = iroh::SecretKey::from_bytes(&[8; 32]).public();
|
||||
let a = NetworkRoutes {
|
||||
range: Some("10.0.0.0/24".parse().unwrap()),
|
||||
local: Some("10.0.0.1".parse().unwrap()),
|
||||
peers: vec![("10.0.0.2".parse().unwrap(), peer)],
|
||||
broadcast: BroadcastPolicy {
|
||||
enabled: true,
|
||||
peers: HashSet::from([peer]),
|
||||
},
|
||||
};
|
||||
let b = NetworkRoutes {
|
||||
range: Some("10.1.0.0/24".parse().unwrap()),
|
||||
local: Some("10.1.0.1".parse().unwrap()),
|
||||
peers: vec![("10.1.0.2".parse().unwrap(), peer)],
|
||||
broadcast: BroadcastPolicy::default(),
|
||||
};
|
||||
let table = BroadcastTable::build(&HashMap::from([(id("a"), a.clone()), (id("b"), b)]));
|
||||
let routes = table
|
||||
.outgoing(a.local.unwrap(), Ipv4Addr::BROADCAST)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&*routes,
|
||||
&[Route {
|
||||
network: id("a"),
|
||||
peer
|
||||
}]
|
||||
);
|
||||
assert!(
|
||||
table
|
||||
.outgoing(a.local.unwrap(), "10.1.0.255".parse().unwrap())
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
table
|
||||
.outgoing("192.168.1.2".parse().unwrap(), Ipv4Addr::BROADCAST)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
table
|
||||
.outgoing("10.1.0.1".parse().unwrap(), Ipv4Addr::BROADCAST)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(!table.accepts(id("a"), peer, "10.1.0.255".parse().unwrap()));
|
||||
assert!(!table.accepts(id("b"), peer, Ipv4Addr::BROADCAST));
|
||||
assert!(table.accepts(id("a"), peer, Ipv4Addr::BROADCAST));
|
||||
let disabled = NetworkRoutes {
|
||||
broadcast: BroadcastPolicy {
|
||||
enabled: false,
|
||||
..a.broadcast.clone()
|
||||
},
|
||||
..a.clone()
|
||||
};
|
||||
let table = BroadcastTable::build(&HashMap::from([(id("a"), disabled)]));
|
||||
assert!(
|
||||
table
|
||||
.outgoing(a.local.unwrap(), Ipv4Addr::BROADCAST)
|
||||
.is_none()
|
||||
);
|
||||
assert!(!table.accepts(id("a"), peer, Ipv4Addr::BROADCAST));
|
||||
}
|
||||
#[test]
|
||||
fn malformed_udp_is_not_amplified_but_ipv4_fragments_are_supported() {
|
||||
let mut packet = vec![0u8; 36];
|
||||
packet[0] = 0x45;
|
||||
packet[3] = 36;
|
||||
packet[9] = 17;
|
||||
packet[25] = 16;
|
||||
assert!(valid_udp(&packet));
|
||||
for length in 0..36 {
|
||||
assert!(!valid_udp(&packet[..length]));
|
||||
}
|
||||
packet[9] = 6;
|
||||
assert!(!valid_udp(&packet));
|
||||
packet[9] = 17;
|
||||
packet[0] = 0x44;
|
||||
assert!(!valid_udp(&packet));
|
||||
packet[0] = 0x45;
|
||||
packet[25] = 8;
|
||||
assert!(!valid_udp(&packet));
|
||||
packet[6] = 0x20;
|
||||
packet[25] = 64;
|
||||
assert!(valid_udp(&packet));
|
||||
packet[6] = 0;
|
||||
packet[7] = 2;
|
||||
assert!(valid_udp(&packet));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -53,6 +54,10 @@ pub enum Rejected {
|
||||
WrongFamily,
|
||||
/// The sending peer does not hold the source address it used.
|
||||
WrongSource,
|
||||
/// Broadcast disabled, outside this domain, or malformed/non-UDP.
|
||||
BroadcastDenied,
|
||||
/// The packet terminates outside this host's network address/domain.
|
||||
WrongDestination,
|
||||
}
|
||||
|
||||
/// Counters for one interface.
|
||||
@@ -60,6 +65,12 @@ pub enum Rejected {
|
||||
pub struct Counters {
|
||||
/// Packets handed to a protocol.
|
||||
pub sent: u64,
|
||||
/// Broadcast copies handed to recipient tunnels.
|
||||
pub broadcast_sent: u64,
|
||||
/// Broadcast packets admitted to this host.
|
||||
pub broadcast_received: u64,
|
||||
/// Broadcasts refused by domain policy or packet validation.
|
||||
pub broadcast_dropped: u64,
|
||||
/// Packets written to the operating system.
|
||||
pub received: u64,
|
||||
/// Packets for an address nobody in any network holds.
|
||||
@@ -68,10 +79,12 @@ pub struct Counters {
|
||||
pub unroutable_sample: Option<IpAddr>,
|
||||
/// Packets nothing could carry, though their destination was known.
|
||||
pub undeliverable: u64,
|
||||
/// Multicast and broadcast packets, which the overlay does not carry.
|
||||
/// Unsupported multicast or unspecified-destination packets.
|
||||
pub multicast: u64,
|
||||
/// Packets from a peer that does not hold the source address used.
|
||||
pub wrong_source: u64,
|
||||
/// Decrypted packets addressed outside the receiving network's local host.
|
||||
pub wrong_destination: u64,
|
||||
/// Packets that could not be read at all.
|
||||
pub malformed: u64,
|
||||
}
|
||||
@@ -79,11 +92,15 @@ pub struct Counters {
|
||||
#[derive(Debug, Default)]
|
||||
struct Tally {
|
||||
sent: AtomicU64,
|
||||
broadcast_sent: AtomicU64,
|
||||
broadcast_received: AtomicU64,
|
||||
broadcast_dropped: AtomicU64,
|
||||
received: AtomicU64,
|
||||
unroutable: AtomicU64,
|
||||
undeliverable: AtomicU64,
|
||||
multicast: AtomicU64,
|
||||
wrong_source: AtomicU64,
|
||||
wrong_destination: AtomicU64,
|
||||
malformed: AtomicU64,
|
||||
sample: std::sync::Mutex<Option<IpAddr>>,
|
||||
}
|
||||
@@ -92,6 +109,9 @@ impl Tally {
|
||||
fn snapshot(&self) -> Counters {
|
||||
Counters {
|
||||
sent: self.sent.load(Ordering::Relaxed),
|
||||
broadcast_sent: self.broadcast_sent.load(Ordering::Relaxed),
|
||||
broadcast_received: self.broadcast_received.load(Ordering::Relaxed),
|
||||
broadcast_dropped: self.broadcast_dropped.load(Ordering::Relaxed),
|
||||
received: self.received.load(Ordering::Relaxed),
|
||||
unroutable: self.unroutable.load(Ordering::Relaxed),
|
||||
unroutable_sample: match self.sample.lock() {
|
||||
@@ -101,6 +121,7 @@ impl Tally {
|
||||
undeliverable: self.undeliverable.load(Ordering::Relaxed),
|
||||
multicast: self.multicast.load(Ordering::Relaxed),
|
||||
wrong_source: self.wrong_source.load(Ordering::Relaxed),
|
||||
wrong_destination: self.wrong_destination.load(Ordering::Relaxed),
|
||||
malformed: self.malformed.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
@@ -132,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 {
|
||||
@@ -144,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 = {
|
||||
@@ -158,6 +192,27 @@ impl Interface {
|
||||
continue;
|
||||
};
|
||||
let destination = header.destination();
|
||||
if let (IpAddr::V4(source), IpAddr::V4(destination)) =
|
||||
(header.source(), destination)
|
||||
&& routes.is_broadcast(destination)
|
||||
{
|
||||
let recipients = super::broadcast::valid_udp(&packet)
|
||||
.then(|| routes.broadcast_recipients(source, destination))
|
||||
.flatten();
|
||||
if let Some(recipients) = recipients {
|
||||
for &route in recipients.iter() {
|
||||
if carrier.carry(route, packet.clone()) {
|
||||
tally.sent.fetch_add(1, Ordering::Relaxed);
|
||||
tally.broadcast_sent.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
tally.undeliverable.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tally.broadcast_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_multicast_or_broadcast(destination) {
|
||||
// The operating system emits these on any interface.
|
||||
// The overlay is a set of point-to-point tunnels and
|
||||
@@ -187,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() {
|
||||
@@ -245,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.
|
||||
@@ -286,7 +422,31 @@ impl Interface {
|
||||
self.tally.wrong_source.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(Rejected::WrongSource);
|
||||
}
|
||||
let broadcast = match header.destination() {
|
||||
IpAddr::V4(destination) if self.routes.is_broadcast(destination) => {
|
||||
if !super::broadcast::valid_udp(&packet)
|
||||
|| !self.routes.accepts_broadcast(network, peer, destination)
|
||||
{
|
||||
self.tally.broadcast_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(Rejected::BroadcastDenied);
|
||||
}
|
||||
true
|
||||
}
|
||||
IpAddr::V4(destination) if self.routes.is_local_destination(network, destination) => {
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
self.tally.wrong_destination.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(Rejected::WrongDestination);
|
||||
}
|
||||
};
|
||||
// Remote broadcasts terminate here. Only local TUN ingress can fan out.
|
||||
if self.device.send(packet).await.is_ok() {
|
||||
if broadcast {
|
||||
self.tally
|
||||
.broadcast_received
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
self.tally.received.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Ok(())
|
||||
@@ -362,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};
|
||||
|
||||
@@ -393,6 +556,73 @@ mod tests {
|
||||
Bytes::from(packet)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn broadcast_ingress_is_validated_and_remote_delivery_never_refloods() {
|
||||
use super::super::broadcast::BroadcastPolicy;
|
||||
use std::collections::HashSet;
|
||||
let (interface, device, routes, carrier, id) = interface(false).await;
|
||||
let enabled = BroadcastPolicy {
|
||||
enabled: true,
|
||||
peers: HashSet::from([peer(2)]),
|
||||
};
|
||||
routes.set_broadcast(id, enabled.clone());
|
||||
let packet = ipv4(addr(2), Ipv4Addr::BROADCAST, &[0, 1, 0, 2, 0, 8, 0, 0]);
|
||||
interface
|
||||
.deliver(id, peer(2), packet.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(device.pop_to_os().await.unwrap(), packet);
|
||||
assert!(
|
||||
carrier.carried().is_empty(),
|
||||
"remote ingress never originates a fanout"
|
||||
);
|
||||
assert_eq!(
|
||||
interface.deliver(id, peer(3), packet.clone()).await,
|
||||
Err(Rejected::WrongSource)
|
||||
);
|
||||
assert_eq!(
|
||||
interface
|
||||
.deliver(id, peer(2), ipv4(addr(2), Ipv4Addr::BROADCAST, b"bad"))
|
||||
.await,
|
||||
Err(Rejected::BroadcastDenied)
|
||||
);
|
||||
routes.set_broadcast(
|
||||
id,
|
||||
BroadcastPolicy {
|
||||
enabled: false,
|
||||
..enabled
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
interface.deliver(id, peer(2), packet).await,
|
||||
Err(Rejected::BroadcastDenied)
|
||||
);
|
||||
// An authenticated sender cannot use TUN as a gateway to a physical LAN.
|
||||
assert_eq!(
|
||||
interface
|
||||
.deliver(
|
||||
id,
|
||||
peer(2),
|
||||
ipv4(
|
||||
addr(2),
|
||||
"192.168.1.255".parse().unwrap(),
|
||||
&[0, 1, 0, 2, 0, 8, 0, 0]
|
||||
)
|
||||
)
|
||||
.await,
|
||||
Err(Rejected::WrongDestination)
|
||||
);
|
||||
device.push_from_os(ipv4(
|
||||
addr(1),
|
||||
Ipv4Addr::BROADCAST,
|
||||
&[0, 1, 0, 2, 0, 8, 0, 0],
|
||||
));
|
||||
wait_for(&interface, |c| c.broadcast_dropped.saturating_sub(2)).await;
|
||||
assert!(carrier.carried().is_empty());
|
||||
assert_eq!(interface.counters().received, 1);
|
||||
interface.remove().await;
|
||||
}
|
||||
|
||||
/// Records what it was asked to carry, and can refuse.
|
||||
#[derive(Debug, Default)]
|
||||
struct Recorder {
|
||||
@@ -437,6 +667,7 @@ mod tests {
|
||||
.set_network(
|
||||
id,
|
||||
NetworkRoutes {
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
local: Some(addr(1)),
|
||||
peers: vec![(addr(2), peer(2)), (addr(3), peer(3))],
|
||||
@@ -602,6 +833,7 @@ mod tests {
|
||||
.set_network(
|
||||
id,
|
||||
NetworkRoutes {
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
local: Some(addr(9)),
|
||||
peers: vec![(addr(2), peer(2))],
|
||||
@@ -613,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.
|
||||
@@ -36,7 +38,9 @@ pub enum OverlayError {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
pub mod broadcast;
|
||||
pub mod config;
|
||||
pub mod hostrules;
|
||||
pub mod interface;
|
||||
pub mod packet;
|
||||
pub mod provision;
|
||||
@@ -44,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::{
|
||||
@@ -54,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
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
//! carries traffic for the same addresses and the table is the same whichever
|
||||
//! one is in use.
|
||||
|
||||
use super::broadcast::{BroadcastPolicy, BroadcastTable};
|
||||
use arc_swap::ArcSwap;
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use iroh::EndpointId;
|
||||
|
||||
@@ -41,6 +43,8 @@ pub struct Route {
|
||||
/// What one network contributes to the table.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct NetworkRoutes {
|
||||
/// Local broadcast policy and authenticated willing recipients.
|
||||
pub broadcast: BroadcastPolicy,
|
||||
/// The range this network allocates from, once it has agreed one.
|
||||
pub range: Option<Ipv4Range>,
|
||||
/// This agent's own address in the network.
|
||||
@@ -73,6 +77,7 @@ pub enum RouteError {
|
||||
/// Address ownership across every network this agent is in.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RoutingTable {
|
||||
broadcast: ArcSwap<BroadcastTable>,
|
||||
networks: RwLock<HashMap<NetworkId, NetworkRoutes>>,
|
||||
}
|
||||
|
||||
@@ -122,12 +127,54 @@ impl RoutingTable {
|
||||
}
|
||||
}
|
||||
networks.insert(network, routes);
|
||||
self.broadcast
|
||||
.store(Arc::new(BroadcastTable::build(&networks)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forgets a network.
|
||||
pub fn remove_network(&self, network: NetworkId) {
|
||||
self.write().remove(&network);
|
||||
let mut networks = self.write();
|
||||
networks.remove(&network);
|
||||
self.broadcast
|
||||
.store(Arc::new(BroadcastTable::build(&networks)));
|
||||
}
|
||||
|
||||
/// Replaces only a network's broadcast participation, without changing addresses.
|
||||
pub fn set_broadcast(&self, network: NetworkId, policy: BroadcastPolicy) {
|
||||
let mut networks = self.write();
|
||||
if let Some(routes) = networks.get_mut(&network) {
|
||||
if routes.broadcast == policy {
|
||||
return;
|
||||
}
|
||||
routes.broadcast = policy;
|
||||
self.broadcast
|
||||
.store(Arc::new(BroadcastTable::build(&networks)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognizes limited and configured subnet broadcasts before unicast lookup.
|
||||
pub fn is_broadcast(&self, destination: Ipv4Addr) -> bool {
|
||||
self.broadcast.load().is_destination(destination)
|
||||
}
|
||||
|
||||
/// A precomputed domain-scoped recipient list; no per-packet graph search.
|
||||
pub fn broadcast_recipients(
|
||||
&self,
|
||||
source: Ipv4Addr,
|
||||
destination: Ipv4Addr,
|
||||
) -> Option<Arc<[Route]>> {
|
||||
self.broadcast.load().outgoing(source, destination)
|
||||
}
|
||||
|
||||
/// Local admission after the plugin authenticated and decrypted the sender.
|
||||
pub fn accepts_broadcast(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
destination: Ipv4Addr,
|
||||
) -> bool {
|
||||
self.broadcast.load().accepts(network, peer, destination)
|
||||
}
|
||||
|
||||
/// The peer that holds a destination address, if anybody does.
|
||||
@@ -164,6 +211,14 @@ impl RoutingTable {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether an ordinary packet terminates on this host in this network.
|
||||
/// Exported LAN subnets must extend this admission policy explicitly later.
|
||||
pub fn is_local_destination(&self, network: NetworkId, destination: Ipv4Addr) -> bool {
|
||||
self.read()
|
||||
.get(&network)
|
||||
.is_some_and(|routes| routes.local == Some(destination))
|
||||
}
|
||||
|
||||
/// Every address this agent should answer to, with its prefix length.
|
||||
pub fn local_addresses(&self) -> Vec<(Ipv4Addr, u8)> {
|
||||
let mut addresses: Vec<(Ipv4Addr, u8)> = self
|
||||
@@ -175,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
|
||||
@@ -236,6 +308,7 @@ mod tests {
|
||||
|
||||
fn routes() -> NetworkRoutes {
|
||||
NetworkRoutes {
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
local: Some(addr(1)),
|
||||
peers: vec![(addr(2), peer(2)), (addr(3), peer(3))],
|
||||
@@ -318,6 +391,7 @@ mod tests {
|
||||
.set_network(
|
||||
second,
|
||||
NetworkRoutes {
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||
peers: vec![(Ipv4Addr::new(10, 99, 0, 2), peer(4))],
|
||||
@@ -336,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
|
||||
@@ -362,6 +495,7 @@ mod tests {
|
||||
.set_network(
|
||||
network("wide"),
|
||||
NetworkRoutes {
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.0.0.0/8".parse().unwrap()),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -373,6 +507,7 @@ mod tests {
|
||||
.set_network(
|
||||
network("narrow"),
|
||||
NetworkRoutes {
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
..Default::default()
|
||||
}
|
||||
@@ -414,6 +549,7 @@ mod tests {
|
||||
.set_network(
|
||||
network("two"),
|
||||
NetworkRoutes {
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||
peers: Vec::new(),
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::error::ProtocolError;
|
||||
/// The version in the ALPN is the wire-compatibility version of the control
|
||||
/// protocol. It is independent of the network identity scheme version, so
|
||||
/// bumping it must not change any existing [`crate::NetworkId`].
|
||||
pub const ALPN: &[u8] = b"tsunagi/ctrl/1";
|
||||
pub const ALPN: &[u8] = b"tsunagi/ctrl/3";
|
||||
|
||||
/// ALPN of the tsunagi data plane.
|
||||
///
|
||||
@@ -29,11 +29,10 @@ pub const ALPN: &[u8] = b"tsunagi/ctrl/1";
|
||||
/// saturated or broken data plane cannot disturb control traffic, and the
|
||||
/// transport underneath can be replaced without touching the control protocol.
|
||||
///
|
||||
/// Version 3 fragments logical datagrams below the peer-relay envelope, so
|
||||
/// the overlay's MTU is independent of the current QUIC path MTU. Older data
|
||||
/// versions cannot form a data link; control and persistent identities remain
|
||||
/// compatible. Both ends, including any intermediate peer, must be upgraded.
|
||||
pub const DATA_ALPN: &[u8] = b"tsunagi/data/3";
|
||||
/// Version 4 adds source, destination, hop limit and stable flow id above
|
||||
/// transport fragmentation. All members must upgrade together. Persistent
|
||||
/// identities and network configuration do not change.
|
||||
pub const DATA_ALPN: &[u8] = b"tsunagi/data/4";
|
||||
|
||||
/// Largest plugin protocol identifier accepted when opening a data channel.
|
||||
pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
|
||||
@@ -42,7 +41,7 @@ pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
|
||||
pub const MAX_SIGNATURE_LEN: usize = 64;
|
||||
|
||||
/// Control protocol version carried inside the handshake.
|
||||
pub const PROTOCOL_VERSION: u16 = 1;
|
||||
pub const PROTOCOL_VERSION: u16 = 3;
|
||||
|
||||
/// First message of the handshake, sent by the initiator.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -74,6 +73,8 @@ pub struct AuthProof {
|
||||
/// What this agent tells a peer about itself.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Announcement {
|
||||
/// Local opt-in to receive LAN broadcasts in this network.
|
||||
pub broadcast: bool,
|
||||
/// Human-readable hostname. A mutable binding, not an identity.
|
||||
pub hostname: String,
|
||||
/// Announced IP plugin capabilities. Opaque to the core.
|
||||
@@ -154,8 +155,8 @@ pub enum ControlMessage {
|
||||
/// and repairs itself; and volatile, so it lives here and not in
|
||||
/// signed state, which is for what has to survive a member being away.
|
||||
Reachable {
|
||||
/// The peers, as raw endpoint keys.
|
||||
peers: Vec<[u8; 32]>,
|
||||
/// Direct links, scoped to the plugin protocol they actually carry.
|
||||
links: Vec<ReachableLink>,
|
||||
},
|
||||
/// Graceful goodbye.
|
||||
///
|
||||
@@ -183,6 +184,15 @@ pub struct PeerHint {
|
||||
pub addrs: Vec<String>,
|
||||
}
|
||||
|
||||
/// One directly observed edge, advertised only by its authenticated source.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ReachableLink {
|
||||
/// The other end of this transport link.
|
||||
pub peer: [u8; 32],
|
||||
/// Protocol carried by the link.
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
/// Longest address text accepted in a hint.
|
||||
pub const MAX_HINT_ADDR_LEN: usize = 128;
|
||||
|
||||
@@ -256,10 +266,18 @@ pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), Protoco
|
||||
}
|
||||
}
|
||||
}
|
||||
ControlMessage::Reachable { peers } => {
|
||||
// One entry per member at most; a sender claiming more than a
|
||||
// network can hold is not describing this network.
|
||||
check_len("reachable.peers", peers.len(), limits.max_state_records)?;
|
||||
ControlMessage::Reachable { links } => {
|
||||
check_len("reachable.links", links.len(), limits.max_state_records)?;
|
||||
for link in links {
|
||||
if link.protocol.is_empty() {
|
||||
return Err(ProtocolError::Malformed("empty routing protocol"));
|
||||
}
|
||||
check_len(
|
||||
"reachable.protocol",
|
||||
link.protocol.len(),
|
||||
MAX_PROTOCOL_ID_LEN,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
ControlMessage::Announce(announcement) => {
|
||||
check_len(
|
||||
|
||||
@@ -248,6 +248,12 @@ impl Storage {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persists local broadcast participation for one configured network.
|
||||
pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
self.with_state(move |state| state.set_broadcast(network_id, enabled))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Updates the auto-start flag of a network.
|
||||
pub async fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
|
||||
self.with_state(move |state| state.set_auto_start(network_id, auto_start))
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
|
||||
use crate::state::{RecordBody, SignedRecord};
|
||||
|
||||
/// Schema version written by this build.
|
||||
pub const SCHEMA_VERSION: i64 = 3;
|
||||
pub const SCHEMA_VERSION: i64 = 4;
|
||||
|
||||
/// Key of the stored hostname setting.
|
||||
const SETTING_HOSTNAME: &str = "hostname";
|
||||
@@ -40,6 +40,8 @@ pub struct StoredNetwork {
|
||||
pub secret: NetworkSecret,
|
||||
/// Whether the network is activated automatically at agent startup.
|
||||
pub auto_start: bool,
|
||||
/// Local broadcast participation, enabled unless explicitly disabled.
|
||||
pub broadcast: bool,
|
||||
}
|
||||
|
||||
/// The mandatory state store.
|
||||
@@ -183,6 +185,12 @@ impl StateStore {
|
||||
.execute_batch(SIGNED_RECORDS_SCHEMA)
|
||||
.map_err(|err| self.corrupt(format!("cannot create schema: {err}")))?;
|
||||
}
|
||||
if found < 4 {
|
||||
self.conn.execute_batch("BEGIN;
|
||||
ALTER TABLE networks ADD COLUMN broadcast INTEGER NOT NULL DEFAULT 1 CHECK (broadcast IN (0,1));
|
||||
PRAGMA user_version = 4;
|
||||
COMMIT;").map_err(|err| self.corrupt(format!("cannot migrate schema to 4: {err}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -396,6 +404,21 @@ impl StateStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves local participation without changing identity, membership or auto-start.
|
||||
pub fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
||||
let changed = self
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE networks SET broadcast = ?2 WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice(), enabled as i64],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot update broadcast policy: {err}")))?;
|
||||
if changed == 0 {
|
||||
return Err(Error::NetworkUnknown(network_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a network configuration entirely.
|
||||
pub fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.conn
|
||||
@@ -411,7 +434,7 @@ impl StateStore {
|
||||
pub fn list_networks(&self) -> Result<Vec<StoredNetwork>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT network_id, name, secret, auto_start FROM networks ORDER BY name")
|
||||
.prepare("SELECT network_id, name, secret, auto_start, broadcast FROM networks ORDER BY name")
|
||||
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
@@ -419,13 +442,13 @@ impl StateStore {
|
||||
let name: String = row.get(1)?;
|
||||
let secret: Vec<u8> = row.get(2)?;
|
||||
let auto_start: i64 = row.get(3)?;
|
||||
Ok((id, name, secret, auto_start != 0))
|
||||
Ok((id, name, secret, auto_start != 0, row.get::<_, bool>(4)?))
|
||||
})
|
||||
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, secret, auto_start) =
|
||||
let (id, name, secret, auto_start, broadcast) =
|
||||
row.map_err(|err| Error::Storage(format!("cannot read network row: {err}")))?;
|
||||
let id: [u8; 32] = id
|
||||
.as_slice()
|
||||
@@ -436,6 +459,7 @@ impl StateStore {
|
||||
name: NetworkName::new(name)?,
|
||||
secret: NetworkSecret::from_bytes(secret)?,
|
||||
auto_start,
|
||||
broadcast,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -668,3 +692,58 @@ pub(crate) fn now_unix() -> i64 {
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod broadcast_storage_tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::identity::NetworkKeys;
|
||||
|
||||
#[test]
|
||||
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("state.sqlite");
|
||||
let name = NetworkName::new("migration-broadcast").unwrap();
|
||||
let secret = NetworkSecret::from_bytes([7; 32]).unwrap();
|
||||
let id = NetworkKeys::derive(&name, &secret).network_id();
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
let identity = store
|
||||
.load_or_create_device_identity()
|
||||
.unwrap()
|
||||
.endpoint_id();
|
||||
store.upsert_network(id, &name, &secret, false).unwrap();
|
||||
store.set_hostname("old-host").unwrap();
|
||||
store
|
||||
.conn
|
||||
.execute_batch("ALTER TABLE networks DROP COLUMN broadcast; PRAGMA user_version=3;")
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
let networks = store.list_networks().unwrap();
|
||||
assert_eq!(networks.len(), 1);
|
||||
assert_eq!(networks[0].network_id, id);
|
||||
assert!(!networks[0].auto_start);
|
||||
assert!(networks[0].broadcast);
|
||||
assert_eq!(store.hostname().unwrap().as_deref(), Some("old-host"));
|
||||
assert_eq!(
|
||||
store
|
||||
.load_or_create_device_identity()
|
||||
.unwrap()
|
||||
.endpoint_id(),
|
||||
identity
|
||||
);
|
||||
store.set_broadcast(id, false).unwrap();
|
||||
store.upsert_network(id, &name, &secret, true).unwrap();
|
||||
drop(store);
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
assert!(
|
||||
!store.list_networks().unwrap()[0].broadcast,
|
||||
"rejoin and restart preserve opt-out"
|
||||
);
|
||||
store.remove_network(id).unwrap();
|
||||
store.upsert_network(id, &name, &secret, true).unwrap();
|
||||
assert!(
|
||||
store.list_networks().unwrap()[0].broadcast,
|
||||
"forgotten network gets defaults"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! The real Mainline UDP receive loop must be quiet while idle on Windows too.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
struct SocketWarnings(Arc<AtomicUsize>);
|
||||
|
||||
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for SocketWarnings {
|
||||
fn on_event(&self, event: &tracing::Event<'_>, _: tracing_subscriber::layer::Context<'_, S>) {
|
||||
if event.metadata().target() == "mainline::rpc::socket"
|
||||
&& *event.metadata().level() <= tracing::Level::WARN
|
||||
{
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_idle_dht_socket_stays_quiet_and_still_answers_packets() {
|
||||
// Mainline owns an OS thread, so a thread-local subscriber cannot see its
|
||||
// events. This integration-test binary has just this test and installs
|
||||
// its own subscriber; it cannot affect the application or other binaries.
|
||||
let warnings = Arc::new(AtomicUsize::new(0));
|
||||
tracing::subscriber::set_global_default(
|
||||
tracing_subscriber::registry().with(SocketWarnings(Arc::clone(&warnings))),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let dht = mainline::Dht::builder()
|
||||
.no_bootstrap()
|
||||
.server_mode()
|
||||
.bind_address(Ipv4Addr::LOCALHOST)
|
||||
.port(0)
|
||||
.build()
|
||||
.unwrap()
|
||||
.as_async();
|
||||
|
||||
let address = dht.info().await.local_addr();
|
||||
// Each info response comes from the actor after another receive-loop
|
||||
// iteration. With no peers or packets, these exercise real socket
|
||||
// read timeouts, without an arbitrary sleep or public bootstrap.
|
||||
for _ in 0..8 {
|
||||
assert_eq!(dht.info().await.local_addr(), address);
|
||||
}
|
||||
assert_eq!(warnings.load(Ordering::Relaxed), 0, "idle receive warnings");
|
||||
|
||||
let client = tokio::net::UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
let ping = b"d1:ad2:id20:abcdefghijklmnopqrste1:q4:ping1:t4:ping1:y1:qe";
|
||||
client.send_to(ping, address).await.unwrap();
|
||||
let mut response = [0u8; 2048];
|
||||
let (len, from) = client.recv_from(&mut response).await.unwrap();
|
||||
assert_eq!(from, std::net::SocketAddr::V4(address));
|
||||
let response = &response[..len];
|
||||
assert!(response.windows(9).any(|field| field == b"1:t4:ping"));
|
||||
assert!(response.windows(6).any(|field| field == b"1:y1:r"));
|
||||
assert_eq!(warnings.load(Ordering::Relaxed), 0);
|
||||
})
|
||||
.await
|
||||
.expect("the DHT receive loop keeps making progress");
|
||||
}
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
// Installed by the tsunagi package.
|
||||
//
|
||||
// Lets a non-root agent configure systemd-resolved for its overlay zones — the
|
||||
// four resolve1 actions it calls, and nothing else — for any user in the
|
||||
// "tsunagi" group (created by the package). The packaged systemd service runs
|
||||
// as root and does not need this rule; it is for running the agent as your own
|
||||
// user instead:
|
||||
//
|
||||
// sudo usermod -aG tsunagi "$USER"
|
||||
// sudo setcap cap_net_admin,cap_net_bind_service+p /usr/bin/tsunagi
|
||||
// tsunagi up
|
||||
//
|
||||
// polkit decides by user/group id, not by capability, which is why the resolver
|
||||
// needs this rule even once the binary has the capability.
|
||||
polkit.addRule(function(action, subject) {
|
||||
var allowed = [
|
||||
"org.freedesktop.resolve1.set-dns-servers",
|
||||
"org.freedesktop.resolve1.set-domains",
|
||||
"org.freedesktop.resolve1.set-default-route",
|
||||
"org.freedesktop.resolve1.revert"
|
||||
];
|
||||
if (allowed.indexOf(action.id) >= 0 && subject.isInGroup("tsunagi")) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assemble a dependency-free Debian package from a prebuilt static tsunagi
|
||||
# binary, using only dpkg-deb (no cargo-deb, no nfpm). The binary is statically
|
||||
# linked, so the package declares no Depends and runs on any Debian/Ubuntu.
|
||||
#
|
||||
# Usage: build-deb.sh <binary> <deb_arch> <version> <output.deb>
|
||||
# <binary> path to the built tsunagi executable
|
||||
# <deb_arch> Debian architecture: amd64 or arm64
|
||||
# <version> package version (the tag without its leading v)
|
||||
# <output> path to write the .deb to
|
||||
set -euo pipefail
|
||||
|
||||
binary="$1"
|
||||
arch="$2"
|
||||
version="$3"
|
||||
output="$4"
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
root="$(mktemp -d)"
|
||||
trap 'rm -rf "$root"' EXIT
|
||||
|
||||
# Payload. Debian installs to /usr/bin (not /usr/local), so the service's
|
||||
# ExecStart is rewritten to match; the source unit targets /usr/local/bin for
|
||||
# the tarball installer.
|
||||
install -D -m 0755 "$binary" "$root/usr/bin/tsunagi"
|
||||
|
||||
mkdir -p "$root/lib/systemd/system"
|
||||
sed 's#/usr/local/bin/tsunagi#/usr/bin/tsunagi#' \
|
||||
"$here/../linux/tsunagi.service" > "$root/lib/systemd/system/tsunagi.service"
|
||||
chmod 0644 "$root/lib/systemd/system/tsunagi.service"
|
||||
|
||||
install -D -m 0644 "$here/50-tsunagi-resolved.rules" \
|
||||
"$root/usr/share/polkit-1/rules.d/50-tsunagi-resolved.rules"
|
||||
install -D -m 0644 "$here/../../README.md" "$root/usr/share/doc/tsunagi/README.md"
|
||||
install -D -m 0644 "$here/../linux/README.md" "$root/usr/share/doc/tsunagi/INSTALL.md"
|
||||
|
||||
mkdir -p "$root/DEBIAN"
|
||||
|
||||
cat > "$root/DEBIAN/control" <<CTRL
|
||||
Package: tsunagi
|
||||
Version: ${version}
|
||||
Architecture: ${arch}
|
||||
Maintainer: AB <ab@hexor.cy>
|
||||
Section: net
|
||||
Priority: optional
|
||||
Homepage: https://github.com/Ultradesu/tsunagi
|
||||
Description: Serverless private mesh networking agent
|
||||
tsunagi forms small private mesh IP networks between peers, with NAT
|
||||
traversal and no central server. This package installs a static,
|
||||
dependency-free binary, a systemd service that runs the agent as root, and a
|
||||
polkit rule that lets a non-root agent configure systemd-resolved.
|
||||
CTRL
|
||||
|
||||
# A system group for the optional non-root run (the polkit rule grants it the
|
||||
# resolve1 actions). The packaged service runs as root and does not need it.
|
||||
cat > "$root/DEBIAN/postinst" <<'POSTINST'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if ! getent group tsunagi >/dev/null 2>&1; then
|
||||
addgroup --system tsunagi >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 0
|
||||
POSTINST
|
||||
|
||||
cat > "$root/DEBIAN/prerm" <<'PRERM'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ "$1" = remove ] || [ "$1" = purge ]; then
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl disable --now tsunagi.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
PRERM
|
||||
|
||||
cat > "$root/DEBIAN/postrm" <<'POSTRM'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 0
|
||||
POSTRM
|
||||
|
||||
chmod 0755 "$root/DEBIAN/postinst" "$root/DEBIAN/prerm" "$root/DEBIAN/postrm"
|
||||
|
||||
# --root-owner-group writes the payload as root:root without needing fakeroot.
|
||||
dpkg-deb --root-owner-group --build "$root" "$output"
|
||||
echo "built $output"
|
||||
Vendored
+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.
|
||||
+14
-1
@@ -35,6 +35,16 @@ a `PluginContext` for re-announcements and error reports, and a bounded
|
||||
A data plane failure never stops the daemon: the control plane keeps running
|
||||
and the agent stays manageable.
|
||||
|
||||
## Userspace routing
|
||||
|
||||
The routing engine builds shortest paths over opaque peer identifiers, with no
|
||||
WireGuard or iroh dependency. The relay adapter binds those next hops to
|
||||
`PacketLink` handles and publishes an immutable table per network and protocol.
|
||||
One reader per raw transport forwards transit without entering the plugin or
|
||||
TUN. End-to-end plugin links survive physical link changes. The existing
|
||||
authenticated control mesh supplies first-hand topology; the data router does
|
||||
not tunnel control sessions. See [routing.md](routing.md).
|
||||
|
||||
## Module responsibilities
|
||||
|
||||
| component | responsibility |
|
||||
@@ -47,7 +57,10 @@ and the agent stays manageable.
|
||||
| `storage` | mandatory state and the separately recoverable cache |
|
||||
| `state` | signed records that outlive a session, merged between replicas |
|
||||
| `dataplane::transport` | authenticated datagram links to peers; where reachability lives |
|
||||
| `dataplane` | the contract a protocol implements, and nothing else |
|
||||
| `dataplane::routing` | transport-independent graph, shortest paths and opaque flow identifiers |
|
||||
| `dataplane::relay` | immutable forwarding snapshots and transport-to-transport transit |
|
||||
| `dataplane` | the contract an IP protocol implements |
|
||||
| `overlay::broadcast` | domain-scoped IPv4 UDP discovery fanout and receive admission |
|
||||
| `overlay` | the one interface an agent owns: provisioning, the TUN, whose packet is whose |
|
||||
| `dns` | the DNS view of a network, and telling the system resolver about it |
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# LAN discovery broadcasts
|
||||
|
||||
Broadcast participation is a **local setting of each network**, enabled by
|
||||
default. Different networks on the same agent can have different settings;
|
||||
each member advertises whether it accepts broadcasts over its authenticated
|
||||
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 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. 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
|
||||
|
||||
At local TUN ingress, the IP router recognizes IPv4 UDP addressed to the limited
|
||||
broadcast `255.255.255.255` or the configured overlay subnet's directed broadcast
|
||||
(for example `10.13.37.255` for `/24`). These address forms follow
|
||||
[RFC 1122 section 3.3.6](https://www.rfc-editor.org/rfc/rfc1122#section-3.3.6).
|
||||
There is no port allowlist: discovery requests and server announcements on
|
||||
game-specific UDP ports use the same mechanism. IPv4/UDP lengths are checked
|
||||
before fanout, including handling IP fragments. IPv6 multicast, mDNS, DHCP
|
||||
bridging, Ethernet/IPX and physical LAN capture are outside this feature.
|
||||
|
||||
The local source address selects exactly one broadcast domain. A limited
|
||||
broadcast is never copied into all networks merely because they share a TUN.
|
||||
A directed broadcast must belong to that source's network. Unspecified sources
|
||||
and physical LAN sources have no authorized domain yet. Recipients are live,
|
||||
authenticated, opted-in members with signed overlay address ownership. The
|
||||
immutable recipient list is rebuilt with address/policy changes, not per packet.
|
||||
|
||||
The origin creates **one encrypted unicast copy per eligible peer** and hands it
|
||||
to the existing shortest-path router. Each can travel through several transport
|
||||
links. Transit handles opaque ciphertext with the existing hop limit and does
|
||||
not create another fanout. The original IP source, destination, TTL, UDP ports,
|
||||
checksum and payload remain unchanged. Reply packets use ordinary unicast.
|
||||
There is no broadcast retransmission timer or application-payload deduplication:
|
||||
games may legitimately repeat the same discovery query. WireGuard replay checks
|
||||
reject duplicated encrypted packets delivered by the transport.
|
||||
|
||||
At the destination, the usual authenticated-source ownership check still runs.
|
||||
Broadcast admission also checks the receiving network's setting, the sender's
|
||||
announced participation, the destination domain and UDP framing. Disabling it
|
||||
takes effect locally even while a remote sender still has an older announcement.
|
||||
Received broadcasts terminate in the local TUN and never enter the fanout path.
|
||||
An ordinary decrypted packet must target this host's address in that network;
|
||||
the TUN is not a transit gateway to another overlay or physical subnet.
|
||||
|
||||
Opt-out disables originating and accepting broadcasts on that host/network.
|
||||
It does not disable opaque unicast transit for other members: intermediate
|
||||
routers cannot inspect another pair's encrypted IP payload.
|
||||
|
||||
## Host behavior and verification
|
||||
|
||||
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. 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
|
||||
|
||||
`overlay::broadcast` owns domain selection and recipient policy separately from
|
||||
IP parsing, encryption and encrypted transit. A future LAN adapter should feed
|
||||
an explicitly authorized ingress domain into this layer, and export routes
|
||||
through the corresponding source/destination admission policy. It must not
|
||||
relax address checks globally or reinterpret all `.255` addresses as broadcasts.
|
||||
Multiple gateways to the same LAN will need origin identifiers and bounded
|
||||
duplicate suppression at LAN ingress/egress before physical rebroadcast is
|
||||
enabled. No subnet export or host forwarding configuration is added here.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Control ALPN is `tsunagi/ctrl/3` because `Announce` now carries participation.
|
||||
The encrypted transit envelope and data ALPN `tsunagi/data/4` stay unchanged.
|
||||
Local control protocol 14 rejects commands from mismatched running binaries
|
||||
with a restart message instead of decoding a different request shape.
|
||||
Upgrade all members and restart the agent before using the new CLI.
|
||||
|
||||
SQLite schema 4 adds `networks.broadcast` with default `1`. Existing network
|
||||
names, secrets, identity keys and signed records are retained. The old binary
|
||||
does not understand schema 4; the migration is not a downgrade mechanism.
|
||||
+39
-6
@@ -9,7 +9,7 @@ Two versions exist and are independent:
|
||||
|
||||
- **Identity scheme**, `tsunagi-network-id-v1`. Frozen. Changing it creates a
|
||||
different network space for the same name and secret.
|
||||
- **Control protocol**, ALPN `tsunagi/ctrl/1`, `PROTOCOL_VERSION = 1`.
|
||||
- **Control protocol**, ALPN `tsunagi/ctrl/3`, `PROTOCOL_VERSION = 3`.
|
||||
|
||||
Upgrading the crate or bumping the control protocol must never change an
|
||||
existing `NetworkId`.
|
||||
@@ -135,7 +135,7 @@ event does not report a network id.
|
||||
## The data plane protocol
|
||||
|
||||
IP plugin packets never travel on a control connection. They use their own
|
||||
ALPN, `tsunagi/data/3`, on their own iroh connection:
|
||||
ALPN, `tsunagi/data/4`, on their own iroh connection:
|
||||
|
||||
```text
|
||||
initiator -> responder : (the same membership handshake as above)
|
||||
@@ -172,9 +172,35 @@ holding up subsequent packets. Packet IDs are scoped to a QUIC connection;
|
||||
reassembly is created only after the membership handshake. All limits live in
|
||||
`config.rs`. There is no transport-layer retransmission added by this framing.
|
||||
|
||||
Version 3 is incompatible with previous data ALPNs. Upgrade both endpoints
|
||||
and intermediate peers together. The control protocol, network secret,
|
||||
device identity and stored network configuration are unchanged.
|
||||
Version 4 adds a routing envelope inside the transport fragmentation framing:
|
||||
|
||||
| field | bytes | encoding |
|
||||
|---|---:|---|
|
||||
| envelope version | 1 | `1` |
|
||||
| remaining hops | 1 | initially `16`; transit decrements, drops at `1` |
|
||||
| source peer | 32 | original endpoint public key |
|
||||
| destination peer | 32 | final endpoint public key |
|
||||
| flow id | 8 | big endian opaque stable hash supplied by the plugin |
|
||||
| payload | remaining | end-to-end encrypted plugin bytes |
|
||||
|
||||
The fixed header is 74 bytes. Zero/oversized hop limits, unknown versions,
|
||||
short frames, oversized datagrams and unknown sources are rejected. A frame
|
||||
addressed here is delivered to its source's protocol inbox; transit goes straight
|
||||
to its next transport. The network and protocol are bound to the authenticated
|
||||
transport connection, not supplied by the frame. Intermediate nodes cannot
|
||||
decrypt or authenticate the inner WireGuard payload; the destination does that.
|
||||
Flow ids are routing hints, not authorization proofs.
|
||||
|
||||
Control ALPN 3 carries `Reachable { links: [{ peer, protocol }] }`. Each row
|
||||
belongs to the authenticated sender and is replaced atomically, expires after
|
||||
90 seconds, and is withdrawn on session closure. Only compatible authenticated
|
||||
members enter a protocol's graph; local edges always come from actual links.
|
||||
Announcements refresh on the existing control maintenance interval and are
|
||||
sent immediately on link changes. Topology stays volatile, outside signed state.
|
||||
|
||||
Upgrade both endpoints and intermediate peers together. Older control and data
|
||||
ALPNs cannot interoperate. The network secret, device identity, identity
|
||||
derivation, transcript encoding and stored network configuration are unchanged.
|
||||
|
||||
Separate connections mean separate congestion control, so a saturated data
|
||||
plane cannot delay control messages, and a data plane failure cannot take the
|
||||
@@ -189,12 +215,19 @@ not affect other networks.
|
||||
|
||||
| message | meaning |
|
||||
|---|---|
|
||||
| `Announce { hostname, capabilities }` | this agent's hostname and IP-plugin capabilities |
|
||||
| `Announce { hostname, capabilities, broadcast }` | this agent's hostname, IP-plugin capabilities and local broadcast participation |
|
||||
| `Ping { seq, payload }` | small request used to verify the exchange |
|
||||
| `Pong { seq, payload }` | the echoed reply |
|
||||
| `State { records }` | a snapshot of signed records, merged into what the receiver holds |
|
||||
| `Peers { peers }` | unverified member address hints, requiring their own handshake |
|
||||
| `Reachable { links }` | sender's current direct data links, scoped by protocol |
|
||||
| `Bye { reason }` | graceful goodbye; not a revocation of anything |
|
||||
|
||||
`broadcast` is a per-network local opt-in, enabled by default and treated as
|
||||
false until an authenticated announcement arrives. It governs IP broadcast
|
||||
fanout and local admission; encrypted transit still uses the existing envelope.
|
||||
See [broadcast.md](broadcast.md) for scope and persistence.
|
||||
|
||||
A `State` snapshot is merged, never substituted: an author missing from it is
|
||||
left untouched. Each record carries its own signature, so a peer forwarding
|
||||
somebody else's record cannot alter it, and a record that fails verification
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Userspace mesh routing
|
||||
|
||||
```text
|
||||
local OS → TUN → IP plugin (encrypt) → router → transport peer link
|
||||
transport peer link → router → transport peer link (transit)
|
||||
transport peer link → router → IP plugin (decrypt) → TUN → local OS
|
||||
```
|
||||
|
||||
The TUN is the boundary of the local host. The kernel sees one overlay route;
|
||||
it never forwards transit traffic. WireGuard encryption remains end to end.
|
||||
The core router sees only an opaque payload and routing metadata. iroh is the
|
||||
current transport adapter, with fragmentation below the routing envelope so
|
||||
the default 1280-byte interface MTU also works on small QUIC paths.
|
||||
|
||||
## Graph and lifecycle
|
||||
|
||||
Each authenticated control session advertises that peer's own direct data
|
||||
links, including the plugin protocol. Tables are separate for each network
|
||||
and protocol; only members offering a matching enabled protocol version enter
|
||||
the graph. A snapshot refreshes on the maintenance interval, expires after
|
||||
90 seconds, and is withdrawn when the session ends. Link arrival, closure and
|
||||
changed announcements rebuild routes immediately. Unchanged announcements
|
||||
refresh their age without republishing the forwarding snapshot.
|
||||
|
||||
Breadth-first search computes the shortest directed paths and every equal-cost
|
||||
first hop in sorted order. An actual direct link always wins. The first row is
|
||||
always supplied by local transport state, never by a remote claim. Routes stop
|
||||
at 16 links. A packet also carries a decreasing hop limit, bounding temporary
|
||||
loops if different nodes have not yet received the same topology update.
|
||||
|
||||
The existing control plane still forms authenticated pairwise sessions.
|
||||
Routing provides multihop **data** paths among these members; it does not add
|
||||
control-plane flooding or carry control sessions through the data plane.
|
||||
Announcements are volatile, not durable membership or availability guarantees.
|
||||
|
||||
## Packet path
|
||||
|
||||
The control loop binds next hops to transport handles in an immutable table
|
||||
and publishes it through `ArcSwap`. Readers do not acquire the topology mutex.
|
||||
The transit routine validates the fixed 74-byte envelope, looks up the source
|
||||
and destination, selects a cached next hop, decrements one byte, and calls the
|
||||
transport's synchronous datagram send. It never searches the graph, formats an
|
||||
endpoint string, validates an Ed25519 key, decrypts, or touches TUN.
|
||||
|
||||
An exclusively owned receive buffer is reused when decrementing the hop limit.
|
||||
Shared buffers require a copy through the safe `bytes` API. There is no added
|
||||
transit queue or timer; every raw transport has its own reader. Readers yield
|
||||
after 64 ready packets to avoid starving other runtime tasks. Local delivery
|
||||
uses a bounded 256-datagram inbox and drops a new packet when full. Transport
|
||||
queues and fragment reassembly retain their own limits.
|
||||
|
||||
`PacketLink::send_flow` accepts an opaque 64-bit flow identifier. IP plugins
|
||||
derive it before encryption from source/destination addresses, protocol and
|
||||
TCP/UDP ports. WireGuard keeps tags alongside its bounded pending queue, so
|
||||
the first application packets retain their flow identity after a handshake.
|
||||
Transit preserves the tag; it never hashes changing ciphertext. Handshake and
|
||||
keepalive frames use flow zero. Fragmented IP traffic uses a coarse address/
|
||||
protocol hash because later fragments lack ports; fragmented flows between
|
||||
the same addresses coalesce. A transition between fragmented and unfragmented
|
||||
traffic can change its path. The default overlay MTU avoids needing IP
|
||||
fragmentation for ordinary host TCP traffic.
|
||||
|
||||
ECMP is deterministic for a source and flow while the table is unchanged.
|
||||
Closed next hops are skipped until the control loop replaces the table. A
|
||||
topology change can move a flow; datagrams remain unreliable and unordered,
|
||||
and there is no promise to preserve order across a physical path failure.
|
||||
Logical peer links and end-to-end encryption state survive these changes.
|
||||
|
||||
## Verification and measurement
|
||||
|
||||
The default offline suite exercises four real agents, iroh transports and
|
||||
WireGuard tunnels with memory TUNs, plus graph/forwarder unit tests. It needs
|
||||
neither administrative rights nor public relays/DHT.
|
||||
|
||||
Run the forwarding microbenchmark explicitly:
|
||||
|
||||
```sh
|
||||
cargo test --release -p tsunagi --lib forwarding_benchmark -- --ignored --nocapture
|
||||
```
|
||||
|
||||
It processes one million 64-byte frames and one million 1280-byte frames with
|
||||
two equal next hops. Frame construction is outside the timed section; the
|
||||
actual ingress routine, header validation, snapshot lookup, ECMP, TTL update,
|
||||
buffer release and mock transport submission are inside. The mock transport
|
||||
counts sends without storing frames. Results are CPU forwarding cost, **not**
|
||||
end-to-end network latency or VPN throughput; encryption, fragmentation,
|
||||
sockets, congestion and scheduling contribute separately.
|
||||
|
||||
Wire compatibility: control ALPN `tsunagi/ctrl/3`, data ALPN `tsunagi/data/4`.
|
||||
Upgrade every participant together; saved identities and network state persist.
|
||||
+34
-9
@@ -63,15 +63,31 @@ intermediate peer. Fragment tests cover reordering, duplicates, loss, changing
|
||||
fragment sizes, malformed input, timeout and memory bounds. These are packet
|
||||
transport checks, not a claim to have run an SSH server or a host TCP stack.
|
||||
|
||||
`crates/tsunagi/src/dataplane/relay.rs` has its own tests for the way
|
||||
through a peer in the middle: the wrapping and what a malformed one does, a
|
||||
direct path being preferred over a hop, the middle passing a datagram on
|
||||
without being handed it, what arrives through somebody reaching the peer it
|
||||
came from rather than the one that carried it, a link outliving the paths
|
||||
under it, and the datagram size not changing when the path does.
|
||||
`tests/wireguard.rs` proves it end to end — two agents that can each reach a
|
||||
third and not each other, with a real WireGuard packet crossing through the
|
||||
middle.
|
||||
Routing tests cover shortest paths, direct preference, deterministic per-flow
|
||||
ECMP, link loss, protocol isolation, malformed/unknown frames, bounded local
|
||||
queues, zero-copy transit of an owned buffer, and deliberately inconsistent
|
||||
tables whose loop terminates at the hop limit. A transit reader is exercised
|
||||
without any plugin reader, so forwarding cannot accidentally depend on one.
|
||||
WireGuard tests also cover flow tags queued before a handshake, queue overflow,
|
||||
and their preservation through encryption.
|
||||
|
||||
`tests/wireguard.rs` includes a four-agent chain A—B—C—D with only adjacent data
|
||||
links. Real encrypted 1280-byte TCP packets travel in both directions while the
|
||||
middle TUNs remain empty. A direct A—D link is enabled, then removed; the route
|
||||
switches back to the chain without replacing end-to-end tunnels.
|
||||
|
||||
Broadcast tests use limited and directed UDP game discovery packets over real
|
||||
WireGuard tunnels, including a missing direct link. Each willing peer gets one
|
||||
copy; disabling reception/origination works at runtime and unicast still works.
|
||||
IP-router tests cover source/destination domain checks, malformed UDP and the
|
||||
absence of reflection into outgoing fanout. SQLite migration tests preserve v3
|
||||
identity/settings and check opt-out after reopen/rejoin. CLI tests cover default
|
||||
on, per-network opt-out, runtime updates and conflicting flags. Actual games and
|
||||
host adapter selection are not simulated by these tests.
|
||||
|
||||
The ignored `forwarding_benchmark` measures the synchronous transit routine in
|
||||
release mode, excluding crypto and socket I/O. Run it explicitly as described
|
||||
in [routing.md](routing.md); it has no timing threshold in the default suite.
|
||||
|
||||
Unit tests in `crates/tsunagi/src/state/` cover the signed record model directly: tampering
|
||||
with any field breaks verification, a newer version wins while an older one
|
||||
@@ -92,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
|
||||
@@ -123,6 +144,10 @@ What the default suite does **not** cover is the real TUN interface, because
|
||||
that needs `CAP_NET_ADMIN`. Everything above it does run.
|
||||
|
||||
Mainline discovery tests use an isolated loopback `mainline::Testnet`.
|
||||
`tests/mainline_socket.rs` checks that idle UDP receive timeouts do not emit
|
||||
warnings (including Windows error 10060) and that the same node still answers
|
||||
a real KRPC ping afterwards. The local dependency correction is documented in
|
||||
[`vendor/mainline/PATCHES.md`](../vendor/mainline/PATCHES.md).
|
||||
`tests/mainline.rs` restores two agents after their DHT and disposable cache
|
||||
have disappeared. `tests/discovery_lifecycle.rs` checks that publication
|
||||
continues while connected, lookup resumes after isolation, and a hung backend
|
||||
|
||||
+1
-1
@@ -184,7 +184,7 @@ QUIC DATAGRAM frames themselves cannot fragment.
|
||||
Reassembly is bounded and expires incomplete packets; one lost fragment loses
|
||||
one packet, without blocking unrelated traffic. The logical data payload limit
|
||||
is 64 KiB, with the relay envelope subtracted before it reaches the plugin.
|
||||
The new framing uses data ALPN `tsunagi/data/3`; both ends and intermediate
|
||||
The new framing uses data ALPN `tsunagi/data/4`; both ends and intermediate
|
||||
peers need the updated binary. Network identities and saved state do not change.
|
||||
See [protocol.md](protocol.md#the-data-plane-protocol) for the wire format.
|
||||
|
||||
|
||||
Vendored
+147
@@ -0,0 +1,147 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2021"
|
||||
rust-version = "1.85"
|
||||
name = "mainline"
|
||||
version = "8.0.0"
|
||||
authors = [
|
||||
"nuh.dev",
|
||||
"SeverinAlexB <severin@synonym.to>",
|
||||
"SHAcollision <shacollision@synonym.to>",
|
||||
"dzdidi <denys@synonym.to>",
|
||||
"Kevin Karsopawiro <kevin@synonym.to>",
|
||||
]
|
||||
build = false
|
||||
exclude = [
|
||||
"/docs/*",
|
||||
"/examples/*",
|
||||
]
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "Simple, robust, BitTorrent's Mainline DHT implementation"
|
||||
homepage = "https://github.com/pubky/mainline"
|
||||
readme = "README.md"
|
||||
keywords = [
|
||||
"bittorrent",
|
||||
"torrent",
|
||||
"dht",
|
||||
"kademlia",
|
||||
"mainline",
|
||||
]
|
||||
categories = ["network-programming"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/pubky/mainline"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
|
||||
[features]
|
||||
async = [
|
||||
"node",
|
||||
"flume/async",
|
||||
"dep:futures-lite",
|
||||
]
|
||||
default = ["full"]
|
||||
full = ["async"]
|
||||
node = ["dep:flume"]
|
||||
|
||||
[lib]
|
||||
name = "mainline"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies.crc]
|
||||
version = "3.4.0"
|
||||
default-features = false
|
||||
|
||||
[dependencies.document-features]
|
||||
version = "0.2.12"
|
||||
|
||||
[dependencies.dyn-clone]
|
||||
version = "1.0.20"
|
||||
default-features = false
|
||||
|
||||
[dependencies.ed25519-dalek]
|
||||
version = "3.0.0-pre.1"
|
||||
default-features = false
|
||||
|
||||
[dependencies.flume]
|
||||
version = "0.12.0"
|
||||
optional = true
|
||||
default-features = false
|
||||
|
||||
[dependencies.futures-lite]
|
||||
version = "2.6.1"
|
||||
optional = true
|
||||
default-features = false
|
||||
|
||||
[dependencies.getrandom]
|
||||
version = "0.4"
|
||||
default-features = false
|
||||
|
||||
[dependencies.lru]
|
||||
version = "0.16.2"
|
||||
default-features = false
|
||||
|
||||
[dependencies.serde]
|
||||
version = "1.0.228"
|
||||
features = ["derive"]
|
||||
|
||||
[dependencies.serde_bencode]
|
||||
version = "0.2.4"
|
||||
default-features = false
|
||||
|
||||
[dependencies.serde_bytes]
|
||||
version = "0.11.19"
|
||||
|
||||
[dependencies.sha1_smol]
|
||||
version = "1.0.1"
|
||||
default-features = false
|
||||
|
||||
[dependencies.thiserror]
|
||||
version = "2.0.18"
|
||||
default-features = false
|
||||
|
||||
[dependencies.tracing]
|
||||
version = "0.1.44"
|
||||
|
||||
[dev-dependencies.clap]
|
||||
version = "4.5.57"
|
||||
features = ["derive"]
|
||||
|
||||
[dev-dependencies.colored]
|
||||
version = "3.1.1"
|
||||
|
||||
[dev-dependencies.ctrlc]
|
||||
version = "3.5.1"
|
||||
|
||||
[dev-dependencies.dashmap]
|
||||
version = "6.1"
|
||||
|
||||
[dev-dependencies.flume]
|
||||
version = "0.12.0"
|
||||
default-features = false
|
||||
|
||||
[dev-dependencies.futures]
|
||||
version = "0.3.31"
|
||||
|
||||
[dev-dependencies.histo]
|
||||
version = "1.0.0"
|
||||
|
||||
[dev-dependencies.rayon]
|
||||
version = "1.11.0"
|
||||
|
||||
[dev-dependencies.tracing-subscriber]
|
||||
version = "0.3"
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
[package]
|
||||
name = "mainline"
|
||||
version = "8.0.0"
|
||||
authors = [
|
||||
"nuh.dev",
|
||||
"SeverinAlexB <severin@synonym.to>",
|
||||
"SHAcollision <shacollision@synonym.to>",
|
||||
"dzdidi <denys@synonym.to>",
|
||||
"Kevin Karsopawiro <kevin@synonym.to>"
|
||||
]
|
||||
edition = "2021"
|
||||
rust-version = "1.85"
|
||||
description = "Simple, robust, BitTorrent's Mainline DHT implementation"
|
||||
homepage = "https://github.com/pubky/mainline"
|
||||
license = "MIT"
|
||||
keywords = ["bittorrent", "torrent", "dht", "kademlia", "mainline"]
|
||||
categories = ["network-programming"]
|
||||
repository = "https://github.com/pubky/mainline"
|
||||
exclude = ["/docs/*", "/examples/*"]
|
||||
|
||||
[dependencies]
|
||||
getrandom = { version = "0.4", default-features = false }
|
||||
serde_bencode = { version = "0.2.4", default-features = false }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_bytes = "0.11.19"
|
||||
thiserror = { version = "2.0.18", default-features = false }
|
||||
crc = { version = "3.4.0", default-features = false }
|
||||
sha1_smol = { version = "1.0.1", default-features = false }
|
||||
ed25519-dalek = { version = "3.0.0-pre.1", default-features = false }
|
||||
tracing = "0.1.44"
|
||||
lru = { version = "0.16.2", default-features = false }
|
||||
dyn-clone = { version = "1.0.20", default-features = false }
|
||||
|
||||
document-features = "0.2.12"
|
||||
|
||||
# `node` dependencies
|
||||
flume = { version = "0.12.0", default-features = false, optional = true }
|
||||
|
||||
# `async` dependencies
|
||||
futures-lite = { version = "2.6.1", default-features = false, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
clap = { version = "4.5.57", features = ["derive"] }
|
||||
futures = "0.3.31"
|
||||
tracing-subscriber = "0.3"
|
||||
ctrlc = "3.5.1"
|
||||
histo = "1.0.0"
|
||||
rayon = "1.11.0"
|
||||
dashmap = "6.1"
|
||||
flume = { version = "0.12.0", default-features = false }
|
||||
colored = "3.1.1"
|
||||
|
||||
[features]
|
||||
## Include [Dht] node.
|
||||
node = ["dep:flume"]
|
||||
## Enable [Dht::as_async()] to use [async_dht::AsyncDht].
|
||||
async = ["node", "flume/async", "dep:futures-lite"]
|
||||
|
||||
full = ["async"]
|
||||
|
||||
default = ["full"]
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 raptorswing
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
# Mainline 8.0.0: Windows receive timeout correction
|
||||
|
||||
This directory contains the published `mainline` 8.0.0 sources, manifest and
|
||||
MIT license. The workspace selects this copy through `[patch.crates-io]`.
|
||||
|
||||
- Upstream: https://github.com/pubky/mainline
|
||||
- Published source commit: `b0cabe684f310004c6dcfe8099b91f0d239b11e3`
|
||||
- Crates.io archive SHA-256:
|
||||
`d32eaee3dcba6e0bbbefe8bd896a8bd6039d5e74b199c0fe248e9feb547c2a26`
|
||||
- The upstream manifest is retained, including its development dependencies;
|
||||
the crate is excluded from the application workspace.
|
||||
|
||||
The only source change is in `src/rpc/socket.rs`: `KrpcSocket::recv_from`
|
||||
treats `ErrorKind::TimedOut` like `WouldBlock`. Its 50 ms socket read timeout
|
||||
is a normal idle tick, not a failed DHT operation. Windows reports this as
|
||||
WSAETIMEDOUT (10060), producing approximately 16 warnings per second with
|
||||
the unmodified crate. Other receive errors still emit the original warning.
|
||||
Request deadlines, DHT routing, publication and lookup behavior are unchanged.
|
||||
|
||||
Rust documents the platform difference here:
|
||||
https://doc.rust-lang.org/std/net/struct.UdpSocket.html#method.set_read_timeout
|
||||
|
||||
Regression coverage is part of the regular workspace suite:
|
||||
|
||||
```sh
|
||||
cargo test --locked -p tsunagi --test mainline_socket
|
||||
```
|
||||
|
||||
It drives the actual DHT actor through idle receive ticks on loopback, checks
|
||||
that no socket warnings were emitted, then exchanges a real KRPC ping. The
|
||||
existing Mainline Testnet tests also cover publication, lookup and recovery.
|
||||
No public DHT, privileged interface or changes to host settings are needed.
|
||||
|
||||
When upstream releases this correction, upgrade the dependency and remove
|
||||
this directory, the patch entry and its workspace exclusion. Keep the
|
||||
regression test. Do not patch Cargo's global registry cache.
|
||||
Vendored
+69
@@ -0,0 +1,69 @@
|
||||
# Mainline
|
||||
|
||||
Simple, robust, BitTorrent's [Mainline](https://en.wikipedia.org/wiki/Mainline_DHT) DHT implementation.
|
||||
|
||||
This library is focused on being the best and simplest Rust client for Mainline, especially focused on reliable and fast time-to-first-response.
|
||||
|
||||
It should work as a routing / storing node (server mode) as well, and has been running in production for many months without an issue.
|
||||
However if you are concerned about spam or DoS, you should consider implementing [rate limiting](#rate-limiting).
|
||||
|
||||
**[API Docs](https://docs.rs/mainline/latest/mainline/)**
|
||||
|
||||
## Getting started
|
||||
|
||||
Check the [Examples](https://github.com/Pubky/mainline/tree/main/examples).
|
||||
|
||||
## Features
|
||||
|
||||
### Client
|
||||
|
||||
Running as a client, means you can store and query for values on the DHT, but not accept any incoming requests.
|
||||
|
||||
```rust
|
||||
use mainline::Dht;
|
||||
|
||||
let dht = Dht::client().unwrap();
|
||||
```
|
||||
|
||||
Supported BEPs:
|
||||
- [x] [BEP_0005 DHT Protocol](https://www.bittorrent.org/beps/bep_0005.html)
|
||||
- [x] [BEP_0042 DHT Security extension](https://www.bittorrent.org/beps/bep_0042.html)
|
||||
- [x] [BEP_0043 Read-only DHT Nodes](https://www.bittorrent.org/beps/bep_0043.html)
|
||||
- [x] [BEP_0044 Storing arbitrary data in the DHT](https://www.bittorrent.org/beps/bep_0044.html)
|
||||
|
||||
This implementation also includes [measures against Vertical Sybil Attacks](./docs/sybil-resistance.md).
|
||||
|
||||
### Server
|
||||
|
||||
Running as a server is the same as a client, but you also respond to incoming requests and serve as a routing and storing node, supporting the general routing of the DHT, and contributing to the storage capacity of the DHT.
|
||||
|
||||
```rust
|
||||
use mainline::Dht;
|
||||
|
||||
let dht = Dht::server().unwrap(); // or `Dht::builder::server_mode().build();`
|
||||
```
|
||||
|
||||
Supported BEPs:
|
||||
- [x] [BEP_0005 DHT Protocol](https://www.bittorrent.org/beps/bep_0005.html)
|
||||
- [x] [BEP_0042 DHT Security extension](https://www.bittorrent.org/beps/bep_0042.html)
|
||||
- [x] [BEP_0043 Read-only DHT Nodes](https://www.bittorrent.org/beps/bep_0043.html)
|
||||
- [x] [BEP_0044 Storing arbitrary data in the DHT](https://www.bittorrent.org/beps/bep_0044.html)
|
||||
|
||||
#### Rate limiting
|
||||
|
||||
The server implementation has no rate-limiting, you can run your own [request filter](./examples/request_filter.rs) and apply your custom rate-limiting.
|
||||
However, that limit/block will only apply _after_ parsing incoming messages, and it won't affect handling incoming responses.
|
||||
|
||||
### Adaptive mode
|
||||
|
||||
The default Adaptive mode will start the node in client mode, and after 15 minutes of running with a publicly accessible address,
|
||||
it will switch to server mode. This way nodes that can serve as routing nodes (accessible and less likely to churn), serve as such.
|
||||
|
||||
If you want to explicitly start in Server mode, because you know you are not running behind firewall,
|
||||
you can call `Dht::builder().server_mode().build()`, and you can optionally add your known public ip so the node doesn't have to depend on,
|
||||
votes from responding nodes: `Dht::builder().server_mode().public_ip().build()`.
|
||||
|
||||
## Acknowledgment
|
||||
|
||||
This implementation was possible thanks to [Webtorrent's Bittorrent-dht](https://github.com/webtorrent/bittorrent-dht) as a reference,
|
||||
and [Rustydht-lib](https://github.com/raptorswing/rustydht-lib) that saved me a lot of time, especially at the serialization and deserialization of Bencode messages.
|
||||
Vendored
+1031
File diff suppressed because it is too large.
Load diff
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
//! Miscellaneous common structs used throughout the library.
|
||||
|
||||
mod id;
|
||||
mod immutable;
|
||||
pub mod messages;
|
||||
mod mutable;
|
||||
mod node;
|
||||
mod routing_table;
|
||||
|
||||
pub use id::*;
|
||||
pub use immutable::*;
|
||||
pub use messages::*;
|
||||
pub(crate) use mutable::most_recent_mutable_item;
|
||||
pub use mutable::*;
|
||||
pub use node::*;
|
||||
pub use routing_table::*;
|
||||
Vendored
+354
@@ -0,0 +1,354 @@
|
||||
//! Kademlia node Id or a lookup target
|
||||
use crc::{Crc, CRC_32_ISCSI};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::convert::TryInto;
|
||||
use std::{
|
||||
fmt::{self, Debug, Display, Formatter},
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
/// The size of node IDs in bits.
|
||||
pub const ID_SIZE: usize = 20;
|
||||
pub const MAX_DISTANCE: u8 = ID_SIZE as u8 * 8;
|
||||
|
||||
const IPV4_MASK: u32 = 0x030f3fff;
|
||||
const CASTAGNOLI: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Ord, PartialOrd, Eq, Hash, Serialize, Deserialize)]
|
||||
/// Kademlia node Id or a lookup target
|
||||
pub struct Id([u8; ID_SIZE]);
|
||||
|
||||
impl Id {
|
||||
/// Generate a random Id
|
||||
pub fn random() -> Id {
|
||||
let mut bytes: [u8; 20] = [0; 20];
|
||||
getrandom::fill(&mut bytes).expect("getrandom");
|
||||
Id(bytes)
|
||||
}
|
||||
|
||||
/// Create a new Id from some bytes. Returns Err if the input is not 20 bytes long.
|
||||
pub fn from_bytes<T: AsRef<[u8]>>(bytes: T) -> Result<Id, InvalidIdSize> {
|
||||
let bytes = bytes.as_ref();
|
||||
if bytes.len() != ID_SIZE {
|
||||
return Err(InvalidIdSize(bytes.len()));
|
||||
}
|
||||
|
||||
let mut tmp: [u8; ID_SIZE] = [0; ID_SIZE];
|
||||
tmp[..ID_SIZE].clone_from_slice(&bytes[..ID_SIZE]);
|
||||
|
||||
Ok(Id(tmp))
|
||||
}
|
||||
|
||||
/// Simplified XOR distance between this Id and a target Id.
|
||||
///
|
||||
/// The distance is the number of trailing non zero bits in the XOR result.
|
||||
///
|
||||
/// Distance to self is 0
|
||||
/// Distance to the furthest Id is 160
|
||||
/// Distance to an Id with 5 leading matching bits is 155
|
||||
pub fn distance(&self, other: &Id) -> u8 {
|
||||
MAX_DISTANCE - self.xor(other).leading_zeros()
|
||||
}
|
||||
|
||||
/// Returns the number of leading zeros in the binary representation of `self`.
|
||||
pub fn leading_zeros(&self) -> u8 {
|
||||
for (i, byte) in self.0.iter().enumerate() {
|
||||
if *byte != 0 {
|
||||
// leading zeros so far + laedinge zeros of this byte
|
||||
return (i as u32 * 8 + byte.leading_zeros()) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
160
|
||||
}
|
||||
|
||||
/// Performs bitwise XOR between two Ids
|
||||
pub fn xor(&self, other: &Id) -> Id {
|
||||
let mut result = [0_u8; 20];
|
||||
|
||||
for (i, (a, b)) in self.0.iter().zip(other.0).enumerate() {
|
||||
result[i] = a ^ b;
|
||||
}
|
||||
|
||||
result.into()
|
||||
}
|
||||
|
||||
/// Returns a byte slice of this Id.
|
||||
pub fn as_bytes(&self) -> &[u8; 20] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Create a new Id according to [BEP_0042](http://bittorrent.org/beps/bep_0042.html).
|
||||
pub fn from_addr(addr: &SocketAddr) -> Id {
|
||||
let ip = addr.ip();
|
||||
|
||||
Id::from_ip(ip)
|
||||
}
|
||||
|
||||
/// Create a new Id from an Ipv4 address according to [BEP_0042](http://bittorrent.org/beps/bep_0042.html).
|
||||
pub fn from_ip(ip: IpAddr) -> Id {
|
||||
match ip {
|
||||
IpAddr::V4(addr) => Id::from_ipv4(addr),
|
||||
IpAddr::V6(_addr) => unimplemented!("Ipv6 is not supported"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new Id from an Ipv4 address according to [BEP_0042](http://bittorrent.org/beps/bep_0042.html).
|
||||
pub fn from_ipv4(ipv4: Ipv4Addr) -> Id {
|
||||
let mut bytes = [0_u8; 21];
|
||||
getrandom::fill(&mut bytes).expect("getrandom");
|
||||
|
||||
from_ipv4_and_r(bytes[1..].try_into().expect("infallible"), ipv4, bytes[0])
|
||||
}
|
||||
|
||||
/// Validate that this Id is valid with respect to [BEP_0042](http://bittorrent.org/beps/bep_0042.html).
|
||||
pub fn is_valid_for_ip(&self, ipv4: Ipv4Addr) -> bool {
|
||||
if ipv4.is_private() || ipv4.is_link_local() || ipv4.is_loopback() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let expected = first_21_bits(&id_prefix_ipv4(ipv4, self.0[ID_SIZE - 1]));
|
||||
|
||||
self.first_21_bits() == expected
|
||||
}
|
||||
|
||||
pub(crate) fn first_21_bits(&self) -> [u8; 3] {
|
||||
first_21_bits(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn first_21_bits(bytes: &[u8]) -> [u8; 3] {
|
||||
[bytes[0], bytes[1], bytes[2] & 0xf8]
|
||||
}
|
||||
|
||||
fn from_ipv4_and_r(bytes: [u8; 20], ip: Ipv4Addr, r: u8) -> Id {
|
||||
let mut bytes = bytes;
|
||||
let prefix = id_prefix_ipv4(ip, r);
|
||||
|
||||
// Set first 21 bits to the prefix
|
||||
bytes[0] = prefix[0];
|
||||
bytes[1] = prefix[1];
|
||||
// set the first 5 bits of the 3rd byte to the remaining 5 bits of the prefix
|
||||
bytes[2] = (prefix[2] & 0xf8) | (bytes[2] & 0x7);
|
||||
|
||||
// Set the last byte to the random r
|
||||
bytes[ID_SIZE - 1] = r;
|
||||
|
||||
Id(bytes)
|
||||
}
|
||||
|
||||
fn id_prefix_ipv4(ip: Ipv4Addr, r: u8) -> [u8; 3] {
|
||||
let r32: u32 = r.into();
|
||||
let ip_int: u32 = u32::from_be_bytes(ip.octets());
|
||||
let masked_ip: u32 = (ip_int & IPV4_MASK) | (r32 << 29);
|
||||
|
||||
let mut digest = CASTAGNOLI.digest();
|
||||
digest.update(&masked_ip.to_be_bytes());
|
||||
|
||||
let crc = digest.finalize();
|
||||
|
||||
crc.to_be_bytes()[..3]
|
||||
.try_into()
|
||||
.expect("Failed to convert bytes 0-2 of the crc into a 3-byte array")
|
||||
}
|
||||
|
||||
impl Display for Id {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
#[allow(clippy::format_collect)]
|
||||
let hex_chars: String = self.0.iter().map(|byte| format!("{:02x}", byte)).collect();
|
||||
|
||||
write!(f, "{}", hex_chars)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<[u8; ID_SIZE]> for Id {
|
||||
fn from(bytes: [u8; ID_SIZE]) -> Id {
|
||||
Id(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&[u8; ID_SIZE]> for Id {
|
||||
fn from(bytes: &[u8; ID_SIZE]) -> Id {
|
||||
Id(*bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Id> for [u8; ID_SIZE] {
|
||||
fn from(value: Id) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Id {
|
||||
type Err = DecodeIdError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Id, DecodeIdError> {
|
||||
if s.len() % 2 != 0 {
|
||||
return Err(DecodeIdError::OddNumberOfCharacters);
|
||||
}
|
||||
|
||||
let mut bytes = Vec::with_capacity(s.len() / 2);
|
||||
|
||||
for i in 0..s.len() / 2 {
|
||||
let byte_str = &s[i * 2..(i * 2) + 2];
|
||||
if let Ok(byte) = u8::from_str_radix(byte_str, 16) {
|
||||
bytes.push(byte);
|
||||
} else {
|
||||
return Err(DecodeIdError::InvalidHexCharacter(byte_str.into()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Id::from_bytes(bytes)?)
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for Id {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Id({})", self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InvalidIdSize(usize);
|
||||
|
||||
impl std::error::Error for InvalidIdSize {}
|
||||
|
||||
impl std::fmt::Display for InvalidIdSize {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Invalid Id size, expected 20, got {0}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
/// Mainline crate error enum.
|
||||
pub enum DecodeIdError {
|
||||
/// Id is expected to by 20 bytes.
|
||||
#[error(transparent)]
|
||||
InvalidIdSize(#[from] InvalidIdSize),
|
||||
|
||||
#[error("Hex encoding should contain an even number of hex characters")]
|
||||
/// Hex encoding should contain an even number of hex characters
|
||||
OddNumberOfCharacters,
|
||||
|
||||
/// Invalid hex character
|
||||
#[error("Invalid Id encoding: {0}")]
|
||||
InvalidHexCharacter(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn distance_to_self() {
|
||||
let id = Id::random();
|
||||
let distance = id.distance(&id);
|
||||
assert_eq!(distance, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_to_id() {
|
||||
let id = Id::from_str("0639A1E24FBB8AB277DF033476AB0DE10FAB3BDC").unwrap();
|
||||
|
||||
let target = Id::from_str("035b1aeb9737ade1a80933594f405d3f772aa08e").unwrap();
|
||||
|
||||
let distance = id.distance(&target);
|
||||
|
||||
assert_eq!(distance, 155)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_to_random_id() {
|
||||
let id = Id::random();
|
||||
let target = Id::random();
|
||||
|
||||
let distance = id.distance(&target);
|
||||
|
||||
assert_ne!(distance, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_to_furthest() {
|
||||
let id = Id::random();
|
||||
|
||||
let mut opposite = [0_u8; 20];
|
||||
for (i, &value) in id.as_bytes().iter().enumerate() {
|
||||
opposite[i] = value ^ 0xff;
|
||||
}
|
||||
let target = Id::from_bytes(opposite).unwrap();
|
||||
|
||||
let distance = id.distance(&target);
|
||||
|
||||
assert_eq!(distance, MAX_DISTANCE)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_u8_20() {
|
||||
let bytes = [8; 20];
|
||||
|
||||
let id: Id = bytes.into();
|
||||
|
||||
assert_eq!(*id.as_bytes(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_ipv4() {
|
||||
let vectors = vec![
|
||||
(Ipv4Addr::new(124, 31, 75, 21), 1, [0x5f, 0xbf, 0xbf]),
|
||||
(Ipv4Addr::new(21, 75, 31, 124), 86, [0x5a, 0x3c, 0xe9]),
|
||||
(Ipv4Addr::new(65, 23, 51, 170), 22, [0xa5, 0xd4, 0x32]),
|
||||
(Ipv4Addr::new(84, 124, 73, 14), 65, [0x1b, 0x03, 0x21]),
|
||||
(Ipv4Addr::new(43, 213, 53, 83), 90, [0xe5, 0x6f, 0x6c]),
|
||||
];
|
||||
|
||||
for vector in vectors {
|
||||
test(vector.0, vector.1, vector.2);
|
||||
}
|
||||
|
||||
fn test(ip: Ipv4Addr, r: u8, expected_prefix: [u8; 3]) {
|
||||
let id = Id::random();
|
||||
let result = from_ipv4_and_r(*id.as_bytes(), ip, r);
|
||||
let prefix = first_21_bits(result.as_bytes());
|
||||
|
||||
assert_eq!(prefix, first_21_bits(&expected_prefix));
|
||||
assert_eq!(result.as_bytes()[ID_SIZE - 1], r);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_for_ipv4() {
|
||||
let valid_vectors = vec![
|
||||
(
|
||||
Ipv4Addr::new(124, 31, 75, 21),
|
||||
"5fbfbff10c5d6a4ec8a88e4c6ab4c28b95eee401",
|
||||
),
|
||||
(
|
||||
Ipv4Addr::new(21, 75, 31, 124),
|
||||
"5a3ce9c14e7a08645677bbd1cfe7d8f956d53256",
|
||||
),
|
||||
(
|
||||
Ipv4Addr::new(65, 23, 51, 170),
|
||||
"a5d43220bc8f112a3d426c84764f8c2a1150e616",
|
||||
),
|
||||
(
|
||||
Ipv4Addr::new(84, 124, 73, 14),
|
||||
"1b0321dd1bb1fe518101ceef99462b947a01ff41",
|
||||
),
|
||||
(
|
||||
Ipv4Addr::new(43, 213, 53, 83),
|
||||
"e56f6cbf5b7c4be0237986d5243b87aa6d51305a",
|
||||
),
|
||||
];
|
||||
|
||||
for vector in valid_vectors {
|
||||
test(vector.0, vector.1);
|
||||
}
|
||||
|
||||
fn test(ip: Ipv4Addr, hex: &str) {
|
||||
let id = Id::from_str(hex).unwrap();
|
||||
|
||||
assert!(id.is_valid_for_ip(ip));
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
//! Helper functions for immutable items.
|
||||
|
||||
use sha1_smol::Sha1;
|
||||
|
||||
use super::ID_SIZE;
|
||||
use crate::Id;
|
||||
|
||||
pub fn validate_immutable(v: &[u8], target: Id) -> bool {
|
||||
hash_immutable(v) == *target.as_bytes()
|
||||
}
|
||||
|
||||
pub fn hash_immutable(v: &[u8]) -> [u8; ID_SIZE] {
|
||||
let mut encoded = Vec::with_capacity(v.len() + 3);
|
||||
encoded.extend(format!("{}:", v.len()).bytes());
|
||||
encoded.extend_from_slice(v);
|
||||
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(&encoded);
|
||||
|
||||
hasher.digest().bytes()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn test_validate_immutable() {
|
||||
let v = vec![
|
||||
171, 118, 111, 111, 174, 109, 195, 32, 138, 140, 113, 176, 76, 135, 116, 132, 156, 126,
|
||||
75, 173,
|
||||
];
|
||||
|
||||
let target = Id::from_bytes([
|
||||
2, 23, 113, 43, 67, 11, 185, 26, 26, 30, 204, 238, 204, 1, 13, 84, 52, 40, 86, 231,
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert!(validate_immutable(&v, target));
|
||||
assert!(!validate_immutable(&v[1..], target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn test_hash_immutable() {
|
||||
let v = b"From the river to the sea, Palestine will be free";
|
||||
let target = Id::from_str("4238af8aff56cf6e0007d9d2003bf23d33eea7c3").unwrap();
|
||||
|
||||
assert_eq!(hash_immutable(v), *target.as_bytes());
|
||||
}
|
||||
}
|
||||
+1255
File diff suppressed because it is too large.
Load diff
+333
@@ -0,0 +1,333 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_bytes::ByteBuf;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTMessage {
|
||||
#[serde(rename = "t", with = "serde_bytes")]
|
||||
// Only few messages received seems to not use exactly 2 bytes,
|
||||
// and they don't seem to have a version.
|
||||
pub transaction_id: [u8; 4],
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(rename = "v", with = "serde_bytes")]
|
||||
pub version: Option<[u8; 4]>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub variant: DHTMessageVariant,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(with = "serde_bytes")]
|
||||
// Ipv6 is not supported anyways.
|
||||
pub ip: Option<[u8; 6]>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(rename = "ro")]
|
||||
pub read_only: Option<i32>,
|
||||
}
|
||||
|
||||
impl DHTMessage {
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<DHTMessage, serde_bencode::Error> {
|
||||
let obj = serde_bencode::from_bytes(bytes)?;
|
||||
Ok(obj)
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, serde_bencode::Error> {
|
||||
serde_bencode::to_bytes(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
#[serde(tag = "y")]
|
||||
pub enum DHTMessageVariant {
|
||||
#[serde(rename = "q")]
|
||||
Request(DHTRequestSpecific),
|
||||
|
||||
#[serde(rename = "r")]
|
||||
Response(DHTResponseSpecific),
|
||||
|
||||
#[serde(rename = "e")]
|
||||
Error(DHTErrorSpecific),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
#[serde(tag = "q")]
|
||||
pub enum DHTRequestSpecific {
|
||||
#[serde(rename = "ping")]
|
||||
Ping {
|
||||
#[serde(rename = "a")]
|
||||
arguments: DHTPingRequestArguments,
|
||||
},
|
||||
|
||||
#[serde(rename = "find_node")]
|
||||
FindNode {
|
||||
#[serde(rename = "a")]
|
||||
arguments: DHTFindNodeRequestArguments,
|
||||
},
|
||||
|
||||
#[serde(rename = "get_peers")]
|
||||
GetPeers {
|
||||
#[serde(rename = "a")]
|
||||
arguments: DHTGetPeersRequestArguments,
|
||||
},
|
||||
|
||||
#[serde(rename = "announce_peer")]
|
||||
AnnouncePeer {
|
||||
#[serde(rename = "a")]
|
||||
arguments: DHTAnnouncePeerRequestArguments,
|
||||
},
|
||||
|
||||
#[serde(rename = "get")]
|
||||
GetValue {
|
||||
#[serde(rename = "a")]
|
||||
arguments: DHTGetValueRequestArguments,
|
||||
},
|
||||
|
||||
#[serde(rename = "put")]
|
||||
PutValue {
|
||||
#[serde(rename = "a")]
|
||||
arguments: DHTPutValueRequestArguments,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
#[serde(untagged)] // This means order matters! Order these from most to least detailed
|
||||
pub enum DHTResponseSpecific {
|
||||
GetMutable {
|
||||
#[serde(rename = "r")]
|
||||
arguments: DHTGetMutableResponseArguments,
|
||||
},
|
||||
|
||||
NoMoreRecentValue {
|
||||
#[serde(rename = "r")]
|
||||
arguments: DHTNoMoreRecentValueResponseArguments,
|
||||
},
|
||||
|
||||
GetImmutable {
|
||||
#[serde(rename = "r")]
|
||||
arguments: DHTGetImmutableResponseArguments,
|
||||
},
|
||||
|
||||
GetPeers {
|
||||
#[serde(rename = "r")]
|
||||
arguments: DHTGetPeersResponseArguments,
|
||||
},
|
||||
|
||||
NoValues {
|
||||
#[serde(rename = "r")]
|
||||
arguments: DHTNoValuesResponseArguments,
|
||||
},
|
||||
|
||||
FindNode {
|
||||
#[serde(rename = "r")]
|
||||
arguments: DHTFindNodeResponseArguments,
|
||||
},
|
||||
|
||||
Ping {
|
||||
#[serde(rename = "r")]
|
||||
arguments: DHTPingResponseArguments,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTErrorSpecific {
|
||||
#[serde(rename = "e")]
|
||||
pub error_info: (i32, String),
|
||||
}
|
||||
|
||||
// === PING ===
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTPingRequestArguments {
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub id: [u8; 20],
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTPingResponseArguments {
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub id: [u8; 20],
|
||||
}
|
||||
|
||||
// === FIND NODE ===
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTFindNodeRequestArguments {
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub id: [u8; 20],
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub target: [u8; 20],
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTFindNodeResponseArguments {
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub id: [u8; 20],
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub nodes: Box<[u8]>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTNoValuesResponseArguments {
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub id: [u8; 20],
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub token: Box<[u8]>,
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
#[serde(default)]
|
||||
pub nodes: Option<Box<[u8]>>,
|
||||
}
|
||||
|
||||
// === Get Peers ===
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTGetPeersRequestArguments {
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub id: [u8; 20],
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub info_hash: [u8; 20],
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTGetPeersResponseArguments {
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub id: [u8; 20],
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub token: Box<[u8]>,
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
#[serde(default)]
|
||||
pub nodes: Option<Box<[u8]>>,
|
||||
|
||||
// values are not optional, because if they are missing this missing
|
||||
// we can just treat this as DHTNoValuesResponseArguments
|
||||
pub values: Vec<ByteBuf>,
|
||||
}
|
||||
|
||||
// === Announce Peer ===
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTAnnouncePeerRequestArguments {
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub id: [u8; 20],
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub info_hash: [u8; 20],
|
||||
|
||||
pub port: u16,
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub token: Box<[u8]>,
|
||||
|
||||
#[serde(default)]
|
||||
pub implied_port: Option<u8>,
|
||||
}
|
||||
|
||||
// === Get Value ===
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTGetValueRequestArguments {
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub id: [u8; 20],
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub target: [u8; 20],
|
||||
|
||||
#[serde(default)]
|
||||
pub seq: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTGetImmutableResponseArguments {
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub id: [u8; 20],
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub token: Box<[u8]>,
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
#[serde(default)]
|
||||
pub nodes: Option<Box<[u8]>>,
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub v: Box<[u8]>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTNoMoreRecentValueResponseArguments {
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub id: [u8; 20],
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub token: Box<[u8]>,
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
#[serde(default)]
|
||||
pub nodes: Option<Box<[u8]>>,
|
||||
|
||||
pub seq: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTGetMutableResponseArguments {
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub id: [u8; 20],
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub token: Box<[u8]>,
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
#[serde(default)]
|
||||
pub nodes: Option<Box<[u8]>>,
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub v: Box<[u8]>,
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub k: [u8; 32],
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub sig: [u8; 64],
|
||||
|
||||
pub seq: i64,
|
||||
}
|
||||
|
||||
// === Put Value ===
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DHTPutValueRequestArguments {
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub id: [u8; 20],
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub target: [u8; 20],
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub token: Box<[u8]>,
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub v: Box<[u8]>,
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
#[serde(default)]
|
||||
pub k: Option<[u8; 32]>,
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
#[serde(default)]
|
||||
pub sig: Option<[u8; 64]>,
|
||||
|
||||
#[serde(default)]
|
||||
pub seq: Option<i64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub cas: Option<i64>,
|
||||
|
||||
#[serde(with = "serde_bytes")]
|
||||
#[serde(default)]
|
||||
pub salt: Option<Box<[u8]>>,
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
//! Helper functions and structs for mutable items.
|
||||
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha1_smol::Sha1;
|
||||
use std::convert::TryFrom;
|
||||
|
||||
use crate::Id;
|
||||
|
||||
use super::PutMutableRequestArguments;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
/// [BEP_0044](https://www.bittorrent.org/beps/bep_0044.html)'s Mutable item.
|
||||
pub struct MutableItem {
|
||||
/// hash of the key and optional salt
|
||||
target: Id,
|
||||
/// ed25519 public key
|
||||
key: [u8; 32],
|
||||
/// sequence number
|
||||
pub(crate) seq: i64,
|
||||
/// mutable value
|
||||
pub(crate) value: Box<[u8]>,
|
||||
/// ed25519 signature
|
||||
#[serde(with = "serde_bytes")]
|
||||
signature: [u8; 64],
|
||||
/// Optional salt
|
||||
salt: Option<Box<[u8]>>,
|
||||
}
|
||||
|
||||
impl MutableItem {
|
||||
/// Create a new mutable item from a signing key, value, sequence number and optional salt.
|
||||
pub fn new(signer: SigningKey, value: &[u8], seq: i64, salt: Option<&[u8]>) -> Self {
|
||||
let signable = encode_signable(seq, value, salt);
|
||||
let signature = signer.sign(&signable);
|
||||
|
||||
Self::new_signed_unchecked(
|
||||
signer.verifying_key().to_bytes(),
|
||||
signature.into(),
|
||||
value,
|
||||
seq,
|
||||
salt,
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the target of a [MutableItem] by hashing its `public_key` and an optional `salt`
|
||||
pub fn target_from_key(public_key: &[u8; 32], salt: Option<&[u8]>) -> Id {
|
||||
let mut encoded = vec![];
|
||||
|
||||
encoded.extend(public_key);
|
||||
|
||||
if let Some(salt) = salt {
|
||||
encoded.extend(salt);
|
||||
}
|
||||
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(&encoded);
|
||||
let bytes = hasher.digest().bytes();
|
||||
|
||||
bytes.into()
|
||||
}
|
||||
|
||||
/// Create a new mutable item from an already signed value.
|
||||
pub fn new_signed_unchecked(
|
||||
key: [u8; 32],
|
||||
signature: [u8; 64],
|
||||
value: &[u8],
|
||||
seq: i64,
|
||||
salt: Option<&[u8]>,
|
||||
) -> Self {
|
||||
Self {
|
||||
target: MutableItem::target_from_key(&key, salt),
|
||||
key,
|
||||
value: value.into(),
|
||||
seq,
|
||||
signature,
|
||||
salt: salt.map(|s| s.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_dht_message(
|
||||
target: Id,
|
||||
key: &[u8],
|
||||
v: Box<[u8]>,
|
||||
seq: i64,
|
||||
signature: &[u8],
|
||||
salt: Option<Box<[u8]>>,
|
||||
) -> Result<Self, MutableError> {
|
||||
let key = VerifyingKey::try_from(key).map_err(|_| MutableError::InvalidMutablePublicKey)?;
|
||||
|
||||
let signature =
|
||||
Signature::from_slice(signature).map_err(|_| MutableError::InvalidMutableSignature)?;
|
||||
|
||||
key.verify(&encode_signable(seq, &v, salt.as_deref()), &signature)
|
||||
.map_err(|_| MutableError::InvalidMutableSignature)?;
|
||||
|
||||
if Self::target_from_key(&key.to_bytes(), salt.as_deref()) != target {
|
||||
return Err(MutableError::InvalidMutableTarget);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
target,
|
||||
key: key.to_bytes(),
|
||||
value: v,
|
||||
seq,
|
||||
signature: signature.to_bytes(),
|
||||
salt,
|
||||
})
|
||||
}
|
||||
|
||||
// === Getters ===
|
||||
|
||||
/// Returns the target (info hash) of this item.
|
||||
pub fn target(&self) -> &Id {
|
||||
&self.target
|
||||
}
|
||||
|
||||
/// Returns a reference to the 32 bytes Ed25519 public key of this item.
|
||||
pub fn key(&self) -> &[u8; 32] {
|
||||
&self.key
|
||||
}
|
||||
|
||||
/// Returns a byte slice of the value of this item.
|
||||
pub fn value(&self) -> &[u8] {
|
||||
&self.value
|
||||
}
|
||||
|
||||
/// Returns the `seq` (sequence) number of this item.
|
||||
pub fn seq(&self) -> i64 {
|
||||
self.seq
|
||||
}
|
||||
|
||||
/// Returns the signature over this item.
|
||||
pub fn signature(&self) -> &[u8; 64] {
|
||||
&self.signature
|
||||
}
|
||||
|
||||
/// Returns the `Salt` value used for generating the
|
||||
/// [Self::target] if any.
|
||||
pub fn salt(&self) -> Option<&[u8]> {
|
||||
self.salt.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_signable(seq: i64, value: &[u8], salt: Option<&[u8]>) -> Box<[u8]> {
|
||||
let mut signable = vec![];
|
||||
|
||||
if let Some(salt) = salt {
|
||||
signable.extend(format!("4:salt{}:", salt.len()).into_bytes());
|
||||
signable.extend(salt);
|
||||
}
|
||||
|
||||
signable.extend(format!("3:seqi{}e1:v{}:", seq, value.len()).into_bytes());
|
||||
signable.extend(value);
|
||||
|
||||
signable.into()
|
||||
}
|
||||
|
||||
pub(crate) fn most_recent_mutable_item(
|
||||
most_recent: Option<MutableItem>,
|
||||
item: MutableItem,
|
||||
) -> Option<MutableItem> {
|
||||
match most_recent {
|
||||
Some(mr)
|
||||
if mr.seq() > item.seq() || (mr.seq() == item.seq() && mr.value() >= item.value()) =>
|
||||
{
|
||||
Some(mr)
|
||||
}
|
||||
_ => Some(item),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
/// Mainline crate error enum.
|
||||
pub enum MutableError {
|
||||
#[error("Invalid mutable item signature")]
|
||||
/// Invalid mutable item signature
|
||||
InvalidMutableSignature,
|
||||
|
||||
#[error("Invalid mutable item public key")]
|
||||
/// Invalid mutable item public key
|
||||
InvalidMutablePublicKey,
|
||||
|
||||
#[error("Mutable item target does not match its public key and salt")]
|
||||
/// Mutable item target does not match its public key and salt
|
||||
InvalidMutableTarget,
|
||||
}
|
||||
|
||||
impl PutMutableRequestArguments {
|
||||
/// Create a [PutMutableRequestArguments] from a [MutableItem],
|
||||
/// and an optional CAS condition, which is usually the [MutableItem::seq]
|
||||
/// of the most recent known [MutableItem]
|
||||
pub fn from(item: MutableItem, cas: Option<i64>) -> Self {
|
||||
Self {
|
||||
target: item.target,
|
||||
v: item.value,
|
||||
k: item.key,
|
||||
seq: item.seq,
|
||||
sig: item.signature,
|
||||
salt: item.salt,
|
||||
cas,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
#[test]
|
||||
fn signable_without_salt() {
|
||||
let signable = encode_signable(4, b"Hello world!", None);
|
||||
|
||||
assert_eq!(&*signable, b"3:seqi4e1:v12:Hello world!");
|
||||
}
|
||||
#[test]
|
||||
fn signable_with_salt() {
|
||||
let signable = encode_signable(4, b"Hello world!", Some(b"foobar"));
|
||||
|
||||
assert_eq!(&*signable, b"4:salt6:foobar3:seqi4e1:v12:Hello world!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn most_recent_mutable_item_selects_by_seq_then_value() {
|
||||
let signer = SigningKey::from_bytes(&[
|
||||
56, 171, 62, 85, 105, 58, 155, 209, 189, 8, 59, 109, 137, 84, 84, 201, 221, 115, 7,
|
||||
228, 127, 70, 4, 204, 182, 64, 77, 98, 92, 215, 27, 103,
|
||||
]);
|
||||
|
||||
let lower_seq = MutableItem::new(signer.clone(), b"lower-seq", 999, None);
|
||||
let current = MutableItem::new(signer.clone(), b"current1", 1000, None);
|
||||
let higher_seq = MutableItem::new(signer.clone(), b"higher-seq", 1001, None);
|
||||
let same_seq_lower_value = MutableItem::new(signer.clone(), b"current0", 1000, None);
|
||||
let same_seq_higher_value = MutableItem::new(signer, b"current2", 1000, None);
|
||||
|
||||
assert_eq!(
|
||||
most_recent_mutable_item(None, current.clone()),
|
||||
Some(current.clone())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
most_recent_mutable_item(Some(current.clone()), higher_seq.clone()),
|
||||
Some(higher_seq)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
most_recent_mutable_item(Some(current.clone()), lower_seq),
|
||||
Some(current.clone())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
most_recent_mutable_item(Some(current.clone()), same_seq_higher_value.clone()),
|
||||
Some(same_seq_higher_value)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
most_recent_mutable_item(Some(current.clone()), same_seq_lower_value),
|
||||
Some(current.clone())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
most_recent_mutable_item(Some(current.clone()), current.clone()),
|
||||
Some(current)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_dht_message_rejects_a_signed_item_for_a_different_target() {
|
||||
let signer = SigningKey::from_bytes(&[42; 32]);
|
||||
let item = MutableItem::new(signer, b"value", 1, Some(b"salt"));
|
||||
let mut wrong_target = *item.target().as_bytes();
|
||||
wrong_target[0] ^= 1;
|
||||
|
||||
let result = MutableItem::from_dht_message(
|
||||
wrong_target.into(),
|
||||
item.key(),
|
||||
item.value().into(),
|
||||
item.seq(),
|
||||
item.signature(),
|
||||
item.salt().map(Into::into),
|
||||
);
|
||||
|
||||
assert!(matches!(result, Err(MutableError::InvalidMutableTarget)));
|
||||
}
|
||||
}
|
||||
Vendored
+142
@@ -0,0 +1,142 @@
|
||||
//! Struct and implementation of the Node entry in the Kademlia routing table
|
||||
use std::{
|
||||
fmt::{self, Debug, Formatter},
|
||||
net::SocketAddrV4,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::common::Id;
|
||||
|
||||
/// The age of a node's last_seen time before it is considered stale and removed from a full bucket
|
||||
/// on inserting a new node.
|
||||
pub const STALE_TIME: Duration = Duration::from_secs(15 * 60);
|
||||
const MIN_PING_BACKOFF_INTERVAL: Duration = Duration::from_secs(10);
|
||||
pub const TOKEN_ROTATE_INTERVAL: Duration = Duration::from_secs(60 * 5);
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub(crate) struct NodeInner {
|
||||
pub(crate) id: Id,
|
||||
pub(crate) address: SocketAddrV4,
|
||||
pub(crate) token: Option<Box<[u8]>>,
|
||||
pub(crate) last_seen: Instant,
|
||||
}
|
||||
|
||||
impl NodeInner {
|
||||
pub fn random() -> Self {
|
||||
Self {
|
||||
id: Id::random(),
|
||||
address: SocketAddrV4::new(0.into(), 0),
|
||||
token: None,
|
||||
last_seen: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
/// Node entry in Kademlia routing table
|
||||
pub struct Node(pub(crate) Arc<NodeInner>);
|
||||
|
||||
impl Debug for Node {
|
||||
fn fmt(&self, fmt: &mut Formatter<'_>) -> fmt::Result {
|
||||
fmt.debug_struct("Node")
|
||||
.field("id", &self.0.id)
|
||||
.field("address", &self.0.address)
|
||||
.field("last_seen", &self.0.last_seen.elapsed().as_secs())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Creates a new Node from an id and socket address.
|
||||
pub fn new(id: Id, address: SocketAddrV4) -> Node {
|
||||
Node(Arc::new(NodeInner {
|
||||
id,
|
||||
address,
|
||||
token: None,
|
||||
last_seen: Instant::now(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn new_with_token(id: Id, address: SocketAddrV4, token: Box<[u8]>) -> Self {
|
||||
Node(Arc::new(NodeInner {
|
||||
id,
|
||||
address,
|
||||
token: Some(token),
|
||||
last_seen: Instant::now(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Creates a node with random Id for testing purposes.
|
||||
pub fn random() -> Node {
|
||||
Node(Arc::new(NodeInner::random()))
|
||||
}
|
||||
|
||||
/// Create a node that is unique per `i` as it has a random Id and sets IP and port to `i`
|
||||
#[cfg(test)]
|
||||
pub fn unique(i: usize) -> Node {
|
||||
Node::new(Id::random(), SocketAddrV4::new((i as u32).into(), i as u16))
|
||||
}
|
||||
|
||||
// === Getters ===
|
||||
|
||||
/// Returns the id of this node
|
||||
pub fn id(&self) -> &Id {
|
||||
&self.0.id
|
||||
}
|
||||
|
||||
/// Returns the address of this node
|
||||
pub fn address(&self) -> SocketAddrV4 {
|
||||
self.0.address
|
||||
}
|
||||
|
||||
/// Returns the token we received from this node if any.
|
||||
pub fn token(&self) -> Option<Box<[u8]>> {
|
||||
self.0.token.clone()
|
||||
}
|
||||
|
||||
/// Node is last seen more than a threshold ago.
|
||||
pub fn is_stale(&self) -> bool {
|
||||
self.0.last_seen.elapsed() > STALE_TIME
|
||||
}
|
||||
|
||||
/// Node's token was received 5 minutes ago or less
|
||||
pub fn valid_token(&self) -> bool {
|
||||
self.0.last_seen.elapsed() <= TOKEN_ROTATE_INTERVAL
|
||||
}
|
||||
|
||||
pub(crate) fn should_ping(&self) -> bool {
|
||||
self.0.last_seen.elapsed() > MIN_PING_BACKOFF_INTERVAL
|
||||
}
|
||||
|
||||
/// Returns true if both nodes have the same ip and port
|
||||
pub fn same_address(&self, other: &Self) -> bool {
|
||||
self.0.address == other.0.address
|
||||
}
|
||||
|
||||
/// Returns true if both nodes have the same ip
|
||||
pub fn same_ip(&self, other: &Self) -> bool {
|
||||
self.0.address.ip() == other.0.address.ip()
|
||||
}
|
||||
|
||||
/// Node [Id] is valid for its IP address.
|
||||
///
|
||||
/// Check [BEP_0042](https://www.bittorrent.org/beps/bep_0042.html).
|
||||
pub fn is_secure(&self) -> bool {
|
||||
self.0.id.is_valid_for_ip(*self.0.address.ip())
|
||||
}
|
||||
|
||||
/// Returns true if Any of the existing nodes:
|
||||
/// - Have the same IP as this node, And:
|
||||
/// = The existing nodes is Not secure.
|
||||
/// = The existing nodes is secure And shares the same first 21 bits.
|
||||
///
|
||||
/// Effectively, allows only One non-secure node or Eight secure nodes from the same IP, in the routing table or ClosestNodes.
|
||||
pub(crate) fn already_exists(&self, nodes: &[Self]) -> bool {
|
||||
nodes.iter().any(|existing| {
|
||||
self.same_ip(existing)
|
||||
&& (!existing.is_secure()
|
||||
|| self.id().first_21_bits() == existing.id().first_21_bits())
|
||||
})
|
||||
}
|
||||
}
|
||||
+649
@@ -0,0 +1,649 @@
|
||||
//! Simplified Kademlia routing table
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::slice::Iter;
|
||||
|
||||
use crate::common::{Id, Node};
|
||||
use crate::rpc::ClosestNodes;
|
||||
|
||||
/// K = the default maximum size of a k-bucket.
|
||||
pub const MAX_BUCKET_SIZE_K: usize = 20;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Simplified Kademlia routing table
|
||||
pub struct RoutingTable {
|
||||
id: Id,
|
||||
buckets: BTreeMap<u8, KBucket>,
|
||||
}
|
||||
|
||||
impl RoutingTable {
|
||||
/// Create a new [RoutingTable] with a given id.
|
||||
pub fn new(id: Id) -> Self {
|
||||
let buckets = BTreeMap::new();
|
||||
|
||||
RoutingTable { id, buckets }
|
||||
}
|
||||
|
||||
/// Returns the [Id] of this node, where the distance is measured from.
|
||||
pub fn id(&self) -> &Id {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Returns the map of distances and their [KBucket]
|
||||
pub(crate) fn buckets(&self) -> &BTreeMap<u8, KBucket> {
|
||||
&self.buckets
|
||||
}
|
||||
|
||||
// === Public Methods ===
|
||||
|
||||
/// Attempts to add a node to this routing table, and return `true` if it did.
|
||||
pub fn add(&mut self, node: Node) -> bool {
|
||||
let distance = self.id.distance(node.id());
|
||||
|
||||
if distance == 0 {
|
||||
// Do not add self to the routing_table
|
||||
return false;
|
||||
}
|
||||
|
||||
if self
|
||||
.buckets()
|
||||
.values()
|
||||
.any(|bucket| node.already_exists(&bucket.nodes))
|
||||
{
|
||||
return false;
|
||||
};
|
||||
|
||||
let bucket = self.buckets.entry(distance).or_default();
|
||||
|
||||
bucket.add(node)
|
||||
}
|
||||
|
||||
/// Remove a node from this routing table.
|
||||
pub fn remove(&mut self, node_id: &Id) {
|
||||
let distance = self.id.distance(node_id);
|
||||
|
||||
if let Some(bucket) = self.buckets.get_mut(&distance) {
|
||||
bucket.remove(node_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the closest nodes to the target while prioritizing secure nodes,
|
||||
/// as defined in [BEP_0042](https://www.bittorrent.org/beps/bep_0042.html)
|
||||
pub fn closest(&self, target: Id) -> Box<[Node]> {
|
||||
let mut closest = ClosestNodes::new(target);
|
||||
|
||||
for bucket in self.buckets.values() {
|
||||
for node in &bucket.nodes {
|
||||
closest.add(node.clone());
|
||||
}
|
||||
}
|
||||
|
||||
closest.nodes()[..MAX_BUCKET_SIZE_K.min(closest.len())].into()
|
||||
}
|
||||
|
||||
/// Secure version of [Self::closest] that tries to circumvent sybil attacks.
|
||||
pub fn closest_secure(
|
||||
&self,
|
||||
target: Id,
|
||||
dht_size_estimate: usize,
|
||||
subnets: usize,
|
||||
) -> Vec<Node> {
|
||||
let mut closest = ClosestNodes::new(target);
|
||||
|
||||
for node in self.nodes() {
|
||||
closest.add(node);
|
||||
}
|
||||
|
||||
closest
|
||||
.take_until_secure(dht_size_estimate, subnets)
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
/// Returns `true` if this routing table is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.buckets.values().all(|bucket| bucket.is_empty())
|
||||
}
|
||||
|
||||
/// Return the number of nodes in this routing table.
|
||||
pub fn size(&self) -> usize {
|
||||
self.buckets
|
||||
.values()
|
||||
.fold(0, |acc, bucket| acc + bucket.nodes.len())
|
||||
}
|
||||
|
||||
/// Returns an iterator over the nodes in this routing table.
|
||||
pub fn nodes(&self) -> RoutingTableIterator<'_> {
|
||||
RoutingTableIterator {
|
||||
bucket_index: 1,
|
||||
node_index: 0,
|
||||
table: self,
|
||||
}
|
||||
}
|
||||
|
||||
/// Export an owned vector of nodes from this routing table.
|
||||
pub fn to_owned_nodes(&self) -> Vec<Node> {
|
||||
self.nodes().collect()
|
||||
}
|
||||
|
||||
/// Turn this routing table to a list of bootstrapping nodes.
|
||||
pub fn to_bootstrap(&self) -> Vec<String> {
|
||||
self.nodes()
|
||||
.filter(|n| !n.is_stale())
|
||||
.map(|n| n.address().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// === Private Methods ===
|
||||
|
||||
#[cfg(test)]
|
||||
fn contains(&self, node_id: &Id) -> bool {
|
||||
let distance = self.id.distance(node_id);
|
||||
|
||||
if let Some(bucket) = self.buckets.get(&distance) {
|
||||
if bucket.contains(node_id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RoutingTableIterator<'a> {
|
||||
bucket_index: u8,
|
||||
node_index: usize,
|
||||
table: &'a RoutingTable,
|
||||
}
|
||||
|
||||
impl Iterator for RoutingTableIterator<'_> {
|
||||
type Item = Node;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
while self.bucket_index <= 160 {
|
||||
if let Some(current_bucket) = self.table.buckets.get(&self.bucket_index) {
|
||||
if let Some(current_node) = current_bucket.nodes.get(self.node_index) {
|
||||
self.node_index += 1;
|
||||
|
||||
if self.node_index == current_bucket.nodes.len() {
|
||||
self.node_index = 0;
|
||||
self.bucket_index += 1;
|
||||
}
|
||||
|
||||
return Some(current_node.clone());
|
||||
}
|
||||
};
|
||||
|
||||
self.bucket_index += 1;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Kbuckets are similar to LRU caches that checks and evicts unresponsive nodes,
|
||||
/// without dropping any responsive nodes in the process.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KBucket {
|
||||
/// Nodes in the k-bucket, sorted by the least recently seen.
|
||||
nodes: Vec<Node>,
|
||||
}
|
||||
|
||||
impl KBucket {
|
||||
pub fn new() -> Self {
|
||||
KBucket {
|
||||
nodes: Vec::with_capacity(MAX_BUCKET_SIZE_K),
|
||||
}
|
||||
}
|
||||
|
||||
// === Getters ===
|
||||
|
||||
// === Public Methods ===
|
||||
|
||||
pub fn add(&mut self, incoming: Node) -> bool {
|
||||
if let Some(index) = self.iter().position(|n| n.id() == incoming.id()) {
|
||||
let existing = self.nodes[index].clone();
|
||||
|
||||
// If the incoming node is secure, then we trust its IP address for this Id,
|
||||
// and even if it changed its port number, we should accept it.
|
||||
//
|
||||
// If neither nodes are secure for this Id, but the incoming is the same IP,
|
||||
// then add the incoming one, effectively updating the node's
|
||||
// `last_seen` and moving it to the end of the bucket.
|
||||
// Possibly also updating the port, which is a good thing, instead of waiting
|
||||
// for the old port to timeout (not responding to Pings).
|
||||
//
|
||||
// Using same ip instead of same address, allow
|
||||
if incoming.is_secure() || (!existing.is_secure() && existing.same_ip(&incoming)) {
|
||||
self.nodes.remove(index);
|
||||
self.nodes.push(incoming);
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else if self.nodes.len() < MAX_BUCKET_SIZE_K {
|
||||
self.nodes.push(incoming);
|
||||
true
|
||||
} else if self.nodes[0].is_stale() {
|
||||
// Remove the least recently seen node and add the new one
|
||||
self.nodes.remove(0);
|
||||
self.nodes.push(incoming);
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, node_id: &Id) {
|
||||
self.nodes.retain(|node| node.id() != node_id);
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> Iter<'_, Node> {
|
||||
self.nodes.iter()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn contains(&self, id: &Id) -> bool {
|
||||
self.iter().any(|node| node.id() == id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KBucket {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::net::SocketAddrV4;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::common::{Id, KBucket, Node, NodeInner, RoutingTable, MAX_BUCKET_SIZE_K};
|
||||
|
||||
#[test]
|
||||
fn table_is_empty() {
|
||||
let mut table = RoutingTable::new(Id::random());
|
||||
assert!(table.is_empty());
|
||||
|
||||
table.add(Node::random());
|
||||
assert!(!table.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_vec() {
|
||||
let mut table = RoutingTable::new(Id::random());
|
||||
|
||||
let mut expected_nodes: Vec<Node> = vec![];
|
||||
|
||||
for i in 0..MAX_BUCKET_SIZE_K {
|
||||
expected_nodes.push(Node::unique(i));
|
||||
}
|
||||
|
||||
for node in &expected_nodes {
|
||||
table.add(node.clone());
|
||||
}
|
||||
|
||||
let mut sorted_table = table.nodes().collect::<Vec<_>>();
|
||||
sorted_table.sort_by(|a, b| a.id().cmp(b.id()));
|
||||
|
||||
let mut sorted_expected = expected_nodes.to_vec();
|
||||
sorted_expected.sort_by(|a, b| a.id().cmp(b.id()));
|
||||
|
||||
assert_eq!(sorted_table, sorted_expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains() {
|
||||
let mut table = RoutingTable::new(Id::random());
|
||||
|
||||
let node = Node::random();
|
||||
|
||||
assert!(!table.contains(node.id()));
|
||||
|
||||
table.add(node.clone());
|
||||
assert!(table.contains(node.id()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove() {
|
||||
let mut table = RoutingTable::new(Id::random());
|
||||
|
||||
let node = Node::random();
|
||||
|
||||
table.add(node.clone());
|
||||
assert!(table.contains(node.id()));
|
||||
|
||||
table.remove(node.id());
|
||||
assert!(!table.contains(node.id()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buckets_are_sets() {
|
||||
let mut table = RoutingTable::new(Id::random());
|
||||
|
||||
let node1 = Node::random();
|
||||
let node2 = Node::new(*node1.id(), node1.address());
|
||||
|
||||
table.add(node1);
|
||||
table.add(node2);
|
||||
|
||||
assert_eq!(table.size(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_add_self() {
|
||||
let mut table = RoutingTable::new(Id::random());
|
||||
let node = Node::new(*table.id(), SocketAddrV4::new(0.into(), 0));
|
||||
|
||||
table.add(node.clone());
|
||||
|
||||
assert!(!table.add(node));
|
||||
assert!(table.is_empty())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_add_more_than_k() {
|
||||
let mut bucket = KBucket::new();
|
||||
|
||||
for i in 0..MAX_BUCKET_SIZE_K {
|
||||
let node = Node::random();
|
||||
assert!(bucket.add(node), "Failed to add node {}", i);
|
||||
}
|
||||
|
||||
let node = Node::random();
|
||||
|
||||
assert!(!bucket.add(node));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_update_existing_node() {
|
||||
// Same address
|
||||
{
|
||||
let mut bucket = KBucket::new();
|
||||
|
||||
let node1 = Node::random();
|
||||
let node2 = Node::new(*node1.id(), node1.address());
|
||||
|
||||
bucket.add(node1.clone());
|
||||
bucket.add(Node::random());
|
||||
|
||||
assert_ne!(bucket.nodes[1].id(), node1.id());
|
||||
|
||||
bucket.add(node2);
|
||||
|
||||
assert_eq!(bucket.nodes.len(), 2);
|
||||
assert_eq!(bucket.nodes[1].id(), node1.id());
|
||||
}
|
||||
|
||||
// Different port
|
||||
{
|
||||
let mut bucket = KBucket::new();
|
||||
|
||||
let node1 = Node::random();
|
||||
let node2 = Node::new(*node1.id(), SocketAddrV4::new(*node1.address().ip(), 1));
|
||||
|
||||
bucket.add(node1.clone());
|
||||
bucket.add(Node::random());
|
||||
|
||||
assert_ne!(bucket.nodes[1].id(), node1.id());
|
||||
|
||||
bucket.add(node2.clone());
|
||||
|
||||
assert_eq!(bucket.nodes.len(), 2);
|
||||
assert_eq!(bucket.nodes[1].id(), node1.id());
|
||||
}
|
||||
|
||||
{
|
||||
let mut bucket = KBucket::new();
|
||||
|
||||
let secure = Node(Arc::new(NodeInner {
|
||||
id: Id::from_str("5a3ce9c14e7a08645677bbd1cfe7d8f956d53256").unwrap(),
|
||||
address: SocketAddrV4::new([21, 75, 31, 124].into(), 0),
|
||||
token: None,
|
||||
last_seen: Instant::now(),
|
||||
}));
|
||||
|
||||
let unsecure = Node::new(*secure.id(), SocketAddrV4::new([0, 0, 0, 0].into(), 1));
|
||||
|
||||
{
|
||||
bucket.add(unsecure.clone());
|
||||
bucket.add(secure.clone());
|
||||
|
||||
assert_eq!(bucket.nodes[0].address(), secure.address())
|
||||
}
|
||||
|
||||
{
|
||||
bucket.add(secure.clone());
|
||||
bucket.add(unsecure.clone());
|
||||
|
||||
assert_eq!(bucket.nodes[0].address(), secure.address())
|
||||
}
|
||||
}
|
||||
|
||||
// Different ip
|
||||
{
|
||||
let mut bucket = KBucket::new();
|
||||
|
||||
let node1 = Node::random();
|
||||
let node2 = Node::new(*node1.id(), SocketAddrV4::new([0, 0, 0, 1].into(), 1));
|
||||
|
||||
bucket.add(node1.clone());
|
||||
bucket.add(Node::random());
|
||||
|
||||
assert_ne!(bucket.nodes[1].id(), node1.id());
|
||||
|
||||
bucket.add(node2.clone());
|
||||
|
||||
assert_eq!(bucket.nodes.len(), 2);
|
||||
assert_ne!(bucket.nodes[1].id(), node1.id());
|
||||
assert_ne!(bucket.nodes[1].address(), node2.address());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closest() {
|
||||
let ids = [
|
||||
"fb449c17f6c34fadea26a5a83e1952e815e001ea",
|
||||
"e63b72f95aacee40ad087f83afb475645739f669",
|
||||
"58c65677e3833cb0f15733a6363cc4cb1352f90a",
|
||||
"fd042ff1404b495720ad8345404ff5f25acd02a8",
|
||||
"dbed34a2c8db568fe59c10adcca9e81825b3dcfd",
|
||||
"079d40b746b5721f59972ebde423429739844914",
|
||||
"094f1d2fb4b95ba2c3250b014a9f06d13cd9eb9a",
|
||||
"98805a55523458c56d59339266bdcecc82370ecd",
|
||||
"0a1d6cce47c60f2c7357e9fec2910192de6eb336",
|
||||
"fb689ce0e18c2c22f316976d3ae524aed4137773",
|
||||
"0d01c32b4cf386b0b784b718b999d0e9dac07876",
|
||||
"9465e80d80f707b222c4ae6ee81c02b62f607629",
|
||||
"6cdc012328cc7a3a9a5b967e93387686e19c9f75",
|
||||
"99719dfc220b145e2aac71d6b3e276731d85be1c",
|
||||
"94d2037bbc534a5f1d672ce3e3350576c2b78ed1",
|
||||
"b48d0aeb94cd3766f23d2ac098bbccf01485dc20",
|
||||
"3b6e1c05f199edd7dee87d3cc8422c8f0ed02358",
|
||||
"d9b50c6ca730c89f8fc9f518136cef6139dd2252",
|
||||
"15827c92e6efbc4f56e507e548409c4bc04360bf",
|
||||
"3c8ff1e484c21132f8e6b8112a2feab984536f57",
|
||||
"c9a8163fa3e85065d46567bfac39b5452cfb3ae8",
|
||||
"ef79f77e9eed9ad51094ce2747e2c4fdc3a81326",
|
||||
"81f038cabb8a845f39da0d40716bf0707da55187",
|
||||
"907fdf0aa137200b395bc210763ed947b03dfc2e",
|
||||
"b0bce9873042aee29cbc7ec395647f6cc7a482f8",
|
||||
"e6b8d5567bc05d9b68f23d562645bc030729abc9",
|
||||
"74667cb7c629fb7e63749134b16e27446984c517",
|
||||
"cdc7f4d5825dc316de20d998bc0f1c5e91e36a5e",
|
||||
"701e7b5af5fabcf0bc3de97cb05a7c00da3e53c6",
|
||||
"36eb09b1db4af2b11312742faa2bb42621fce753",
|
||||
"9e4923966754c02b036698e95f95cec8fc40a9d2",
|
||||
"0e43d66e9da1bfc7e2581155dfd1b8f4be57d3f1",
|
||||
"647679a0d8816d2f62200e7b6ef6171297756dd5",
|
||||
"c03d9008add37f8414cb41549448bb2dcb5c6c9b",
|
||||
"dff82b028a6ec033e00b387df8e386417b92a47c",
|
||||
"42e8b38494b0ee11003592da11b5cbe43332190e",
|
||||
"03161976385301ac9b965202e8f3922cef840790",
|
||||
"7d598e5726fb58501d8cc65faf6b676bab7cb4bc",
|
||||
"54ddde105d3f2c6ea7a5e7641ff24522eea2e784",
|
||||
"3a75532b5916c772c1b7a18627bf170cf915aeb3",
|
||||
"fa2b38321419e63cb890f8a8b5c53a1c4728a10a",
|
||||
"a3ba598bee9da287092f4f2f3864322af38e1824",
|
||||
"a94df01f21d870a006748b6ab3c04d31428c959d",
|
||||
"396aabc66c603617f376409053d1e2cec3813101",
|
||||
"a7b4becc2304da63792eb6c33f95677b2e7c9f8c",
|
||||
"58b1623af15a9828ccf41b8cee47d123c5cfe8b6",
|
||||
"3cb7eeac7be3a0195a9243537d452f790ccf1ca9",
|
||||
"e0296cfc4726d91a1f7f041e24638a1276a08bed",
|
||||
"aeb03edad3edc7c54a3c5f7916ecba981e65ce91",
|
||||
"4a81a4596b7c4b8706fd8b5c88ddfde18ca72293",
|
||||
"0d4e9ae7c486e5a0361bd4e3b918b6bdca89cfcb",
|
||||
"81d394b44403315f9845c3da6f018b8daedd89ef",
|
||||
"345630675ff0f319c8f2bb355edf59f9bd93072f",
|
||||
"b61fbd992a13af05feba939f597b5f6ee61188e3",
|
||||
"5ea45447e2e79a5f3b3d8c2f68aebdabf71c42f9",
|
||||
"84325dd6fbd9a93f4ab61d091a9562a6c6111df4",
|
||||
"e7c796aeecd47cfd01a2d62fd3fb1d41aafa2464",
|
||||
"897457b33c4eb1ffcab08331877108cbf3fac6de",
|
||||
"833843b1f33e720c17bccfb75647a49040861b4c",
|
||||
"06b49c253d3fc9800cfd75605d26426f8ccb89af",
|
||||
"5024212c42bed9f45e48c450147fecb3e934fc4e",
|
||||
"5a9de8041b045a7a4f85b71a6dc6a794a7fcd4ea",
|
||||
"70cad33774ddacb51ed1918adedeb67ff13a3b1e",
|
||||
"840d201e3c213c01b4ab85983efaac44f0671552",
|
||||
"aa7ffc7999a1b1bb79ce19b61c37f70331f492d6",
|
||||
"e2ec0c07e15411564292b5fa75246e4c385f4411",
|
||||
"38c1a0d14f548d4d81655920ec564b08e9fcf5e6",
|
||||
"1d128b8343569c7e9a8985879fafd325d458d31c",
|
||||
"4fcd30cbe02b74cece57babac93aded26ecdc893",
|
||||
"57d8a6d782ee1df62ceebd5d10884805ed382336",
|
||||
"54443ed3476d1d542f37bf069973bbd2b64c1b27",
|
||||
"0e7ba6c5e4c29cf4fff25733892b63cf2a6efdfc",
|
||||
"18824378226a6d33bcdbe39dd3bc9ee656ce20a2",
|
||||
"93b0cb01befc90b65a0026acf85bea2fefec7d44",
|
||||
"d65e378a1ec70cc79ae5b4469ae7f0e8939033fe",
|
||||
"9230a2f8ac81e73f16c63dd60adb030328fbc983",
|
||||
"302de797c9d73275ea184d7f6a8bf77364a8fd52",
|
||||
"cea92f6e6612ef408d8c22ad5c1ed602bb2aedbf",
|
||||
"353f2ff278f4ee038e7b217276a82d6ed0617130",
|
||||
"e962e3a1946afa0d3ee97f3a0418cb3489a5f84c",
|
||||
"a4e42b6cf98e957684aa4e7006940d31bcb76b1f",
|
||||
"57af8f960b2450ffa0dc5bc7314fece53996d4d0",
|
||||
"28e73f73084bc8e91fe9ec0a5581b583ef468d8c",
|
||||
"9481589ddec9a6d9ad2cee7f73e8319aab3f1e95",
|
||||
"edec09cc7476cd019560874def4af852bfeaffe3",
|
||||
"6c3ae2cf5f9452d5176788e15635c5958581c931",
|
||||
"f547b9717e84036c3d5eefec6d6ee3bfa5af89cb",
|
||||
"87b51f4bf1ccd41cda3aa85c71da5de56aeeda33",
|
||||
"e743092a576b92c8c05e04d5d2b23f2838825fd1",
|
||||
"e713b84894b761e2a4e20fd0e5a81ae48a6b6f9d",
|
||||
"6b1abca34099d2436bac8ab25aa17a57cbfe1564",
|
||||
"93cb2977e536a680c043b158345254c14b946d52",
|
||||
"8c2754fa9e93cbf1cccfd9241ebe0cc141199cfe",
|
||||
"13e4abf95a8a9e6525419b4db7b1704ed0a2789d",
|
||||
"8d53d453d7cfbb9bc386e128fa68aca388a5ddc6",
|
||||
"caebf39e9c9b48d87277f2a13faa5931a24819a4",
|
||||
"5025ca6cda98f31bc3ef321dd9a015b7f06b8bfa",
|
||||
"531fbf18fdf3e513091614f20d65e920a505ca41",
|
||||
"2f81e6159f7de0bc90c8a1db661b33bffbee85fd",
|
||||
"85d4d9954f3a28228a2786b320ad58a46a13f37b",
|
||||
];
|
||||
|
||||
let nodes: Vec<Node> = ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, str)| {
|
||||
let id = Id::from_str(str).unwrap();
|
||||
Node(Arc::new(NodeInner {
|
||||
id,
|
||||
address: SocketAddrV4::new((i as u32).into(), i as u16),
|
||||
token: None,
|
||||
last_seen: Instant::now(),
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let local_id = Id::from_str("ba3042eb2d373b19e7c411ce6826e31b37be0b2e").unwrap();
|
||||
|
||||
let mut table = RoutingTable::new(local_id);
|
||||
|
||||
for node in nodes {
|
||||
table.add(node);
|
||||
}
|
||||
|
||||
{
|
||||
let expected_closest_ids: Vec<_> = [
|
||||
"897457b33c4eb1ffcab08331877108cbf3fac6de",
|
||||
"907fdf0aa137200b395bc210763ed947b03dfc2e",
|
||||
"9230a2f8ac81e73f16c63dd60adb030328fbc983",
|
||||
"93b0cb01befc90b65a0026acf85bea2fefec7d44",
|
||||
"93cb2977e536a680c043b158345254c14b946d52",
|
||||
"9465e80d80f707b222c4ae6ee81c02b62f607629",
|
||||
"9481589ddec9a6d9ad2cee7f73e8319aab3f1e95",
|
||||
"94d2037bbc534a5f1d672ce3e3350576c2b78ed1",
|
||||
"98805a55523458c56d59339266bdcecc82370ecd",
|
||||
"99719dfc220b145e2aac71d6b3e276731d85be1c",
|
||||
"9e4923966754c02b036698e95f95cec8fc40a9d2",
|
||||
"a3ba598bee9da287092f4f2f3864322af38e1824",
|
||||
"a4e42b6cf98e957684aa4e7006940d31bcb76b1f",
|
||||
"a7b4becc2304da63792eb6c33f95677b2e7c9f8c",
|
||||
"a94df01f21d870a006748b6ab3c04d31428c959d",
|
||||
"aa7ffc7999a1b1bb79ce19b61c37f70331f492d6",
|
||||
"aeb03edad3edc7c54a3c5f7916ecba981e65ce91",
|
||||
"b0bce9873042aee29cbc7ec395647f6cc7a482f8",
|
||||
"b48d0aeb94cd3766f23d2ac098bbccf01485dc20",
|
||||
"b61fbd992a13af05feba939f597b5f6ee61188e3",
|
||||
]
|
||||
.iter()
|
||||
.map(|id| Id::from_str(id).unwrap())
|
||||
.collect();
|
||||
|
||||
let target = local_id;
|
||||
let closest = table.closest(target);
|
||||
|
||||
let mut closest_ids: Vec<Id> = closest.iter().map(|n| *n.id()).collect();
|
||||
closest_ids.sort();
|
||||
|
||||
assert_eq!(closest_ids, expected_closest_ids);
|
||||
}
|
||||
|
||||
{
|
||||
let expected_closest_ids: Vec<_> = [
|
||||
"c03d9008add37f8414cb41549448bb2dcb5c6c9b",
|
||||
"c9a8163fa3e85065d46567bfac39b5452cfb3ae8",
|
||||
"cdc7f4d5825dc316de20d998bc0f1c5e91e36a5e",
|
||||
"cea92f6e6612ef408d8c22ad5c1ed602bb2aedbf",
|
||||
"d65e378a1ec70cc79ae5b4469ae7f0e8939033fe",
|
||||
"d9b50c6ca730c89f8fc9f518136cef6139dd2252",
|
||||
"dbed34a2c8db568fe59c10adcca9e81825b3dcfd",
|
||||
"dff82b028a6ec033e00b387df8e386417b92a47c",
|
||||
"e0296cfc4726d91a1f7f041e24638a1276a08bed",
|
||||
"e2ec0c07e15411564292b5fa75246e4c385f4411",
|
||||
"e63b72f95aacee40ad087f83afb475645739f669",
|
||||
"e6b8d5567bc05d9b68f23d562645bc030729abc9",
|
||||
"e7c796aeecd47cfd01a2d62fd3fb1d41aafa2464",
|
||||
"e962e3a1946afa0d3ee97f3a0418cb3489a5f84c",
|
||||
"edec09cc7476cd019560874def4af852bfeaffe3",
|
||||
"ef79f77e9eed9ad51094ce2747e2c4fdc3a81326",
|
||||
"fa2b38321419e63cb890f8a8b5c53a1c4728a10a",
|
||||
"fb449c17f6c34fadea26a5a83e1952e815e001ea",
|
||||
"fb689ce0e18c2c22f316976d3ae524aed4137773",
|
||||
"fd042ff1404b495720ad8345404ff5f25acd02a8",
|
||||
]
|
||||
.iter()
|
||||
.map(|str| Id::from_str(str).unwrap())
|
||||
.collect();
|
||||
|
||||
let target = Id::from_str("d1406a3d3a8354d566f21dba8bd06c537cde2a20").unwrap();
|
||||
let closest = table.closest(target);
|
||||
|
||||
let mut closest_ids: Vec<Id> = closest.iter().map(|n| *n.id()).collect();
|
||||
closest_ids.sort();
|
||||
|
||||
assert_eq!(closest_ids, expected_closest_ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user