Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6438982d45 | ||
|
|
21b619388c | ||
|
|
d1ab993dd2 | ||
|
|
da7ce1dca1 | ||
|
|
4d2f8a54ec | ||
|
|
c3a6201ee2 | ||
|
|
533921059a | ||
|
|
d2778f0b5a | ||
|
|
8cfece9479 | ||
|
|
3930dff2dc | ||
|
|
35acc4d231 | ||
|
|
c791102ad9 | ||
|
|
f80841da63 | ||
|
|
7070a16104 | ||
|
|
46575d0899 | ||
|
|
a89e65475d | ||
|
|
2ed447ae86 | ||
|
|
ac5a1596ef | ||
|
|
192ab25bbd | ||
|
|
c0a76f1942 | ||
|
|
510116afb9 | ||
|
|
e24548b3b0 | ||
|
|
a4e341cd66 | ||
|
|
41990c9934 | ||
|
|
254a25ebd6 | ||
|
|
5495cb5b34 | ||
|
|
fac3bd7bb9 | ||
|
|
db9015b26c | ||
|
|
c724981bfd | ||
|
|
b4f3e57c8d | ||
|
|
d1a0eca723 | ||
|
|
686c74a3bd | ||
|
|
9698f21d55 | ||
|
|
72eaf9a228 | ||
|
|
e735151d62 | ||
|
|
458051f47d | ||
|
|
83d3445b7e | ||
|
|
3581feb9b9 | ||
|
|
3044e592bd | ||
|
|
9b240672e6 | ||
|
|
1b2f050c05 | ||
|
|
637e2f74e4 | ||
|
|
415b6a6667 | ||
|
|
0f97d60854 | ||
|
|
44a799faee | ||
|
|
41604225ba | ||
|
|
fae3816bbf | ||
|
|
0b3915d52b | ||
|
|
4c84cc9e4b | ||
|
|
990b9f2e0f | ||
|
|
142fdf995c | ||
|
|
ff7e235414 | ||
|
|
7c1be332e3 | ||
|
|
9415866193 | ||
|
|
c852de2c78 | ||
|
|
a80a014f20 | ||
|
|
41d5f554c7 | ||
|
|
745bbaea06 | ||
|
|
5bee148497 | ||
|
|
60e6b263d1 | ||
|
|
ca8759c023 | ||
|
|
75b37fdda3 | ||
|
|
240e471c88 |
No files matched your search
@@ -0,0 +1,175 @@
|
||||
name: Release
|
||||
|
||||
# Build the agent for every supported target on a version tag and attach the
|
||||
# archives to a release. Linux is a fully static musl binary (built with
|
||||
# cargo-zigbuild) so one artifact runs on any distro — Arch, Alpine, NixOS, old
|
||||
# glibc systems alike — and can then be repackaged however a distro prefers.
|
||||
#
|
||||
# Targeted at Gitea Actions: the release assets are uploaded straight to the
|
||||
# Gitea release API with curl, rather than through actions/upload-artifact and
|
||||
# `gh`, because the v4 artifact backend and the GitHub REST API are not
|
||||
# available there. The only runner tools it needs are curl and coreutils.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_NET_RETRY: "10"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.asset }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
asset: tsunagi-linux-x86_64
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
asset: tsunagi-linux-aarch64
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
asset: tsunagi-macos-aarch64
|
||||
archive: tar.gz
|
||||
zig: false
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
asset: tsunagi-windows-x86_64
|
||||
archive: zip
|
||||
zig: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
# aws-lc-sys (iroh's rustls backend) and the bundled sqlite are C, and the
|
||||
# musl targets have no prebuilt bindings, so bindgen needs libclang and the
|
||||
# C build needs cmake. zig is the cross C compiler cargo-zigbuild drives.
|
||||
- name: Install musl build tooling
|
||||
if: matrix.zig
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang libclang-dev
|
||||
- name: Install zig
|
||||
if: matrix.zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
# Pin a real, archived Zig release. Left unpinned the action resolves
|
||||
# to the newest version it knows of (a yet-unreleased 0.16.0-dev),
|
||||
# whose tarball 404s on ziglang.org while the community mirrors flake.
|
||||
# A concrete release always has a permanent official URL, so the
|
||||
# download succeeds even if mirrors are down, and the version stays
|
||||
# compatible with cargo-zigbuild.
|
||||
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
|
||||
cargo zigbuild --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
||||
else
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
||||
fi
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
stage="dist/pkg/${{ matrix.asset }}"
|
||||
mkdir -p "$stage"
|
||||
|
||||
bin="tsunagi"
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
|
||||
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
|
||||
|
||||
# The top-level README, plus the per-OS install notes and integration
|
||||
# files, so the archive carries what the platform needs to set up.
|
||||
cp README.md "$stage/"
|
||||
case "${{ runner.os }}" in
|
||||
Linux)
|
||||
cp dist/linux/tsunagi.service dist/linux/install.sh "$stage/"
|
||||
cp dist/linux/README.md "$stage/INSTALL.md"
|
||||
;;
|
||||
macOS)
|
||||
cp dist/macos/cy.hexor.tsunagi.plist "$stage/"
|
||||
cp dist/macos/README.md "$stage/INSTALL.md"
|
||||
;;
|
||||
Windows)
|
||||
cp dist/windows/README.md "$stage/INSTALL.md"
|
||||
;;
|
||||
esac
|
||||
|
||||
archive="${{ matrix.asset }}-${version}.${{ matrix.archive }}"
|
||||
if [[ "${{ matrix.archive }}" == "zip" ]]; then
|
||||
(cd dist/pkg && 7z a "../../${archive}" "${{ matrix.asset }}")
|
||||
else
|
||||
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
|
||||
fi
|
||||
|
||||
- name: Upload the archive to the release
|
||||
shell: bash
|
||||
env:
|
||||
# Gitea injects this with write access to the current repository.
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -u
|
||||
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
archive="${{ matrix.asset }}-${version}.${{ matrix.archive }}"
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
auth="Authorization: token ${TOKEN}"
|
||||
|
||||
prerelease=false
|
||||
case "$tag" in *-*) prerelease=true ;; esac
|
||||
|
||||
# The release id for this tag, or empty if there is no release yet.
|
||||
# Parsed with grep so the job needs no jq on the runner image.
|
||||
get_id() {
|
||||
curl -fsSL -H "$auth" "$api/releases/tags/$tag" 2>/dev/null \
|
||||
| grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2
|
||||
}
|
||||
|
||||
# Create the release if it is not there. Several matrix jobs can race
|
||||
# here, so a create that loses the race is ignored and the id is then
|
||||
# read back by tag.
|
||||
id="$(get_id)"
|
||||
if [ -z "$id" ]; then
|
||||
curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"$tag\",\"name\":\"tsunagi $tag\",\"prerelease\":$prerelease}" \
|
||||
"$api/releases" >/dev/null 2>&1 || true
|
||||
id="$(get_id)"
|
||||
fi
|
||||
if [ -z "$id" ]; then
|
||||
echo "could not create or find a release for $tag at $api" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -fsS -X POST -H "$auth" \
|
||||
-F "attachment=@${archive}" \
|
||||
"$api/releases/$id/assets?name=${archive}" >/dev/null
|
||||
echo "uploaded $archive to release $tag (id $id)"
|
||||
@@ -13,23 +13,67 @@ messages. Scope and non-scope are in [README.md](README.md).
|
||||
There is no central server, no owner, no registration and no majority vote.
|
||||
Design accordingly: a majority is not a root of trust.
|
||||
|
||||
## User experience
|
||||
|
||||
User convenience is a core requirement. The CLI should feel logical and
|
||||
predictable to an experienced administrator: use consistent selectors and
|
||||
flags, sensible defaults, and actionable errors. Let users refer to configured
|
||||
networks by their exact name as well as by id or a unique id prefix; do not
|
||||
make them copy an opaque identifier when the stored name identifies one network.
|
||||
When a selector is ambiguous, list the matching names and ids and change
|
||||
nothing. Never guess which resource a destructive command should affect.
|
||||
Keep help text and examples in sync with behavior, and preserve existing
|
||||
scripts when improving the interactive workflow.
|
||||
|
||||
## Architectural boundaries
|
||||
|
||||
Keep these separate. Crossing them is the main thing to review for.
|
||||
|
||||
- **Control plane vs data plane.** The separation is **logical, not physical**.
|
||||
The control protocol in `src/proto/` knows nothing about packets, and
|
||||
`src/dataplane/` knows nothing about the control protocol; either can be
|
||||
replaced on its own. Both may ride on iroh — refusing to would throw away
|
||||
iroh's NAT traversal and force the data plane to reimplement it. They use
|
||||
different ALPNs and different connections, so a busy or broken data plane
|
||||
cannot disturb control traffic.
|
||||
The control protocol in `crates/tsunagi/src/proto/` knows nothing about
|
||||
packets, and a protocol crate knows nothing about the control protocol;
|
||||
either can be replaced on its own. Both may ride on iroh — refusing to
|
||||
would throw away iroh's NAT traversal and force the data plane to
|
||||
reimplement it. They use different ALPNs and different connections, so a
|
||||
busy or broken data plane cannot disturb control traffic.
|
||||
- **One agent, one interface.** It belongs to the system level, along with
|
||||
the addresses on it and the decision of whose packet is whose. Several
|
||||
protocols may be carrying traffic at once and none of them owns the thing
|
||||
they carry it for. A protocol is handed a routed packet and hands back a
|
||||
decrypted one; it never creates an interface and never picks an address.
|
||||
- **One state directory, one identity, one live agent — many networks.** A
|
||||
network is added to the agent that is already running, never by starting a
|
||||
second one on the same directory. A second agent is a second identity and
|
||||
is isolated: its own directories, its own interface, its own runtime. Do
|
||||
not add anything that lets two agents share a directory, and do not make a
|
||||
network's lifetime depend on the process that happened to start it.
|
||||
- **A protocol is a separate crate with its own version.** The version peers
|
||||
compare is the *wire* version, never the software version: two peers on
|
||||
different releases work together for as long as the bytes between them
|
||||
have not changed. Nothing negotiated may be derived from anything that
|
||||
moves with a release.
|
||||
- **A protocol never sees the network secret.** It proves who is at the other
|
||||
end of a tunnel; proving membership of a network stays in the core, which
|
||||
is why the authenticated transport does too.
|
||||
- **A relay carries bytes, never packets.** A datagram passed on for two
|
||||
other peers goes link in, link out: it is never written to the middle's
|
||||
interface or decrypted there. Multihop routing has a bounded hop limit;
|
||||
equal-cost next hops are chosen per flow. Reachability is first-hand and
|
||||
volatile: each authenticated member advertises its own protocol-specific
|
||||
links. Build routing tables on topology changes, never per packet. Transit
|
||||
must not acquire a routing mutex or wait for a protocol/TUN reader.
|
||||
- **Broadcast fanout belongs at local IP ingress.** Participation is local to
|
||||
each network, enabled by default, persisted and advertised to authenticated
|
||||
peers. Resolve one source domain and target only its willing members. Remote
|
||||
delivery never triggers another fanout. Physical LAN exports must extend
|
||||
explicit ingress and source/destination admission policies; never bypass
|
||||
ownership checks or add broadcast flooding to encrypted transit.
|
||||
- **Plugins never learn reachability.** An `IpPlugin` is handed a `PacketLink`
|
||||
per peer and moves datagrams over it. Addresses, hole punching and relays
|
||||
belong to `src/dataplane/transport/`. A plugin announcement says *who*, never
|
||||
belong to `crates/tsunagi/src/dataplane/transport/`. A plugin announcement says *who*, never
|
||||
*where*.
|
||||
- **The core never parses a plugin payload.** See `src/dataplane/mod.rs`. Only
|
||||
`src/dataplane/wireguard/announcement.rs` interprets WireGuard payloads, and
|
||||
- **The core never parses a plugin payload.** See `crates/tsunagi/src/dataplane/mod.rs`. Only
|
||||
`crates/tsunagi-wg-quic/src/announcement.rs` interprets `wg-quic` payloads, and
|
||||
only after bounding every field. A data plane failure must never stop the
|
||||
control plane.
|
||||
- **Derived, not claimed.** A peer's overlay address is derived from its public
|
||||
@@ -37,7 +81,7 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
inbound packets are dropped unless their source is the address derived for
|
||||
the peer that sent them. Never trust an address a peer announces.
|
||||
- **Signed state is the only durable agreement.** A fact that must survive a
|
||||
participant being away goes in `src/state/` as a record signed by its
|
||||
participant being away goes in `crates/tsunagi/src/state/` as a record signed by its
|
||||
author, never in a session. Merging is deterministic, an older version never
|
||||
rolls back a newer one, and absence from a snapshot is not deletion. Never
|
||||
add a vote or a quorum: a majority is not a trust root here, and it would
|
||||
@@ -46,6 +90,12 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
transaction, **before** the record is announced.
|
||||
- **Plugins own their system objects.** A plugin creates and removes its own
|
||||
interface and nothing else. Never touch routing, DNS or firewall settings.
|
||||
The one exception is the system level, not a plugin: while a network's
|
||||
broadcast is on, `overlay::hostrules` may install exactly a
|
||||
`255.255.255.255/32` route and an inbound UDP firewall allowance (overlay range
|
||||
only) for the interface the agent owns. Both are tagged `tsunagi:<interface>`,
|
||||
reversible, removed on broadcast off, leave and shutdown, and computed from
|
||||
local state, never from a remote announcement.
|
||||
- **Device identity vs network identity.** The iroh endpoint id is the device's
|
||||
public key. `NetworkId` is derived from name + secret only. Never conflate
|
||||
them, and never let one change the other.
|
||||
@@ -65,7 +115,7 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
normal path *and* the important failures. See [docs/testing.md](docs/testing.md).
|
||||
Do not add tests for getters or to move a coverage number.
|
||||
2. **Never change `IDENTITY_SCHEME`, the derivation labels, or the transcript
|
||||
encoding** in `src/identity/network.rs` and `src/proto/handshake.rs` without
|
||||
encoding** in `crates/tsunagi/src/identity/network.rs` and `crates/tsunagi/src/proto/handshake.rs` without
|
||||
treating it as an incompatible protocol change. Bumping the crate version or
|
||||
the control protocol version must not change an existing `NetworkId`.
|
||||
3. **Secrets never leak.** Not into logs, not into `Debug`, not into status
|
||||
@@ -76,7 +126,7 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
the library; tests opt out explicitly at the top of each file.
|
||||
5. **Bounds before allocation.** Frame lengths are checked against
|
||||
`Limits::max_frame_len` before a buffer is allocated. Every string, list and
|
||||
queue has a limit in `src/config.rs`.
|
||||
queue has a limit in `crates/tsunagi/src/config.rs`.
|
||||
6. **Failure is contained.** A bad signature, wrong secret, malformed packet or
|
||||
unknown version rejects one message or one session. It never stops another
|
||||
network and never stops the agent. There is no irreversible global error
|
||||
@@ -93,25 +143,35 @@ Keep these separate. Crossing them is the main thing to review for.
|
||||
up shutdown. Wind tasks down with a grace period and then abort.
|
||||
10. **Nothing from a remote announcement becomes a shell command, a filesystem
|
||||
path or an OS setting.** The agent never touches interfaces or OS settings
|
||||
it did not create.
|
||||
it did not create (the tagged broadcast host rules above are for its own
|
||||
interface).
|
||||
|
||||
## Where things live
|
||||
|
||||
| path | responsibility |
|
||||
|---------------------|----------------|
|
||||
| `src/identity/` | device identity; deterministic network space identity and derived keys |
|
||||
| `src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
|
||||
| `src/discovery.rs` | candidate sources; test and static backends |
|
||||
| `src/proto/` | framing, message formats, membership handshake |
|
||||
| `src/net.rs` | iroh endpoint adapter and observability snapshots |
|
||||
| `src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
|
||||
| `src/state/` | signed records that outlive a session, their merge rules and address allocation |
|
||||
| `src/dataplane/` | the plugin contract, the packet transport, and the WireGuard plugin |
|
||||
| `src/bin/tsunagi.rs`| the command line agent; the only place that owns a runtime, a logger and signals |
|
||||
| `crates/tsunagi/src/identity/` | device identity; deterministic network space identity and derived keys |
|
||||
| `crates/tsunagi/src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
|
||||
| `crates/tsunagi/src/discovery.rs` | candidate sources; test and static backends |
|
||||
| `crates/tsunagi/src/proto/` | framing, message formats, membership handshake |
|
||||
| `crates/tsunagi/src/net.rs` | iroh endpoint adapter and observability snapshots |
|
||||
| `crates/tsunagi/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
|
||||
| `crates/tsunagi/src/state/` | signed records that outlive a session, their merge rules and address allocation |
|
||||
| `crates/tsunagi/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks, 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 |
|
||||
| `crates/tsunagi-cli/` | the command line agent; the only place that owns a runtime, a logger and signals |
|
||||
| `tests/` | integration tests; `tests/common/` is the shared harness |
|
||||
|
||||
Add abstractions only at real substitution or testing boundaries. Do not add a
|
||||
trait per struct. Prefer one crate with clear modules over many small crates.
|
||||
trait per struct.
|
||||
|
||||
**The system level and its plugins are separate crates**, so that boundary is
|
||||
checked by the compiler and not by discipline: a plugin can reach only what
|
||||
`tsunagi` makes public, and carries its own version. Everything else stays
|
||||
one crate with clear modules — do not split further without a reason of that
|
||||
kind.
|
||||
|
||||
## Testing rules
|
||||
|
||||
@@ -133,7 +193,26 @@ trait per struct. Prefer one crate with clear modules over many small crates.
|
||||
- Running several library instances in one process is not a test of several
|
||||
system processes; do not describe it as one.
|
||||
|
||||
## Before you open a change
|
||||
## Commit every completed change
|
||||
|
||||
Agents must create a Git commit before finishing any task that changes this
|
||||
repository. Do not leave the user to make the commit. Inspect recent commit
|
||||
messages and follow their style: a short English subject stating the change,
|
||||
then a useful body explaining the problem, final behavior, architectural
|
||||
decisions, compatibility/migration effects, and checks actually performed.
|
||||
Write enough context for a future maintainer or LLM to understand why the code
|
||||
exists. Record failed or skipped checks honestly; never claim unrun tests.
|
||||
|
||||
Before committing, inspect the diff and stage only this task's changes. Preserve
|
||||
unrelated user changes and never commit secrets or generated build artifacts.
|
||||
If Git has no effective author name or email, set only the missing repository-
|
||||
local values to `AB` and `ab@hexor.cy`. Do not override an existing identity or
|
||||
change global Git configuration. Report the resulting commit hash.
|
||||
|
||||
Version bumps, release tags, and pushing commits/tags belong to the user unless
|
||||
they explicitly ask the agent to do them. Read-only tasks need no empty commit.
|
||||
|
||||
## Required validation
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
|
||||
Generated
+825
-18
File diff suppressed because it is too large.
Load diff
+30
-70
@@ -1,80 +1,40 @@
|
||||
[package]
|
||||
name = "tsunagi"
|
||||
version = "0.1.0"
|
||||
# The system level and its plugins are separate crates, so the boundary
|
||||
# between them is checked by the compiler rather than by discipline: a plugin
|
||||
# can reach only what the core makes public, and carries its own version.
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*"]
|
||||
exclude = ["vendor/mainline"]
|
||||
|
||||
# Mainline 8.0.0 treats Windows UDP read timeouts as socket failures.
|
||||
# Keep the small receive-loop correction reproducible until an upstream fix.
|
||||
[patch.crates-io]
|
||||
mainline = { path = "vendor/mainline" }
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
rust-version = "1.91"
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "Proof-of-concept library for small private mesh networks: persistent agent identity, deterministic network spaces, iroh-based control plane."
|
||||
repository = "https://github.com/tsunagi-net/tsunagi"
|
||||
readme = "README.md"
|
||||
keywords = ["mesh", "p2p", "iroh", "networking"]
|
||||
categories = ["network-programming"]
|
||||
|
||||
[features]
|
||||
default = ["cli"]
|
||||
# The `tsunagi` command line binary. Library users can opt out.
|
||||
cli = ["dep:clap", "dep:anstream", "dep:anstyle", "dep:tracing-subscriber", "tokio/signal", "tun-device"]
|
||||
# A real TUN device, so the WireGuard plugin can carry actual IP traffic.
|
||||
# Needs CAP_NET_ADMIN at run time; without it the plugin still runs and its
|
||||
# in-memory device can be used for tests.
|
||||
tun-device = ["dep:tun", "dep:rtnetlink", "dep:caps", "dep:futures-util"]
|
||||
|
||||
[[bin]]
|
||||
name = "tsunagi"
|
||||
path = "src/bin/tsunagi.rs"
|
||||
required-features = ["cli"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5", features = ["derive", "env"], optional = true }
|
||||
# Already in the tree through clap. `anstream` strips the escapes when stdout
|
||||
# is not a terminal and turns on virtual terminal processing on Windows, so
|
||||
# colour is never written where it would show up as rubbish.
|
||||
anstream = { version = "1.0", optional = true }
|
||||
anstyle = { version = "1.0", optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
iroh = { version = "1.2", default-features = false, features = ["tls-ring"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
hkdf = "0.13"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.9", features = ["derive"] }
|
||||
rand = "0.10"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
|
||||
data-encoding = "2.11"
|
||||
hex = "0.4"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
fs4 = { version = "1.1", features = ["sync"] }
|
||||
directories = "6.0"
|
||||
# The real system hostname, without a libc call of our own: this crate is
|
||||
# forbidden `unsafe` and will not make one.
|
||||
gethostname = "1.1"
|
||||
netwatch = "0.19.3"
|
||||
bytes = "1.12.1"
|
||||
boringtun = { version = "0.7.1", default-features = false }
|
||||
tun = { version = "0.8", features = ["async"], optional = true }
|
||||
|
||||
# Linux-only interface provisioning. `rtnetlink` configures the interface in
|
||||
# process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN
|
||||
# out of the effective set except during the moments it is used.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
rtnetlink = { version = "0.23", optional = true }
|
||||
caps = { version = "0.5", optional = true }
|
||||
futures-util = { version = "0.3", default-features = false, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
||||
tempfile = "3.24"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[lints.rust]
|
||||
[workspace.lints.rust]
|
||||
missing_docs = "warn"
|
||||
unsafe_code = "forbid"
|
||||
unsafe_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
[workspace.lints.clippy]
|
||||
unwrap_used = "warn"
|
||||
expect_used = "warn"
|
||||
panic = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
iroh = { version = "1.2", default-features = false, features = ["tls-ring", "portmapper"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
|
||||
bytes = "1.12.1"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
hex = "0.4"
|
||||
data-encoding = "2.11"
|
||||
tempfile = "3.24"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
@@ -24,6 +24,7 @@ A working library with **real iroh connections** and integration tests:
|
||||
- several independent networks at once in one agent;
|
||||
- deterministic network identity derived from name + secret;
|
||||
- candidates supplied by a replaceable discovery component;
|
||||
- automatic first contact through the public Mainline DHT (BEP44);
|
||||
- real iroh connections plus an explicit mutual proof of network membership;
|
||||
- a small versioned control protocol: handshake, hostname/capability
|
||||
announcement, ping/pong;
|
||||
@@ -39,7 +40,7 @@ A working library with **real iroh connections** and integration tests:
|
||||
|
||||
### What it deliberately does **not** do
|
||||
|
||||
Not implemented, and not pretended to be: Mainline DHT, DNS, routing through
|
||||
Not implemented, and not pretended to be: DNS, routing through
|
||||
intermediate participants, a full CRDT, dynamically loaded plugins, a system
|
||||
service, a complete CLI, or a local control socket. Snapshot synchronisation
|
||||
and signed revocations are designed for but not implemented — see
|
||||
@@ -58,7 +59,7 @@ system's and the user's responsibility, not this library's.
|
||||
|
||||
- Rust 1.91 or newer (iroh 1.2 requires it), edition 2024. Pinned dependencies
|
||||
in `Cargo.lock`.
|
||||
- No internet, no DHT, no public relay, no administrator rights and no changes
|
||||
- No internet, no public DHT, no public relay, no administrator rights and no changes
|
||||
to OS network settings are needed to build or test.
|
||||
- WireGuard runs in userspace (boringtun): **no kernel module and no `wg`
|
||||
tool**. Only creating a real network interface needs `CAP_NET_ADMIN`, and
|
||||
@@ -70,29 +71,67 @@ On the first machine:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
./target/release/tsunagi id secret generate # prints tsn1...; share it privately
|
||||
./target/release/tsunagi status # this device, the agent, and this host
|
||||
|
||||
./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard
|
||||
./target/release/tsunagi network # the networks this device belongs to
|
||||
```
|
||||
|
||||
It prints its endpoint id and then waits. On the second machine, pass that id:
|
||||
Two commands do the work, and they are separate on purpose: `up` runs the
|
||||
agent — one per device, for as long as you want it — and `join` decides
|
||||
what it belongs to, at any time, while it runs.
|
||||
|
||||
```bash
|
||||
./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard \
|
||||
--peer <endpoint-id-from-the-first-machine>
|
||||
# Terminal one: the agent. It prints its endpoint id and then serves.
|
||||
./target/release/tsunagi up
|
||||
|
||||
# Terminal two: make a network. With no --secret it invents one and prints
|
||||
# it, along with the line to send the other machine.
|
||||
./target/release/tsunagi join --network lab
|
||||
```
|
||||
|
||||
On the second machine, start its agent and join the same network with the
|
||||
secret that was printed:
|
||||
|
||||
```bash
|
||||
./target/release/tsunagi up
|
||||
./target/release/tsunagi join --network lab --secret tsn1...
|
||||
```
|
||||
|
||||
**Name and secret are enough for the first meeting.** Mainline DHT discovery
|
||||
is enabled by default. Each active network publishes this device's endpoint
|
||||
and searches until the first authenticated connection. While connected it
|
||||
only republishes; after 60 seconds without any authenticated connection it
|
||||
searches again. Publication is approximately every five minutes, and records
|
||||
older than 15 minutes are ignored. DHT storage nodes may retain them longer.
|
||||
|
||||
Use `--no-dht` to disable DHT, or `--peer <endpoint-id>` for optional manual
|
||||
bootstrap. `--reach local` disables public DHT regardless of the DHT flag.
|
||||
Endpoint records are public, signed, and contain no network secret. Discovery
|
||||
is not authentication: every connection must still prove network membership.
|
||||
Details and failure behavior are in [docs/mainline-dht.md](docs/mainline-dht.md).
|
||||
|
||||
**One introduction is enough for the whole network.** Members tell each
|
||||
other about the members they know, and every author of a signed record is
|
||||
somebody to try, so a device pointed at one member ends up talking to all
|
||||
of them rather than to the one that happened to be on its command line.
|
||||
What travels is a candidate — an address somebody has seen — and it is
|
||||
authenticated by the handshake like any other; being introduced grants
|
||||
nothing. It is deliberately the same shape a lookup in a distributed hash
|
||||
table would return, so that is a source to add beside this one rather than
|
||||
a redesign.
|
||||
|
||||
An agent with nobody to contact says so in `status` rather than sitting
|
||||
there looking patient.
|
||||
|
||||
Within a few seconds both print something like:
|
||||
|
||||
```text
|
||||
+ peer b47c958462 connected over Direct rtt=Some(4.5ms)
|
||||
+ data link to b47c958462 for wireguard: Direct via Ip(…), datagram 1382
|
||||
+ peer b47c958462 connected over direct rtt=Some(4.5ms)
|
||||
+ data link to b47c958462 for wg-quic: direct via 192.0.2.7:41234, datagram 1382
|
||||
|
||||
--- status ---
|
||||
control: 1 peer(s), 0 dial failure(s), 0 handshake failure(s)
|
||||
wireguard: tsunkkcp43lmdje on fd15:1d9e:fa21:f201:…/64 mtu 1280, 1/1 tunnel(s) established
|
||||
4jO4kx9Z fd15:1d9e:fa21:f201:… handshake 3s ago tx=0 rx=0 dropped=0 path=Direct via Ip(…)
|
||||
wg-quic: tsun0 on 10.13.37.69/24 mtu 1280, 1/1 tunnel(s) established
|
||||
4jO4kx9Z 10.13.37.237 handshake 3s ago tx=0 rx=0 dropped=0 path=direct via 192.0.2.7:41234
|
||||
```
|
||||
|
||||
`1/1 tunnel(s) established` means a real WireGuard handshake completed.
|
||||
@@ -126,18 +165,21 @@ ping 100.65.243.53
|
||||
|
||||
`tx` and `rx` in the status should start moving.
|
||||
|
||||
IPv6 works out of the box: each member's address is derived from the network
|
||||
id and collides with essentially nothing.
|
||||
|
||||
**IPv4 addresses are allocated and then remembered.** The default range is
|
||||
**Addresses are allocated and then remembered.** The default range is
|
||||
`10.13.37.0/24`; the first member to join settles it and later members adopt
|
||||
what they find, so `--ipv4-range` only matters for whoever starts the network:
|
||||
|
||||
```bash
|
||||
tsunagi up --network lab --secret "$SECRET" --wireguard --ipv4-range 10.44.0.0/16
|
||||
tsunagi up --network lab --secret "$SECRET" --wireguard --ipv4-range none # IPv6 only
|
||||
tsunagi up --ipv4-range 10.44.0.0/16
|
||||
tsunagi up --ipv4-range none # no data plane at all
|
||||
```
|
||||
|
||||
One agent has one interface, so two of its networks cannot both use that
|
||||
range. The second takes the range **derived from its own network id**: not
|
||||
picked locally — every member derives the same one from something they all
|
||||
already have — so it is an agreement rather than a guess, and a device in
|
||||
several networks gets an address in each.
|
||||
|
||||
An address is claimed with a record signed by that member's persistent device
|
||||
key, stored, and merged between every replica. A member that disappears for a
|
||||
month comes back to the same address, because the claim outlived the session.
|
||||
@@ -149,6 +191,123 @@ Because the address is allocated at run time rather than derived, it is not
|
||||
known until the agent has started and agreed with its peers. The agent then
|
||||
assigns it to the interface itself.
|
||||
|
||||
## Levels
|
||||
|
||||
The command line is split the way the design is. A bare `tsunagi up` needs
|
||||
only a network name and a secret; everything else sits under the level it
|
||||
belongs to, which `--help` shows as two sections:
|
||||
|
||||
* **System** — what the agent itself does: how it reaches peers (`--reach`),
|
||||
the one overlay interface it owns (`--interface`, `--mtu`, `--no-tun`), the
|
||||
address range (`--ipv4-range`), and the local resolver (`--no-dns` to disable).
|
||||
* **Transport** — which protocols carry packets (`--protocol`, a list) and
|
||||
their own settings (`-o key=value`, or `-o protocol:key=value`).
|
||||
|
||||
```bash
|
||||
tsunagi protocols # what this build can carry packets with
|
||||
```
|
||||
|
||||
```text
|
||||
wg-quic (wire version 4)
|
||||
what WireGuard's cryptography carried in iroh's QUIC datagrams
|
||||
-o keepalive=SECONDS keeps a tunnel and its link warm through a NAT
|
||||
-o mtu=BYTES largest packet a tunnel will carry, at least 576
|
||||
```
|
||||
|
||||
Each protocol declares its own settings, so the agent can list them without
|
||||
knowing anything about the protocol, and a setting no selected protocol takes
|
||||
is refused rather than ignored. `--protocol none` runs the control plane by
|
||||
itself.
|
||||
|
||||
A pair of peers uses a protocol they both have **at the same wire version**.
|
||||
That is not the software version: two peers on different builds carry traffic
|
||||
for each other for as long as the bytes between them have not changed. A peer
|
||||
with nothing in common keeps its control plane — messages and signed state
|
||||
still flow — and simply has no data plane, which `tsunagi status` shows as a
|
||||
session with no agreed protocol.
|
||||
|
||||
## Names
|
||||
|
||||
DNS is enabled by default and serves a local zone for **every network this device is in**,
|
||||
each named after the network, so members can be reached by name instead of
|
||||
by address:
|
||||
|
||||
```bash
|
||||
tsunagi up # DNS is already enabled
|
||||
dig @127.0.0.1 -p 5354 music.lab
|
||||
```
|
||||
|
||||
An explicit choice is remembered across restarts. `--no-dns` or `tsunagi dns off`
|
||||
disables it; `--dns` or `tsunagi dns on` enables it again. Existing saved opt-outs
|
||||
remain respected. The `dns on` and `dns off` commands also take effect immediately
|
||||
on a running agent. `--dns` and `--no-dns` cannot be combined.
|
||||
|
||||
The zone of a network is its name — `--dns-zone` is gone, because with
|
||||
several networks there is no single zone to name. A network name may contain
|
||||
dots, so `--network lab.internal` is how you get `music.lab.internal`.
|
||||
|
||||
Names come from signed state, which is the point: **a member that is
|
||||
switched off still resolves**, because its claim outlived the session.
|
||||
|
||||
The answers are IPv4 addresses, because that is what the overlay is. The
|
||||
*questions* are taken on `127.0.0.1` and `[::1]`, over UDP and TCP, so a
|
||||
resolver reaches it over whichever family it uses; both are published to the
|
||||
system resolver together. Loopback and nothing else: the zones are a view
|
||||
for the host running the agent, and binding an overlay address would put
|
||||
them in front of the whole mesh — an agent in two networks would then answer
|
||||
one network's questions about the other's names.
|
||||
|
||||
The zone is the network name, and it is yours to choose. A name that
|
||||
shadows a real public domain is reported and then used: a network called
|
||||
`ru` warns that every public `.ru` name becomes unreachable from this host,
|
||||
and then does it. Anything that collides with nothing — which is most names
|
||||
— is said nothing about, because a warning that fires on every private name
|
||||
anybody picks is how people learn to ignore warnings.
|
||||
|
||||
On Linux the agent tells systemd-resolved to send questions for that suffix
|
||||
here, over D-Bus, scoped to the overlay interface and as a *routing* domain
|
||||
so it never becomes the resolver for anything else. resolved drops the whole
|
||||
setting when the interface goes, and the interface goes with the agent.
|
||||
|
||||
Only an interface the agent created, though. Under `--no-tun` there is no
|
||||
host interface at all, and the agent says so and serves the zone on
|
||||
loopback rather than configuring whatever else on the host happens to share
|
||||
the name.
|
||||
|
||||
That last step needs permission that `CAP_NET_ADMIN` does not give:
|
||||
systemd-resolved asks polkit, and polkit decides by **user**, not by
|
||||
capability, so there is no way for the agent to arrange it from inside. On
|
||||
a desktop the refusal reads `Interactive authentication required`.
|
||||
|
||||
Running as a system service is enough. Otherwise the agent prints the rule
|
||||
that grants it — the four actions it calls and nothing else — ready to
|
||||
paste:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/polkit-1/rules.d/50-tsunagi-resolved.rules > /dev/null <<'RULE'
|
||||
polkit.addRule(function(action, subject) {
|
||||
var allowed = [
|
||||
"org.freedesktop.resolve1.set-dns-servers",
|
||||
"org.freedesktop.resolve1.set-domains",
|
||||
"org.freedesktop.resolve1.set-default-route",
|
||||
"org.freedesktop.resolve1.revert"
|
||||
];
|
||||
if (allowed.indexOf(action.id) >= 0 && subject.user == "YOUR-USER") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
RULE
|
||||
```
|
||||
|
||||
**Without it the server still runs** — `tsunagi status` prints where it is
|
||||
listening and the exact `dig` line — so the automatic part is missing, not
|
||||
the feature. The refusal is said once rather than on every pass, and retried
|
||||
slowly, because nothing but a person will change it.
|
||||
|
||||
The server is authoritative for its zone and nothing else. No recursion, no
|
||||
forwarding, no cache: pointing a resolver at it can never make it a route to
|
||||
the outside.
|
||||
|
||||
## Privileges
|
||||
|
||||
On Linux the agent **manages its own overlay interface**. It creates the TUN
|
||||
@@ -184,10 +343,14 @@ sayable — it remembers who belongs while they are gone, so the report can say
|
||||
dial-failure counter to imply it. Counters are history and are never graded:
|
||||
a peer that left and came back should not leave the report looking broken.
|
||||
|
||||
`id` is the other half: it shows what this device is — its signing key, the
|
||||
name it answers to, and the secret of every network it has joined — and
|
||||
changes those. Every item takes the same shape, so there is nothing to
|
||||
remember: name it to see it, name it with a value to change it.
|
||||
The other two commands split along the line the system itself draws. `id` is
|
||||
this **device**: the key it signs with and the name it answers to. `network`
|
||||
is what it **belongs to**: which networks, their secrets, joining and
|
||||
leaving. A device outlives every network it is in, and a network outlives
|
||||
any device in it, so a command that mixed them had to be read twice.
|
||||
|
||||
Every item takes the same shape, so there is nothing to remember: name it to
|
||||
see it, name it with a value to change it.
|
||||
|
||||
```
|
||||
tsunagi id everything about this device
|
||||
@@ -195,12 +358,66 @@ tsunagi id hostname the name it answers to
|
||||
tsunagi id hostname mango change it
|
||||
tsunagi id key the key it signs with
|
||||
tsunagi id key rotate replace that key
|
||||
tsunagi id secret the secret of each joined network
|
||||
tsunagi id secret generate a fresh secret for a network that does not exist yet
|
||||
|
||||
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 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 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
|
||||
tsunagi dns on start it, now and after every restart
|
||||
tsunagi dns off stop it, now and after every restart
|
||||
```
|
||||
|
||||
Secrets appear in `id`, which is where you go to ask for one, and never in
|
||||
`status`, in a log, in a `Debug` rendering or in anything sent to a peer.
|
||||
**A network without a secret makes one.** `tsunagi join --network lab`
|
||||
resolves a bare name in the obvious way: if this device is already in
|
||||
exactly one network called `lab`, that one — so the name alone resumes what
|
||||
you have; if it is in none, a fresh random secret, printed in full with the
|
||||
line to send the other machine:
|
||||
|
||||
```
|
||||
joined `lab` (k2on43wadbi5x267vp6z3ogkm7nbjedfdoxyauxhtxhwqrprylba)
|
||||
secret tsn1u7c…
|
||||
|
||||
Run this on the other machine:
|
||||
|
||||
tsunagi join --network lab --secret tsn1u7c…
|
||||
|
||||
Its agent has to be running. If it is not:
|
||||
|
||||
tsunagi up --peer 91e83a6e2b7a…
|
||||
```
|
||||
|
||||
Two networks of one name is a thing that happens — a mistyped secret makes
|
||||
one — so joining says whether the network was already here, and warns when
|
||||
another configured network answers to the same name. A name is a label and
|
||||
the id is the identity.
|
||||
|
||||
That is the ad-hoc case: one person makes a network and sends the command
|
||||
round. The secret is printed *only* when the agent invented it — there is
|
||||
nowhere else to read it from — and never when it was supplied, because then
|
||||
it is already yours. Two networks of one name and no secret is the one case
|
||||
with no answer, and it says so instead of choosing.
|
||||
|
||||
A secret is printed by `network secret` and nowhere else — not by `id`, not
|
||||
by `status`, not in a log, a `Debug` rendering or anything sent to a peer.
|
||||
Asking for it is deliberate, because these reports get pasted into chats.
|
||||
|
||||
**`up` takes no network at all** if you would rather decide later: it
|
||||
brings up the agent and whatever it is already configured for, and waits.
|
||||
That is the shape of a daemon in one terminal and `tsunagi network join`
|
||||
in another.
|
||||
|
||||
`network join` is also the answer to a question `up` cannot: a state
|
||||
directory belongs to one live agent, so a second `tsunagi up` cannot add a
|
||||
network to the one already running. This adds it over the control socket and
|
||||
it starts at once. With no agent running it is written to the configuration
|
||||
and starts with the next `up`.
|
||||
|
||||
The name is part of the signed state, so changing it revokes the previous
|
||||
one: there is one record per author, a new version replaces the whole claim,
|
||||
@@ -247,13 +464,12 @@ its whole life has carrier for its whole life.
|
||||
|
||||
### The MTU is 1280
|
||||
|
||||
That is the minimum IPv6 requires (RFC 8200), and Linux enforces it by
|
||||
disabling IPv6 outright on an interface below it — the per-device
|
||||
`/proc/sys/net/ipv6` entries vanish and adding an address fails with
|
||||
`Invalid argument`. A smaller MTU cannot work at all, so the agent refuses one
|
||||
rather than letting it fail later. See
|
||||
[docs/wireguard.md](docs/wireguard.md#mtu) for the ceiling that pushes back
|
||||
from the other side.
|
||||
The default interface MTU stays at 1280 on direct paths, relays and paths
|
||||
inside another VPN. Large encrypted packets are split into smaller QUIC
|
||||
datagrams and reassembled before reaching WireGuard. SSH and full-size TCP
|
||||
segments therefore do not require a manual MTU override or MSS adjustment.
|
||||
The original IP packet, including its DF bit, is preserved. See
|
||||
[docs/wireguard.md](docs/wireguard.md#mtu) for limits and compatibility.
|
||||
|
||||
### Summary
|
||||
|
||||
@@ -262,14 +478,36 @@ from the other side.
|
||||
| `setcap cap_net_admin+p` | ordinary user, one capability | recommended: nothing to prepare, nothing left behind. Lost on every rebuild or copy of the binary. |
|
||||
| systemd service | `User=`, `AmbientCapabilities=CAP_NET_ADMIN` | the same, for an installed service |
|
||||
| `sudo tsunagi up` | root | everything works, nothing is isolated |
|
||||
| macOS root LaunchDaemon | root | the native macOS deployment; see below |
|
||||
| `--no-tun` | ordinary user, no capabilities | tunnels run and handshake, traffic never reaches the OS |
|
||||
|
||||
**Not implemented yet.** macOS and Windows have no provisioner: both need
|
||||
real platform work — `utun` and `SystemConfiguration` on one, the IP Helper
|
||||
API and a Wintun adapter on the other. There the agent says so and `--no-tun`
|
||||
is the way to run it; the control plane and the tunnels are unaffected. The
|
||||
decision logic that says *what* to change is shared and tested on every
|
||||
platform, so only the execution is left to write.
|
||||
On Windows, put `wintun.dll` beside the executable and start `tsunagi up`
|
||||
from PowerShell or Command Prompt opened with **Run as administrator**.
|
||||
Run commands controlling that agent (`join`, `status`, `dns`, `network`)
|
||||
as the same Windows user with the same elevation. If Windows denies access
|
||||
to the agent's control pipe, the command reports a permission error instead
|
||||
of claiming the agent is absent or trying to edit its locked state.
|
||||
Creating a TUN without sufficient privileges also explains how to restart
|
||||
the agent with the required permissions.
|
||||
|
||||
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
|
||||
|
||||
@@ -287,7 +525,6 @@ tests:
|
||||
|
||||
```bash
|
||||
cargo run --example two_agents # control plane only
|
||||
cargo run --example wireguard_mesh # a WireGuard overlay carrying a real packet
|
||||
```
|
||||
|
||||
Both run with no privileges and change nothing on the host.
|
||||
@@ -340,31 +577,85 @@ async fn main() -> Result<()> {
|
||||
reaches the internet by accident. Opt into `DirectOnly` or `N0Defaults`
|
||||
explicitly.
|
||||
|
||||
### Through somebody in the middle
|
||||
|
||||
Tsunagi routes the overlay in userspace. A direct data link always wins;
|
||||
otherwise the shortest available path can pass through several members.
|
||||
Each authenticated member advertises only its own live, protocol-specific
|
||||
transport links. The control plane collects these into a graph and replaces
|
||||
the routing table when connectivity changes; announcements expire after
|
||||
90 seconds without refresh.
|
||||
|
||||
Transit goes transport → router → transport, without entering WireGuard or
|
||||
the intermediate host's TUN. The payload stays encrypted between the original
|
||||
endpoints. TUN connects only the local OS to Tsunagi; the kernel needs only
|
||||
the overlay route through that interface, with no IP forwarding configuration.
|
||||
|
||||
A 16-hop limit bounds loops while topology updates converge. Equal shortest
|
||||
paths use a stable flow hash derived before encryption, so packets of a TCP
|
||||
or UDP flow keep their path while topology is unchanged. Physical link changes
|
||||
do not replace end-to-end WireGuard tunnels or change the 1280-byte default MTU.
|
||||
`status` reports the hop count and first next hop for relayed paths.
|
||||
|
||||
The hot path reads an immutable routing snapshot and sends directly to a cached
|
||||
transport handle. It takes no routing mutex, walks no graph and does not parse
|
||||
the encrypted payload. See [routing.md](docs/routing.md) for the architecture,
|
||||
limits and reproducible forwarding microbenchmark.
|
||||
|
||||
This wire format requires all members to upgrade together (control ALPN 3,
|
||||
data ALPN 4); saved identities, network names, secrets and addresses survive.
|
||||
|
||||
### LAN game discovery
|
||||
|
||||
IPv4 UDP broadcast relay is enabled by default for each network. The IP router
|
||||
sends one encrypted copy to each participating peer, including through multihop
|
||||
paths. It supports `255.255.255.255` and the overlay subnet's broadcast address;
|
||||
received broadcasts never trigger another fanout.
|
||||
|
||||
```sh
|
||||
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
|
||||
|
||||
Two different lookups are involved, and only one of them is this project's:
|
||||
|
||||
**1. Resolving one endpoint's address — iroh's, and it works today.**
|
||||
With `--transport relay` or `--transport direct`, iroh publishes a signed
|
||||
With `--reach relay` or `--reach direct`, iroh publishes a signed
|
||||
record of this endpoint's addresses, keyed by its endpoint id, to the public
|
||||
service run by Number 0 — "n0", the company behind iroh — at `dns.iroh.link`,
|
||||
over pkarr and DNS, and resolves other endpoints the same way. That is why `--peer <endpoint-id>` works with no address attached:
|
||||
iroh looks it up. None of that code is ours.
|
||||
|
||||
**2. Finding who is in a network — ours, and it is still manual.**
|
||||
`NetworkDiscovery` maps a secret-derived `DiscoveryKey` to a set of *candidate*
|
||||
members. Two backends exist: `StaticBootstrap` (what `--peer` feeds) and an
|
||||
in-memory one for tests. The planned Mainline DHT backend, which would let
|
||||
members find each other from the network secret alone, is **not implemented**.
|
||||
So today you bootstrap by passing one peer's id; after that the mesh is
|
||||
whatever those agents reach.
|
||||
**2. Finding who is in a network — Mainline DHT by default.**
|
||||
`NetworkDiscovery` supplies unverified candidates. The Mainline backend uses
|
||||
a signing key derived from the network name and secret to publish signed
|
||||
BEP44 records containing each writer's endpoint ID and addresses. Its 16
|
||||
slots provide starting candidates, not a complete membership list or a limit
|
||||
on network size. After the first authenticated connection, introductions and
|
||||
signed state reveal the other members. `StaticBootstrap` (`--peer`) remains
|
||||
available, and tests use either a local Mainline Testnet or in-memory discovery.
|
||||
|
||||
What this means in practice:
|
||||
|
||||
- With `relay` or `direct`, **your endpoint id and IP addresses are published
|
||||
to a public third-party service** (Number 0's, unless you change it). They are not secret, and the network secret is
|
||||
never published, but an observer of that service learns that your endpoint
|
||||
exists and where it is. `--transport local` publishes nothing.
|
||||
exists and where it is. `--reach local` publishes nothing.
|
||||
- With DHT enabled, endpoint records are also published to the public Mainline
|
||||
DHT. They are signed, not encrypted; membership still requires the secret.
|
||||
- A relay, when one is needed, sees the volume and timing of your traffic — not
|
||||
its contents. The default relays are Number 0's, in the US, EU and
|
||||
Asia-Pacific.
|
||||
@@ -380,15 +671,72 @@ paths; tests always use temporary directories.
|
||||
| `state.sqlite` | device identity, network configuration, hostname | clear error, never reset |
|
||||
| `cache.sqlite` | address hints and other recoverable data | discarded and recreated |
|
||||
|
||||
The WireGuard plugin keeps its own keys in its own `wireguard.sqlite`, wherever
|
||||
its configuration points, because plugin keys are neither the iroh identity nor
|
||||
the network secret.
|
||||
The `wg-quic` protocol keeps its own keys in its own store under `wg-quic/`,
|
||||
because a protocol's keys are neither the iroh identity nor the network
|
||||
secret.
|
||||
|
||||
The command line agent puts everything under the platform's per-user
|
||||
directories by default; `--state-dir` and `--cache-dir` override them.
|
||||
|
||||
One state directory belongs to one live agent, enforced with a real OS file
|
||||
lock rather than an existence check.
|
||||
lock rather than an existence check. That directory *is* the identity: one
|
||||
agent, one device key, one interface, and as many networks as you like on it
|
||||
— `tsunagi network join` adds them to the agent that is already running,
|
||||
which is why a second `tsunagi up` on the same directory is refused rather
|
||||
than made to work. A second agent on the same host is a second identity, and
|
||||
needs everything of its own: its own state and cache directories, its own
|
||||
interface name and its own overlay range. The library owns no globals, so
|
||||
several of them run side by side in one process as readily as in one host.
|
||||
|
||||
### Leaving, and starting over
|
||||
|
||||
Membership outlives a session, so it also has to be possible to end it.
|
||||
|
||||
```bash
|
||||
tsunagi network # what this device belongs to
|
||||
tsunagi network leave lab # give up the address and the name, then forget it
|
||||
tsunagi wipe --yes # remove everything and be a stranger again
|
||||
```
|
||||
|
||||
**Stopping is not leaving.** `stop` closes this network's sessions, takes
|
||||
its address off the interface and keeps it from starting again, and that is
|
||||
all: the configuration, the secret, the signed state and the protocol key
|
||||
stay exactly as they are, nothing is announced, and to the others this
|
||||
device is simply away — an ordinary condition they already handle, with its
|
||||
address and name still reserved for it. `start` picks it up where it left
|
||||
off. A network named on the `up` command line is started by that command
|
||||
whatever its stored state, and the start-up banner says so rather than
|
||||
letting a `stop` quietly come back.
|
||||
|
||||
`leave` is the other one, and it removes, locally: the configuration and
|
||||
the secret, this network's signed records, its cached address hints and the
|
||||
protocol key it used there. What it keeps is this author's version counter
|
||||
for that network — a rejoin has to continue above the release, or every
|
||||
replica would treat the new claim as stale. Everything about *other*
|
||||
networks, and the device identity itself, is untouched.
|
||||
|
||||
`leave` publishes a signed `Release` **first**, while the agent is running and
|
||||
its sessions are up, so the address and the name are freed for the others
|
||||
instead of staying reserved to a member that has gone. They pass the tombstone
|
||||
on, so a member that was away hears it from them. With no agent running,
|
||||
nothing can sign or send it: the command says so and refuses, and `--offline`
|
||||
drops the network locally while leaving the others holding the old claim. The
|
||||
protocol key for that network goes too — rejoining is joining, not resuming.
|
||||
|
||||
`leave`, `stop`, `start`, `secret` and `broadcast` accept an exact stored network
|
||||
name, its full id, or a unique id prefix (including a copied report ellipsis).
|
||||
Names are case-sensitive. If several networks share a name, or a name matches
|
||||
another network's id prefix, the command lists matching names and full ids and
|
||||
changes nothing. Use a full id to choose explicitly; a full id always takes
|
||||
precedence over a name. No secret is needed to select a configured network.
|
||||
|
||||
`wipe` removes both directories' contents: the device identity, every network,
|
||||
every signed record and everything a protocol kept beside them. It refuses
|
||||
while an agent is running, and refuses a directory with no `state.sqlite` in
|
||||
it, so a mistyped `--state-dir` cannot take somebody's documents with it.
|
||||
Without `--yes` it only says what it would remove. It is not a goodbye: nobody
|
||||
is told, because after it there is no key left to sign anything with. Leave the
|
||||
networks first if the addresses should be freed.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "tsunagi-cli"
|
||||
version = "0.1.0"
|
||||
description = "The tsunagi command line agent."
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "tsunagi"
|
||||
path = "src/main.rs"
|
||||
# The binary and the library share a name, which `cargo doc` cannot map to
|
||||
# two directories. The command line is documented by `--help`, not rustdoc,
|
||||
# so the library keeps the name and this opts out.
|
||||
doc = false
|
||||
|
||||
[dependencies]
|
||||
tsunagi = { path = "../tsunagi", version = "0.1.0", features = ["tun-device", "dns-publish"] }
|
||||
tsunagi-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
|
||||
tracing-subscriber.workspace = true
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
# Already in the tree through clap. `anstream` strips the escapes when stdout
|
||||
# is not a terminal and turns on virtual terminal processing on Windows, so
|
||||
# colour is never written where it would show up as rubbish.
|
||||
anstream = "1.0"
|
||||
anstyle = "1.0"
|
||||
# Local interface addresses, for the diagnostics in `status`.
|
||||
netwatch = "0.19.3"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
||||
tempfile.workspace = true
|
||||
simple-dns = "0.12"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,347 @@
|
||||
//! The DNS service as the binary actually runs it.
|
||||
//!
|
||||
//! The zone and the server have their own tests. What this covers is the
|
||||
//! wiring between them and the agent, which is where the interesting
|
||||
//! mistakes live: choosing an address to listen on, and deciding when to
|
||||
//! rebuild the listener. Both were wrong once, and neither was reachable
|
||||
//! from a unit test, so this runs the real binary.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use simple_dns::{Name, Packet, QCLASS, RCODE, TYPE, rdata::RData};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// A port per test, high enough to need no privileges and fixed so the query
|
||||
// knows where to look. Distinct because these tests run in parallel and each
|
||||
// starts its own agent.
|
||||
const PORT_BINDS: u16 = 15361;
|
||||
const PORT_REBIND: u16 = 15362;
|
||||
const PORT_REFUSE: u16 = 15363;
|
||||
const PORT_TWO_ZONES: u16 = 15364;
|
||||
const PORT_SWITCH: u16 = 15365;
|
||||
const PORT_FLAGS: u16 = 15366;
|
||||
|
||||
/// Asks, and returns the raw reply. Raw because a parsed packet borrows
|
||||
/// from the bytes it came out of.
|
||||
fn query(server: SocketAddr, name: &str, qtype: TYPE) -> Option<Vec<u8>> {
|
||||
let mut packet = Packet::new_query(0x2468);
|
||||
packet.questions.push(simple_dns::Question::new(
|
||||
Name::new(name).unwrap(),
|
||||
qtype.into(),
|
||||
QCLASS::CLASS(simple_dns::CLASS::IN),
|
||||
false,
|
||||
));
|
||||
let bytes = packet.build_bytes_vec().unwrap();
|
||||
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").ok()?;
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(500)))
|
||||
.ok()?;
|
||||
socket.send_to(&bytes, server).ok()?;
|
||||
let mut buffer = vec![0u8; 4096];
|
||||
let read = socket.recv(&mut buffer).ok()?;
|
||||
buffer.truncate(read);
|
||||
Packet::parse(&buffer).ok()?;
|
||||
Some(buffer)
|
||||
}
|
||||
|
||||
/// Whether a reply carries at least one answer record.
|
||||
fn has_answer(reply: &[u8]) -> bool {
|
||||
Packet::parse(reply).is_ok_and(|packet| !packet.answers.is_empty())
|
||||
}
|
||||
|
||||
/// Blocks until the server answers, or gives up.
|
||||
fn wait_for_answer(server: SocketAddr, name: &str) -> Vec<u8> {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
if let Some(reply) = query(server, name, TYPE::A)
|
||||
&& has_answer(&reply)
|
||||
{
|
||||
return reply;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "the dns server never answered");
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
/// The agent, running as a real process with its DNS service on.
|
||||
struct Running {
|
||||
child: std::process::Child,
|
||||
dir: TempDir,
|
||||
}
|
||||
|
||||
impl Running {
|
||||
fn wait_ready(&mut self) {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let socket = tsunagi::ipc::control_socket_path(&self.dir.path().join("state"));
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
while !runtime.block_on(tsunagi::ipc::is_serving(&socket)) {
|
||||
assert!(
|
||||
self.child.try_wait().unwrap().is_none(),
|
||||
"the agent exited before becoming ready"
|
||||
);
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"the control socket never became ready"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
|
||||
fn restart(&mut self, dns_flag: Option<&str>) {
|
||||
self.child.kill().unwrap();
|
||||
self.child.wait().unwrap();
|
||||
self.child = spawn_child(&self.dir, None, dns_flag);
|
||||
self.wait_ready();
|
||||
}
|
||||
|
||||
/// Runs another `tsunagi` command against this agent's directory.
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts an agent in one network, whose name is therefore the zone.
|
||||
///
|
||||
/// There is no separate zone setting: an agent serves a zone per network,
|
||||
/// named after it, so the network name is the zone name.
|
||||
fn start(zone: &str, port: u16) -> Running {
|
||||
let agent = start_without_dns_at(zone, Some(port));
|
||||
let joined = agent.run(&[
|
||||
"join",
|
||||
"--network",
|
||||
zone,
|
||||
"--secret",
|
||||
"a-secret-for-the-dns-test",
|
||||
]);
|
||||
assert!(
|
||||
joined.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&joined.stderr)
|
||||
);
|
||||
agent
|
||||
}
|
||||
|
||||
/// Starts an agent in one network with the resolver off, to switch on later.
|
||||
fn start_without_dns(network: &str) -> Running {
|
||||
let agent = start_without_dns_at(network, None);
|
||||
let joined = agent.run(&[
|
||||
"join",
|
||||
"--network",
|
||||
network,
|
||||
"--secret",
|
||||
"a-secret-for-the-dns-test",
|
||||
]);
|
||||
assert!(
|
||||
joined.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&joined.stderr)
|
||||
);
|
||||
agent
|
||||
}
|
||||
|
||||
/// The agent alone: `up` runs it, and what it belongs to is decided after.
|
||||
fn start_without_dns_at(_network: &str, dns_port: Option<u16>) -> Running {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let child = spawn_child(&dir, dns_port, dns_port.is_none().then_some("--no-dns"));
|
||||
let mut agent = Running { child, dir };
|
||||
agent.wait_ready();
|
||||
agent
|
||||
}
|
||||
|
||||
fn spawn_child(
|
||||
dir: &TempDir,
|
||||
dns_port: Option<u16>,
|
||||
dns_flag: Option<&str>,
|
||||
) -> std::process::Child {
|
||||
let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"));
|
||||
command
|
||||
.arg("up")
|
||||
.arg("--state-dir")
|
||||
.arg(dir.path().join("state"))
|
||||
.arg("--cache-dir")
|
||||
.arg(dir.path().join("cache"))
|
||||
// No real interface and no internet: this is about the wiring.
|
||||
.args(["--reach", "local", "--no-tun"]);
|
||||
if let Some(port) = dns_port {
|
||||
command.args(["--dns-port", &port.to_string()]);
|
||||
}
|
||||
if let Some(flag) = dns_flag {
|
||||
command.arg(flag);
|
||||
}
|
||||
command
|
||||
.args(["--log", "error", "--status-interval", "0"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.expect("the agent binary starts")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_resolver_is_on_by_default_even_without_an_overlay_interface() {
|
||||
// The promise is that the port is served whatever else fails: with
|
||||
// `--no-tun` there is no interface to attach a resolver setting to,
|
||||
// and the zone is answered on loopback all the same. Getting this
|
||||
// wrong left the feature silently dead.
|
||||
let _agent = start("lab.internal", PORT_BINDS);
|
||||
let server: SocketAddr = format!("127.0.0.1:{PORT_BINDS}").parse().unwrap();
|
||||
|
||||
let reply = wait_for_answer(server, &format!("{}.lab.internal", hostname()));
|
||||
let answer = Packet::parse(&reply).unwrap();
|
||||
assert_eq!(answer.rcode(), RCODE::NoError);
|
||||
match &answer.answers[0].rdata {
|
||||
RData::A(_) => {}
|
||||
other => panic!("expected an A record, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_listener_is_not_rebuilt_on_every_pass() {
|
||||
// The listener is bound once and kept, and the zones are swapped
|
||||
// underneath it as networks and members come and go. Rebuilding it on
|
||||
// the way past — which an earlier version did on every tick — shut the
|
||||
// port for a moment each time.
|
||||
let _agent = start("rebind.internal", PORT_REBIND);
|
||||
let server: SocketAddr = format!("127.0.0.1:{PORT_REBIND}").parse().unwrap();
|
||||
let name = format!("{}.rebind.internal", hostname());
|
||||
wait_for_answer(server, &name);
|
||||
|
||||
// Long enough to cross several of the supervisor's passes.
|
||||
for round in 0..6 {
|
||||
std::thread::sleep(Duration::from_millis(900));
|
||||
assert!(
|
||||
query(server, &name, TYPE::A).is_some_and(|reply| has_answer(&reply)),
|
||||
"the server stopped answering on round {round}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_outside_the_zone_is_refused_and_never_forwarded() {
|
||||
let _agent = start("refuse.internal", PORT_REFUSE);
|
||||
let server: SocketAddr = format!("127.0.0.1:{PORT_REFUSE}").parse().unwrap();
|
||||
wait_for_answer(server, &format!("{}.refuse.internal", hostname()));
|
||||
|
||||
let reply = query(server, "example.com", TYPE::A).expect("an answer");
|
||||
let answer = Packet::parse(&reply).unwrap();
|
||||
assert_eq!(answer.rcode(), RCODE::Refused);
|
||||
assert!(answer.answers.is_empty());
|
||||
}
|
||||
|
||||
fn hostname() -> String {
|
||||
tsunagi::agent::system_hostname().unwrap_or_else(|| "unknown".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_network_gets_a_zone_of_its_own() {
|
||||
// One agent, one identity, several networks — and a question carries a
|
||||
// name, not the network it belongs to. Each network is a zone named
|
||||
// after it, and joining one while the agent runs adds its zone without
|
||||
// restarting anything.
|
||||
let agent = start("first.internal", PORT_TWO_ZONES);
|
||||
let server: SocketAddr = format!("127.0.0.1:{PORT_TWO_ZONES}").parse().unwrap();
|
||||
let host = hostname();
|
||||
wait_for_answer(server, &format!("{host}.first.internal"));
|
||||
|
||||
let joined = agent.run(&[
|
||||
"join",
|
||||
"--network",
|
||||
"second.internal",
|
||||
"--secret",
|
||||
"another-secret-for-the-dns-test",
|
||||
]);
|
||||
assert!(
|
||||
joined.status.success(),
|
||||
"joining failed: {}",
|
||||
String::from_utf8_lossy(&joined.stderr)
|
||||
);
|
||||
|
||||
// The second network's zone answers too, and neither leaks into the
|
||||
// other: a member of one is not a name in the other.
|
||||
let reply = wait_for_answer(server, &format!("{host}.second.internal"));
|
||||
assert_eq!(Packet::parse(&reply).unwrap().rcode(), RCODE::NoError);
|
||||
let first = wait_for_answer(server, &format!("{host}.first.internal"));
|
||||
assert_eq!(Packet::parse(&first).unwrap().rcode(), RCODE::NoError);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_resolver_can_be_switched_on_and_off_while_the_agent_runs() {
|
||||
// An intentional --no-dns can be reversed at runtime without restarting.
|
||||
let agent = start_without_dns("switch.internal");
|
||||
let server: SocketAddr = format!("127.0.0.1:{PORT_SWITCH}").parse().unwrap();
|
||||
let host = hostname();
|
||||
|
||||
assert!(
|
||||
query(server, &format!("{host}.switch.internal"), TYPE::A).is_none(),
|
||||
"nothing should be answering yet"
|
||||
);
|
||||
|
||||
let on = agent.run(&["dns", "on", "--port", &PORT_SWITCH.to_string()]);
|
||||
assert!(
|
||||
on.status.success(),
|
||||
"dns on failed: {}",
|
||||
String::from_utf8_lossy(&on.stderr)
|
||||
);
|
||||
wait_for_answer(server, &format!("{host}.switch.internal"));
|
||||
|
||||
let off = agent.run(&["dns", "off"]);
|
||||
assert!(off.status.success());
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while query(server, &format!("{host}.switch.internal"), TYPE::A).is_some() {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"it kept answering after `dns off`"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dns_choices_override_the_saved_setting_and_survive_restart() {
|
||||
let mut agent = start("flags.internal", PORT_FLAGS);
|
||||
let server = format!("127.0.0.1:{PORT_FLAGS}").parse().unwrap();
|
||||
let name = format!("{}.flags.internal", hostname());
|
||||
wait_for_answer(server, &name);
|
||||
|
||||
for flag in [Some("--no-dns"), None] {
|
||||
agent.restart(flag);
|
||||
let state = agent.run(&["dns"]);
|
||||
assert!(state.status.success());
|
||||
assert!(String::from_utf8_lossy(&state.stdout).contains("not serving"));
|
||||
assert!(query(server, &name, TYPE::A).is_none());
|
||||
}
|
||||
agent.restart(Some("--dns"));
|
||||
wait_for_answer(server, &name);
|
||||
agent.restart(None);
|
||||
wait_for_answer(server, &name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opposing_dns_flags_are_rejected() {
|
||||
let result = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
.args(["up", "--dns", "--no-dns"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!result.status.success());
|
||||
assert!(String::from_utf8_lossy(&result.stderr).contains("cannot be used with"));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! What the command line says about this device, against a running agent.
|
||||
//!
|
||||
//! These drive the real binary, which is why they live beside it rather
|
||||
//! than with the library's own tests: the library cannot depend on a binary
|
||||
//! built from a crate that depends on it.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use tempfile::TempDir;
|
||||
use tsunagi::Agent;
|
||||
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
||||
|
||||
/// Asking who this device is must not need the directory lock.
|
||||
///
|
||||
/// The lock belongs to the one agent allowed to *write* the state. `id` only
|
||||
/// reads, so making it take the lock would mean the question could never be
|
||||
/// answered while an agent was running — which is exactly when you want to
|
||||
/// ask it.
|
||||
#[tokio::test]
|
||||
async fn identity_can_be_read_while_an_agent_holds_the_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// Hold the directory the way a running agent does.
|
||||
let agent = Agent::spawn(
|
||||
AgentConfig::new(StoragePaths::under(dir.path()))
|
||||
.with_transport(TransportPolicy::LocalOnly)
|
||||
.with_loopback_bind(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected = agent.endpoint_id().to_string();
|
||||
|
||||
let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
.arg("id")
|
||||
// The same layout `StoragePaths::under` gives the agent above.
|
||||
.arg("--state-dir")
|
||||
.arg(dir.path().join("state"))
|
||||
.arg("--cache-dir")
|
||||
.arg(dir.path().join("cache"))
|
||||
.output()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"`id` failed while an agent was running:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains(&expected),
|
||||
"expected {expected} in:\n{stdout}"
|
||||
);
|
||||
|
||||
agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
//! Making and joining a network from the command line, against a real agent.
|
||||
//!
|
||||
//! The rules here are small and easy to get wrong in a way no unit test
|
||||
//! notices: what a bare network name means, when a secret is invented, and
|
||||
//! whether what is printed is enough for the other person to paste.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// An agent running as a real process, with its own directories.
|
||||
struct Running {
|
||||
child: std::process::Child,
|
||||
dir: TempDir,
|
||||
}
|
||||
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Running {
|
||||
fn run(&self, args: &[&str]) -> std::process::Output {
|
||||
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 {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
.arg("up")
|
||||
.arg("--state-dir")
|
||||
.arg(dir.path().join("state"))
|
||||
.arg("--cache-dir")
|
||||
.arg(dir.path().join("cache"))
|
||||
.args(["--reach", "local", "--no-tun", "--no-dns"])
|
||||
.arg("--bind")
|
||||
.arg(format!("127.0.0.1:{port}"))
|
||||
.args(["--log", "error", "--status-interval", "0"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.expect("the agent binary starts");
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
Running { child, dir }
|
||||
}
|
||||
|
||||
/// An agent already in one network, the way most of these start.
|
||||
fn start(network: &str, port: u16) -> Running {
|
||||
let agent = start_bare(port);
|
||||
let joined = agent.run(&[
|
||||
"join",
|
||||
"--network",
|
||||
network,
|
||||
"--secret",
|
||||
"a-secret-for-the-cli-test",
|
||||
]);
|
||||
assert!(
|
||||
joined.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&joined.stderr)
|
||||
);
|
||||
agent
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joining_with_no_secret_makes_one_and_prints_what_to_send() {
|
||||
// The hurry case: a network with somebody, for as long as it is needed,
|
||||
// and then gone. Asking for a secret first is a step with no purpose,
|
||||
// and a secret that is not printed is a network nobody else can join.
|
||||
let agent = start("resident", 45071);
|
||||
|
||||
let joined = agent.run(&["network", "join", "--network", "spontaneous"]);
|
||||
assert!(
|
||||
joined.status.success(),
|
||||
"joining failed: {}",
|
||||
String::from_utf8_lossy(&joined.stderr)
|
||||
);
|
||||
let out = String::from_utf8_lossy(&joined.stdout);
|
||||
assert!(out.contains("joined `spontaneous`"), "{out}");
|
||||
|
||||
// The secret in full, and one line the other side can paste as it is.
|
||||
let secret = out
|
||||
.lines()
|
||||
.find_map(|line| line.trim().strip_prefix("secret "))
|
||||
.expect("the invented secret is printed");
|
||||
assert!(secret.starts_with("tsn1"), "{out}");
|
||||
let command = out
|
||||
.lines()
|
||||
.find(|line| line.contains("tsunagi join --network spontaneous"))
|
||||
.expect("a command to send");
|
||||
assert!(command.contains(secret), "with the secret in it: {out}");
|
||||
// And where to find this device, for an agent that is not up yet.
|
||||
assert!(out.contains("--peer "), "somewhere to find us: {out}");
|
||||
|
||||
// Joining the same name again resumes it rather than making another
|
||||
// network that merely looks the same.
|
||||
let again = agent.run(&["network", "join", "--network", "spontaneous"]);
|
||||
let out = String::from_utf8_lossy(&again.stdout);
|
||||
assert!(again.status.success());
|
||||
assert!(out.contains("already here"), "{out}");
|
||||
assert!(!out.contains("secret tsn1"), "no second secret: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_name_resumes_the_network_of_that_name_rather_than_inventing_one() {
|
||||
// `join --network resident` with no secret is the same rule: this device
|
||||
// has exactly one network of that name, so that is the one meant.
|
||||
let agent = start("resident", 45072);
|
||||
let listed = agent.run(&["network"]);
|
||||
let before = String::from_utf8_lossy(&listed.stdout).to_string();
|
||||
let id = before
|
||||
.lines()
|
||||
.find(|line| line.contains("resident"))
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.expect("the network is listed")
|
||||
.to_string();
|
||||
|
||||
let joined = agent.run(&["network", "join", "--network", "resident"]);
|
||||
assert!(joined.status.success());
|
||||
let out = String::from_utf8_lossy(&joined.stdout);
|
||||
assert!(out.contains(&id), "the same network, not a new one: {out}");
|
||||
assert!(out.contains("already here"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_can_be_stopped_and_resumed_without_losing_anything() {
|
||||
// Leaving gives everything up; stopping is being away. The difference
|
||||
// is what somebody wants when they will be back.
|
||||
let agent = start("resident", 45073);
|
||||
let listed = agent.run(&["network"]);
|
||||
let out = String::from_utf8_lossy(&listed.stdout).to_string();
|
||||
let id = out
|
||||
.lines()
|
||||
.find(|line| line.contains("resident"))
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.expect("the network is listed")
|
||||
.to_string();
|
||||
let secret_before = agent.run(&["network", "secret", &id[..10]]);
|
||||
let secret_before = String::from_utf8_lossy(&secret_before.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
assert!(secret_before.starts_with("tsn1"));
|
||||
|
||||
let stopped = agent.run(&["network", "stop", "resident"]);
|
||||
assert!(
|
||||
stopped.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&stopped.stderr)
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&stopped.stdout).contains("stopped `resident`"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&stopped.stdout)
|
||||
);
|
||||
|
||||
// Still configured, and said to be stopped rather than missing.
|
||||
let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string();
|
||||
assert!(listed.contains(&id), "still configured: {listed}");
|
||||
assert!(listed.contains("stopped"), "{listed}");
|
||||
|
||||
// Stopping what is stopped is the state asked for, not an error.
|
||||
let again = agent.run(&["network", "stop", &id[..10]]);
|
||||
assert!(again.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&again.stdout).contains("already stopped"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&again.stdout)
|
||||
);
|
||||
|
||||
let started = agent.run(&["network", "start", "resident"]);
|
||||
assert!(started.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&started.stdout).contains("started `resident`"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&started.stdout)
|
||||
);
|
||||
let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string();
|
||||
assert!(listed.contains("running"), "{listed}");
|
||||
|
||||
// And nothing was given up on the way: the same network, same secret.
|
||||
let secret_after = agent.run(&["network", "secret", "resident"]);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&secret_after.stdout).trim(),
|
||||
secret_before,
|
||||
"the secret is kept, so this is the same network"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_starts_with_no_network_and_takes_one_later() {
|
||||
// A daemon in one terminal and the deciding done in another: the
|
||||
// agent is the identity, and which networks it is in is a separate
|
||||
// question it can answer at any time.
|
||||
let agent = start_bare(45074);
|
||||
|
||||
let listed = agent.run(&["network"]);
|
||||
assert!(listed.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&listed.stderr).contains("no network has been joined"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&listed.stderr)
|
||||
);
|
||||
|
||||
let joined = agent.run(&["network", "join", "--network", "afterwards"]);
|
||||
assert!(
|
||||
joined.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&joined.stderr)
|
||||
);
|
||||
let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string();
|
||||
assert!(listed.contains("afterwards"), "{listed}");
|
||||
assert!(listed.contains("running"), "and it is live: {listed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_is_the_agent_and_takes_no_network_at_all() {
|
||||
// The two commands are separate on purpose: `up` runs the device's
|
||||
// agent, `join` decides what it is in. A network on `up` would be a
|
||||
// second way to do the same thing, and the one that cannot be undone
|
||||
// without a restart.
|
||||
for argument in ["--network", "--secret"] {
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
.args(["up", argument, "whatever"])
|
||||
.arg("--state-dir")
|
||||
.arg(TempDir::new().unwrap().path().join("state"))
|
||||
.output()
|
||||
.expect("the agent binary runs");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"`up {argument}` should not be a thing"
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("unexpected argument"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
#[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"));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "tsunagi-wg-quic"
|
||||
# Its own version, and deliberately separate from the wire version it
|
||||
# negotiates: a release here does not stop it talking to a peer on an older
|
||||
# build, because what peers compare is `ANNOUNCEMENT_VERSION`.
|
||||
version = "0.1.0"
|
||||
description = "The wg-quic protocol for tsunagi: WireGuard's cryptography carried in iroh's QUIC datagrams."
|
||||
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
|
||||
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"] }
|
||||
sha2 = "0.11"
|
||||
hkdf = "0.13"
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.9", features = ["derive"] }
|
||||
rand = "0.10"
|
||||
boringtun = { version = "0.7.1", default-features = false }
|
||||
|
||||
[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
|
||||
+43
-42
@@ -1,45 +1,49 @@
|
||||
//! What a WireGuard peer tells the network about itself.
|
||||
//!
|
||||
//! This is the opaque payload the control plane carries in a
|
||||
//! [`crate::dataplane::PluginCapability`]. The agent core never parses it —
|
||||
//! [`tsunagi::dataplane::PluginCapability`]. The agent core never parses it —
|
||||
//! only this module does, and only after bounding every field.
|
||||
//!
|
||||
//! The announcement is deliberately tiny: a participant says **who it is**,
|
||||
//! not **where it is**. Reachability is the data plane transport's job, and
|
||||
//! the transport already solves it — see
|
||||
//! [`crate::dataplane::transport`]. A plugin that also tried to advertise
|
||||
//! [`tsunagi::dataplane::transport`]. A plugin that also tried to advertise
|
||||
//! addresses would be reimplementing NAT traversal badly.
|
||||
|
||||
use std::net::Ipv6Addr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::identity::NetworkId;
|
||||
use tsunagi::dataplane::PluginError;
|
||||
use tsunagi::identity::NetworkId;
|
||||
|
||||
use super::keys::WgPublicKey;
|
||||
use super::overlay::overlay_address;
|
||||
use crate::keys::WgPublicKey;
|
||||
|
||||
/// Version of the announcement format.
|
||||
///
|
||||
/// Version 3 dropped the IPv4 range again: overlay addressing moved to the
|
||||
/// signed records in [`crate::state`], which carry the range and survive a
|
||||
/// signed records in [`tsunagi::state`], which carry the range and survive a
|
||||
/// participant being away. postcard is not self-describing, so an older peer
|
||||
/// cannot read a newer announcement; the mismatch is reported, not misparsed.
|
||||
pub const ANNOUNCEMENT_VERSION: u16 = 3;
|
||||
pub const ANNOUNCEMENT_VERSION: u16 = 4;
|
||||
|
||||
/// What one participant advertises for the WireGuard data plane.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WgAnnouncement {
|
||||
/// Announcement format version.
|
||||
pub version: u16,
|
||||
/// The peer's WireGuard public key. Its overlay address is derived from it.
|
||||
pub public_key: [u8; 32],
|
||||
/// The overlay address the peer believes it has.
|
||||
/// The peer's WireGuard public key.
|
||||
///
|
||||
/// Carried for diagnostics and cross-checking only. Addresses are always
|
||||
/// derived locally, never taken from this field.
|
||||
pub overlay_address: Ipv6Addr,
|
||||
/// The whole announcement, now that addresses belong to the system
|
||||
/// level: this says *who* is at the other end of a tunnel, and nothing
|
||||
/// about where.
|
||||
pub public_key: [u8; 32],
|
||||
/// The network this key is for.
|
||||
///
|
||||
/// Strictly redundant — a capability arrives on a session that already
|
||||
/// proved membership of one network — and kept anyway, because the
|
||||
/// binding used to be a side effect of checking a derived address and
|
||||
/// losing it silently when that check went would be the wrong way to
|
||||
/// lose it.
|
||||
pub network: [u8; 32],
|
||||
}
|
||||
|
||||
/// A peer announcement that has been validated against a specific network.
|
||||
@@ -47,8 +51,6 @@ pub struct WgAnnouncement {
|
||||
pub struct ValidatedAnnouncement {
|
||||
/// The peer's WireGuard public key.
|
||||
pub public_key: WgPublicKey,
|
||||
/// The overlay address derived locally for this key. Authoritative.
|
||||
pub overlay_address: Ipv6Addr,
|
||||
}
|
||||
|
||||
impl WgAnnouncement {
|
||||
@@ -57,7 +59,7 @@ impl WgAnnouncement {
|
||||
Self {
|
||||
version: ANNOUNCEMENT_VERSION,
|
||||
public_key: *public_key.as_bytes(),
|
||||
overlay_address: overlay_address(network, public_key),
|
||||
network: *network.as_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,20 +107,13 @@ impl WgAnnouncement {
|
||||
"peer announced this agent's own WireGuard key".into(),
|
||||
));
|
||||
}
|
||||
// AllowedIPs are derived, never trusted. A mismatch means the peer is
|
||||
// confused or lying, and either way its own claim is discarded.
|
||||
let derived = overlay_address(network, &public_key);
|
||||
if self.overlay_address != derived {
|
||||
if self.network != *network.as_bytes() {
|
||||
return Err(PluginError::Rejected(
|
||||
"announced overlay address does not match the one derived from the peer's key"
|
||||
.into(),
|
||||
"announcement is for a different network".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ValidatedAnnouncement {
|
||||
public_key,
|
||||
overlay_address: derived,
|
||||
})
|
||||
Ok(ValidatedAnnouncement { public_key })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +122,7 @@ mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
|
||||
use super::super::keys::WgSecretKey;
|
||||
|
||||
@@ -149,7 +144,6 @@ mod tests {
|
||||
let validated = WgAnnouncement::decode_and_validate(&payload, id, &local).unwrap();
|
||||
|
||||
assert_eq!(validated.public_key, peer);
|
||||
assert_eq!(validated.overlay_address, overlay_address(id, &peer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -167,20 +161,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_ips_are_derived_not_taken_from_the_peer() {
|
||||
fn there_is_nothing_address_like_to_forge() {
|
||||
// Addresses belong to the system level, are allocated there and are
|
||||
// signed by the member that holds one. A protocol announcement
|
||||
// carries no address at all, so this is not a thing a peer can lie
|
||||
// about here — and a peer sending traffic from an address it does
|
||||
// not hold is rejected by the agreed address, not by anything it
|
||||
// said in this message.
|
||||
let id = network("no-hijack");
|
||||
let victim = WgSecretKey::generate().public();
|
||||
let attacker = WgSecretKey::generate().public();
|
||||
let peer = WgSecretKey::generate().public();
|
||||
let local = WgSecretKey::generate().public();
|
||||
|
||||
// An attacker claims the victim's overlay address with its own key.
|
||||
let mut forged = WgAnnouncement::new(id, &attacker);
|
||||
forged.overlay_address = overlay_address(id, &victim);
|
||||
let announcement = WgAnnouncement::new(id, &peer);
|
||||
let validated =
|
||||
WgAnnouncement::decode_and_validate(&announcement.encode().unwrap(), id, &local)
|
||||
.unwrap();
|
||||
assert_eq!(validated.public_key, peer);
|
||||
|
||||
let result = WgAnnouncement::decode_and_validate(&forged.encode().unwrap(), id, &local);
|
||||
assert!(
|
||||
matches!(result, Err(PluginError::Rejected(ref reason)) if reason.contains("does not match")),
|
||||
"claiming another member's overlay address must be rejected: {result:?}"
|
||||
// The validated form has one field, and it is an identity.
|
||||
assert_eq!(
|
||||
std::mem::size_of_val(&validated),
|
||||
std::mem::size_of::<WgPublicKey>()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -236,7 +237,7 @@ mod tests {
|
||||
let peer = WgSecretKey::generate().public();
|
||||
let payload = WgAnnouncement::new(id, &peer).encode().unwrap();
|
||||
assert!(
|
||||
payload.len() < crate::config::Limits::default().max_capability_data_len,
|
||||
payload.len() < tsunagi::config::Limits::default().max_capability_data_len,
|
||||
"announcement is {} bytes",
|
||||
payload.len()
|
||||
);
|
||||
@@ -18,16 +18,18 @@
|
||||
//! ourselves, and we do:
|
||||
//!
|
||||
//! * outbound, a packet is routed to the peer that **owns** its destination
|
||||
//! address, where ownership is the derivation in [`super::overlay`];
|
||||
//! address, where ownership is the signed claim the system level agreed;
|
||||
//! * inbound, a decrypted packet is dropped unless its **source** is exactly
|
||||
//! the address derived for the peer whose tunnel decrypted it.
|
||||
//! the address that peer holds.
|
||||
//!
|
||||
//! So a participant cannot receive traffic addressed to someone else, and
|
||||
//! cannot forge traffic that appears to come from someone else, no matter
|
||||
//! what it announced.
|
||||
//! cannot forge traffic that appears to come from someone else. Neither
|
||||
//! check consults anything the peer said here: an address is claimed at the
|
||||
//! system level and signed by its holder, and that is what is compared
|
||||
//! against.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
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;
|
||||
@@ -37,15 +39,12 @@ use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::dataplane::transport::{SharedLink, TransportError};
|
||||
use crate::identity::NetworkId;
|
||||
use tsunagi::dataplane::routing::{FlowId, flow::ip_flow};
|
||||
use tsunagi::dataplane::transport::{SharedLink, TransportError};
|
||||
use tsunagi::dataplane::{PacketSink, PluginError};
|
||||
use tsunagi::identity::NetworkId;
|
||||
|
||||
use super::keys::{WgPublicKey, WgSecretKey};
|
||||
use super::overlay::overlay_address;
|
||||
use super::packet::IpHeader;
|
||||
use super::tun::TunDevice;
|
||||
use crate::state::Ipv4Range;
|
||||
use crate::keys::{WgPublicKey, WgSecretKey};
|
||||
|
||||
/// How often WireGuard's own timers are driven.
|
||||
///
|
||||
@@ -56,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 {
|
||||
@@ -65,6 +124,8 @@ struct PeerCounters {
|
||||
rx_bytes: AtomicU64,
|
||||
dropped_wrong_source: AtomicU64,
|
||||
dropped_oversize: AtomicU64,
|
||||
/// Packets there was no session to encrypt with yet.
|
||||
dropped_no_session: AtomicU64,
|
||||
protocol_errors: AtomicU64,
|
||||
}
|
||||
|
||||
@@ -85,6 +146,11 @@ pub struct PeerStats {
|
||||
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.
|
||||
///
|
||||
/// A handful while a tunnel comes up is normal; a number that keeps
|
||||
/// climbing means the handshake is not completing.
|
||||
pub dropped_no_session: u64,
|
||||
/// WireGuard protocol errors, including packets that failed to decrypt.
|
||||
pub protocol_errors: u64,
|
||||
}
|
||||
@@ -109,11 +175,13 @@ impl PeerHealth {
|
||||
struct Peer {
|
||||
endpoint_id: EndpointId,
|
||||
public_key: WgPublicKey,
|
||||
overlay: Ipv6Addr,
|
||||
/// The IPv4 address this peer owns, when the overlay is dual stack and
|
||||
/// nobody else derived the same one.
|
||||
/// The overlay address this peer holds, as the control plane agreed it.
|
||||
///
|
||||
/// Not derived here and not taken from the peer: the system level
|
||||
/// 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<()>>>,
|
||||
@@ -124,7 +192,6 @@ impl std::fmt::Debug for Peer {
|
||||
f.debug_struct("Peer")
|
||||
.field("peer", &self.endpoint_id.fmt_short().to_string())
|
||||
.field("public_key", &self.public_key)
|
||||
.field("overlay", &self.overlay)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -148,6 +215,7 @@ impl Peer {
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -158,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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,15 +238,8 @@ pub struct PeerSummary {
|
||||
pub endpoint_id: EndpointId,
|
||||
/// The peer's WireGuard public key.
|
||||
pub public_key: WgPublicKey,
|
||||
/// The overlay address this agent derived for it.
|
||||
pub overlay_address: Ipv6Addr,
|
||||
/// Its IPv4 overlay address, when the overlay is dual stack.
|
||||
///
|
||||
/// `None` with `ipv4_conflict` set means another member derived the same
|
||||
/// address and won it; that peer is still fully reachable over IPv6.
|
||||
/// The overlay address it holds, once the control plane has agreed one.
|
||||
pub overlay_address_v4: Option<Ipv4Addr>,
|
||||
/// Whether this peer lost an IPv4 address to a derivation collision.
|
||||
pub ipv4_conflict: bool,
|
||||
/// Whether the tunnel has handshaken.
|
||||
pub health: PeerHealth,
|
||||
/// Traffic counters.
|
||||
@@ -192,30 +253,25 @@ pub struct PeerSummary {
|
||||
struct Inner {
|
||||
network: NetworkId,
|
||||
private_key: WgSecretKey,
|
||||
tun: Arc<dyn TunDevice>,
|
||||
/// The IPv4 overlay range, when the overlay is dual stack.
|
||||
ipv4_range: Option<Ipv4Range>,
|
||||
peers: RwLock<HashMap<WgPublicKey, Arc<Peer>>>,
|
||||
/// Both families, so one lookup routes any packet.
|
||||
routes: RwLock<HashMap<IpAddr, WgPublicKey>>,
|
||||
/// Where decrypted packets go.
|
||||
///
|
||||
/// The interface belongs to the system level, so this hands a packet up
|
||||
/// rather than writing it out: only that level knows which addresses the
|
||||
/// sending member is entitled to use.
|
||||
sink: Arc<dyn PacketSink>,
|
||||
next_index: AtomicU32,
|
||||
unroutable: AtomicU64,
|
||||
/// One destination nobody owned, kept so the counter can be acted on.
|
||||
unroutable_sample: Mutex<Option<IpAddr>>,
|
||||
multicast: AtomicU64,
|
||||
ipv4_conflicts: AtomicU64,
|
||||
}
|
||||
|
||||
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())
|
||||
.field("tun", &self.tun.name())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A userspace WireGuard interface for one network.
|
||||
/// The userspace WireGuard tunnels of one network.
|
||||
#[derive(Debug)]
|
||||
pub struct WireguardDevice {
|
||||
inner: Arc<Inner>,
|
||||
@@ -223,44 +279,75 @@ pub struct WireguardDevice {
|
||||
}
|
||||
|
||||
impl WireguardDevice {
|
||||
/// Starts a device on top of `tun`.
|
||||
pub fn start(
|
||||
network: NetworkId,
|
||||
private_key: WgSecretKey,
|
||||
tun: Arc<dyn TunDevice>,
|
||||
ipv4_range: Option<Ipv4Range>,
|
||||
) -> Self {
|
||||
/// Starts the tunnels for one network.
|
||||
///
|
||||
/// No interface is involved: packets arrive through [`Self::carry`] and
|
||||
/// leave through the sink. Which address belongs to whom, and therefore
|
||||
/// where a packet should go, is decided above this.
|
||||
pub fn start(network: NetworkId, private_key: WgSecretKey, sink: Arc<dyn PacketSink>) -> Self {
|
||||
let inner = Arc::new(Inner {
|
||||
network,
|
||||
private_key,
|
||||
tun,
|
||||
ipv4_range,
|
||||
peers: RwLock::new(HashMap::new()),
|
||||
routes: RwLock::new(HashMap::new()),
|
||||
sink,
|
||||
next_index: AtomicU32::new(1),
|
||||
unroutable: AtomicU64::new(0),
|
||||
unroutable_sample: Mutex::new(None),
|
||||
multicast: AtomicU64::new(0),
|
||||
ipv4_conflicts: AtomicU64::new(0),
|
||||
});
|
||||
|
||||
let reader = tokio::spawn(read_from_os(Arc::clone(&inner)));
|
||||
let timers = tokio::spawn(drive_timers(Arc::clone(&inner)));
|
||||
|
||||
Self {
|
||||
inner,
|
||||
tasks: vec![reader, timers],
|
||||
tasks: vec![timers],
|
||||
}
|
||||
}
|
||||
|
||||
/// The interface name in use.
|
||||
pub fn interface(&self) -> &str {
|
||||
self.inner.tun.name()
|
||||
}
|
||||
/// Encrypts a packet and sends it to a peer.
|
||||
///
|
||||
/// `false` when there is no tunnel for that peer, which is a state the
|
||||
/// caller reports rather than an error: a peer whose link has not come
|
||||
/// up yet is normal.
|
||||
pub fn carry(&self, peer: iroh::EndpointId, packet: &[u8]) -> bool {
|
||||
let found = read_lock(&self.inner.peers)
|
||||
.values()
|
||||
.find(|candidate| candidate.endpoint_id == peer)
|
||||
.map(Arc::clone);
|
||||
let Some(peer) = found else {
|
||||
return false;
|
||||
};
|
||||
|
||||
/// The interface MTU.
|
||||
pub fn mtu(&self) -> u32 {
|
||||
self.inner.tun.mtu()
|
||||
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 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)),
|
||||
// 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, 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.
|
||||
@@ -292,14 +379,14 @@ impl WireguardDevice {
|
||||
None,
|
||||
);
|
||||
|
||||
let overlay = overlay_address(self.inner.network, &public_key);
|
||||
let overlay_v4 = self.claim_ipv4(&public_key, overlay_v4);
|
||||
let peer = Arc::new(Peer {
|
||||
endpoint_id,
|
||||
public_key,
|
||||
overlay,
|
||||
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),
|
||||
@@ -311,10 +398,6 @@ impl WireguardDevice {
|
||||
}
|
||||
|
||||
write_lock(&self.inner.peers).insert(public_key, Arc::clone(&peer));
|
||||
write_lock(&self.inner.routes).insert(IpAddr::V6(overlay), public_key);
|
||||
if let Some(v4) = overlay_v4 {
|
||||
write_lock(&self.inner.routes).insert(IpAddr::V4(v4), public_key);
|
||||
}
|
||||
|
||||
// Start the handshake now instead of waiting for the next timer tick,
|
||||
// so the tunnel is usable as soon as the link exists.
|
||||
@@ -322,61 +405,11 @@ impl WireguardDevice {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decides which IPv4 address a new peer gets, if any.
|
||||
///
|
||||
/// IPv4 has far too little room for a derived address to be collision
|
||||
/// free. When two members derive the same one, the member whose public
|
||||
/// key sorts lower keeps it — a rule every member computes identically,
|
||||
/// so they all agree on the outcome without talking about it. The other
|
||||
/// member simply has no IPv4 address; it is still fully reachable over
|
||||
/// IPv6, which never collides.
|
||||
fn claim_ipv4(&self, public_key: &WgPublicKey, wanted: Option<Ipv4Addr>) -> Option<Ipv4Addr> {
|
||||
let wanted = wanted?;
|
||||
|
||||
let holder = read_lock(&self.inner.routes)
|
||||
.get(&IpAddr::V4(wanted))
|
||||
.copied();
|
||||
let Some(holder) = holder else {
|
||||
return Some(wanted);
|
||||
};
|
||||
if holder == *public_key {
|
||||
return Some(wanted);
|
||||
}
|
||||
|
||||
self.inner.ipv4_conflicts.fetch_add(1, Ordering::Relaxed);
|
||||
if holder.as_bytes() <= public_key.as_bytes() {
|
||||
// The peer already holding it wins.
|
||||
return None;
|
||||
}
|
||||
// The newcomer wins; take the address away from the other peer.
|
||||
if let Some(loser) = read_lock(&self.inner.peers).get(&holder).cloned() {
|
||||
let mut slot = match loser.overlay_v4.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
*slot = None;
|
||||
}
|
||||
Some(wanted)
|
||||
}
|
||||
|
||||
/// Removes a peer and stops its tunnel.
|
||||
pub fn remove_peer(&self, public_key: &WgPublicKey) {
|
||||
if let Some(peer) = write_lock(&self.inner.peers).remove(public_key) {
|
||||
let mut routes = write_lock(&self.inner.routes);
|
||||
routes.remove(&IpAddr::V6(peer.overlay));
|
||||
let v4 = match peer.overlay_v4.lock() {
|
||||
Ok(guard) => *guard,
|
||||
Err(poisoned) => *poisoned.into_inner(),
|
||||
};
|
||||
if let Some(v4) = v4 {
|
||||
routes.remove(&IpAddr::V4(v4));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many IPv4 derivation collisions have been resolved.
|
||||
pub fn ipv4_conflicts(&self) -> u64 {
|
||||
self.inner.ipv4_conflicts.load(Ordering::Relaxed)
|
||||
// Dropping it stops the task and closes the link; there is no route
|
||||
// to withdraw, because routes are not kept here.
|
||||
write_lock(&self.inner.peers).remove(public_key);
|
||||
}
|
||||
|
||||
/// Removes every peer whose key is not in `keep`.
|
||||
@@ -408,9 +441,7 @@ impl WireguardDevice {
|
||||
PeerSummary {
|
||||
endpoint_id: peer.endpoint_id,
|
||||
public_key: peer.public_key,
|
||||
overlay_address: peer.overlay,
|
||||
overlay_address_v4: overlay_v4,
|
||||
ipv4_conflict: overlay_v4.is_none() && self.inner.ipv4_range.is_some(),
|
||||
health: peer.health(),
|
||||
stats: peer.stats(),
|
||||
path: peer.link.path_description(),
|
||||
@@ -421,34 +452,6 @@ impl WireguardDevice {
|
||||
peers.sort_by_key(|peer| peer.public_key);
|
||||
peers
|
||||
}
|
||||
|
||||
/// Unicast packets the operating system sent to an address no peer owns.
|
||||
///
|
||||
/// A non-zero value means something tried to reach a host that is not in
|
||||
/// the overlay.
|
||||
pub fn unroutable_packets(&self) -> u64 {
|
||||
self.inner.unroutable.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// One destination that nobody owned, if there was one.
|
||||
///
|
||||
/// A bare count says something is wrong but not what; the address usually
|
||||
/// says it outright.
|
||||
pub fn unroutable_sample(&self) -> Option<IpAddr> {
|
||||
match self.inner.unroutable_sample.lock() {
|
||||
Ok(guard) => *guard,
|
||||
Err(poisoned) => *poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Multicast packets dropped.
|
||||
///
|
||||
/// Expected and harmless: Linux emits multicast listener and router
|
||||
/// solicitation traffic on any IPv6 interface, and this overlay is
|
||||
/// unicast only. Counted separately so it does not look like a fault.
|
||||
pub fn multicast_packets(&self) -> u64 {
|
||||
self.inner.multicast.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WireguardDevice {
|
||||
@@ -475,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 = {
|
||||
@@ -485,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,
|
||||
}
|
||||
@@ -497,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
|
||||
@@ -511,84 +516,13 @@ fn send_to_peer(peer: &Peer, payload: &[u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Operating system -> peer.
|
||||
async fn read_from_os(inner: Arc<Inner>) {
|
||||
loop {
|
||||
let Some(packet) = inner.tun.recv().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Route by destination: only the peer that owns that overlay address
|
||||
// may receive it. Both families go through the same table.
|
||||
let Some(destination) = IpHeader::parse(&packet).map(|header| header.destination()) else {
|
||||
inner.unroutable.fetch_add(1, Ordering::Relaxed);
|
||||
continue;
|
||||
};
|
||||
// The kernel emits multicast on every IPv6 interface. The overlay is
|
||||
// unicast only, so this is dropped, but it is not a fault.
|
||||
if destination.is_multicast() {
|
||||
inner.multicast.fetch_add(1, Ordering::Relaxed);
|
||||
continue;
|
||||
}
|
||||
let target = read_lock(&inner.routes).get(&destination).copied();
|
||||
let Some(target) = target else {
|
||||
note_unroutable(&inner, destination);
|
||||
continue;
|
||||
};
|
||||
let peer = read_lock(&inner.peers).get(&target).cloned();
|
||||
let Some(peer) = peer else {
|
||||
note_unroutable(&inner, destination);
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut scratch = vec![0u8; SCRATCH];
|
||||
let outcome = {
|
||||
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()),
|
||||
TunnResult::Done => None,
|
||||
TunnResult::Err(err) => {
|
||||
tracing::trace!(?err, "wireguard encapsulation failed");
|
||||
peer.counters
|
||||
.protocol_errors
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(len) = outcome {
|
||||
send_to_peer(&peer, &scratch[..len]);
|
||||
peer.counters.tx_packets.fetch_add(1, Ordering::Relaxed);
|
||||
peer.counters
|
||||
.tx_bytes
|
||||
.fetch_add(packet.len() as u64, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts a packet nobody owned the destination of, keeping one example.
|
||||
fn note_unroutable(inner: &Inner, destination: IpAddr) {
|
||||
inner.unroutable.fetch_add(1, Ordering::Relaxed);
|
||||
let mut sample = match inner.unroutable_sample.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
*sample = Some(destination);
|
||||
}
|
||||
|
||||
/// Peer -> operating system.
|
||||
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);
|
||||
@@ -598,14 +532,14 @@ 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()),
|
||||
TunnResult::WriteToTunnelV6(out, source) => {
|
||||
Outcome::ToTunnel(out.len(), IpAddr::V6(source))
|
||||
}
|
||||
TunnResult::WriteToTunnelV4(out, source) => {
|
||||
Outcome::ToTunnel(out.len(), IpAddr::V4(source))
|
||||
}
|
||||
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.
|
||||
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");
|
||||
@@ -615,38 +549,26 @@ 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;
|
||||
}
|
||||
Outcome::ToTunnel(len, source) => {
|
||||
Outcome::ToTunnel(len) => {
|
||||
// Handed up, not written out. This end has proved *who*
|
||||
// sent the packet; whether that member may use the source
|
||||
// address it chose is a question about a signed claim,
|
||||
// and only the system level holds those.
|
||||
let payload = Bytes::copy_from_slice(&scratch[..len]);
|
||||
// Enforce address ownership: a peer may only send from an
|
||||
// address derived for its own key, in either family.
|
||||
let owned = match source {
|
||||
IpAddr::V6(addr) => addr == peer.overlay,
|
||||
IpAddr::V4(addr) => {
|
||||
let held = match peer.overlay_v4.lock() {
|
||||
Ok(guard) => *guard,
|
||||
Err(poisoned) => *poisoned.into_inner(),
|
||||
};
|
||||
held == Some(addr)
|
||||
}
|
||||
};
|
||||
if !owned {
|
||||
peer.counters
|
||||
.dropped_wrong_source
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
if inner.tun.send(payload).await.is_ok() {
|
||||
peer.counters.rx_packets.fetch_add(1, Ordering::Relaxed);
|
||||
peer.counters
|
||||
.rx_bytes
|
||||
.fetch_add(len as u64, Ordering::Relaxed);
|
||||
}
|
||||
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 => {
|
||||
@@ -662,8 +584,8 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
ToNetwork(usize),
|
||||
ToTunnel(usize, IpAddr),
|
||||
ToNetwork(usize, FlowId),
|
||||
ToTunnel(usize),
|
||||
Done,
|
||||
Failed,
|
||||
}
|
||||
@@ -697,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);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use boringtun::x25519;
|
||||
use data_encoding::BASE64;
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use crate::dataplane::PluginError;
|
||||
use tsunagi::dataplane::PluginError;
|
||||
|
||||
/// Length of a raw WireGuard key, in bytes.
|
||||
pub const KEY_LEN: usize = 32;
|
||||
@@ -0,0 +1,50 @@
|
||||
//! The `wg-quic` protocol: WireGuard's cryptography in iroh's QUIC datagrams.
|
||||
//!
|
||||
//! A crate of its own, so the line between a protocol and the system level
|
||||
//! is drawn by the compiler rather than by discipline: nothing here can
|
||||
//! reach into `tsunagi` beyond what it makes public. It also carries its own
|
||||
//! version, which is **not** the version peers compare — that is
|
||||
//! [`ANNOUNCEMENT_VERSION`], and it moves only when the bytes on the wire
|
||||
//! do, so two peers on different releases keep working.
|
||||
//!
|
||||
//! That it uses iroh is a convenience, not a requirement of the design: the
|
||||
//! system level already has an iroh endpoint that crosses NAT, and plain
|
||||
//! WireGuard is blocked on some networks where this is not.
|
||||
//!
|
||||
//! Where the line falls:
|
||||
//!
|
||||
//! * **It knows nothing about reachability.** It is handed a
|
||||
//! [`PacketLink`](tsunagi::dataplane::transport::PacketLink) per peer and
|
||||
//! moves datagrams over it. Hole punching and relaying are the transport's.
|
||||
//! * **It knows nothing about addresses, and owns no interface.** One agent
|
||||
//! has one interface at the system level, and every protocol carries
|
||||
//! traffic for the same addresses on it. A packet arrives here already
|
||||
//! routed and leaves here already decrypted, to be checked against the
|
||||
//! signed claim by the level that holds those.
|
||||
//! * **Its announcement says who, not where.** A public key and the network
|
||||
//! it is for, so there is nothing about reachability to lie about.
|
||||
//! * **The core never parses that announcement.** It moves a bounded opaque
|
||||
//! blob; only [`announcement`] reads it.
|
||||
//! * **Its keys are its own.** One per network, in its own store, unrelated
|
||||
//! to the iroh device key and to the network secret — which it never sees.
|
||||
//! * **WireGuard's own crypto is untouched.** The handshake and encryption
|
||||
//! run end to end between the two ends of a tunnel, via [`boringtun`]'s
|
||||
//! state machine in this process: no kernel module, no `wg` tool, the same
|
||||
//! code on every platform.
|
||||
//!
|
||||
//! See `docs/wireguard.md` for the full picture.
|
||||
|
||||
pub mod announcement;
|
||||
pub mod device;
|
||||
pub mod keys;
|
||||
pub mod plugin;
|
||||
pub mod store;
|
||||
|
||||
pub use announcement::{ANNOUNCEMENT_VERSION, ValidatedAnnouncement, WgAnnouncement};
|
||||
pub use device::{PeerHealth, PeerStats, PeerSummary, WireguardDevice};
|
||||
pub use keys::{WgPublicKey, WgSecretKey};
|
||||
pub use plugin::{
|
||||
DEFAULT_MTU, MIN_MTU, NetworkOverview, PeerOverview, WIREGUARD_OVERHEAD, WIREGUARD_PROTOCOL,
|
||||
WireguardConfig, WireguardPlugin,
|
||||
};
|
||||
pub use store::WgKeyStore;
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The WireGuard IP plugin.
|
||||
//! The WireGuard protocol plugin.
|
||||
//!
|
||||
//! Each agent builds its own view of the overlay from the set of participants
|
||||
//! the control plane agreed on. For a full mesh of `N` members that is `N - 1`
|
||||
@@ -8,24 +8,24 @@
|
||||
//! # What this plugin does and does not know
|
||||
//!
|
||||
//! * It does **not** know where a peer is. It is handed a
|
||||
//! [`PacketLink`](crate::dataplane::transport::PacketLink) per peer and runs
|
||||
//! [`PacketLink`](tsunagi::dataplane::transport::PacketLink) per peer and runs
|
||||
//! a WireGuard tunnel over it. Reachability, hole punching and relaying are
|
||||
//! the transport's problem.
|
||||
//! * It does **not** know which addresses anybody holds, and owns no
|
||||
//! interface. One agent has one interface, at the system level, and every
|
||||
//! protocol carries traffic for the same addresses on it. A packet arrives
|
||||
//! here already routed and leaves here already decrypted.
|
||||
//! * It owns one WireGuard key per network, in its own store, unrelated to the
|
||||
//! iroh device key and to the network secret.
|
||||
//! * It owns one packet interface per network, named deterministically.
|
||||
//! * It never touches an interface it did not create, and never changes
|
||||
//! routing, DNS or firewall settings beyond its own device.
|
||||
//!
|
||||
//! WireGuard runs in userspace via [`boringtun`], so there is no kernel module
|
||||
//! and no `wg` tool to depend on. The only privileged step is creating the
|
||||
//! packet interface, and even that is behind [`TunFactory`] so the whole data
|
||||
//! plane can run unprivileged in tests.
|
||||
//! and no `wg` tool to depend on, and nothing this plugin does needs
|
||||
//! privileges: creating the interface is somebody else's job now.
|
||||
//!
|
||||
//! A failure here is reported and retried. It never stops the control plane.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
@@ -34,43 +34,46 @@ use iroh::EndpointId;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::transport::SharedLink;
|
||||
use crate::dataplane::{IpPlugin, PluginCapability, PluginContext, PluginError};
|
||||
use crate::identity::NetworkId;
|
||||
use tsunagi::BoxFuture;
|
||||
use tsunagi::dataplane::PacketSink;
|
||||
use tsunagi::dataplane::transport::SharedLink;
|
||||
use tsunagi::dataplane::{IpPlugin, PluginCapability, PluginContext, PluginError};
|
||||
use tsunagi::identity::NetworkId;
|
||||
|
||||
use super::announcement::{ValidatedAnnouncement, WgAnnouncement};
|
||||
use super::config::{DEFAULT_INTERFACE_PREFIX, interface_name};
|
||||
use super::device::{PeerSummary, WireguardDevice};
|
||||
use super::keys::{WgPublicKey, WgSecretKey};
|
||||
use super::overlay::{OVERLAY_PREFIX_LEN, overlay_address, overlay_prefix};
|
||||
use super::store::WgKeyStore;
|
||||
use super::tun::{TunFactory, TunRequest};
|
||||
use crate::state::Ipv4Range;
|
||||
use crate::announcement::{ValidatedAnnouncement, WgAnnouncement};
|
||||
use crate::device::{PeerSummary, WireguardDevice};
|
||||
use crate::keys::{WgPublicKey, WgSecretKey};
|
||||
use crate::store::WgKeyStore;
|
||||
use tsunagi::state::Ipv4Range;
|
||||
use tsunagi::task::{TASK_GRACE, wind_down};
|
||||
|
||||
/// The protocol identifier this plugin announces.
|
||||
pub const WIREGUARD_PROTOCOL: &str = "wireguard";
|
||||
|
||||
/// Smallest interface MTU IPv6 permits, from RFC 8200.
|
||||
/// The protocol id of this plugin.
|
||||
///
|
||||
/// This is not advice, it is a hard limit. Linux tears IPv6 down entirely on
|
||||
/// an interface whose MTU is below it — the per-device `/proc/sys/net/ipv6`
|
||||
/// entries disappear and `ip -6 address add` fails with `Invalid argument` —
|
||||
/// so the overlay address could never be assigned. Anything smaller is
|
||||
/// rejected up front instead of failing obscurely later.
|
||||
pub const MIN_MTU: u32 = 1280;
|
||||
/// `wg-quic`, because that is what it is: WireGuard's cryptography carried
|
||||
/// in QUIC datagrams. The name is on the wire, so it is a protocol name and
|
||||
/// not a description of the implementation.
|
||||
pub const WIREGUARD_PROTOCOL: &str = "wg-quic";
|
||||
|
||||
/// Smallest interface MTU the overlay accepts.
|
||||
///
|
||||
/// 576 bytes is what IPv4 guarantees every host can reassemble (RFC 1122),
|
||||
/// so nothing below it is worth offering. The current overlay is IPv4.
|
||||
/// Lowering this is not needed to accommodate a narrow QUIC path: the default
|
||||
/// transport fragments opaque payloads below the plugin.
|
||||
pub const MIN_MTU: u32 = 576;
|
||||
|
||||
/// Default interface MTU.
|
||||
///
|
||||
/// Equal to [`MIN_MTU`], because the overlay is IPv6 and there is no room
|
||||
/// below it.
|
||||
pub const DEFAULT_MTU: u32 = MIN_MTU;
|
||||
/// Kept stable across path changes. The default transport splits encrypted
|
||||
/// packets when the current QUIC path cannot carry them in one datagram.
|
||||
pub const DEFAULT_MTU: u32 = 1280;
|
||||
|
||||
/// Bytes WireGuard adds to a packet: type and reserved, receiver index,
|
||||
/// counter and the Poly1305 tag.
|
||||
///
|
||||
/// A link therefore has to carry `mtu + WIREGUARD_OVERHEAD` bytes in one
|
||||
/// datagram for a full-size packet to get through.
|
||||
/// A link must carry `mtu + WIREGUARD_OVERHEAD` logical payload bytes.
|
||||
/// Transport fragmentation is independent of WireGuard and the inner IP flags.
|
||||
pub const WIREGUARD_OVERHEAD: u32 = 32;
|
||||
|
||||
/// Configuration of the WireGuard plugin.
|
||||
@@ -78,14 +81,13 @@ pub const WIREGUARD_OVERHEAD: u32 = 32;
|
||||
pub struct WireguardConfig {
|
||||
/// Directory for the plugin's own key store. Separate from agent state.
|
||||
pub state_dir: PathBuf,
|
||||
/// Prefix of the interface names this plugin creates.
|
||||
///
|
||||
/// Two agents on one host in the same network need different prefixes,
|
||||
/// because the rest of the name is derived from the network id.
|
||||
pub interface_prefix: String,
|
||||
/// WireGuard keepalive, which keeps tunnels and their links warm.
|
||||
pub keepalive: Option<u16>,
|
||||
/// Interface MTU. See [`DEFAULT_MTU`].
|
||||
/// The largest packet a tunnel will carry.
|
||||
///
|
||||
/// Not the interface MTU, which belongs to the agent: this is what this
|
||||
/// protocol is configured to carry. The transport may split ciphertext
|
||||
/// into smaller datagrams without changing the original IP packet.
|
||||
pub mtu: u32,
|
||||
/// How long to coalesce changes before reconciling.
|
||||
pub reconcile_debounce: Duration,
|
||||
@@ -99,7 +101,6 @@ impl WireguardConfig {
|
||||
pub fn new(state_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
state_dir: state_dir.into(),
|
||||
interface_prefix: DEFAULT_INTERFACE_PREFIX.to_string(),
|
||||
keepalive: Some(25),
|
||||
mtu: DEFAULT_MTU,
|
||||
reconcile_debounce: Duration::from_millis(200),
|
||||
@@ -107,12 +108,6 @@ impl WireguardConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the interface name prefix.
|
||||
pub fn with_interface_prefix(mut self, prefix: impl Into<String>) -> Self {
|
||||
self.interface_prefix = prefix.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the interface MTU.
|
||||
///
|
||||
/// Validated when the plugin is opened; see [`MIN_MTU`].
|
||||
@@ -139,30 +134,16 @@ impl WireguardConfig {
|
||||
pub struct NetworkOverview {
|
||||
/// The network.
|
||||
pub network: NetworkId,
|
||||
/// Packet interface this plugin created for it.
|
||||
pub interface: String,
|
||||
/// Interface MTU.
|
||||
/// The largest packet a tunnel in this network will carry.
|
||||
pub mtu: u32,
|
||||
/// This agent's WireGuard public key in this network.
|
||||
pub public_key: WgPublicKey,
|
||||
/// This agent's overlay address.
|
||||
pub overlay_address: IpAddr,
|
||||
/// The overlay subnet every member shares.
|
||||
pub overlay_prefix: IpAddr,
|
||||
/// Prefix length of the overlay subnet.
|
||||
pub overlay_prefix_len: u8,
|
||||
/// This agent's IPv4 overlay address, when the overlay is dual stack.
|
||||
/// This agent's overlay address, once the network has agreed one.
|
||||
pub overlay_address_v4: Option<Ipv4Addr>,
|
||||
/// The IPv4 overlay range in use.
|
||||
pub ipv4_range: Option<Ipv4Range>,
|
||||
/// Peers this agent knows about.
|
||||
pub peers: Vec<PeerOverview>,
|
||||
/// Unicast packets the operating system sent to an address no peer owns.
|
||||
pub unroutable_packets: u64,
|
||||
/// Multicast packets dropped. Expected, not a fault.
|
||||
pub multicast_packets: u64,
|
||||
/// One destination nobody owned, if there was one.
|
||||
pub unroutable_sample: Option<IpAddr>,
|
||||
}
|
||||
|
||||
impl NetworkOverview {
|
||||
@@ -179,9 +160,7 @@ pub struct PeerOverview {
|
||||
pub endpoint_id: EndpointId,
|
||||
/// The peer's WireGuard public key.
|
||||
pub public_key: WgPublicKey,
|
||||
/// The overlay address derived for it locally.
|
||||
pub overlay_address: IpAddr,
|
||||
/// Its IPv4 overlay address, once a tunnel exists and it won the address.
|
||||
/// The overlay address the network agreed it holds.
|
||||
pub overlay_address_v4: Option<Ipv4Addr>,
|
||||
/// Whether a data plane link to it exists.
|
||||
pub has_link: bool,
|
||||
@@ -201,7 +180,6 @@ impl PeerOverview {
|
||||
#[derive(Debug)]
|
||||
struct NetworkState {
|
||||
key: WgSecretKey,
|
||||
interface: String,
|
||||
device: Option<Arc<WireguardDevice>>,
|
||||
announcements: HashMap<EndpointId, ValidatedAnnouncement>,
|
||||
links: HashMap<EndpointId, SharedLink>,
|
||||
@@ -209,14 +187,6 @@ struct NetworkState {
|
||||
allocations: HashMap<EndpointId, Ipv4Addr>,
|
||||
/// The range those allocations came from.
|
||||
ipv4_range: Option<Ipv4Range>,
|
||||
/// The address last reported as missing, so it is said once, not forever.
|
||||
reported_missing_v4: Option<Ipv4Addr>,
|
||||
/// What was last applied to the host interface.
|
||||
///
|
||||
/// The overlay IPv4 address is allocated at run time and can change while
|
||||
/// the agent runs, so the interface has to be brought back in line
|
||||
/// without being recreated — recreating it would drop every tunnel.
|
||||
applied: Option<TunRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -241,16 +211,16 @@ struct Worker {
|
||||
config: WireguardConfig,
|
||||
/// This agent's endpoint id, learned when the plugin is attached.
|
||||
local_id: OnceLock<EndpointId>,
|
||||
tun_factory: Arc<dyn TunFactory>,
|
||||
store: WgKeyStore,
|
||||
shared: Mutex<Shared>,
|
||||
context: OnceLock<PluginContext>,
|
||||
/// Where decrypted packets go, once the agent has attached one.
|
||||
sink: OnceLock<Arc<dyn PacketSink>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Worker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Worker")
|
||||
.field("tun", &self.tun_factory.name())
|
||||
.field("store", &self.store.path())
|
||||
.finish()
|
||||
}
|
||||
@@ -265,22 +235,65 @@ pub struct WireguardPlugin {
|
||||
}
|
||||
|
||||
impl WireguardPlugin {
|
||||
/// The settings this protocol accepts.
|
||||
pub const OPTIONS: &'static [tsunagi::dataplane::ProtocolOption] = &[
|
||||
tsunagi::dataplane::ProtocolOption {
|
||||
key: "keepalive",
|
||||
value: "SECONDS",
|
||||
help: "keeps a tunnel and its link warm through a NAT; 0 turns it off",
|
||||
default: Some("25"),
|
||||
},
|
||||
tsunagi::dataplane::ProtocolOption {
|
||||
key: "mtu",
|
||||
value: "BYTES",
|
||||
help: "largest packet a tunnel will carry, at least 576",
|
||||
default: Some("1280"),
|
||||
},
|
||||
];
|
||||
|
||||
/// Applies `key=value` settings to a configuration.
|
||||
///
|
||||
/// An unknown key is refused rather than ignored: a setting that was
|
||||
/// silently dropped looks exactly like one that did not work.
|
||||
pub fn configure(
|
||||
mut config: WireguardConfig,
|
||||
options: &[(String, String)],
|
||||
) -> Result<WireguardConfig, PluginError> {
|
||||
for (key, value) in options {
|
||||
match key.as_str() {
|
||||
"keepalive" => {
|
||||
let seconds: u16 = value.parse().map_err(|_| {
|
||||
PluginError::Other(format!("keepalive={value} is not a number of seconds"))
|
||||
})?;
|
||||
config.keepalive = (seconds > 0).then_some(seconds);
|
||||
}
|
||||
"mtu" => {
|
||||
let mtu: u32 = value.parse().map_err(|_| {
|
||||
PluginError::Other(format!("mtu={value} is not a number of bytes"))
|
||||
})?;
|
||||
config = config.with_mtu(mtu);
|
||||
}
|
||||
other => {
|
||||
let known: Vec<&str> = Self::OPTIONS.iter().map(|spec| spec.key).collect();
|
||||
return Err(PluginError::Other(format!(
|
||||
"`{other}` is not a setting of {WIREGUARD_PROTOCOL}; it takes {}",
|
||||
known.join(", ")
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Opens the plugin's key store and starts its reconciliation task.
|
||||
///
|
||||
/// Must be called from inside a tokio runtime; the plugin starts no
|
||||
/// runtime of its own.
|
||||
pub async fn open(
|
||||
config: WireguardConfig,
|
||||
tun_factory: Arc<dyn TunFactory>,
|
||||
) -> Result<Arc<Self>, PluginError> {
|
||||
// Validate the prefix once, here, rather than failing per network.
|
||||
interface_name(&config.interface_prefix, NetworkId::from_bytes([0u8; 32]))?;
|
||||
|
||||
pub async fn open(config: WireguardConfig) -> Result<Arc<Self>, PluginError> {
|
||||
if config.mtu < MIN_MTU {
|
||||
return Err(PluginError::Other(format!(
|
||||
"an MTU of {} is below the {MIN_MTU} bytes IPv6 requires (RFC 8200). \
|
||||
Linux disables IPv6 on an interface below that, so the overlay address \
|
||||
could never be assigned.",
|
||||
"an MTU of {} is below the {MIN_MTU} bytes every IPv4 host must be able \
|
||||
to reassemble (RFC 1122)",
|
||||
config.mtu
|
||||
)));
|
||||
}
|
||||
@@ -293,10 +306,10 @@ impl WireguardPlugin {
|
||||
let worker = Arc::new(Worker {
|
||||
config,
|
||||
local_id: OnceLock::new(),
|
||||
tun_factory,
|
||||
store,
|
||||
shared: Mutex::new(Shared::default()),
|
||||
context: OnceLock::new(),
|
||||
sink: OnceLock::new(),
|
||||
});
|
||||
|
||||
let (commands, receiver) = mpsc::channel(64);
|
||||
@@ -332,7 +345,6 @@ impl WireguardPlugin {
|
||||
.map(|(endpoint_id, announcement)| PeerOverview {
|
||||
endpoint_id: *endpoint_id,
|
||||
public_key: announcement.public_key,
|
||||
overlay_address: IpAddr::V6(announcement.overlay_address),
|
||||
overlay_address_v4: state.allocations.get(endpoint_id).copied(),
|
||||
has_link: state.links.contains_key(endpoint_id),
|
||||
tunnel: tunnels.get(&announcement.public_key).cloned(),
|
||||
@@ -342,29 +354,11 @@ impl WireguardPlugin {
|
||||
|
||||
Some(NetworkOverview {
|
||||
network,
|
||||
interface: state.interface.clone(),
|
||||
mtu: self.worker.config.mtu,
|
||||
public_key: state.key.public(),
|
||||
overlay_address: IpAddr::V6(overlay_address(network, &state.key.public())),
|
||||
overlay_prefix: IpAddr::V6(overlay_prefix(network)),
|
||||
overlay_prefix_len: OVERLAY_PREFIX_LEN,
|
||||
overlay_address_v4: state.allocations.get(&self.worker.local_id()).copied(),
|
||||
ipv4_range: state.ipv4_range,
|
||||
peers,
|
||||
unroutable_packets: state
|
||||
.device
|
||||
.as_ref()
|
||||
.map(|device| device.unroutable_packets())
|
||||
.unwrap_or(0),
|
||||
multicast_packets: state
|
||||
.device
|
||||
.as_ref()
|
||||
.map(|device| device.multicast_packets())
|
||||
.unwrap_or(0),
|
||||
unroutable_sample: state
|
||||
.device
|
||||
.as_ref()
|
||||
.and_then(|device| device.unroutable_sample()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -433,7 +427,6 @@ impl Worker {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let name = interface_name(&self.config.interface_prefix, network)?;
|
||||
let worker = Arc::clone(self);
|
||||
let key = tokio::task::spawn_blocking(move || worker.store.load_or_create(network))
|
||||
.await
|
||||
@@ -443,14 +436,11 @@ impl Worker {
|
||||
let mut shared = self.lock_shared();
|
||||
shared.networks.entry(network).or_insert(NetworkState {
|
||||
key,
|
||||
interface: name,
|
||||
device: None,
|
||||
announcements: HashMap::new(),
|
||||
links: HashMap::new(),
|
||||
allocations: HashMap::new(),
|
||||
ipv4_range: None,
|
||||
reported_missing_v4: None,
|
||||
applied: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -463,79 +453,35 @@ impl Worker {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// What the host interface for a network should look like.
|
||||
fn desired_request(&self, state: &NetworkState, network: NetworkId) -> TunRequest {
|
||||
let own_range = state.ipv4_range;
|
||||
TunRequest {
|
||||
name: state.interface.clone(),
|
||||
address: overlay_address(network, &state.key.public()),
|
||||
prefix_len: OVERLAY_PREFIX_LEN,
|
||||
address_v4: state.allocations.get(&self.local_id()).copied(),
|
||||
prefix_len_v4: own_range.map_or(0, |range| range.prefix_len),
|
||||
mtu: self.config.mtu,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the packet interface and starts the WireGuard device.
|
||||
/// Starts this network's tunnels.
|
||||
///
|
||||
/// No interface is created: one agent has one, it belongs to the system
|
||||
/// level, and decrypted packets are handed there rather than written
|
||||
/// out.
|
||||
async fn ensure_device(&self, network: NetworkId) -> Result<(), PluginError> {
|
||||
let (request, key, own_range) = {
|
||||
let key = {
|
||||
let shared = self.lock_shared();
|
||||
match shared.networks.get(&network) {
|
||||
Some(state) if state.device.is_none() => (
|
||||
self.desired_request(state, network),
|
||||
state.key.clone(),
|
||||
state.ipv4_range,
|
||||
),
|
||||
Some(state) if state.device.is_none() => state.key.clone(),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
};
|
||||
|
||||
let applied = request.clone();
|
||||
let tun = self.tun_factory.create(request).await?;
|
||||
let device = Arc::new(WireguardDevice::start(network, key, tun, own_range));
|
||||
let sink = match self.sink.get() {
|
||||
Some(sink) => Arc::clone(sink),
|
||||
// Not attached to an agent: the protocol still runs, and its
|
||||
// packets have nowhere to go.
|
||||
None => Arc::new(tsunagi::dataplane::DiscardPackets) as Arc<dyn PacketSink>,
|
||||
};
|
||||
let device = Arc::new(WireguardDevice::start(network, key, sink));
|
||||
|
||||
let mut shared = self.lock_shared();
|
||||
if let Some(state) = shared.networks.get_mut(&network) {
|
||||
state.interface = device.interface().to_string();
|
||||
state.device = Some(device);
|
||||
state.applied = Some(applied);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Brings a live interface back in line after the overlay changed its
|
||||
/// mind about this agent's address.
|
||||
async fn ensure_addresses(&self, network: NetworkId) {
|
||||
let wanted = {
|
||||
let shared = self.lock_shared();
|
||||
match shared.networks.get(&network) {
|
||||
Some(state) if state.device.is_some() => {
|
||||
let wanted = self.desired_request(state, network);
|
||||
if state.applied.as_ref() == Some(&wanted) {
|
||||
return;
|
||||
}
|
||||
wanted
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
};
|
||||
|
||||
match self.tun_factory.reconfigure(wanted.clone()).await {
|
||||
Ok(()) => {
|
||||
let mut shared = self.lock_shared();
|
||||
if let Some(state) = shared.networks.get_mut(&network) {
|
||||
state.applied = Some(wanted);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Not fatal: the tunnels keep running on the addresses that
|
||||
// are there, and the next reconciliation tries again.
|
||||
tracing::warn!(%err, "cannot update the overlay interface addresses");
|
||||
self.report(network, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Brings the running tunnels in line with what is known.
|
||||
///
|
||||
/// A peer gets a tunnel once both halves have arrived: its announcement,
|
||||
@@ -550,10 +496,6 @@ impl Worker {
|
||||
};
|
||||
|
||||
let allocations = state.allocations.clone();
|
||||
let own_v4 = state.allocations.get(&self.local_id()).copied();
|
||||
let interface = state.device.as_ref().map(|_| state.interface.clone());
|
||||
let range = state.ipv4_range;
|
||||
let state_reported = state.reported_missing_v4;
|
||||
let mut wanted: Vec<WgPublicKey> = Vec::new();
|
||||
let mut too_small: Vec<(usize, usize)> = Vec::new();
|
||||
for (endpoint_id, announcement) in &state.announcements {
|
||||
@@ -594,53 +536,15 @@ impl Worker {
|
||||
}
|
||||
device.retain_peers(&wanted);
|
||||
|
||||
// The agent assigns this address itself, so finding it absent means
|
||||
// the assignment did not take — something outside removed it, or the
|
||||
// provisioner reported a success it did not achieve. Left unsaid it
|
||||
// looks like a broken network: the kernel would send packets with the
|
||||
// wrong source address and every peer would drop them. So it is
|
||||
// checked rather than assumed, because the assumption is exactly the
|
||||
// kind that has been wrong here before.
|
||||
let missing_v4 = match (own_v4, interface.as_deref(), range) {
|
||||
(Some(address), Some(interface), Some(range))
|
||||
if !super::tun::address_is_local(IpAddr::V4(address)) =>
|
||||
{
|
||||
let already = state_reported == Some(address);
|
||||
if let Some(state) = shared.networks.get_mut(&network) {
|
||||
state.reported_missing_v4 = Some(address);
|
||||
}
|
||||
(!already).then_some((address, interface.to_string(), range))
|
||||
}
|
||||
_ => {
|
||||
if let Some(state) = shared.networks.get_mut(&network) {
|
||||
state.reported_missing_v4 = None;
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
drop(shared);
|
||||
|
||||
if let Some((address, interface, range)) = missing_v4 {
|
||||
self.report(
|
||||
network,
|
||||
format!(
|
||||
"this agent was allocated {address}/{} but the address is not on any \
|
||||
interface, so IPv4 cannot work: packets would leave with the wrong \
|
||||
source and every peer would drop them. It should have been assigned \
|
||||
to `{interface}` automatically; check whether something else removed \
|
||||
it.",
|
||||
range.prefix_len
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (available, needed) in too_small {
|
||||
self.report(
|
||||
network,
|
||||
format!(
|
||||
"this path carries only {available} byte datagrams but a {} byte MTU needs \
|
||||
{needed}; packets larger than {} bytes will be dropped. Lower the MTU only \
|
||||
if you can stay at or above {MIN_MTU}, which IPv6 requires.",
|
||||
{needed}; packets larger than {} bytes will be dropped. The floor is \
|
||||
{MIN_MTU} bytes, what every IPv4 host must be able to reassemble.",
|
||||
self.config.mtu,
|
||||
available.saturating_sub(WIREGUARD_OVERHEAD as usize)
|
||||
),
|
||||
@@ -655,11 +559,10 @@ impl Worker {
|
||||
// kernel to remove an interface this agent created; the explicit
|
||||
// destroy makes that immediate and definite rather than dependent on
|
||||
// the last reader letting go.
|
||||
let removed = self.lock_shared().networks.remove(&network);
|
||||
if let Some(state) = removed {
|
||||
drop(state.device);
|
||||
self.tun_factory.destroy(&state.interface).await;
|
||||
}
|
||||
// Dropping the state drops the tunnels, which stops their tasks and
|
||||
// closes their links. There is no interface to remove: the agent owns
|
||||
// it, and it outlives any one network.
|
||||
self.lock_shared().networks.remove(&network);
|
||||
}
|
||||
|
||||
fn known_networks(&self) -> Vec<NetworkId> {
|
||||
@@ -725,7 +628,6 @@ async fn run(worker: Arc<Worker>, mut commands: mpsc::Receiver<Command>) {
|
||||
}, if wait_until.is_some() => {
|
||||
deadline = None;
|
||||
for network in std::mem::take(&mut pending) {
|
||||
worker.ensure_addresses(network).await;
|
||||
worker.sync(network);
|
||||
}
|
||||
}
|
||||
@@ -736,7 +638,6 @@ async fn run(worker: Arc<Worker>, mut commands: mpsc::Receiver<Command>) {
|
||||
if let Err(err) = worker.ensure_device(network).await {
|
||||
tracing::debug!(%err, "packet interface still unavailable");
|
||||
}
|
||||
worker.ensure_addresses(network).await;
|
||||
worker.sync(network);
|
||||
}
|
||||
}
|
||||
@@ -749,10 +650,19 @@ impl IpPlugin for WireguardPlugin {
|
||||
WIREGUARD_PROTOCOL
|
||||
}
|
||||
|
||||
fn protocol_version(&self) -> u16 {
|
||||
crate::announcement::ANNOUNCEMENT_VERSION
|
||||
}
|
||||
|
||||
fn options(&self) -> &'static [tsunagi::dataplane::ProtocolOption] {
|
||||
Self::OPTIONS
|
||||
}
|
||||
|
||||
fn attach(&self, context: PluginContext) {
|
||||
if let Some(local) = context.local_endpoint_id() {
|
||||
let _ = self.worker.local_id.set(local);
|
||||
}
|
||||
let _ = self.worker.sink.set(context.packet_sink());
|
||||
let _ = self.worker.context.set(context);
|
||||
}
|
||||
|
||||
@@ -777,7 +687,7 @@ impl IpPlugin for WireguardPlugin {
|
||||
let announcement = WgAnnouncement::new(network, &state.key.public());
|
||||
Ok(Some(PluginCapability {
|
||||
protocol: WIREGUARD_PROTOCOL.to_string(),
|
||||
version: super::announcement::ANNOUNCEMENT_VERSION,
|
||||
version: crate::announcement::ANNOUNCEMENT_VERSION,
|
||||
enabled: true,
|
||||
data: announcement.encode()?,
|
||||
}))
|
||||
@@ -845,6 +755,15 @@ impl IpPlugin for WireguardPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
fn carry(&self, network: NetworkId, peer: EndpointId, packet: bytes::Bytes) -> bool {
|
||||
let shared = self.worker.lock_shared();
|
||||
shared
|
||||
.networks
|
||||
.get(&network)
|
||||
.and_then(|state| state.device.as_ref())
|
||||
.is_some_and(|device| device.carry(peer, &packet))
|
||||
}
|
||||
|
||||
fn on_peer_link(&self, network: NetworkId, peer: EndpointId, link: SharedLink) {
|
||||
self.nudge(Command::Link {
|
||||
network,
|
||||
@@ -873,15 +792,28 @@ impl IpPlugin for WireguardPlugin {
|
||||
self.nudge(Command::Teardown(network));
|
||||
}
|
||||
|
||||
fn on_network_forgotten(&self, network: NetworkId) {
|
||||
// The key is this protocol's identity in that network and nothing
|
||||
// else's. Keeping it after leaving would keep a secret for a
|
||||
// network this agent is no longer in — and hand back the same
|
||||
// overlay address on a rejoin that everyone else has moved past.
|
||||
if let Err(err) = self.worker.store.forget(network) {
|
||||
tracing::warn!(%err, "cannot remove the WireGuard key of a network we left");
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
if self.commands.send(Command::Stop(reply_tx)).await.is_ok() {
|
||||
let _ = reply_rx.await;
|
||||
// Bounded here as well as by the agent's grace: that grace
|
||||
// abandons this future, which would leave the runtime task
|
||||
// running behind it.
|
||||
let _ = tokio::time::timeout(TASK_GRACE, reply_rx).await;
|
||||
}
|
||||
let task = self.task.lock().ok().and_then(|mut guard| guard.take());
|
||||
if let Some(task) = task {
|
||||
let _ = task.await;
|
||||
wind_down(task, TASK_GRACE, "wg-quic runtime").await;
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -17,10 +17,10 @@ use std::sync::Mutex;
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::identity::NetworkId;
|
||||
use tsunagi::dataplane::PluginError;
|
||||
use tsunagi::identity::NetworkId;
|
||||
|
||||
use super::keys::{KEY_LEN, WgSecretKey};
|
||||
use crate::keys::{KEY_LEN, WgSecretKey};
|
||||
|
||||
/// Schema version written by this build.
|
||||
pub const SCHEMA_VERSION: i64 = 1;
|
||||
@@ -37,14 +37,14 @@ impl WgKeyStore {
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, PluginError> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
if let Some(parent) = path.parent() {
|
||||
crate::storage::create_dir(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}"))
|
||||
})?;
|
||||
crate::storage::restrict_path_permissions(&path)
|
||||
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))
|
||||
@@ -169,7 +169,7 @@ mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
|
||||
fn network(name: &str) -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
+43
-36
@@ -11,29 +11,27 @@
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{config_with, network, wait_until};
|
||||
use tempfile::TempDir;
|
||||
use tsunagi::dataplane::IpPlugin;
|
||||
use tsunagi::dataplane::wireguard::{
|
||||
Cidr, InterfaceState, LinkKind, ManagedTunFactory, MockHost, MockProvisioner, WireguardConfig,
|
||||
WireguardPlugin,
|
||||
};
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::NetworkId;
|
||||
use tsunagi::overlay::{
|
||||
Cidr, InterfaceState, LinkKind, ManagedTunFactory, MockHost, MockProvisioner,
|
||||
};
|
||||
use tsunagi::state::DEFAULT_IPV4_RANGE;
|
||||
use tsunagi::testing::{config_with, network, wait_until};
|
||||
use tsunagi::{Agent, NetworkStatus};
|
||||
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
|
||||
|
||||
/// An agent whose overlay interface is applied to a pretend host.
|
||||
struct HostedAgent {
|
||||
_dir: TempDir,
|
||||
agent: Agent,
|
||||
plugin: Arc<WireguardPlugin>,
|
||||
_plugin: Arc<WireguardPlugin>,
|
||||
host: MockHost,
|
||||
}
|
||||
|
||||
@@ -43,12 +41,12 @@ impl HostedAgent {
|
||||
let provisioner = Arc::new(MockProvisioner::new(host.clone()));
|
||||
let factory = Arc::new(ManagedTunFactory::new(provisioner));
|
||||
let config = WireguardConfig::new(dir.path().join("wireguard"))
|
||||
.with_interface_prefix(tag)
|
||||
.with_reconcile(Duration::from_millis(20), Duration::from_millis(100));
|
||||
let plugin = WireguardPlugin::open(config, factory).await.unwrap();
|
||||
let plugin = WireguardPlugin::open(config).await.unwrap();
|
||||
let agent = Agent::spawn(
|
||||
config_with(dir.path(), discovery)
|
||||
.with_overlay_ipv4_range(Some(DEFAULT_IPV4_RANGE))
|
||||
.with_interface(factory, tag, 1280)
|
||||
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
|
||||
)
|
||||
.await
|
||||
@@ -56,17 +54,18 @@ impl HostedAgent {
|
||||
Self {
|
||||
_dir: dir,
|
||||
agent,
|
||||
plugin,
|
||||
_plugin: plugin,
|
||||
host,
|
||||
}
|
||||
}
|
||||
|
||||
/// The interface name the plugin settled on for a network.
|
||||
async fn interface(&self, network: NetworkId) -> String {
|
||||
wait_until("the plugin named its interface", || async {
|
||||
self.plugin
|
||||
.overview(network)
|
||||
.map(|view| view.interface)
|
||||
/// The one interface this agent owns. Not per network.
|
||||
async fn interface(&self, _network: NetworkId) -> String {
|
||||
wait_until("the agent named its interface", || async {
|
||||
self.agent
|
||||
.overlay()
|
||||
.map(|overlay| overlay.interface)
|
||||
.filter(|name| !name.is_empty())
|
||||
})
|
||||
.await
|
||||
@@ -94,11 +93,12 @@ fn v4(state: &InterfaceState) -> Vec<Ipv4Addr> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_v6(state: &InterfaceState) -> bool {
|
||||
state
|
||||
.addresses
|
||||
.iter()
|
||||
.any(|cidr| matches!(cidr.addr, IpAddr::V6(_)))
|
||||
/// Whether the overlay address has been put on the interface yet.
|
||||
///
|
||||
/// It is allocated and signed at the system level, so it arrives on a later
|
||||
/// reconciliation than the interface itself rather than with it.
|
||||
fn addressed(state: &InterfaceState) -> bool {
|
||||
!state.addresses.is_empty()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -111,18 +111,13 @@ async fn an_agent_creates_and_configures_its_own_overlay_interface() {
|
||||
let interface = agent.interface(network_id).await;
|
||||
|
||||
let state = agent
|
||||
.wait_for_host("the interface to be created", &interface, |state| {
|
||||
state.filter(|state| !state.addresses.is_empty())
|
||||
})
|
||||
.wait_for_host("the interface to be created", &interface, |state| state)
|
||||
.await;
|
||||
|
||||
assert_eq!(state.kind, LinkKind::Tun);
|
||||
assert!(state.up, "the agent brought the link up itself");
|
||||
assert_eq!(state.mtu, 1280);
|
||||
assert!(has_v6(&state), "the derived overlay address is assigned");
|
||||
|
||||
// The IPv4 address is allocated at run time, so it arrives on a later
|
||||
// reconciliation than the interface itself.
|
||||
let addresses = agent
|
||||
.wait_for_host("the allocated IPv4 address", &interface, |state| {
|
||||
state.map(|state| v4(&state)).filter(|v4| !v4.is_empty())
|
||||
@@ -160,9 +155,14 @@ async fn an_interface_left_by_a_crashed_run_is_replaced_rather_than_tripped_over
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
assert_eq!(agent.interface(network_id).await, interface);
|
||||
|
||||
// Waited on directly rather than on "it has some address": the address
|
||||
// this agent was allocated arrives a reconciliation after the interface
|
||||
// does, and in between the leftover is still the only one there.
|
||||
let state = agent
|
||||
.wait_for_host("the interface to be rebuilt", &interface, |state| {
|
||||
state.filter(|state| state.attached && has_v6(state))
|
||||
.wait_for_host("the stale address to be replaced", &interface, |state| {
|
||||
state.filter(|state| {
|
||||
state.attached && addressed(state) && !state.addresses.contains(&stale)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
@@ -216,7 +216,10 @@ async fn an_interface_belonging_to_something_else_is_left_alone() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leaving_a_network_removes_the_interface_from_the_host() {
|
||||
async fn leaving_a_network_takes_its_address_off_the_interface_but_not_the_interface() {
|
||||
// The interface belongs to the agent, so it outlives any one network:
|
||||
// another may still be using it. What a network takes with it is its own
|
||||
// address.
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("provision-cleanup");
|
||||
let agent = HostedAgent::spawn(&discovery, "tsunx", MockHost::new()).await;
|
||||
@@ -224,21 +227,25 @@ async fn leaving_a_network_removes_the_interface_from_the_host() {
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
let interface = agent.interface(network_id).await;
|
||||
agent
|
||||
.wait_for_host("the interface to exist", &interface, |state| state)
|
||||
.wait_for_host("the address to be assigned", &interface, |state| {
|
||||
state.filter(addressed)
|
||||
})
|
||||
.await;
|
||||
|
||||
agent.agent.deactivate_network(network_id).await.unwrap();
|
||||
|
||||
agent
|
||||
.wait_for_host("the interface to be removed", &interface, |state| {
|
||||
state.is_none().then_some(())
|
||||
let state = agent
|
||||
.wait_for_host("the address to be withdrawn", &interface, |state| {
|
||||
state.filter(|state| !addressed(state))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(state.kind, LinkKind::Tun, "the interface is still there");
|
||||
|
||||
// And it goes when the agent does.
|
||||
agent.agent.shutdown().await;
|
||||
assert!(
|
||||
agent.host.names().is_empty(),
|
||||
"nothing is left behind: {:?}",
|
||||
agent.host.names()
|
||||
);
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
@@ -6,22 +6,21 @@
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{config_with, network, wait_for_peers, wait_until};
|
||||
use tempfile::TempDir;
|
||||
use tsunagi::dataplane::IpPlugin;
|
||||
use tsunagi::dataplane::wireguard::{MemoryTunFactory, WireguardConfig, WireguardPlugin};
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::ipc::unix::{ControlSocket, request_status};
|
||||
use tsunagi::ipc::{ControlSocket, request_status};
|
||||
use tsunagi::ipc::{StatusReport, control_socket_path};
|
||||
use tsunagi::overlay::MemoryTunFactory;
|
||||
use tsunagi::testing::{config_with, network, wait_for_peers, wait_until};
|
||||
use tsunagi::{Agent, BoxFuture};
|
||||
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
|
||||
|
||||
/// Builds the report the way the binary does, from the agent plus the plugin.
|
||||
fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::unix::ReportSource> {
|
||||
fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::ReportSource> {
|
||||
Arc::new(move || -> BoxFuture<'static, StatusReport> {
|
||||
let agent = agent.clone();
|
||||
let plugin = Arc::clone(&plugin);
|
||||
@@ -46,17 +45,19 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::u
|
||||
.collect(),
|
||||
overlay: plugin.overview(net.network_id).map(|view| {
|
||||
tsunagi::ipc::OverlayReport {
|
||||
interface: view.interface.clone(),
|
||||
mtu: view.mtu,
|
||||
address: view.overlay_address.to_string(),
|
||||
prefix: view.overlay_prefix.to_string(),
|
||||
prefix_len: view.overlay_prefix_len,
|
||||
// The interface belongs to the agent now.
|
||||
interface: agent
|
||||
.overlay()
|
||||
.map_or_else(String::new, |overlay| overlay.interface),
|
||||
mtu: agent.overlay().map_or(0, |overlay| overlay.mtu),
|
||||
address: view.overlay_address_v4.map(|a| a.to_string()),
|
||||
prefix_len: view.ipv4_range.map_or(0, |range| range.prefix_len),
|
||||
peers: view
|
||||
.peers
|
||||
.iter()
|
||||
.map(|peer| tsunagi::ipc::OverlayPeerReport {
|
||||
public_key: peer.public_key.to_string(),
|
||||
address: peer.overlay_address.to_string(),
|
||||
address: peer.overlay_address_v4.map(|a| a.to_string()),
|
||||
handshake_secs_ago: peer
|
||||
.tunnel
|
||||
.as_ref()
|
||||
@@ -81,6 +82,7 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::u
|
||||
.collect(),
|
||||
cache_healthy: status.cache_healthy,
|
||||
networks,
|
||||
dns: None,
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -95,14 +97,14 @@ async fn a_client_sees_the_agent_and_its_overlay() {
|
||||
let tuns = MemoryTunFactory::new();
|
||||
let plugin = WireguardPlugin::open(
|
||||
WireguardConfig::new(dir_a.path().join("wg"))
|
||||
.with_interface_prefix("tca")
|
||||
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
|
||||
Arc::new(tuns),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let agent = Agent::spawn(
|
||||
config_with(dir_a.path(), &discovery).with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
|
||||
config_with(dir_a.path(), &discovery)
|
||||
.with_interface(Arc::new(tuns), "tca0", 1280)
|
||||
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -110,14 +112,14 @@ async fn a_client_sees_the_agent_and_its_overlay() {
|
||||
let dir_b = TempDir::new().unwrap();
|
||||
let plugin_b = WireguardPlugin::open(
|
||||
WireguardConfig::new(dir_b.path().join("wg"))
|
||||
.with_interface_prefix("tcb")
|
||||
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
|
||||
Arc::new(MemoryTunFactory::new()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let agent_b = Agent::spawn(
|
||||
config_with(dir_b.path(), &discovery).with_plugin(plugin_b.clone() as Arc<dyn IpPlugin>),
|
||||
config_with(dir_b.path(), &discovery)
|
||||
.with_interface(Arc::new(MemoryTunFactory::new()), "tcb0", 1280)
|
||||
.with_plugin(plugin_b.clone() as Arc<dyn IpPlugin>),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -167,7 +169,7 @@ async fn a_client_sees_the_agent_and_its_overlay() {
|
||||
"the tunnel is up: {:?}",
|
||||
overlay.peers
|
||||
);
|
||||
assert!(!overlay.address.is_empty());
|
||||
assert!(overlay.address.is_some());
|
||||
|
||||
control.shutdown().await;
|
||||
assert!(!socket_path.exists(), "the socket is removed on shutdown");
|
||||
@@ -184,7 +186,7 @@ async fn a_leftover_socket_file_is_replaced_but_a_live_one_is_not() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("agent.sock");
|
||||
|
||||
let empty: Arc<dyn tsunagi::ipc::unix::ReportSource> =
|
||||
let empty: Arc<dyn tsunagi::ipc::ReportSource> =
|
||||
Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
||||
Box::pin(async { StatusReport::default() })
|
||||
});
|
||||
@@ -232,45 +234,172 @@ fn the_socket_path_is_derived_and_short_enough() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Asking who this device is must not need the directory lock.
|
||||
///
|
||||
/// The lock belongs to the one agent allowed to *write* the state. `id` only
|
||||
/// reads, so making it take the lock would mean the question could never be
|
||||
/// answered while an agent was running — which is exactly when you want to
|
||||
/// ask it.
|
||||
#[cfg(feature = "cli")]
|
||||
#[tokio::test]
|
||||
async fn identity_can_be_read_while_an_agent_holds_the_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
/// A source that can also leave, the way the binary's one does.
|
||||
#[derive(Debug)]
|
||||
struct Control(Agent);
|
||||
|
||||
// Hold the directory the way a running agent does.
|
||||
impl tsunagi::ipc::ReportSource for Control {
|
||||
fn report(&self) -> BoxFuture<'_, StatusReport> {
|
||||
Box::pin(async move { StatusReport::default() })
|
||||
}
|
||||
|
||||
fn join(
|
||||
&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())?;
|
||||
let secret =
|
||||
tsunagi::identity::NetworkSecret::decode(&secret).map_err(|e| e.to_string())?;
|
||||
let already = self
|
||||
.0
|
||||
.list_networks()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
.iter()
|
||||
.any(|other| other.name == name);
|
||||
let network_id = self
|
||||
.0
|
||||
.join_network(&name, &secret)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(tsunagi::ipc::JoinedReport {
|
||||
name: name.as_str().to_string(),
|
||||
network_id: network_id.to_string(),
|
||||
already_configured: already,
|
||||
name_shared_with: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn leave(&self, network_id: String) -> BoxFuture<'_, Result<tsunagi::ipc::LeftReport, String>> {
|
||||
Box::pin(async move {
|
||||
let wanted: tsunagi::NetworkId = network_id.parse().map_err(|_| "not an id")?;
|
||||
let name = self
|
||||
.0
|
||||
.list_networks()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
.into_iter()
|
||||
.find(|network| network.network_id == wanted)
|
||||
.map(|network| network.name.as_str().to_string())
|
||||
.ok_or("not a network this agent is in")?;
|
||||
let outcome = self
|
||||
.0
|
||||
.leave_network(wanted)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(tsunagi::ipc::LeftReport {
|
||||
name,
|
||||
announced: outcome.announced,
|
||||
peers_told: outcome.peers_told as u32,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_client_can_leave_a_network_through_the_running_agent() {
|
||||
// The release can only be published by the agent that is running, and
|
||||
// only while its sessions are up, so leaving goes over this socket
|
||||
// rather than being done behind its back in the state store.
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("control-leave");
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let expected = agent.endpoint_id().to_string();
|
||||
let other = TempDir::new().unwrap();
|
||||
let peer = Agent::spawn(config_with(other.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let network_id = agent.join_network(&name, &secret).await.unwrap();
|
||||
peer.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&agent, network_id, 1).await;
|
||||
|
||||
let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
.arg("id")
|
||||
// The same layout `StoragePaths::under` gives the agent above.
|
||||
.arg("--state-dir")
|
||||
.arg(dir.path().join("state"))
|
||||
.arg("--cache-dir")
|
||||
.arg(dir.path().join("cache"))
|
||||
.output()
|
||||
let socket_path = dir.path().join("control.sock");
|
||||
let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone())))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"`id` failed while an agent was running:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains(&expected),
|
||||
"expected {expected} in:\n{stdout}"
|
||||
);
|
||||
let report = tsunagi::ipc::leave_network(&socket_path, &network_id.to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(report.name, name.as_str());
|
||||
assert!(report.announced);
|
||||
assert_eq!(report.peers_told, 1);
|
||||
assert!(agent.list_networks().await.unwrap().is_empty());
|
||||
|
||||
// Asking again names the state it is in rather than failing obscurely.
|
||||
let err = tsunagi::ipc::leave_network(&socket_path, &network_id.to_string())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("not a network"), "{err}");
|
||||
|
||||
control.shutdown().await;
|
||||
agent.shutdown().await;
|
||||
peer.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_client_can_add_a_network_to_a_running_agent() {
|
||||
// One agent per identity, and it may be in several networks at once —
|
||||
// but the state directory belongs to that one live agent, so a second
|
||||
// `up` cannot add a network to it. Without this there was no way at
|
||||
// all: you could leave a network while running, but not join one.
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (first, first_secret) = network("control-join-one");
|
||||
let (second, second_secret) = network("control-join-two");
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
agent.join_network(&first, &first_secret).await.unwrap();
|
||||
|
||||
let socket_path = dir.path().join("control.sock");
|
||||
let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone())))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = tsunagi::ipc::join_network(
|
||||
&socket_path,
|
||||
second.as_str(),
|
||||
second_secret.encode().as_str(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(report.name, second.as_str());
|
||||
assert!(!report.already_configured);
|
||||
|
||||
// Running, not merely written down: it is in the agent's own list and
|
||||
// answering for status straight away, on the same identity.
|
||||
let joined: tsunagi::NetworkId = report.network_id.parse().unwrap();
|
||||
assert!(agent.is_active(joined).await);
|
||||
assert_eq!(agent.list_networks().await.unwrap().len(), 2);
|
||||
assert!(agent.network_status(joined).await.is_ok());
|
||||
|
||||
// Joining the same one again is not an error, and says which it was.
|
||||
let again = tsunagi::ipc::join_network(
|
||||
&socket_path,
|
||||
second.as_str(),
|
||||
second_secret.encode().as_str(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(again.already_configured);
|
||||
assert_eq!(again.network_id, report.network_id);
|
||||
|
||||
// A secret that is not one is refused rather than stored.
|
||||
let err = tsunagi::ipc::join_network(&socket_path, "rubbish", "not-a-secret")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!err.to_string().is_empty());
|
||||
assert_eq!(agent.list_networks().await.unwrap().len(), 2);
|
||||
|
||||
control.shutdown().await;
|
||||
agent.shutdown().await;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
[package]
|
||||
name = "tsunagi"
|
||||
version = "0.1.0"
|
||||
description = "Small private mesh networks: persistent agent identity, deterministic network spaces, an iroh control plane, signed state and a local DNS view."
|
||||
readme = "../../README.md"
|
||||
keywords = ["mesh", "p2p", "iroh", "networking"]
|
||||
categories = ["network-programming"]
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# A real TUN device, so a plugin can carry actual IP traffic. On Linux it
|
||||
# needs CAP_NET_ADMIN; on Windows the process must run elevated and
|
||||
# `wintun.dll` must be reachable. Without either the in-memory device serves
|
||||
# tests. The platform-specific dependencies are declared per target, so
|
||||
# enabling this only pulls what the target it is built for actually uses.
|
||||
tun-device = [
|
||||
"dep:tun",
|
||||
"dep:rtnetlink",
|
||||
"dep:caps",
|
||||
"dep:futures-util",
|
||||
]
|
||||
# Telling the operating system where to send its DNS questions. systemd-resolved
|
||||
# on Linux, the Name Resolution Policy Table on Windows; the zone and the
|
||||
# server work without it either way.
|
||||
dns-publish = ["dep:zbus"]
|
||||
# The test harness in `testing`, for this crate's own tests and for a
|
||||
# protocol crate's. Not on by default: it is only of use to a test.
|
||||
testing = ["dep:tempfile", "dep:tracing-subscriber"]
|
||||
|
||||
[dependencies]
|
||||
arc-swap = "1.9"
|
||||
iroh.workspace = true
|
||||
# `net` for the local control interface: a Unix socket on Unix, a named pipe
|
||||
# on Windows. Both live behind the same `net` feature.
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
tokio-stream = "0.1"
|
||||
serde.workspace = true
|
||||
postcard.workspace = true
|
||||
bytes.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
hex.workspace = true
|
||||
data-encoding.workspace = true
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
hkdf = "0.13"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.9", features = ["derive"] }
|
||||
rand = "0.10"
|
||||
# Already in the tree through iroh. A packet codec, not a DNS server: the
|
||||
# zone logic is ours and a full server framework would be a large dependency
|
||||
# for answering A records from memory.
|
||||
simple-dns = "0.12"
|
||||
# The IANA top-level domain list, compiled in: one function, no
|
||||
# dependencies, no network. Used only to warn that a zone name shadows a
|
||||
# real public domain, never to refuse one.
|
||||
tld = "2.40"
|
||||
fs4 = { version = "1.1", features = ["sync"] }
|
||||
directories = "6.0"
|
||||
# The real system hostname, without a libc call of our own: this crate is
|
||||
# forbidden `unsafe` and will not make one.
|
||||
gethostname = "1.1"
|
||||
netwatch = "0.19.3"
|
||||
netdev = "0.46"
|
||||
mainline = "8.0"
|
||||
futures-lite = "2.6"
|
||||
tun = { version = "0.8", features = ["async"], optional = true }
|
||||
# Only for the `testing` harness, which a protocol crate's tests use too.
|
||||
tempfile = { version = "3.24", optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
|
||||
# Linux-only interface provisioning. `rtnetlink` configures the interface in
|
||||
# process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN
|
||||
# out of the effective set except during the moments it is used.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
rtnetlink = { version = "0.23", optional = true }
|
||||
# Pure Rust D-Bus, no libdbus to link against. `tokio` rather than the
|
||||
# default reactor, because the agent brings its own.
|
||||
zbus = { version = "5.19", default-features = false, features = ["tokio"], optional = true }
|
||||
caps = { version = "0.5", optional = true }
|
||||
futures-util = { version = "0.3", default-features = false, optional = true }
|
||||
|
||||
# Windows-only interface provisioning. The Wintun adapter itself is created
|
||||
# and driven through the `tun` dependency, whose safe wrapper is the only way
|
||||
# in — loading `wintun.dll` is an `unsafe` call this crate forbids. `netdev`
|
||||
# (already in the tree through `netwatch`) reads the interface table through a
|
||||
# safe wrapper, which is how the provisioner observes an adapter by name
|
||||
# before deciding what to change. Creating an adapter needs the process to be
|
||||
# elevated and `wintun.dll` to be reachable at run time.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Security",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Foundation",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Its own tests use the harness it publishes.
|
||||
tsunagi = { path = ".", features = ["testing"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process", "test-util"] }
|
||||
tempfile.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File renamed without changes.
@@ -27,7 +27,7 @@ mod status;
|
||||
pub use events::Event;
|
||||
pub use status::{
|
||||
AgentStatus, CandidateStatus, MemberStatus, NetworkMetrics, NetworkState, NetworkStatus,
|
||||
PeerStatus,
|
||||
OverlayStatus, PeerStatus,
|
||||
};
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -37,23 +37,29 @@ use iroh::{EndpointAddr, EndpointId};
|
||||
use tokio::sync::{RwLock, broadcast, mpsc, oneshot};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::config::{AgentConfig, Limits};
|
||||
use crate::dataplane::transport::PacketTransport;
|
||||
use crate::dataplane::transport::iroh_link::{IrohTransport, TransportContext};
|
||||
use crate::dataplane::{PluginContext, PluginRequest};
|
||||
use crate::dataplane::{PacketSink, PluginContext, PluginRequest};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::{DeviceIdentity, NetworkId, NetworkKeys, NetworkName, NetworkSecret};
|
||||
use crate::net::EndpointAdapter;
|
||||
use crate::overlay::PacketCarrier;
|
||||
use crate::proto::handshake;
|
||||
use crate::proto::message::ControlMessage;
|
||||
use crate::state::Ipv4Range;
|
||||
use crate::storage::{CacheOutcome, Storage};
|
||||
|
||||
use crate::task::{TASK_GRACE, wind_down};
|
||||
use network::{InboundSession, NetCommand, NetworkHandle, RuntimeParams};
|
||||
use shutdown::Shutdown;
|
||||
|
||||
/// Summary of a configured network, whether or not it is running.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfiguredNetwork {
|
||||
/// Saved local broadcast participation.
|
||||
pub broadcast: bool,
|
||||
/// Public network identifier.
|
||||
pub network_id: NetworkId,
|
||||
/// Network name.
|
||||
@@ -87,6 +93,10 @@ struct Inner {
|
||||
accept_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||
plugin_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||
transport: std::sync::OnceLock<Arc<dyn PacketTransport>>,
|
||||
/// Who holds which overlay address, across every network.
|
||||
routes: Arc<crate::overlay::RoutingTable>,
|
||||
/// The one interface, when the agent was given a way to make one.
|
||||
interface: std::sync::OnceLock<Arc<crate::overlay::Interface>>,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
@@ -107,11 +117,69 @@ impl Inner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes an outbound packet to whichever protocol can carry it.
|
||||
///
|
||||
/// Holds a weak reference: the interface belongs to the agent, and a strong
|
||||
/// one here would keep the agent alive for as long as its own interface.
|
||||
struct Carrier(Weak<Inner>);
|
||||
|
||||
impl std::fmt::Debug for Carrier {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Carrier")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Sink {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Sink")
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketCarrier for Carrier {
|
||||
fn carry(&self, route: crate::overlay::Route, packet: bytes::Bytes) -> bool {
|
||||
let Some(inner) = self.0.upgrade() else {
|
||||
return false;
|
||||
};
|
||||
// Offered to each protocol in turn. With one configured this is a
|
||||
// single call; the shape is what allows several to be live at once,
|
||||
// each carrying the peers it has a link to.
|
||||
inner
|
||||
.config
|
||||
.plugins
|
||||
.iter()
|
||||
.any(|plugin| plugin.carry(route.network, route.peer, packet.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a packet a protocol decrypted to the interface.
|
||||
struct Sink(Weak<Inner>);
|
||||
|
||||
impl PacketSink for Sink {
|
||||
fn deliver<'a>(
|
||||
&'a self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
packet: bytes::Bytes,
|
||||
) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
let Some(inner) = self.0.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(interface) = inner.interface.get() else {
|
||||
return;
|
||||
};
|
||||
// The rejection is already counted on the interface; there is
|
||||
// nothing useful to do with it here.
|
||||
let _ = interface.deliver(network, peer, packet).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers the data plane transport's questions about the agent.
|
||||
///
|
||||
/// Holds a weak reference on purpose: the transport lives inside the agent, so
|
||||
/// a strong one would be a cycle and the agent — with its open databases and
|
||||
/// its directory lock — would never be released.
|
||||
/// Holds a weak reference for the same reason as [`Carrier`]: the transport
|
||||
/// lives inside the agent, so a strong one would be a cycle and the agent —
|
||||
/// with its open databases and its directory lock — would never be released.
|
||||
#[derive(Debug)]
|
||||
struct TransportCtx(Weak<Inner>);
|
||||
|
||||
@@ -178,17 +246,54 @@ impl Agent {
|
||||
accept_task: std::sync::Mutex::new(None),
|
||||
plugin_task: std::sync::Mutex::new(None),
|
||||
transport: std::sync::OnceLock::new(),
|
||||
routes: Arc::new(crate::overlay::RoutingTable::new()),
|
||||
interface: std::sync::OnceLock::new(),
|
||||
config,
|
||||
});
|
||||
|
||||
// One interface for the agent, if it was given a way to make one.
|
||||
// Failing to is reported and not fatal: the control plane works, and
|
||||
// so do the protocols, they just have nowhere to put packets.
|
||||
if let Some(factory) = inner.config.tun_factory.clone() {
|
||||
let carrier = Arc::new(Carrier(Arc::downgrade(&inner))) as Arc<dyn PacketCarrier>;
|
||||
match crate::overlay::Interface::start(
|
||||
factory,
|
||||
inner.config.interface_name.clone(),
|
||||
inner.config.interface_mtu,
|
||||
Arc::clone(&inner.routes),
|
||||
carrier,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(interface) => {
|
||||
let _ = inner.interface.set(Arc::new(interface));
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = inner.events.send(Event::PluginError {
|
||||
network: NetworkId::from_bytes([0u8; 32]),
|
||||
protocol: "overlay".into(),
|
||||
reason: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The data plane rides on iroh too, which is where it gets hole
|
||||
// punching and relay fallback from. It is a separate ALPN and a
|
||||
// separate connection, so the two planes stay independent.
|
||||
let transport: Arc<dyn PacketTransport> = Arc::new(IrohTransport::new(
|
||||
let iroh_transport: Arc<dyn PacketTransport> = Arc::new(IrohTransport::new(
|
||||
inner.adapter.clone(),
|
||||
Arc::clone(&inner.limits),
|
||||
Arc::new(TransportCtx(Arc::downgrade(&inner))) as Arc<dyn TransportContext>,
|
||||
));
|
||||
let transport: Arc<dyn PacketTransport> = if inner.config.custom_transports.is_empty() {
|
||||
iroh_transport
|
||||
} else {
|
||||
Arc::new(crate::dataplane::MultiPacketTransport::new(
|
||||
iroh_transport,
|
||||
inner.config.custom_transports.clone(),
|
||||
))
|
||||
};
|
||||
let _ = inner.transport.set(transport);
|
||||
|
||||
if let CacheOutcome::Reset(reason) = inner.storage.cache_outcome().clone() {
|
||||
@@ -205,7 +310,11 @@ impl Agent {
|
||||
// bound; overflow drops the request rather than stalling the plugin.
|
||||
if !inner.config.plugins.is_empty() {
|
||||
let (plugin_tx, plugin_rx) = mpsc::channel(64);
|
||||
let context = PluginContext::new(plugin_tx, inner.identity.endpoint_id());
|
||||
let sink = inner
|
||||
.interface
|
||||
.get()
|
||||
.map(|_| Arc::new(Sink(Arc::downgrade(&inner))) as Arc<dyn PacketSink>);
|
||||
let context = PluginContext::new(plugin_tx, inner.identity.endpoint_id(), sink);
|
||||
for plugin in &inner.config.plugins {
|
||||
plugin.attach(context.clone());
|
||||
}
|
||||
@@ -245,6 +354,137 @@ impl Agent {
|
||||
self.inner.adapter.loopback_addr()
|
||||
}
|
||||
|
||||
/// Delivers an inbound data plane link to the matching network runtime.
|
||||
pub async fn install_inbound_link(
|
||||
&self,
|
||||
inbound: crate::dataplane::transport::InboundLink,
|
||||
) -> Result<()> {
|
||||
let network = inbound.network;
|
||||
let sender = {
|
||||
let networks = self.inner.networks.read().await;
|
||||
networks.get(&network).map(|handle| handle.commands.clone())
|
||||
};
|
||||
let Some(sender) = sender else {
|
||||
return Err(Error::NetworkNotActive(network));
|
||||
};
|
||||
sender
|
||||
.send(NetCommand::InboundLink(Box::new(inbound)))
|
||||
.await
|
||||
.map_err(|_| Error::NetworkNotActive(network))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reserves the configured overlay range for a network, if it can.
|
||||
///
|
||||
/// Done here, where activations are serialised, rather than inside the
|
||||
/// runtime: whether a range is free depends on what the other networks
|
||||
/// took, and deciding that in a task would make the answer depend on
|
||||
/// which task ran first.
|
||||
///
|
||||
/// `None` means this agent will not propose a range for that network and
|
||||
/// waits to adopt whatever it settles on. One agent has one interface, so
|
||||
/// proposing a range it could not route would be worse than having none:
|
||||
/// the lowest author's range wins, and the collision would spread.
|
||||
async fn reserve_range(&self, network: NetworkId) -> RangePlan {
|
||||
let reserve = |wanted: Ipv4Range| {
|
||||
let reservation = crate::overlay::NetworkRoutes {
|
||||
broadcast: Default::default(),
|
||||
range: Some(wanted),
|
||||
local: None,
|
||||
peers: Vec::new(),
|
||||
};
|
||||
self.inner.routes.set_network(network, reservation).is_ok()
|
||||
};
|
||||
|
||||
// If this network already has signed records in storage, check if its
|
||||
// members have already agreed on a range. If so, preserve and reserve
|
||||
// THAT range rather than preemptively seizing the default configured range.
|
||||
let stored_records = self
|
||||
.inner
|
||||
.storage
|
||||
.signed_records(network)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut state_set = crate::state::StateSet::new();
|
||||
state_set.merge_all(network, stored_records);
|
||||
if let Some(agreed) = state_set.agreed_range() {
|
||||
if reserve(agreed) {
|
||||
return RangePlan {
|
||||
propose: if Some(agreed) == self.inner.config.overlay_ipv4_range {
|
||||
Some(agreed)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
..RangePlan::default()
|
||||
};
|
||||
} else {
|
||||
tracing::warn!(
|
||||
%network,
|
||||
range = %agreed,
|
||||
"agreed range for this network overlaps another network already active on this interface"
|
||||
);
|
||||
return RangePlan {
|
||||
conflict: Some(agreed),
|
||||
..RangePlan::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let Some(configured) = self.inner.config.overlay_ipv4_range else {
|
||||
return RangePlan::default();
|
||||
};
|
||||
|
||||
// The configured range first, so a network a device has always had
|
||||
// keeps the addresses it has always had.
|
||||
if reserve(configured) {
|
||||
return RangePlan {
|
||||
propose: Some(configured),
|
||||
..RangePlan::default()
|
||||
};
|
||||
}
|
||||
|
||||
// A second network on the same agent cannot have it — one agent,
|
||||
// one interface — so it falls back to the range derived from its
|
||||
// own id, which every one of its members derives identically
|
||||
// without being told.
|
||||
let derived = crate::state::derived_ipv4_range(network);
|
||||
if reserve(derived) {
|
||||
tracing::info!(
|
||||
%network,
|
||||
range = %derived,
|
||||
"another network here holds the configured range; this one uses the \
|
||||
range derived from its id unless its members settled another"
|
||||
);
|
||||
return RangePlan {
|
||||
fallback: Some(derived),
|
||||
conflict: Some(configured),
|
||||
..RangePlan::default()
|
||||
};
|
||||
}
|
||||
|
||||
tracing::info!(%network, "not proposing a range for this network");
|
||||
RangePlan {
|
||||
conflict: Some(configured),
|
||||
..RangePlan::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The overlay interface this agent owns, when it has one.
|
||||
///
|
||||
/// One agent, one interface, so this is not asked per network: a packet
|
||||
/// on it may belong to any of them.
|
||||
pub fn overlay(&self) -> Option<OverlayStatus> {
|
||||
let interface = self.inner.interface.get()?;
|
||||
Some(OverlayStatus {
|
||||
interface: interface.name().to_string(),
|
||||
on_host: interface.on_host(),
|
||||
mtu: interface.mtu(),
|
||||
addresses: interface.wanted_addresses(),
|
||||
counters: interface.counters(),
|
||||
broadcast_rules: interface.broadcast_rules(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The hostname announced to peers.
|
||||
pub fn hostname(&self) -> String {
|
||||
self.inner.read_hostname()
|
||||
@@ -315,13 +555,47 @@ impl Agent {
|
||||
&self,
|
||||
name: &NetworkName,
|
||||
secret: &NetworkSecret,
|
||||
) -> Result<NetworkId> {
|
||||
self.join_network_with_broadcast(name, secret, None).await
|
||||
}
|
||||
|
||||
/// Joins with an explicit broadcast choice; absent preserves saved policy.
|
||||
pub async fn join_network_with_broadcast(
|
||||
&self,
|
||||
name: &NetworkName,
|
||||
secret: &NetworkSecret,
|
||||
broadcast: Option<bool>,
|
||||
) -> Result<NetworkId> {
|
||||
let keys = NetworkKeys::derive(name, secret);
|
||||
let network_id = keys.network_id();
|
||||
|
||||
// A network's identity is its name *and* its secret, so the same
|
||||
// name with a different secret is a different network — and one that
|
||||
// looks identical in anything that shows a name. Almost always a
|
||||
// mistyped secret, so it is said out loud rather than left to be
|
||||
// discovered as an empty network sitting beside a working one.
|
||||
if let Ok(configured) = self.inner.storage.list_networks().await {
|
||||
for other in configured {
|
||||
if other.name == *name && other.network_id != network_id {
|
||||
tracing::warn!(
|
||||
"`{name}` is already configured with a different secret, as {}. \
|
||||
Joining with this one adds a second network under the same name; \
|
||||
they share nothing. Check the secret, or use \
|
||||
`tsunagi network secret` \
|
||||
to see which is which.",
|
||||
other.network_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.inner
|
||||
.storage
|
||||
.upsert_network(network_id, name.clone(), secret.clone(), true)
|
||||
.await?;
|
||||
if let Some(enabled) = broadcast {
|
||||
self.set_broadcast(network_id, enabled).await?;
|
||||
}
|
||||
match self.activate_with_keys(keys).await {
|
||||
// Already a member of exactly this network space: nothing to do.
|
||||
Ok(()) | Err(Error::NetworkAlreadyActive(_)) => Ok(network_id),
|
||||
@@ -329,6 +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.
|
||||
@@ -357,20 +646,52 @@ impl Agent {
|
||||
return Err(Error::NetworkAlreadyActive(network_id));
|
||||
}
|
||||
|
||||
let range = self.reserve_range(network_id).await;
|
||||
let mut backends = Vec::new();
|
||||
if let Some(discovery) = &self.inner.config.discovery {
|
||||
backends.push(discovery.clone());
|
||||
}
|
||||
if let Some(dht) = &self.inner.config.dht {
|
||||
backends.push(dht.for_network(&keys));
|
||||
}
|
||||
let discovery = if backends.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
Arc::new(crate::discovery::CompositeDiscovery::new(backends))
|
||||
as Arc<dyn crate::discovery::NetworkDiscovery>,
|
||||
)
|
||||
};
|
||||
let 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(),
|
||||
events: self.inner.events.clone(),
|
||||
limits: Arc::clone(&self.inner.limits),
|
||||
reconnect: self.inner.config.reconnect.clone(),
|
||||
discovery: self.inner.config.discovery.clone(),
|
||||
discovery,
|
||||
discovery_interval: self.inner.config.discovery_interval,
|
||||
discovery_policy: self.inner.config.discovery_policy.clone(),
|
||||
plugins: self.inner.config.plugins.clone(),
|
||||
routes: Arc::clone(&self.inner.routes),
|
||||
interface: self.inner.interface.get().cloned(),
|
||||
hostname: self.inner.read_hostname(),
|
||||
transport: self.inner.transport.get().cloned(),
|
||||
device_secret: self.inner.identity.signing_key(),
|
||||
ipv4_range: self.inner.config.overlay_ipv4_range,
|
||||
#[cfg(feature = "testing")]
|
||||
unreachable_data_peers: Arc::clone(&self.inner.config.unreachable_data_peers),
|
||||
ipv4_range: range.propose,
|
||||
ipv4_fallback: range.fallback,
|
||||
range_conflict: range.conflict,
|
||||
});
|
||||
networks.insert(network_id, handle);
|
||||
drop(networks);
|
||||
@@ -401,17 +722,77 @@ impl Agent {
|
||||
for plugin in &self.inner.config.plugins {
|
||||
plugin.on_network_deactivated(network_id);
|
||||
}
|
||||
// The network's addresses come off the interface; the interface
|
||||
// itself stays, because the agent owns it and other networks may
|
||||
// still be using it.
|
||||
self.inner.routes.remove_network(network_id);
|
||||
if let Some(interface) = self.inner.interface.get()
|
||||
&& let Err(err) = interface.sync_addresses().await
|
||||
{
|
||||
tracing::warn!(%err, "cannot update the overlay addresses");
|
||||
}
|
||||
if let Some(interface) = self.inner.interface.get() {
|
||||
interface.sync_broadcast_rules().await;
|
||||
}
|
||||
self.inner.storage.set_auto_start(network_id, false).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deactivates a network if it is running and removes it from the state
|
||||
/// store together with its cached hints.
|
||||
///
|
||||
/// Local and silent: nobody is told. [`Agent::leave_network`] is the one
|
||||
/// that says goodbye first, and is what a person means by leaving.
|
||||
pub async fn forget_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
if self.is_active(network_id).await {
|
||||
self.deactivate_network(network_id).await?;
|
||||
}
|
||||
self.inner.storage.remove_network(network_id).await
|
||||
self.inner.storage.remove_network(network_id).await?;
|
||||
// Whatever a protocol kept for this network goes with it. The
|
||||
// agent cannot know what that is, only that there is no longer any
|
||||
// reason to hold it.
|
||||
for plugin in &self.inner.config.plugins {
|
||||
plugin.on_network_forgotten(network_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leaves a network: gives up what was claimed, then forgets it.
|
||||
///
|
||||
/// The order matters and cannot be improved on. A signed `Release` goes
|
||||
/// out first, while there are still sessions to carry it, so the address
|
||||
/// and name this agent held are freed for somebody else rather than
|
||||
/// staying reserved to a member that has gone. Only then is the network
|
||||
/// deactivated and removed.
|
||||
///
|
||||
/// Reaching every member is not on offer and never could be: a member
|
||||
/// that is away hears the tombstone from the ones that were here, the
|
||||
/// same way it hears everything else. Leaving with nobody connected
|
||||
/// tells nobody, and says so in the outcome rather than pretending.
|
||||
///
|
||||
/// The author's version counter is deliberately **kept**. Rejoining the
|
||||
/// same network with the same device key must continue from a higher
|
||||
/// version than the release, or every replica would treat the new claim
|
||||
/// as stale and ignore it.
|
||||
pub async fn leave_network(&self, network_id: NetworkId) -> Result<LeaveOutcome> {
|
||||
let mut outcome = LeaveOutcome::default();
|
||||
if self.is_active(network_id).await {
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
self.command(network_id, NetCommand::Release { reply: reply_tx })
|
||||
.await?;
|
||||
if let Ok(peers) = reply_rx.await {
|
||||
outcome.announced = true;
|
||||
outcome.peers_told = peers;
|
||||
}
|
||||
// The tombstone is queued on each session, not yet written to
|
||||
// the wire. A short pause is the difference between peers
|
||||
// hearing it now and hearing it from somebody else much later.
|
||||
if outcome.peers_told > 0 {
|
||||
tokio::time::sleep(RELEASE_FLUSH).await;
|
||||
}
|
||||
}
|
||||
self.forget_network(network_id).await?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Whether a network is currently running.
|
||||
@@ -429,6 +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,
|
||||
@@ -503,16 +885,20 @@ 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,
|
||||
state: NetworkState::Inactive,
|
||||
peers: Vec::new(),
|
||||
candidates: Vec::new(),
|
||||
// An inactive network has no runtime to ask; the roster comes
|
||||
// from one. Empty, not invented.
|
||||
// An inactive network has no runtime to ask; the roster and
|
||||
// the range come from one. Empty, not invented.
|
||||
members: Vec::new(),
|
||||
range: None,
|
||||
range_conflict: None,
|
||||
metrics: NetworkMetrics::default(),
|
||||
relay: Default::default(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -537,7 +923,8 @@ impl Agent {
|
||||
self.command(network_id, NetCommand::Reannounce).await
|
||||
}
|
||||
|
||||
/// Asks one network to re-run discovery and re-evaluate dials right now.
|
||||
/// Asks one network to re-evaluate known peers and dials right now.
|
||||
/// External lookup still follows its connected/isolation policy.
|
||||
///
|
||||
/// Call this when the host's network environment changed. Platform wake-up
|
||||
/// notifications can be wired to it later.
|
||||
@@ -545,7 +932,7 @@ impl Agent {
|
||||
self.command(network_id, NetCommand::Recheck).await
|
||||
}
|
||||
|
||||
/// Asks every running network to re-run discovery right now.
|
||||
/// Asks every running network to re-evaluate known peers right now.
|
||||
pub async fn recheck(&self) {
|
||||
let senders: Vec<mpsc::Sender<NetCommand>> = self
|
||||
.inner
|
||||
@@ -574,15 +961,35 @@ impl Agent {
|
||||
handle.stop().await;
|
||||
}
|
||||
|
||||
self.inner.adapter.close().await;
|
||||
if let Some(dht) = &self.inner.config.dht {
|
||||
dht.shutdown().await;
|
||||
}
|
||||
|
||||
for handle in [&self.inner.accept_task, &self.inner.plugin_task] {
|
||||
// iroh's own close waits for peers to acknowledge; a peer that has
|
||||
// gone silent must not decide how long that takes.
|
||||
if tokio::time::timeout(TASK_GRACE, self.inner.adapter.close())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("the endpoint did not close in time; abandoning it");
|
||||
}
|
||||
|
||||
for (what, handle) in [
|
||||
("inbound connections", &self.inner.accept_task),
|
||||
("plugin requests", &self.inner.plugin_task),
|
||||
] {
|
||||
let task = handle.lock().ok().and_then(|mut guard| guard.take());
|
||||
if let Some(task) = task {
|
||||
let _ = task.await;
|
||||
wind_down(task, TASK_GRACE, what).await;
|
||||
}
|
||||
}
|
||||
|
||||
// The interface goes with the agent that owns it. Before the
|
||||
// plugins, so nothing is still trying to write to it.
|
||||
if let Some(interface) = self.inner.interface.get() {
|
||||
interface.remove().await;
|
||||
}
|
||||
|
||||
// Plugins remove whatever system objects they created. A plugin that
|
||||
// misbehaves here must not hold up the agent, so this is bounded.
|
||||
for plugin in &self.inner.config.plugins {
|
||||
@@ -613,6 +1020,41 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a network may propose as its overlay range, and what it cannot have.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct RangePlan {
|
||||
/// Proposed straight away: the configured range, when it is free here.
|
||||
propose: Option<Ipv4Range>,
|
||||
/// Proposed after a moment, when the configured range is another
|
||||
/// network's: the range derived from this network's own id.
|
||||
fallback: Option<Ipv4Range>,
|
||||
/// The configured range, when this network cannot have it.
|
||||
conflict: Option<Ipv4Range>,
|
||||
}
|
||||
|
||||
/// How long a release is given to reach the sessions it was queued on.
|
||||
///
|
||||
/// Short: it is one small message on a connection that is already open, and
|
||||
/// the alternative to waiting at all is tearing the sessions down underneath
|
||||
/// it.
|
||||
const RELEASE_FLUSH: std::time::Duration = std::time::Duration::from_millis(300);
|
||||
|
||||
/// What happened when an agent left a network.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct LeaveOutcome {
|
||||
/// Whether a signed release was published at all.
|
||||
///
|
||||
/// `false` when the network was not running: there was nothing to
|
||||
/// publish it from, so this was a local removal only.
|
||||
pub announced: bool,
|
||||
/// How many connected peers it was sent to.
|
||||
///
|
||||
/// Zero with `announced` true means the tombstone is in this agent's
|
||||
/// own state and nowhere else, and it is leaving with it — so nobody
|
||||
/// will learn of it.
|
||||
pub peers_told: usize,
|
||||
}
|
||||
|
||||
/// How long each plugin gets to tear itself down during agent shutdown.
|
||||
const PLUGIN_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
@@ -680,6 +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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -825,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;
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -51,3 +51,25 @@ impl Shutdown {
|
||||
// The sender is gone, which for our purposes means "stop".
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn waiting_resolves_once_triggered_and_stays_resolved() {
|
||||
let shutdown = Shutdown::new();
|
||||
assert!(!shutdown.is_triggered());
|
||||
let waiter = shutdown.clone();
|
||||
let waiting = tokio::spawn(async move { waiter.wait().await });
|
||||
shutdown.trigger();
|
||||
waiting.await.unwrap();
|
||||
assert!(shutdown.is_triggered());
|
||||
// Triggering twice is not an error, and a token cloned afterwards
|
||||
// sees the trigger that already happened.
|
||||
shutdown.trigger();
|
||||
shutdown.clone().wait().await;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,29 @@ pub struct CandidateStatus {
|
||||
pub consecutive_failures: u32,
|
||||
}
|
||||
|
||||
/// The overlay interface, as the agent sees it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OverlayStatus {
|
||||
/// The interface name the operating system gave.
|
||||
pub interface: String,
|
||||
/// Whether that interface exists on the host.
|
||||
///
|
||||
/// `false` with an in-memory device: the name is real to the agent and
|
||||
/// to nothing else. Reported, because everything that would configure
|
||||
/// the operating system for this interface must not when it is this.
|
||||
pub on_host: bool,
|
||||
/// Its MTU.
|
||||
pub mtu: u32,
|
||||
/// The addresses it should be carrying.
|
||||
pub addresses: Vec<crate::overlay::Cidr>,
|
||||
/// What has happened on it.
|
||||
pub counters: crate::overlay::Counters,
|
||||
/// The route and firewall allowance installed for LAN broadcast, when
|
||||
/// some network has broadcast on and the interface is on the host.
|
||||
/// `None` is "none installed", not "unknown".
|
||||
pub broadcast_rules: Option<crate::overlay::BroadcastRulesReport>,
|
||||
}
|
||||
|
||||
/// A member the signed state knows about, connected or not.
|
||||
///
|
||||
/// This is the durable roster: it comes from signed records, so a member that
|
||||
@@ -62,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.
|
||||
@@ -70,6 +95,12 @@ pub struct PeerStatus {
|
||||
///
|
||||
/// A mutable binding, not an identity.
|
||||
pub hostname: Option<String>,
|
||||
/// Protocols agreed with this peer: both sides have them at the same
|
||||
/// version.
|
||||
///
|
||||
/// Empty means no data plane with that peer, which leaves the control
|
||||
/// plane working — messages and state still flow.
|
||||
pub protocols: Vec<String>,
|
||||
/// Capabilities the peer announced. Payloads stay opaque.
|
||||
pub capabilities: Vec<PluginCapability>,
|
||||
/// How long the session has been up.
|
||||
@@ -126,6 +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.
|
||||
@@ -140,8 +173,19 @@ pub struct NetworkStatus {
|
||||
pub candidates: Vec<CandidateStatus>,
|
||||
/// Members the signed state knows about, whether connected or not.
|
||||
pub members: Vec<MemberStatus>,
|
||||
/// The overlay range this network uses, once it has one.
|
||||
pub range: Option<crate::state::Ipv4Range>,
|
||||
/// The range it could not have, because another network on this agent
|
||||
/// already holds it.
|
||||
///
|
||||
/// One agent has one interface, so an address belongs to one network.
|
||||
/// This network waits to adopt whatever its members settle on instead of
|
||||
/// proposing something it could not route.
|
||||
pub range_conflict: Option<crate::state::Ipv4Range>,
|
||||
/// Per-network counters.
|
||||
pub metrics: NetworkMetrics,
|
||||
/// What has gone through a peer in the middle, in both directions.
|
||||
pub relay: crate::dataplane::relay::RelayCounters,
|
||||
}
|
||||
|
||||
impl NetworkStatus {
|
||||
@@ -11,12 +11,42 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::dataplane::SharedPlugin;
|
||||
use crate::discovery::NetworkDiscovery;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::discovery::{MainlineDiscovery, NetworkDiscovery};
|
||||
use crate::error::Result;
|
||||
|
||||
/// Qualifier/organisation/application triple used for platform directories.
|
||||
///
|
||||
/// Unused on macOS, where the default paths are fixed system locations rather
|
||||
/// than per-user directories derived from this.
|
||||
#[cfg_attr(target_os = "macos", allow(dead_code))]
|
||||
const APP_NAME: &str = "tsunagi";
|
||||
|
||||
/// Rendezvous slots per network; this bounds a discovery sample, not membership.
|
||||
pub const DHT_SLOTS: u8 = 16;
|
||||
/// BEP44 permits 1000 bencoded bytes; a 996-byte string has a four-byte prefix.
|
||||
pub const DHT_MAX_VALUE: usize = 996;
|
||||
/// Maximum direct addresses in one rendezvous record.
|
||||
pub const DHT_MAX_ADDRS: usize = 8;
|
||||
/// Maximum relay URL bytes in a rendezvous record.
|
||||
pub const DHT_MAX_RELAY_LEN: usize = 512;
|
||||
/// Application freshness, independent of storage nodes' own expiration.
|
||||
pub const DHT_RECORD_TTL: Duration = Duration::from_secs(900);
|
||||
/// Clock skew tolerated when reading a recently published record.
|
||||
pub const DHT_CLOCK_SKEW: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Maximum reassembled data-transport payload, independent of the path MTU.
|
||||
pub const MAX_DATA_DATAGRAM: usize = 64 * 1024;
|
||||
/// Maximum incomplete datagrams held by one authenticated data link.
|
||||
pub const MAX_DATA_ASSEMBLIES: usize = 64;
|
||||
/// Maximum allocated payload bytes for incomplete datagrams on one data link.
|
||||
pub const MAX_DATA_REASSEMBLY_BYTES: usize = 256 * 1024;
|
||||
/// Maximum fragments of one logical datagram, including after a path MTU change.
|
||||
pub const MAX_DATA_FRAGMENTS: usize = 128;
|
||||
/// Time allowed to assemble a datagram; loss never stalls later datagrams.
|
||||
pub const DATA_REASSEMBLY_TTL: Duration = Duration::from_secs(5);
|
||||
/// Completed or discarded packet IDs retained to ignore late duplicate fragments.
|
||||
pub const DATA_RECENT_IDS: usize = 128;
|
||||
|
||||
/// Where the two stores live.
|
||||
///
|
||||
/// The mandatory state and the disposable cache are separate both logically and
|
||||
@@ -48,17 +78,34 @@ impl StoragePaths {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-user platform directories.
|
||||
/// The default platform directories.
|
||||
///
|
||||
/// A future system service can supply its own paths instead.
|
||||
/// Per user on Linux and Windows. On macOS the agent runs as a root
|
||||
/// LaunchDaemon (creating a utun interface needs root, and `$HOME` under
|
||||
/// `sudo` points at the wrong place), so the stores live in the system
|
||||
/// locations instead — `/var/db/tsunagi` for the mandatory state and a
|
||||
/// disposable cache beside it. A caller that wants somewhere else passes
|
||||
/// explicit paths to [`StoragePaths::new`] or [`StoragePaths::under`].
|
||||
pub fn user_default() -> Result<Self> {
|
||||
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
|
||||
Error::Storage("no valid home directory for platform config paths".into())
|
||||
})?;
|
||||
Ok(Self {
|
||||
state_dir: dirs.data_dir().to_path_buf(),
|
||||
cache_dir: dirs.cache_dir().to_path_buf(),
|
||||
})
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Ok(Self {
|
||||
state_dir: PathBuf::from("/var/db/tsunagi"),
|
||||
cache_dir: PathBuf::from("/var/db/tsunagi/cache"),
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
|
||||
crate::error::Error::Storage(
|
||||
"no valid home directory for platform config paths".into(),
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
state_dir: dirs.data_dir().to_path_buf(),
|
||||
cache_dir: dirs.cache_dir().to_path_buf(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Path of the mandatory state database.
|
||||
@@ -137,6 +184,8 @@ pub struct Limits {
|
||||
pub write_timeout: Duration,
|
||||
/// Maximum simultaneous outbound dials per network.
|
||||
pub max_concurrent_dials: usize,
|
||||
/// Maximum unverified discovery candidates retained per network.
|
||||
pub max_discovery_candidates: usize,
|
||||
/// Maximum simultaneous authenticated sessions per network.
|
||||
pub max_sessions_per_network: usize,
|
||||
/// Maximum simultaneous inbound connections being handshaken.
|
||||
@@ -163,6 +212,7 @@ impl Default for Limits {
|
||||
dial_timeout: Duration::from_secs(10),
|
||||
write_timeout: Duration::from_secs(30),
|
||||
max_concurrent_dials: 8,
|
||||
max_discovery_candidates: 16,
|
||||
max_sessions_per_network: 64,
|
||||
max_inbound_handshakes: 32,
|
||||
session_send_queue: 64,
|
||||
@@ -212,6 +262,33 @@ impl ReconnectPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scheduling of candidate lookup and independent self-publication.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveryPolicy {
|
||||
/// Mean interval between successful self-publications (20% jitter).
|
||||
pub publish_interval: Duration,
|
||||
/// Initial delay between unsuccessful bootstrap lookups.
|
||||
pub lookup_interval: Duration,
|
||||
/// Maximum delay between bootstrap lookups.
|
||||
pub max_lookup_interval: Duration,
|
||||
/// Continuous isolation before a previously connected network searches again.
|
||||
pub reconnect_delay: Duration,
|
||||
/// Deadline for one backend operation.
|
||||
pub request_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for DiscoveryPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
publish_interval: Duration::from_secs(300),
|
||||
lookup_interval: Duration::from_secs(5),
|
||||
max_lookup_interval: Duration::from_secs(30),
|
||||
reconnect_delay: Duration::from_secs(60),
|
||||
request_timeout: Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything needed to start an [`crate::Agent`].
|
||||
#[derive(Clone)]
|
||||
pub struct AgentConfig {
|
||||
@@ -223,13 +300,24 @@ pub struct AgentConfig {
|
||||
pub bind_addrs: Vec<SocketAddr>,
|
||||
/// How much external connectivity machinery the endpoint may use.
|
||||
pub transport: TransportPolicy,
|
||||
/// Real QUIC transport settings for constrained-path integration tests.
|
||||
#[cfg(feature = "testing")]
|
||||
pub test_quic_transport: Option<iroh::endpoint::QuicTransportConfig>,
|
||||
/// Peers with no direct data path, for tests. See
|
||||
/// [`AgentConfig::with_unreachable_data_peers`].
|
||||
#[cfg(feature = "testing")]
|
||||
pub unreachable_data_peers: Arc<std::sync::Mutex<std::collections::HashSet<iroh::EndpointId>>>,
|
||||
/// Hostname announced to peers. `None` keeps whatever the state store holds,
|
||||
/// falling back to the OS hostname and finally to a short endpoint id.
|
||||
pub hostname: Option<String>,
|
||||
/// Discovery backend. `None` disables discovery-driven dialling; static
|
||||
/// bootstrap candidates still work.
|
||||
/// Additional discovery backend, composed with Mainline when it is enabled.
|
||||
pub discovery: Option<Arc<dyn NetworkDiscovery>>,
|
||||
/// How often each active network re-runs discovery and re-evaluates dials.
|
||||
/// Optional Mainline client, shared by this agent's networks. The CLI enables
|
||||
/// it by default except in local-only mode; library callers opt in explicitly.
|
||||
pub dht: Option<MainlineDiscovery>,
|
||||
/// Lookup, publication and recovery scheduling.
|
||||
pub discovery_policy: DiscoveryPolicy,
|
||||
/// How often each active network maintains known peers and observes address changes.
|
||||
pub discovery_interval: Duration,
|
||||
/// Bounds applied to network input.
|
||||
pub limits: Limits,
|
||||
@@ -237,6 +325,20 @@ pub struct AgentConfig {
|
||||
pub reconnect: ReconnectPolicy,
|
||||
/// IP plugins whose capabilities are announced and dispatched.
|
||||
pub plugins: Vec<SharedPlugin>,
|
||||
/// Custom packet transports registered per protocol.
|
||||
pub custom_transports:
|
||||
std::collections::HashMap<String, Arc<dyn crate::dataplane::PacketTransport>>,
|
||||
/// Where the overlay interface comes from, when the agent should have one.
|
||||
///
|
||||
/// One agent, one interface: it belongs here rather than to a protocol,
|
||||
/// because every protocol carries traffic for the same addresses on it.
|
||||
/// `None` means no interface — the protocols still run and their packets
|
||||
/// are discarded.
|
||||
pub tun_factory: Option<Arc<dyn crate::overlay::TunFactory>>,
|
||||
/// The name to ask the operating system for.
|
||||
pub interface_name: String,
|
||||
/// The interface MTU.
|
||||
pub interface_mtu: u32,
|
||||
/// The IPv4 overlay range this agent proposes.
|
||||
///
|
||||
/// Addresses are allocated from it and recorded in signed state, so a
|
||||
@@ -253,12 +355,24 @@ impl AgentConfig {
|
||||
paths,
|
||||
bind_addrs: Vec::new(),
|
||||
transport: TransportPolicy::default(),
|
||||
#[cfg(feature = "testing")]
|
||||
test_quic_transport: None,
|
||||
#[cfg(feature = "testing")]
|
||||
unreachable_data_peers: Arc::new(std::sync::Mutex::new(
|
||||
std::collections::HashSet::new(),
|
||||
)),
|
||||
hostname: None,
|
||||
discovery: None,
|
||||
dht: None,
|
||||
discovery_policy: DiscoveryPolicy::default(),
|
||||
discovery_interval: Duration::from_secs(5),
|
||||
limits: Limits::default(),
|
||||
reconnect: ReconnectPolicy::default(),
|
||||
plugins: Vec::new(),
|
||||
custom_transports: std::collections::HashMap::new(),
|
||||
tun_factory: None,
|
||||
interface_name: "tsun0".to_string(),
|
||||
interface_mtu: 1280,
|
||||
overlay_ipv4_range: Some(crate::state::DEFAULT_IPV4_RANGE),
|
||||
}
|
||||
}
|
||||
@@ -278,6 +392,21 @@ impl AgentConfig {
|
||||
self
|
||||
}
|
||||
|
||||
/// Peers this agent will not open or accept a **direct** data link with.
|
||||
///
|
||||
/// Test-only, and behind the `testing` feature so a release build has
|
||||
/// no such switch. The one arrangement that cannot be made on a single
|
||||
/// host is two agents that both reach a third and not each other,
|
||||
/// which is exactly the case a relay exists for.
|
||||
#[cfg(feature = "testing")]
|
||||
pub fn with_unreachable_data_peers(
|
||||
mut self,
|
||||
peers: Arc<std::sync::Mutex<std::collections::HashSet<iroh::EndpointId>>>,
|
||||
) -> Self {
|
||||
self.unreachable_data_peers = peers;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the transport policy.
|
||||
pub fn with_transport(mut self, transport: TransportPolicy) -> Self {
|
||||
self.transport = transport;
|
||||
@@ -290,7 +419,19 @@ impl AgentConfig {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets how often discovery runs.
|
||||
/// Adds Mainline discovery alongside the configured candidate backend.
|
||||
pub fn with_dht(mut self, dht: MainlineDiscovery) -> Self {
|
||||
self.dht = Some(dht);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets lookup, publication and isolation recovery timing.
|
||||
pub fn with_discovery_policy(mut self, policy: DiscoveryPolicy) -> Self {
|
||||
self.discovery_policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the known-peer maintenance and endpoint-address observation interval.
|
||||
pub fn with_discovery_interval(mut self, interval: Duration) -> Self {
|
||||
self.discovery_interval = interval;
|
||||
self
|
||||
@@ -314,6 +455,29 @@ impl AgentConfig {
|
||||
self
|
||||
}
|
||||
|
||||
/// Registers a custom packet transport for a specific protocol.
|
||||
pub fn with_custom_transport(
|
||||
mut self,
|
||||
protocol: impl Into<String>,
|
||||
transport: Arc<dyn crate::dataplane::PacketTransport>,
|
||||
) -> Self {
|
||||
self.custom_transports.insert(protocol.into(), transport);
|
||||
self
|
||||
}
|
||||
|
||||
/// Gives the agent an overlay interface, from this factory.
|
||||
pub fn with_interface(
|
||||
mut self,
|
||||
factory: Arc<dyn crate::overlay::TunFactory>,
|
||||
name: impl Into<String>,
|
||||
mtu: u32,
|
||||
) -> Self {
|
||||
self.tun_factory = Some(factory);
|
||||
self.interface_name = name.into();
|
||||
self.interface_mtu = mtu;
|
||||
self
|
||||
}
|
||||
|
||||
/// Replaces the limits.
|
||||
pub fn with_limits(mut self, limits: Limits) -> Self {
|
||||
self.limits = limits;
|
||||
@@ -335,6 +499,8 @@ impl std::fmt::Debug for AgentConfig {
|
||||
.field("transport", &self.transport)
|
||||
.field("hostname", &self.hostname)
|
||||
.field("discovery", &self.discovery.as_ref().map(|d| d.name()))
|
||||
.field("dht", &self.dht.is_some())
|
||||
.field("discovery_policy", &self.discovery_policy)
|
||||
.field("discovery_interval", &self.discovery_interval)
|
||||
.field("limits", &self.limits)
|
||||
.field("reconnect", &self.reconnect)
|
||||
@@ -349,3 +515,5 @@ impl std::fmt::Debug for AgentConfig {
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
/// Maximum number of transport links a routed datagram may traverse.
|
||||
pub const ROUTING_HOP_LIMIT: u8 = 16;
|
||||
@@ -19,8 +19,10 @@
|
||||
//! A data plane failure never stops the daemon: errors returned here are
|
||||
//! recorded and surfaced, the control plane keeps running.
|
||||
|
||||
pub mod relay;
|
||||
pub mod routing;
|
||||
pub mod transport;
|
||||
pub mod wireguard;
|
||||
pub mod tunnel;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -30,7 +32,10 @@ use tokio::sync::mpsc;
|
||||
use crate::BoxFuture;
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
pub use transport::{PacketLink, PacketTransport, SharedLink, TransportError};
|
||||
pub use transport::{
|
||||
MultiPacketTransport, PacketLink, PacketTransport, SharedLink, TransportError,
|
||||
};
|
||||
pub use tunnel::{GenericTunnelPlugin, TunnelCodec};
|
||||
|
||||
/// Maximum length of a plugin protocol identifier.
|
||||
pub const MAX_PROTOCOL_ID_LEN: usize = 32;
|
||||
@@ -42,7 +47,7 @@ pub const MAX_PROTOCOL_ID_LEN: usize = 32;
|
||||
/// chooses to do so must validate it itself.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PluginCapability {
|
||||
/// Protocol identifier, e.g. `wireguard`. Bounded by [`MAX_PROTOCOL_ID_LEN`].
|
||||
/// Protocol identifier, e.g. `wg-quic`. Bounded by [`MAX_PROTOCOL_ID_LEN`].
|
||||
pub protocol: String,
|
||||
/// Version of the plugin's announcement format.
|
||||
pub version: u16,
|
||||
@@ -52,6 +57,51 @@ pub struct PluginCapability {
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Where a protocol hands the packets it has decrypted.
|
||||
///
|
||||
/// A protocol proves *who* sent a packet; it does not know what that member
|
||||
/// is entitled to say, because entitlement is an address claim the system
|
||||
/// level holds. So a decrypted packet goes here rather than straight to an
|
||||
/// interface, and is checked on the way.
|
||||
pub trait PacketSink: Send + Sync + 'static {
|
||||
/// Hands over one packet, attributed to the peer whose tunnel decrypted
|
||||
/// it.
|
||||
fn deliver<'a>(
|
||||
&'a self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
packet: bytes::Bytes,
|
||||
) -> BoxFuture<'a, ()>;
|
||||
}
|
||||
|
||||
/// A sink that drops everything, for a protocol running without an interface.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct DiscardPackets;
|
||||
|
||||
impl PacketSink for DiscardPackets {
|
||||
fn deliver<'a>(
|
||||
&'a self,
|
||||
_network: NetworkId,
|
||||
_peer: EndpointId,
|
||||
_packet: bytes::Bytes,
|
||||
) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {})
|
||||
}
|
||||
}
|
||||
|
||||
/// One setting a protocol accepts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProtocolOption {
|
||||
/// The key, as written in `key=value`.
|
||||
pub key: &'static str,
|
||||
/// A word for the value, for the help line: `BYTES`, `NAME`.
|
||||
pub value: &'static str,
|
||||
/// What it does.
|
||||
pub help: &'static str,
|
||||
/// What happens when it is not given.
|
||||
pub default: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Errors a plugin may return. They are recorded, never fatal for the agent.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
@@ -69,7 +119,7 @@ pub enum PluginError {
|
||||
|
||||
/// A request a plugin makes of the agent that owns it.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum PluginRequest {
|
||||
pub enum PluginRequest {
|
||||
/// Re-send this agent's announcement to every peer of a network.
|
||||
Reannounce(NetworkId),
|
||||
/// Surface a plugin error on the agent's event stream.
|
||||
@@ -81,6 +131,17 @@ pub(crate) enum PluginRequest {
|
||||
/// Human readable reason, free of secrets.
|
||||
reason: String,
|
||||
},
|
||||
/// A plugin reported that a data link or tunnel for a peer failed.
|
||||
ProtocolFailure {
|
||||
/// Network the failure occurred in.
|
||||
network: NetworkId,
|
||||
/// Peer whose protocol failed.
|
||||
peer: EndpointId,
|
||||
/// Protocol that failed.
|
||||
protocol: String,
|
||||
/// Human readable reason.
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The agent-side handle a plugin is given when it is attached.
|
||||
@@ -95,13 +156,32 @@ pub(crate) enum PluginRequest {
|
||||
pub struct PluginContext {
|
||||
sender: Option<mpsc::Sender<PluginRequest>>,
|
||||
local: Option<EndpointId>,
|
||||
/// Where decrypted packets go. Absent when nothing is carrying traffic,
|
||||
/// in which case a protocol still runs and its packets are discarded.
|
||||
sink: Option<Arc<dyn PacketSink>>,
|
||||
}
|
||||
|
||||
impl PluginContext {
|
||||
pub(crate) fn new(sender: mpsc::Sender<PluginRequest>, local: EndpointId) -> Self {
|
||||
pub(crate) fn new(
|
||||
sender: mpsc::Sender<PluginRequest>,
|
||||
local: EndpointId,
|
||||
sink: Option<Arc<dyn PacketSink>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
sender: Some(sender),
|
||||
local: Some(local),
|
||||
sink,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where to hand a decrypted packet.
|
||||
///
|
||||
/// Always answers: with no interface to write to, the packets are
|
||||
/// discarded, which is what `--no-tun` means and is not an error.
|
||||
pub fn packet_sink(&self) -> Arc<dyn PacketSink> {
|
||||
match &self.sink {
|
||||
Some(sink) => Arc::clone(sink),
|
||||
None => Arc::new(DiscardPackets),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +190,30 @@ impl PluginContext {
|
||||
Self {
|
||||
sender: None,
|
||||
local: None,
|
||||
sink: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A context configured with a custom packet sink.
|
||||
pub fn with_sink(sink: Arc<dyn PacketSink>) -> Self {
|
||||
Self {
|
||||
sender: None,
|
||||
local: None,
|
||||
sink: Some(sink),
|
||||
}
|
||||
}
|
||||
|
||||
/// A context configured with a channel for plugin requests and custom sink, for tests.
|
||||
#[cfg(feature = "testing")]
|
||||
pub fn for_test(
|
||||
sender: mpsc::Sender<PluginRequest>,
|
||||
local: EndpointId,
|
||||
sink: Option<Arc<dyn PacketSink>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
sender: Some(sender),
|
||||
local: Some(local),
|
||||
sink,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +258,22 @@ impl PluginContext {
|
||||
reason: reason.into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Reports that data plane connectivity or a tunnel for a peer has failed.
|
||||
pub fn report_peer_protocol_failure(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
protocol: impl Into<String>,
|
||||
reason: impl Into<String>,
|
||||
) {
|
||||
self.send(PluginRequest::ProtocolFailure {
|
||||
network,
|
||||
peer,
|
||||
protocol: protocol.into(),
|
||||
reason: reason.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PluginContext {
|
||||
@@ -170,11 +290,34 @@ impl std::fmt::Debug for PluginContext {
|
||||
/// Implementations must be cheap and non-blocking: the agent calls them from
|
||||
/// its runtime tasks. Anything slow belongs in the plugin's own tasks.
|
||||
pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// Stable protocol identifier, e.g. `wireguard`.
|
||||
/// Stable protocol identifier, e.g. `wg-quic`.
|
||||
///
|
||||
/// Must be non-empty and at most [`MAX_PROTOCOL_ID_LEN`] bytes.
|
||||
fn protocol_id(&self) -> &str;
|
||||
|
||||
/// The wire version of this protocol.
|
||||
///
|
||||
/// Two peers carry traffic for each other only when they have the same
|
||||
/// protocol at the same version. There is no negotiating a middle
|
||||
/// ground: a protocol either speaks the same words at both ends or it
|
||||
/// does not, and pretending otherwise produces a link that fails later
|
||||
/// and less clearly.
|
||||
///
|
||||
/// **Not the software version.** A plugin crate has its own version and
|
||||
/// it is nobody else's business: two peers on different builds work
|
||||
/// together for as long as the wire between them has not changed. This
|
||||
/// number moves only when the bytes do, so it must never be derived from
|
||||
/// `CARGO_PKG_VERSION` or anything else that moves with a release.
|
||||
fn protocol_version(&self) -> u16;
|
||||
|
||||
/// The settings this protocol accepts, and what they mean.
|
||||
///
|
||||
/// Declared rather than documented elsewhere, so the agent can list them
|
||||
/// without knowing anything about the protocol.
|
||||
fn options(&self) -> &'static [ProtocolOption] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Called once, when the agent starts, before any network is activated.
|
||||
///
|
||||
/// The plugin keeps the context to ask for re-announcements and to report
|
||||
@@ -224,6 +367,20 @@ pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
|
||||
let _ = (network, range, allocations);
|
||||
}
|
||||
|
||||
/// Carries one packet to a peer, encrypting it however this protocol
|
||||
/// does.
|
||||
///
|
||||
/// `false` when it cannot right now — no link, no tunnel, not this
|
||||
/// protocol's peer — which the caller reports rather than treats as an
|
||||
/// error. The packet came off the one interface the agent owns, and
|
||||
/// which protocol takes it is settled by asking.
|
||||
///
|
||||
/// The default carries nothing, which is right for a plugin that only
|
||||
/// announces something.
|
||||
fn carry(&self, _network: NetworkId, _peer: EndpointId, _packet: bytes::Bytes) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// A data plane link to a peer is available for this plugin's protocol.
|
||||
///
|
||||
/// The plugin moves its packets over this link and never learns how the
|
||||
@@ -243,6 +400,19 @@ pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// The plugin is expected to remove whatever it created for that network.
|
||||
fn on_network_deactivated(&self, network: NetworkId);
|
||||
|
||||
/// Called when this agent has left a network for good.
|
||||
///
|
||||
/// Deactivation is temporary and keeps everything ready for next time;
|
||||
/// this is the other one. Whatever the plugin holds *durably* for that
|
||||
/// network — a key of its own, a file, a record — goes now, because the
|
||||
/// agent is no longer a member and keeping it is keeping a secret for a
|
||||
/// network it cannot rejoin without being told the secret again.
|
||||
///
|
||||
/// Always preceded by [`IpPlugin::on_network_deactivated`], so this is
|
||||
/// only about what outlives a session. Defaulted to nothing, for a
|
||||
/// plugin that stores nothing.
|
||||
fn on_network_forgotten(&self, _network: NetworkId) {}
|
||||
|
||||
/// Called once when the agent shuts down.
|
||||
///
|
||||
/// The plugin removes the system objects it created and stops its tasks.
|
||||
@@ -290,6 +460,10 @@ impl IpPlugin for TestCapabilityPlugin {
|
||||
&self.protocol
|
||||
}
|
||||
|
||||
fn protocol_version(&self) -> u16 {
|
||||
1
|
||||
}
|
||||
|
||||
fn local_capability(
|
||||
&self,
|
||||
_network: NetworkId,
|
||||
@@ -0,0 +1,506 @@
|
||||
//! Userspace forwarding. Topology updates publish immutable tables; the packet
|
||||
//! path takes no routing mutex, walks no graph and never inspects plugin bytes.
|
||||
//! Each transport reader forwards transit immediately, independently of the
|
||||
//! destination protocol's receive task. Only local delivery enters its inbox.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use tokio::sync::{Notify, mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use super::routing::{FlowId, PeerId, RoutingTable, envelope, mix64};
|
||||
use super::transport::{PacketLink, SharedLink, TransportError};
|
||||
use crate::BoxFuture;
|
||||
use crate::config::{MAX_DATA_DATAGRAM, ROUTING_HOP_LIMIT};
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
/// Fixed routing header, subtracted from the logical transport MTU.
|
||||
pub const RELAY_OVERHEAD: usize = envelope::HEADER;
|
||||
const INBOX: usize = 256;
|
||||
|
||||
/// Forwarding counters, shared by all protocols of one network.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct RelayCounters {
|
||||
/// Datagrams originated here and sent through another member.
|
||||
pub sent_via: u64,
|
||||
/// Transit datagrams handed to the next transport link.
|
||||
pub forwarded: u64,
|
||||
/// Local datagrams received through another member.
|
||||
pub received_via: u64,
|
||||
/// Missing or failed next hop.
|
||||
pub dropped_no_link: u64,
|
||||
/// Malformed envelope or unknown member.
|
||||
pub dropped_unknown: u64,
|
||||
/// Exhausted hop limit, including loops during topology convergence.
|
||||
pub dropped_hop_limit: u64,
|
||||
/// Full local protocol inbox. Transit does not use this queue.
|
||||
pub dropped_congested: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Tally {
|
||||
sent_via: AtomicU64,
|
||||
forwarded: AtomicU64,
|
||||
received_via: AtomicU64,
|
||||
dropped_no_link: AtomicU64,
|
||||
dropped_unknown: AtomicU64,
|
||||
dropped_hop_limit: AtomicU64,
|
||||
dropped_congested: AtomicU64,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
fn snapshot(&self) -> RelayCounters {
|
||||
RelayCounters {
|
||||
sent_via: self.sent_via.load(Ordering::Relaxed),
|
||||
forwarded: self.forwarded.load(Ordering::Relaxed),
|
||||
received_via: self.received_via.load(Ordering::Relaxed),
|
||||
dropped_no_link: self.dropped_no_link.load(Ordering::Relaxed),
|
||||
dropped_unknown: self.dropped_unknown.load(Ordering::Relaxed),
|
||||
dropped_hop_limit: self.dropped_hop_limit.load(Ordering::Relaxed),
|
||||
dropped_congested: self.dropped_congested.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NextHop {
|
||||
peer: PeerId,
|
||||
link: SharedLink,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ForwardRoute {
|
||||
hops: u8,
|
||||
next: Box<[NextHop]>,
|
||||
}
|
||||
|
||||
impl ForwardRoute {
|
||||
fn select(&self, source: &PeerId, flow: FlowId) -> Option<&NextHop> {
|
||||
if self.next.len() == 1 {
|
||||
return self.next.first().filter(|hop| !hop.link.is_closed());
|
||||
}
|
||||
let seed = u64::from_le_bytes(source[..8].try_into().ok()?);
|
||||
let start = mix64(flow ^ seed) as usize % self.next.len();
|
||||
// Stable fallback if a link closed just before its table was replaced.
|
||||
(0..self.next.len())
|
||||
.map(|i| &self.next[(start + i) % self.next.len()])
|
||||
.find(|hop| !hop.link.is_closed())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ForwardingTable {
|
||||
routes: HashMap<PeerId, ForwardRoute>,
|
||||
inboxes: HashMap<PeerId, mpsc::Sender<Bytes>>,
|
||||
members: HashSet<PeerId>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Plane {
|
||||
local: PeerId,
|
||||
table: ArcSwap<ForwardingTable>,
|
||||
tally: Arc<Tally>,
|
||||
}
|
||||
|
||||
impl Plane {
|
||||
/// Synchronous hot path: one snapshot, bounded header decode, table lookup,
|
||||
/// and transport send. An exclusively owned buffer is modified in place.
|
||||
fn receive(&self, incoming: PeerId, frame: Bytes) {
|
||||
let Some(header) = envelope::decode(&frame) else {
|
||||
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
};
|
||||
let table = self.table.load();
|
||||
if !table.members.contains(&header.source) || header.source == self.local {
|
||||
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
if header.destination == self.local {
|
||||
let Some(inbox) = table.inboxes.get(&header.source) else {
|
||||
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
};
|
||||
if inbox.try_send(frame.slice(RELAY_OVERHEAD..)).is_err() {
|
||||
self.tally.dropped_congested.fetch_add(1, Ordering::Relaxed);
|
||||
} else if incoming != header.source {
|
||||
self.tally.received_via.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if header.remaining <= 1 {
|
||||
self.tally.dropped_hop_limit.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
let hop = table
|
||||
.routes
|
||||
.get(&header.destination)
|
||||
.and_then(|route| route.select(&header.source, header.flow));
|
||||
let Some(hop) = hop else {
|
||||
self.tally.dropped_no_link.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
};
|
||||
if hop.link.send(envelope::decrement(frame)).is_ok() {
|
||||
self.tally.forwarded.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
self.tally.dropped_no_link.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Protocol {
|
||||
plane: Arc<Plane>,
|
||||
graph: HashMap<PeerId, Vec<PeerId>>,
|
||||
members: HashSet<PeerId>,
|
||||
raw: HashMap<PeerId, SharedLink>,
|
||||
readers: HashMap<PeerId, JoinHandle<()>>,
|
||||
links: HashMap<PeerId, Arc<PeerLink>>,
|
||||
}
|
||||
|
||||
impl Protocol {
|
||||
fn publish(&self) {
|
||||
let mut graph = self.graph.clone();
|
||||
graph.insert(
|
||||
self.plane.local,
|
||||
self.raw
|
||||
.iter()
|
||||
.filter(|(_, link)| !link.is_closed())
|
||||
.map(|(peer, _)| *peer)
|
||||
.collect(),
|
||||
);
|
||||
let routing = RoutingTable::build(self.plane.local, &graph, ROUTING_HOP_LIMIT);
|
||||
let routes = routing
|
||||
.iter()
|
||||
.filter_map(|(peer, route)| {
|
||||
let next: Box<[_]> = route
|
||||
.next_hops
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
self.raw.get(id).map(|link| NextHop {
|
||||
peer: *id,
|
||||
link: link.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(!next.is_empty()).then_some((
|
||||
*peer,
|
||||
ForwardRoute {
|
||||
hops: route.hops,
|
||||
next,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let mut members = self.members.clone();
|
||||
members.extend(self.raw.keys().copied());
|
||||
self.plane.table.store(Arc::new(ForwardingTable {
|
||||
routes,
|
||||
members,
|
||||
inboxes: self
|
||||
.links
|
||||
.iter()
|
||||
.map(|(peer, link)| (*peer, link.inbox_tx.clone()))
|
||||
.collect(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Protocol {
|
||||
fn drop(&mut self) {
|
||||
for task in self.readers.values() {
|
||||
task.abort();
|
||||
}
|
||||
for link in self.links.values() {
|
||||
link.close();
|
||||
}
|
||||
self.plane.table.store(Arc::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// One network's transport links and independently published protocol tables.
|
||||
#[derive(Debug)]
|
||||
pub struct RelayHub {
|
||||
network: NetworkId,
|
||||
local: PeerId,
|
||||
protocols: Mutex<HashMap<String, Protocol>>,
|
||||
tally: Arc<Tally>,
|
||||
changed: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl RelayHub {
|
||||
/// Creates the router at this network's local endpoint.
|
||||
pub fn new(network: NetworkId, local: EndpointId) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
network,
|
||||
local: *local.as_bytes(),
|
||||
protocols: Mutex::default(),
|
||||
tally: Arc::default(),
|
||||
changed: Arc::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn protocols(&self) -> std::sync::MutexGuard<'_, HashMap<String, Protocol>> {
|
||||
self.protocols
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
|
||||
fn protocol<'a>(
|
||||
&self,
|
||||
protocols: &'a mut HashMap<String, Protocol>,
|
||||
name: &str,
|
||||
) -> &'a mut Protocol {
|
||||
protocols
|
||||
.entry(name.to_owned())
|
||||
.or_insert_with(|| Protocol {
|
||||
plane: Arc::new(Plane {
|
||||
local: self.local,
|
||||
table: ArcSwap::from_pointee(ForwardingTable::default()),
|
||||
tally: self.tally.clone(),
|
||||
}),
|
||||
graph: HashMap::new(),
|
||||
members: HashSet::new(),
|
||||
raw: HashMap::new(),
|
||||
readers: HashMap::new(),
|
||||
links: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Wakes the control loop promptly when a transport reader ends.
|
||||
pub async fn changed(&self) {
|
||||
self.changed.notified().await;
|
||||
}
|
||||
|
||||
/// Current counters.
|
||||
pub fn counters(&self) -> RelayCounters {
|
||||
self.tally.snapshot()
|
||||
}
|
||||
|
||||
/// Publishes authenticated topology. Refreshing identical state is cheap.
|
||||
pub fn set_topology(
|
||||
&self,
|
||||
protocol: &str,
|
||||
graph: HashMap<PeerId, Vec<PeerId>>,
|
||||
members: HashSet<PeerId>,
|
||||
) {
|
||||
let mut protocols = self.protocols();
|
||||
let state = self.protocol(&mut protocols, protocol);
|
||||
if state.graph != graph || state.members != members {
|
||||
state.graph = graph;
|
||||
state.members = members;
|
||||
state.publish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the current table has a path to this destination.
|
||||
pub fn reachable(&self, peer: EndpointId, protocol: &str) -> bool {
|
||||
self.protocols().get(protocol).is_some_and(|state| {
|
||||
state
|
||||
.plane
|
||||
.table
|
||||
.load()
|
||||
.routes
|
||||
.contains_key(peer.as_bytes())
|
||||
})
|
||||
}
|
||||
|
||||
/// Stable logical link, preserved across topology changes.
|
||||
pub fn link(&self, peer: EndpointId, protocol: &str) -> Arc<PeerLink> {
|
||||
let mut protocols = self.protocols();
|
||||
let state = self.protocol(&mut protocols, protocol);
|
||||
if let Some(link) = state.links.get(peer.as_bytes()) {
|
||||
return link.clone();
|
||||
}
|
||||
let (inbox_tx, inbox_rx) = mpsc::channel(INBOX);
|
||||
let (closed, _) = watch::channel(false);
|
||||
let link = Arc::new(PeerLink {
|
||||
network: self.network,
|
||||
peer,
|
||||
plane: state.plane.clone(),
|
||||
inbox_tx,
|
||||
inbox_rx: tokio::sync::Mutex::new(inbox_rx),
|
||||
closed,
|
||||
});
|
||||
state.links.insert(*peer.as_bytes(), link.clone());
|
||||
state.publish();
|
||||
link
|
||||
}
|
||||
|
||||
/// Whether the protocol already owns its logical link.
|
||||
pub fn has_link(&self, peer: EndpointId, protocol: &str) -> bool {
|
||||
self.protocols()
|
||||
.get(protocol)
|
||||
.is_some_and(|state| state.links.contains_key(peer.as_bytes()))
|
||||
}
|
||||
|
||||
/// Installs a raw transport link and starts its independent ingress reader.
|
||||
pub fn set_direct(&self, peer: EndpointId, protocol: &str, raw: SharedLink) {
|
||||
if raw.network() != self.network || raw.peer() != peer {
|
||||
return;
|
||||
}
|
||||
let mut protocols = self.protocols();
|
||||
let state = self.protocol(&mut protocols, protocol);
|
||||
let id = *peer.as_bytes();
|
||||
if let Some(old) = state.readers.remove(&id) {
|
||||
old.abort();
|
||||
}
|
||||
state.raw.insert(id, raw.clone());
|
||||
state.publish();
|
||||
let plane = state.plane.clone();
|
||||
let changed = self.changed.clone();
|
||||
state.readers.insert(
|
||||
id,
|
||||
tokio::spawn(async move {
|
||||
let mut batch = 0;
|
||||
while let Some(frame) = raw.recv().await {
|
||||
plane.receive(id, frame);
|
||||
batch += 1;
|
||||
if batch == 64 {
|
||||
tokio::task::yield_now().await;
|
||||
batch = 0;
|
||||
}
|
||||
}
|
||||
changed.notify_one();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes a physical link without tearing down end-to-end protocol state.
|
||||
pub fn clear_direct(&self, peer: EndpointId, protocol: &str) {
|
||||
if let Some(state) = self.protocols().get_mut(protocol) {
|
||||
state.raw.remove(peer.as_bytes());
|
||||
if let Some(task) = state.readers.remove(peer.as_bytes()) {
|
||||
task.abort();
|
||||
}
|
||||
state.publish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Revokes all links and topology involving a departed authenticated peer.
|
||||
pub fn remove_peer(&self, peer: EndpointId) {
|
||||
for state in self.protocols().values_mut() {
|
||||
state.raw.remove(peer.as_bytes());
|
||||
if let Some(task) = state.readers.remove(peer.as_bytes()) {
|
||||
task.abort();
|
||||
}
|
||||
if let Some(link) = state.links.remove(peer.as_bytes()) {
|
||||
link.close();
|
||||
}
|
||||
state.members.remove(peer.as_bytes());
|
||||
state.graph.remove(peer.as_bytes());
|
||||
for neighbors in state.graph.values_mut() {
|
||||
neighbors.retain(|id| id != peer.as_bytes());
|
||||
}
|
||||
state.publish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops every reader and releases all transport handles.
|
||||
pub fn close(&self) {
|
||||
self.protocols().clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// A protocol's end-to-end channel, independent of its current next hop.
|
||||
#[derive(Debug)]
|
||||
pub struct PeerLink {
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
plane: Arc<Plane>,
|
||||
inbox_tx: mpsc::Sender<Bytes>,
|
||||
inbox_rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
|
||||
closed: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl PeerLink {
|
||||
fn close(&self) {
|
||||
self.closed.send_replace(true);
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketLink for PeerLink {
|
||||
fn network(&self) -> NetworkId {
|
||||
self.network
|
||||
}
|
||||
fn peer(&self) -> EndpointId {
|
||||
self.peer
|
||||
}
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
MAX_DATA_DATAGRAM - RELAY_OVERHEAD
|
||||
}
|
||||
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
|
||||
self.send_flow(payload, 0)
|
||||
}
|
||||
fn send_flow(&self, payload: Bytes, flow: FlowId) -> Result<(), TransportError> {
|
||||
if self.is_closed() {
|
||||
return Err(TransportError::Closed);
|
||||
}
|
||||
if payload.len() > self.max_datagram_size() {
|
||||
return Err(TransportError::TooLarge {
|
||||
size: payload.len(),
|
||||
limit: self.max_datagram_size(),
|
||||
});
|
||||
}
|
||||
let table = self.plane.table.load();
|
||||
let route = table
|
||||
.routes
|
||||
.get(self.peer.as_bytes())
|
||||
.ok_or(TransportError::Closed)?;
|
||||
let hop = route
|
||||
.select(&self.plane.local, flow)
|
||||
.ok_or(TransportError::Closed)?;
|
||||
hop.link.send(envelope::encode(
|
||||
self.plane.local,
|
||||
*self.peer.as_bytes(),
|
||||
flow,
|
||||
&payload,
|
||||
))?;
|
||||
if route.hops > 1 {
|
||||
self.plane.tally.sent_via.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move {
|
||||
let mut closed = self.closed.subscribe();
|
||||
if *closed.borrow_and_update() {
|
||||
return None;
|
||||
}
|
||||
let mut inbox = self.inbox_rx.lock().await;
|
||||
tokio::select! { biased; _ = closed.changed() => None, frame = inbox.recv() => frame }
|
||||
})
|
||||
}
|
||||
fn closed(&self) -> BoxFuture<'_, ()> {
|
||||
Box::pin(async move {
|
||||
let mut closed = self.closed.subscribe();
|
||||
if !*closed.borrow_and_update() {
|
||||
let _ = closed.changed().await;
|
||||
}
|
||||
})
|
||||
}
|
||||
fn is_closed(&self) -> bool {
|
||||
*self.closed.borrow()
|
||||
}
|
||||
fn path_description(&self) -> String {
|
||||
let table = self.plane.table.load();
|
||||
match table.routes.get(self.peer.as_bytes()) {
|
||||
Some(route) if route.hops == 1 => route.next[0].link.path_description(),
|
||||
Some(route) => format!(
|
||||
"relay {} hops via {} ({} equal paths)",
|
||||
route.hops,
|
||||
hex::encode(route.next[0].peer),
|
||||
route.next.len()
|
||||
),
|
||||
None => "unreachable".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "relay_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,287 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
fn network() -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new("routing-tests").unwrap(),
|
||||
&NetworkSecret::from_bytes([3; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
fn peer(seed: u8) -> EndpointId {
|
||||
iroh::SecretKey::from_bytes(&[seed; 32]).public()
|
||||
}
|
||||
fn id(seed: u8) -> PeerId {
|
||||
*peer(seed).as_bytes()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Wire {
|
||||
peer: EndpointId,
|
||||
sent: Mutex<Vec<Bytes>>,
|
||||
feed: mpsc::Sender<Bytes>,
|
||||
inbound: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
|
||||
dead: AtomicBool,
|
||||
count: AtomicU64,
|
||||
record: bool,
|
||||
}
|
||||
impl Wire {
|
||||
fn new(seed: u8, record: bool) -> Arc<Self> {
|
||||
let (feed, inbound) = mpsc::channel(32);
|
||||
Arc::new(Self {
|
||||
peer: peer(seed),
|
||||
sent: Mutex::default(),
|
||||
feed,
|
||||
inbound: tokio::sync::Mutex::new(inbound),
|
||||
dead: AtomicBool::new(false),
|
||||
count: AtomicU64::new(0),
|
||||
record,
|
||||
})
|
||||
}
|
||||
fn take(&self) -> Vec<Bytes> {
|
||||
std::mem::take(&mut self.sent.lock().unwrap())
|
||||
}
|
||||
}
|
||||
impl PacketLink for Wire {
|
||||
fn network(&self) -> NetworkId {
|
||||
network()
|
||||
}
|
||||
fn peer(&self) -> EndpointId {
|
||||
self.peer
|
||||
}
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
MAX_DATA_DATAGRAM
|
||||
}
|
||||
fn send(&self, frame: Bytes) -> Result<(), TransportError> {
|
||||
if self.is_closed() {
|
||||
return Err(TransportError::Closed);
|
||||
}
|
||||
self.count.fetch_add(1, Ordering::Relaxed);
|
||||
if self.record {
|
||||
self.sent.lock().unwrap().push(frame);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move { self.inbound.lock().await.recv().await })
|
||||
}
|
||||
fn closed(&self) -> BoxFuture<'_, ()> {
|
||||
Box::pin(std::future::pending())
|
||||
}
|
||||
fn is_closed(&self) -> bool {
|
||||
self.dead.load(Ordering::Relaxed)
|
||||
}
|
||||
fn path_description(&self) -> String {
|
||||
"test-direct".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn topology(hub: &RelayHub, protocol: &str, edges: &[(u8, &[u8])], members: &[u8]) {
|
||||
hub.set_topology(
|
||||
protocol,
|
||||
edges
|
||||
.iter()
|
||||
.map(|(a, bs)| (id(*a), bs.iter().map(|b| id(*b)).collect()))
|
||||
.collect(),
|
||||
members.iter().map(|p| id(*p)).collect(),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ecmp_is_per_flow_direct_wins_and_existing_tunnel_survives_changes() {
|
||||
let hub = RelayHub::new(network(), peer(1));
|
||||
let b = Wire::new(2, true);
|
||||
let c = Wire::new(3, true);
|
||||
hub.set_direct(peer(2), "ip", b.clone());
|
||||
hub.set_direct(peer(3), "ip", c.clone());
|
||||
topology(&hub, "ip", &[(2, &[4]), (3, &[4])], &[1, 2, 3, 4]);
|
||||
let link = hub.link(peer(4), "ip");
|
||||
let mut chosen = HashMap::new();
|
||||
for _ in 0..8 {
|
||||
for flow in 0..100 {
|
||||
link.send_flow(Bytes::from_static(b"ciphertext"), flow)
|
||||
.unwrap();
|
||||
}
|
||||
for (hop, wire) in [(2, &b), (3, &c)] {
|
||||
for frame in wire.take() {
|
||||
let header = envelope::decode(&frame).unwrap();
|
||||
assert_eq!(header.source, id(1));
|
||||
assert_eq!(header.destination, id(4));
|
||||
if let Some(old) = chosen.insert(header.flow, hop) {
|
||||
assert_eq!(old, hop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
chosen.values().copied().collect::<HashSet<_>>(),
|
||||
HashSet::from([2, 3])
|
||||
);
|
||||
let direct = Wire::new(4, true);
|
||||
hub.set_direct(peer(4), "ip", direct.clone());
|
||||
link.send_flow(Bytes::from_static(b"direct"), 8).unwrap();
|
||||
assert_eq!(direct.take().len(), 1);
|
||||
assert!(b.take().is_empty() && c.take().is_empty());
|
||||
hub.clear_direct(peer(4), "ip");
|
||||
b.dead.store(true, Ordering::Relaxed);
|
||||
for flow in 0..100 {
|
||||
link.send_flow(Bytes::from_static(b"fallback"), flow)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(c.take().len(), 100);
|
||||
hub.clear_direct(peer(2), "ip");
|
||||
topology(&hub, "ip", &[(3, &[])], &[1, 2, 3, 4]);
|
||||
assert!(link.send(Bytes::new()).is_err());
|
||||
assert!(!link.is_closed());
|
||||
assert!(Arc::ptr_eq(&link, &hub.link(peer(4), "ip")));
|
||||
hub.remove_peer(peer(4));
|
||||
assert!(link.is_closed());
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), link.closed())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(link.recv().await.is_none());
|
||||
hub.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_reader_forwards_without_any_plugin_reader_and_isolates_protocols() {
|
||||
let hub = RelayHub::new(network(), peer(2));
|
||||
let a = Wire::new(1, true);
|
||||
let c = Wire::new(3, true);
|
||||
hub.set_direct(peer(1), "ip", a.clone());
|
||||
hub.set_direct(peer(3), "ip", c.clone());
|
||||
topology(&hub, "ip", &[(3, &[4])], &[1, 2, 3, 4]);
|
||||
let frame = envelope::encode(id(1), id(4), 91, b"end-to-end encrypted");
|
||||
let pointer = frame.as_ptr();
|
||||
a.feed.send(frame).await.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
while hub.counters().forwarded == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let sent = c.take();
|
||||
assert_eq!(sent[0].as_ptr(), pointer);
|
||||
assert_eq!(
|
||||
envelope::decode(&sent[0]).unwrap().remaining,
|
||||
ROUTING_HOP_LIMIT - 1
|
||||
);
|
||||
assert_eq!(&sent[0][RELAY_OVERHEAD..], b"end-to-end encrypted");
|
||||
let isolated = hub.link(peer(4), "other-ip");
|
||||
assert!(isolated.send(Bytes::new()).is_err());
|
||||
assert_eq!(hub.counters().forwarded, 1);
|
||||
hub.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_delivery_is_bounded_and_malformed_or_unknown_sources_are_dropped() {
|
||||
let hub = RelayHub::new(network(), peer(2));
|
||||
let a = Wire::new(1, true);
|
||||
hub.set_direct(peer(1), "ip", a);
|
||||
let link = hub.link(peer(1), "ip");
|
||||
let plane = link.plane.clone();
|
||||
for _ in 0..INBOX + 1 {
|
||||
plane.receive(id(1), envelope::encode(id(1), id(2), 0, b"ip"));
|
||||
}
|
||||
assert_eq!(hub.counters().dropped_congested, 1);
|
||||
for _ in 0..INBOX {
|
||||
assert_eq!(link.recv().await.unwrap(), Bytes::from_static(b"ip"));
|
||||
}
|
||||
plane.receive(id(1), Bytes::from_static(b"broken"));
|
||||
plane.receive(id(1), envelope::encode(id(9), id(2), 0, b"unknown member"));
|
||||
assert_eq!(hub.counters().dropped_unknown, 2);
|
||||
plane.receive(
|
||||
id(1),
|
||||
envelope::encode(id(1), id(9), 0, b"unknown destination"),
|
||||
);
|
||||
assert_eq!(hub.counters().dropped_no_link, 1);
|
||||
hub.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inconsistent_tables_cannot_loop_beyond_the_hop_limit() {
|
||||
let a = RelayHub::new(network(), peer(1));
|
||||
let b = RelayHub::new(network(), peer(2));
|
||||
let ab = Wire::new(2, true);
|
||||
let ba = Wire::new(1, true);
|
||||
a.set_direct(peer(2), "ip", ab.clone());
|
||||
b.set_direct(peer(1), "ip", ba.clone());
|
||||
// Deliberately inconsistent snapshots while updates are in flight.
|
||||
topology(&a, "ip", &[(2, &[4])], &[1, 2, 3, 4]);
|
||||
topology(&b, "ip", &[(1, &[4])], &[1, 2, 3, 4]);
|
||||
let ap = a.link(peer(4), "ip").plane.clone();
|
||||
let bp = b.link(peer(4), "ip").plane.clone();
|
||||
let mut frame = envelope::encode(id(3), id(4), 7, b"transit");
|
||||
for turn in 0..ROUTING_HOP_LIMIT {
|
||||
let (plane, incoming, wire) = if turn % 2 == 0 {
|
||||
(&ap, id(2), &ab)
|
||||
} else {
|
||||
(&bp, id(1), &ba)
|
||||
};
|
||||
plane.receive(incoming, frame);
|
||||
let sent = wire.take();
|
||||
if turn == ROUTING_HOP_LIMIT - 1 {
|
||||
assert!(sent.is_empty());
|
||||
break;
|
||||
}
|
||||
assert_eq!(sent.len(), 1);
|
||||
frame = sent.into_iter().next().unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
a.counters().forwarded + b.counters().forwarded,
|
||||
u64::from(ROUTING_HOP_LIMIT - 1)
|
||||
);
|
||||
assert_eq!(
|
||||
a.counters().dropped_hop_limit + b.counters().dropped_hop_limit,
|
||||
1
|
||||
);
|
||||
a.close();
|
||||
b.close();
|
||||
}
|
||||
|
||||
/// Measures the actual synchronous transit routine, including header checks,
|
||||
/// snapshot lookup, ECMP, in-place TTL update and mock transport submission.
|
||||
/// Packet construction, encryption and socket I/O are outside this measurement.
|
||||
#[tokio::test]
|
||||
#[ignore = "manual release-mode forwarding microbenchmark"]
|
||||
async fn forwarding_benchmark() {
|
||||
let hub = RelayHub::new(network(), peer(2));
|
||||
let wire = Wire::new(3, false);
|
||||
hub.set_direct(peer(3), "ip", wire.clone());
|
||||
let alternate = Wire::new(5, false);
|
||||
hub.set_direct(peer(5), "ip", alternate.clone());
|
||||
topology(&hub, "ip", &[(3, &[4]), (5, &[4])], &[1, 2, 3, 4, 5]);
|
||||
let plane = hub.link(peer(4), "ip").plane.clone();
|
||||
let source = id(1);
|
||||
let destination = id(4);
|
||||
let incoming = id(1);
|
||||
for size in [64, 1280] {
|
||||
let mut elapsed = std::time::Duration::ZERO;
|
||||
const COUNT: usize = 10000;
|
||||
const BATCHES: usize = 100;
|
||||
for _ in 0..BATCHES {
|
||||
let frames: Vec<_> = (0..COUNT)
|
||||
.map(|flow| envelope::encode(source, destination, flow as u64, &vec![42; size]))
|
||||
.collect();
|
||||
let start = std::time::Instant::now();
|
||||
for frame in frames {
|
||||
plane.receive(incoming, std::hint::black_box(frame));
|
||||
}
|
||||
elapsed += start.elapsed();
|
||||
}
|
||||
let count = (COUNT * BATCHES) as f64;
|
||||
println!(
|
||||
"transit {size} B: {:.1} ns/packet, {:.2} Mpps (1M packets, two equal paths)",
|
||||
elapsed.as_nanos() as f64 / count,
|
||||
count / elapsed.as_secs_f64() / 1e6
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
wire.count.load(Ordering::Relaxed) + alternate.count.load(Ordering::Relaxed),
|
||||
2000000
|
||||
);
|
||||
hub.close();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Fixed header. Transit changes one byte in an owned receive buffer.
|
||||
use super::{FlowId, PeerId};
|
||||
use crate::config::{MAX_DATA_DATAGRAM, ROUTING_HOP_LIMIT};
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
|
||||
pub(crate) const HEADER: usize = 2 + 32 + 32 + 8;
|
||||
const VERSION: u8 = 1;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Header {
|
||||
pub source: PeerId,
|
||||
pub destination: PeerId,
|
||||
pub remaining: u8,
|
||||
pub flow: FlowId,
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8]) -> Option<Header> {
|
||||
if bytes.len() < HEADER
|
||||
|| bytes.len() > MAX_DATA_DATAGRAM
|
||||
|| bytes[0] != VERSION
|
||||
|| bytes[1] == 0
|
||||
|| bytes[1] > ROUTING_HOP_LIMIT
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Header {
|
||||
source: bytes[2..34].try_into().ok()?,
|
||||
destination: bytes[34..66].try_into().ok()?,
|
||||
remaining: bytes[1],
|
||||
flow: u64::from_be_bytes(bytes[66..74].try_into().ok()?),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn encode(source: PeerId, destination: PeerId, flow: FlowId, payload: &[u8]) -> Bytes {
|
||||
let mut bytes = BytesMut::with_capacity(HEADER + payload.len());
|
||||
bytes.put_u8(VERSION);
|
||||
bytes.put_u8(ROUTING_HOP_LIMIT);
|
||||
bytes.extend_from_slice(&source);
|
||||
bytes.extend_from_slice(&destination);
|
||||
bytes.put_u64(flow);
|
||||
bytes.extend_from_slice(payload);
|
||||
bytes.freeze()
|
||||
}
|
||||
|
||||
pub(crate) fn decrement(bytes: Bytes) -> Bytes {
|
||||
let mut bytes = match bytes.try_into_mut() {
|
||||
Ok(bytes) => bytes,
|
||||
Err(bytes) => BytesMut::from(bytes.as_ref()),
|
||||
};
|
||||
// Called only after decode and after excluding remaining <= 1.
|
||||
bytes[1] -= 1;
|
||||
bytes.freeze()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
#[test]
|
||||
fn transit_reuses_the_buffer_and_rejects_invalid_headers() {
|
||||
let frame = encode([1; 32], [2; 32], 17, &[42; 1280]);
|
||||
let address = frame.as_ptr();
|
||||
let frame = decrement(frame);
|
||||
assert_eq!(
|
||||
frame.as_ptr(),
|
||||
address,
|
||||
"no payload copy for an owned buffer"
|
||||
);
|
||||
assert_eq!(decode(&frame).unwrap().remaining, ROUTING_HOP_LIMIT - 1);
|
||||
assert_eq!(&frame[HEADER..], &[42; 1280]);
|
||||
for length in 0..HEADER {
|
||||
assert!(decode(&frame[..length]).is_none());
|
||||
}
|
||||
for ttl in [0, ROUTING_HOP_LIMIT + 1] {
|
||||
let mut bad = frame.to_vec();
|
||||
bad[1] = ttl;
|
||||
assert!(decode(&bad).is_none());
|
||||
}
|
||||
let mut bad = frame.to_vec();
|
||||
bad[0] = 99;
|
||||
assert!(decode(&bad).is_none());
|
||||
assert!(decode(&vec![1; MAX_DATA_DATAGRAM + 1]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Optional IP flow classifier for IP plugins, called before encryption.
|
||||
//! The router itself treats this identifier and the payload as opaque.
|
||||
|
||||
use super::FlowId;
|
||||
|
||||
fn hash(parts: &[&[u8]]) -> FlowId {
|
||||
let mut value = 0xcbf29ce484222325u64;
|
||||
for part in parts {
|
||||
for byte in *part {
|
||||
value = (value ^ u64::from(*byte)).wrapping_mul(0x100000001b3);
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// Stable directional IP flow hash, independent of TCP sequence numbers,
|
||||
/// checksums and packet contents. Fragmented traffic uses addresses/protocol
|
||||
/// instead of ports: even non-initial fragments and subsequent fragmented
|
||||
/// datagrams stay together. This deliberately coalesces fragmented flows.
|
||||
/// Malformed packets get a coarse hash; parsing is bounded and never allocates.
|
||||
pub fn ip_flow(packet: &[u8]) -> FlowId {
|
||||
match packet.first().map(|b| b >> 4) {
|
||||
Some(4) if packet.len() >= 20 => {
|
||||
let addresses = &packet[12..20];
|
||||
let protocol = &packet[9..10];
|
||||
let offset = usize::from(packet[0] & 15) * 4;
|
||||
if u16::from_be_bytes([packet[6], packet[7]]) & 0x3fff != 0 {
|
||||
return hash(&[addresses, protocol]);
|
||||
}
|
||||
if offset >= 20
|
||||
&& matches!(packet[9], 6 | 17)
|
||||
&& let Some(ports) = packet.get(offset..offset + 4)
|
||||
{
|
||||
return hash(&[addresses, protocol, ports]);
|
||||
}
|
||||
hash(&[addresses, protocol])
|
||||
}
|
||||
Some(6) if packet.len() >= 40 => {
|
||||
let addresses = &packet[8..40];
|
||||
let mut protocol = packet[6];
|
||||
let mut offset = 40;
|
||||
for _ in 0..8 {
|
||||
match protocol {
|
||||
44 => {
|
||||
if let Some(fragment) = packet.get(offset..offset + 8) {
|
||||
return hash(&[addresses, &fragment[..1]]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
0 | 43 | 60 | 51 => {
|
||||
let Some(header) = packet.get(offset..offset + 2) else {
|
||||
break;
|
||||
};
|
||||
let size = if protocol == 51 {
|
||||
(usize::from(header[1]) + 2) * 4
|
||||
} else {
|
||||
(usize::from(header[1]) + 1) * 8
|
||||
};
|
||||
protocol = header[0];
|
||||
offset += size;
|
||||
}
|
||||
6 | 17 => {
|
||||
if let Some(ports) = packet.get(offset..offset + 4) {
|
||||
return hash(&[addresses, &[protocol], ports]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
hash(&[addresses, &[protocol]])
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tcp_flow_ignores_packet_contents_but_distinguishes_ports() {
|
||||
let mut packet = [0u8; 80];
|
||||
packet[0] = 0x45;
|
||||
packet[9] = 6;
|
||||
packet[12..24].copy_from_slice(&[10, 0, 0, 1, 10, 0, 0, 2, 0, 22, 1, 2]);
|
||||
let flow = ip_flow(&packet);
|
||||
packet[4] = 123; // IP id changes for each unfragmented packet.
|
||||
packet[24..].fill(47);
|
||||
assert_eq!(flow, ip_flow(&packet));
|
||||
packet[23] = 3;
|
||||
assert_ne!(flow, ip_flow(&packet));
|
||||
packet[6] = 0x20;
|
||||
let fragment_flow = ip_flow(&packet);
|
||||
packet[6] = 0;
|
||||
packet[7] = 1;
|
||||
packet[20..].fill(99);
|
||||
assert_eq!(fragment_flow, ip_flow(&packet));
|
||||
packet[4] = 97;
|
||||
assert_eq!(fragment_flow, ip_flow(&packet));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_extensions_and_fragments_are_bounded_and_stable() {
|
||||
let mut packet = [0u8; 80];
|
||||
packet[0] = 0x60;
|
||||
packet[6] = 0;
|
||||
packet[40] = 17;
|
||||
packet[48..52].copy_from_slice(&[1, 2, 3, 4]);
|
||||
let flow = ip_flow(&packet);
|
||||
packet[52..].fill(5);
|
||||
assert_eq!(flow, ip_flow(&packet));
|
||||
packet[50] = 9;
|
||||
assert_ne!(flow, ip_flow(&packet));
|
||||
packet[6] = 44;
|
||||
packet[40] = 6;
|
||||
let flow = ip_flow(&packet);
|
||||
packet[42] = 32;
|
||||
packet[48..].fill(3);
|
||||
assert_eq!(flow, ip_flow(&packet));
|
||||
for len in 0..packet.len() {
|
||||
let _ = ip_flow(&packet[..len]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Transport-independent routing over opaque identities. Tables are built on
|
||||
//! topology changes, never on the packet path. No WireGuard or iroh APIs here.
|
||||
pub(crate) mod envelope;
|
||||
pub mod flow;
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
|
||||
/// Opaque authenticated identity; the transport adapter validates its keys.
|
||||
pub type PeerId = [u8; 32];
|
||||
/// Stable flow identifier, carried unchanged between transit routers.
|
||||
pub type FlowId = u64;
|
||||
|
||||
/// A shortest route with all equal-cost first hops in stable order.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Route {
|
||||
/// Number of transport links to the destination.
|
||||
pub hops: u8,
|
||||
/// Equal-cost first hops. Direct destinations have one entry.
|
||||
pub next_hops: Box<[PeerId]>,
|
||||
}
|
||||
|
||||
/// Immutable shortest-path table, replaced when connectivity changes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RoutingTable {
|
||||
routes: HashMap<PeerId, Route>,
|
||||
}
|
||||
|
||||
impl RoutingTable {
|
||||
/// Computes shortest directed paths bounded by `hop_limit`.
|
||||
/// The caller supplies only authenticated, fresh, compatible edges.
|
||||
pub fn build(local: PeerId, graph: &HashMap<PeerId, Vec<PeerId>>, hop_limit: u8) -> Self {
|
||||
let mut distances = HashMap::from([(local, 0u8)]);
|
||||
let mut first_hops: HashMap<PeerId, BTreeSet<PeerId>> = HashMap::new();
|
||||
let mut pending = VecDeque::from([local]);
|
||||
while let Some(node) = pending.pop_front() {
|
||||
let distance = distances[&node];
|
||||
if distance >= hop_limit {
|
||||
continue;
|
||||
}
|
||||
let inherited = first_hops.get(&node).cloned().unwrap_or_default();
|
||||
for &next in graph.get(&node).into_iter().flatten() {
|
||||
let next_distance = distance + 1;
|
||||
match distances.get(&next) {
|
||||
Some(&known) if known < next_distance => continue,
|
||||
None => {
|
||||
distances.insert(next, next_distance);
|
||||
pending.push_back(next);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let hops = first_hops.entry(next).or_default();
|
||||
if node == local {
|
||||
hops.insert(next);
|
||||
} else {
|
||||
hops.extend(inherited.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
routes: first_hops
|
||||
.into_iter()
|
||||
.map(|(peer, hops)| {
|
||||
(
|
||||
peer,
|
||||
Route {
|
||||
hops: distances[&peer],
|
||||
next_hops: hops.into_iter().collect(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
/// Precomputed route to a destination.
|
||||
pub fn get(&self, destination: &PeerId) -> Option<&Route> {
|
||||
self.routes.get(destination)
|
||||
}
|
||||
/// All reachable destinations, excluding ourselves.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&PeerId, &Route)> {
|
||||
self.routes.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable ECMP mixing, without random state or hashing the payload per hop.
|
||||
pub(crate) fn mix64(mut value: u64) -> u64 {
|
||||
value ^= value >> 30;
|
||||
value = value.wrapping_mul(0xbf58476d1ce4e5b9);
|
||||
value ^= value >> 27;
|
||||
value = value.wrapping_mul(0x94d049bb133111eb);
|
||||
value ^ (value >> 31)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
fn p(n: u8) -> PeerId {
|
||||
[n; 32]
|
||||
}
|
||||
#[test]
|
||||
fn shortest_paths_keep_equal_cost_hops_and_prefer_direct_links() {
|
||||
let mut graph = HashMap::from([
|
||||
(p(1), vec![p(3), p(2)]),
|
||||
(p(2), vec![p(4)]),
|
||||
(p(3), vec![p(4)]),
|
||||
(p(4), vec![p(5)]),
|
||||
(p(5), vec![p(1)]),
|
||||
]);
|
||||
let routes = RoutingTable::build(p(1), &graph, 16);
|
||||
assert_eq!(routes.get(&p(5)).unwrap().hops, 3);
|
||||
assert_eq!(&*routes.get(&p(5)).unwrap().next_hops, &[p(2), p(3)]);
|
||||
assert!(routes.get(&p(1)).is_none());
|
||||
assert!(routes.get(&p(9)).is_none());
|
||||
graph.get_mut(&p(1)).unwrap().push(p(5));
|
||||
assert_eq!(
|
||||
RoutingTable::build(p(1), &graph, 16).get(&p(5)).unwrap(),
|
||||
&Route {
|
||||
hops: 1,
|
||||
next_hops: Box::new([p(5)])
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn topology_removal_and_hop_budget_remove_impossible_routes() {
|
||||
let mut graph = HashMap::from([(p(1), vec![p(2)]), (p(2), vec![p(3)]), (p(3), vec![p(4)])]);
|
||||
assert!(RoutingTable::build(p(1), &graph, 2).get(&p(4)).is_none());
|
||||
assert!(RoutingTable::build(p(1), &graph, 3).get(&p(4)).is_some());
|
||||
graph.remove(&p(2));
|
||||
assert!(RoutingTable::build(p(1), &graph, 16).get(&p(4)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Bounded fragmentation of opaque transport payloads, below peer relaying.
|
||||
//!
|
||||
//! Data ALPN v3: u64 packet ID, u32 total length, u32 byte offset (big endian),
|
||||
//! followed by payload. Each QUIC connection owns its IDs and reassembly state.
|
||||
//! There is no retransmission: one missing fragment loses one datagram only.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::config::{
|
||||
DATA_REASSEMBLY_TTL, DATA_RECENT_IDS, MAX_DATA_ASSEMBLIES, MAX_DATA_DATAGRAM,
|
||||
MAX_DATA_FRAGMENTS, MAX_DATA_REASSEMBLY_BYTES,
|
||||
};
|
||||
|
||||
pub(super) const HEADER: usize = 16;
|
||||
|
||||
pub(super) fn encode(id: u64, total: usize, offset: usize, payload: &[u8]) -> Bytes {
|
||||
let mut frame = BytesMut::with_capacity(HEADER + payload.len());
|
||||
frame.put_u64(id);
|
||||
frame.put_u32(total as u32);
|
||||
frame.put_u32(offset as u32);
|
||||
frame.extend_from_slice(payload);
|
||||
frame.freeze()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Assembly {
|
||||
started: Instant,
|
||||
bytes: Vec<u8>,
|
||||
ranges: Vec<(usize, usize)>,
|
||||
received: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct Reassembler {
|
||||
packets: HashMap<u64, Assembly>,
|
||||
buffered: usize,
|
||||
recent: VecDeque<u64>,
|
||||
}
|
||||
|
||||
impl Reassembler {
|
||||
fn remember(&mut self, id: u64) {
|
||||
if self.recent.len() == DATA_RECENT_IDS {
|
||||
self.recent.pop_front();
|
||||
}
|
||||
self.recent.push_back(id);
|
||||
}
|
||||
|
||||
fn remove(&mut self, id: u64) -> Option<Assembly> {
|
||||
let packet = self.packets.remove(&id)?;
|
||||
self.buffered -= packet.bytes.len();
|
||||
self.remember(id);
|
||||
Some(packet)
|
||||
}
|
||||
|
||||
pub(super) fn expire(&mut self, now: Instant) {
|
||||
let expired: Vec<_> = self
|
||||
.packets
|
||||
.iter()
|
||||
.filter(|(_, p)| now.duration_since(p.started) >= DATA_REASSEMBLY_TTL)
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
for id in expired {
|
||||
self.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push(&mut self, frame: Bytes, now: Instant) -> Option<Bytes> {
|
||||
self.expire(now);
|
||||
let id = u64::from_be_bytes(frame.get(..8)?.try_into().ok()?);
|
||||
let total = u32::from_be_bytes(frame.get(8..12)?.try_into().ok()?) as usize;
|
||||
let offset = u32::from_be_bytes(frame.get(12..HEADER)?.try_into().ok()?) as usize;
|
||||
let payload = frame.get(HEADER..)?;
|
||||
let end = offset.checked_add(payload.len())?;
|
||||
if total > MAX_DATA_DATAGRAM
|
||||
|| end > total
|
||||
|| (payload.is_empty() && total != 0)
|
||||
|| self.recent.contains(&id)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if !self.packets.contains_key(&id) {
|
||||
if offset == 0 && end == total {
|
||||
self.remember(id);
|
||||
return Some(frame.slice(HEADER..));
|
||||
}
|
||||
// The length is validated before allocating. Drop the oldest
|
||||
// incomplete packet when the per-link byte or entry budget is full.
|
||||
while self.packets.len() >= MAX_DATA_ASSEMBLIES
|
||||
|| self.buffered + total > MAX_DATA_REASSEMBLY_BYTES
|
||||
{
|
||||
let oldest = self
|
||||
.packets
|
||||
.iter()
|
||||
.min_by_key(|(_, p)| p.started)
|
||||
.map(|(id, _)| *id)?;
|
||||
self.remove(oldest);
|
||||
}
|
||||
self.packets.insert(
|
||||
id,
|
||||
Assembly {
|
||||
started: now,
|
||||
bytes: vec![0; total],
|
||||
ranges: Vec::new(),
|
||||
received: 0,
|
||||
},
|
||||
);
|
||||
self.buffered += total;
|
||||
}
|
||||
let packet = self.packets.get_mut(&id)?;
|
||||
if packet.bytes.len() != total {
|
||||
self.remove(id);
|
||||
return None;
|
||||
}
|
||||
for &(start, stop) in &packet.ranges {
|
||||
if start == offset && stop == end && packet.bytes[offset..end] == *payload {
|
||||
return None; // a duplicate does not reset the expiry time
|
||||
}
|
||||
if offset < stop && end > start {
|
||||
self.remove(id); // conflicting or overlapping fragments
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if packet.ranges.len() == MAX_DATA_FRAGMENTS {
|
||||
self.remove(id);
|
||||
return None;
|
||||
}
|
||||
packet.bytes[offset..end].copy_from_slice(payload);
|
||||
packet.ranges.push((offset, end));
|
||||
packet.received += payload.len();
|
||||
if packet.received == total {
|
||||
return self.remove(id).map(|p| Bytes::from(p.bytes));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reordered_duplicates_and_changing_fragment_sizes_reassemble_once() {
|
||||
let mut rx = Reassembler::default();
|
||||
let now = Instant::now();
|
||||
let packet: Vec<u8> = (0..9000).map(|i| (i % 251) as u8).collect();
|
||||
let mut parts = Vec::new();
|
||||
let mut offset = 0;
|
||||
for size in [1100, 700, 1100, 1100, 700, 1100, 1100, 1100, 1000] {
|
||||
parts.push(encode(
|
||||
7,
|
||||
packet.len(),
|
||||
offset,
|
||||
&packet[offset..offset + size],
|
||||
));
|
||||
offset += size;
|
||||
}
|
||||
assert_eq!(offset, packet.len());
|
||||
for part in parts[1..].iter().rev() {
|
||||
assert!(rx.push(part.clone(), now).is_none());
|
||||
assert!(rx.push(part.clone(), now).is_none());
|
||||
}
|
||||
assert_eq!(rx.push(parts[0].clone(), now).unwrap().as_ref(), packet);
|
||||
assert!(rx.push(parts[0].clone(), now).is_none());
|
||||
assert_eq!(rx.buffered, 0);
|
||||
assert!(rx.packets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_fragment_never_blocks_the_next_packet_and_expires() {
|
||||
let mut rx = Reassembler::default();
|
||||
let now = Instant::now();
|
||||
assert!(rx.push(encode(1, 1280, 0, &[1; 800]), now).is_none());
|
||||
assert_eq!(
|
||||
rx.push(encode(2, 4, 0, b"next"), now).unwrap(),
|
||||
&b"next"[..]
|
||||
);
|
||||
rx.expire(now + DATA_REASSEMBLY_TTL);
|
||||
assert!(rx.packets.is_empty());
|
||||
assert_eq!(rx.buffered, 0);
|
||||
assert!(
|
||||
rx.push(encode(1, 1280, 800, &[1; 480]), now + DATA_REASSEMBLY_TTL)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_conflicting_and_oversized_fragments_are_bounded() {
|
||||
let now = Instant::now();
|
||||
let mut rx = Reassembler::default();
|
||||
for len in 0..HEADER {
|
||||
assert!(rx.push(Bytes::from(vec![0xff; len]), now).is_none());
|
||||
}
|
||||
assert!(
|
||||
rx.push(encode(1, MAX_DATA_DATAGRAM + 1, 0, b"x"), now)
|
||||
.is_none()
|
||||
);
|
||||
assert!(rx.push(encode(2, 100, 100, b"x"), now).is_none());
|
||||
assert!(rx.push(encode(3, 100, 0, b""), now).is_none());
|
||||
assert_eq!(rx.buffered, 0);
|
||||
for (id, bad) in [
|
||||
(4, encode(4, 100, 0, b"different")),
|
||||
(5, encode(5, 100, 3, b"overlap")),
|
||||
(6, encode(6, 101, 9, b"changed total")),
|
||||
] {
|
||||
assert!(rx.push(encode(id, 100, 0, b"123456789"), now).is_none());
|
||||
assert!(rx.push(bad, now).is_none());
|
||||
assert_eq!(rx.buffered, 0);
|
||||
}
|
||||
for id in 100..400 {
|
||||
assert!(
|
||||
rx.push(encode(id, MAX_DATA_DATAGRAM, 0, b"x"), now)
|
||||
.is_none()
|
||||
);
|
||||
assert!(rx.buffered <= MAX_DATA_REASSEMBLY_BYTES);
|
||||
assert!(rx.packets.len() <= MAX_DATA_ASSEMBLIES);
|
||||
assert!(rx.recent.len() <= DATA_RECENT_IDS);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excessive_fragment_counts_are_discarded() {
|
||||
let mut rx = Reassembler::default();
|
||||
let now = Instant::now();
|
||||
for offset in 0..=MAX_DATA_FRAGMENTS {
|
||||
assert!(rx.push(encode(1, 1000, offset, b"x"), now).is_none());
|
||||
}
|
||||
assert!(rx.packets.is_empty());
|
||||
assert_eq!(rx.buffered, 0);
|
||||
}
|
||||
}
|
||||
+86
-14
@@ -20,13 +20,14 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use iroh::endpoint::{Connection, RecvStream, SendStream};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::config::Limits;
|
||||
use crate::config::{DATA_REASSEMBLY_TTL, Limits, MAX_DATA_DATAGRAM, MAX_DATA_FRAGMENTS};
|
||||
use crate::error::ProtocolError;
|
||||
use crate::identity::{NetworkId, NetworkKeys};
|
||||
use crate::net::EndpointAdapter;
|
||||
@@ -36,6 +37,7 @@ use crate::proto::message::{
|
||||
};
|
||||
use crate::proto::{read_frame, write_frame};
|
||||
|
||||
use super::fragments::{self, Reassembler};
|
||||
use super::{InboundLink, PacketLink, PacketTransport, SharedLink, TransportError};
|
||||
|
||||
/// What the iroh transport needs from the agent.
|
||||
@@ -61,6 +63,8 @@ pub struct IrohLink {
|
||||
peer: EndpointId,
|
||||
conn: Connection,
|
||||
max_datagram: usize,
|
||||
next_packet: AtomicU64,
|
||||
reassembly: tokio::sync::Mutex<Reassembler>,
|
||||
// Kept alive so the peer sees the channel as open; the connection closes
|
||||
// when the link is dropped.
|
||||
_send: tokio::sync::Mutex<SendStream>,
|
||||
@@ -76,14 +80,16 @@ impl IrohLink {
|
||||
send: SendStream,
|
||||
recv: RecvStream,
|
||||
) -> Self {
|
||||
let local_limit = conn.max_datagram_size().unwrap_or(0);
|
||||
// Both ends must agree, so the smaller limit wins.
|
||||
let max_datagram = local_limit.min(peer_limit);
|
||||
// The negotiated limit is for reassembled payloads, never a snapshot
|
||||
// of the initial path (which may still be a relay or another VPN).
|
||||
let max_datagram = MAX_DATA_DATAGRAM.min(peer_limit);
|
||||
Self {
|
||||
network,
|
||||
peer,
|
||||
conn,
|
||||
max_datagram,
|
||||
next_packet: AtomicU64::new(0),
|
||||
reassembly: tokio::sync::Mutex::new(Reassembler::default()),
|
||||
_send: tokio::sync::Mutex::new(send),
|
||||
_recv: tokio::sync::Mutex::new(recv),
|
||||
}
|
||||
@@ -110,17 +116,65 @@ impl PacketLink for IrohLink {
|
||||
limit: self.max_datagram,
|
||||
});
|
||||
}
|
||||
self.conn.send_datagram(payload).map_err(|err| {
|
||||
use iroh::endpoint::SendDatagramError;
|
||||
match err {
|
||||
SendDatagramError::ConnectionLost(_) => TransportError::Closed,
|
||||
other => TransportError::Other(other.to_string()),
|
||||
let id = self.next_packet.fetch_add(1, Ordering::Relaxed);
|
||||
let mut offset: usize = 0;
|
||||
let mut attempts = 0;
|
||||
// Re-read the current path's capacity for every fragment. A migration
|
||||
// can shrink it even between two send_datagram calls.
|
||||
for _ in 0..MAX_DATA_FRAGMENTS {
|
||||
loop {
|
||||
let capacity = self
|
||||
.conn
|
||||
.max_datagram_size()
|
||||
.unwrap_or(0)
|
||||
.checked_sub(fragments::HEADER)
|
||||
.filter(|n| *n > 0)
|
||||
.ok_or_else(|| {
|
||||
TransportError::Other("QUIC path cannot carry data fragments".into())
|
||||
})?;
|
||||
let end = offset.saturating_add(capacity).min(payload.len());
|
||||
let frame = fragments::encode(id, payload.len(), offset, &payload[offset..end]);
|
||||
match self.conn.send_datagram(frame) {
|
||||
Ok(()) => {
|
||||
offset = end;
|
||||
break;
|
||||
}
|
||||
Err(iroh::endpoint::SendDatagramError::TooLarge) if attempts < 3 => {
|
||||
attempts += 1;
|
||||
}
|
||||
Err(iroh::endpoint::SendDatagramError::ConnectionLost(_)) => {
|
||||
return Err(TransportError::Closed);
|
||||
}
|
||||
Err(err) => return Err(TransportError::Other(err.to_string())),
|
||||
}
|
||||
}
|
||||
})
|
||||
if offset == payload.len() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(TransportError::Other(
|
||||
"QUIC path needs too many fragments".into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move { self.conn.read_datagram().await.ok() })
|
||||
Box::pin(async move {
|
||||
let mut reassembly = self.reassembly.lock().await;
|
||||
loop {
|
||||
match tokio::time::timeout(DATA_REASSEMBLY_TTL, self.conn.read_datagram()).await {
|
||||
Ok(Ok(frame)) => {
|
||||
if let Some(packet) = reassembly.push(frame, tokio::time::Instant::now()) {
|
||||
return Some(packet);
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
*reassembly = Reassembler::default();
|
||||
return None;
|
||||
}
|
||||
Err(_) => reassembly.expire(tokio::time::Instant::now()),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn closed(&self) -> BoxFuture<'_, ()> {
|
||||
@@ -208,10 +262,14 @@ impl IrohTransport {
|
||||
}
|
||||
|
||||
let serves = self.lookup.serves(outcome.network_id, &open.protocol).await;
|
||||
let max_datagram = conn.max_datagram_size().unwrap_or(0);
|
||||
if open.max_datagram == 0 || conn.max_datagram_size().is_none() {
|
||||
return Err(TransportError::Other(
|
||||
"data channel has no datagram support".into(),
|
||||
));
|
||||
}
|
||||
let ack = DataOpenAck {
|
||||
accepted: serves,
|
||||
max_datagram: max_datagram as u32,
|
||||
max_datagram: MAX_DATA_DATAGRAM as u32,
|
||||
};
|
||||
write_frame(
|
||||
&mut send,
|
||||
@@ -226,7 +284,14 @@ impl IrohTransport {
|
||||
return Err(TransportError::Declined(open.protocol));
|
||||
}
|
||||
|
||||
let link = IrohLink::new(outcome.network_id, peer, conn, usize::MAX, send, recv);
|
||||
let link = IrohLink::new(
|
||||
outcome.network_id,
|
||||
peer,
|
||||
conn,
|
||||
open.max_datagram as usize,
|
||||
send,
|
||||
recv,
|
||||
);
|
||||
Ok(InboundLink {
|
||||
network: outcome.network_id,
|
||||
peer,
|
||||
@@ -289,6 +354,7 @@ impl PacketTransport for IrohTransport {
|
||||
|
||||
let open = DataOpen {
|
||||
protocol: protocol.to_string(),
|
||||
max_datagram: MAX_DATA_DATAGRAM as u32,
|
||||
};
|
||||
write_frame(
|
||||
&mut send,
|
||||
@@ -310,6 +376,12 @@ impl PacketTransport for IrohTransport {
|
||||
return Err(TransportError::Declined(protocol.to_string()));
|
||||
}
|
||||
|
||||
if ack.max_datagram == 0 || conn.max_datagram_size().is_none() {
|
||||
return Err(TransportError::Other(
|
||||
"data channel has no datagram support".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let link = IrohLink::new(network, peer, conn, ack.max_datagram as usize, send, recv);
|
||||
Ok(Arc::new(link) as SharedLink)
|
||||
})
|
||||
@@ -29,8 +29,11 @@
|
||||
//! encrypted by the transport, and scoped to exactly one network, one peer and
|
||||
//! one plugin protocol.
|
||||
|
||||
mod fragments;
|
||||
pub mod iroh_link;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
|
||||
@@ -77,8 +80,8 @@ pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
|
||||
|
||||
/// The largest datagram this link can carry, in bytes.
|
||||
///
|
||||
/// A plugin must size its own packets to fit, because there is no
|
||||
/// fragmentation here.
|
||||
/// This is the logical payload limit. A transport can fragment underneath
|
||||
/// it so path MTU changes do not force a plugin to resize the host interface.
|
||||
fn max_datagram_size(&self) -> usize;
|
||||
|
||||
/// Sends one datagram.
|
||||
@@ -87,6 +90,16 @@ pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// handed to the transport, nothing more.
|
||||
fn send(&self, payload: Bytes) -> Result<(), TransportError>;
|
||||
|
||||
/// Sends a datagram with an opaque stable flow identifier. Plugins derive
|
||||
/// this before encryption; transports that do not route can ignore it.
|
||||
fn send_flow(
|
||||
&self,
|
||||
payload: Bytes,
|
||||
_flow: super::routing::FlowId,
|
||||
) -> Result<(), TransportError> {
|
||||
self.send(payload)
|
||||
}
|
||||
|
||||
/// Receives the next datagram, or `None` once the link is finished.
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>>;
|
||||
|
||||
@@ -144,3 +157,51 @@ pub trait PacketTransport: Send + Sync + std::fmt::Debug + 'static {
|
||||
protocol: &'a str,
|
||||
) -> BoxFuture<'a, Result<SharedLink, TransportError>>;
|
||||
}
|
||||
|
||||
/// A composite packet transport that dispatches requests by protocol id.
|
||||
#[derive(Debug)]
|
||||
pub struct MultiPacketTransport {
|
||||
default: Arc<dyn PacketTransport>,
|
||||
by_protocol: std::collections::HashMap<String, Arc<dyn PacketTransport>>,
|
||||
}
|
||||
|
||||
impl MultiPacketTransport {
|
||||
/// Creates a new composite transport with a default fallback transport.
|
||||
pub fn new(
|
||||
default: Arc<dyn PacketTransport>,
|
||||
by_protocol: std::collections::HashMap<String, Arc<dyn PacketTransport>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
default,
|
||||
by_protocol,
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the default underlying transport.
|
||||
pub fn default_transport(&self) -> &Arc<dyn PacketTransport> {
|
||||
&self.default
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketTransport for MultiPacketTransport {
|
||||
fn name(&self) -> &str {
|
||||
"multi"
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn open<'a>(
|
||||
&'a self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
protocol: &'a str,
|
||||
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
|
||||
if let Some(transport) = self.by_protocol.get(protocol) {
|
||||
transport.open(network, peer, protocol)
|
||||
} else {
|
||||
self.default.open(network, peer, protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,7 @@
|
||||
//!
|
||||
//! * *Finding members of a network* — [`NetworkDiscovery::resolve`], keyed by
|
||||
//! the secret-derived [`DiscoveryKey`]. That is what lives here, and today
|
||||
//! it is [`StaticBootstrap`] plus a test backend; a DHT backend is future
|
||||
//! work.
|
||||
//! it includes [`StaticBootstrap`], [`MainlineDiscovery`] and a test backend.
|
||||
//! * *Resolving the address of one iroh endpoint* — **iroh's job, not ours**.
|
||||
//! With [`crate::config::TransportPolicy::N0Defaults`] or `DirectOnly`, iroh
|
||||
//! publishes and resolves endpoint addresses through Number 0's public
|
||||
@@ -23,14 +22,18 @@
|
||||
//! No empty result ever proves a network is empty. It only means "nobody found
|
||||
//! yet".
|
||||
//!
|
||||
//! Mainline DHT discovery is future work and is not implemented here.
|
||||
//! Mainline rendezvous is opt-in for library callers; the command line enables it.
|
||||
|
||||
mod mainline;
|
||||
pub(crate) mod worker;
|
||||
pub use mainline::MainlineDiscovery;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use iroh::{EndpointAddr, EndpointId};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::DiscoveryKey;
|
||||
|
||||
pub use crate::BoxFuture;
|
||||
@@ -44,6 +47,18 @@ pub enum CandidateSource {
|
||||
Discovery,
|
||||
/// An address hint restored from the disposable cache.
|
||||
Cache,
|
||||
/// An author of a signed record: somebody who belongs to this network,
|
||||
/// learned from state that reached us through anybody.
|
||||
///
|
||||
/// It carries no address of its own — the endpoint's own discovery has
|
||||
/// to resolve it — but knowing that a member exists is what turns a
|
||||
/// star around whoever was named on the command line into a mesh.
|
||||
Member,
|
||||
/// Passed on by a member we are talking to.
|
||||
///
|
||||
/// A candidate like any other: an introduction is not a vouching, and
|
||||
/// membership is still decided by the handshake.
|
||||
Introduced,
|
||||
}
|
||||
|
||||
/// An unverified candidate peer.
|
||||
@@ -91,6 +106,23 @@ pub trait NetworkDiscovery: Send + Sync + std::fmt::Debug + 'static {
|
||||
|
||||
/// Returns the candidates currently known for `key`.
|
||||
fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>>;
|
||||
|
||||
/// Delivers candidates as they arrive. The default adapts a batch backend;
|
||||
/// remote backends can override this so the first dial need not wait for all lookups.
|
||||
fn resolve_into<'a>(
|
||||
&'a self,
|
||||
key: DiscoveryKey,
|
||||
candidates: tokio::sync::mpsc::Sender<Candidate>,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
for candidate in self.resolve(key).await? {
|
||||
if candidates.send(candidate).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A statically configured list of bootstrap candidates.
|
||||
@@ -261,13 +293,25 @@ impl NetworkDiscovery for CompositeDiscovery {
|
||||
|
||||
fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
for backend in &self.backends {
|
||||
// One failing backend must not stop the others.
|
||||
if let Err(err) = backend.publish(key, addr.clone()).await {
|
||||
tracing::debug!(backend = backend.name(), %err, "publish failed");
|
||||
let backend = backend.clone();
|
||||
let addr = addr.clone();
|
||||
tasks.spawn(async move { backend.publish(key, addr).await });
|
||||
}
|
||||
let mut failed = false;
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
if !matches!(result, Ok(Ok(()))) {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
if failed {
|
||||
Err(Error::Discovery(
|
||||
"a discovery publication failed; will retry".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -277,11 +321,12 @@ impl NetworkDiscovery for CompositeDiscovery {
|
||||
endpoint: EndpointId,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
for backend in &self.backends {
|
||||
if let Err(err) = backend.unpublish(key, endpoint).await {
|
||||
tracing::debug!(backend = backend.name(), %err, "unpublish failed");
|
||||
}
|
||||
let backend = backend.clone();
|
||||
tasks.spawn(async move { backend.unpublish(key, endpoint).await });
|
||||
}
|
||||
while tasks.join_next().await.is_some() {}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -300,4 +345,25 @@ impl NetworkDiscovery for CompositeDiscovery {
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_into<'a>(
|
||||
&'a self,
|
||||
key: DiscoveryKey,
|
||||
candidates: tokio::sync::mpsc::Sender<Candidate>,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
for backend in &self.backends {
|
||||
let backend = backend.clone();
|
||||
let candidates = candidates.clone();
|
||||
tasks.spawn(async move {
|
||||
if let Err(err) = backend.resolve_into(key, candidates).await {
|
||||
tracing::debug!(backend = backend.name(), %err, "resolve failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
while tasks.join_next().await.is_some() {}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
//! Mainline BEP44 rendezvous. Slots are a changing sample, not a membership list.
|
||||
|
||||
use ::mainline::{Dht, MutableItem, SigningKey, async_dht::AsyncDht};
|
||||
use futures_lite::StreamExt;
|
||||
use iroh::{EndpointAddr, EndpointId};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::{Mutex as AsyncMutex, mpsc};
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::timeout;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::{BoxFuture, Candidate, CandidateSource, NetworkDiscovery};
|
||||
use crate::config::{
|
||||
DHT_CLOCK_SKEW, DHT_MAX_ADDRS, DHT_MAX_RELAY_LEN, DHT_MAX_VALUE, DHT_RECORD_TTL, DHT_SLOTS,
|
||||
};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::{DiscoveryKey, NetworkKeys};
|
||||
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
const MAGIC: &[u8; 5] = b"TSND\x01";
|
||||
|
||||
/// One lazily started Mainline client, shared by all networks on one agent.
|
||||
/// No socket is opened until an active network publishes or looks up candidates.
|
||||
/// Agent shutdown closes this client and all its clones; a new agent needs a new client.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MainlineDiscovery {
|
||||
client: Arc<AsyncMutex<ClientState>>,
|
||||
allow_loopback: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
enum ClientState {
|
||||
#[default]
|
||||
Pending,
|
||||
Ready(AsyncDht),
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl MainlineDiscovery {
|
||||
/// Uses a caller-supplied Mainline node instead of public bootstrap defaults.
|
||||
pub fn from_dht(dht: Dht) -> Self {
|
||||
Self {
|
||||
client: Arc::new(AsyncMutex::new(ClientState::Ready(dht.as_async()))),
|
||||
allow_loopback: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a loopback-only client for a local Mainline Testnet.
|
||||
#[cfg(feature = "testing")]
|
||||
pub fn local_testnet(bootstrap: &[String]) -> Result<Self> {
|
||||
// Reject anything that could resolve or send outside loopback.
|
||||
if bootstrap.is_empty()
|
||||
|| bootstrap.iter().any(|s| {
|
||||
s.parse::<SocketAddr>()
|
||||
.map_or(true, |a| !a.ip().is_loopback())
|
||||
})
|
||||
{
|
||||
return Err(Error::Discovery(
|
||||
"test bootstrap must contain loopback sockets".into(),
|
||||
));
|
||||
}
|
||||
let dht = Dht::builder()
|
||||
.bootstrap(bootstrap)
|
||||
.bind_address(Ipv4Addr::LOCALHOST)
|
||||
.port(0)
|
||||
.request_timeout(Duration::from_millis(200))
|
||||
.build()
|
||||
.map_err(|_| Error::Discovery("cannot bind local DHT".into()))?;
|
||||
Ok(Self {
|
||||
allow_loopback: true,
|
||||
..Self::from_dht(dht)
|
||||
})
|
||||
}
|
||||
|
||||
/// Binds this client's rendezvous backend to one network's independent keys.
|
||||
pub fn for_network(&self, keys: &NetworkKeys) -> Arc<dyn NetworkDiscovery> {
|
||||
Arc::new(NetworkDht {
|
||||
client: self.clone(),
|
||||
key: keys.discovery_key(),
|
||||
seed: Zeroizing::new(*keys.dht_write_key()),
|
||||
observed: Mutex::new(VecDeque::new()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn client(&self) -> Result<AsyncDht> {
|
||||
let mut client = self.client.lock().await;
|
||||
match &*client {
|
||||
ClientState::Ready(dht) => return Ok(dht.clone()),
|
||||
ClientState::Stopped => return Err(Error::Discovery("DHT client is stopped".into())),
|
||||
ClientState::Pending => {}
|
||||
}
|
||||
// Construction may resolve bootstrap hostnames. Keep it off Tokio's
|
||||
// executor; a construction failure is retried on the next operation.
|
||||
let dht = tokio::task::spawn_blocking(|| Dht::builder().port(0).build())
|
||||
.await
|
||||
.map_err(|_| Error::Discovery("DHT startup task failed".into()))?
|
||||
.map(Dht::as_async)
|
||||
.map_err(|_| Error::Discovery("cannot start DHT client".into()))?;
|
||||
*client = ClientState::Ready(dht.clone());
|
||||
Ok(dht)
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(&self) {
|
||||
*self.client.lock().await = ClientState::Stopped;
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkDht {
|
||||
client: MainlineDiscovery,
|
||||
key: DiscoveryKey,
|
||||
seed: Zeroizing<[u8; 32]>,
|
||||
// Records encountered while publishing. Only an explicit lookup consumes
|
||||
// these hints: publication never causes dials on a connected network.
|
||||
observed: Mutex<VecDeque<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NetworkDht {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("NetworkDht(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
fn salt(slot: u8) -> Vec<u8> {
|
||||
let mut salt = b"tsunagi-rendezvous-v1".to_vec();
|
||||
salt.push(slot);
|
||||
salt
|
||||
}
|
||||
|
||||
fn slots_for(id: EndpointId) -> [u8; 2] {
|
||||
let hash = Sha256::digest(id.as_bytes());
|
||||
let first = hash[0] % DHT_SLOTS;
|
||||
[first, (first + 1 + hash[1] % (DHT_SLOTS - 1)) % DHT_SLOTS]
|
||||
}
|
||||
|
||||
fn now() -> Result<u64> {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.map_err(|_| Error::Discovery("system clock predates Unix epoch".into()))
|
||||
}
|
||||
|
||||
impl NetworkDht {
|
||||
async fn publish_slot(&self, dht: &AsyncDht, slot: u8, value: &[u8]) -> Result<()> {
|
||||
let salt = salt(slot);
|
||||
let signer = SigningKey::from_bytes(&self.seed);
|
||||
let public = signer.verifying_key().to_bytes();
|
||||
for attempt in 0..3 {
|
||||
let recent = timeout(
|
||||
QUERY_TIMEOUT,
|
||||
dht.get_mutable_most_recent(&public, Some(&salt)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| Error::Discovery("DHT read timed out".into()))?;
|
||||
if let Some(item) = &recent
|
||||
&& item.value().len() <= DHT_MAX_VALUE
|
||||
&& let Ok(mut observed) = self.observed.lock()
|
||||
{
|
||||
if observed.len() == DHT_SLOTS as usize {
|
||||
observed.pop_front();
|
||||
}
|
||||
observed.push_back(item.value().to_vec());
|
||||
}
|
||||
let seq = recent
|
||||
.as_ref()
|
||||
.map_or(0, MutableItem::seq)
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| Error::Discovery("DHT sequence exhausted".into()))?;
|
||||
let item = MutableItem::new(signer.clone(), value, seq, Some(&salt));
|
||||
match timeout(
|
||||
QUERY_TIMEOUT,
|
||||
dht.put_mutable(item, recent.as_ref().map(MutableItem::seq)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(outcome)) if outcome.stored_at > 0 => return Ok(()),
|
||||
_ if attempt < 2 => {
|
||||
// CAS is per storage node, not a global lock. Read again on
|
||||
// conflicts; another writer's entry is an acceptable sample.
|
||||
tokio::time::sleep(Duration::from_millis(rand::random_range(30..150))).await;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Err(Error::Discovery(
|
||||
"DHT publication was not acknowledged".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkDiscovery for NetworkDht {
|
||||
fn name(&self) -> &str {
|
||||
"mainline"
|
||||
}
|
||||
|
||||
fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
if key != self.key {
|
||||
return Err(Error::Discovery("wrong rendezvous network".into()));
|
||||
}
|
||||
let value = encode_record(&addr, now()?, self.client.allow_loopback)?;
|
||||
let dht = self.client.client().await?;
|
||||
let [first, second] = slots_for(addr.id);
|
||||
let (a, b) = tokio::join!(
|
||||
self.publish_slot(&dht, first, &value),
|
||||
self.publish_slot(&dht, second, &value)
|
||||
);
|
||||
a.and(b)
|
||||
})
|
||||
}
|
||||
|
||||
fn unpublish<'a>(&'a self, _: DiscoveryKey, _: EndpointId) -> BoxFuture<'a, Result<()>> {
|
||||
// Stopping publication lets freshness expire. Deleting a shared slot
|
||||
// could erase a different writer, and BEP44 has no delete operation.
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
|
||||
Box::pin(async move {
|
||||
let (tx, mut rx) = mpsc::channel(DHT_SLOTS as usize);
|
||||
let collect = async move {
|
||||
let mut found = Vec::new();
|
||||
while let Some(candidate) = rx.recv().await {
|
||||
found.push(candidate);
|
||||
}
|
||||
found
|
||||
};
|
||||
let (result, found) = tokio::join!(self.resolve_into(key, tx), collect);
|
||||
result?;
|
||||
Ok(found)
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_into<'a>(
|
||||
&'a self,
|
||||
key: DiscoveryKey,
|
||||
candidates: mpsc::Sender<Candidate>,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
if key != self.key {
|
||||
return Err(Error::Discovery("wrong rendezvous network".into()));
|
||||
}
|
||||
let dht = self.client.client().await?;
|
||||
let public = SigningKey::from_bytes(&self.seed)
|
||||
.verifying_key()
|
||||
.to_bytes();
|
||||
let observed = self
|
||||
.observed
|
||||
.lock()
|
||||
.map(|mut values| std::mem::take(&mut *values))
|
||||
.unwrap_or_default();
|
||||
let mut seen = HashSet::new();
|
||||
for value in observed {
|
||||
if let Some(addr) = decode_record(&value, now()?, self.client.allow_loopback)
|
||||
&& seen.insert(addr.id)
|
||||
&& candidates
|
||||
.send(Candidate::new(addr, CandidateSource::Discovery))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let mut slots: Vec<_> = (0..DHT_SLOTS).collect();
|
||||
for i in (1..slots.len()).rev() {
|
||||
slots.swap(i, rand::random_range(0..=i));
|
||||
}
|
||||
let mut queries = JoinSet::new();
|
||||
loop {
|
||||
if seen.len() >= DHT_SLOTS as usize {
|
||||
break;
|
||||
}
|
||||
while queries.len() < 4 {
|
||||
let Some(slot) = slots.pop() else {
|
||||
break;
|
||||
};
|
||||
let dht = dht.clone();
|
||||
let allow_loopback = self.client.allow_loopback;
|
||||
queries.spawn(async move {
|
||||
timeout(QUERY_TIMEOUT, async move {
|
||||
let salt = salt(slot);
|
||||
let target = MutableItem::target_from_key(&public, Some(&salt));
|
||||
let mut items = dht.get_mutable(&public, Some(&salt), None);
|
||||
while let Some(item) = items.next().await {
|
||||
// Mainline verifies the signature; also bind the
|
||||
// returned item to the key and slot we requested.
|
||||
if item.key() != &public || item.target() != &target {
|
||||
continue;
|
||||
}
|
||||
if let Some(addr) =
|
||||
decode_record(item.value(), now().ok()?, allow_loopback)
|
||||
{
|
||||
return Some(addr);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
});
|
||||
}
|
||||
let Some(result) = queries.join_next().await else {
|
||||
break;
|
||||
};
|
||||
if let Ok(Some(addr)) = result
|
||||
&& seen.insert(addr.id)
|
||||
&& candidates
|
||||
.send(Candidate::new(addr, CandidateSource::Discovery))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn usable(addr: &SocketAddr, allow_loopback: bool) -> bool {
|
||||
addr.port() != 0
|
||||
&& !addr.ip().is_unspecified()
|
||||
&& !addr.ip().is_multicast()
|
||||
&& (allow_loopback || !addr.ip().is_loopback())
|
||||
&& match addr.ip() {
|
||||
IpAddr::V4(ip) => !ip.is_broadcast() && !ip.is_link_local(),
|
||||
IpAddr::V6(ip) => !ip.is_unicast_link_local(),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_record(addr: &EndpointAddr, timestamp: u64, allow_loopback: bool) -> Result<Vec<u8>> {
|
||||
let ips: Vec<_> = addr
|
||||
.ip_addrs()
|
||||
.filter(|ip| usable(ip, allow_loopback))
|
||||
.take(DHT_MAX_ADDRS)
|
||||
.collect();
|
||||
let relay = addr
|
||||
.relay_urls()
|
||||
.next()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_default();
|
||||
if relay.len() > DHT_MAX_RELAY_LEN || (ips.is_empty() && relay.is_empty()) {
|
||||
return Err(Error::Discovery("no encodable endpoint address yet".into()));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(DHT_MAX_VALUE);
|
||||
bytes.extend_from_slice(MAGIC);
|
||||
bytes.extend_from_slice(×tamp.to_be_bytes());
|
||||
bytes.extend_from_slice(addr.id.as_bytes());
|
||||
bytes.extend_from_slice(&(relay.len() as u16).to_be_bytes());
|
||||
bytes.extend_from_slice(relay.as_bytes());
|
||||
bytes.push(ips.len() as u8);
|
||||
for ip in ips {
|
||||
match ip.ip() {
|
||||
IpAddr::V4(v) => {
|
||||
bytes.push(4);
|
||||
bytes.extend_from_slice(&v.octets());
|
||||
}
|
||||
IpAddr::V6(v) => {
|
||||
bytes.push(6);
|
||||
bytes.extend_from_slice(&v.octets());
|
||||
}
|
||||
}
|
||||
bytes.extend_from_slice(&ip.port().to_be_bytes());
|
||||
}
|
||||
if bytes.len() > DHT_MAX_VALUE {
|
||||
return Err(Error::Discovery("DHT record too large".into()));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn take<'a>(input: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
|
||||
let (head, rest) = input.split_at_checked(n)?;
|
||||
*input = rest;
|
||||
Some(head)
|
||||
}
|
||||
|
||||
fn decode_record(mut bytes: &[u8], now: u64, allow_loopback: bool) -> Option<EndpointAddr> {
|
||||
if bytes.len() > DHT_MAX_VALUE || take(&mut bytes, MAGIC.len())? != MAGIC {
|
||||
return None;
|
||||
}
|
||||
let timestamp = u64::from_be_bytes(take(&mut bytes, 8)?.try_into().ok()?);
|
||||
if timestamp > now.saturating_add(DHT_CLOCK_SKEW.as_secs())
|
||||
|| now.saturating_sub(timestamp) >= DHT_RECORD_TTL.as_secs()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let id = EndpointId::from_bytes(take(&mut bytes, 32)?.try_into().ok()?).ok()?;
|
||||
let mut addr = EndpointAddr::new(id);
|
||||
let len = u16::from_be_bytes(take(&mut bytes, 2)?.try_into().ok()?) as usize;
|
||||
if len > DHT_MAX_RELAY_LEN {
|
||||
return None;
|
||||
}
|
||||
let relay = std::str::from_utf8(take(&mut bytes, len)?).ok()?;
|
||||
if !relay.is_empty() {
|
||||
addr = addr.with_relay_url(relay.parse().ok()?);
|
||||
}
|
||||
let count = *take(&mut bytes, 1)?.first()? as usize;
|
||||
if count > DHT_MAX_ADDRS {
|
||||
return None;
|
||||
}
|
||||
for _ in 0..count {
|
||||
let ip = match *take(&mut bytes, 1)?.first()? {
|
||||
4 => IpAddr::V4(Ipv4Addr::from(
|
||||
<[u8; 4]>::try_from(take(&mut bytes, 4)?).ok()?,
|
||||
)),
|
||||
6 => IpAddr::V6(Ipv6Addr::from(
|
||||
<[u8; 16]>::try_from(take(&mut bytes, 16)?).ok()?,
|
||||
)),
|
||||
_ => return None,
|
||||
};
|
||||
let port = u16::from_be_bytes(take(&mut bytes, 2)?.try_into().ok()?);
|
||||
let socket = SocketAddr::new(ip, port);
|
||||
if usable(&socket, allow_loopback) {
|
||||
addr = addr.with_ip_addr(socket);
|
||||
}
|
||||
}
|
||||
(bytes.is_empty() && !addr.addrs.is_empty()).then_some(addr)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::testing::network;
|
||||
|
||||
fn address() -> EndpointAddr {
|
||||
EndpointAddr::new(iroh::SecretKey::generate().public())
|
||||
.with_ip_addr("127.0.0.1:12345".parse().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_reject_expiration_future_dates_truncation_and_oversize() {
|
||||
let addr = address();
|
||||
let bytes = encode_record(&addr, 1_000, true).unwrap();
|
||||
assert_eq!(decode_record(&bytes, 1_100, true).unwrap(), addr);
|
||||
assert!(decode_record(&bytes, 1_901, true).is_none());
|
||||
assert!(decode_record(&bytes, 100, true).is_none());
|
||||
for n in 0..bytes.len() {
|
||||
assert!(decode_record(&bytes[..n], 1_100, true).is_none());
|
||||
}
|
||||
assert!(decode_record(&vec![0; DHT_MAX_VALUE + 1], 1_100, true).is_none());
|
||||
assert!(encode_record(&addr, 1_000, false).is_err());
|
||||
assert!(decode_record(&bytes, 1_100, false).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stopped_custom_client_never_falls_back_to_public_bootstrap() {
|
||||
let net = mainline::Testnet::builder(3).build().unwrap();
|
||||
let client = MainlineDiscovery::local_testnet(&net.bootstrap).unwrap();
|
||||
let (name, secret) = network("mainline-shutdown");
|
||||
let keys = NetworkKeys::derive(&name, &secret);
|
||||
let backend = client.for_network(&keys);
|
||||
client.shutdown().await;
|
||||
assert!(backend.resolve(keys.discovery_key()).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_writers_share_slots_and_other_secrets_find_nothing() {
|
||||
let net = mainline::Testnet::builder(5).build().unwrap();
|
||||
let client_a = MainlineDiscovery::local_testnet(&net.bootstrap).unwrap();
|
||||
let client_b = MainlineDiscovery::local_testnet(&net.bootstrap).unwrap();
|
||||
let (name, secret) = network("mainline-slots");
|
||||
let keys = NetworkKeys::derive(&name, &secret);
|
||||
let a = client_a.for_network(&keys);
|
||||
let b = client_b.for_network(&keys);
|
||||
let first = address();
|
||||
let second = loop {
|
||||
let next = address();
|
||||
if slots_for(next.id) == slots_for(first.id) {
|
||||
break next;
|
||||
}
|
||||
};
|
||||
let key = keys.discovery_key();
|
||||
let (ra, rb) = tokio::join!(
|
||||
a.publish(key, first.clone()),
|
||||
b.publish(key, second.clone())
|
||||
);
|
||||
assert!(ra.is_ok() || rb.is_ok());
|
||||
let (found_a, found_b) = tokio::join!(a.resolve(key), b.resolve(key));
|
||||
let found_a = found_a.unwrap();
|
||||
let found_b = found_b.unwrap();
|
||||
assert!(
|
||||
found_a.iter().any(|c| c.addr == second) || found_b.iter().any(|c| c.addr == first),
|
||||
"at least one writer must discover the other despite colliding in both slots"
|
||||
);
|
||||
assert!(
|
||||
found_a
|
||||
.iter()
|
||||
.chain(&found_b)
|
||||
.all(|c| c.addr == first || c.addr == second)
|
||||
);
|
||||
let other = NetworkKeys::derive(&name, &crate::identity::NetworkSecret::generate());
|
||||
assert!(
|
||||
client_a
|
||||
.for_network(&other)
|
||||
.resolve(other.discovery_key())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
// Restarting the publisher must read seq from DHT instead of starting at 1.
|
||||
let restarted = client_a.for_network(&keys);
|
||||
restarted.publish(key, first.clone()).await.unwrap();
|
||||
assert!(
|
||||
b.resolve(key)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c.addr == first)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Per-network discovery tasks. Dropping the owner cancels all backend futures.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::{Instant, timeout};
|
||||
|
||||
use super::{Candidate, NetworkDiscovery};
|
||||
use crate::config::DiscoveryPolicy;
|
||||
use crate::identity::DiscoveryKey;
|
||||
use crate::net::EndpointAdapter;
|
||||
|
||||
pub(crate) struct DiscoveryWorker {
|
||||
connected: watch::Sender<Connectivity>,
|
||||
tasks: JoinSet<()>,
|
||||
backend: Arc<dyn NetworkDiscovery>,
|
||||
key: DiscoveryKey,
|
||||
endpoint: iroh::EndpointId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Connectivity {
|
||||
Initial,
|
||||
Connected,
|
||||
Isolated(Instant),
|
||||
}
|
||||
|
||||
impl DiscoveryWorker {
|
||||
pub(crate) fn spawn(
|
||||
backend: Arc<dyn NetworkDiscovery>,
|
||||
key: DiscoveryKey,
|
||||
adapter: EndpointAdapter,
|
||||
policy: DiscoveryPolicy,
|
||||
address_check: Duration,
|
||||
candidates: mpsc::Sender<Candidate>,
|
||||
) -> Self {
|
||||
let (connected, receiver) = watch::channel(Connectivity::Initial);
|
||||
let mut tasks = JoinSet::new();
|
||||
tasks.spawn(publish_loop(
|
||||
backend.clone(),
|
||||
key,
|
||||
adapter.clone(),
|
||||
policy.clone(),
|
||||
address_check,
|
||||
));
|
||||
tasks.spawn(lookup_loop(
|
||||
backend.clone(),
|
||||
key,
|
||||
policy,
|
||||
receiver,
|
||||
candidates,
|
||||
));
|
||||
Self {
|
||||
connected,
|
||||
tasks,
|
||||
backend,
|
||||
key,
|
||||
endpoint: adapter.endpoint_id(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_connected(&self, connected: bool) {
|
||||
self.connected.send_if_modified(|current| {
|
||||
if matches!(*current, Connectivity::Connected) == connected {
|
||||
return false;
|
||||
}
|
||||
*current = if connected {
|
||||
Connectivity::Connected
|
||||
} else {
|
||||
Connectivity::Isolated(Instant::now())
|
||||
};
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) async fn stop(mut self) {
|
||||
self.tasks.abort_all();
|
||||
while self.tasks.join_next().await.is_some() {}
|
||||
// BEP44 has no deletion. Memory/static backends can withdraw promptly;
|
||||
// an unresponsive backend must not extend agent shutdown.
|
||||
let _ = timeout(
|
||||
Duration::from_millis(250),
|
||||
self.backend.unpublish(self.key, self.endpoint),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn jitter(delay: Duration) -> Duration {
|
||||
delay
|
||||
.mul_f64(0.8 + 0.4 * rand::random::<f64>())
|
||||
.max(Duration::from_millis(10))
|
||||
}
|
||||
|
||||
async fn publish_loop(
|
||||
backend: Arc<dyn NetworkDiscovery>,
|
||||
key: DiscoveryKey,
|
||||
adapter: EndpointAdapter,
|
||||
policy: DiscoveryPolicy,
|
||||
address_check: Duration,
|
||||
) {
|
||||
let mut previous = None;
|
||||
let mut due = Instant::now();
|
||||
let mut ticker = tokio::time::interval(address_check.max(Duration::from_millis(10)));
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let mut addr = adapter.addr();
|
||||
addr.addrs.extend(adapter.loopback_addr().addrs);
|
||||
if previous.as_ref() != Some(&addr) {
|
||||
previous = Some(addr.clone());
|
||||
due = Instant::now();
|
||||
}
|
||||
if Instant::now() < due {
|
||||
continue;
|
||||
}
|
||||
let result = timeout(policy.request_timeout, backend.publish(key, addr.clone())).await;
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
due = Instant::now() + jitter(policy.publish_interval);
|
||||
}
|
||||
result => {
|
||||
tracing::debug!(?result, "discovery publication failed; will retry");
|
||||
due = Instant::now() + jitter(policy.lookup_interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn lookup_loop(
|
||||
backend: Arc<dyn NetworkDiscovery>,
|
||||
key: DiscoveryKey,
|
||||
policy: DiscoveryPolicy,
|
||||
mut connected: watch::Receiver<Connectivity>,
|
||||
candidates: mpsc::Sender<Candidate>,
|
||||
) {
|
||||
let mut last_isolation = None;
|
||||
let mut due = Instant::now();
|
||||
let mut delay = policy.lookup_interval;
|
||||
loop {
|
||||
let state = *connected.borrow_and_update();
|
||||
if matches!(state, Connectivity::Connected) {
|
||||
if connected.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Connectivity::Isolated(since) = state
|
||||
&& last_isolation != Some(since)
|
||||
{
|
||||
// Preserve the transition time even if a rapid reconnect/disconnect
|
||||
// overwrote a watch value before this task had a chance to run.
|
||||
due = since + policy.reconnect_delay;
|
||||
delay = policy.lookup_interval;
|
||||
last_isolation = Some(since);
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
changed = connected.changed() => {
|
||||
if changed.is_err() { break; }
|
||||
continue;
|
||||
}
|
||||
_ = tokio::time::sleep_until(due) => {}
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
changed = connected.changed() => {
|
||||
if changed.is_err() { break; }
|
||||
continue;
|
||||
}
|
||||
result = timeout(policy.request_timeout, backend.resolve_into(key, candidates.clone())) => {
|
||||
if !matches!(result, Ok(Ok(()))) {
|
||||
tracing::debug!(?result, "discovery lookup failed; will retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
due = Instant::now() + jitter(delay);
|
||||
delay = delay.saturating_mul(2).min(policy.max_lookup_interval);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::Result;
|
||||
use crate::discovery::BoxFuture;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Reads(AtomicUsize);
|
||||
impl NetworkDiscovery for Reads {
|
||||
fn name(&self) -> &str {
|
||||
"reads"
|
||||
}
|
||||
fn publish<'a>(
|
||||
&'a self,
|
||||
_: DiscoveryKey,
|
||||
_: iroh::EndpointAddr,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
fn unpublish<'a>(
|
||||
&'a self,
|
||||
_: DiscoveryKey,
|
||||
_: iroh::EndpointId,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
fn resolve<'a>(&'a self, _: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Box::pin(async { Ok(Vec::new()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn recovery_requires_a_full_minute_and_reconnection_restarts_that_minute() {
|
||||
let reads = Arc::new(Reads::default());
|
||||
let (tx, rx) = watch::channel(Connectivity::Connected);
|
||||
let (candidates, _receiver) = mpsc::channel(16);
|
||||
let mut tasks = JoinSet::new();
|
||||
tasks.spawn(lookup_loop(
|
||||
reads.clone(),
|
||||
DiscoveryKey::from_bytes([0; 32]),
|
||||
DiscoveryPolicy::default(),
|
||||
rx,
|
||||
candidates,
|
||||
));
|
||||
tokio::task::yield_now().await;
|
||||
tx.send(Connectivity::Isolated(Instant::now())).unwrap();
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(Duration::from_secs(59)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(reads.0.load(Ordering::SeqCst), 0);
|
||||
tx.send(Connectivity::Connected).unwrap();
|
||||
tx.send(Connectivity::Isolated(Instant::now())).unwrap();
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(Duration::from_secs(59)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(reads.0.load(Ordering::SeqCst), 0);
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(reads.0.load(Ordering::SeqCst), 1);
|
||||
tx.send(Connectivity::Connected).unwrap();
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(Duration::from_secs(600)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(reads.0.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! A DNS view of the overlay.
|
||||
//!
|
||||
//! The names and addresses of a network, served to the host that runs the
|
||||
//! agent, so members can be reached by name. Answered from signed state, so a
|
||||
//! member that is switched off still resolves.
|
||||
//!
|
||||
//! Three parts, kept apart on purpose:
|
||||
//!
|
||||
//! * [`zone`] decides what the answer is. Pure, and knows nothing about
|
||||
//! packets or sockets.
|
||||
//! * [`server`] puts that on the wire.
|
||||
//! * `publish` tells the operating system where to send its questions,
|
||||
//! which is the only part that differs between platforms.
|
||||
|
||||
pub mod publish;
|
||||
pub mod server;
|
||||
pub mod zone;
|
||||
|
||||
pub use publish::{DnsPublisher, PublishError, Published};
|
||||
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
|
||||
/// Where the server listens, one list per address family.
|
||||
///
|
||||
/// Loopback, and only loopback. The zones are a view *for the host running
|
||||
/// the agent*: it is that host's resolver that is pointed at them. Binding
|
||||
/// an overlay address instead would put them in front of the whole mesh —
|
||||
/// and an agent in several networks would then answer one network's
|
||||
/// questions about another's names, which is precisely what separate
|
||||
/// networks are for.
|
||||
///
|
||||
/// Both families, and independently: a question arrives over whichever one
|
||||
/// the resolver happens to use, and on a host with one of them switched off
|
||||
/// the other must still answer. So neither list failing says anything about
|
||||
/// the other, and a listener is opened from each.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListenPlan {
|
||||
/// IPv4 candidates, best first.
|
||||
pub v4: Vec<SocketAddr>,
|
||||
/// IPv6 candidates, best first.
|
||||
pub v6: Vec<SocketAddr>,
|
||||
}
|
||||
|
||||
impl ListenPlan {
|
||||
/// The two lists, so a caller can bind one listener from each.
|
||||
pub fn families(&self) -> [&[SocketAddr]; 2] {
|
||||
[&self.v4, &self.v6]
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the listen plan for one port.
|
||||
pub fn listen_plan(port: u16) -> ListenPlan {
|
||||
ListenPlan {
|
||||
v4: vec![SocketAddr::from((Ipv4Addr::LOCALHOST, port))],
|
||||
v6: vec![SocketAddr::from((Ipv6Addr::LOCALHOST, port))],
|
||||
}
|
||||
}
|
||||
|
||||
pub use server::{DnsServer, SharedZone};
|
||||
pub use zone::{Answer, Query, Zone, ZoneError, ZoneName, Zones};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn both_families_are_planned_for_and_neither_is_reachable_off_this_host() {
|
||||
let plan = listen_plan(5354);
|
||||
assert_eq!(
|
||||
plan.v4,
|
||||
vec!["127.0.0.1:5354".parse::<SocketAddr>().unwrap()]
|
||||
);
|
||||
assert_eq!(plan.v6, vec!["[::1]:5354".parse::<SocketAddr>().unwrap()]);
|
||||
assert_eq!(plan.families().len(), 2);
|
||||
// Nothing here is reachable from another member: the zones are a
|
||||
// view for this host, and an agent in two networks must not answer
|
||||
// one network's questions about the other's names.
|
||||
for address in plan.families().concat() {
|
||||
assert!(address.ip().is_loopback(), "{address} is not loopback");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Telling the macOS resolver to send some questions here, through
|
||||
//! `/etc/resolver`.
|
||||
//!
|
||||
//! macOS reads one file per domain out of `/etc/resolver`: a file named after
|
||||
//! a domain, containing the `nameserver` and `port` to send that domain's
|
||||
//! questions to, makes the system resolver a *scoped* resolver for exactly
|
||||
//! that suffix and nothing else (see `resolver(5)`). That is the same contract
|
||||
//! the systemd-resolved and NRPT publishers arrange on the other platforms:
|
||||
//! route these suffixes to this server, and claim nothing else.
|
||||
//!
|
||||
//! # Why a port, and so no privileged socket
|
||||
//!
|
||||
//! A resolver file carries a `port`, so the server does not have to sit on 53
|
||||
//! and the agent needs nothing like `CAP_NET_BIND_SERVICE`. It can serve on a
|
||||
//! high port and still be reached for its suffixes.
|
||||
//!
|
||||
//! # Tagged, reversible, and never clobbering
|
||||
//!
|
||||
//! Every file this publisher writes begins with a marker line, so it only ever
|
||||
//! replaces or removes files it wrote itself: a pre-existing `/etc/resolver`
|
||||
//! entry a user set up by hand is left untouched. The files are removed when
|
||||
//! the resolver is turned off and on shutdown; a crash leaves them behind, and
|
||||
//! the next apply for the same domains overwrites them.
|
||||
//!
|
||||
//! # Privilege
|
||||
//!
|
||||
//! Writing under `/etc/resolver` needs root. A permission error is reported as
|
||||
//! a refusal — the kind of failure a person has to act on — rather than a
|
||||
//! transient one, so the agent does not retry it at the ordinary pace.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published};
|
||||
|
||||
/// The directory macOS reads scoped resolvers from.
|
||||
const RESOLVER_DIR: &str = "/etc/resolver";
|
||||
|
||||
/// The first line of every file this publisher writes, so it recognises its
|
||||
/// own and never touches a file a user created.
|
||||
const MARKER: &str = "# managed by tsunagi — do not edit";
|
||||
|
||||
/// Writes and removes `/etc/resolver` files for the overlay zones.
|
||||
#[derive(Debug)]
|
||||
pub struct ResolverDirPublisher {
|
||||
/// The resolver directory. A field rather than a constant so the tests can
|
||||
/// point it at a temporary directory and never touch the real one.
|
||||
dir: PathBuf,
|
||||
/// The domain files written by the last apply, so a later apply can remove
|
||||
/// the ones no longer wanted and `revert` can remove them all.
|
||||
written: Mutex<BTreeSet<String>>,
|
||||
}
|
||||
|
||||
impl Default for ResolverDirPublisher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolverDirPublisher {
|
||||
/// A publisher writing to the real `/etc/resolver`.
|
||||
pub fn new() -> Self {
|
||||
Self::in_dir(RESOLVER_DIR)
|
||||
}
|
||||
|
||||
/// A publisher writing to a specific directory. For the tests.
|
||||
fn in_dir(dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
dir: dir.into(),
|
||||
written: Mutex::new(BTreeSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_inner(&self, published: &Published) -> Result<(), PublishError> {
|
||||
let desired = desired_files(published)?;
|
||||
|
||||
// The directory may not exist yet; creating it needs root.
|
||||
std::fs::create_dir_all(&self.dir).map_err(|err| classify(&self.dir, err))?;
|
||||
|
||||
// Remove the files from a previous apply that are no longer wanted.
|
||||
let previous = lock(&self.written).clone();
|
||||
for name in previous.difference(&keys(&desired)) {
|
||||
remove_if_ours(&self.dir.join(name));
|
||||
}
|
||||
|
||||
for (name, body) in &desired {
|
||||
let path = self.dir.join(name);
|
||||
// Only ever overwrite a file that is already ours, or a name that
|
||||
// is free. A resolver a user set up by hand is left in place and
|
||||
// reported, never clobbered.
|
||||
if path.exists() && !is_ours(&path) {
|
||||
return Err(PublishError::Failed(format!(
|
||||
"{} already exists and was not created by tsunagi; \
|
||||
remove it or rename the network to publish its resolver",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
std::fs::write(&path, body).map_err(|err| classify(&path, err))?;
|
||||
}
|
||||
|
||||
*lock(&self.written) = keys(&desired);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn revert_inner(&self) {
|
||||
let written = std::mem::take(&mut *lock(&self.written));
|
||||
for name in &written {
|
||||
remove_if_ours(&self.dir.join(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsPublisher for ResolverDirPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"resolver-dir"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
// A handful of small file writes, short enough to run in place rather
|
||||
// than hop to the blocking pool.
|
||||
Box::pin(async move { self.apply_inner(published) })
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
self.revert_inner();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolver files a publication implies: a map of file name to contents.
|
||||
fn desired_files(published: &Published) -> Result<Vec<(String, String)>, PublishError> {
|
||||
if published.servers.is_empty() {
|
||||
return Err(PublishError::Failed(
|
||||
"no server address to point the resolver at".to_string(),
|
||||
));
|
||||
}
|
||||
let body = file_contents(published);
|
||||
let mut files = Vec::new();
|
||||
for domain in &published.domains {
|
||||
let name = resolver_file_name(domain)?;
|
||||
files.push((name, body.clone()));
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// The contents of one resolver file: the marker, every server, one port.
|
||||
fn file_contents(published: &Published) -> String {
|
||||
let mut out = String::from(MARKER);
|
||||
out.push('\n');
|
||||
for server in &published.servers {
|
||||
let ip = match server.ip() {
|
||||
IpAddr::V4(v4) => v4.to_string(),
|
||||
IpAddr::V6(v6) => v6.to_string(),
|
||||
};
|
||||
out.push_str("nameserver ");
|
||||
out.push_str(&ip);
|
||||
out.push('\n');
|
||||
}
|
||||
// One port for the file; every server listens on the same one. The `port`
|
||||
// directive is what lets the server stay off 53.
|
||||
if let Some(first) = published.servers.first() {
|
||||
out.push_str("port ");
|
||||
out.push_str(&first.port().to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The file name for a domain, rejecting anything that is not a plain label
|
||||
/// path component: a resolver file name must never escape the directory.
|
||||
fn resolver_file_name(domain: &str) -> Result<String, PublishError> {
|
||||
let trimmed = domain.trim_end_matches('.');
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.contains('/')
|
||||
|| trimmed.contains('\\')
|
||||
|| trimmed.contains(std::path::MAIN_SEPARATOR)
|
||||
|| trimmed == "."
|
||||
|| trimmed == ".."
|
||||
|| trimmed.starts_with('.')
|
||||
{
|
||||
return Err(PublishError::Failed(format!(
|
||||
"cannot make a resolver file name for the domain {domain:?}"
|
||||
)));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
/// Whether a file is one this publisher wrote, by its marker first line.
|
||||
fn is_ours(path: &Path) -> bool {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => text.lines().next() == Some(MARKER),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a file, but only if it is one of ours. Best effort.
|
||||
fn remove_if_ours(path: &Path) {
|
||||
if is_ours(path)
|
||||
&& let Err(err) = std::fs::remove_file(path)
|
||||
&& err.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
tracing::debug!(path = %path.display(), %err, "cannot remove the resolver file");
|
||||
}
|
||||
}
|
||||
|
||||
/// The set of file names in a desired-files list.
|
||||
fn keys(files: &[(String, String)]) -> BTreeSet<String> {
|
||||
files.iter().map(|(name, _)| name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Turns a filesystem error into the right kind of publish error: a permission
|
||||
/// problem is a refusal a person must act on, anything else is a plain failure.
|
||||
fn classify(path: &Path, err: std::io::Error) -> PublishError {
|
||||
if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
PublishError::Refused(format!(
|
||||
"writing {} needs root; start the agent with `sudo` or as a root LaunchDaemon",
|
||||
path.display()
|
||||
))
|
||||
} else {
|
||||
PublishError::Failed(format!("cannot write {}: {err}", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn published(domains: &[&str]) -> Published {
|
||||
Published {
|
||||
interface: "utun5".into(),
|
||||
servers: vec![
|
||||
"10.13.37.1:5353".parse::<SocketAddr>().unwrap(),
|
||||
"[fd00::1]:5353".parse::<SocketAddr>().unwrap(),
|
||||
],
|
||||
domains: domains.iter().map(|d| d.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_lists_every_server_and_one_port_under_the_marker() {
|
||||
let body = file_contents(&published(&["mynet."]));
|
||||
let mut lines = body.lines();
|
||||
assert_eq!(lines.next(), Some(MARKER));
|
||||
assert_eq!(lines.next(), Some("nameserver 10.13.37.1"));
|
||||
assert_eq!(lines.next(), Some("nameserver fd00::1"));
|
||||
assert_eq!(lines.next(), Some("port 5353"));
|
||||
assert_eq!(lines.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_domain_becomes_a_plain_file_name_without_its_trailing_dot() {
|
||||
assert_eq!(resolver_file_name("mynet.").unwrap(), "mynet");
|
||||
assert_eq!(resolver_file_name("my.net").unwrap(), "my.net");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_domain_that_would_escape_the_directory_is_refused() {
|
||||
assert!(resolver_file_name("../etc/passwd").is_err());
|
||||
assert!(resolver_file_name("a/b").is_err());
|
||||
assert!(resolver_file_name("").is_err());
|
||||
assert!(resolver_file_name(".").is_err());
|
||||
assert!(resolver_file_name(".hidden").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_writes_a_file_per_domain_and_revert_removes_them() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let publisher = ResolverDirPublisher::in_dir(dir.path());
|
||||
|
||||
publisher
|
||||
.apply(&published(&["mynet.", "other."]))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(dir.path().join("mynet").is_file());
|
||||
assert!(dir.path().join("other").is_file());
|
||||
|
||||
publisher.revert().await.unwrap();
|
||||
assert!(!dir.path().join("mynet").exists());
|
||||
assert!(!dir.path().join("other").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn re_applying_removes_a_domain_that_is_no_longer_wanted() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let publisher = ResolverDirPublisher::in_dir(dir.path());
|
||||
|
||||
publisher.apply(&published(&["a.", "b."])).await.unwrap();
|
||||
publisher.apply(&published(&["a."])).await.unwrap();
|
||||
|
||||
assert!(dir.path().join("a").is_file());
|
||||
assert!(
|
||||
!dir.path().join("b").exists(),
|
||||
"the dropped domain's file is removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_file_a_user_created_is_never_clobbered_or_removed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let foreign = dir.path().join("mynet");
|
||||
std::fs::write(&foreign, "nameserver 9.9.9.9\n").unwrap();
|
||||
|
||||
let publisher = ResolverDirPublisher::in_dir(dir.path());
|
||||
let err = publisher.apply(&published(&["mynet."])).await.unwrap_err();
|
||||
assert!(matches!(err, PublishError::Failed(_)), "{err:?}");
|
||||
// Untouched.
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&foreign).unwrap(),
|
||||
"nameserver 9.9.9.9\n"
|
||||
);
|
||||
|
||||
// And revert leaves a foreign file alone even if it is in our set.
|
||||
publisher.revert().await.unwrap();
|
||||
assert!(foreign.is_file());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! A pretend resolver, so the wiring is tested without touching this host's.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published};
|
||||
|
||||
/// Records what it was asked to do, and can be told to refuse.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MockPublisher {
|
||||
applied: Arc<Mutex<Option<Published>>>,
|
||||
failure: Option<PublishError>,
|
||||
}
|
||||
|
||||
impl MockPublisher {
|
||||
/// A publisher that accepts everything.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// A publisher that refuses everything, with this reason.
|
||||
pub fn failing(error: PublishError) -> Self {
|
||||
Self {
|
||||
applied: Arc::new(Mutex::new(None)),
|
||||
failure: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// What is currently applied, if anything.
|
||||
pub fn applied(&self) -> Option<Published> {
|
||||
match self.applied.lock() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsPublisher for MockPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
if let Some(failure) = &self.failure {
|
||||
return Err(failure.clone());
|
||||
}
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = Some(published.clone()),
|
||||
Err(poisoned) => *poisoned.into_inner() = Some(published.clone()),
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = None,
|
||||
Err(poisoned) => *poisoned.into_inner() = None,
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn published() -> Published {
|
||||
Published {
|
||||
interface: "tsundemo".into(),
|
||||
servers: vec![
|
||||
"10.13.37.69:5354".parse().unwrap(),
|
||||
"[::1]:5354".parse().unwrap(),
|
||||
],
|
||||
domains: vec!["lab".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn applying_and_reverting_are_both_recorded() {
|
||||
let publisher = MockPublisher::new();
|
||||
assert!(publisher.applied().is_none());
|
||||
|
||||
publisher.apply(&published()).await.unwrap();
|
||||
assert_eq!(publisher.applied(), Some(published()));
|
||||
|
||||
publisher.revert().await.unwrap();
|
||||
assert!(publisher.applied().is_none());
|
||||
// Reverting twice is not an error: shutdown must not fail here.
|
||||
publisher.revert().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refusal_leaves_nothing_applied() {
|
||||
let publisher = MockPublisher::failing(PublishError::Refused("polkit said no".into()));
|
||||
assert!(publisher.apply(&published()).await.is_err());
|
||||
assert!(publisher.applied().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Telling the operating system where to send its questions.
|
||||
//!
|
||||
//! The server answers whether or not this works. That is the whole reason it
|
||||
//! is a separate thing: if the resolver cannot be configured — no
|
||||
//! systemd-resolved, an unwilling polkit, a platform nobody has written this
|
||||
//! for — the port is still up and the user can point something at it by
|
||||
//! hand. A failure here is a degraded overlay, not a broken one.
|
||||
//!
|
||||
//! Only the *mechanics* differ between systems. What has to be arranged is
|
||||
//! the same everywhere: send questions for these suffixes to this address,
|
||||
//! through this interface, and do not make it the resolver for anything
|
||||
//! else. That is [`Published`]; the rest is behind [`DnsPublisher`].
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
mod mock;
|
||||
pub use mock::MockPublisher;
|
||||
|
||||
#[cfg(all(feature = "dns-publish", target_os = "linux"))]
|
||||
mod resolved;
|
||||
#[cfg(all(feature = "dns-publish", target_os = "linux"))]
|
||||
pub use resolved::ResolvedPublisher;
|
||||
|
||||
#[cfg(all(feature = "dns-publish", target_os = "windows"))]
|
||||
mod windows;
|
||||
#[cfg(all(feature = "dns-publish", target_os = "windows"))]
|
||||
pub use windows::NrptPublisher;
|
||||
|
||||
#[cfg(all(feature = "dns-publish", target_os = "macos"))]
|
||||
mod macos;
|
||||
#[cfg(all(feature = "dns-publish", target_os = "macos"))]
|
||||
pub use macos::ResolverDirPublisher;
|
||||
|
||||
mod unsupported;
|
||||
pub use unsupported::UnsupportedPublisher;
|
||||
|
||||
/// What the operating system is asked to do.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Published {
|
||||
/// The interface questions should be sent through.
|
||||
///
|
||||
/// The server listens on an overlay address, which is only reachable
|
||||
/// over the overlay interface, so the two travel together. It must be an
|
||||
/// interface this agent created: configuring one it did not is
|
||||
/// configuring somebody else's.
|
||||
pub interface: String,
|
||||
/// Every address the server is listening on, best first.
|
||||
///
|
||||
/// One per address family, so a resolver reaches it over whichever it
|
||||
/// uses. All of them are published together and none is preferred by
|
||||
/// this: which one a resolver picks is its business.
|
||||
pub servers: Vec<SocketAddr>,
|
||||
/// The suffixes that belong to this server.
|
||||
///
|
||||
/// Routing suffixes only: they say *which questions* come here, never
|
||||
/// that this is the resolver for anything else.
|
||||
pub domains: Vec<String>,
|
||||
}
|
||||
|
||||
/// Why the resolver could not be told.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum PublishError {
|
||||
/// There is nothing here that can be configured this way.
|
||||
#[error("{0}")]
|
||||
Unavailable(String),
|
||||
/// Something is there, and it declined.
|
||||
///
|
||||
/// Separate from a plain failure because the answer is different: this
|
||||
/// one is about who the agent is running as, not about whether the thing
|
||||
/// works.
|
||||
#[error("{0}")]
|
||||
Refused(String),
|
||||
/// It was there, it accepted the request, and it went wrong anyway.
|
||||
#[error("{0}")]
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl PublishError {
|
||||
/// Whether waiting will fix it.
|
||||
///
|
||||
/// A refusal will not change on its own — somebody has to grant
|
||||
/// permission — so retrying it at the pace of everything else is just
|
||||
/// noise. Anything else might be a service still starting.
|
||||
pub fn needs_a_human(&self) -> bool {
|
||||
matches!(self, PublishError::Refused(_))
|
||||
}
|
||||
|
||||
/// One line for a status table.
|
||||
pub fn remedy(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
PublishError::Refused(_) => Some(
|
||||
"grant this user the `org.freedesktop.resolve1.set-*` actions in \
|
||||
/etc/polkit-1/rules.d, or run the agent as a system service",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The polkit rule that lets this user configure the resolver.
|
||||
///
|
||||
/// Printed in full rather than described, because the point of this feature
|
||||
/// is that the user has as little to do as possible, and "write a polkit
|
||||
/// rule" is a great deal more work than pasting one.
|
||||
///
|
||||
/// It grants exactly the four actions this agent calls and nothing else.
|
||||
/// polkit decides by user id — a capability does not help here — so there is
|
||||
/// no way to do this from inside the process.
|
||||
pub fn polkit_recipe(user: &str) -> String {
|
||||
format!(
|
||||
"sudo tee /etc/polkit-1/rules.d/50-tsunagi-resolved.rules > /dev/null <<'RULE'\n\
|
||||
polkit.addRule(function(action, subject) {{\n\
|
||||
\x20 var allowed = [\n\
|
||||
\x20 \"org.freedesktop.resolve1.set-dns-servers\",\n\
|
||||
\x20 \"org.freedesktop.resolve1.set-domains\",\n\
|
||||
\x20 \"org.freedesktop.resolve1.set-default-route\",\n\
|
||||
\x20 \"org.freedesktop.resolve1.revert\"\n\
|
||||
\x20 ];\n\
|
||||
\x20 if (allowed.indexOf(action.id) >= 0 && subject.user == \"{user}\") {{\n\
|
||||
\x20 return polkit.Result.YES;\n\
|
||||
\x20 }}\n\
|
||||
}});\n\
|
||||
RULE"
|
||||
)
|
||||
}
|
||||
|
||||
/// Arranges for the operating system to ask this server.
|
||||
pub trait DnsPublisher: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// A short name used in diagnostics.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Applies the setting, replacing whatever this publisher set before.
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>>;
|
||||
|
||||
/// Undoes it.
|
||||
///
|
||||
/// Reverting something that was never applied succeeds: this runs on the
|
||||
/// shutdown path, where the setting being gone is the point.
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>>;
|
||||
}
|
||||
|
||||
/// The kernel's index for an interface.
|
||||
///
|
||||
/// From sysfs, which needs no privileges and no netlink round trip.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn interface_index(name: &str) -> Option<u32> {
|
||||
std::fs::read_to_string(format!("/sys/class/net/{name}/ifindex"))
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// The kernel's index for an interface.
|
||||
///
|
||||
/// Only the systemd-resolved publisher needs one, so off Linux there is
|
||||
/// nothing to look up and this is always `None`.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn interface_index(_name: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_a_refusal_suggests_what_to_do_about_it() {
|
||||
// The other two are conditions of the host, not of the user.
|
||||
assert!(PublishError::Refused("no".into()).remedy().is_some());
|
||||
assert!(PublishError::Unavailable("none".into()).remedy().is_none());
|
||||
assert!(PublishError::Failed("bang".into()).remedy().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_refusal_waits_for_a_person() {
|
||||
// The rest may come right on their own, so they are worth retrying
|
||||
// at the ordinary pace; a refusal is not.
|
||||
assert!(PublishError::Refused("no".into()).needs_a_human());
|
||||
assert!(!PublishError::Unavailable("none".into()).needs_a_human());
|
||||
assert!(!PublishError::Failed("bang".into()).needs_a_human());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_polkit_recipe_grants_what_is_called_and_no_more() {
|
||||
let recipe = polkit_recipe("ab");
|
||||
for action in [
|
||||
"set-dns-servers",
|
||||
"set-domains",
|
||||
"set-default-route",
|
||||
"revert",
|
||||
] {
|
||||
assert!(recipe.contains(action), "{action} missing from:\n{recipe}");
|
||||
}
|
||||
// Nothing beyond what the agent calls: a rule that granted the lot
|
||||
// would be handing out more than this feature needs.
|
||||
for other in ["set-dnssec", "set-mdns", "register-service", "set-llmnr"] {
|
||||
assert!(!recipe.contains(other), "{other} should not be granted");
|
||||
}
|
||||
assert!(recipe.contains("subject.user == \"ab\""));
|
||||
// ES5: the rules engine is duktape and has no `startsWith`.
|
||||
assert!(!recipe.contains("startsWith"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn an_interface_index_is_read_from_the_running_kernel() {
|
||||
assert_eq!(interface_index("lo"), Some(1));
|
||||
assert_eq!(interface_index("tsunagi-no-such-interface"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Telling systemd-resolved to send some questions here.
|
||||
//!
|
||||
//! Three calls on `org.freedesktop.resolve1.Manager`, all scoped to the
|
||||
//! overlay interface:
|
||||
//!
|
||||
//! * `SetLinkDNSEx` — where to send them. The `Ex` form carries a **port**,
|
||||
//! which is why this server does not have to sit on 53 and the agent needs
|
||||
//! no `CAP_NET_BIND_SERVICE`. Systems before systemd 247 have only
|
||||
//! `SetLinkDNS`, which has no port; there the fallback only works if the
|
||||
//! server did get port 53, and it says so rather than appearing to work.
|
||||
//! * `SetLinkDomains` with `routing_only` set — a *routing* suffix, the
|
||||
//! `~domain` form. It says which questions come here and claims nothing
|
||||
//! else.
|
||||
//! * `SetLinkDefaultRoute(false)` — so this never becomes the resolver for
|
||||
//! anything outside those suffixes. Without it resolved may fall back to
|
||||
//! this link for ordinary names, and this server refuses those.
|
||||
//!
|
||||
//! # It cleans up by itself
|
||||
//!
|
||||
//! resolved keys all of this to the interface, and drops it when the
|
||||
//! interface goes. The overlay interface belongs to a file descriptor the
|
||||
//! agent holds, so it goes when the agent does — however the agent goes. The
|
||||
//! explicit `RevertLink` on shutdown only makes that immediate.
|
||||
//!
|
||||
//! # Privilege
|
||||
//!
|
||||
//! resolved asks polkit, and polkit decides by user id, not by capability.
|
||||
//! So `CAP_NET_ADMIN` does not help here: an ordinary user is prompted or
|
||||
//! refused, while a system service running as root is not. That refusal is
|
||||
//! reported as its own kind of error, because the answer to it is different
|
||||
//! from the answer to "resolved is not installed".
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published, interface_index};
|
||||
|
||||
/// `AF_INET`, as resolved wants it.
|
||||
const AF_INET: i32 = 2;
|
||||
/// `AF_INET6`.
|
||||
const AF_INET6: i32 = 10;
|
||||
|
||||
#[zbus::proxy(
|
||||
interface = "org.freedesktop.resolve1.Manager",
|
||||
default_service = "org.freedesktop.resolve1",
|
||||
default_path = "/org/freedesktop/resolve1"
|
||||
)]
|
||||
trait Resolved {
|
||||
/// Servers for a link, with a port and a name. systemd 247 and later.
|
||||
#[zbus(name = "SetLinkDNSEx")]
|
||||
fn set_link_dns_ex(
|
||||
&self,
|
||||
ifindex: i32,
|
||||
addresses: &[(i32, Vec<u8>, u16, String)],
|
||||
) -> zbus::Result<()>;
|
||||
|
||||
/// Servers for a link, without a port. Always port 53.
|
||||
#[zbus(name = "SetLinkDNS")]
|
||||
fn set_link_dns(&self, ifindex: i32, addresses: &[(i32, Vec<u8>)]) -> zbus::Result<()>;
|
||||
|
||||
/// Suffixes for a link. The flag makes one routing-only.
|
||||
#[zbus(name = "SetLinkDomains")]
|
||||
fn set_link_domains(&self, ifindex: i32, domains: &[(String, bool)]) -> zbus::Result<()>;
|
||||
|
||||
/// Whether this link may answer for names outside its suffixes.
|
||||
#[zbus(name = "SetLinkDefaultRoute")]
|
||||
fn set_link_default_route(&self, ifindex: i32, enable: bool) -> zbus::Result<()>;
|
||||
|
||||
/// Forgets everything set for a link.
|
||||
#[zbus(name = "RevertLink")]
|
||||
fn revert_link(&self, ifindex: i32) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
/// Configures systemd-resolved over D-Bus.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ResolvedPublisher {
|
||||
/// The link last configured, so shutdown knows what to undo.
|
||||
applied: std::sync::Mutex<Option<u32>>,
|
||||
}
|
||||
|
||||
impl ResolvedPublisher {
|
||||
/// Creates the publisher. Nothing is contacted until [`Self::apply`].
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn proxy() -> Result<ResolvedProxy<'static>, PublishError> {
|
||||
let connection = zbus::Connection::system().await.map_err(|err| {
|
||||
PublishError::Unavailable(format!("no system D-Bus to talk to resolved on: {err}"))
|
||||
})?;
|
||||
ResolvedProxy::new(&connection).await.map_err(|err| {
|
||||
PublishError::Unavailable(format!("systemd-resolved is not answering: {err}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a D-Bus failure into the kind of failure it actually is.
|
||||
fn classify(err: zbus::Error, what: &str) -> PublishError {
|
||||
let name = match &err {
|
||||
zbus::Error::MethodError(name, _, _) => name.as_str().to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
match name.as_str() {
|
||||
"org.freedesktop.DBus.Error.InteractiveAuthorizationRequired"
|
||||
| "org.freedesktop.DBus.Error.AccessDenied" => {
|
||||
PublishError::Refused(format!("systemd-resolved refused {what}: {err}"))
|
||||
}
|
||||
"org.freedesktop.DBus.Error.UnknownMethod"
|
||||
| "org.freedesktop.DBus.Error.ServiceUnknown" => {
|
||||
PublishError::Unavailable(format!("systemd-resolved cannot do {what}: {err}"))
|
||||
}
|
||||
_ => PublishError::Failed(format!("systemd-resolved failed {what}: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The address in the shape resolved wants: a family and raw octets.
|
||||
fn wire_address(address: IpAddr) -> (i32, Vec<u8>) {
|
||||
match address {
|
||||
IpAddr::V4(address) => (AF_INET, address.octets().to_vec()),
|
||||
IpAddr::V6(address) => (AF_INET6, address.octets().to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsPublisher for ResolvedPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"systemd-resolved"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
let ifindex = interface_index(&published.interface).ok_or_else(|| {
|
||||
PublishError::Unavailable(format!(
|
||||
"interface `{}` is not on this host, so there is no link to configure",
|
||||
published.interface
|
||||
))
|
||||
})?;
|
||||
if published.servers.is_empty() {
|
||||
return Err(PublishError::Unavailable(
|
||||
"there is no listening address to send questions to".to_string(),
|
||||
));
|
||||
}
|
||||
let proxy = Self::proxy().await?;
|
||||
let index = ifindex as i32;
|
||||
// Every family in one call, because this replaces the link's
|
||||
// whole list: sending them one at a time would leave only the
|
||||
// last. A resolver then picks whichever it can reach.
|
||||
let servers: Vec<(i32, Vec<u8>, u16, String)> = published
|
||||
.servers
|
||||
.iter()
|
||||
.map(|server| {
|
||||
let (family, octets) = wire_address(server.ip());
|
||||
(family, octets, server.port(), String::new())
|
||||
})
|
||||
.collect();
|
||||
|
||||
match proxy.set_link_dns_ex(index, &servers).await {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
let classified = classify(err, "setting the link's DNS servers");
|
||||
// Older systemd has no `Ex` form, and the plain one is
|
||||
// always port 53. Falling back to it when the server is
|
||||
// somewhere else would point resolved at nothing.
|
||||
if !matches!(classified, PublishError::Unavailable(_)) {
|
||||
return Err(classified);
|
||||
}
|
||||
if let Some(elsewhere) =
|
||||
published.servers.iter().find(|server| server.port() != 53)
|
||||
{
|
||||
return Err(PublishError::Unavailable(format!(
|
||||
"this systemd-resolved cannot be given a port, and the server is on \
|
||||
{}. Run the server on port 53, or point your resolver at \
|
||||
{elsewhere} yourself.",
|
||||
elsewhere.port()
|
||||
)));
|
||||
}
|
||||
let plain: Vec<(i32, Vec<u8>)> = servers
|
||||
.into_iter()
|
||||
.map(|(family, octets, _, _)| (family, octets))
|
||||
.collect();
|
||||
proxy
|
||||
.set_link_dns(index, &plain)
|
||||
.await
|
||||
.map_err(|err| classify(err, "setting the link's DNS servers"))?;
|
||||
}
|
||||
}
|
||||
|
||||
let domains: Vec<(String, bool)> = published
|
||||
.domains
|
||||
.iter()
|
||||
.map(|domain| (domain.clone(), true))
|
||||
.collect();
|
||||
proxy
|
||||
.set_link_domains(index, &domains)
|
||||
.await
|
||||
.map_err(|err| classify(err, "setting the link's search domains"))?;
|
||||
|
||||
// Last, and deliberately: until this is off, resolved may send
|
||||
// ordinary names here, and this server refuses them.
|
||||
proxy
|
||||
.set_link_default_route(index, false)
|
||||
.await
|
||||
.map_err(|err| classify(err, "clearing the link's default route"))?;
|
||||
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = Some(ifindex),
|
||||
Err(poisoned) => *poisoned.into_inner() = Some(ifindex),
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
let applied = match self.applied.lock() {
|
||||
Ok(mut guard) => guard.take(),
|
||||
Err(poisoned) => poisoned.into_inner().take(),
|
||||
};
|
||||
let Some(ifindex) = applied else {
|
||||
return Ok(());
|
||||
};
|
||||
let proxy = Self::proxy().await?;
|
||||
match proxy.revert_link(ifindex as i32).await {
|
||||
Ok(()) => Ok(()),
|
||||
// The link going away takes the setting with it, so this is
|
||||
// the outcome asked for rather than a failure.
|
||||
Err(zbus::Error::MethodError(name, _, _))
|
||||
if name.as_str().contains("NoSuchLink") =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(classify(err, "reverting the link")),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
#[test]
|
||||
fn an_address_is_encoded_the_way_resolved_expects() {
|
||||
assert_eq!(
|
||||
wire_address(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 69))),
|
||||
(AF_INET, vec![10, 13, 37, 69])
|
||||
);
|
||||
let (family, octets) = wire_address("fd55::1".parse().unwrap());
|
||||
assert_eq!(family, AF_INET6);
|
||||
assert_eq!(octets.len(), 16);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_interface_that_is_not_here_is_unavailable_not_a_failure() {
|
||||
// Nothing is contacted: there is no link to configure, and saying so
|
||||
// is the honest answer without bothering the bus.
|
||||
let publisher = ResolvedPublisher::new();
|
||||
let published = Published {
|
||||
interface: "tsunagi-no-such-interface".into(),
|
||||
servers: vec![SocketAddr::from(([10, 13, 37, 69], 5354))],
|
||||
domains: vec!["lab".into()],
|
||||
};
|
||||
let err = publisher.apply(&published).await.unwrap_err();
|
||||
assert!(matches!(err, PublishError::Unavailable(_)), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reverting_without_having_applied_does_nothing_and_succeeds() {
|
||||
// The shutdown path must not fail because there was nothing to undo.
|
||||
ResolvedPublisher::new().revert().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! The publisher for systems nobody has written one for.
|
||||
//!
|
||||
//! macOS needs `SystemConfiguration` and Windows the IP Helper API, and
|
||||
//! neither is written. Saying so is more use than appearing to work: the
|
||||
//! server is already answering, so all that is missing is the last step, and
|
||||
//! the user can take it by hand once they know that is what is needed.
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published};
|
||||
|
||||
/// Refuses to configure anything, with an explanation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnsupportedPublisher {
|
||||
platform: &'static str,
|
||||
}
|
||||
|
||||
impl Default for UnsupportedPublisher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl UnsupportedPublisher {
|
||||
/// A publisher naming the platform it stands in for.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
platform: std::env::consts::OS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsPublisher for UnsupportedPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"unsupported"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, _published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
Err(PublishError::Unavailable(format!(
|
||||
"configuring the system resolver is not implemented on {} yet",
|
||||
self.platform
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
// Nothing was set, so there is nothing to undo and no reason to fail
|
||||
// a shutdown over it.
|
||||
Box::pin(async move { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_names_the_platform_and_still_reverts_quietly() {
|
||||
let publisher = UnsupportedPublisher::new();
|
||||
let published = Published {
|
||||
interface: "tsundemo".into(),
|
||||
servers: vec!["10.0.0.1:5354".parse().unwrap()],
|
||||
domains: vec!["lab".into()],
|
||||
};
|
||||
let err = publisher.apply(&published).await.unwrap_err();
|
||||
assert!(matches!(err, PublishError::Unavailable(_)));
|
||||
assert!(err.to_string().contains(std::env::consts::OS));
|
||||
publisher.revert().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! Telling the Windows DNS client to send some questions here, through the
|
||||
//! Name Resolution Policy Table.
|
||||
//!
|
||||
//! The NRPT is how Windows does split DNS: a rule says "names under this
|
||||
//! suffix are resolved by these servers", and names outside every rule are
|
||||
//! resolved the ordinary way. That is exactly the contract of [`Published`] —
|
||||
//! route these suffixes here and claim nothing else — so one NRPT rule per
|
||||
//! suffix is the whole of it. The rules are keyed by namespace, not by
|
||||
//! interface, so the interface name in [`Published`] is not needed here.
|
||||
//!
|
||||
//! # Why PowerShell
|
||||
//!
|
||||
//! The NRPT lives in the registry, but a rule only takes effect once the DNS
|
||||
//! client is told to reload its policy, and that notification is an RPC with
|
||||
//! no safe wrapper this crate may call — it forbids `unsafe`. The
|
||||
//! `DnsClient` PowerShell module does the write *and* the reload, so it is
|
||||
//! used by absolute path from `%SystemRoot%`. Every value passed to it is one
|
||||
//! this agent produced: the suffixes are validated zone names (letters,
|
||||
//! digits, `-`, `_`, `.` only) and the servers are addresses it is listening
|
||||
//! on. They are still single-quoted and escaped when built into the script,
|
||||
//! so nothing could be read as PowerShell rather than as data.
|
||||
//!
|
||||
//! # The port
|
||||
//!
|
||||
//! The Windows DNS client always asks on port 53; it cannot be told another.
|
||||
//! So a server on a different port cannot be reached this way, and rather than
|
||||
//! configure a rule that points at nothing, this says so — the same honest
|
||||
//! failure the systemd-resolved path gives for an old resolver with no port.
|
||||
//!
|
||||
//! # Privilege
|
||||
//!
|
||||
//! Writing an NRPT rule needs an elevated process. An ordinary user is
|
||||
//! refused, which is reported as its own kind of error because the answer to
|
||||
//! it — run elevated, or as a service — differs from "this is not Windows".
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published};
|
||||
|
||||
/// The port the Windows DNS client is fixed to.
|
||||
const DNS_PORT: u16 = 53;
|
||||
|
||||
/// Configures the Name Resolution Policy Table through PowerShell.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NrptPublisher {
|
||||
/// The namespaces last configured, so shutdown knows what to undo.
|
||||
applied: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl NrptPublisher {
|
||||
/// Creates the publisher. Nothing is contacted until [`Self::apply`].
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A suffix as the NRPT wants it: a leading dot means "this and everything
|
||||
/// under it".
|
||||
fn namespace(domain: &str) -> String {
|
||||
let trimmed = domain.trim_matches('.');
|
||||
format!(".{trimmed}")
|
||||
}
|
||||
|
||||
/// A PowerShell single-quoted literal, with any embedded quote doubled.
|
||||
fn ps_literal(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
/// A PowerShell array literal, `@('a','b')`, or `@()` when empty.
|
||||
fn ps_array(values: &[String]) -> String {
|
||||
let items: Vec<String> = values.iter().map(|value| ps_literal(value)).collect();
|
||||
format!("@({})", items.join(","))
|
||||
}
|
||||
|
||||
/// The script that clears our rules for these namespaces and then adds them.
|
||||
fn apply_script(namespaces: &[String], servers: &[String]) -> String {
|
||||
format!(
|
||||
"$ErrorActionPreference = 'Stop'\n\
|
||||
$ns = {ns}\n\
|
||||
$srv = {srv}\n\
|
||||
foreach ($n in $ns) {{ Get-DnsClientNrptRule | Where-Object {{ $_.Namespace -eq $n }} | \
|
||||
ForEach-Object {{ Remove-DnsClientNrptRule -Name $_.Name -Force }} }}\n\
|
||||
foreach ($n in $ns) {{ Add-DnsClientNrptRule -Namespace $n -NameServers $srv }}\n",
|
||||
ns = ps_array(namespaces),
|
||||
srv = ps_array(servers),
|
||||
)
|
||||
}
|
||||
|
||||
/// The script that clears our rules for these namespaces.
|
||||
fn revert_script(namespaces: &[String]) -> String {
|
||||
format!(
|
||||
"$ErrorActionPreference = 'Stop'\n\
|
||||
$ns = {ns}\n\
|
||||
foreach ($n in $ns) {{ Get-DnsClientNrptRule | Where-Object {{ $_.Namespace -eq $n }} | \
|
||||
ForEach-Object {{ Remove-DnsClientNrptRule -Name $_.Name -Force }} }}\n",
|
||||
ns = ps_array(namespaces),
|
||||
)
|
||||
}
|
||||
|
||||
/// Turns what PowerShell said on failure into the kind of failure it is.
|
||||
fn classify(text: &str) -> PublishError {
|
||||
let lower = text.to_ascii_lowercase();
|
||||
if lower.contains("access is denied")
|
||||
|| lower.contains("requires elevation")
|
||||
|| lower.contains("run as administrator")
|
||||
|| lower.contains("administrator privilege")
|
||||
|| lower.contains("permissiondenied")
|
||||
{
|
||||
PublishError::Refused(format!("the Windows DNS client refused the change: {text}"))
|
||||
} else if lower.contains("is not recognized")
|
||||
|| lower.contains("commandnotfoundexception")
|
||||
|| lower.contains("not recognized as the name of a cmdlet")
|
||||
{
|
||||
PublishError::Unavailable(format!(
|
||||
"the DnsClient PowerShell module is not available: {text}"
|
||||
))
|
||||
} else {
|
||||
PublishError::Failed(format!("the Windows DNS client failed the change: {text}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a PowerShell script from `%SystemRoot%` and maps a failure to a
|
||||
/// [`PublishError`].
|
||||
async fn powershell(script: String) -> Result<(), PublishError> {
|
||||
let program = powershell_path();
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
std::process::Command::new(program)
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
&script,
|
||||
])
|
||||
.output()
|
||||
})
|
||||
.await
|
||||
.map_err(|err| PublishError::Failed(format!("could not run PowerShell: {err}")))?
|
||||
.map_err(|err| PublishError::Unavailable(format!("could not run PowerShell: {err}")))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let text = if stderr.trim().is_empty() {
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
} else {
|
||||
stderr.trim().to_string()
|
||||
};
|
||||
Err(classify(if text.is_empty() { "no output" } else { &text }))
|
||||
}
|
||||
|
||||
/// The absolute path to Windows PowerShell, so nothing on `PATH` can stand in
|
||||
/// for it.
|
||||
fn powershell_path() -> std::path::PathBuf {
|
||||
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into());
|
||||
std::path::Path::new(&root)
|
||||
.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe")
|
||||
}
|
||||
|
||||
impl DnsPublisher for NrptPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"windows-nrpt"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
if published.domains.is_empty() {
|
||||
return Err(PublishError::Unavailable(
|
||||
"there are no suffixes to route, so there is no rule to write".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Windows can only ask on port 53, so a server anywhere else is
|
||||
// unreachable this way. Keep the ones it can use.
|
||||
let servers: BTreeSet<IpAddr> = published
|
||||
.servers
|
||||
.iter()
|
||||
.filter(|server| server.port() == DNS_PORT)
|
||||
.map(|server| server.ip())
|
||||
.collect();
|
||||
if servers.is_empty() {
|
||||
let elsewhere = published
|
||||
.servers
|
||||
.iter()
|
||||
.map(|server| server.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
return Err(PublishError::Unavailable(format!(
|
||||
"the Windows DNS client only asks on port {DNS_PORT}, and the server is on \
|
||||
{elsewhere}. Run the server on port {DNS_PORT}, or point your resolver at it \
|
||||
yourself."
|
||||
)));
|
||||
}
|
||||
|
||||
let namespaces: Vec<String> = published.domains.iter().map(|d| namespace(d)).collect();
|
||||
let servers: Vec<String> = servers.iter().map(|ip| ip.to_string()).collect();
|
||||
|
||||
powershell(apply_script(&namespaces, &servers)).await?;
|
||||
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = namespaces,
|
||||
Err(poisoned) => *poisoned.into_inner() = namespaces,
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
let namespaces = match self.applied.lock() {
|
||||
Ok(mut guard) => std::mem::take(&mut *guard),
|
||||
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
|
||||
};
|
||||
if namespaces.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
powershell(revert_script(&namespaces)).await
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[test]
|
||||
fn a_suffix_gets_a_leading_dot() {
|
||||
assert_eq!(namespace("lab"), ".lab");
|
||||
assert_eq!(namespace(".lab"), ".lab");
|
||||
assert_eq!(namespace("a.b"), ".a.b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quote_in_a_value_cannot_escape_the_literal() {
|
||||
// Zone names never contain a quote, but the escaping is what makes
|
||||
// that a guarantee rather than a hope.
|
||||
assert_eq!(ps_literal("a'b"), "'a''b'");
|
||||
assert_eq!(ps_array(&["x".into(), "y".into()]), "@('x','y')");
|
||||
assert_eq!(ps_array(&[]), "@()");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_apply_script_removes_before_it_adds() {
|
||||
let script = apply_script(&[".lab".into()], &["10.13.37.69".into()]);
|
||||
let remove = script.find("Remove-DnsClientNrptRule").unwrap();
|
||||
let add = script.find("Add-DnsClientNrptRule").unwrap();
|
||||
assert!(
|
||||
remove < add,
|
||||
"a stale rule must go before the new one:\n{script}"
|
||||
);
|
||||
assert!(script.contains("@('.lab')"));
|
||||
assert!(script.contains("@('10.13.37.69')"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_access_denied_is_a_refusal_a_missing_cmdlet_is_unavailable() {
|
||||
assert!(matches!(
|
||||
classify("Access is denied"),
|
||||
PublishError::Refused(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify("The term 'Add-DnsClientNrptRule' is not recognized"),
|
||||
PublishError::Unavailable(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify("something else"),
|
||||
PublishError::Failed(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_server_only_on_a_nonstandard_port_is_unavailable_not_a_failure() {
|
||||
// Nothing is run: Windows cannot ask there, and saying so is the
|
||||
// honest answer without touching the DNS client.
|
||||
let publisher = NrptPublisher::new();
|
||||
let published = Published {
|
||||
interface: "tsun0".into(),
|
||||
servers: vec![SocketAddr::from(([10, 13, 37, 69], 5354))],
|
||||
domains: vec!["lab".into()],
|
||||
};
|
||||
let err = publisher.apply(&published).await.unwrap_err();
|
||||
assert!(matches!(err, PublishError::Unavailable(_)), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reverting_without_having_applied_does_nothing_and_succeeds() {
|
||||
NrptPublisher::new().revert().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
//! Answering DNS questions about the overlay, over UDP and TCP.
|
||||
//!
|
||||
//! Authoritative for one zone and nothing else. There is no recursion, no
|
||||
//! forwarding and no cache: a question this agent cannot answer from the
|
||||
//! signed roster is refused rather than passed anywhere, so pointing a
|
||||
//! resolver at this server can never make it a path to the outside.
|
||||
//!
|
||||
//! The decision of what to answer lives in [`super::zone`]; this module is
|
||||
//! only the wire format and the sockets. [`respond`] sits between them and
|
||||
//! takes bytes to bytes, so everything the server does to a packet is
|
||||
//! testable without opening a socket.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use simple_dns::rdata::{A, PTR, RData, SOA};
|
||||
use simple_dns::{Name, PacketFlag, QCLASS, QTYPE, RCODE, ResourceRecord, TYPE};
|
||||
|
||||
use super::zone::{Answer, Query, Zone, Zones};
|
||||
|
||||
/// How long an answer may be cached.
|
||||
///
|
||||
/// Short, because the roster changes when members come and go and a stale
|
||||
/// answer is worse than another question.
|
||||
pub const TTL: u32 = 30;
|
||||
|
||||
/// The largest question this server will read.
|
||||
///
|
||||
/// A DNS message is 512 bytes without EDNS and 4096 with it; anything past
|
||||
/// that is not a question worth answering.
|
||||
pub const MAX_MESSAGE_LEN: usize = 4096;
|
||||
|
||||
/// The largest answer sent over UDP without the client offering EDNS.
|
||||
const CLASSIC_UDP_LIMIT: usize = 512;
|
||||
|
||||
/// How long a TCP client may take over one question.
|
||||
const TCP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// How many TCP questions may be in flight at once.
|
||||
const MAX_TCP_CONNECTIONS: usize = 32;
|
||||
|
||||
/// The zones the server answers from, swapped as the rosters change.
|
||||
///
|
||||
/// Shared rather than copied into the server so that a roster change — or a
|
||||
/// network joining or leaving — is one write, not a restart: rebinding the
|
||||
/// socket would drop questions in flight for no reason.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SharedZone(Arc<RwLock<Arc<Zones>>>);
|
||||
|
||||
impl SharedZone {
|
||||
/// Wraps a set of zones.
|
||||
pub fn new(zones: Zones) -> Self {
|
||||
Self(Arc::new(RwLock::new(Arc::new(zones))))
|
||||
}
|
||||
|
||||
/// Wraps a single zone, which is the common case in a test.
|
||||
pub fn one(zone: Zone) -> Self {
|
||||
Self::new(Zones::new([zone]))
|
||||
}
|
||||
|
||||
/// Replaces them.
|
||||
pub fn set(&self, zones: Zones) {
|
||||
match self.0.write() {
|
||||
Ok(mut guard) => *guard = Arc::new(zones),
|
||||
Err(poisoned) => *poisoned.into_inner() = Arc::new(zones),
|
||||
}
|
||||
}
|
||||
|
||||
/// The zones as they are now.
|
||||
pub fn get(&self) -> Arc<Zones> {
|
||||
match self.0.read() {
|
||||
Ok(guard) => Arc::clone(&guard),
|
||||
Err(poisoned) => Arc::clone(&poisoned.into_inner()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the answer to one question.
|
||||
///
|
||||
/// `None` means say nothing at all: the message was not a question this
|
||||
/// server should reply to, and replying anyway would make this a useful
|
||||
/// amplifier for somebody spoofing a source address.
|
||||
pub fn respond(zones: &Zones, query: &[u8]) -> Option<Vec<u8>> {
|
||||
let packet = simple_dns::Packet::parse(query).ok()?;
|
||||
if packet.has_flags(PacketFlag::RESPONSE) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut reply = simple_dns::Packet::new_reply(packet.id());
|
||||
// Recursion is not available here and the flag says so honestly; the
|
||||
// desired bit is echoed because a resolver compares it.
|
||||
if packet.has_flags(PacketFlag::RECURSION_DESIRED) {
|
||||
reply.set_flags(PacketFlag::RECURSION_DESIRED);
|
||||
}
|
||||
|
||||
if packet.opcode() != simple_dns::OPCODE::StandardQuery {
|
||||
*reply.rcode_mut() = RCODE::NotImplemented;
|
||||
return reply.build_bytes_vec().ok();
|
||||
}
|
||||
|
||||
// Exactly one question. Zero is nothing to answer; more than one has no
|
||||
// agreed meaning and every real server rejects it.
|
||||
let [question] = packet.questions.as_slice() else {
|
||||
*reply.rcode_mut() = RCODE::FormatError;
|
||||
return reply.build_bytes_vec().ok();
|
||||
};
|
||||
if !matches!(question.qclass, QCLASS::CLASS(simple_dns::CLASS::IN)) {
|
||||
*reply.rcode_mut() = RCODE::Refused;
|
||||
return reply.build_bytes_vec().ok();
|
||||
}
|
||||
|
||||
let qname = question.qname.to_string();
|
||||
// Which zone answers is part of the answer: a denial is bounded by the
|
||||
// SOA of the zone that denied it, and this server may hold several.
|
||||
let (zone, answer) = match zones.lookup(&qname, query_kind(question.qtype)) {
|
||||
Some(answered) => answered,
|
||||
None => {
|
||||
reply.questions.push(question.clone());
|
||||
*reply.rcode_mut() = RCODE::Refused;
|
||||
return reply.build_bytes_vec_compressed().ok();
|
||||
}
|
||||
};
|
||||
reply.questions.push(question.clone());
|
||||
|
||||
let name = Name::new(&qname).ok()?;
|
||||
match answer {
|
||||
Answer::NotOurs => *reply.rcode_mut() = RCODE::Refused,
|
||||
Answer::Addresses(addresses) => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
for address in addresses {
|
||||
reply.answers.push(ResourceRecord::new(
|
||||
name.clone(),
|
||||
simple_dns::CLASS::IN,
|
||||
TTL,
|
||||
RData::A(A::from(address)),
|
||||
));
|
||||
}
|
||||
}
|
||||
Answer::Name(target) => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
let target = Name::new(&target).ok()?.into_owned();
|
||||
reply.answers.push(ResourceRecord::new(
|
||||
name.clone(),
|
||||
simple_dns::CLASS::IN,
|
||||
TTL,
|
||||
RData::PTR(PTR(target)),
|
||||
));
|
||||
}
|
||||
Answer::Soa => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
reply.answers.push(soa_record(zone)?);
|
||||
}
|
||||
Answer::NoData => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
// The authority section carries the SOA so a resolver knows how
|
||||
// long it may remember that there is nothing here.
|
||||
reply.name_servers.push(soa_record(zone)?);
|
||||
}
|
||||
Answer::NoSuchName => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
*reply.rcode_mut() = RCODE::NameError;
|
||||
reply.name_servers.push(soa_record(zone)?);
|
||||
}
|
||||
}
|
||||
|
||||
reply.build_bytes_vec_compressed().ok()
|
||||
}
|
||||
|
||||
/// The zone's start of authority.
|
||||
fn soa_record(zone: &Zone) -> Option<ResourceRecord<'static>> {
|
||||
let origin = Name::new(zone.origin().as_str()).ok()?.into_owned();
|
||||
Some(ResourceRecord::new(
|
||||
origin.clone(),
|
||||
simple_dns::CLASS::IN,
|
||||
TTL,
|
||||
RData::SOA(SOA {
|
||||
mname: origin.clone(),
|
||||
// There is no mailbox behind this zone and inventing one would
|
||||
// be a fiction; the origin itself is the honest answer.
|
||||
rname: origin,
|
||||
serial: zone.serial(),
|
||||
refresh: TTL as i32,
|
||||
retry: TTL as i32,
|
||||
expire: 86_400,
|
||||
minimum: TTL,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn query_kind(qtype: QTYPE) -> Query {
|
||||
match qtype {
|
||||
QTYPE::TYPE(TYPE::A) => Query::A,
|
||||
QTYPE::TYPE(TYPE::PTR) => Query::Ptr,
|
||||
QTYPE::TYPE(TYPE::SOA) => Query::Soa,
|
||||
QTYPE::TYPE(TYPE::NS) => Query::Ns,
|
||||
// ANY is answered as an address question rather than by dumping the
|
||||
// zone: an ANY that returns everything is an amplification gift.
|
||||
QTYPE::ANY => Query::A,
|
||||
_ => Query::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the client offered EDNS, and so how large an answer it will take.
|
||||
fn udp_limit(query: &[u8]) -> usize {
|
||||
simple_dns::Packet::parse(query)
|
||||
.ok()
|
||||
.and_then(|packet| packet.opt().map(|opt| opt.udp_packet_size as usize))
|
||||
.unwrap_or(CLASSIC_UDP_LIMIT as u16 as usize)
|
||||
.clamp(CLASSIC_UDP_LIMIT, MAX_MESSAGE_LEN)
|
||||
}
|
||||
|
||||
/// Cuts an answer down to what the client said it would take.
|
||||
///
|
||||
/// The records are dropped and the truncated bit set, which tells a resolver
|
||||
/// to ask again over TCP. Sending a reply it cannot reassemble would just
|
||||
/// look like packet loss.
|
||||
fn truncate_for_udp(query: &[u8], reply: Vec<u8>) -> Vec<u8> {
|
||||
let limit = udp_limit(query);
|
||||
if reply.len() <= limit {
|
||||
return reply;
|
||||
}
|
||||
let Ok(parsed) = simple_dns::Packet::parse(&reply) else {
|
||||
return reply;
|
||||
};
|
||||
let mut short = simple_dns::Packet::new_reply(parsed.id());
|
||||
short.set_flags(PacketFlag::TRUNCATION | PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
*short.rcode_mut() = parsed.rcode();
|
||||
for question in &parsed.questions {
|
||||
short.questions.push(question.clone());
|
||||
}
|
||||
short.build_bytes_vec().unwrap_or(reply)
|
||||
}
|
||||
|
||||
/// A running DNS server.
|
||||
#[derive(Debug)]
|
||||
pub struct DnsServer {
|
||||
local_addr: SocketAddr,
|
||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl DnsServer {
|
||||
/// Binds and starts answering.
|
||||
///
|
||||
/// Both transports on the same address and port, as a resolver expects:
|
||||
/// it falls back to TCP when an answer does not fit, and a server that
|
||||
/// only listened on UDP would leave it with nowhere to go.
|
||||
pub async fn bind(addr: SocketAddr, zone: SharedZone) -> std::io::Result<Self> {
|
||||
let udp = tokio::net::UdpSocket::bind(addr).await?;
|
||||
let local_addr = udp.local_addr()?;
|
||||
let tcp = tokio::net::TcpListener::bind(local_addr).await?;
|
||||
|
||||
let udp_zone = zone.clone();
|
||||
let udp_task = tokio::spawn(async move {
|
||||
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
|
||||
loop {
|
||||
let (read, from) = match udp.recv_from(&mut buffer).await {
|
||||
Ok(pair) => pair,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "dns udp receive failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let query = &buffer[..read];
|
||||
let Some(reply) = respond(&udp_zone.get(), query) else {
|
||||
continue;
|
||||
};
|
||||
let reply = truncate_for_udp(query, reply);
|
||||
if let Err(err) = udp.send_to(&reply, from).await {
|
||||
tracing::debug!(%err, "dns udp reply failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let tcp_zone = zone.clone();
|
||||
let tcp_task = tokio::spawn(async move {
|
||||
let permits = Arc::new(tokio::sync::Semaphore::new(MAX_TCP_CONNECTIONS));
|
||||
loop {
|
||||
let (stream, _) = match tcp.accept().await {
|
||||
Ok(pair) => pair,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "dns tcp accept failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Ok(permit) = Arc::clone(&permits).acquire_owned().await else {
|
||||
return;
|
||||
};
|
||||
let zone = tcp_zone.clone();
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
// Bounded, so a client that connects and says nothing
|
||||
// cannot hold a slot open.
|
||||
let _ = tokio::time::timeout(TCP_TIMEOUT, serve_tcp(stream, zone)).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
local_addr,
|
||||
tasks: vec![udp_task, tcp_task],
|
||||
})
|
||||
}
|
||||
|
||||
/// The address it is answering on.
|
||||
pub fn local_addr(&self) -> SocketAddr {
|
||||
self.local_addr
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DnsServer {
|
||||
fn drop(&mut self) {
|
||||
for task in &self.tasks {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_tcp(mut stream: tokio::net::TcpStream, zone: SharedZone) -> std::io::Result<()> {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
loop {
|
||||
let mut header = [0u8; 2];
|
||||
if stream.read_exact(&mut header).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
// Checked before the buffer is allocated, as everywhere else that
|
||||
// reads a length off a wire.
|
||||
let len = u16::from_be_bytes(header) as usize;
|
||||
if len == 0 || len > MAX_MESSAGE_LEN {
|
||||
return Ok(());
|
||||
}
|
||||
let mut query = vec![0u8; len];
|
||||
stream.read_exact(&mut query).await?;
|
||||
|
||||
let Some(reply) = respond(&zone.get(), &query) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(len) = u16::try_from(reply.len()) else {
|
||||
return Ok(());
|
||||
};
|
||||
stream.write_all(&len.to_be_bytes()).await?;
|
||||
stream.write_all(&reply).await?;
|
||||
stream.flush().await?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use crate::dns::zone::ZoneName;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn zone() -> Zones {
|
||||
Zones::new([one_zone()])
|
||||
}
|
||||
|
||||
fn one_zone() -> Zone {
|
||||
Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[
|
||||
("music".to_string(), Ipv4Addr::new(10, 13, 37, 237)),
|
||||
("ai".to_string(), Ipv4Addr::new(10, 13, 37, 69)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn ask(name: &str, qtype: TYPE) -> Vec<u8> {
|
||||
let mut packet = simple_dns::Packet::new_query(0x1234);
|
||||
packet.questions.push(simple_dns::Question::new(
|
||||
Name::new(name).unwrap(),
|
||||
qtype.into(),
|
||||
QCLASS::CLASS(simple_dns::CLASS::IN),
|
||||
false,
|
||||
));
|
||||
packet.build_bytes_vec().unwrap()
|
||||
}
|
||||
|
||||
/// The bytes of a reply. Parsed by each caller, because a parsed packet
|
||||
/// borrows from them.
|
||||
fn answer(query: &[u8]) -> Vec<u8> {
|
||||
respond(&zone(), query).expect("a reply")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_is_answered_authoritatively() {
|
||||
let bytes = answer(&ask("music.lab", TYPE::A));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NoError);
|
||||
assert!(reply.has_flags(PacketFlag::RESPONSE));
|
||||
assert!(reply.has_flags(PacketFlag::AUTHORITATIVE_ANSWER));
|
||||
assert!(!reply.has_flags(PacketFlag::RECURSION_AVAILABLE));
|
||||
assert_eq!(reply.answers.len(), 1);
|
||||
assert_eq!(reply.questions.len(), 1, "the question is echoed");
|
||||
match &reply.answers[0].rdata {
|
||||
RData::A(a) => assert_eq!(Ipv4Addr::from(a.address), Ipv4Addr::new(10, 13, 37, 237)),
|
||||
other => panic!("expected an A record, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_name_is_denied_with_a_soa_to_cache_the_denial() {
|
||||
let bytes = answer(&ask("nobody.lab", TYPE::A));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NameError);
|
||||
assert!(reply.answers.is_empty());
|
||||
assert_eq!(
|
||||
reply.name_servers.len(),
|
||||
1,
|
||||
"a SOA bounds the negative cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_exists_without_that_record_is_not_denied() {
|
||||
// NODATA, not NXDOMAIN: denying the name would stop a resolver
|
||||
// asking for the A record it could have had.
|
||||
let bytes = answer(&ask("music.lab", TYPE::AAAA));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NoError);
|
||||
assert!(reply.answers.is_empty());
|
||||
assert_eq!(reply.name_servers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_outside_the_zone_is_refused_and_never_forwarded() {
|
||||
for name in ["example.com", "evillab", "google.com"] {
|
||||
let bytes = answer(&ask(name, TYPE::A));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::Refused, "{name}");
|
||||
assert!(reply.answers.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_is_answered_backwards() {
|
||||
let bytes = answer(&ask("237.37.13.10.in-addr.arpa", TYPE::PTR));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NoError);
|
||||
match &reply.answers[0].rdata {
|
||||
RData::PTR(ptr) => assert_eq!(ptr.0.to_string(), "music.lab"),
|
||||
other => panic!("expected a PTR record, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_is_never_sent_to_something_that_was_not_a_question() {
|
||||
// Answering a response would make this a reflector for anyone who
|
||||
// can spoof a source address.
|
||||
let mut packet = simple_dns::Packet::new_reply(1);
|
||||
packet.set_flags(PacketFlag::RESPONSE);
|
||||
assert!(respond(&zone(), &packet.build_bytes_vec().unwrap()).is_none());
|
||||
assert!(respond(&zone(), b"").is_none());
|
||||
assert!(respond(&zone(), b"not a dns packet at all").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_question_with_no_question_in_it_is_a_format_error() {
|
||||
let packet = simple_dns::Packet::new_query(7);
|
||||
let bytes = respond(&zone(), &packet.build_bytes_vec().unwrap()).unwrap();
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::FormatError);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_class_other_than_internet_is_refused() {
|
||||
let mut packet = simple_dns::Packet::new_query(9);
|
||||
packet.questions.push(simple_dns::Question::new(
|
||||
Name::new("music.lab").unwrap(),
|
||||
TYPE::A.into(),
|
||||
QCLASS::CLASS(simple_dns::CLASS::CH),
|
||||
false,
|
||||
));
|
||||
let bytes = respond(&zone(), &packet.build_bytes_vec().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
simple_dns::Packet::parse(&bytes).unwrap().rcode(),
|
||||
RCODE::Refused
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_too_large_for_udp_is_truncated_rather_than_dropped() {
|
||||
// A resolver that gets a truncated reply asks again over TCP; one
|
||||
// that gets nothing back just waits.
|
||||
let many: Vec<(String, Ipv4Addr)> = (0..200)
|
||||
.map(|i| ("host".to_string(), Ipv4Addr::new(10, 13, 37, i as u8)))
|
||||
.collect();
|
||||
let wide = Zones::new([Zone::new(ZoneName::new("lab").unwrap(), many)]);
|
||||
let query = ask("host.lab", TYPE::A);
|
||||
let full = respond(&wide, &query).unwrap();
|
||||
assert!(
|
||||
full.len() > CLASSIC_UDP_LIMIT,
|
||||
"the test needs a big answer"
|
||||
);
|
||||
|
||||
let short = truncate_for_udp(&query, full);
|
||||
assert!(short.len() <= CLASSIC_UDP_LIMIT);
|
||||
let parsed = simple_dns::Packet::parse(&short).unwrap();
|
||||
assert!(parsed.has_flags(PacketFlag::TRUNCATION));
|
||||
assert_eq!(parsed.questions.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_server_answers_over_udp_and_tcp_on_one_address() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let shared = SharedZone::new(zone());
|
||||
let server = DnsServer::bind("127.0.0.1:0".parse().unwrap(), shared.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = server.local_addr();
|
||||
|
||||
let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
client
|
||||
.send_to(&ask("music.lab", TYPE::A), addr)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
|
||||
let read = client.recv(&mut buffer).await.unwrap();
|
||||
let reply = simple_dns::Packet::parse(&buffer[..read]).unwrap();
|
||||
assert_eq!(reply.answers.len(), 1);
|
||||
|
||||
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
let query = ask("ai.lab", TYPE::A);
|
||||
stream
|
||||
.write_all(&(query.len() as u16).to_be_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
stream.write_all(&query).await.unwrap();
|
||||
let mut header = [0u8; 2];
|
||||
stream.read_exact(&mut header).await.unwrap();
|
||||
let mut body = vec![0u8; u16::from_be_bytes(header) as usize];
|
||||
stream.read_exact(&mut body).await.unwrap();
|
||||
let reply = simple_dns::Packet::parse(&body).unwrap();
|
||||
assert_eq!(reply.answers.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_same_zone_is_answered_over_ipv6_as_over_ipv4() {
|
||||
// A question arrives over whichever family the resolver chooses, so
|
||||
// a listener is opened from each list in the plan and both have to
|
||||
// answer the same thing. Answering one family only leaves the other
|
||||
// timing out, which looks like a broken overlay.
|
||||
let shared = SharedZone::new(zone());
|
||||
let plan = crate::dns::listen_plan(0);
|
||||
let mut answered = 0;
|
||||
|
||||
for family in plan.families() {
|
||||
let candidate = family[0];
|
||||
let server = match DnsServer::bind(candidate, shared.clone()).await {
|
||||
Ok(server) => server,
|
||||
// A host with IPv6 switched off in the kernel cannot bind
|
||||
// `::1`, and the agent copes with that by design; so does
|
||||
// this. The other family still has to work.
|
||||
Err(err) if candidate.is_ipv6() => {
|
||||
eprintln!("no IPv6 loopback on this host ({err}); skipping that family");
|
||||
continue;
|
||||
}
|
||||
Err(err) => panic!("cannot listen on {candidate}: {err}"),
|
||||
};
|
||||
let addr = server.local_addr();
|
||||
assert_eq!(addr.is_ipv6(), candidate.is_ipv6());
|
||||
|
||||
let client = tokio::net::UdpSocket::bind(if addr.is_ipv6() {
|
||||
"[::1]:0"
|
||||
} else {
|
||||
"127.0.0.1:0"
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.send_to(&ask("music.lab", TYPE::A), addr)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
|
||||
let read = client.recv(&mut buffer).await.unwrap();
|
||||
let reply = simple_dns::Packet::parse(&buffer[..read]).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NoError, "over {addr}");
|
||||
match &reply.answers[0].rdata {
|
||||
// The same IPv4 answer either way: the family a question
|
||||
// travelled over says nothing about what the answer is.
|
||||
RData::A(a) => {
|
||||
assert_eq!(Ipv4Addr::from(a.address), Ipv4Addr::new(10, 13, 37, 237))
|
||||
}
|
||||
other => panic!("expected an A record over {addr}, got {other:?}"),
|
||||
}
|
||||
answered += 1;
|
||||
}
|
||||
|
||||
assert!(answered > 0, "at least one family must have answered");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacing_the_zone_changes_what_the_running_server_answers() {
|
||||
let shared = SharedZone::one(Zone::new(ZoneName::new("lab").unwrap(), []));
|
||||
let server = DnsServer::bind("127.0.0.1:0".parse().unwrap(), shared.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = server.local_addr();
|
||||
let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
|
||||
|
||||
client
|
||||
.send_to(&ask("music.lab", TYPE::A), addr)
|
||||
.await
|
||||
.unwrap();
|
||||
let read = client.recv(&mut buffer).await.unwrap();
|
||||
assert_eq!(
|
||||
simple_dns::Packet::parse(&buffer[..read]).unwrap().rcode(),
|
||||
RCODE::NameError
|
||||
);
|
||||
|
||||
// A member joins: no rebind, no dropped socket.
|
||||
shared.set(zone());
|
||||
client
|
||||
.send_to(&ask("music.lab", TYPE::A), addr)
|
||||
.await
|
||||
.unwrap();
|
||||
let read = client.recv(&mut buffer).await.unwrap();
|
||||
let reply = simple_dns::Packet::parse(&buffer[..read]).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NoError);
|
||||
assert_eq!(reply.answers.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
//! What the overlay answers to, as a DNS zone.
|
||||
//!
|
||||
//! Nothing here touches a socket or the operating system, and it does not
|
||||
//! depend on a DNS wire library either: it takes a roster and a question and
|
||||
//! says what the answer is. That is what makes the interesting parts — which
|
||||
//! names exist, what "does not exist" means as against "exists with nothing
|
||||
//! of that type", and what is outside the zone entirely — testable on their
|
||||
//! own.
|
||||
//!
|
||||
//! # Where the names come from
|
||||
//!
|
||||
//! From signed state, which is why a member that is switched off still
|
||||
//! resolves. Its claim outlived the session, so the name and the address are
|
||||
//! both still there to answer with. Nothing is invented for a member that
|
||||
//! claimed neither.
|
||||
//!
|
||||
//! Only IPv4 is served. The IPv6 overlay address is derived from a
|
||||
//! WireGuard key that travels in live announcements and is not in signed
|
||||
//! state, so it cannot be answered for a member that is away — and answering
|
||||
//! for some members and not others depending on whether they happen to be
|
||||
//! online is worse than not answering at all.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// Longest a DNS name may be, in the presentation form used here.
|
||||
const MAX_NAME_LEN: usize = 253;
|
||||
/// Longest one label may be.
|
||||
const MAX_LABEL_LEN: usize = 63;
|
||||
|
||||
/// Why a zone name cannot be used.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ZoneError {
|
||||
/// The name was empty, or became empty once normalised.
|
||||
#[error("a zone name must have at least one label")]
|
||||
Empty,
|
||||
/// One label was unusable.
|
||||
#[error("`{label}` is not a usable DNS label: {reason}")]
|
||||
Label {
|
||||
/// The offending label.
|
||||
label: String,
|
||||
/// What is wrong with it.
|
||||
reason: &'static str,
|
||||
},
|
||||
/// The whole name is too long.
|
||||
#[error("a zone name must be at most {MAX_NAME_LEN} characters")]
|
||||
TooLong,
|
||||
}
|
||||
|
||||
/// A validated, canonical zone name.
|
||||
///
|
||||
/// Held without a trailing dot and lower-cased, so comparison is a plain
|
||||
/// string comparison rather than a special case at every use.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ZoneName(String);
|
||||
|
||||
impl ZoneName {
|
||||
/// Validates and normalises a zone name.
|
||||
pub fn new(raw: &str) -> Result<Self, ZoneError> {
|
||||
let trimmed = raw.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ZoneError::Empty);
|
||||
}
|
||||
if trimmed.len() > MAX_NAME_LEN {
|
||||
return Err(ZoneError::TooLong);
|
||||
}
|
||||
for label in trimmed.split('.') {
|
||||
let reason = if label.is_empty() {
|
||||
Some("it is empty")
|
||||
} else if label.len() > MAX_LABEL_LEN {
|
||||
Some("it is longer than 63 characters")
|
||||
} else if !label
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
{
|
||||
Some("only letters, digits, `-` and `_` are allowed")
|
||||
} else if label.starts_with('-') || label.ends_with('-') {
|
||||
Some("a label may not start or end with `-`")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
return Err(ZoneError::Label {
|
||||
label: label.to_string(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self(trimmed))
|
||||
}
|
||||
|
||||
/// The name, without a trailing dot.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// The last label, which is what would collide with a real top-level
|
||||
/// domain.
|
||||
pub fn top_label(&self) -> &str {
|
||||
self.0.rsplit('.').next().unwrap_or(&self.0)
|
||||
}
|
||||
|
||||
/// What is worrying about this zone name, if anything.
|
||||
///
|
||||
/// A warning and never a refusal: the name is the user's to choose, and
|
||||
/// a private zone that shadows a public one is a decision, not a
|
||||
/// mistake. Saying nothing would let it be an accident.
|
||||
pub fn collision(&self) -> Option<String> {
|
||||
let top = self.top_label();
|
||||
// Reserved for exactly this use and never delegated, so nothing to
|
||||
// say. See RFC 6761 and RFC 8375.
|
||||
const RESERVED: &[&str] = &[
|
||||
"internal",
|
||||
"home",
|
||||
"test",
|
||||
"example",
|
||||
"invalid",
|
||||
"localhost",
|
||||
];
|
||||
if RESERVED.contains(&top) {
|
||||
return None;
|
||||
}
|
||||
if top == "local" {
|
||||
return Some(
|
||||
"`.local` belongs to multicast DNS: on a host running Avahi or \
|
||||
systemd-resolved's mDNS, names under it are resolved by that and \
|
||||
not by this agent"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
// A real top-level domain is the whole of it. A name that is not
|
||||
// delegated today might be one day — and saying so fires on every
|
||||
// private name anybody ever picks, which is a warning about
|
||||
// nothing that teaches people to ignore warnings.
|
||||
tld::exist_case_insensitive(top).then(|| {
|
||||
format!(
|
||||
"`.{top}` is a real top-level domain, so every public name under it \
|
||||
becomes unreachable from this host while the overlay is up"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `name` is this zone or sits under it.
|
||||
///
|
||||
/// Compared label-wise, so `evilzone` does not count as being under
|
||||
/// `zone`.
|
||||
pub fn covers(&self, name: &str) -> bool {
|
||||
let name = name.trim_end_matches('.').to_ascii_lowercase();
|
||||
name == self.0
|
||||
|| name
|
||||
.strip_suffix(&self.0)
|
||||
.is_some_and(|rest| rest.ends_with('.'))
|
||||
}
|
||||
|
||||
/// The part of `name` below this zone, if it is under it.
|
||||
fn relative(&self, name: &str) -> Option<String> {
|
||||
let name = name.trim_end_matches('.').to_ascii_lowercase();
|
||||
if name == self.0 {
|
||||
return Some(String::new());
|
||||
}
|
||||
let rest = name.strip_suffix(&self.0)?;
|
||||
let rest = rest.strip_suffix('.')?;
|
||||
(!rest.is_empty()).then(|| rest.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds of question this zone knows how to answer.
|
||||
///
|
||||
/// Its own enum rather than the wire library's, so the decision of what to
|
||||
/// answer does not depend on how a packet is encoded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Query {
|
||||
/// An IPv4 address.
|
||||
A,
|
||||
/// A name for an address.
|
||||
Ptr,
|
||||
/// The zone's start of authority.
|
||||
Soa,
|
||||
/// The zone's name servers.
|
||||
Ns,
|
||||
/// Anything else, including AAAA.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// What the zone has to say.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Answer {
|
||||
/// Addresses for the name asked about.
|
||||
Addresses(Vec<Ipv4Addr>),
|
||||
/// A name for the address asked about.
|
||||
Name(String),
|
||||
/// The zone's start of authority.
|
||||
Soa,
|
||||
/// The name exists here but has nothing of the type asked for.
|
||||
///
|
||||
/// Distinct from [`Answer::NoSuchName`] because the two are different
|
||||
/// answers on the wire: this one is a success with no records, and a
|
||||
/// resolver must not take it as proof the name is absent. Getting them
|
||||
/// the wrong way round teaches a resolver to cache the wrong thing.
|
||||
NoData,
|
||||
/// No such name in this zone.
|
||||
NoSuchName,
|
||||
/// Not a name this zone is responsible for.
|
||||
NotOurs,
|
||||
}
|
||||
|
||||
/// The names and addresses of one network, ready to answer questions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Zone {
|
||||
origin: ZoneName,
|
||||
/// Name relative to the origin, to the addresses it answers to.
|
||||
hosts: BTreeMap<String, Vec<Ipv4Addr>>,
|
||||
/// Address to the name that holds it.
|
||||
names: BTreeMap<Ipv4Addr, String>,
|
||||
/// Changes whenever the contents do.
|
||||
serial: u32,
|
||||
}
|
||||
|
||||
impl Zone {
|
||||
/// Builds a zone from the members that have both a name and an address.
|
||||
///
|
||||
/// A member with one but not the other contributes nothing: a name with
|
||||
/// no address cannot be answered, and an address with no name has nothing
|
||||
/// to be asked about.
|
||||
pub fn new(origin: ZoneName, members: impl IntoIterator<Item = (String, Ipv4Addr)>) -> Self {
|
||||
let mut hosts: BTreeMap<String, Vec<Ipv4Addr>> = BTreeMap::new();
|
||||
let mut names: BTreeMap<Ipv4Addr, String> = BTreeMap::new();
|
||||
for (hostname, address) in members {
|
||||
let hostname = hostname.trim_matches('.').to_ascii_lowercase();
|
||||
if hostname.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let addresses = hosts.entry(hostname.clone()).or_default();
|
||||
if !addresses.contains(&address) {
|
||||
addresses.push(address);
|
||||
}
|
||||
// First name wins, and the map is ordered, so the reverse answer
|
||||
// is the same on every replica rather than depending on the order
|
||||
// records happened to arrive in.
|
||||
names.entry(address).or_insert(hostname);
|
||||
}
|
||||
for addresses in hosts.values_mut() {
|
||||
addresses.sort();
|
||||
}
|
||||
|
||||
let serial = content_serial(&hosts);
|
||||
Self {
|
||||
origin,
|
||||
hosts,
|
||||
names,
|
||||
serial,
|
||||
}
|
||||
}
|
||||
|
||||
/// The zone's origin.
|
||||
pub fn origin(&self) -> &ZoneName {
|
||||
&self.origin
|
||||
}
|
||||
|
||||
/// A number that changes whenever the contents do.
|
||||
pub fn serial(&self) -> u32 {
|
||||
self.serial
|
||||
}
|
||||
|
||||
/// How many names it answers for.
|
||||
pub fn len(&self) -> usize {
|
||||
self.hosts.len()
|
||||
}
|
||||
|
||||
/// Whether it answers for nothing.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.hosts.is_empty()
|
||||
}
|
||||
|
||||
/// Every name it answers for, with its addresses.
|
||||
pub fn entries(&self) -> impl Iterator<Item = (&str, &[Ipv4Addr])> {
|
||||
self.hosts
|
||||
.iter()
|
||||
.map(|(name, addresses)| (name.as_str(), addresses.as_slice()))
|
||||
}
|
||||
|
||||
/// The reverse zones this zone is authoritative for.
|
||||
///
|
||||
/// Only when the range lands on an octet boundary. Claiming a reverse
|
||||
/// zone larger than the range would shadow reverse lookups for addresses
|
||||
/// that are nothing to do with us, which is worse than not answering.
|
||||
pub fn reverse_origin(base: Ipv4Addr, prefix_len: u8) -> Option<String> {
|
||||
let octets = base.octets();
|
||||
match prefix_len {
|
||||
8 => Some(format!("{}.in-addr.arpa", octets[0])),
|
||||
16 => Some(format!("{}.{}.in-addr.arpa", octets[1], octets[0])),
|
||||
24 => Some(format!(
|
||||
"{}.{}.{}.in-addr.arpa",
|
||||
octets[2], octets[1], octets[0]
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers one question.
|
||||
pub fn lookup(&self, qname: &str, query: Query) -> Answer {
|
||||
if let Some(address) = reverse_address(qname) {
|
||||
return match self.names.get(&address) {
|
||||
Some(name) if query == Query::Ptr => {
|
||||
Answer::Name(format!("{name}.{}", self.origin.as_str()))
|
||||
}
|
||||
Some(_) => Answer::NoData,
|
||||
None => Answer::NoSuchName,
|
||||
};
|
||||
}
|
||||
|
||||
let Some(relative) = self.origin.relative(qname) else {
|
||||
return Answer::NotOurs;
|
||||
};
|
||||
|
||||
// The apex: the zone itself exists whether or not anybody is in it.
|
||||
if relative.is_empty() {
|
||||
return match query {
|
||||
Query::Soa => Answer::Soa,
|
||||
Query::Ns => Answer::NoData,
|
||||
_ => Answer::NoData,
|
||||
};
|
||||
}
|
||||
|
||||
match self.hosts.get(&relative) {
|
||||
Some(addresses) if query == Query::A => Answer::Addresses(addresses.clone()),
|
||||
// The name is here, it just has no AAAA and never will while
|
||||
// only IPv4 is served. Saying "no such name" instead would tell
|
||||
// a resolver to stop asking for the A record too.
|
||||
Some(_) => Answer::NoData,
|
||||
None => Answer::NoSuchName,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every zone one agent serves.
|
||||
///
|
||||
/// One agent has one identity and as many networks as it likes, and each of
|
||||
/// them is a zone of its own named after the network. They are served
|
||||
/// together because they arrive at the same socket: a question is not
|
||||
/// labelled with the network it belongs to, only with a name, and the
|
||||
/// suffix is what decides. Nothing is shared between them — a member of one
|
||||
/// network is not a name in another.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Zones {
|
||||
zones: Vec<Zone>,
|
||||
}
|
||||
|
||||
impl Zones {
|
||||
/// Collects zones, dropping any that repeats an origin already taken.
|
||||
///
|
||||
/// Two networks can be given the same name, and then only one of them
|
||||
/// can own the suffix. First wins, deterministically, rather than one
|
||||
/// shadowing the other depending on the order they happened to start.
|
||||
pub fn new(zones: impl IntoIterator<Item = Zone>) -> Self {
|
||||
let mut taken: Vec<Zone> = Vec::new();
|
||||
for zone in zones {
|
||||
if taken.iter().any(|other| other.origin() == zone.origin()) {
|
||||
continue;
|
||||
}
|
||||
taken.push(zone);
|
||||
}
|
||||
Self { zones: taken }
|
||||
}
|
||||
|
||||
/// The zones, in the order they will be consulted.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &Zone> {
|
||||
self.zones.iter()
|
||||
}
|
||||
|
||||
/// How many zones are served.
|
||||
pub fn len(&self) -> usize {
|
||||
self.zones.len()
|
||||
}
|
||||
|
||||
/// Whether nothing is served.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.zones.is_empty()
|
||||
}
|
||||
|
||||
/// How many names are answered for, across every zone.
|
||||
pub fn names(&self) -> usize {
|
||||
self.zones.iter().map(Zone::len).sum()
|
||||
}
|
||||
|
||||
/// Answers one question, and says which zone answered it.
|
||||
///
|
||||
/// The answer has to travel with its zone because the SOA that bounds a
|
||||
/// denial belongs to the zone that denied it, and serving several means
|
||||
/// that is no longer a foregone conclusion.
|
||||
///
|
||||
/// A reverse question carries no suffix to match on, so every zone is
|
||||
/// asked and the one that holds the address answers; if none does, the
|
||||
/// denial comes from whichever is authoritative for that reverse zone,
|
||||
/// and from nowhere at all when none is.
|
||||
pub fn lookup(&self, qname: &str, query: Query) -> Option<(&Zone, Answer)> {
|
||||
let mut denial: Option<(&Zone, Answer)> = None;
|
||||
for zone in &self.zones {
|
||||
match zone.lookup(qname, query) {
|
||||
// Not this zone's question. Another may still own it.
|
||||
Answer::NotOurs => continue,
|
||||
// A zone that owns the suffix but not the name can still be
|
||||
// overruled by one that has an answer: only reverse
|
||||
// questions reach more than one zone, and there the zone
|
||||
// holding the address is the one that knows.
|
||||
answer @ (Answer::NoSuchName | Answer::NoData) => {
|
||||
denial.get_or_insert((zone, answer));
|
||||
}
|
||||
answer => return Some((zone, answer)),
|
||||
}
|
||||
}
|
||||
denial
|
||||
}
|
||||
}
|
||||
|
||||
/// The address a reverse name asks about, if it is one.
|
||||
fn reverse_address(qname: &str) -> Option<Ipv4Addr> {
|
||||
let name = qname.trim_end_matches('.').to_ascii_lowercase();
|
||||
let rest = name.strip_suffix(".in-addr.arpa")?;
|
||||
let mut octets = [0u8; 4];
|
||||
let mut seen = 0;
|
||||
for (index, part) in rest.split('.').enumerate() {
|
||||
if index >= 4 {
|
||||
return None;
|
||||
}
|
||||
octets[3 - index] = part.parse().ok()?;
|
||||
seen += 1;
|
||||
}
|
||||
(seen == 4).then(|| Ipv4Addr::from(octets))
|
||||
}
|
||||
|
||||
/// A serial that changes with the contents and not otherwise.
|
||||
///
|
||||
/// Derived rather than counted, so two agents holding the same roster agree,
|
||||
/// and a restart does not go backwards.
|
||||
fn content_serial(hosts: &BTreeMap<String, Vec<Ipv4Addr>>) -> u32 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
for (name, addresses) in hosts {
|
||||
name.hash(&mut hasher);
|
||||
for address in addresses {
|
||||
address.octets().hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
// Never zero: a zero serial is legal but reads like "unset" in a log.
|
||||
(hasher.finish() as u32).max(1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn zone() -> Zone {
|
||||
Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[
|
||||
("music".to_string(), Ipv4Addr::new(10, 13, 37, 237)),
|
||||
("ai".to_string(), Ipv4Addr::new(10, 13, 37, 69)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zone_name_is_normalised_and_checked() {
|
||||
assert_eq!(ZoneName::new("LAB.").unwrap().as_str(), "lab");
|
||||
assert_eq!(
|
||||
ZoneName::new(" lab.internal ").unwrap().as_str(),
|
||||
"lab.internal"
|
||||
);
|
||||
assert_eq!(
|
||||
ZoneName::new("lab.internal").unwrap().top_label(),
|
||||
"internal"
|
||||
);
|
||||
|
||||
assert_eq!(ZoneName::new("").unwrap_err(), ZoneError::Empty);
|
||||
assert_eq!(ZoneName::new(".").unwrap_err(), ZoneError::Empty);
|
||||
assert!(matches!(
|
||||
ZoneName::new("a..b").unwrap_err(),
|
||||
ZoneError::Label { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
ZoneName::new("-lab").unwrap_err(),
|
||||
ZoneError::Label { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
ZoneName::new("la b").unwrap_err(),
|
||||
ZoneError::Label { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
ZoneName::new(&"x".repeat(64)).unwrap_err(),
|
||||
ZoneError::Label { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zone_name_that_shadows_a_public_one_is_flagged_but_allowed() {
|
||||
// Flagged, never refused: shadowing is the user's decision to make.
|
||||
let ru = ZoneName::new("ru").unwrap();
|
||||
let warning = ru.collision().expect("a real TLD is worth mentioning");
|
||||
assert!(warning.contains("real top-level domain"), "{warning}");
|
||||
|
||||
assert!(ZoneName::new("com").unwrap().collision().is_some());
|
||||
assert!(ZoneName::new("lab.com").unwrap().collision().is_some());
|
||||
|
||||
// Reserved for private use, so nothing to say.
|
||||
for quiet in ["internal", "lab.internal", "home", "test", "invalid"] {
|
||||
assert_eq!(ZoneName::new(quiet).unwrap().collision(), None, "{quiet}");
|
||||
}
|
||||
|
||||
// `.local` is not a delegated TLD, but it is not free either.
|
||||
let local = ZoneName::new("local").unwrap().collision().unwrap();
|
||||
assert!(local.contains("multicast DNS"), "{local}");
|
||||
|
||||
// A name that collides with nothing is said nothing about. It
|
||||
// might be delegated one day, and warning about that fires on
|
||||
// every private name anybody picks — a warning about nothing,
|
||||
// which is how people learn to ignore warnings.
|
||||
for quiet in ["lab", "a.lab", "pidar", "twar", "kitchen-table"] {
|
||||
assert_eq!(ZoneName::new(quiet).unwrap().collision(), None, "{quiet}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neighbouring_name_is_not_inside_the_zone() {
|
||||
// `evillab` ends with `lab`, and a suffix comparison that forgot the
|
||||
// label boundary would hand it to us.
|
||||
let origin = ZoneName::new("lab").unwrap();
|
||||
assert!(origin.covers("lab"));
|
||||
assert!(origin.covers("music.lab."));
|
||||
assert!(origin.covers("a.b.lab"));
|
||||
assert!(!origin.covers("evillab"));
|
||||
assert!(!origin.covers("lab.example.com"));
|
||||
assert!(!origin.covers("example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_resolves_by_name() {
|
||||
let zone = zone();
|
||||
assert_eq!(
|
||||
zone.lookup("music.lab", Query::A),
|
||||
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 237)])
|
||||
);
|
||||
// Case and a trailing dot are the same question.
|
||||
assert_eq!(
|
||||
zone.lookup("MUSIC.LAB.", Query::A),
|
||||
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 237)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_with_no_record_of_that_type_is_not_a_missing_name() {
|
||||
// AAAA for a member that exists must be NODATA, not NXDOMAIN: an
|
||||
// NXDOMAIN would tell the resolver the name is absent and stop it
|
||||
// asking for the A record.
|
||||
let zone = zone();
|
||||
assert_eq!(zone.lookup("music.lab", Query::Other), Answer::NoData);
|
||||
assert_eq!(zone.lookup("nobody.lab", Query::A), Answer::NoSuchName);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn questions_outside_the_zone_are_refused_rather_than_denied() {
|
||||
// Denying them would be a lie: this server knows nothing about them.
|
||||
let zone = zone();
|
||||
assert_eq!(zone.lookup("example.com", Query::A), Answer::NotOurs);
|
||||
assert_eq!(zone.lookup("evillab", Query::A), Answer::NotOurs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_apex_exists_even_with_nobody_in_the_network() {
|
||||
let empty = Zone::new(ZoneName::new("lab").unwrap(), []);
|
||||
assert!(empty.is_empty());
|
||||
assert_eq!(empty.lookup("lab", Query::Soa), Answer::Soa);
|
||||
assert_eq!(empty.lookup("lab", Query::A), Answer::NoData);
|
||||
assert_eq!(empty.lookup("music.lab", Query::A), Answer::NoSuchName);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_resolves_back_to_its_name() {
|
||||
let zone = zone();
|
||||
assert_eq!(
|
||||
zone.lookup("237.37.13.10.in-addr.arpa", Query::Ptr),
|
||||
Answer::Name("music.lab".into())
|
||||
);
|
||||
assert_eq!(
|
||||
zone.lookup("9.37.13.10.in-addr.arpa", Query::Ptr),
|
||||
Answer::NoSuchName
|
||||
);
|
||||
// A reverse name asked for the wrong type is still a name we know.
|
||||
assert_eq!(
|
||||
zone.lookup("237.37.13.10.in-addr.arpa", Query::A),
|
||||
Answer::NoData
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reverse_zone_is_claimed_only_when_it_is_exactly_ours() {
|
||||
// Claiming a reverse zone wider than the range would shadow lookups
|
||||
// for addresses that have nothing to do with this network.
|
||||
assert_eq!(
|
||||
Zone::reverse_origin(Ipv4Addr::new(10, 13, 37, 0), 24).as_deref(),
|
||||
Some("37.13.10.in-addr.arpa")
|
||||
);
|
||||
assert_eq!(
|
||||
Zone::reverse_origin(Ipv4Addr::new(10, 13, 0, 0), 16).as_deref(),
|
||||
Some("13.10.in-addr.arpa")
|
||||
);
|
||||
assert_eq!(Zone::reverse_origin(Ipv4Addr::new(10, 13, 37, 0), 25), None);
|
||||
assert_eq!(Zone::reverse_origin(Ipv4Addr::new(100, 64, 0, 0), 10), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_with_nothing_to_say_are_left_out() {
|
||||
let zone = Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[(String::new(), Ipv4Addr::new(10, 0, 0, 1))],
|
||||
);
|
||||
assert!(zone.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_members_sharing_a_name_both_answer_and_the_order_is_stable() {
|
||||
// The state layer resolves name ownership; if two records still
|
||||
// reach here, answering with both beats picking one at random.
|
||||
let build = |flip: bool| {
|
||||
let members = if flip {
|
||||
vec![
|
||||
("music".to_string(), Ipv4Addr::new(10, 0, 0, 2)),
|
||||
("music".to_string(), Ipv4Addr::new(10, 0, 0, 1)),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
("music".to_string(), Ipv4Addr::new(10, 0, 0, 1)),
|
||||
("music".to_string(), Ipv4Addr::new(10, 0, 0, 2)),
|
||||
]
|
||||
};
|
||||
Zone::new(ZoneName::new("lab").unwrap(), members)
|
||||
};
|
||||
assert_eq!(
|
||||
build(false).lookup("music.lab", Query::A),
|
||||
build(true).lookup("music.lab", Query::A)
|
||||
);
|
||||
assert_eq!(build(false).serial(), build(true).serial());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_serial_follows_the_contents() {
|
||||
let a = zone();
|
||||
let b = zone();
|
||||
assert_eq!(a.serial(), b.serial(), "the same roster, the same serial");
|
||||
|
||||
let changed = Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 238))],
|
||||
);
|
||||
assert_ne!(a.serial(), changed.serial());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_network_answers_only_for_its_own_zone() {
|
||||
// One agent, several networks, one socket. A question carries a
|
||||
// name and not the network it belongs to, so the suffix is what
|
||||
// decides — and a member of one network is not a name in another.
|
||||
let zones = Zones::new([
|
||||
Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))],
|
||||
),
|
||||
Zone::new(
|
||||
ZoneName::new("home").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 20, 0, 2))],
|
||||
),
|
||||
]);
|
||||
assert_eq!(zones.len(), 2);
|
||||
assert_eq!(zones.names(), 2);
|
||||
|
||||
let (zone, answer) = zones.lookup("music.lab", Query::A).unwrap();
|
||||
assert_eq!(zone.origin().as_str(), "lab");
|
||||
assert_eq!(
|
||||
answer,
|
||||
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 2)])
|
||||
);
|
||||
|
||||
let (zone, answer) = zones.lookup("music.home", Query::A).unwrap();
|
||||
assert_eq!(zone.origin().as_str(), "home");
|
||||
assert_eq!(answer, Answer::Addresses(vec![Ipv4Addr::new(10, 20, 0, 2)]));
|
||||
|
||||
// A name under neither is nobody's business here.
|
||||
assert!(zones.lookup("music.example.com", Query::A).is_none());
|
||||
assert!(Zones::default().lookup("music.lab", Query::A).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_denial_comes_from_the_zone_that_owns_the_name() {
|
||||
// The SOA that bounds a negative answer belongs to the zone that
|
||||
// denied it; with several served that is no longer a given.
|
||||
let zones = Zones::new([
|
||||
Zone::new(ZoneName::new("lab").unwrap(), []),
|
||||
Zone::new(
|
||||
ZoneName::new("home").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 20, 0, 2))],
|
||||
),
|
||||
]);
|
||||
let (zone, answer) = zones.lookup("nobody.lab", Query::A).unwrap();
|
||||
assert_eq!(zone.origin().as_str(), "lab");
|
||||
assert_eq!(answer, Answer::NoSuchName);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reverse_question_is_answered_by_whichever_zone_holds_the_address() {
|
||||
// It carries no suffix to match on, so every zone is asked.
|
||||
let zones = Zones::new([
|
||||
Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))],
|
||||
),
|
||||
Zone::new(
|
||||
ZoneName::new("home").unwrap(),
|
||||
[("kitchen".to_string(), Ipv4Addr::new(10, 20, 0, 2))],
|
||||
),
|
||||
]);
|
||||
let (_, answer) = zones.lookup("2.0.20.10.in-addr.arpa", Query::Ptr).unwrap();
|
||||
assert_eq!(answer, Answer::Name("kitchen.home".to_string()));
|
||||
let (_, answer) = zones.lookup("2.37.13.10.in-addr.arpa", Query::Ptr).unwrap();
|
||||
assert_eq!(answer, Answer::Name("music.lab".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_networks_of_one_name_do_not_shadow_each_other_at_random() {
|
||||
// Exactly the two-`LAB` case. Only one can own the suffix; which
|
||||
// one must not depend on the order they happened to start in.
|
||||
let first = Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))],
|
||||
);
|
||||
let second = Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 99, 0, 2))],
|
||||
);
|
||||
let zones = Zones::new([first, second]);
|
||||
assert_eq!(zones.len(), 1, "one suffix, one zone");
|
||||
let (_, answer) = zones.lookup("music.lab", Query::A).unwrap();
|
||||
assert_eq!(
|
||||
answer,
|
||||
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 2)])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,16 @@ pub enum Error {
|
||||
#[error("agent is stopped")]
|
||||
Stopped,
|
||||
|
||||
/// Something that had to answer in bounded time did not.
|
||||
///
|
||||
/// Reported instead of waited out: an agent that has wedged must not
|
||||
/// leave a command hanging with nothing on screen and no way out.
|
||||
#[error("{what} did not answer in time")]
|
||||
Timeout {
|
||||
/// What was asked, phrased so the message stands on its own.
|
||||
what: String,
|
||||
},
|
||||
|
||||
/// Discovery backend failure. Never fatal for the agent.
|
||||
#[error("discovery error: {0}")]
|
||||
Discovery(String),
|
||||
@@ -49,11 +49,16 @@ impl DeviceIdentity {
|
||||
self.secret.public()
|
||||
}
|
||||
|
||||
/// The raw secret key bytes, for persistence only.
|
||||
pub(crate) fn secret_bytes(&self) -> [u8; 32] {
|
||||
/// The raw secret key bytes, for persistence and transport certificate generation.
|
||||
pub fn secret_bytes(&self) -> [u8; 32] {
|
||||
self.secret.to_bytes()
|
||||
}
|
||||
|
||||
/// Signs a message with the device's persistent secret key.
|
||||
pub fn sign(&self, message: &[u8]) -> iroh::Signature {
|
||||
self.secret.sign(message)
|
||||
}
|
||||
|
||||
/// A clone of the iroh secret key, for endpoint construction only.
|
||||
pub(crate) fn secret_key(&self) -> SecretKey {
|
||||
self.secret.clone()
|
||||
@@ -340,6 +340,8 @@ pub struct NetworkKeys {
|
||||
network_id: NetworkId,
|
||||
discovery_key: DiscoveryKey,
|
||||
auth_key: Zeroizing<[u8; 32]>,
|
||||
// Separate from the semi-public DiscoveryKey. Existing derivations stay frozen.
|
||||
dht_write_key: Zeroizing<[u8; 32]>,
|
||||
name: NetworkName,
|
||||
}
|
||||
|
||||
@@ -373,6 +375,7 @@ impl NetworkKeys {
|
||||
network_id: NetworkId(expand("network-id")),
|
||||
discovery_key: DiscoveryKey(expand("discovery-key")),
|
||||
auth_key: Zeroizing::new(expand("handshake-auth")),
|
||||
dht_write_key: Zeroizing::new(expand("mainline-rendezvous-write-v1")),
|
||||
name: name.clone(),
|
||||
}
|
||||
}
|
||||
@@ -405,6 +408,11 @@ impl NetworkKeys {
|
||||
pub(crate) fn auth_key(&self) -> &[u8; 32] {
|
||||
&self.auth_key
|
||||
}
|
||||
|
||||
/// Secret signing material for network rendezvous; never exported or logged.
|
||||
pub(crate) fn dht_write_key(&self) -> &[u8; 32] {
|
||||
&self.dht_write_key
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NetworkKeys {
|
||||
@@ -0,0 +1,863 @@
|
||||
//! The local control interface.
|
||||
//!
|
||||
//! This is how a command line tool asks a running agent what it is doing. It
|
||||
//! is deliberately **an adapter over the public API, not part of the core**:
|
||||
//! nothing in [`crate::agent`] knows this module exists, so a Windows named
|
||||
//! pipe or an authenticated loopback socket can be added beside it without
|
||||
//! touching anything else.
|
||||
//!
|
||||
//! It is also a different interface from the peer-to-peer control protocol in
|
||||
//! [`crate::proto`]. That one is between machines and is authenticated by the
|
||||
//! network secret; this one is between processes on one machine and is
|
||||
//! authorised by filesystem permissions.
|
||||
//!
|
||||
//! # Access
|
||||
//!
|
||||
//! The socket lives inside the agent's state directory, which is owner-only,
|
||||
//! and the socket itself is created with mode `0600`. There is no
|
||||
//! unauthenticated listener reachable by other local users, and nothing is
|
||||
//! exposed on the network.
|
||||
//!
|
||||
//! # Wire format
|
||||
//!
|
||||
//! Length-prefixed postcard, with the same frame bounds the network protocol
|
||||
//! uses. The report types here are a stable data transfer format of their own
|
||||
//! rather than the crate's internal structures, so internal refactors do not
|
||||
//! silently change what a client sees.
|
||||
|
||||
#[cfg(unix)]
|
||||
pub mod unix;
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
|
||||
// The one transport this build serves on. The two adapters expose the same
|
||||
// items, so the rest of the crate — and the client wrappers below — name the
|
||||
// transport through this alias and never a platform directly.
|
||||
#[cfg(unix)]
|
||||
use unix as transport;
|
||||
#[cfg(windows)]
|
||||
use windows as transport;
|
||||
|
||||
/// Serves the local control interface on this platform's transport.
|
||||
///
|
||||
/// A Unix socket on Unix, a named pipe on Windows; the same API either way.
|
||||
#[cfg(any(unix, windows))]
|
||||
pub use transport::ControlSocket;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Largest accepted local control message.
|
||||
pub const MAX_MESSAGE_LEN: usize = 1024 * 1024;
|
||||
|
||||
/// Where the control socket for a state directory lives.
|
||||
///
|
||||
/// A Unix socket path is limited to around 100 bytes, which a state directory
|
||||
/// nested deeply enough will exceed. So the runtime directory is preferred
|
||||
/// when the platform provides one — which is also where a runtime socket
|
||||
/// belongs — with a short name derived from the state directory so that two
|
||||
/// agents with different state never share a socket. The state directory
|
||||
/// itself is the fallback.
|
||||
///
|
||||
/// Both the agent and the client compute this the same way, so neither has to
|
||||
/// be told where the other put it.
|
||||
pub fn control_socket_path(state_dir: &Path) -> PathBuf {
|
||||
let digest = Sha256::digest(state_dir.as_os_str().as_encoded_bytes());
|
||||
let tag = hex::encode(&digest[..8]);
|
||||
|
||||
if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") {
|
||||
let runtime = PathBuf::from(runtime);
|
||||
if runtime.is_absolute() {
|
||||
return runtime.join("tsunagi").join(format!("{tag}.sock"));
|
||||
}
|
||||
}
|
||||
state_dir.join("agent.sock")
|
||||
}
|
||||
|
||||
/// Where the default system control socket lives on this platform.
|
||||
///
|
||||
/// On Windows, the system-wide `%ProgramData%\tsunagi\agent.sock` is used so that
|
||||
/// any local user, tray application, or CLI tool can manage the running agent
|
||||
/// without needing to know which user account or service started it.
|
||||
/// On macOS the agent runs as a root LaunchDaemon, so a fixed system path under
|
||||
/// `/var/run` is used for the same reason, and it stays short enough for the
|
||||
/// Unix socket path limit. On other Unix it derives from the default per-user
|
||||
/// state directory.
|
||||
pub fn default_control_socket_path() -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(program_data) = std::env::var_os("ProgramData") {
|
||||
let pd = PathBuf::from(program_data);
|
||||
if pd.is_absolute() {
|
||||
return pd.join("tsunagi").join("agent.sock");
|
||||
}
|
||||
}
|
||||
PathBuf::from(r"C:\ProgramData\tsunagi\agent.sock")
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
PathBuf::from("/var/run/tsunagi/agent.sock")
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
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
|
||||
/// network secret, and a derived one would put it in any log line that
|
||||
/// printed a request.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
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
|
||||
/// the change takes effect and reaches peers immediately instead of
|
||||
/// waiting for a restart.
|
||||
SetHostname(String),
|
||||
/// Leave a network: give up what was claimed, then forget it.
|
||||
///
|
||||
/// Asked of the running agent rather than done behind its back, because
|
||||
/// only it can publish the release, and only while its sessions are up.
|
||||
/// The network is named by its id, in the text form `status` prints.
|
||||
Leave(String),
|
||||
/// Stop serving a network, or start serving it again.
|
||||
///
|
||||
/// Not leaving: the configuration, the secret and the signed state all
|
||||
/// stay, so it can be resumed exactly where it was. The network is
|
||||
/// named by its id, in the text form `status` prints.
|
||||
SetActive {
|
||||
/// Which network.
|
||||
network_id: String,
|
||||
/// Whether it should be running.
|
||||
active: bool,
|
||||
},
|
||||
/// Turn the local resolver on or off, now and for future starts.
|
||||
Dns {
|
||||
/// Whether it should be serving.
|
||||
enable: bool,
|
||||
/// The port to serve on. Keeps the stored one when absent.
|
||||
port: Option<u16>,
|
||||
},
|
||||
/// Join a network, or start one that is configured and not running.
|
||||
///
|
||||
/// Asked of the running agent because that is the only way to add a
|
||||
/// network to an agent that is already up: the state directory belongs
|
||||
/// to one live agent, so a second `tsunagi up` cannot.
|
||||
Join {
|
||||
/// The network name.
|
||||
name: String,
|
||||
/// The shared secret in its `tsn1…` text form.
|
||||
///
|
||||
/// 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>,
|
||||
},
|
||||
}
|
||||
|
||||
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 } => {
|
||||
write!(f, "SetActive {{ {network_id}, active: {active} }}")
|
||||
}
|
||||
// The name is not a secret; the secret is.
|
||||
Request::Dns { enable, port } => {
|
||||
write!(f, "Dns {{ enable: {enable}, port: {port:?} }}")
|
||||
}
|
||||
Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: <redacted> }}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the agent answers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
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.
|
||||
Left(LeftReport),
|
||||
/// A network was joined, or was already there and is now running.
|
||||
Joined(JoinedReport),
|
||||
/// A network was stopped or started.
|
||||
Active(ActiveReport),
|
||||
/// What the local resolver is doing, after being changed or asked.
|
||||
///
|
||||
/// `None` means it is not serving at all.
|
||||
Dns(Option<DnsReport>),
|
||||
/// The request could not be served.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// What happened when a network was stopped or started.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ActiveReport {
|
||||
/// The network's name, for the message the user reads.
|
||||
pub name: String,
|
||||
/// Whether it is running now.
|
||||
pub active: bool,
|
||||
/// Whether this request is what changed it.
|
||||
///
|
||||
/// Stopping something already stopped is not an error — it is the state
|
||||
/// asked for — but it is worth saying which happened.
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
/// What happened when a network was joined.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JoinedReport {
|
||||
/// The network's name, as given.
|
||||
pub name: String,
|
||||
/// The derived network id, which is what tells two same-named networks
|
||||
/// apart.
|
||||
pub network_id: String,
|
||||
/// Whether this device was already configured for exactly this network.
|
||||
///
|
||||
/// Joining is idempotent, so this is the difference between "added" and
|
||||
/// "it was already there and is now running".
|
||||
pub already_configured: bool,
|
||||
/// Another configured network with the same name but a different
|
||||
/// secret, if there is one.
|
||||
///
|
||||
/// Almost always a mistyped secret, and the one thing that makes two
|
||||
/// sections of a report look like one network.
|
||||
pub name_shared_with: Option<String>,
|
||||
}
|
||||
|
||||
/// What happened when a network was left.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LeftReport {
|
||||
/// The network's name, for the message the user reads.
|
||||
pub name: String,
|
||||
/// Whether a signed release was published.
|
||||
///
|
||||
/// `false` when the network was not running: nothing could sign or send
|
||||
/// it, so this was a local removal and the others keep the old claim.
|
||||
pub announced: bool,
|
||||
/// How many connected peers it was handed to.
|
||||
///
|
||||
/// They pass it on, so this is not the number of members that will
|
||||
/// learn of it — but zero means none of them will.
|
||||
pub peers_told: u32,
|
||||
}
|
||||
|
||||
/// Everything the agent is doing, in one snapshot.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StatusReport {
|
||||
/// This device's persistent endpoint id.
|
||||
pub endpoint_id: String,
|
||||
/// Hostname announced to peers.
|
||||
pub hostname: String,
|
||||
/// Sockets the endpoint is bound to.
|
||||
pub bound_sockets: Vec<String>,
|
||||
/// Whether the disposable cache is usable.
|
||||
pub cache_healthy: bool,
|
||||
/// One entry per configured network.
|
||||
pub networks: Vec<NetworkReport>,
|
||||
/// The local DNS service, when one was asked for.
|
||||
pub dns: Option<DnsReport>,
|
||||
}
|
||||
|
||||
/// The local DNS service.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DnsReport {
|
||||
/// The zones it answers for: one per network, named after it.
|
||||
pub zones: Vec<String>,
|
||||
/// Every address it is listening on, one per family where it could.
|
||||
///
|
||||
/// Empty means it answers nowhere, and `bind_error` says why.
|
||||
pub listening: Vec<String>,
|
||||
/// Why it could not bind, if it did not.
|
||||
pub bind_error: Option<String>,
|
||||
/// Why the system resolver was not told, if it was not.
|
||||
///
|
||||
/// `None` means it was told. The server answers either way, so this is a
|
||||
/// degraded overlay rather than a broken one.
|
||||
pub publish_error: Option<String>,
|
||||
/// What to do about that, when there is something.
|
||||
pub publish_remedy: Option<String>,
|
||||
/// Anything worth saying about those names, one line each.
|
||||
pub zone_warnings: Vec<String>,
|
||||
/// How many names it answers for.
|
||||
pub names: u32,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub network_id: String,
|
||||
/// Whether the network is running locally.
|
||||
pub active: bool,
|
||||
/// Authenticated control plane peers.
|
||||
pub peers: Vec<PeerReport>,
|
||||
/// Members the signed state knows about, connected or not.
|
||||
///
|
||||
/// This is what makes "offline" sayable. Without it a member that is away
|
||||
/// is indistinguishable from one that never existed, and the only thing
|
||||
/// left to report is a dial-failure counter — which describes the symptom
|
||||
/// and not the cause.
|
||||
pub members: Vec<MemberReport>,
|
||||
/// Unverified candidates this network currently knows of.
|
||||
///
|
||||
/// Not peers: somewhere to try. Zero of them, with nobody connected,
|
||||
/// is the difference between "nobody has joined yet" and "this agent
|
||||
/// has no way to reach anybody" — which look identical in a report
|
||||
/// that counts only members.
|
||||
pub candidates: u32,
|
||||
/// Datagrams this agent passed on between two other peers.
|
||||
pub relay_forwarded: u64,
|
||||
/// Datagrams this agent sent to a peer through somebody else.
|
||||
pub relay_sent_via: u64,
|
||||
/// Datagrams that reached this agent through somebody else.
|
||||
pub relay_received_via: u64,
|
||||
/// Outbound dials that failed.
|
||||
pub dial_failures: u64,
|
||||
/// Handshakes rejected in either direction.
|
||||
pub handshake_failures: u64,
|
||||
/// Control messages sent and received.
|
||||
pub control_messages: (u64, u64),
|
||||
/// The overlay range this network uses, once it has one.
|
||||
pub range: Option<String>,
|
||||
/// The range it could not have, because another network on this agent
|
||||
/// already holds it.
|
||||
pub range_conflict: Option<String>,
|
||||
/// The overlay, when a protocol is running one.
|
||||
pub overlay: Option<OverlayReport>,
|
||||
}
|
||||
|
||||
/// One member of the network, from signed state.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MemberReport {
|
||||
/// The member's device identity.
|
||||
pub endpoint_id: String,
|
||||
/// The IPv4 overlay address it claimed and signed for.
|
||||
pub overlay_address_v4: Option<String>,
|
||||
/// Consecutive failed dial attempts, when this agent is trying to reach it.
|
||||
pub failed_dials: u32,
|
||||
}
|
||||
|
||||
/// One control plane peer.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PeerReport {
|
||||
/// The peer's endpoint id.
|
||||
pub endpoint_id: String,
|
||||
/// Hostname it announced, if any.
|
||||
pub hostname: Option<String>,
|
||||
/// How the connection reaches the peer: `direct`, `relay` or `unknown`,
|
||||
/// as the transport reports it.
|
||||
pub transport: String,
|
||||
/// Round-trip time in milliseconds, when a path is selected.
|
||||
pub rtt_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// The WireGuard overlay of one network.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OverlayReport {
|
||||
/// Packet interface name.
|
||||
pub interface: String,
|
||||
/// Whether that interface exists on the host.
|
||||
///
|
||||
/// `false` under `--no-tun`: tunnels run and packets move between
|
||||
/// agents, but the operating system has no interface, no address and no
|
||||
/// route, so nothing local reaches the overlay.
|
||||
pub on_host: bool,
|
||||
/// Interface MTU.
|
||||
pub mtu: u32,
|
||||
/// This agent's overlay address, once the network has agreed one.
|
||||
pub address: Option<String>,
|
||||
/// Prefix length of the overlay range every member shares.
|
||||
pub prefix_len: u8,
|
||||
/// One entry per overlay peer.
|
||||
pub peers: Vec<OverlayPeerReport>,
|
||||
/// Unicast packets sent to an address no peer owns.
|
||||
pub unroutable_packets: u64,
|
||||
/// Multicast packets dropped. Expected, not a fault.
|
||||
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.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
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,
|
||||
/// 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>,
|
||||
/// Seconds since the last WireGuard handshake.
|
||||
///
|
||||
/// `None` means the tunnel has never handshaken and cannot carry traffic.
|
||||
pub handshake_secs_ago: Option<u64>,
|
||||
/// Packets encrypted and sent to this peer.
|
||||
pub tx_packets: u64,
|
||||
/// Packets decrypted from this peer.
|
||||
pub rx_packets: u64,
|
||||
/// Data packets dropped: wrong source address, or too large for the path.
|
||||
pub dropped: u64,
|
||||
/// WireGuard protocol errors.
|
||||
///
|
||||
/// A few are normal while a tunnel is being set up, because both ends
|
||||
/// start a handshake at once and one of the two is discarded.
|
||||
pub protocol_errors: u64,
|
||||
/// What the transport reports about the path in use.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl OverlayPeerReport {
|
||||
/// Whether the tunnel has handshaken and can carry traffic.
|
||||
pub fn is_up(&self) -> bool {
|
||||
self.handshake_secs_ago.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The transport-agnostic half of the local control interface.
|
||||
//
|
||||
// Everything below is the same on every platform: the request/response
|
||||
// framing, the dispatch of a request onto a [`ReportSource`], and the client
|
||||
// wrappers that ask one question and read one answer. Only the listener and
|
||||
// the stream differ, and those live in the per-platform `unix` and `windows`
|
||||
// adapters. Each adapter drives the shared [`serve_connection`] for its
|
||||
// accepted streams and offers a `connect`/`is_serving` for the client side,
|
||||
// so the logic here is written once.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Builds the report that answers a status request, and applies the changes a
|
||||
/// client asks for.
|
||||
///
|
||||
/// Supplied by the caller, because only the caller knows which plugins are
|
||||
/// running and what they can report or change. That is what keeps this module
|
||||
/// free of any knowledge of them.
|
||||
pub trait ReportSource: Send + Sync + 'static {
|
||||
/// Produces a fresh report.
|
||||
fn report(&self) -> BoxFuture<'_, StatusReport>;
|
||||
|
||||
/// Changes the name the agent answers to, returning the accepted form.
|
||||
///
|
||||
/// Defaulted to a refusal so that a source which only reports — the
|
||||
/// closure impl below, and every test that uses it — stays valid and
|
||||
/// says plainly that it cannot do this, rather than appearing to.
|
||||
fn set_hostname(
|
||||
&self,
|
||||
_hostname: String,
|
||||
) -> BoxFuture<'_, std::result::Result<String, String>> {
|
||||
Box::pin(async move { Err("this agent cannot change its hostname".to_string()) })
|
||||
}
|
||||
|
||||
/// Leaves a network, publishing a release first.
|
||||
///
|
||||
/// Defaulted to a refusal for the same reason as the above: a source
|
||||
/// that only reports says so plainly rather than appearing to do it.
|
||||
fn leave(&self, _network_id: String) -> BoxFuture<'_, std::result::Result<LeftReport, String>> {
|
||||
Box::pin(async move { Err("this agent cannot leave a network".to_string()) })
|
||||
}
|
||||
|
||||
/// Joins a network, or starts one that is configured and not running.
|
||||
///
|
||||
/// Defaulted to a refusal, like the others: a source that only reports
|
||||
/// says so rather than appearing to have done it.
|
||||
fn join(
|
||||
&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.
|
||||
fn set_active(
|
||||
&self,
|
||||
_network_id: String,
|
||||
_active: bool,
|
||||
) -> BoxFuture<'_, std::result::Result<ActiveReport, String>> {
|
||||
Box::pin(async move { Err("this agent cannot stop or start a network".to_string()) })
|
||||
}
|
||||
|
||||
/// Turns the local resolver on or off while the agent runs.
|
||||
///
|
||||
/// Defaulted to a refusal, like the others.
|
||||
fn set_dns(
|
||||
&self,
|
||||
_enable: bool,
|
||||
_port: Option<u16>,
|
||||
) -> BoxFuture<'_, std::result::Result<Option<DnsReport>, String>> {
|
||||
Box::pin(async move { Err("this agent cannot serve DNS".to_string()) })
|
||||
}
|
||||
}
|
||||
|
||||
impl<F> ReportSource for F
|
||||
where
|
||||
F: Fn() -> BoxFuture<'static, StatusReport> + Send + Sync + 'static,
|
||||
{
|
||||
fn report(&self) -> BoxFuture<'_, StatusReport> {
|
||||
(self)()
|
||||
}
|
||||
}
|
||||
|
||||
/// How long either end waits for the other.
|
||||
///
|
||||
/// A local answer comes from memory, so anything this slow means the agent is
|
||||
/// wedged rather than busy. Saying so beats waiting: unbounded, one wedged
|
||||
/// runtime leaves `tsunagi status` hanging with nothing on screen and no way
|
||||
/// out but Ctrl-C.
|
||||
pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Marks the wire format of the local control interface.
|
||||
///
|
||||
/// `b"TSN"` followed by the version, so a mismatch is recognised as one
|
||||
/// instead of being read as a length. The encoding is postcard, which is not
|
||||
/// self-describing: adding a field to a report changes how the bytes parse,
|
||||
/// and without this a client one build ahead of its agent reports something
|
||||
/// like "Found an Option discriminant that wasn't 0 or 1" — which says
|
||||
/// nothing about the actual problem, that the two are different builds.
|
||||
///
|
||||
/// 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', 15]);
|
||||
|
||||
/// Reads one request off an accepted stream, answers it, writes the response.
|
||||
///
|
||||
/// The per-platform adapters call this for every connection they accept, so
|
||||
/// the request handling is identical on every transport.
|
||||
pub(crate) async fn serve_connection<S>(mut stream: S, source: Arc<dyn ReportSource>) -> Result<()>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
// Bounded, so a connection that sends nothing cannot hold a task open.
|
||||
// Only the wait for the request: building the report afterwards takes as
|
||||
// long as it takes, and cutting it off would answer a live client with a
|
||||
// closed socket.
|
||||
let request: Request =
|
||||
match tokio::time::timeout(EXCHANGE_TIMEOUT, read_message(&mut stream)).await {
|
||||
Ok(request) => request?,
|
||||
Err(_) => {
|
||||
return Err(Error::Timeout {
|
||||
what: "a local control connection".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
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),
|
||||
},
|
||||
Request::Leave(network_id) => match source.leave(network_id).await {
|
||||
Ok(report) => Response::Left(report),
|
||||
Err(reason) => Response::Error(reason),
|
||||
},
|
||||
Request::Join {
|
||||
name,
|
||||
secret,
|
||||
broadcast,
|
||||
} => match source.join(name, secret, broadcast).await {
|
||||
Ok(report) => Response::Joined(report),
|
||||
Err(reason) => Response::Error(reason),
|
||||
},
|
||||
Request::SetActive { network_id, active } => {
|
||||
match source.set_active(network_id, active).await {
|
||||
Ok(report) => Response::Active(report),
|
||||
Err(reason) => Response::Error(reason),
|
||||
}
|
||||
}
|
||||
Request::Dns { enable, port } => match source.set_dns(enable, port).await {
|
||||
Ok(report) => Response::Dns(report),
|
||||
Err(reason) => Response::Error(reason),
|
||||
},
|
||||
};
|
||||
write_message(&mut stream, &response).await
|
||||
}
|
||||
|
||||
/// Connects, sends one request and reads the answer, all within `within`.
|
||||
///
|
||||
/// The bound covers the whole exchange rather than each read: an agent that
|
||||
/// answers the header and then stops is as stuck as one that never answers.
|
||||
async fn exchange(path: &Path, request: &Request, within: Duration) -> Result<Response> {
|
||||
let attempt = async {
|
||||
let mut stream = transport::connect(path).await?;
|
||||
write_message(&mut stream, request).await?;
|
||||
read_message::<Response, _>(&mut stream).await
|
||||
};
|
||||
|
||||
match tokio::time::timeout(within, attempt).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(Error::Timeout {
|
||||
what: format!("the agent at {}", path.display()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an agent is serving the control interface at `path`.
|
||||
///
|
||||
/// Used to tell a running agent from a leftover: on Unix a socket file with
|
||||
/// nothing behind it, on Windows nothing at all.
|
||||
/// A Windows pipe that denies access still counts as present; the subsequent
|
||||
/// request reports the permission error instead of attempting an offline edit.
|
||||
pub async fn is_serving(path: impl AsRef<Path>) -> bool {
|
||||
transport::probe(path.as_ref()).await
|
||||
}
|
||||
|
||||
/// Asks a running agent for its status.
|
||||
pub async fn request_status(path: impl AsRef<Path>) -> Result<StatusReport> {
|
||||
let path = path.as_ref();
|
||||
match exchange(path, &Request::Status, EXCHANGE_TIMEOUT).await? {
|
||||
Response::Status(report) => Ok(*report),
|
||||
Response::Error(reason) => Err(Error::Storage(reason)),
|
||||
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks a running agent to answer to a different name.
|
||||
///
|
||||
/// Returns the name it accepted, which is the canonical form of what was
|
||||
/// asked for and may differ from it.
|
||||
pub async fn set_hostname(path: impl AsRef<Path>, hostname: &str) -> Result<String> {
|
||||
let path = path.as_ref();
|
||||
let request = Request::SetHostname(hostname.to_string());
|
||||
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
|
||||
Response::Hostname(accepted) => Ok(accepted),
|
||||
Response::Error(reason) => Err(Error::Storage(reason)),
|
||||
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks a running agent to leave a network.
|
||||
///
|
||||
/// The agent publishes the release and removes the network; this only
|
||||
/// carries the request and the outcome.
|
||||
pub async fn leave_network(path: impl AsRef<Path>, network_id: &str) -> Result<LeftReport> {
|
||||
let path = path.as_ref();
|
||||
let request = Request::Leave(network_id.to_string());
|
||||
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
|
||||
Response::Left(report) => Ok(report),
|
||||
Response::Error(reason) => Err(Error::Storage(reason)),
|
||||
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks a running agent to join a network.
|
||||
///
|
||||
/// The one way to add a network to an agent that is already up: the state
|
||||
/// directory belongs to one live agent, so a second `up` cannot.
|
||||
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(),
|
||||
};
|
||||
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
|
||||
Response::Joined(report) => Ok(report),
|
||||
Response::Error(reason) => Err(Error::Storage(reason)),
|
||||
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
network_id: &str,
|
||||
active: bool,
|
||||
) -> Result<ActiveReport> {
|
||||
let path = path.as_ref();
|
||||
let request = Request::SetActive {
|
||||
network_id: network_id.to_string(),
|
||||
active,
|
||||
};
|
||||
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
|
||||
Response::Active(report) => Ok(report),
|
||||
Response::Error(reason) => Err(Error::Storage(reason)),
|
||||
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns the running agent's local resolver on or off.
|
||||
pub async fn set_dns(
|
||||
path: impl AsRef<Path>,
|
||||
enable: bool,
|
||||
port: Option<u16>,
|
||||
) -> Result<Option<DnsReport>> {
|
||||
let path = path.as_ref();
|
||||
match exchange(path, &Request::Dns { enable, port }, EXCHANGE_TIMEOUT).await? {
|
||||
Response::Dns(report) => Ok(report),
|
||||
Response::Error(reason) => Err(Error::Storage(reason)),
|
||||
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn write_message<S, T>(stream: &mut S, value: &T) -> Result<()>
|
||||
where
|
||||
S: AsyncWrite + Unpin,
|
||||
T: serde::Serialize,
|
||||
{
|
||||
let encoded = postcard::to_stdvec(value)
|
||||
.map_err(|err| Error::Storage(format!("cannot encode a control message: {err}")))?;
|
||||
if encoded.len() > MAX_MESSAGE_LEN {
|
||||
return Err(Error::Storage("control message is too large".into()));
|
||||
}
|
||||
let len = encoded.len() as u32;
|
||||
stream
|
||||
.write_all(&CONTROL_PROTOCOL.to_be_bytes())
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
stream
|
||||
.write_all(&len.to_be_bytes())
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
stream.write_all(&encoded).await.map_err(io_error)?;
|
||||
stream.flush().await.map_err(io_error)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_message<T, S>(stream: &mut S) -> Result<T>
|
||||
where
|
||||
T: for<'de> serde::Deserialize<'de>,
|
||||
S: AsyncRead + Unpin,
|
||||
{
|
||||
let mut header = [0u8; 4];
|
||||
stream.read_exact(&mut header).await.map_err(io_error)?;
|
||||
let version = u32::from_be_bytes(header);
|
||||
if version != CONTROL_PROTOCOL {
|
||||
return Err(Error::Storage(format!(
|
||||
"the other end speaks control protocol {version:#010x} and this build speaks \
|
||||
{CONTROL_PROTOCOL:#010x}; they are different builds of tsunagi, so restart the \
|
||||
agent with the binary you are running now"
|
||||
)));
|
||||
}
|
||||
|
||||
stream.read_exact(&mut header).await.map_err(io_error)?;
|
||||
let len = u32::from_be_bytes(header) as usize;
|
||||
// Checked before allocating, exactly as on the network.
|
||||
if len > MAX_MESSAGE_LEN {
|
||||
return Err(Error::Storage(format!(
|
||||
"control message of {len} bytes exceeds the {MAX_MESSAGE_LEN} byte limit"
|
||||
)));
|
||||
}
|
||||
let mut payload = vec![0u8; len];
|
||||
stream.read_exact(&mut payload).await.map_err(io_error)?;
|
||||
postcard::from_bytes(&payload)
|
||||
.map_err(|err| Error::Storage(format!("cannot decode a control message: {err}")))
|
||||
}
|
||||
|
||||
pub(crate) fn io_error(source: std::io::Error) -> Error {
|
||||
Error::Io {
|
||||
path: PathBuf::from("<local control socket>"),
|
||||
source,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! A Unix socket adapter for the local control interface.
|
||||
//!
|
||||
//! One of the per-platform adapters; see [`super`]. It provides only what is
|
||||
//! particular to a Unix socket — binding a listener, connecting a client, and
|
||||
//! the owner-only permissions — and hands every accepted connection to the
|
||||
//! shared [`serve_connection`](super::serve_connection). The request framing,
|
||||
//! the dispatch and the client wrappers are the same on every platform and
|
||||
//! live in [`super`], re-exported here so `ipc::unix::request_status` and its
|
||||
//! siblings keep resolving.
|
||||
//!
|
||||
//! It is reachable only by a process that can open a file inside the agent's
|
||||
//! owner-only state directory, and the socket itself is created mode `0600`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
// The transport-agnostic surface, re-exported so this module is a complete
|
||||
// view of the local control interface on its own.
|
||||
pub use super::{
|
||||
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
|
||||
request_status, set_active, set_dns, set_hostname,
|
||||
};
|
||||
|
||||
/// Serves the local control interface on a Unix socket.
|
||||
#[derive(Debug)]
|
||||
pub struct ControlSocket {
|
||||
path: PathBuf,
|
||||
task: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ControlSocket {
|
||||
/// Binds the socket and starts serving.
|
||||
///
|
||||
/// A socket file left behind by a crashed agent is replaced, but only
|
||||
/// after checking that nothing is listening on it, so two live agents
|
||||
/// never fight over one path.
|
||||
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
crate::storage::create_dir(parent)?;
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
if is_serving(&path).await {
|
||||
return Err(Error::StateLocked { path: path.clone() });
|
||||
}
|
||||
// Nothing is listening, so the file is a leftover.
|
||||
std::fs::remove_file(&path).map_err(|source| Error::Io {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(&path).map_err(|source| Error::Io {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
restrict(&path)?;
|
||||
|
||||
let task = tokio::spawn(serve(listener, source));
|
||||
Ok(Self {
|
||||
path,
|
||||
task: Some(task),
|
||||
})
|
||||
}
|
||||
|
||||
/// The path being served.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Stops serving and removes the socket file.
|
||||
pub async fn shutdown(mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ControlSocket {
|
||||
fn drop(&mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn restrict(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
|
||||
Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn serve(listener: UnixListener, source: Arc<dyn ReportSource>) {
|
||||
loop {
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
continue;
|
||||
};
|
||||
let source = Arc::clone(&source);
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = super::serve_connection(stream, source).await {
|
||||
tracing::debug!(%err, "local control request failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects a client to the socket at `path`.
|
||||
pub(crate) async fn connect(path: &Path) -> Result<UnixStream> {
|
||||
UnixStream::connect(path).await.map_err(|source| Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether an agent is listening on the socket at `path`.
|
||||
pub(crate) async fn probe(path: &Path) -> bool {
|
||||
UnixStream::connect(path).await.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::super::{Request, Response, StatusReport, exchange};
|
||||
use super::*;
|
||||
use crate::BoxFuture;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_silent_agent_is_reported_rather_than_waited_out() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("control.sock");
|
||||
// A socket that accepts and then says nothing, like a wedged agent:
|
||||
// the connection succeeds, the answer never comes. Unbounded, this
|
||||
// call would never return.
|
||||
let _listener = UnixListener::bind(&path).unwrap();
|
||||
|
||||
let error = exchange(&path, &Request::Status, Duration::from_millis(100))
|
||||
.await
|
||||
.expect_err("a silent agent cannot be reported as healthy");
|
||||
|
||||
match error {
|
||||
Error::Timeout { what } => assert!(
|
||||
what.contains("control.sock"),
|
||||
"the message names what did not answer: {what}"
|
||||
),
|
||||
other => panic!("expected a timeout, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_answered_request_is_not_affected_by_the_bound() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("control.sock");
|
||||
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
||||
Box::pin(async { StatusReport::default() })
|
||||
});
|
||||
let control = ControlSocket::bind(&path, source).await.unwrap();
|
||||
|
||||
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(answer, Response::Status(_)));
|
||||
|
||||
control.shutdown().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
//! A named-pipe adapter for the local control interface.
|
||||
//!
|
||||
//! The Windows counterpart of [`super::unix`]. It provides only what is
|
||||
//! particular to a named pipe — creating the server instance, opening a client
|
||||
//! and turning the state directory path into a pipe name — and hands every
|
||||
//! accepted connection to the shared [`serve_connection`](super::serve_connection).
|
||||
//! The framing, the dispatch and the client wrappers are the same on every
|
||||
//! platform and live in [`super`], re-exported here so `ipc::windows::request_status`
|
||||
//! and its siblings resolve just as their Unix equivalents do.
|
||||
//!
|
||||
//! # From a path to a pipe
|
||||
//!
|
||||
//! The rest of the agent addresses the control interface by a filesystem path,
|
||||
//! the same one a Unix socket would live at. A named pipe has no filesystem
|
||||
//! path, so the path is hashed into a stable name under `\\.\pipe\`. The agent
|
||||
//! and a client derive it the same way from the same path, so neither has to
|
||||
//! be told where the other put it.
|
||||
//!
|
||||
//! # Who may connect
|
||||
//!
|
||||
//! The pipe rejects clients from other machines (`reject_remote_clients(true)`).
|
||||
//! An explicit SDDL security descriptor grants Full Control to Administrators
|
||||
//! and LocalSystem, while granting Read and Write (`GRGW`) to all local users
|
||||
//! with a Medium Integrity SACL label (`S:(ML;;NW;;;ME)`). This allows unprivileged
|
||||
//! user-space tools (such as tray applications, status monitors, or CLI commands
|
||||
//! launched by regular users) to connect to the agent even when the agent was
|
||||
//! started by an elevated Administrator or as a system service.
|
||||
//! A permission denial is reported as such, never as an absent agent.
|
||||
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient, ServerOptions};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
pub use super::{
|
||||
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
|
||||
request_status, set_active, set_dns, set_hostname,
|
||||
};
|
||||
|
||||
/// `ERROR_ACCESS_DENIED`: creating a first instance finds an existing pipe,
|
||||
/// or a client cannot access an existing pipe with its current privileges.
|
||||
const ERROR_ACCESS_DENIED: i32 = 5;
|
||||
/// `ERROR_PIPE_BUSY`: every instance is serving a client right now. A server
|
||||
/// is there; the client only has to wait for a free instance.
|
||||
const ERROR_PIPE_BUSY: i32 = 231;
|
||||
|
||||
/// RAII wrapper for Windows named pipe security attributes with permissive local ACL.
|
||||
///
|
||||
/// SDDL specification:
|
||||
/// - `D:(A;;GRGW;;;WD)`: DACL allows Generic Read and Generic Write to Everyone (`WD`).
|
||||
/// Full control (`GA`) remains reserved to Administrators and LocalSystem.
|
||||
/// - `(A;;GA;;;BA)`: DACL allows Generic All to Builtin Administrators (`BA`).
|
||||
/// - `(A;;GA;;;SY)`: DACL allows Generic All to LocalSystem (`SY`).
|
||||
/// - `S:(ML;;NW;;;ME)`: SACL Mandatory Label with Medium Integrity (`ME`) and No Write Up (`NW`).
|
||||
/// This allows unprivileged user-space processes (Medium Integrity) to write to the
|
||||
/// named pipe even when the agent runs as an elevated Administrator (High Integrity).
|
||||
struct PipeSecurityAttributes {
|
||||
attrs: windows_sys::Win32::Security::SECURITY_ATTRIBUTES,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PipeSecurityAttributes {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PipeSecurityAttributes")
|
||||
.field("nLength", &self.attrs.nLength)
|
||||
.field("lpSecurityDescriptor", &self.attrs.lpSecurityDescriptor)
|
||||
.field("bInheritHandle", &self.attrs.bInheritHandle)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PipeSecurityAttributes {
|
||||
fn new() -> Result<Self> {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
|
||||
const SDDL: &str = "D:(A;;GRGW;;;WD)(A;;GA;;;BA)(A;;GA;;;SY)S:(ML;;NW;;;ME)";
|
||||
let wide: Vec<u16> = OsStr::new(SDDL)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
let mut p_sd = std::ptr::null_mut();
|
||||
// SAFETY: We pass a valid null-terminated wide string, standard revision 1,
|
||||
// and a valid pointer to receive the descriptor.
|
||||
let success = unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
wide.as_ptr(),
|
||||
SDDL_REVISION_1,
|
||||
&mut p_sd,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
if success == 0 || p_sd.is_null() {
|
||||
let err = std::io::Error::last_os_error();
|
||||
return Err(Error::Storage(format!(
|
||||
"cannot create pipe security descriptor: {err}"
|
||||
)));
|
||||
}
|
||||
|
||||
let attrs = windows_sys::Win32::Security::SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<windows_sys::Win32::Security::SECURITY_ATTRIBUTES>()
|
||||
as u32,
|
||||
lpSecurityDescriptor: p_sd,
|
||||
bInheritHandle: 0,
|
||||
};
|
||||
|
||||
Ok(Self { attrs })
|
||||
}
|
||||
|
||||
fn as_raw_mut(&mut self) -> *mut std::ffi::c_void {
|
||||
&mut self.attrs as *mut _ as *mut std::ffi::c_void
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PipeSecurityAttributes {
|
||||
fn drop(&mut self) {
|
||||
if !self.attrs.lpSecurityDescriptor.is_null() {
|
||||
// SAFETY: The descriptor was allocated by ConvertStringSecurityDescriptorToSecurityDescriptorW
|
||||
// and must be freed with LocalFree.
|
||||
unsafe {
|
||||
windows_sys::Win32::Foundation::LocalFree(self.attrs.lpSecurityDescriptor);
|
||||
}
|
||||
self.attrs.lpSecurityDescriptor = std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves the local control interface on a named pipe.
|
||||
#[derive(Debug)]
|
||||
pub struct ControlSocket {
|
||||
path: PathBuf,
|
||||
task: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ControlSocket {
|
||||
/// Binds the pipe and starts serving.
|
||||
///
|
||||
/// Claiming the first instance of the name is what detects a second agent:
|
||||
/// if the name already exists, the create is refused and that is reported
|
||||
/// as the state being locked, so two live agents never share one pipe.
|
||||
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let name = pipe_name(&path);
|
||||
|
||||
let mut sec_attrs = PipeSecurityAttributes::new()?;
|
||||
// SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES.
|
||||
let server = match unsafe {
|
||||
ServerOptions::new()
|
||||
.first_pipe_instance(true)
|
||||
.reject_remote_clients(true)
|
||||
.create_with_security_attributes_raw(&name, sec_attrs.as_raw_mut())
|
||||
} {
|
||||
Ok(server) => server,
|
||||
Err(err) if err.raw_os_error() == Some(ERROR_ACCESS_DENIED) => {
|
||||
return Err(Error::StateLocked { path });
|
||||
}
|
||||
Err(source) => return Err(Error::Io { path, source }),
|
||||
};
|
||||
|
||||
let task = tokio::spawn(serve(name, server, source));
|
||||
Ok(Self {
|
||||
path,
|
||||
task: Some(task),
|
||||
})
|
||||
}
|
||||
|
||||
/// The path the pipe name was derived from.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Stops serving. The pipe goes with the server, so there is nothing to
|
||||
/// remove.
|
||||
pub async fn shutdown(mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ControlSocket {
|
||||
fn drop(&mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts one client at a time, keeping a fresh instance ready for the next.
|
||||
///
|
||||
/// A named pipe server instance serves a single client, so a new instance is
|
||||
/// created as soon as one is taken — otherwise a second `tsunagi status` while
|
||||
/// the first is mid-flight would find nothing listening.
|
||||
async fn serve(
|
||||
name: String,
|
||||
first: tokio::net::windows::named_pipe::NamedPipeServer,
|
||||
source: Arc<dyn ReportSource>,
|
||||
) {
|
||||
let mut server = first;
|
||||
loop {
|
||||
if server.connect().await.is_err() {
|
||||
match next_instance(&name) {
|
||||
Some(next) => {
|
||||
server = next;
|
||||
continue;
|
||||
}
|
||||
None => return,
|
||||
}
|
||||
}
|
||||
let connected = server;
|
||||
match next_instance(&name) {
|
||||
Some(next) => server = next,
|
||||
None => {
|
||||
// Nothing left to accept the next client on, but the one in
|
||||
// hand is still answered before the loop ends.
|
||||
let source = Arc::clone(&source);
|
||||
tokio::spawn(async move {
|
||||
let _ = super::serve_connection(connected, source).await;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
let source = Arc::clone(&source);
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = super::serve_connection(connected, source).await {
|
||||
tracing::debug!(%err, "local control request failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the next pipe instance, or `None` if the name can no longer be
|
||||
/// served.
|
||||
fn next_instance(name: &str) -> Option<tokio::net::windows::named_pipe::NamedPipeServer> {
|
||||
let mut sec_attrs = match PipeSecurityAttributes::new() {
|
||||
Ok(attrs) => attrs,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "cannot build pipe security attributes for next instance");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES.
|
||||
match unsafe {
|
||||
ServerOptions::new()
|
||||
.reject_remote_clients(true)
|
||||
.create_with_security_attributes_raw(name, sec_attrs.as_raw_mut())
|
||||
} {
|
||||
Ok(server) => Some(server),
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "cannot create the next control pipe instance");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects a client to the pipe for `path`, waiting out a busy pipe.
|
||||
///
|
||||
/// The wait is bounded by the caller's exchange timeout, so a pipe that is
|
||||
/// busy forever is given up on rather than spun on.
|
||||
pub(crate) async fn connect(path: &Path) -> Result<NamedPipeClient> {
|
||||
let name = pipe_name(path);
|
||||
loop {
|
||||
match ClientOptions::new().open(&name) {
|
||||
Ok(client) => return Ok(client),
|
||||
Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY) => {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
return Err(Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source: std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"the agent's control pipe exists, but Windows denied access. \
|
||||
Run this command as the same Windows user and with the same \
|
||||
elevation as `tsunagi up` (use an administrator terminal if \
|
||||
the agent is elevated). Windows error: {err}"
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
Err(source) => {
|
||||
return Err(Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an agent is serving the pipe for `path`.
|
||||
///
|
||||
/// A busy or inaccessible pipe still means a server is there. In particular,
|
||||
/// access denied must not send a mutating command down the offline path,
|
||||
/// where it would only produce a misleading state-directory lock error.
|
||||
pub(crate) async fn probe(path: &Path) -> bool {
|
||||
let name = pipe_name(path);
|
||||
match ClientOptions::new().open(&name) {
|
||||
Ok(_) => true,
|
||||
Err(err) => {
|
||||
err.raw_os_error() == Some(ERROR_PIPE_BUSY)
|
||||
|| err.kind() == std::io::ErrorKind::PermissionDenied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pipe name for a control-socket path.
|
||||
///
|
||||
/// A hash rather than the path itself, because a pipe name may not contain the
|
||||
/// separators and drive letters a path does, and because two agents with
|
||||
/// different state directories must never collide.
|
||||
fn pipe_name(path: &Path) -> String {
|
||||
let digest = Sha256::digest(path.as_os_str().as_encoded_bytes());
|
||||
format!(r"\\.\pipe\tsunagi-{}", hex::encode(&digest[..16]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::super::{Request, Response, StatusReport, exchange};
|
||||
use super::*;
|
||||
use crate::BoxFuture;
|
||||
|
||||
#[test]
|
||||
fn one_path_maps_to_one_name_and_two_paths_do_not_collide() {
|
||||
let a = pipe_name(Path::new(r"C:\a\agent.sock"));
|
||||
let b = pipe_name(Path::new(r"C:\b\agent.sock"));
|
||||
assert!(a.starts_with(r"\\.\pipe\tsunagi-"), "{a}");
|
||||
assert_eq!(a, pipe_name(Path::new(r"C:\a\agent.sock")));
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_request_makes_the_round_trip_over_a_real_pipe() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("agent.sock");
|
||||
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
||||
Box::pin(async { StatusReport::default() })
|
||||
});
|
||||
|
||||
assert!(!is_serving(&path).await, "nothing serves the pipe yet");
|
||||
let control = ControlSocket::bind(&path, source).await.unwrap();
|
||||
assert!(is_serving(&path).await, "the agent serves it now");
|
||||
|
||||
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(answer, Response::Status(_)));
|
||||
|
||||
control.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_inaccessible_agent_is_not_mistaken_for_an_absent_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("agent.sock");
|
||||
// An outbound-only pipe denies the client's duplex open even when
|
||||
// both processes have the same privileges. This exercises the same
|
||||
// Windows error as a client unable to access an elevated agent,
|
||||
// without requiring elevation or changing any ACLs.
|
||||
let _server = ServerOptions::new()
|
||||
.first_pipe_instance(true)
|
||||
.access_inbound(false)
|
||||
.create(pipe_name(&path))
|
||||
.unwrap();
|
||||
|
||||
assert!(is_serving(&path).await, "access denied is not absence");
|
||||
for error in [
|
||||
request_status(&path).await.unwrap_err(),
|
||||
join_network(&path, "test", "local-ipc-test-secret")
|
||||
.await
|
||||
.unwrap_err(),
|
||||
set_dns(&path, false, None).await.unwrap_err(),
|
||||
] {
|
||||
let Error::Io { source, .. } = error else {
|
||||
panic!("expected an access error, got {error}");
|
||||
};
|
||||
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
let message = source.to_string();
|
||||
assert!(message.contains("same Windows user"), "{message}");
|
||||
assert!(message.contains("administrator"), "{message}");
|
||||
assert!(!message.contains("local-ipc-test-secret"), "{message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_silent_agent_is_reported_rather_than_waited_out() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("agent.sock");
|
||||
// A server instance that accepts and then never answers, like a
|
||||
// wedged agent. Unbounded, the exchange below would never return.
|
||||
let name = pipe_name(&path);
|
||||
let server = ServerOptions::new()
|
||||
.first_pipe_instance(true)
|
||||
.create(&name)
|
||||
.unwrap();
|
||||
let _accept = tokio::spawn(async move {
|
||||
let _ = server.connect().await;
|
||||
// Hold the connection open, answering nothing.
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
});
|
||||
|
||||
let error = exchange(&path, &Request::Status, Duration::from_millis(200))
|
||||
.await
|
||||
.expect_err("a silent agent cannot be reported as healthy");
|
||||
assert!(matches!(error, Error::Timeout { .. }), "{error:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_second_agent_on_the_same_pipe_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("agent.sock");
|
||||
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
||||
Box::pin(async { StatusReport::default() })
|
||||
});
|
||||
|
||||
let first = ControlSocket::bind(&path, Arc::clone(&source))
|
||||
.await
|
||||
.unwrap();
|
||||
let second = ControlSocket::bind(&path, source).await;
|
||||
assert!(
|
||||
matches!(second, Err(Error::StateLocked { .. })),
|
||||
"{second:?}"
|
||||
);
|
||||
|
||||
first.shutdown().await;
|
||||
}
|
||||
}
|
||||
@@ -39,13 +39,18 @@ pub mod agent;
|
||||
pub mod config;
|
||||
pub mod dataplane;
|
||||
pub mod discovery;
|
||||
pub mod dns;
|
||||
pub mod error;
|
||||
pub mod identity;
|
||||
pub mod ipc;
|
||||
pub mod net;
|
||||
pub mod overlay;
|
||||
pub mod proto;
|
||||
pub mod state;
|
||||
pub mod storage;
|
||||
pub mod task;
|
||||
#[cfg(feature = "testing")]
|
||||
pub mod testing;
|
||||
|
||||
pub use agent::{Agent, AgentStatus, Event, NetworkStatus, PeerStatus};
|
||||
pub use config::{AgentConfig, Limits, ReconnectPolicy, StoragePaths, TransportPolicy};
|
||||
@@ -240,6 +240,62 @@ impl EndpointAdapter {
|
||||
TransportPolicy::N0Defaults => builder.preset(presets::N0),
|
||||
};
|
||||
|
||||
if matches!(
|
||||
config.transport,
|
||||
TransportPolicy::N0Defaults | TransportPolicy::DirectOnly
|
||||
) {
|
||||
let filter = iroh::address_lookup::AddrFilter::new(|addrs| {
|
||||
let mut overlay_ips = std::collections::HashSet::new();
|
||||
for iface in netdev::get_interfaces() {
|
||||
let name = iface.name.to_lowercase();
|
||||
let friendly = iface
|
||||
.friendly_name
|
||||
.as_deref()
|
||||
.map(str::to_lowercase)
|
||||
.unwrap_or_default();
|
||||
let desc = iface
|
||||
.description
|
||||
.as_deref()
|
||||
.map(str::to_lowercase)
|
||||
.unwrap_or_default();
|
||||
if name.starts_with("tsun")
|
||||
|| name.contains("tsunagi")
|
||||
|| name.contains("wintun")
|
||||
|| friendly.starts_with("tsun")
|
||||
|| friendly.contains("tsunagi")
|
||||
|| friendly.contains("wintun")
|
||||
|| desc.contains("wintun")
|
||||
|| desc.contains("tsunagi")
|
||||
{
|
||||
for ip in iface.ipv4 {
|
||||
overlay_ips.insert(std::net::IpAddr::V4(ip.addr()));
|
||||
}
|
||||
for ip in iface.ipv6 {
|
||||
overlay_ips.insert(std::net::IpAddr::V6(ip.addr()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if overlay_ips.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(addrs);
|
||||
}
|
||||
let filtered: Vec<iroh::TransportAddr> = addrs
|
||||
.iter()
|
||||
.filter(|addr| match addr {
|
||||
iroh::TransportAddr::Ip(sock) => !overlay_ips.contains(&sock.ip()),
|
||||
_ => true,
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
std::borrow::Cow::Owned(filtered)
|
||||
});
|
||||
builder = builder.addr_filter(filter);
|
||||
}
|
||||
|
||||
#[cfg(feature = "testing")]
|
||||
if let Some(transport) = &config.test_quic_transport {
|
||||
builder = builder.transport_config(transport.clone());
|
||||
}
|
||||
|
||||
if !config.bind_addrs.is_empty() {
|
||||
builder = builder.clear_ip_transports();
|
||||
for addr in &config.bind_addrs {
|
||||
@@ -254,6 +310,91 @@ impl EndpointAdapter {
|
||||
.await
|
||||
.map_err(|err| Error::Endpoint(format!("cannot bind endpoint: {err}")))?;
|
||||
|
||||
tracing::debug!(
|
||||
endpoint = %endpoint.id().fmt_short(),
|
||||
bound_sockets = ?endpoint.bound_sockets(),
|
||||
"iroh endpoint bound locally"
|
||||
);
|
||||
|
||||
let ep_id = endpoint.id();
|
||||
let mut addr_watcher = endpoint.watch_addr();
|
||||
let mut relay_watcher = endpoint.home_relay_status();
|
||||
let close_token = endpoint.clone();
|
||||
tokio::spawn(async move {
|
||||
use iroh::Watcher;
|
||||
let mut last_addrs: Vec<SocketAddr> = Vec::new();
|
||||
let mut last_relays: Vec<String> = Vec::new();
|
||||
|
||||
let warn_timer = tokio::time::sleep(Duration::from_secs(12));
|
||||
tokio::pin!(warn_timer);
|
||||
let mut warned = false;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = close_token.closed() => {
|
||||
break;
|
||||
}
|
||||
() = &mut warn_timer, if !warned => {
|
||||
warned = true;
|
||||
if last_addrs.is_empty() && !last_relays.is_empty() {
|
||||
tracing::warn!(
|
||||
endpoint = %ep_id.fmt_short(),
|
||||
relays = ?last_relays,
|
||||
"No direct reflexive or UPnP addresses discovered after probe; communications will rely on relay servers"
|
||||
);
|
||||
} else if last_addrs.is_empty() && last_relays.is_empty() {
|
||||
tracing::warn!(
|
||||
endpoint = %ep_id.fmt_short(),
|
||||
"No reachability discovered (neither direct reflexive/UPnP addresses nor relay servers available)"
|
||||
);
|
||||
}
|
||||
}
|
||||
res = addr_watcher.updated() => {
|
||||
let addr = match res {
|
||||
Ok(a) => a,
|
||||
Err(_) => break,
|
||||
};
|
||||
let addrs: Vec<SocketAddr> = addr.ip_addrs().copied().collect();
|
||||
let relays: Vec<String> = addr.relay_urls().map(|u| u.to_string()).collect();
|
||||
|
||||
if addrs != last_addrs || relays != last_relays {
|
||||
tracing::debug!(
|
||||
endpoint = %ep_id.fmt_short(),
|
||||
direct_addrs = ?addrs,
|
||||
relays = ?relays,
|
||||
"Discovered endpoint reachability (STUN/UPnP/local/relay)"
|
||||
);
|
||||
last_addrs = addrs;
|
||||
last_relays = relays;
|
||||
}
|
||||
}
|
||||
res = relay_watcher.updated() => {
|
||||
let relays = match res {
|
||||
Ok(r) => r,
|
||||
Err(_) => break,
|
||||
};
|
||||
for relay in &relays {
|
||||
if relay.is_connected() {
|
||||
tracing::debug!(
|
||||
endpoint = %ep_id.fmt_short(),
|
||||
relay = %relay.url(),
|
||||
"Connected to home relay server"
|
||||
);
|
||||
} else if let Some(err) = relay.last_error() {
|
||||
tracing::debug!(
|
||||
endpoint = %ep_id.fmt_short(),
|
||||
relay = %relay.url(),
|
||||
error = %err,
|
||||
"Relay connection attempt failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self { endpoint })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,11 @@
|
||||
|
||||
use std::net::{IpAddr, Ipv6Addr};
|
||||
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::identity::NetworkId;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::overlay::OVERLAY_HOST_PREFIX_LEN;
|
||||
/// Prefix length of a single IPv6 host address.
|
||||
const HOST_PREFIX_LEN: u8 = 128;
|
||||
|
||||
/// Longest interface name Linux accepts, excluding the terminating NUL.
|
||||
pub const MAX_INTERFACE_NAME_LEN: usize = 15;
|
||||
@@ -33,13 +34,13 @@ pub struct Cidr {
|
||||
|
||||
impl Cidr {
|
||||
/// Builds a CIDR, rejecting an impossible prefix length.
|
||||
pub fn new(addr: IpAddr, prefix_len: u8) -> Result<Self, PluginError> {
|
||||
pub fn new(addr: IpAddr, prefix_len: u8) -> Result<Self, OverlayError> {
|
||||
let max = match addr {
|
||||
IpAddr::V4(_) => 32,
|
||||
IpAddr::V6(_) => 128,
|
||||
};
|
||||
if prefix_len > max {
|
||||
return Err(PluginError::Other(format!(
|
||||
return Err(OverlayError::Other(format!(
|
||||
"prefix length /{prefix_len} is impossible for {addr}"
|
||||
)));
|
||||
}
|
||||
@@ -50,7 +51,7 @@ impl Cidr {
|
||||
pub fn host(addr: Ipv6Addr) -> Self {
|
||||
Self {
|
||||
addr: IpAddr::V6(addr),
|
||||
prefix_len: OVERLAY_HOST_PREFIX_LEN,
|
||||
prefix_len: HOST_PREFIX_LEN,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,9 +67,9 @@ impl std::fmt::Display for Cidr {
|
||||
/// The name is stable across restarts and short enough for the platform. Two
|
||||
/// agents on the same host in the same network must be given different
|
||||
/// prefixes, or they would derive the same name.
|
||||
pub fn interface_name(prefix: &str, network: NetworkId) -> Result<String, PluginError> {
|
||||
pub fn interface_name(prefix: &str, network: NetworkId) -> Result<String, OverlayError> {
|
||||
if prefix.is_empty() {
|
||||
return Err(PluginError::Other(
|
||||
return Err(OverlayError::Other(
|
||||
"interface prefix must not be empty".into(),
|
||||
));
|
||||
}
|
||||
@@ -76,12 +77,12 @@ pub fn interface_name(prefix: &str, network: NetworkId) -> Result<String, Plugin
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
{
|
||||
return Err(PluginError::Other(
|
||||
return Err(OverlayError::Other(
|
||||
"interface prefix must be lowercase ASCII letters and digits".into(),
|
||||
));
|
||||
}
|
||||
if prefix.len() >= MAX_INTERFACE_NAME_LEN {
|
||||
return Err(PluginError::Other(format!(
|
||||
return Err(OverlayError::Other(format!(
|
||||
"interface prefix must be shorter than {MAX_INTERFACE_NAME_LEN} characters"
|
||||
)));
|
||||
}
|
||||
@@ -0,0 +1,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:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
//! The one interface an agent owns, and the loop that moves packets across it.
|
||||
//!
|
||||
//! Outbound, a packet read from the interface is looked up in the
|
||||
//! [`RoutingTable`] and handed to whatever can carry it to that peer.
|
||||
//! Inbound, a packet a protocol has decrypted is checked against the same
|
||||
//! table and written to the interface.
|
||||
//!
|
||||
//! Neither direction knows which protocol is involved, and that is the
|
||||
//! point: the interface, the addresses on it and the decision of whose
|
||||
//! packet this is all belong here, so several protocols can be carrying
|
||||
//! traffic at once without any of them owning the thing they are carrying it
|
||||
//! for.
|
||||
//!
|
||||
//! # What is counted rather than hidden
|
||||
//!
|
||||
//! A packet with nowhere to go, a packet from a peer that does not hold its
|
||||
//! source address, a multicast packet the operating system emitted anyway:
|
||||
//! each is dropped and counted, with a sample kept for the first kind,
|
||||
//! because "the overlay is quiet" and "the overlay is dropping everything"
|
||||
//! look identical without them.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
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};
|
||||
use super::{Cidr, OverlayError};
|
||||
|
||||
/// Carries one packet to a peer, by whatever protocol currently can.
|
||||
///
|
||||
/// Implemented by the layer that knows which protocols are live for which
|
||||
/// peers. The interface does not: it asks for a packet to reach a peer and
|
||||
/// is told whether that was possible.
|
||||
pub trait PacketCarrier: Send + Sync + 'static {
|
||||
/// Carries a packet, answering `false` if nothing can right now.
|
||||
fn carry(&self, route: Route, packet: Bytes) -> bool;
|
||||
}
|
||||
|
||||
/// Why a decrypted packet was not written to the interface.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Rejected {
|
||||
/// It was not a packet this agent could read.
|
||||
Malformed,
|
||||
/// The overlay is IPv4; anything else has no owner here.
|
||||
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.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
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.
|
||||
pub unroutable: u64,
|
||||
/// One such destination, so the number can be acted on.
|
||||
pub unroutable_sample: Option<IpAddr>,
|
||||
/// Packets nothing could carry, though their destination was known.
|
||||
pub undeliverable: u64,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
#[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>>,
|
||||
}
|
||||
|
||||
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() {
|
||||
Ok(guard) => *guard,
|
||||
Err(poisoned) => *poisoned.into_inner(),
|
||||
},
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
fn note_unroutable(&self, destination: IpAddr) {
|
||||
self.unroutable.fetch_add(1, Ordering::Relaxed);
|
||||
// The first one is kept. A later sample would keep overwriting the
|
||||
// one a reader is looking at.
|
||||
let mut guard = match self.sample.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
if guard.is_none() {
|
||||
*guard = Some(destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The overlay interface.
|
||||
#[derive(Debug)]
|
||||
pub struct Interface {
|
||||
device: Arc<dyn TunDevice>,
|
||||
factory: Arc<dyn TunFactory>,
|
||||
name: String,
|
||||
mtu: u32,
|
||||
routes: Arc<RoutingTable>,
|
||||
tally: Arc<Tally>,
|
||||
/// What was last applied to the host, so an unchanged table is not
|
||||
/// 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 {
|
||||
/// Creates the interface and starts moving packets.
|
||||
pub async fn start(
|
||||
factory: Arc<dyn TunFactory>,
|
||||
name: impl Into<String>,
|
||||
mtu: u32,
|
||||
routes: Arc<RoutingTable>,
|
||||
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 = {
|
||||
let device = Arc::clone(&device);
|
||||
let routes = Arc::clone(&routes);
|
||||
let tally = Arc::clone(&tally);
|
||||
tokio::spawn(async move {
|
||||
while let Some(packet) = device.recv().await {
|
||||
let Some(header) = IpHeader::parse(&packet) else {
|
||||
tally.malformed.fetch_add(1, Ordering::Relaxed);
|
||||
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
|
||||
// has nowhere to put them; expected, not a fault.
|
||||
tally.multicast.fetch_add(1, Ordering::Relaxed);
|
||||
continue;
|
||||
}
|
||||
let Some(route) = routes.route_v4(destination) else {
|
||||
tally.note_unroutable(destination);
|
||||
continue;
|
||||
};
|
||||
if carrier.carry(route, packet) {
|
||||
tally.sent.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
tally.undeliverable.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
device,
|
||||
factory,
|
||||
name,
|
||||
mtu,
|
||||
routes,
|
||||
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() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
};
|
||||
if applied == wanted {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Every address this agent holds, in every network it is in. The
|
||||
// plan is exhaustive by contract, so this is also what takes an
|
||||
// address off the interface when a network is left or stopped.
|
||||
let request = TunRequest {
|
||||
name: self.name.clone(),
|
||||
addresses: wanted.clone(),
|
||||
mtu: self.mtu,
|
||||
};
|
||||
self.factory.reconfigure(request).await?;
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = wanted,
|
||||
Err(poisoned) => *poisoned.into_inner() = wanted,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The interface name the operating system gave.
|
||||
pub fn name(&self) -> &str {
|
||||
self.device.name()
|
||||
}
|
||||
|
||||
/// The interface MTU.
|
||||
pub fn mtu(&self) -> u32 {
|
||||
self.device.mtu()
|
||||
}
|
||||
|
||||
/// Whether this interface exists on the host.
|
||||
///
|
||||
/// `false` with an in-memory device, where tunnels run and packets move
|
||||
/// but the operating system knows nothing about any of it. Anything that
|
||||
/// would configure the host — a route, a resolver setting, a complaint
|
||||
/// that an address is missing from a link — has to ask first.
|
||||
pub fn on_host(&self) -> bool {
|
||||
self.factory.on_host()
|
||||
}
|
||||
|
||||
/// 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. Stop it directly before destroying the interface.
|
||||
let task = match self.task.lock() {
|
||||
Ok(mut guard) => guard.take(),
|
||||
Err(poisoned) => poisoned.into_inner().take(),
|
||||
};
|
||||
if let Some(task) = task {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
self.factory.destroy(self.device.name()).await;
|
||||
}
|
||||
|
||||
/// The counters as they stand.
|
||||
pub fn counters(&self) -> Counters {
|
||||
self.tally.snapshot()
|
||||
}
|
||||
|
||||
/// Writes a packet a protocol decrypted to the operating system.
|
||||
///
|
||||
/// The source is checked against what the network agreed that peer
|
||||
/// holds. A protocol proved *who* sent the packet; only this level knows
|
||||
/// what that member is entitled to say.
|
||||
pub async fn deliver(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
packet: Bytes,
|
||||
) -> Result<(), Rejected> {
|
||||
let Some(header) = IpHeader::parse(&packet) else {
|
||||
self.tally.malformed.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(Rejected::Malformed);
|
||||
};
|
||||
let IpAddr::V4(source) = header.source() else {
|
||||
self.tally.wrong_source.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(Rejected::WrongFamily);
|
||||
};
|
||||
if !self.routes.may_send_from(network, peer, source) {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// The addresses the interface should be carrying, from the table.
|
||||
pub fn wanted_addresses(&self) -> Vec<Cidr> {
|
||||
self.routes
|
||||
.local_addresses()
|
||||
.into_iter()
|
||||
.filter_map(|(address, prefix_len)| Cidr::new(address.into(), prefix_len).ok())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Interface {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut guard) = self.task.lock()
|
||||
&& let Some(task) = guard.take()
|
||||
{
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RoutingTable {
|
||||
/// The route for a destination, when it is one the overlay carries.
|
||||
fn route_v4(&self, destination: IpAddr) -> Option<Route> {
|
||||
match destination {
|
||||
IpAddr::V4(address) => self.route(address),
|
||||
// The overlay is IPv4. An IPv6 packet has no owner here, and
|
||||
// counting it as unroutable is the truth rather than a fault.
|
||||
IpAddr::V6(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an address is one the overlay has nowhere to send.
|
||||
fn is_multicast_or_broadcast(address: IpAddr) -> bool {
|
||||
match address {
|
||||
IpAddr::V4(address) => {
|
||||
address.is_multicast() || address.is_broadcast() || address == Ipv4Addr::UNSPECIFIED
|
||||
}
|
||||
IpAddr::V6(address) => address.is_multicast(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_in_memory_interface_says_it_is_not_on_the_host() {
|
||||
// Everything that would configure the operating system for this
|
||||
// interface asks first, and the answer here is no: the name is real
|
||||
// to the agent and to nothing else. A real `tsun0` belonging to
|
||||
// another agent looks identical from here, so acting on the name
|
||||
// alone would configure that one.
|
||||
let interface = Interface::start(
|
||||
Arc::new(crate::overlay::MemoryTunFactory::new()),
|
||||
"tsun0",
|
||||
1280,
|
||||
Arc::new(RoutingTable::new()),
|
||||
Arc::new(Recorder::default()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(interface.name(), "tsun0");
|
||||
assert!(!interface.on_host());
|
||||
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};
|
||||
|
||||
fn network(name: &str) -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new(name).unwrap(),
|
||||
&NetworkSecret::from_bytes(vec![7u8; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
|
||||
fn peer(seed: u8) -> EndpointId {
|
||||
iroh::SecretKey::from_bytes(&[seed; 32]).public()
|
||||
}
|
||||
|
||||
fn addr(last: u8) -> Ipv4Addr {
|
||||
Ipv4Addr::new(10, 13, 37, last)
|
||||
}
|
||||
|
||||
fn ipv4(source: Ipv4Addr, destination: Ipv4Addr, payload: &[u8]) -> Bytes {
|
||||
let mut packet = vec![0u8; 20];
|
||||
packet[0] = 0x45;
|
||||
let total = (20 + payload.len()) as u16;
|
||||
packet[2..4].copy_from_slice(&total.to_be_bytes());
|
||||
packet[9] = 17;
|
||||
packet[12..16].copy_from_slice(&source.octets());
|
||||
packet[16..20].copy_from_slice(&destination.octets());
|
||||
packet.extend_from_slice(payload);
|
||||
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 {
|
||||
carried: std::sync::Mutex<Vec<(Route, Bytes)>>,
|
||||
refuse: bool,
|
||||
}
|
||||
|
||||
impl PacketCarrier for Recorder {
|
||||
fn carry(&self, route: Route, packet: Bytes) -> bool {
|
||||
if self.refuse {
|
||||
return false;
|
||||
}
|
||||
match self.carried.lock() {
|
||||
Ok(mut guard) => guard.push((route, packet)),
|
||||
Err(poisoned) => poisoned.into_inner().push((route, packet)),
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
fn carried(&self) -> Vec<(Route, Bytes)> {
|
||||
match self.carried.lock() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn interface(
|
||||
refuse: bool,
|
||||
) -> (
|
||||
Interface,
|
||||
Arc<MemoryTun>,
|
||||
Arc<RoutingTable>,
|
||||
Arc<Recorder>,
|
||||
NetworkId,
|
||||
) {
|
||||
let id = network("interface");
|
||||
let routes = Arc::new(RoutingTable::new());
|
||||
routes
|
||||
.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))],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let carrier = Arc::new(Recorder {
|
||||
carried: std::sync::Mutex::new(Vec::new()),
|
||||
refuse,
|
||||
});
|
||||
let factory = Arc::new(MemoryTunFactory::new());
|
||||
let interface = Interface::start(
|
||||
Arc::clone(&factory) as Arc<dyn TunFactory>,
|
||||
"tsuntest",
|
||||
1280,
|
||||
Arc::clone(&routes),
|
||||
Arc::clone(&carrier) as Arc<dyn PacketCarrier>,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// The factory keeps what it made, which is how a test reaches the
|
||||
// device the interface is using without exposing it.
|
||||
let device = factory.device("tsuntest").expect("the device was created");
|
||||
(interface, device, routes, carrier, id)
|
||||
}
|
||||
|
||||
/// Waits for a counter to move, so the interface's own task has run.
|
||||
async fn wait_for(interface: &Interface, pick: impl Fn(&Counters) -> u64) {
|
||||
for _ in 0..400 {
|
||||
if pick(&interface.counters()) > 0 {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
panic!("the counter never moved: {:?}", interface.counters());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_packet_leaving_the_interface_goes_to_the_peer_that_owns_it() {
|
||||
let (interface, device, _routes, carrier, id) = interface(false).await;
|
||||
device.push_from_os(ipv4(addr(1), addr(2), b"hello"));
|
||||
|
||||
wait_for(&interface, |counters| counters.sent).await;
|
||||
let carried = carrier.carried();
|
||||
assert_eq!(carried.len(), 1);
|
||||
assert_eq!(carried[0].0.peer, peer(2));
|
||||
assert_eq!(carried[0].0.network, id);
|
||||
assert_eq!(&carried[0].1[20..], b"hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_packet_for_nobody_is_counted_with_a_sample_and_not_sent() {
|
||||
// Counted rather than dropped quietly: without the number and an
|
||||
// example of it, "the overlay is quiet" and "the overlay is dropping
|
||||
// everything" look the same.
|
||||
let (interface, device, _routes, carrier, _id) = interface(false).await;
|
||||
let nowhere = Ipv4Addr::new(10, 13, 37, 200);
|
||||
device.push_from_os(ipv4(addr(1), nowhere, b"lost"));
|
||||
|
||||
wait_for(&interface, |counters| counters.unroutable).await;
|
||||
assert_eq!(
|
||||
interface.counters().unroutable_sample,
|
||||
Some(IpAddr::V4(nowhere))
|
||||
);
|
||||
assert!(carrier.carried().is_empty(), "nothing was flooded");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multicast_is_expected_and_never_carried() {
|
||||
let (interface, device, _routes, carrier, _id) = interface(false).await;
|
||||
device.push_from_os(ipv4(addr(1), Ipv4Addr::new(224, 0, 0, 251), b"mdns"));
|
||||
|
||||
wait_for(&interface, |counters| counters.multicast).await;
|
||||
assert_eq!(interface.counters().unroutable, 0, "not a routing failure");
|
||||
assert!(carrier.carried().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_packet_nothing_can_carry_is_counted_separately() {
|
||||
// Distinct from unroutable: the destination is known, there is just
|
||||
// no live protocol for it. The two want different answers.
|
||||
let (interface, device, _routes, _carrier, _id) = interface(true).await;
|
||||
device.push_from_os(ipv4(addr(1), addr(2), b"no link"));
|
||||
|
||||
wait_for(&interface, |counters| counters.undeliverable).await;
|
||||
let counters = interface.counters();
|
||||
assert_eq!(counters.sent, 0);
|
||||
assert_eq!(counters.unroutable, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_decrypted_packet_from_its_owner_reaches_the_operating_system() {
|
||||
let (interface, device, _routes, _carrier, id) = interface(false).await;
|
||||
interface
|
||||
.deliver(id, peer(2), ipv4(addr(2), addr(1), b"inbound"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let written = tokio::time::timeout(std::time::Duration::from_secs(5), device.pop_to_os())
|
||||
.await
|
||||
.expect("the packet reaches the interface")
|
||||
.unwrap();
|
||||
assert_eq!(&written[20..], b"inbound");
|
||||
assert_eq!(interface.counters().received, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_peer_cannot_send_from_an_address_it_does_not_hold() {
|
||||
let (interface, _device, _routes, _carrier, id) = interface(false).await;
|
||||
|
||||
// Another member's address.
|
||||
assert_eq!(
|
||||
interface
|
||||
.deliver(id, peer(2), ipv4(addr(3), addr(1), b"spoofed"))
|
||||
.await,
|
||||
Err(Rejected::WrongSource)
|
||||
);
|
||||
// And this agent's own.
|
||||
assert_eq!(
|
||||
interface
|
||||
.deliver(id, peer(2), ipv4(addr(1), addr(1), b"spoofed"))
|
||||
.await,
|
||||
Err(Rejected::WrongSource)
|
||||
);
|
||||
assert_eq!(interface.counters().wrong_source, 2);
|
||||
assert_eq!(interface.counters().received, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unreadable_or_wrong_family_packet_is_refused_without_panicking() {
|
||||
let (interface, _device, _routes, _carrier, id) = interface(false).await;
|
||||
|
||||
assert_eq!(
|
||||
interface.deliver(id, peer(2), Bytes::new()).await,
|
||||
Err(Rejected::Malformed)
|
||||
);
|
||||
assert_eq!(
|
||||
interface
|
||||
.deliver(id, peer(2), Bytes::from_static(&[0xff; 8]))
|
||||
.await,
|
||||
Err(Rejected::Malformed)
|
||||
);
|
||||
|
||||
// An IPv6 packet: readable, but the overlay has no owner for it.
|
||||
let mut six = vec![0u8; 40];
|
||||
six[0] = 0x60;
|
||||
assert_eq!(
|
||||
interface.deliver(id, peer(2), Bytes::from(six)).await,
|
||||
Err(Rejected::WrongFamily)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_wanted_addresses_follow_the_table() {
|
||||
let (interface, _device, routes, _carrier, id) = interface(false).await;
|
||||
assert_eq!(
|
||||
interface.wanted_addresses(),
|
||||
vec![Cidr::new(addr(1).into(), 24).unwrap()]
|
||||
);
|
||||
|
||||
// The network agreed a different address for us.
|
||||
routes
|
||||
.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))],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
interface.wanted_addresses(),
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! The overlay interface: the one network interface an agent owns.
|
||||
//!
|
||||
//! One agent, one interface. It belongs to the system level rather than to
|
||||
//! any protocol, and that is the whole point: a packet leaving it is routed
|
||||
//! to whichever peer owns its destination address, over whichever protocol
|
||||
//! currently has a link to that peer. Several protocols can be live at once
|
||||
//! without arguing over who holds the address, because neither of them
|
||||
//! holds it — the agent does.
|
||||
//!
|
||||
//! What lives here:
|
||||
//!
|
||||
//! * [`provision`] creates the interface and configures it, and removes it
|
||||
//! again. Platform mechanics behind one decision function.
|
||||
//! * [`hostrules`] installs the route and firewall allowance that let a
|
||||
//! game's LAN discovery reach the interface, while broadcast is on.
|
||||
//! * [`tun`] is the packet interface itself, real or in memory.
|
||||
//! * [`packet`] reads just enough of an IP header to route by it.
|
||||
//! * [`config`] names interfaces and describes addresses.
|
||||
//!
|
||||
//! What does not live here: encryption, peer discovery, and how a packet
|
||||
//! actually reaches another machine. Those belong to a protocol plugin,
|
||||
//! which this level hands packets to and takes packets from.
|
||||
|
||||
/// Why the overlay interface cannot be brought into the state asked for.
|
||||
///
|
||||
/// Its own error rather than the plugin one it used to borrow: this level
|
||||
/// owns the interface now, and a plugin failing is a different event from
|
||||
/// the interface failing.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum OverlayError {
|
||||
/// The interface cannot be created or configured right now, with the
|
||||
/// reason and, where there is one, what to do about it.
|
||||
#[error("overlay unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
/// Anything else.
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
pub mod broadcast;
|
||||
pub mod config;
|
||||
pub mod hostrules;
|
||||
pub mod interface;
|
||||
pub mod packet;
|
||||
pub mod provision;
|
||||
pub mod router;
|
||||
pub mod tun;
|
||||
|
||||
pub use config::{Cidr, DEFAULT_INTERFACE_PREFIX, MAX_INTERFACE_NAME_LEN, interface_name};
|
||||
pub use hostrules::{
|
||||
BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, MockHostRules, RuleOutcome,
|
||||
UnsupportedHostRules,
|
||||
};
|
||||
pub use interface::{Counters, Interface, PacketCarrier, Rejected};
|
||||
pub use packet::IpHeader;
|
||||
pub use provision::{
|
||||
Changes, InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, ManagedTunFactory,
|
||||
MockHost, MockProvisioner, Privilege, Provisioned, UnsupportedProvisioner, plan_changes,
|
||||
probe_net_admin,
|
||||
};
|
||||
pub use router::{NetworkRoutes, Route, RouteError, RoutingTable};
|
||||
pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest, address_is_local};
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use hostrules::LinuxHostRules;
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use hostrules::MacosHostRules;
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use hostrules::WindowsHostRules;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use provision::NetlinkProvisioner;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use provision::UtunProvisioner;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use provision::WintunProvisioner;
|
||||
File renamed without changes.
+88
-18
@@ -9,22 +9,21 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::super::config::Cidr;
|
||||
use super::super::hostrules::BroadcastHostRules;
|
||||
use super::super::tun::{TunDevice, TunFactory, TunRequest};
|
||||
use super::{InterfacePlan, InterfaceProvisioner};
|
||||
|
||||
/// Turns a [`TunRequest`] into the plan for a host interface.
|
||||
fn plan_for(request: &TunRequest) -> Result<InterfacePlan, PluginError> {
|
||||
let mut addresses = vec![Cidr::new(request.address.into(), request.prefix_len)?];
|
||||
if let Some(address) = request.address_v4 {
|
||||
addresses.push(Cidr::new(address.into(), request.prefix_len_v4)?);
|
||||
}
|
||||
fn plan_for(request: &TunRequest) -> Result<InterfacePlan, OverlayError> {
|
||||
// Every address, because the plan is exhaustive: the provisioner adds
|
||||
// what is missing and removes what is not in it. Passing one network's
|
||||
// address would take every other network's off the host.
|
||||
Ok(InterfacePlan::new(
|
||||
request.name.clone(),
|
||||
request.mtu,
|
||||
addresses,
|
||||
request.addresses.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -32,12 +31,23 @@ fn plan_for(request: &TunRequest) -> Result<InterfacePlan, PluginError> {
|
||||
#[derive(Debug)]
|
||||
pub struct ManagedTunFactory {
|
||||
provisioner: Arc<dyn InterfaceProvisioner>,
|
||||
host_rules: Option<Arc<dyn BroadcastHostRules>>,
|
||||
}
|
||||
|
||||
impl ManagedTunFactory {
|
||||
/// Wraps a provisioner.
|
||||
pub fn new(provisioner: Arc<dyn InterfaceProvisioner>) -> Self {
|
||||
Self { provisioner }
|
||||
Self {
|
||||
provisioner,
|
||||
host_rules: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets the agent install the broadcast route and firewall allowance for
|
||||
/// the interface this factory manages.
|
||||
pub fn with_host_rules(mut self, rules: Arc<dyn BroadcastHostRules>) -> Self {
|
||||
self.host_rules = Some(rules);
|
||||
self
|
||||
}
|
||||
|
||||
/// The provisioner underneath.
|
||||
@@ -51,10 +61,19 @@ impl TunFactory for ManagedTunFactory {
|
||||
self.provisioner.name()
|
||||
}
|
||||
|
||||
/// It creates the interface on the host and holds it open.
|
||||
fn on_host(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn host_rules(&self) -> Option<Arc<dyn BroadcastHostRules>> {
|
||||
self.host_rules.clone()
|
||||
}
|
||||
|
||||
fn create<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, PluginError>> {
|
||||
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>> {
|
||||
Box::pin(async move {
|
||||
let plan = plan_for(&request)?;
|
||||
let provisioned = self.provisioner.reconcile(&plan).await?;
|
||||
@@ -67,7 +86,7 @@ impl TunFactory for ManagedTunFactory {
|
||||
// Reaching here would mean the interface already existed and
|
||||
// was held open by us, which cannot be true on the path that
|
||||
// creates a device.
|
||||
PluginError::Other(format!(
|
||||
OverlayError::Other(format!(
|
||||
"interface `{}` was reconciled but no device came back",
|
||||
plan.name
|
||||
))
|
||||
@@ -75,7 +94,7 @@ impl TunFactory for ManagedTunFactory {
|
||||
})
|
||||
}
|
||||
|
||||
fn reconfigure<'a>(&'a self, request: TunRequest) -> BoxFuture<'a, Result<(), PluginError>> {
|
||||
fn reconfigure<'a>(&'a self, request: TunRequest) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move {
|
||||
let plan = plan_for(&request)?;
|
||||
let provisioned = self.provisioner.reconcile(&plan).await?;
|
||||
@@ -106,18 +125,23 @@ impl TunFactory for ManagedTunFactory {
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use super::super::super::config::Cidr;
|
||||
use super::super::{LinkKind, MockHost, MockProvisioner};
|
||||
use super::*;
|
||||
|
||||
fn request(v4: Option<Ipv4Addr>) -> TunRequest {
|
||||
addressed(v4.into_iter().collect())
|
||||
}
|
||||
|
||||
fn addressed(v4: Vec<Ipv4Addr>) -> TunRequest {
|
||||
TunRequest {
|
||||
name: "tsunfactory".into(),
|
||||
address: Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1),
|
||||
prefix_len: 64,
|
||||
address_v4: v4,
|
||||
prefix_len_v4: 24,
|
||||
addresses: v4
|
||||
.into_iter()
|
||||
.map(|address| Cidr::new(address.into(), 24).unwrap())
|
||||
.collect(),
|
||||
mtu: 1280,
|
||||
}
|
||||
}
|
||||
@@ -137,7 +161,7 @@ mod tests {
|
||||
let state = provisioner.host().get("tsunfactory").unwrap();
|
||||
assert_eq!(state.kind, LinkKind::Tun);
|
||||
assert_eq!(state.mtu, 1280);
|
||||
assert_eq!(state.addresses.len(), 2, "both families are assigned");
|
||||
assert_eq!(state.addresses.len(), 1, "the overlay address is assigned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -198,4 +222,50 @@ mod tests {
|
||||
let err = factory.create(request(None)).await.unwrap_err();
|
||||
assert!(err.to_string().contains("bridge"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_network_s_address_reaches_the_host_not_just_the_first() {
|
||||
// One agent has one interface and a network apiece on it. Carrying
|
||||
// only the first address is carrying only the first network: the
|
||||
// rest have addresses the operating system has never heard of, and
|
||||
// their traffic goes nowhere while the status says all is well.
|
||||
let provisioner = Arc::new(MockProvisioner::default());
|
||||
let factory = ManagedTunFactory::new(provisioner.clone());
|
||||
factory
|
||||
.create(addressed(vec![
|
||||
Ipv4Addr::new(10, 13, 37, 69),
|
||||
Ipv4Addr::new(10, 156, 200, 116),
|
||||
]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = provisioner.host().get("tsunfactory").unwrap();
|
||||
let addresses: Vec<String> = state
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|entry| entry.to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
addresses.contains(&"10.13.37.69/24".to_string()),
|
||||
"{addresses:?}"
|
||||
);
|
||||
assert!(
|
||||
addresses.contains(&"10.156.200.116/24".to_string()),
|
||||
"{addresses:?}"
|
||||
);
|
||||
|
||||
// And leaving one network takes its address off, because the plan
|
||||
// is what the interface should carry and nothing else.
|
||||
factory
|
||||
.reconfigure(addressed(vec![Ipv4Addr::new(10, 13, 37, 69)]))
|
||||
.await
|
||||
.unwrap();
|
||||
let state = provisioner.host().get("tsunfactory").unwrap();
|
||||
let addresses: Vec<String> = state
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|entry| entry.to_string())
|
||||
.collect();
|
||||
assert_eq!(addresses, vec!["10.13.37.69/24".to_string()]);
|
||||
}
|
||||
}
|
||||
+184
-40
@@ -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,10 +47,11 @@ 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::dataplane::PluginError;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::super::config::Cidr;
|
||||
use super::super::tun::{TunDevice, TunRequest};
|
||||
@@ -67,11 +68,15 @@ 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,
|
||||
}
|
||||
|
||||
type Reply<T> = mpsc::Sender<Result<T, PluginError>>;
|
||||
type Reply<T> = mpsc::Sender<Result<T, OverlayError>>;
|
||||
|
||||
/// The configuration half of a reconciliation.
|
||||
struct Configure {
|
||||
@@ -97,17 +102,17 @@ pub struct NetlinkProvisioner {
|
||||
|
||||
impl NetlinkProvisioner {
|
||||
/// Starts the netlink thread, after checking this process can use it.
|
||||
pub fn new() -> Result<Self, PluginError> {
|
||||
pub fn new() -> Result<Self, OverlayError> {
|
||||
match probe_net_admin() {
|
||||
Privilege::Available => {}
|
||||
Privilege::Missing(reason) => {
|
||||
return Err(PluginError::Unavailable(format!(
|
||||
return Err(OverlayError::Unavailable(format!(
|
||||
"{reason}. {}",
|
||||
Privilege::how_to_grant(¤t_program())
|
||||
)));
|
||||
}
|
||||
Privilege::Unsupported => {
|
||||
return Err(PluginError::Unavailable(
|
||||
return Err(OverlayError::Unavailable(
|
||||
"interface management is not compiled in".to_string(),
|
||||
));
|
||||
}
|
||||
@@ -118,7 +123,7 @@ impl NetlinkProvisioner {
|
||||
.name("tsunagi-netlink".to_string())
|
||||
.spawn(move || netlink_thread(requests))
|
||||
.map_err(|err| {
|
||||
PluginError::Unavailable(format!("cannot start the netlink thread: {err}"))
|
||||
OverlayError::Unavailable(format!("cannot start the netlink thread: {err}"))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
@@ -132,14 +137,19 @@ impl NetlinkProvisioner {
|
||||
fn call<T: Send + 'static>(
|
||||
&self,
|
||||
make: impl FnOnce(Reply<T>) -> Command,
|
||||
) -> Result<T, PluginError> {
|
||||
let (reply_tx, reply_rx) = mpsc::channel();
|
||||
self.commands
|
||||
.send(make(reply_tx))
|
||||
.map_err(|_| PluginError::Unavailable("the netlink thread has stopped".to_string()))?;
|
||||
reply_rx.recv().map_err(|_| {
|
||||
PluginError::Unavailable("the netlink thread stopped mid-request".to_string())
|
||||
})?
|
||||
) -> Result<T, OverlayError> {
|
||||
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 {
|
||||
@@ -150,13 +160,51 @@ impl NetlinkProvisioner {
|
||||
///
|
||||
/// Synchronous on purpose: the capability guard is raised and lowered
|
||||
/// without an `await` in between, so it cannot outlive this thread.
|
||||
fn create_device(&self, plan: &InterfacePlan) -> Result<Arc<dyn TunDevice>, PluginError> {
|
||||
fn create_device(&self, plan: &InterfacePlan) -> Result<Arc<dyn TunDevice>, OverlayError> {
|
||||
let request = TunRequest::bare(plan.name.clone(), plan.mtu);
|
||||
let _guard = NetAdmin::acquire()?;
|
||||
super::super::tun::open_tun(&request)
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -174,7 +222,7 @@ impl InterfaceProvisioner for NetlinkProvisioner {
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, PluginError>> {
|
||||
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
|
||||
Box::pin(async move {
|
||||
let current = self.call(|reply| Command::Observe(plan.name.clone(), reply))?;
|
||||
let changes = plan_changes(¤t, plan, self.is_ours(&plan.name))?;
|
||||
@@ -224,7 +272,7 @@ impl InterfaceProvisioner for NetlinkProvisioner {
|
||||
})
|
||||
}
|
||||
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), PluginError>> {
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move {
|
||||
// Dropping the device is what removes the interface; the explicit
|
||||
// delete is only so it is gone by the time this returns rather
|
||||
@@ -271,10 +319,13 @@ fn netlink_thread(requests: mpsc::Receiver<Command>) {
|
||||
let message = format!("the netlink thread has no runtime: {err}");
|
||||
match command {
|
||||
Command::Observe(_, reply) => {
|
||||
let _ = reply.send(Err(PluginError::Unavailable(message)));
|
||||
let _ = reply.send(Err(OverlayError::Unavailable(message)));
|
||||
}
|
||||
Command::Delete(_, reply) | Command::Configure(_, reply) => {
|
||||
let _ = reply.send(Err(PluginError::Unavailable(message)));
|
||||
Command::Delete(_, reply)
|
||||
| Command::Configure(_, reply)
|
||||
| Command::SetBroadcastRoute(_, _, reply)
|
||||
| Command::DelBroadcastRoute(_, reply) => {
|
||||
let _ = reply.send(Err(OverlayError::Unavailable(message)));
|
||||
}
|
||||
Command::Stop => return,
|
||||
}
|
||||
@@ -306,17 +357,33 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a netlink connection and runs one unit of work over it.
|
||||
async fn with_netlink<T, F>(work: impl FnOnce(rtnetlink::Handle) -> F) -> Result<T, PluginError>
|
||||
async fn with_netlink<T, F>(work: impl FnOnce(rtnetlink::Handle) -> F) -> Result<T, OverlayError>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, PluginError>>,
|
||||
F: std::future::Future<Output = Result<T, OverlayError>>,
|
||||
{
|
||||
let (connection, handle, _messages) = rtnetlink::new_connection()
|
||||
.map_err(|err| PluginError::Unavailable(format!("cannot open netlink: {err}")))?;
|
||||
.map_err(|err| OverlayError::Unavailable(format!("cannot open netlink: {err}")))?;
|
||||
let pump = tokio::spawn(connection);
|
||||
let result = work(handle).await;
|
||||
pump.abort();
|
||||
@@ -361,7 +428,7 @@ fn link_kind(message: &LinkMessage, name: &str) -> LinkKind {
|
||||
}
|
||||
}
|
||||
|
||||
async fn observe(name: &str) -> Result<InterfaceState, PluginError> {
|
||||
async fn observe(name: &str) -> Result<InterfaceState, OverlayError> {
|
||||
with_netlink(|handle| async move {
|
||||
let mut links = handle.link().get().match_name(name.to_string()).execute();
|
||||
let message = match links.try_next().await {
|
||||
@@ -373,7 +440,7 @@ async fn observe(name: &str) -> Result<InterfaceState, PluginError> {
|
||||
if !std::path::Path::new(&format!("/sys/class/net/{name}")).exists() {
|
||||
return Ok(InterfaceState::absent());
|
||||
}
|
||||
return Err(PluginError::Unavailable(format!(
|
||||
return Err(OverlayError::Unavailable(format!(
|
||||
"cannot read interface `{name}`: {err}"
|
||||
)));
|
||||
}
|
||||
@@ -397,7 +464,7 @@ async fn observe(name: &str) -> Result<InterfaceState, PluginError> {
|
||||
.set_link_index_filter(index)
|
||||
.execute();
|
||||
while let Some(message) = stream.try_next().await.map_err(|err| {
|
||||
PluginError::Unavailable(format!("cannot read the addresses of `{name}`: {err}"))
|
||||
OverlayError::Unavailable(format!("cannot read the addresses of `{name}`: {err}"))
|
||||
})? {
|
||||
if let Some(cidr) = address_of(&message)
|
||||
&& !is_link_local(cidr.addr)
|
||||
@@ -440,7 +507,7 @@ fn cidr(addr: IpAddr, prefix_len: u8) -> Option<Cidr> {
|
||||
Cidr::new(addr, prefix_len).ok()
|
||||
}
|
||||
|
||||
async fn delete_link(name: &str) -> Result<(), PluginError> {
|
||||
async fn delete_link(name: &str) -> Result<(), OverlayError> {
|
||||
with_netlink(|handle| async move {
|
||||
let mut links = handle.link().get().match_name(name.to_string()).execute();
|
||||
let index = match links.try_next().await {
|
||||
@@ -449,13 +516,87 @@ async fn delete_link(name: &str) -> Result<(), PluginError> {
|
||||
Ok(None) | Err(_) => return Ok(()),
|
||||
};
|
||||
handle.link().del(index).execute().await.map_err(|err| {
|
||||
PluginError::Unavailable(format!("cannot remove interface `{name}`: {err}"))
|
||||
OverlayError::Unavailable(format!("cannot remove interface `{name}`: {err}"))
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn configure_link(configure: &Configure) -> Result<(), PluginError> {
|
||||
/// 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 {
|
||||
let mut links = handle.link().get().match_name(name.to_string()).execute();
|
||||
@@ -466,7 +607,7 @@ async fn configure_link(configure: &Configure) -> Result<(), PluginError> {
|
||||
.flatten()
|
||||
.map(|message| message.header.index)
|
||||
.ok_or_else(|| {
|
||||
PluginError::Unavailable(format!(
|
||||
OverlayError::Unavailable(format!(
|
||||
"interface `{name}` disappeared before it could be configured"
|
||||
))
|
||||
})?;
|
||||
@@ -485,7 +626,7 @@ async fn configure_link(configure: &Configure) -> Result<(), PluginError> {
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
PluginError::Unavailable(format!("cannot configure interface `{name}`: {err}"))
|
||||
OverlayError::Unavailable(format!("cannot configure interface `{name}`: {err}"))
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -512,7 +653,7 @@ async fn configure_link(configure: &Configure) -> Result<(), PluginError> {
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
PluginError::Unavailable(format!(
|
||||
OverlayError::Unavailable(format!(
|
||||
"cannot remove {cidr} from interface `{name}`: {err}"
|
||||
))
|
||||
})?;
|
||||
@@ -520,16 +661,19 @@ async fn configure_link(configure: &Configure) -> Result<(), PluginError> {
|
||||
}
|
||||
|
||||
for cidr in &configure.add {
|
||||
handle
|
||||
let res = handle
|
||||
.address()
|
||||
.add(index, cidr.addr, cidr.prefix_len)
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
PluginError::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
|
||||
))));
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -9,7 +9,7 @@ use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::super::config::Cidr;
|
||||
use super::super::tun::{MemoryTun, MemoryTunFactory, TunFactory, TunRequest};
|
||||
@@ -125,9 +125,9 @@ impl MockProvisioner {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&self, plan: &InterfacePlan) -> Result<Changes, PluginError> {
|
||||
fn apply(&self, plan: &InterfacePlan) -> Result<Changes, OverlayError> {
|
||||
if let Some(reason) = &self.failure {
|
||||
return Err(PluginError::Unavailable(reason.clone()));
|
||||
return Err(OverlayError::Unavailable(reason.clone()));
|
||||
}
|
||||
|
||||
let ours = self.owned().iter().any(|name| name == &plan.name);
|
||||
@@ -179,7 +179,7 @@ impl InterfaceProvisioner for MockProvisioner {
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, PluginError>> {
|
||||
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
|
||||
Box::pin(async move {
|
||||
let changes = self.apply(plan)?;
|
||||
let device = if changes.create_link {
|
||||
@@ -195,7 +195,7 @@ impl InterfaceProvisioner for MockProvisioner {
|
||||
})
|
||||
}
|
||||
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), PluginError>> {
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move {
|
||||
self.host.lock().remove(name);
|
||||
self.owned().retain(|owned| owned != name);
|
||||
+23
-7
@@ -20,9 +20,13 @@
|
||||
//! pure and tested on every platform, and only the execution is behind
|
||||
//! [`InterfaceProvisioner`].
|
||||
//!
|
||||
//! Three implementations:
|
||||
//! Five implementations:
|
||||
//!
|
||||
//! * `NetlinkProvisioner` on Linux, which needs `CAP_NET_ADMIN`.
|
||||
//! * `WintunProvisioner` on Windows, a Wintun adapter configured with `netsh`,
|
||||
//! which needs an elevated process.
|
||||
//! * `UtunProvisioner` on macOS, a utun interface configured with `ifconfig`
|
||||
//! and `route`, which needs root.
|
||||
//! * [`MockProvisioner`], an in-memory host used by the tests.
|
||||
//! * [`UnsupportedProvisioner`] elsewhere, which fails with an explanation
|
||||
//! and a pointer at the manual route rather than pretending to work.
|
||||
@@ -38,7 +42,7 @@ use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::config::Cidr;
|
||||
use super::tun::TunDevice;
|
||||
@@ -50,6 +54,18 @@ pub use mock::{MockHost, MockProvisioner};
|
||||
mod linux;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use linux::NetlinkProvisioner;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub(crate) use linux::RouteHandle;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
mod windows;
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use windows::WintunProvisioner;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
mod macos;
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use macos::UtunProvisioner;
|
||||
|
||||
mod privilege;
|
||||
pub use privilege::{Privilege, probe_net_admin};
|
||||
@@ -203,19 +219,19 @@ pub fn plan_changes(
|
||||
current: &InterfaceState,
|
||||
plan: &InterfacePlan,
|
||||
ours: bool,
|
||||
) -> Result<Changes, PluginError> {
|
||||
) -> Result<Changes, OverlayError> {
|
||||
let mut changes = Changes::default();
|
||||
|
||||
match ¤t.kind {
|
||||
LinkKind::Foreign(kind) => {
|
||||
return Err(PluginError::Unavailable(format!(
|
||||
return Err(OverlayError::Unavailable(format!(
|
||||
"`{}` already exists and is a {kind} interface, not one of ours. \
|
||||
Refusing to touch it. Run with a different interface prefix.",
|
||||
plan.name
|
||||
)));
|
||||
}
|
||||
LinkKind::Tun if !ours && current.attached => {
|
||||
return Err(PluginError::Unavailable(format!(
|
||||
return Err(OverlayError::Unavailable(format!(
|
||||
"`{}` already exists and another process is attached to it. \
|
||||
That is most likely a second agent on this host in the same \
|
||||
network; give one of them a different interface prefix.",
|
||||
@@ -293,13 +309,13 @@ pub trait InterfaceProvisioner: Send + Sync + std::fmt::Debug + 'static {
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, PluginError>>;
|
||||
) -> BoxFuture<'a, Result<Provisioned, OverlayError>>;
|
||||
|
||||
/// Removes an interface this provisioner created.
|
||||
///
|
||||
/// Removing one that is already gone succeeds: this runs on the shutdown
|
||||
/// path, where the interface having vanished is the desired outcome.
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), PluginError>>;
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
+63
-9
@@ -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."
|
||||
@@ -60,10 +60,24 @@ impl Privilege {
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use linux_impl::{NetAdmin, probe_net_admin};
|
||||
|
||||
#[cfg(not(all(feature = "tun-device", target_os = "linux")))]
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
pub use windows_impl::probe_net_admin;
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
pub use macos_impl::probe_net_admin;
|
||||
|
||||
#[cfg(not(any(
|
||||
all(feature = "tun-device", target_os = "linux"),
|
||||
all(feature = "tun-device", target_os = "windows"),
|
||||
all(feature = "tun-device", target_os = "macos")
|
||||
)))]
|
||||
pub use other_impl::probe_net_admin;
|
||||
|
||||
#[cfg(not(all(feature = "tun-device", target_os = "linux")))]
|
||||
#[cfg(not(any(
|
||||
all(feature = "tun-device", target_os = "linux"),
|
||||
all(feature = "tun-device", target_os = "windows"),
|
||||
all(feature = "tun-device", target_os = "macos")
|
||||
)))]
|
||||
mod other_impl {
|
||||
use super::Privilege;
|
||||
|
||||
@@ -73,12 +87,50 @@ mod other_impl {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
mod windows_impl {
|
||||
use super::Privilege;
|
||||
|
||||
/// Whether this process can configure interfaces.
|
||||
///
|
||||
/// Windows has no capability to hold and lower the way Linux does:
|
||||
/// creating an adapter simply needs the process to be elevated. Reading
|
||||
/// whether it *is* elevated means inspecting the process token through a
|
||||
/// raw call this crate forbids, so the probe is deliberately optimistic —
|
||||
/// it reports that the platform can manage interfaces — and the real
|
||||
/// check is left to Wintun's adapter creation, which fails with a precise
|
||||
/// message when the process is not elevated. This matches how the Linux
|
||||
/// path treats the open itself as the honest answer.
|
||||
pub fn probe_net_admin() -> Privilege {
|
||||
Privilege::Available
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||
mod macos_impl {
|
||||
use super::Privilege;
|
||||
|
||||
/// Whether this process can configure interfaces.
|
||||
///
|
||||
/// macOS has no capability to hold and lower the way Linux does: creating
|
||||
/// a utun interface simply needs the process to be root. Reading the
|
||||
/// effective uid means a libc call this crate forbids, so the probe is
|
||||
/// deliberately optimistic — it reports that the platform can manage
|
||||
/// interfaces — and the real check is left to the utun open, which fails
|
||||
/// with a precise "run with sudo" message when the process is not root.
|
||||
/// This matches how the Linux path treats the open itself as the honest
|
||||
/// answer.
|
||||
pub fn probe_net_admin() -> Privilege {
|
||||
Privilege::Available
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
mod linux_impl {
|
||||
use caps::{CapSet, Capability};
|
||||
|
||||
use super::Privilege;
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
/// Whether this thread holds `CAP_NET_ADMIN` in its permitted set.
|
||||
pub fn probe_net_admin() -> Privilege {
|
||||
@@ -108,16 +160,16 @@ mod linux_impl {
|
||||
|
||||
impl NetAdmin {
|
||||
/// Raises `CAP_NET_ADMIN` into the effective set.
|
||||
pub fn acquire() -> Result<Self, PluginError> {
|
||||
pub fn acquire() -> Result<Self, OverlayError> {
|
||||
let already = caps::has_cap(None, CapSet::Effective, Capability::CAP_NET_ADMIN)
|
||||
.map_err(|err| {
|
||||
PluginError::Unavailable(format!("cannot read capabilities: {err}"))
|
||||
OverlayError::Unavailable(format!("cannot read capabilities: {err}"))
|
||||
})?;
|
||||
if already {
|
||||
return Ok(Self { raised: false });
|
||||
}
|
||||
caps::raise(None, CapSet::Effective, Capability::CAP_NET_ADMIN).map_err(|err| {
|
||||
PluginError::Unavailable(format!(
|
||||
OverlayError::Unavailable(format!(
|
||||
"cannot raise CAP_NET_ADMIN: {err}. {}",
|
||||
Privilege::how_to_grant("tsunagi")
|
||||
))
|
||||
@@ -153,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");
|
||||
}
|
||||
|
||||
+5
-5
@@ -7,7 +7,7 @@
|
||||
//! of provisioning and says what to do instead.
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::{InterfacePlan, InterfaceProvisioner, Provisioned};
|
||||
|
||||
@@ -31,8 +31,8 @@ impl UnsupportedProvisioner {
|
||||
}
|
||||
}
|
||||
|
||||
fn refusal(&self) -> PluginError {
|
||||
PluginError::Unavailable(format!(
|
||||
fn refusal(&self) -> OverlayError {
|
||||
OverlayError::Unavailable(format!(
|
||||
"managing the overlay interface is not implemented on {} yet. \
|
||||
Run with `--no-tun` until it is: the tunnels still form, they just \
|
||||
do not reach the operating system.",
|
||||
@@ -49,11 +49,11 @@ impl InterfaceProvisioner for UnsupportedProvisioner {
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
_plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, PluginError>> {
|
||||
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
|
||||
Box::pin(async move { Err(self.refusal()) })
|
||||
}
|
||||
|
||||
fn remove<'a>(&'a self, _name: &'a str) -> BoxFuture<'a, Result<(), PluginError>> {
|
||||
fn remove<'a>(&'a self, _name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
// Nothing was ever created, so there is nothing to clean up and no
|
||||
// reason to fail a shutdown path.
|
||||
Box::pin(async move { Ok(()) })
|
||||
@@ -0,0 +1,513 @@
|
||||
//! Managing the overlay interface on Windows, through Wintun and `netsh`.
|
||||
//!
|
||||
//! The shape is the same as the Linux provisioner: observe what is there,
|
||||
//! [`plan_changes`](super::plan_changes) to decide what to do, apply the
|
||||
//! difference. Only the two mechanisms differ.
|
||||
//!
|
||||
//! # Creating the interface
|
||||
//!
|
||||
//! The interface is a Wintun adapter, created through the `tun` dependency's
|
||||
//! safe wrapper — [`open_tun`](super::super::tun) — because loading
|
||||
//! `wintun.dll` is an `unsafe` call and this crate forbids `unsafe`. The
|
||||
//! adapter is created, not made persistent: closing the last handle to it
|
||||
//! removes it, so it goes away when the agent does, however the agent ends.
|
||||
//! The reader and writer inside the device hold that handle, so the adapter
|
||||
//! lives exactly as long as the device does.
|
||||
//!
|
||||
//! # Configuring it
|
||||
//!
|
||||
//! Addresses and MTU are applied with `netsh`. On Linux the same work is done
|
||||
//! in process over a netlink socket; here there is no equivalent that this
|
||||
//! crate may call, because the in-process route is the IP Helper API and that
|
||||
//! is `unsafe`. `netsh` is the supported tool for the job, it is invoked by
|
||||
//! absolute path from `%SystemRoot%` so nothing on `PATH` can stand in for it,
|
||||
//! and every value handed to it is one this agent derived — an interface name
|
||||
//! from the network id, an address it allocated — never anything a peer said.
|
||||
//!
|
||||
//! # Observing it
|
||||
//!
|
||||
//! The interface table is read through `netdev`, whose enumeration is a safe
|
||||
//! wrapper over the same IP Helper API. An adapter is matched by the friendly
|
||||
//! name it was created with. A match that carries a default gateway is treated
|
||||
//! as foreign and left alone: a tsunagi overlay interface never has one, so a
|
||||
//! gateway is the mark of a real device that happens to share the name.
|
||||
//!
|
||||
//! # What Windows cannot do that Linux can
|
||||
//!
|
||||
//! Wintun exposes no way to delete an adapter this process did not create, so
|
||||
//! a leftover from a run that was killed is *reused* rather than replaced: its
|
||||
//! addresses are flushed and the planned ones applied, which reaches the same
|
||||
//! end state. A reused adapter is not removed on exit, only closed — that is a
|
||||
//! Wintun limitation, and the common case of a clean start and a clean stop is
|
||||
//! unaffected.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::super::config::Cidr;
|
||||
use super::super::tun::{TunDevice, TunRequest, open_tun};
|
||||
use super::privilege::{Privilege, probe_net_admin};
|
||||
use super::{
|
||||
InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, Provisioned, plan_changes,
|
||||
};
|
||||
|
||||
/// Manages the overlay interface with Wintun and `netsh`.
|
||||
#[derive(Debug)]
|
||||
pub struct WintunProvisioner {
|
||||
/// Interfaces this process created, so they are adjusted rather than
|
||||
/// replaced. See [`plan_changes`].
|
||||
ours: Mutex<Vec<String>>,
|
||||
/// Devices kept alive for as long as the interface should exist. Dropping
|
||||
/// one closes the adapter, which removes it if this process created it.
|
||||
held: Mutex<Vec<(String, Arc<dyn TunDevice>)>>,
|
||||
}
|
||||
|
||||
impl WintunProvisioner {
|
||||
/// Creates the provisioner, after checking the platform supports one.
|
||||
///
|
||||
/// Whether the process is actually elevated is not checked here — that
|
||||
/// cannot be read without an `unsafe` call — so a missing privilege
|
||||
/// surfaces at adapter creation with a precise message, the same way the
|
||||
/// Linux path treats the open itself as the honest answer.
|
||||
pub fn new() -> Result<Self, OverlayError> {
|
||||
match probe_net_admin() {
|
||||
Privilege::Available => {}
|
||||
Privilege::Missing(reason) => return Err(OverlayError::Unavailable(reason)),
|
||||
Privilege::Unsupported => {
|
||||
return Err(OverlayError::Unavailable(
|
||||
"interface management is not compiled in".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
ours: Mutex::new(Vec::new()),
|
||||
held: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_ours(&self, name: &str) -> bool {
|
||||
lock(&self.ours)
|
||||
.iter()
|
||||
.any(|owned| owned.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Opens the Wintun adapter, which is what creates it.
|
||||
fn create_device(&self, plan: &InterfacePlan) -> Result<Arc<dyn TunDevice>, OverlayError> {
|
||||
let request = TunRequest::bare(plan.name.clone(), plan.mtu);
|
||||
open_tun(&request)
|
||||
}
|
||||
}
|
||||
|
||||
impl InterfaceProvisioner for WintunProvisioner {
|
||||
fn name(&self) -> &str {
|
||||
"wintun"
|
||||
}
|
||||
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
|
||||
Box::pin(async move {
|
||||
let current = observe(&plan.name).await;
|
||||
let changes = plan_changes(¤t, plan, self.is_ours(&plan.name))?;
|
||||
|
||||
// Wintun cannot delete an adapter this process did not create, so a
|
||||
// "delete then create" from `plan_changes` becomes "flush then
|
||||
// reuse": clear the stale addresses off the interface that is
|
||||
// there, and let the create step below reopen the same adapter.
|
||||
// The end state — an adapter carrying exactly the plan's addresses
|
||||
// — is identical.
|
||||
let flush: Vec<Cidr> = if changes.delete_link {
|
||||
tracing::info!(
|
||||
interface = %plan.name,
|
||||
"reusing an abandoned interface left by an earlier run"
|
||||
);
|
||||
current.addresses.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
for cidr in flush.iter().chain(changes.remove.iter()) {
|
||||
// Best effort: an address that is already gone is the outcome
|
||||
// wanted, and refusing to start over one is not worth it.
|
||||
if let Err(err) = address_del(&plan.name, *cidr).await {
|
||||
tracing::debug!(interface = %plan.name, %cidr, %err, "could not remove address");
|
||||
}
|
||||
}
|
||||
|
||||
let device = if changes.create_link {
|
||||
let device = self.create_device(plan)?;
|
||||
lock(&self.ours).push(plan.name.clone());
|
||||
lock(&self.held).push((plan.name.clone(), Arc::clone(&device)));
|
||||
Some(device)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// A failure past this point leaves an interface that exists but
|
||||
// cannot carry traffic. Undo the bookkeeping for one we just
|
||||
// created so it is not mistaken for a working interface, and drop
|
||||
// the device so the adapter closes.
|
||||
let configured = configure(plan, &changes).await;
|
||||
if let Err(err) = configured {
|
||||
if changes.create_link {
|
||||
lock(&self.held).retain(|(held, _)| held != &plan.name);
|
||||
lock(&self.ours).retain(|owned| owned != &plan.name);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(Provisioned { changes, device })
|
||||
})
|
||||
}
|
||||
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move {
|
||||
// Dropping the device closes the adapter, which removes it if this
|
||||
// process created it. A reused adapter is only closed, not removed;
|
||||
// flushing its addresses first keeps no stale configuration behind.
|
||||
let current = observe(name).await;
|
||||
for cidr in ¤t.addresses {
|
||||
if let Err(err) = address_del(name, *cidr).await {
|
||||
tracing::debug!(interface = %name, %cidr, %err, "could not remove address");
|
||||
}
|
||||
}
|
||||
lock(&self.held).retain(|(held, _)| !held.eq_ignore_ascii_case(name));
|
||||
lock(&self.ours).retain(|owned| !owned.eq_ignore_ascii_case(name));
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `fe80::/10` and `169.254.0.0/16`, which Windows assigns on its own.
|
||||
fn is_link_local(addr: IpAddr) -> bool {
|
||||
match addr {
|
||||
IpAddr::V4(addr) => addr.is_link_local(),
|
||||
IpAddr::V6(addr) => (addr.segments()[0] & 0xffc0) == 0xfe80,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the interface named `name` currently looks like.
|
||||
///
|
||||
/// Never fails: a name nothing is using reads as [`InterfaceState::absent`],
|
||||
/// which is what the plan then acts on.
|
||||
async fn observe(name: &str) -> InterfaceState {
|
||||
let name = name.to_string();
|
||||
let found = tokio::task::spawn_blocking(move || {
|
||||
netdev::get_interfaces()
|
||||
.into_iter()
|
||||
.find(|iface| match &iface.friendly_name {
|
||||
Some(friendly) => friendly.eq_ignore_ascii_case(&name),
|
||||
None => false,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let Some(iface) = found else {
|
||||
return InterfaceState::absent();
|
||||
};
|
||||
|
||||
let mut addresses: Vec<Cidr> = Vec::new();
|
||||
for net in &iface.ipv4 {
|
||||
if let Ok(cidr) = Cidr::new(IpAddr::V4(net.addr()), net.prefix_len())
|
||||
&& !is_link_local(cidr.addr)
|
||||
{
|
||||
addresses.push(cidr);
|
||||
}
|
||||
}
|
||||
for net in &iface.ipv6 {
|
||||
if let Ok(cidr) = Cidr::new(IpAddr::V6(net.addr()), net.prefix_len())
|
||||
&& !is_link_local(cidr.addr)
|
||||
{
|
||||
addresses.push(cidr);
|
||||
}
|
||||
}
|
||||
addresses.sort();
|
||||
|
||||
// A tsunagi overlay interface only ever carries the addresses it was given
|
||||
// and no gateway. One that has a gateway is a real device sharing the name,
|
||||
// so it is foreign and never touched.
|
||||
let kind = if iface.gateway.is_some() {
|
||||
LinkKind::Foreign("gatewayed".to_string())
|
||||
} else {
|
||||
LinkKind::Tun
|
||||
};
|
||||
|
||||
InterfaceState {
|
||||
kind,
|
||||
// Wintun reports an adapter with a live session as up and an abandoned
|
||||
// one as down, so "up" doubles as "some process is holding it".
|
||||
attached: iface.is_up(),
|
||||
up: iface.is_up(),
|
||||
mtu: iface.mtu.unwrap_or(0),
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies MTU, link state and addresses.
|
||||
async fn configure(plan: &InterfacePlan, changes: &super::Changes) -> Result<(), OverlayError> {
|
||||
if let Some(mtu) = changes.set_mtu {
|
||||
set_mtu(&plan.name, mtu).await?;
|
||||
}
|
||||
for cidr in &changes.add {
|
||||
if let Err(err) = address_add(&plan.name, *cidr).await {
|
||||
let err_str = err.to_string();
|
||||
if !err_str.contains("already exists") && !err_str.contains("0x80071392") {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
if changes.bring_up {
|
||||
// Best effort: a Wintun adapter with a session is up already, and a
|
||||
// failure to nudge it is not a reason to fail the whole reconcile.
|
||||
if let Err(err) = set_up(&plan.name).await {
|
||||
tracing::debug!(interface = %plan.name, %err, "could not enable the interface");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The dotted netmask for an IPv4 prefix length.
|
||||
fn ipv4_mask(prefix_len: u8) -> Ipv4Addr {
|
||||
if prefix_len == 0 {
|
||||
Ipv4Addr::UNSPECIFIED
|
||||
} else {
|
||||
Ipv4Addr::from(u32::MAX << (32 - u32::from(prefix_len)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The `netsh` arguments that add an address to an interface.
|
||||
fn address_add_args(name: &str, cidr: Cidr) -> Vec<String> {
|
||||
match cidr.addr {
|
||||
IpAddr::V4(addr) => vec![
|
||||
"interface".into(),
|
||||
"ipv4".into(),
|
||||
"add".into(),
|
||||
"address".into(),
|
||||
format!("name={name}"),
|
||||
format!("address={addr}"),
|
||||
format!("mask={}", ipv4_mask(cidr.prefix_len)),
|
||||
"store=active".into(),
|
||||
],
|
||||
IpAddr::V6(addr) => vec![
|
||||
"interface".into(),
|
||||
"ipv6".into(),
|
||||
"add".into(),
|
||||
"address".into(),
|
||||
format!("interface={name}"),
|
||||
format!("address={addr}/{}", cidr.prefix_len),
|
||||
"store=active".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The `netsh` arguments that remove an address from an interface.
|
||||
fn address_del_args(name: &str, cidr: Cidr) -> Vec<String> {
|
||||
match cidr.addr {
|
||||
IpAddr::V4(addr) => vec![
|
||||
"interface".into(),
|
||||
"ipv4".into(),
|
||||
"delete".into(),
|
||||
"address".into(),
|
||||
format!("name={name}"),
|
||||
format!("address={addr}"),
|
||||
"store=active".into(),
|
||||
],
|
||||
IpAddr::V6(addr) => vec![
|
||||
"interface".into(),
|
||||
"ipv6".into(),
|
||||
"delete".into(),
|
||||
"address".into(),
|
||||
format!("interface={name}"),
|
||||
format!("address={addr}"),
|
||||
"store=active".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The `netsh` arguments that set an interface's MTU, one call per family.
|
||||
fn mtu_args(name: &str, family: &str, mtu: u32) -> Vec<String> {
|
||||
vec![
|
||||
"interface".into(),
|
||||
family.into(),
|
||||
"set".into(),
|
||||
"subinterface".into(),
|
||||
name.into(),
|
||||
format!("mtu={mtu}"),
|
||||
"store=active".into(),
|
||||
]
|
||||
}
|
||||
|
||||
async fn address_add(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
||||
netsh(address_add_args(name, cidr)).await
|
||||
}
|
||||
|
||||
async fn address_del(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
||||
netsh(address_del_args(name, cidr)).await
|
||||
}
|
||||
|
||||
/// Sets the MTU on both families.
|
||||
///
|
||||
/// A family that is turned off on the adapter cannot take an MTU, so this
|
||||
/// fails only when *neither* would — one family carrying the plan's MTU is
|
||||
/// enough, and refusing the whole interface because the other is disabled
|
||||
/// would be wrong.
|
||||
async fn set_mtu(name: &str, mtu: u32) -> Result<(), OverlayError> {
|
||||
let v4 = netsh(mtu_args(name, "ipv4", mtu)).await;
|
||||
let v6 = netsh(mtu_args(name, "ipv6", mtu)).await;
|
||||
match (&v4, &v6) {
|
||||
(Err(_), Err(_)) => v4,
|
||||
_ => {
|
||||
if let Err(err) = &v4 {
|
||||
tracing::debug!(interface = %name, %err, "could not set the IPv4 MTU");
|
||||
}
|
||||
if let Err(err) = &v6 {
|
||||
tracing::debug!(interface = %name, %err, "could not set the IPv6 MTU");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_up(name: &str) -> Result<(), OverlayError> {
|
||||
netsh(vec![
|
||||
"interface".into(),
|
||||
"set".into(),
|
||||
"interface".into(),
|
||||
format!("name={name}"),
|
||||
"admin=enabled".into(),
|
||||
])
|
||||
.await
|
||||
}
|
||||
|
||||
/// Runs `netsh` from `%SystemRoot%\System32`, so nothing on `PATH` can stand
|
||||
/// in for it, and turns a non-zero exit into an error carrying what it said.
|
||||
async fn netsh(args: Vec<String>) -> Result<(), OverlayError> {
|
||||
let program = system32("netsh.exe");
|
||||
let display = format!("netsh {}", args.join(" "));
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
std::process::Command::new(program).args(&args).output()
|
||||
})
|
||||
.await
|
||||
.map_err(|err| OverlayError::Other(format!("could not run {display}: {err}")))?
|
||||
.map_err(|err| OverlayError::Unavailable(format!("could not run {display}: {err}")))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let message = {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let text = if stderr.trim().is_empty() {
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
} else {
|
||||
stderr.trim().to_string()
|
||||
};
|
||||
if text.is_empty() {
|
||||
format!("exit code {}", output.status)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
};
|
||||
Err(OverlayError::Unavailable(format!(
|
||||
"{display} failed: {message}"
|
||||
)))
|
||||
}
|
||||
|
||||
/// The absolute path to a program in `System32`.
|
||||
fn system32(exe: &str) -> std::path::PathBuf {
|
||||
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into());
|
||||
std::path::Path::new(&root).join("System32").join(exe)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::net::Ipv6Addr;
|
||||
|
||||
fn v4(last: u8, prefix: u8) -> Cidr {
|
||||
Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, last)), prefix).unwrap()
|
||||
}
|
||||
|
||||
fn v6(last: u16) -> Cidr {
|
||||
Cidr::new(
|
||||
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, last)),
|
||||
64,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prefix_length_becomes_the_right_dotted_mask() {
|
||||
assert_eq!(ipv4_mask(24), Ipv4Addr::new(255, 255, 255, 0));
|
||||
assert_eq!(ipv4_mask(16), Ipv4Addr::new(255, 255, 0, 0));
|
||||
assert_eq!(ipv4_mask(32), Ipv4Addr::new(255, 255, 255, 255));
|
||||
assert_eq!(ipv4_mask(0), Ipv4Addr::UNSPECIFIED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v4_address_is_added_by_name_with_a_dotted_mask() {
|
||||
let args = address_add_args("tsun0", v4(69, 24));
|
||||
assert!(args.contains(&"ipv4".to_string()));
|
||||
assert!(args.contains(&"add".to_string()));
|
||||
assert!(args.contains(&"name=tsun0".to_string()));
|
||||
assert!(args.contains(&"address=10.13.37.69".to_string()));
|
||||
assert!(args.contains(&"mask=255.255.255.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v6_address_is_added_by_interface_with_a_prefix() {
|
||||
let args = address_add_args("tsun0", v6(1));
|
||||
assert!(args.contains(&"ipv6".to_string()));
|
||||
assert!(args.contains(&"interface=tsun0".to_string()));
|
||||
assert!(args.contains(&"address=fd00::1/64".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_an_address_names_no_mask() {
|
||||
let args = address_del_args("tsun0", v4(69, 24));
|
||||
assert!(args.contains(&"delete".to_string()));
|
||||
assert!(args.contains(&"address=10.13.37.69".to_string()));
|
||||
assert!(!args.iter().any(|arg| arg.starts_with("mask=")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mtu_is_set_per_family_on_the_subinterface() {
|
||||
let args = mtu_args("tsun0", "ipv4", 1280);
|
||||
assert!(args.contains(&"subinterface".to_string()));
|
||||
assert!(args.contains(&"tsun0".to_string()));
|
||||
assert!(args.contains(&"mtu=1280".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_local_addresses_are_not_ours_to_manage() {
|
||||
assert!(is_link_local(IpAddr::V6(Ipv6Addr::new(
|
||||
0xfe80, 0, 0, 0, 0, 0, 0, 1
|
||||
))));
|
||||
assert!(is_link_local(IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1))));
|
||||
assert!(!is_link_local(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 1))));
|
||||
assert!(!is_link_local(IpAddr::V6(Ipv6Addr::new(
|
||||
0xfd00, 0, 0, 0, 0, 0, 0, 1
|
||||
))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system32_is_absolute_and_ends_with_the_program() {
|
||||
let path = system32("netsh.exe");
|
||||
assert!(path.is_absolute(), "{path:?}");
|
||||
assert!(path.ends_with("netsh.exe"), "{path:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
//! Which peer a packet is for, and whether a peer may have sent it.
|
||||
//!
|
||||
//! One agent has one interface, so a packet coming off it could belong to any
|
||||
//! network the agent is in and to any protocol currently carrying traffic.
|
||||
//! Deciding which is this table's job, and it is the reason the interface
|
||||
//! belongs to the system level: the answer comes from signed state, which no
|
||||
//! protocol owns.
|
||||
//!
|
||||
//! Two questions, and they are not the same one:
|
||||
//!
|
||||
//! * **Outbound**: this destination address — whose is it? A packet for
|
||||
//! nobody is dropped rather than broadcast.
|
||||
//! * **Inbound**: this peer decrypted a packet with this source address —
|
||||
//! is that address actually its? A peer may not speak for anybody else,
|
||||
//! and the check consults the signed claim rather than anything the peer
|
||||
//! said.
|
||||
//!
|
||||
//! Nothing here is derived from a protocol's key. An address is allocated at
|
||||
//! the system level and signed by the member that holds it, so every protocol
|
||||
//! carries traffic for the same addresses and the table is the same whichever
|
||||
//! one is in use.
|
||||
|
||||
use super::broadcast::{BroadcastPolicy, BroadcastTable};
|
||||
use arc_swap::ArcSwap;
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use iroh::EndpointId;
|
||||
|
||||
use crate::identity::NetworkId;
|
||||
use crate::state::Ipv4Range;
|
||||
|
||||
/// Where an outbound packet is going.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Route {
|
||||
/// The network the destination belongs to.
|
||||
pub network: NetworkId,
|
||||
/// The member that holds the destination address.
|
||||
pub peer: EndpointId,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub local: Option<Ipv4Addr>,
|
||||
/// Every other member's address, as the signed state has it.
|
||||
pub peers: Vec<(Ipv4Addr, EndpointId)>,
|
||||
}
|
||||
|
||||
/// Why a network cannot be added to the table.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum RouteError {
|
||||
/// Two networks would answer for the same addresses.
|
||||
#[error(
|
||||
"network {network} uses {range}, which overlaps {other_range} already in use by \
|
||||
{other}. One agent has one interface, so an address can belong to only one of \
|
||||
them: give one network a different range."
|
||||
)]
|
||||
Overlap {
|
||||
/// The network being added.
|
||||
network: NetworkId,
|
||||
/// The range it wants.
|
||||
range: Ipv4Range,
|
||||
/// The network already using an overlapping range.
|
||||
other: NetworkId,
|
||||
/// That network's range.
|
||||
other_range: Ipv4Range,
|
||||
},
|
||||
}
|
||||
|
||||
/// Address ownership across every network this agent is in.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RoutingTable {
|
||||
broadcast: ArcSwap<BroadcastTable>,
|
||||
networks: RwLock<HashMap<NetworkId, NetworkRoutes>>,
|
||||
}
|
||||
|
||||
impl RoutingTable {
|
||||
/// An empty table.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn read(&self) -> std::sync::RwLockReadGuard<'_, HashMap<NetworkId, NetworkRoutes>> {
|
||||
match self.networks.read() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self) -> std::sync::RwLockWriteGuard<'_, HashMap<NetworkId, NetworkRoutes>> {
|
||||
match self.networks.write() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces what one network contributes.
|
||||
///
|
||||
/// Rejected when its range overlaps another network's. With one
|
||||
/// interface an address can belong to only one network, and guessing
|
||||
/// which would send somebody's traffic to a stranger.
|
||||
pub fn set_network(&self, network: NetworkId, routes: NetworkRoutes) -> Result<(), RouteError> {
|
||||
let mut networks = self.write();
|
||||
if let Some(range) = routes.range {
|
||||
for (other, existing) in networks.iter() {
|
||||
if *other == network {
|
||||
continue;
|
||||
}
|
||||
let Some(other_range) = existing.range else {
|
||||
continue;
|
||||
};
|
||||
if ranges_overlap(range, other_range) {
|
||||
return Err(RouteError::Overlap {
|
||||
network,
|
||||
range,
|
||||
other: *other,
|
||||
other_range,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
networks.insert(network, routes);
|
||||
self.broadcast
|
||||
.store(Arc::new(BroadcastTable::build(&networks)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forgets a network.
|
||||
pub fn remove_network(&self, network: NetworkId) {
|
||||
let mut networks = self.write();
|
||||
networks.remove(&network);
|
||||
self.broadcast
|
||||
.store(Arc::new(BroadcastTable::build(&networks)));
|
||||
}
|
||||
|
||||
/// Replaces only a network's broadcast participation, without changing addresses.
|
||||
pub fn set_broadcast(&self, network: NetworkId, policy: BroadcastPolicy) {
|
||||
let mut networks = self.write();
|
||||
if let Some(routes) = networks.get_mut(&network) {
|
||||
if routes.broadcast == policy {
|
||||
return;
|
||||
}
|
||||
routes.broadcast = policy;
|
||||
self.broadcast
|
||||
.store(Arc::new(BroadcastTable::build(&networks)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognizes limited and configured subnet broadcasts before unicast lookup.
|
||||
pub fn is_broadcast(&self, destination: Ipv4Addr) -> bool {
|
||||
self.broadcast.load().is_destination(destination)
|
||||
}
|
||||
|
||||
/// A precomputed domain-scoped recipient list; no per-packet graph search.
|
||||
pub fn broadcast_recipients(
|
||||
&self,
|
||||
source: Ipv4Addr,
|
||||
destination: Ipv4Addr,
|
||||
) -> Option<Arc<[Route]>> {
|
||||
self.broadcast.load().outgoing(source, destination)
|
||||
}
|
||||
|
||||
/// Local admission after the plugin authenticated and decrypted the sender.
|
||||
pub fn accepts_broadcast(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
destination: Ipv4Addr,
|
||||
) -> bool {
|
||||
self.broadcast.load().accepts(network, peer, destination)
|
||||
}
|
||||
|
||||
/// The peer that holds a destination address, if anybody does.
|
||||
///
|
||||
/// This agent's own address returns `None`: a packet for ourselves does
|
||||
/// not go over a tunnel, and answering with a peer would send it to one.
|
||||
pub fn route(&self, destination: Ipv4Addr) -> Option<Route> {
|
||||
let networks = self.read();
|
||||
for (network, routes) in networks.iter() {
|
||||
if routes.local == Some(destination) {
|
||||
return None;
|
||||
}
|
||||
if let Some((_, peer)) = routes
|
||||
.peers
|
||||
.iter()
|
||||
.find(|(address, _)| *address == destination)
|
||||
{
|
||||
return Some(Route {
|
||||
network: *network,
|
||||
peer: *peer,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether a peer may send from a source address.
|
||||
pub fn may_send_from(&self, network: NetworkId, peer: EndpointId, source: Ipv4Addr) -> bool {
|
||||
self.read().get(&network).is_some_and(|routes| {
|
||||
routes
|
||||
.peers
|
||||
.iter()
|
||||
.any(|(address, holder)| *address == source && *holder == peer)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether an ordinary packet terminates on this host in this network.
|
||||
/// Exported LAN subnets must extend this admission policy explicitly later.
|
||||
pub fn is_local_destination(&self, network: NetworkId, destination: Ipv4Addr) -> bool {
|
||||
self.read()
|
||||
.get(&network)
|
||||
.is_some_and(|routes| routes.local == Some(destination))
|
||||
}
|
||||
|
||||
/// Every address this agent should answer to, with its prefix length.
|
||||
pub fn local_addresses(&self) -> Vec<(Ipv4Addr, u8)> {
|
||||
let mut addresses: Vec<(Ipv4Addr, u8)> = self
|
||||
.read()
|
||||
.values()
|
||||
.filter_map(|routes| Some((routes.local?, routes.range?.prefix_len)))
|
||||
.collect();
|
||||
addresses.sort();
|
||||
addresses
|
||||
}
|
||||
|
||||
/// The one network whose address the limited broadcast route is bound to.
|
||||
///
|
||||
/// The host has a single `255.255.255.255` route per interface, and one
|
||||
/// interface carries every network, so only one overlay address can be its
|
||||
/// source. The choice is the lowest [`NetworkId`] among networks that have
|
||||
/// broadcast enabled, a range and a local address: deterministic, so two
|
||||
/// runs with the same networks pick the same one.
|
||||
pub fn broadcast_host_target(&self) -> Option<(Ipv4Addr, Ipv4Range)> {
|
||||
let networks = self.read();
|
||||
networks
|
||||
.iter()
|
||||
.filter(|(_, routes)| routes.broadcast.enabled)
|
||||
.filter_map(|(network, routes)| Some((*network, routes.local?, routes.range?)))
|
||||
.min_by_key(|(network, _, _)| *network)
|
||||
.map(|(_, local, range)| (local, range))
|
||||
}
|
||||
|
||||
/// Whether a range would collide with one another network already uses.
|
||||
///
|
||||
/// Asked before proposing a range rather than after: with one interface
|
||||
/// an agent that claimed an address it could not route would also be
|
||||
/// telling everybody else to use that range, and "the range of the
|
||||
/// lowest author wins" would spread the collision instead of containing
|
||||
/// it.
|
||||
pub fn would_overlap(&self, network: NetworkId, range: Ipv4Range) -> Option<Ipv4Range> {
|
||||
self.read().iter().find_map(|(other, existing)| {
|
||||
if *other == network {
|
||||
return None;
|
||||
}
|
||||
let other_range = existing.range?;
|
||||
ranges_overlap(range, other_range).then_some(other_range)
|
||||
})
|
||||
}
|
||||
|
||||
/// How many networks the table covers.
|
||||
pub fn len(&self) -> usize {
|
||||
self.read().len()
|
||||
}
|
||||
|
||||
/// Whether it covers none.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.read().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two ranges share any address.
|
||||
fn ranges_overlap(one: Ipv4Range, other: Ipv4Range) -> bool {
|
||||
// A range contains the other's base, or the other way round. With
|
||||
// prefix-aligned ranges that is the whole of it: two blocks either nest
|
||||
// or are disjoint.
|
||||
one.contains(other.base) || other.contains(one.base)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
|
||||
fn network(name: &str) -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new(name).unwrap(),
|
||||
&NetworkSecret::from_bytes(vec![4u8; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
|
||||
fn peer(seed: u8) -> EndpointId {
|
||||
iroh::SecretKey::from_bytes(&[seed; 32]).public()
|
||||
}
|
||||
|
||||
fn addr(last: u8) -> Ipv4Addr {
|
||||
Ipv4Addr::new(10, 13, 37, last)
|
||||
}
|
||||
|
||||
fn routes() -> NetworkRoutes {
|
||||
NetworkRoutes {
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
local: Some(addr(1)),
|
||||
peers: vec![(addr(2), peer(2)), (addr(3), peer(3))],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packet_goes_to_the_member_that_holds_its_destination() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("routing");
|
||||
table.set_network(id, routes()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
table.route(addr(2)),
|
||||
Some(Route {
|
||||
network: id,
|
||||
peer: peer(2)
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
table.route(addr(3)),
|
||||
Some(Route {
|
||||
network: id,
|
||||
peer: peer(3)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packet_for_nobody_has_nowhere_to_go() {
|
||||
// Dropped rather than sent to everybody: a tunnel is not a broadcast
|
||||
// domain, and flooding would hand one member another's traffic.
|
||||
let table = RoutingTable::new();
|
||||
table.set_network(network("routing"), routes()).unwrap();
|
||||
assert_eq!(table.route(addr(200)), None);
|
||||
assert_eq!(table.route(Ipv4Addr::new(192, 0, 2, 1)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packet_for_ourselves_is_not_sent_to_a_peer() {
|
||||
let table = RoutingTable::new();
|
||||
table.set_network(network("routing"), routes()).unwrap();
|
||||
assert_eq!(table.route(addr(1)), None, "that address is ours");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peer_may_only_send_from_the_address_it_holds() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("ownership");
|
||||
table.set_network(id, routes()).unwrap();
|
||||
|
||||
assert!(table.may_send_from(id, peer(2), addr(2)));
|
||||
// Somebody else's address, and its own network's.
|
||||
assert!(!table.may_send_from(id, peer(2), addr(3)));
|
||||
assert!(!table.may_send_from(id, peer(2), addr(1)));
|
||||
assert!(!table.may_send_from(id, peer(2), addr(99)));
|
||||
// A peer that holds nothing here.
|
||||
assert!(!table.may_send_from(id, peer(9), addr(2)));
|
||||
// And the question is per network, not global.
|
||||
assert!(!table.may_send_from(network("elsewhere"), peer(2), addr(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_networks_with_overlapping_ranges_are_refused() {
|
||||
// One interface means an address belongs to one network. Accepting
|
||||
// both and guessing would send somebody's traffic to a stranger.
|
||||
let table = RoutingTable::new();
|
||||
let first = network("first");
|
||||
let second = network("second");
|
||||
table.set_network(first, routes()).unwrap();
|
||||
|
||||
let err = table.set_network(second, routes()).unwrap_err();
|
||||
let RouteError::Overlap { other, .. } = err;
|
||||
assert_eq!(other, first);
|
||||
assert!(err.to_string().contains("different range"), "{err}");
|
||||
assert_eq!(table.len(), 1, "the overlapping network is not added");
|
||||
|
||||
// A range of its own is fine.
|
||||
table
|
||||
.set_network(
|
||||
second,
|
||||
NetworkRoutes {
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||
peers: vec![(Ipv4Addr::new(10, 99, 0, 2), peer(4))],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(table.len(), 2);
|
||||
assert_eq!(
|
||||
table.route(Ipv4Addr::new(10, 99, 0, 2)),
|
||||
Some(Route {
|
||||
network: second,
|
||||
peer: peer(4)
|
||||
})
|
||||
);
|
||||
// And the first network still answers for its own.
|
||||
assert_eq!(table.route(addr(2)).map(|route| route.network), Some(first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_broadcast_target_is_the_lowest_enabled_network_with_an_address() {
|
||||
let table = RoutingTable::new();
|
||||
assert_eq!(table.broadcast_host_target(), None);
|
||||
|
||||
let (low, high) = {
|
||||
let (a, b) = (network("first"), network("second"));
|
||||
if a < b { (a, b) } else { (b, a) }
|
||||
};
|
||||
let mk = |enabled: bool, base: [u8; 4], local: Option<Ipv4Addr>| NetworkRoutes {
|
||||
broadcast: BroadcastPolicy {
|
||||
enabled,
|
||||
..Default::default()
|
||||
},
|
||||
range: Some(
|
||||
format!("{}.{}.{}.0/24", base[0], base[1], base[2])
|
||||
.parse()
|
||||
.unwrap(),
|
||||
),
|
||||
local,
|
||||
peers: Vec::new(),
|
||||
};
|
||||
let a = Ipv4Addr::new(10, 1, 0, 1);
|
||||
let b = Ipv4Addr::new(10, 2, 0, 1);
|
||||
|
||||
// The lower id has broadcast off, so the higher one is chosen.
|
||||
table
|
||||
.set_network(low, mk(false, [10, 1, 0, 0], Some(a)))
|
||||
.unwrap();
|
||||
table
|
||||
.set_network(high, mk(true, [10, 2, 0, 0], Some(b)))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
table.broadcast_host_target(),
|
||||
Some((b, "10.2.0.0/24".parse().unwrap()))
|
||||
);
|
||||
|
||||
// Both enabled: the lowest id wins, whatever the insertion order.
|
||||
table
|
||||
.set_network(low, mk(true, [10, 1, 0, 0], Some(a)))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
table.broadcast_host_target(),
|
||||
Some((a, "10.1.0.0/24".parse().unwrap()))
|
||||
);
|
||||
|
||||
// No local address yet: it cannot be the route's source.
|
||||
table
|
||||
.set_network(low, mk(true, [10, 1, 0, 0], None))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
table.broadcast_host_target(),
|
||||
Some((b, "10.2.0.0/24".parse().unwrap()))
|
||||
);
|
||||
|
||||
table.remove_network(high);
|
||||
assert_eq!(table.broadcast_host_target(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_can_be_asked_about_before_it_is_proposed() {
|
||||
// The point of asking first: an agent that claims an address it
|
||||
// cannot route also tells everybody else to use that range.
|
||||
let table = RoutingTable::new();
|
||||
let first = network("first");
|
||||
let second = network("second");
|
||||
table.set_network(first, routes()).unwrap();
|
||||
|
||||
let mine: Ipv4Range = "10.13.37.0/24".parse().unwrap();
|
||||
assert_eq!(table.would_overlap(second, mine), Some(mine));
|
||||
// Its own range is not a collision with itself.
|
||||
assert_eq!(table.would_overlap(first, mine), None);
|
||||
assert_eq!(
|
||||
table.would_overlap(second, "10.99.0.0/16".parse().unwrap()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_range_counts_as_overlapping() {
|
||||
let table = RoutingTable::new();
|
||||
table
|
||||
.set_network(
|
||||
network("wide"),
|
||||
NetworkRoutes {
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.0.0.0/8".parse().unwrap()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// Inside the /8, in either direction.
|
||||
assert!(
|
||||
table
|
||||
.set_network(
|
||||
network("narrow"),
|
||||
NetworkRoutes {
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.13.37.0/24".parse().unwrap()),
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_network_does_not_count_as_overlapping_itself() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("refresh");
|
||||
table.set_network(id, routes()).unwrap();
|
||||
|
||||
let mut updated = routes();
|
||||
updated.peers.push((addr(4), peer(4)));
|
||||
table.set_network(id, updated).unwrap();
|
||||
assert_eq!(table.len(), 1);
|
||||
assert!(table.route(addr(4)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_a_network_takes_its_addresses_with_it() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("leaving");
|
||||
table.set_network(id, routes()).unwrap();
|
||||
table.remove_network(id);
|
||||
|
||||
assert!(table.is_empty());
|
||||
assert_eq!(table.route(addr(2)), None);
|
||||
assert!(!table.may_send_from(id, peer(2), addr(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_local_addresses_are_what_the_interface_should_carry() {
|
||||
let table = RoutingTable::new();
|
||||
table.set_network(network("one"), routes()).unwrap();
|
||||
table
|
||||
.set_network(
|
||||
network("two"),
|
||||
NetworkRoutes {
|
||||
broadcast: Default::default(),
|
||||
range: Some("10.99.0.0/16".parse().unwrap()),
|
||||
local: Some(Ipv4Addr::new(10, 99, 0, 1)),
|
||||
peers: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
table.local_addresses(),
|
||||
vec![(addr(1), 24), (Ipv4Addr::new(10, 99, 0, 1), 16)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_with_no_address_yet_contributes_nothing() {
|
||||
let table = RoutingTable::new();
|
||||
let id = network("waiting");
|
||||
table.set_network(id, NetworkRoutes::default()).unwrap();
|
||||
|
||||
assert!(table.local_addresses().is_empty());
|
||||
assert_eq!(table.route(addr(2)), None);
|
||||
assert_eq!(table.len(), 1, "the network is known, it just has nothing");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
//! The boundary to the operating system's packet interface.
|
||||
//!
|
||||
//! One agent has one of these, and it belongs to the agent rather than to
|
||||
//! any protocol: a packet arriving here is routed to whichever peer owns its
|
||||
//! destination, over whichever protocol has a link to that peer.
|
||||
//!
|
||||
//! Two implementations:
|
||||
//!
|
||||
//! * [`MemoryTun`] keeps packets in memory. It needs no privileges at all and
|
||||
//! is what the test suite uses, so the entire data plane — handshake,
|
||||
//! encryption, routing — is exercised without touching the host.
|
||||
//! * `SystemTun`, behind the `tun-device` feature, is a real TUN interface.
|
||||
//! Creating one needs `CAP_NET_ADMIN`, and it is
|
||||
//! [`provision`](super::provision) that holds that and creates it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
/// What a device should look like once created.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TunRequest {
|
||||
/// Interface name to ask for.
|
||||
pub name: String,
|
||||
/// Every overlay address this host answers to, and no others.
|
||||
///
|
||||
/// One per network the agent is in — one agent has one interface, and
|
||||
/// it carries them all. A list rather than one address because
|
||||
/// carrying only the first is carrying only the first network: the
|
||||
/// others have addresses the operating system has never heard of, and
|
||||
/// their traffic goes nowhere.
|
||||
///
|
||||
/// Allocated and signed at the system level, never derived from a
|
||||
/// protocol's key: every protocol carries traffic for the same
|
||||
/// addresses.
|
||||
pub addresses: Vec<super::Cidr>,
|
||||
/// Interface MTU.
|
||||
pub mtu: u32,
|
||||
}
|
||||
|
||||
impl TunRequest {
|
||||
/// A request carrying nothing but a name and an MTU.
|
||||
///
|
||||
/// Used where the addresses have already been applied to the host, so the
|
||||
/// device itself only needs opening.
|
||||
pub fn bare(name: impl Into<String>, mtu: u32) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
addresses: Vec::new(),
|
||||
mtu,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A packet interface.
|
||||
///
|
||||
/// `recv` yields packets the operating system wants sent; `send` delivers
|
||||
/// packets that arrived from a peer.
|
||||
pub trait TunDevice: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// The interface name the operating system actually gave us.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// The interface MTU.
|
||||
fn mtu(&self) -> u32;
|
||||
|
||||
/// The next packet the operating system wants to send, or `None` once the
|
||||
/// device is gone.
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>>;
|
||||
|
||||
/// Delivers a packet to the operating system.
|
||||
fn send<'a>(&'a self, packet: Bytes) -> BoxFuture<'a, Result<(), OverlayError>>;
|
||||
}
|
||||
|
||||
/// Creates packet interfaces.
|
||||
pub trait TunFactory: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// A short name used in diagnostics.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Whether what this factory creates exists on the host.
|
||||
///
|
||||
/// `false` for an in-memory device: it has a name and an MTU and nothing
|
||||
/// else — no link, no addresses, nothing for the operating system to
|
||||
/// route through or to attach a resolver setting to. Stated rather than
|
||||
/// guessed from the name, because a name is not evidence: an in-memory
|
||||
/// `tsun0` and a real `tsun0` belonging to another agent look identical
|
||||
/// from here, and configuring the operating system for the first would
|
||||
/// land on the second.
|
||||
fn on_host(&self) -> bool;
|
||||
|
||||
/// Creates a device.
|
||||
fn create<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>>;
|
||||
|
||||
/// Applies a changed request to an interface that already exists.
|
||||
///
|
||||
/// The overlay IPv4 address is allocated at run time, so it can change
|
||||
/// while the agent runs. A factory that manages the host applies that to
|
||||
/// the live interface, without recreating it: recreating would drop every
|
||||
/// tunnel riding on it.
|
||||
///
|
||||
/// The default does nothing, which is right for a factory that only
|
||||
/// attaches to an interface somebody else prepared.
|
||||
fn reconfigure<'a>(&'a self, _request: TunRequest) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move { Ok(()) })
|
||||
}
|
||||
|
||||
/// The host rules for this factory's interface, when it manages the host.
|
||||
///
|
||||
/// `None` for an in-memory device or an interface somebody else prepared:
|
||||
/// there is nothing of ours to route or to open in a firewall.
|
||||
fn host_rules(&self) -> Option<Arc<dyn super::hostrules::BroadcastHostRules>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Removes an interface this factory created.
|
||||
///
|
||||
/// Runs on the teardown path, so it reports rather than fails: there is
|
||||
/// nothing useful to do about a failure at that point, and an interface
|
||||
/// that is already gone is the desired outcome anyway.
|
||||
fn destroy<'a>(&'a self, _name: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {})
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-memory packet interface.
|
||||
///
|
||||
/// Nothing reaches the operating system. Packets the device "sends" can be
|
||||
/// read back with [`MemoryTun::pop_to_os`], and packets can be injected as if
|
||||
/// the operating system produced them with [`MemoryTun::push_from_os`].
|
||||
#[derive(Debug)]
|
||||
pub struct MemoryTun {
|
||||
name: String,
|
||||
mtu: u32,
|
||||
from_os_tx: tokio::sync::mpsc::UnboundedSender<Bytes>,
|
||||
from_os_rx: tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<Bytes>>,
|
||||
to_os_tx: tokio::sync::mpsc::UnboundedSender<Bytes>,
|
||||
to_os_rx: tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<Bytes>>,
|
||||
/// Set once the device is gone, so a reader stops rather than waiting
|
||||
/// for a packet that will never come.
|
||||
///
|
||||
/// A `watch` rather than a `Notify`: closing must be seen by a reader
|
||||
/// that has not started waiting yet as well as by one already waiting —
|
||||
/// a device can be removed before the loop reading it has been polled
|
||||
/// even once. The receiver is kept for the same reason: a `watch` send
|
||||
/// with nobody subscribed does nothing at all.
|
||||
closed: tokio::sync::watch::Sender<bool>,
|
||||
closed_rx: tokio::sync::watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
impl MemoryTun {
|
||||
/// Creates a device with the given name and MTU.
|
||||
pub fn new(name: impl Into<String>, mtu: u32) -> Arc<Self> {
|
||||
let (from_os_tx, from_os_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (to_os_tx, to_os_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (closed, closed_rx) = tokio::sync::watch::channel(false);
|
||||
Arc::new(Self {
|
||||
name: name.into(),
|
||||
mtu,
|
||||
from_os_tx,
|
||||
from_os_rx: tokio::sync::Mutex::new(from_os_rx),
|
||||
to_os_tx,
|
||||
to_os_rx: tokio::sync::Mutex::new(to_os_rx),
|
||||
closed,
|
||||
closed_rx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Marks the device gone, so the packet loop reading it ends.
|
||||
///
|
||||
/// A real interface reports end of stream when it is removed; this is
|
||||
/// how that happens here. Without it teardown waits out the whole grace
|
||||
/// period for a reader that had no way of knowing.
|
||||
pub fn close(&self) {
|
||||
let _ = self.closed.send(true);
|
||||
}
|
||||
|
||||
/// Injects a packet as if the operating system had produced it.
|
||||
pub fn push_from_os(&self, packet: Bytes) {
|
||||
let _ = self.from_os_tx.send(packet);
|
||||
}
|
||||
|
||||
/// Takes the next packet the device delivered to the operating system.
|
||||
pub async fn pop_to_os(&self) -> Option<Bytes> {
|
||||
self.to_os_rx.lock().await.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
impl TunDevice for MemoryTun {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn mtu(&self) -> u32 {
|
||||
self.mtu
|
||||
}
|
||||
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move {
|
||||
let mut closed = self.closed_rx.clone();
|
||||
if *closed.borrow_and_update() {
|
||||
return None;
|
||||
}
|
||||
let mut queue = self.from_os_rx.lock().await;
|
||||
tokio::select! {
|
||||
packet = queue.recv() => packet,
|
||||
_ = closed.changed() => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn send<'a>(&'a self, packet: Bytes) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move {
|
||||
let _ = self.to_os_tx.send(packet);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an address is assigned to some interface on this host.
|
||||
///
|
||||
/// Binding a UDP socket to a specific address only succeeds when the address
|
||||
/// is local, which makes this a cheap check that needs no privileges and no
|
||||
/// platform-specific code. It does not say *which* interface has it, which is
|
||||
/// enough here: the agent chose the address, so anything else holding it is a
|
||||
/// problem in its own right.
|
||||
pub fn address_is_local(address: std::net::IpAddr) -> bool {
|
||||
if std::net::UdpSocket::bind((address, 0)).is_ok() {
|
||||
return true;
|
||||
}
|
||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||
{
|
||||
if let Ok(interfaces) = std::panic::catch_unwind(netdev::get_interfaces) {
|
||||
for iface in interfaces {
|
||||
if iface.is_up() {
|
||||
match address {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
if iface.ipv4.iter().any(|ip| ip.addr() == v4) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
if iface.ipv6.iter().any(|ip| ip.addr() == v6) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Creates [`MemoryTun`] devices.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MemoryTunFactory {
|
||||
created: Arc<std::sync::Mutex<Vec<Arc<MemoryTun>>>>,
|
||||
}
|
||||
|
||||
impl MemoryTunFactory {
|
||||
/// Creates a factory.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The device created for an interface name, if any.
|
||||
pub fn device(&self, name: &str) -> Option<Arc<MemoryTun>> {
|
||||
let guard = match self.created.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
guard
|
||||
.iter()
|
||||
.find(|device| device.name() == name)
|
||||
.map(Arc::clone)
|
||||
}
|
||||
|
||||
/// Every device created so far.
|
||||
pub fn devices(&self) -> Vec<Arc<MemoryTun>> {
|
||||
let guard = match self.created.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
guard.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl TunFactory for MemoryTunFactory {
|
||||
fn name(&self) -> &str {
|
||||
"memory"
|
||||
}
|
||||
|
||||
/// Nothing here is on the host, which is the whole point of it.
|
||||
fn on_host(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Closes the device so whatever is reading it stops.
|
||||
///
|
||||
/// The device itself is kept, because a test asks what went through it
|
||||
/// after the agent that owned it has gone.
|
||||
fn destroy<'a>(&'a self, name: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
if let Some(device) = self.device(name) {
|
||||
device.close();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>> {
|
||||
Box::pin(async move {
|
||||
let device = MemoryTun::new(request.name, request.mtu);
|
||||
let mut guard = match self.created.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
guard.push(Arc::clone(&device));
|
||||
Ok(device as Arc<dyn TunDevice>)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "tun-device")]
|
||||
pub(crate) use system::open_tun;
|
||||
|
||||
#[cfg(feature = "tun-device")]
|
||||
mod system {
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::{TunDevice, TunRequest};
|
||||
use crate::BoxFuture;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
/// A real TUN interface.
|
||||
///
|
||||
/// On Linux it is created by opening `/dev/net/tun`, which needs
|
||||
/// `CAP_NET_ADMIN`; on Windows it is a Wintun adapter, which needs the
|
||||
/// process to be elevated and `wintun.dll` to be reachable. Either way
|
||||
/// [`open_tun`] is only ever called from
|
||||
/// [`provision`](super::super::provision), which holds whatever privilege
|
||||
/// the platform requires for the length of the call and no longer.
|
||||
///
|
||||
/// It is deliberately **not** made persistent, so the operating system
|
||||
/// removes the interface when this value is dropped — however the process
|
||||
/// ends. (A Wintun adapter created this way is likewise torn down when the
|
||||
/// last handle to it closes, which the reader and writer below hold.)
|
||||
pub struct SystemTun {
|
||||
name: String,
|
||||
mtu: u32,
|
||||
reader: Mutex<tokio::io::ReadHalf<tun::AsyncDevice>>,
|
||||
writer: Mutex<tokio::io::WriteHalf<tun::AsyncDevice>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SystemTun {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SystemTun")
|
||||
.field("name", &self.name)
|
||||
.field("mtu", &self.mtu)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TunDevice for SystemTun {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn mtu(&self) -> u32 {
|
||||
self.mtu
|
||||
}
|
||||
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut buffer = vec![0u8; self.mtu as usize + 64];
|
||||
let mut reader = self.reader.lock().await;
|
||||
match reader.read(&mut buffer).await {
|
||||
Ok(0) => None,
|
||||
Ok(read) => {
|
||||
buffer.truncate(read);
|
||||
Some(Bytes::from(buffer))
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "tun read failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn send<'a>(&'a self, packet: Bytes) -> BoxFuture<'a, Result<(), OverlayError>> {
|
||||
Box::pin(async move {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut writer = self.writer.lock().await;
|
||||
writer
|
||||
.write_all(&packet)
|
||||
.await
|
||||
.map_err(|err| OverlayError::Other(format!("tun write failed: {err}")))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the TUN interface by opening it.
|
||||
///
|
||||
/// Synchronous, and deliberately so: the caller holds a capability guard
|
||||
/// across this call, and such a guard must not span an `await` because
|
||||
/// Linux capabilities are per thread.
|
||||
pub(crate) fn open_tun(request: &TunRequest) -> Result<Arc<dyn TunDevice>, OverlayError> {
|
||||
// macOS mutates neither field below (it neither names the interface nor
|
||||
// sets a platform config), so `mut` is unused only there.
|
||||
#[cfg_attr(target_os = "macos", allow(unused_mut))]
|
||||
let mut config = tun::Configuration::default();
|
||||
// macOS names its tunnels `utunN` and assigns the unit number itself.
|
||||
// A name derived from the network id (`tsun…`) is not a legal utun
|
||||
// name, so there the kernel is left to choose and the real name is read
|
||||
// back below. Linux and Windows take the requested name verbatim.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
config.tun_name(&request.name);
|
||||
#[cfg(target_os = "linux")]
|
||||
config.platform_config(|platform| {
|
||||
// The crate's own root check is not the check we want: this holds
|
||||
// CAP_NET_ADMIN without being root. Whether the open succeeds is
|
||||
// the honest answer.
|
||||
platform.ensure_root_privileges(false);
|
||||
});
|
||||
// Packet information stays off, so reads and writes are raw IP
|
||||
// packets. `ip tuntap add ... mode tun` also defaults to no packet
|
||||
// information, so the flags match when attaching to one. The address
|
||||
// and MTU are left off the configuration on purpose: the provisioner
|
||||
// applies those to the interface separately, the same way on every
|
||||
// platform, so the plan stays the single source of what it carries.
|
||||
|
||||
let device = tun::create_as_async(&config).map_err(|err| open_error(&request.name, err))?;
|
||||
|
||||
// The interface name the operating system actually gave us: the
|
||||
// requested one everywhere except macOS, where the kernel chose the
|
||||
// utun unit and so the real name has to be read back off the device.
|
||||
#[cfg(target_os = "macos")]
|
||||
let name = {
|
||||
use tun::AbstractDevice;
|
||||
device.tun_name().unwrap_or_else(|_| request.name.clone())
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let name = request.name.clone();
|
||||
|
||||
let (reader, writer) = tokio::io::split(device);
|
||||
Ok(Arc::new(SystemTun {
|
||||
name,
|
||||
mtu: request.mtu,
|
||||
reader: Mutex::new(reader),
|
||||
writer: Mutex::new(writer),
|
||||
}) as Arc<dyn TunDevice>)
|
||||
}
|
||||
|
||||
fn open_error(name: &str, error: tun::Error) -> OverlayError {
|
||||
// Wintun preserves the Windows code in its I/O variant. Normalize it
|
||||
// before testing permissions; matching localized error text would
|
||||
// miss the same failure on another Windows language.
|
||||
#[cfg(target_os = "windows")]
|
||||
let error = match error {
|
||||
tun::Error::WintunError(error) => tun::Error::Io(error.into()),
|
||||
error => error,
|
||||
};
|
||||
let denied = match &error {
|
||||
tun::Error::Io(error) => {
|
||||
error.kind() == std::io::ErrorKind::PermissionDenied
|
||||
|| (cfg!(target_os = "windows")
|
||||
&& matches!(error.raw_os_error(), Some(5 | 740 | 1314)))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if denied {
|
||||
let hint = if cfg!(target_os = "windows") {
|
||||
"Open PowerShell or Command Prompt with 'Run as administrator' and \
|
||||
start `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,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."
|
||||
};
|
||||
return OverlayError::Unavailable(format!(
|
||||
"permission denied while creating the TUN interface `{name}`. \
|
||||
{hint} OS error: {error}"
|
||||
));
|
||||
}
|
||||
OverlayError::Unavailable(format!(
|
||||
"cannot create the TUN interface `{name}`: {error}. {}",
|
||||
open_hint()
|
||||
))
|
||||
}
|
||||
|
||||
/// The platform-appropriate tail of a "cannot create the interface" error:
|
||||
/// what privilege it needs and the fallback that always works.
|
||||
fn open_hint() -> &'static str {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
"Creating one needs CAP_NET_ADMIN; grant it with `setcap cap_net_admin,cap_net_bind_service+p`, \
|
||||
or run with `--no-tun` to keep the tunnels off the operating system."
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
"Creating one needs an elevated process and `wintun.dll` on the search path; \
|
||||
run as Administrator with the DLL beside the executable, or run with `--no-tun` \
|
||||
to keep the tunnels off the operating system."
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
"Creating a utun interface needs root; start the agent with `sudo` or as a root \
|
||||
LaunchDaemon, or run with `--no-tun` to keep the tunnels off the operating system."
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
"Run with `--no-tun` to keep the tunnels off the operating system."
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_tun_permission_error_explains_how_to_start_the_agent() {
|
||||
let error = tun::Error::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
|
||||
let message = open_error("tsun0", error).to_string();
|
||||
assert!(message.contains("permission denied"), "{message}");
|
||||
assert!(message.contains("tsun0"), "{message}");
|
||||
assert!(message.contains("tsunagi up"), "{message}");
|
||||
#[cfg(target_os = "windows")]
|
||||
assert!(message.contains("Run as administrator"), "{message}");
|
||||
#[cfg(target_os = "linux")]
|
||||
assert!(message.contains("CAP_NET_ADMIN"), "{message}");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn wintun_access_and_elevation_errors_get_the_permission_hint() {
|
||||
for code in [5, 740, 1314] {
|
||||
let error = tun::Error::WintunError(std::io::Error::from_raw_os_error(code).into());
|
||||
let message = open_error("tsun0", error).to_string();
|
||||
assert!(message.contains("permission denied"), "{message}");
|
||||
assert!(message.contains("Run as administrator"), "{message}");
|
||||
assert!(message.contains(&format!("os error {code}")), "{message}");
|
||||
assert!(!message.contains("wintun.dll"), "{message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_tun_errors_keep_their_cause_without_claiming_access_was_denied() {
|
||||
let message = open_error("tsun0", tun::Error::InvalidName).to_string();
|
||||
assert!(message.contains("invalid device tun name"), "{message}");
|
||||
assert!(!message.contains("permission denied"), "{message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user