Compare commits

...
23 Commits
Author SHA1 Message Date
Aleksandr Bogomiakov 2166ba4800 Separate internal WireGuard encryption from data carriers
AWG-only policy also selected a competing end-to-end plugin, so compatible
external links could still have no compatible encrypted data channel. Carrier
changes replaced tunnel state and status selected counters from different
engines. Use one internal WireGuard plugin and a separate DataCarrier contract
for external capabilities/lifecycle. Keep the logical link, keys, handshake,
interface and counter epoch across policy, fallback and direct/relay changes.
Control/discovery transport selection and membership remain independent.

Share the bounded per-neighbour UDP WireGuard engine between ordinary WG and
Amnezia masking. Encrypt the relay envelope inside the outer hop channel with
fresh pair keys/PSK. Remove duplicate WG key/device code and the TCP/TLS inner
cipher. TLS carries opaque internal WireGuard frames. Refuse missing native
transports instead of letting default iroh impersonate their protocol names.
Probe the actual authenticated carrier, independently of the shared IP tunnel;
failed local probe submissions count as loss too.

Apply the vendored iroh interface exclusion to actual direct candidates, not
just public discovery records. Exclude overlay subnets from introductions and
propagate the actual owned interface name, including kernel-assigned utun names,
to every carrier. Production logs showed paths through the overlay itself.
This removes a recursive transport path; it does not establish the historical
cause of the reported 100 GB absolute counter. Exit-node traffic is also real
peer IP traffic even when local applications are idle.

Hide inner cipher choices/handshake clutter in CLI and tray. Report external
routes, readiness and actionable policy/version failures. Count original IP
packets only when their ciphertext is accepted by the logical transport,
including queued packets' exact unpadded lengths. Exclude physical probes and
keepalives. Export counter epochs; sum per-peer rate deltas and discard stale
histories so joins/restarts cannot turn old totals into fresh traffic. Status
also prints exact IP byte totals for comparison with the tray.

Coordinated upgrade: data ALPN 6, internal wireguard-e2e capability v4,
wg/awg v2, QUIC carrier v1, TLS v2, local IPC v23. Control ALPN 8, device/network
identity derivation, signed records and saved network choices stay unchanged.
Keep the historical wg-quic key store; transactionally import missing keys from
wg then awg without replacing canonical keys. Forget removes legacy copies too,
on the blocking pool, to prevent resurrection after restart. Preserve historic
cipher options as shared settings, rejecting contradictory values.

Validation on Linux in the Nix development shell:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets (562 passed, 3 ignored)
The inherited service socket/secret/group environment overrides were removed
for isolated CLI process tests. A new carrier test initially raced the first
peer announcement; its deadline polling now waits for a peer row explicitly.
Real loopback endpoints, membership, SQLite, WG/AWG/TLS encryption and memory
TUNs cover mixed-carrier relaying and one smoltcp TCP connection surviving
relay/direct/policy changes and a dropped transmission. Counter tests check
exact plaintext lengths, refused sends, epochs, joins and stale histories.
Public DHT, privileged kernel and manual benchmark tests remain ignored;
macOS/Windows native TUNs, desktop GUI and WAN/DPI behavior were not run.
2026-10-22 01:07:52 +01:00
Aleksandr Bogomiakov 8a6216bccb Expose tray runtime libraries in the Nix development shell
The development shell inherited build dependencies but Cargo-built tray
binaries lack the installed package's wrapper. AppIndicator was present in
the store yet its dlopen lookup failed because the runtime library path was
missing. The same problem affected dynamically loaded graphics libraries.

Expose the package's existing trayRuntimeLibs through passthru and reuse that
list in mkShell packages and its LD_LIBRARY_PATH hook. Include the existing
NixOS OpenGL driver path and preserve any caller-supplied library paths.
The development shell and installed tray now share one dependency list.
Document direct execution of Cargo-built tray binaries inside nix develop.
No agent, protocol, package version or system configuration changes.

Validation on Linux:
- Reproduced the missing AppIndicator dlopen failure before the change.
- Loaded libayatana-appindicator3.so.1, libxkbcommon-x11.so.0, libEGL.so.1,
  libGL.so.1 and libwayland-client.so.0 inside the updated shell.
- Verified AppIndicator still loads with an inherited LD_LIBRARY_PATH and
  that the existing value is preserved. The first probe used an older host
  Python with an incompatible glibc; probes using Python from the pinned
  nixpkgs input passed.
- Evaluated the x86_64-linux CLI package and shared runtime dependency list,
  and the aarch64-linux development shell; no aarch64 execution claimed.
- nixfmt --check flake.nix nix/package.nix
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets: 566 passed, 3 ignored
Cargo checks ran through nix develop, with TSUNAGI_CONTROL_SOCKET unset for
Clippy and tests. Existing agent and tray processes were not restarted.
2026-10-21 17:05:25 +01:00
Aleksandr Bogomiakov 2e7e238459 Improve tray VIP monitoring and table layout
The VIP editor put routing and traffic observations in a dense text block,
while nested layouts in the devices grid could let text escape its row.
Separate the VIP overview from configuration and draw both live tables with
explicit cell bounds, font-aware row heights, clipping and horizontal overflow.
Measure columns before painting and keep widths stable as live values change.

The overview reuses the existing traffic sparklines for service totals and
individual listener backends, with TX/RX rates, health, RTT, open flows and
counters. Retain bounded histories by service/listener/backend identity and
release them when their resources disappear. Recreated listeners start fresh.

Use egui 0.31 Scene and Painter for a pannable, zoomable routing diagram with
Fit, clickable backend details and linked table selection. Highlight recent
counter growth separately from the backend selected for new connections;
idle bindings alone do not imply traffic, and stale activity expires. Draw
legend markers directly rather than depending on optional font glyphs.
The overview only reads saved observations; edits and deletion still require
Save. No dependencies, wire formats, IPC or persistent schemas change.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets: 566 passed, 3 ignored
- cargo build --locked -p tsunagi-tray
The inherited TSUNAGI_CONTROL_SOCKET was unset for Clippy and tests.
Seven new GUI tests cover enlarged fonts, mixed online/offline rows, long
names, large counters, narrow/wide layouts, scene clicks, traffic selection,
stale samples, counter resets, listener recreation and history cleanup.
The first enlarged-font regression exposed an override-font height mismatch;
row sizing now resolves the actual selected font and the regression passes.
A disposable fixture preview rendered successfully with software OpenGL on
an isolated Xvfb display. Initial Xvfb attempts needed explicit xkbcommon and
OpenGL runtime paths in the Nix environment. The preview never contacted an
agent; its temporary source was removed. macOS, Windows, Wayland and live host
TUN traffic were not exercised by this UI change.
2026-10-21 16:57:27 +01:00
Aleksandr Bogomiakov 23af1a2d9d Add shared TCP and UDP virtual services
Provide named VIPs with independent protocol/port listeners and node/port
backends so applications can use one DNS endpoint without a separate load
balancer. Every authenticated member may create, edit or delete any service.
Client agents select the eligible backend with the lowest measured encrypted
data-plane RTT, including relayed paths, and pin existing flows. UDP recovers
on another backend after failure; established TCP is never replayed or moved.
An unanswered empty TCP SYN may retry another backend before establishment.

Keep signed multi-writer field registers in state/vip.rs. Independent backend
edits merge, same-field conflicts use Lamport/author/canonical ordering, and
listener generations prevent old backend entries returning on port recreation.
Permanent service tombstones protect replay while allowing obsolete fields to
be compacted. Active-service and deletion-history limits are separate. Commit
complete snapshots and the author's durable counter atomically before local
routing changes or publication. Restore counters from newer genuine author
updates learned from replicas after an old backup is restored.

Implement bounded client-side address/port NAT at overlay ingress and reverse
translation only after authenticated source/destination admission. Reserve OS
source ports to avoid direct-flow collisions and quarantine retired ports for
late replies. Handle a backend on the client itself, scoped fragment assembly,
checksums, re-fragmentation and quoted ICMP/PMTU headers. Plugins and encrypted
transit retain their existing contracts. Data-path probes measure RTT; bounded
fair TCP connect checks are enabled by default and configurable per listener.
Arbitrary UDP application readiness remains unknown; path checks and explicit
port errors drive eligibility without protocol-specific probe payloads.

Expose the shared draft API through local IPC, simple vip/listener/backend CLI
commands, JSON, editor-based atomic updates and watch output. Add a VIP button
on each network tile and a separate tray editor. Edits and removals stay local
until Save; stale revisions retain the draft, and closing a dirty window offers
explicit discard. Report local routing choices, health reasons, RTT age, active
and draining bindings, IP counters, rates, drops and UDP failovers. Extend DNS
with service names; keep numeric addresses secondary and preserve existing
bindings while VIP addresses or listeners change.

This changes control wire ALPN/version to tsunagi/ctrl/8 and local IPC to 22;
all members and local clients must upgrade together. Data ALPN remains 5.
SQLite schema 10 migrates existing mandatory state without changing identity,
secrets, network ids, derivation labels, handshake encoding or node records.
Crate versions and release tags are unchanged. No installed agent or tray was
replaced. Commands, limits and operational semantics are in docs/vip.md.

Checks on Linux in nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets: 559 passed, 3 ignored
- git diff --cached --check

The full suite includes real encrypted TCP/UDP VIP traffic, backend-stop UDP
failover, real loopback TCP check recovery, source admission, signed merges,
stale/deleted state, SQLite migration and rollback, restart/author recovery,
network isolation, actual CLI/DNS processes, and headless text-entry/Save-button
interaction. Discovery and memory TUNs are the existing test substitutions;
authentication, transport, encryption and SQLite remain real.

Initial checks outside Nix failed for missing GTK pkg-config metadata, and an
initial CLI test inherited TSUNAGI_CONTROL_SOCKET. Final checks use the Nix
environment with that variable unset so tests address their temporary sockets.
Root/internet-dependent tests remain ignored. No live host TUN, native desktop
session, macOS or Windows validation was performed.
2026-10-21 15:40:45 +01:00
Aleksandr Bogomiakov 94318e7c16 Keep tray device metrics on one line
The device table sized handshake/RTT and packet columns from fixed samples.
Its own header exceeded the handshake sample, and six-digit counters wrapped
at the slash, making device and total rows taller and misaligning the plots.
Measure actual headers, displayed values and totals before drawing, retain
the widest values across frames, and disable wrapping inside table cells.
When a column grows, request a second egui layout pass so the grid's cached
positions settle before larger text is painted over the next column.
Keep the plot as the stretch column and horizontal scrolling for narrow
windows. This changes only tray presentation.

A headless rendering regression checks the actual text shapes for the
header, handshake age/control RTT, per-device packet counts and totals as
counts grow past five digits, reach billions, shrink again, and the table
gets a narrower container. It checks one-line text and non-overlap with the
next column; it failed on the original wrapped header. Existing resize and
long-relay tests remain in place.

Checks on Linux in nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets, with inherited
  TSUNAGI_CONTROL_SOCKET unset so tests use their temporary sockets
- git diff --cached --check

The suite's root/internet-dependent tests remain ignored. No native GUI
session or checks on macOS/Windows were run; rendering was checked through
egui's headless output. No installed tray binary was replaced.
2026-10-14 23:37:38 +01:00
Aleksandr Bogomiakov 2b0c938487 Bound iroh path retries to prevent multi-connection OOM
A long-running rc.33 daemon reached OOM with roughly 22 GiB resident and
24 GiB swapped. Live inspection found 40 GiB and 10/20 GiB anonymous
container mappings; the active worker stack and tracing metadata identified
iroh's scheduled path-open retry loop. In iroh 1.2.0 every failed connection
requeues the same path, while each retry visits every connection again.
Separate control and data connections multiply the queue geometrically.

Vendor the published iroh 1.2.0 source and select it with a workspace Cargo
patch. Deduplicate retry admission and cap distinct pending paths at 64,
evicting the oldest before inserting a new candidate. Keep the current and
next retry batches bounded without combining control/data connections or
changing wire protocols, identity derivation, authentication or state.
Document source provenance, the live evidence and removal conditions.
Upstream PR 4551 merged on October 6, but inspection of the official 1.3.0
crate still shows the unconditional enqueue, so that upgrade alone is not
a fix. No version bump, release tag or installed-service change.

The default workspace suite includes the exact vendored admission module.
Three regressions failed with the original enqueue and pass with the fix:
repeated failures across connections, duplicate admission at capacity, and
10,000 distinct candidates with bounded length/capacity and FIFO eviction.
Transport integration tests continue using real endpoints and encryption.

Checks on Linux in nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets, with only the inherited
  TSUNAGI_CONTROL_SOCKET removed from the test environment
- git diff --cached --check

Initial full-suite runs failed six DNS readiness checks because the inherited
system socket path overrode their temporary state directories. Clearing that
environment variable resolves those failures without changing application code.
Root/network-dependent ignored tests and other operating systems were not run.
2026-10-14 23:28:53 +01:00
Ultradesu 4e235cbac6 Keep device data paths on one line
The tray used a fixed sample for the data-path column, so longer relay names wrapped and made device rows uneven. Measure each rendered path and retain the widest value, then render path labels without wrapping. Replace direct/via words with embedded Phosphor route icons while preserving transport, relay name, hop count and explanatory tooltips. The app already ships the font; keep its setup in one function and verify registration alongside a long-relay-width regression test. No wire or stored-state changes. Checks: cargo fmt --all -- --check; cargo clippy --locked --workspace --all-targets -- -D warnings; cargo test --locked --workspace --all-targets.
2026-10-14 13:50:05 +01:00
Aleksandr Bogomiakov 2631eb6975 Protect Linux exit-node connections and tidy the device table
Using an exit node could destroy direct peer connections: the agent's UID
bypass protected outbound routing, but reverse-path checks look up replies
without that UID and selected the overlay default. A real-host A/B check
confirmed that relaxing only strict reverse-path filtering restored direct
connections and exit HTTP traffic; the diagnostic host changes were removed.

Keep strict filtering and protect the Linux underlay with a dedicated masked
mark bit, 0x40000000. Mark the dedicated service UID in OUTPUT, save only that
bit in conntrack and restore it before PREROUTING reverse-path checks. Install
IPv4/IPv6 mark and UID main-table bypass rules before Tailscale's priorities;
leave application-specific and exit-default routing after them. Preserve
foreign mark bits, main-table routes and foreign firewall/policy rules.

Protect the first inbound TCP/UDP packet using bounded, locally owned socket
addresses/ports from /proc and an actual socket match. Refresh socket protection
before sending private transport negotiations and on discovery ticks, without
firewall writes for an unchanged socket set. Remote announcements never become
OS arguments. A failed refresh leaves active exit routing in place. Install
protection before routes, remove routes before protection, roll back partial
activation, and remove only exact interface tags, including legacy UID rules.

Reject strict kernel rp_filter without src_valid_mark with an actionable error;
never weaken filtering or change host sysctls. Document mark-aware Netfilter
requirements and provide the trusted ip6tables path in the NixOS module.
Other platforms retain their existing exit implementation. Peer wire formats,
identity derivation, local IPC and persistent storage are unchanged.

Make the tray devices grid fill and shrink with the window, constrain the first
cell and give remaining width to traffic plots. Keep displayed end-to-end
protocol and its short encryption tooltip consistent, independent of carrier
protocol. Move handshake/session age into its own column beside the existing
iroh control RTT. Use stable per-device hover identities and concise tooltips;
missing protocols/measurements remain explicit rather than invented.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets: 529 passed, 3 ignored;
  inherited TSUNAGI_CONTROL_SOCKET and TSUNAGI_CONTROL_GROUP were unset
- explicit privileged kernel regression: 1 passed, using disposable namespaces,
  real TCP/UDP IPv4/IPv6, strict kernel and Netfilter RPF, first inbound packets,
  dynamic UDP binding, application exit routing/IPv6 blocking, repeated apply
  and exact cleanup with foreign Tailscale-priority rules retained
- headless egui grid/scroll-area rendering at multiple sizes and control RTT,
  mark masks/order, passive socket parsing, partial failure and cleanup tests
- git diff --check

Earlier iterations caught rtnetlink family API and nonexhaustive initializer
errors, Clippy warnings, egui's implicit minimum column width, and kernel-test
fixture issues (wildcard echo source, blocked IPv6 neighbour discovery and a
closed child stdout pipe). Corrected before final checks; the stdout-only test
fix was checked with fmt, Clippy and the explicit kernel regression afterward.
Native tray rendering and macOS/Windows checks were not run. The running host
agent was not replaced or restarted. Requested release tag: v0.1.0-rc.32.
2026-10-14 10:40:30 +01:00
Aleksandr Bogomiakov 90ef5cefec Retire offline node claims with equal network-wide cleanup rights
Offline devices reserved names and IPv4 addresses indefinitely, leaving stale
members in the device history. Give every authenticated network participant
the same right to retire another device's observed signed claim without an
owner, quorum, permanent identity ban or secret change.

Add a separately signed retirement containing the target's original signed
claim as proof. Verify both signatures and network scopes, retain the strongest
proved cutoff per target with deterministic ties, and suppress claim versions
at or below it in membership, hostname ownership and IPv4 allocation. Keep the
old signed records and range agreement, and retain markers after a newer claim
returns so old replicas cannot resurrect obsolete reservations. A returning
device signs a newer claim, allocates against current holders and resets its
previous hostname priority rather than taking a reused name with an old time.

Commit local retirement and the requester's author counter atomically before
announcing it; receivers persist proofs before applying them. Bound history to
1024 targets by default, reject additional targets without evicting protection,
and treat corrupt or over-limit mandatory storage as an explicit error. Repeat
bounded marker batches with a per-session rotating cursor so queue pressure
cannot indefinitely starve later markers. Exclude retired claims and cached
hints from history-derived discovery without preventing a fresh authenticated
device from returning.

Expose Agent::forget_node through the shared local control API. CLI command
tsng network forget-node accepts exact network names, full ids or unique id
prefixes, and exact node hostnames, ids, prefixes or overlay IPv4 addresses.
Ambiguous selections change nothing. Recheck the observed version and refuse
self, online nodes, the selected exit node and absent or changed claims.
Add Forget with an identity confirmation to offline tray device rows. Widen
Show devices to 1440x640 with an 800x400 minimum to fit its contents.

Control ALPN/version advances to tsunagi/ctrl/7 and local IPC to 21; upgrade
all peers and restart the agent with matching CLI/tray clients. Data ALPN 5,
network identity derivation, handshake encoding and existing record signing
bytes remain unchanged. SQLite schema 9 adds retirement storage while retaining
identity, secrets, settings and signed claims. Document behavior and limits.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets: 519 passed, 2 existing ignored
  tests; inherited TSUNAGI_CONTROL_SOCKET and TSUNAGI_CONTROL_GROUP were unset
- git diff --check and the built CLI forget-node help

Tests use real iroh authentication and SQLite. Cover restart, stale-replica
recovery, equal rights and network isolation, hostname reuse and return, twelve
marker batches through a two-message queue, malformed signatures and proofs,
invented future cutoffs, deterministic merge, capacity, migration and injected
counter-write rollback, selectors and cleanup through two real CLI processes.
Earlier iterations caught a private test API, a collapsible tray conditional
and a crash-based CLI test waiting for QUIC's idle timeout; corrected before
the final successful suite. Manual tray rendering, macOS/Windows, privileged
TUNs and public-network traversal were not run. The requested RC tag is
v0.1.0-rc.31; no push was requested.
2026-10-12 18:04:29 +01:00
Aleksandr Bogomiakov 13bdcaf98d Render protocol priority buttons with Phosphor icons
The tray protocol selection used ordinary Unicode up/down arrows for its
priority buttons. The default UI fonts lack these glyphs, so both buttons
rendered missing-glyph boxes. Use the regular Phosphor ARROW_UP and ARROW_DOWN
icons already installed in the application's proportional font fallback.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets, with inherited control socket
  and group overrides removed: 509 passed, 2 existing ignored tests

No new tests were added for the two glyph substitutions. Manual tray rendering
and macOS/Windows checks were not performed. No release tag was requested for
this fix.
2026-10-12 17:19:53 +01:00
Aleksandr Bogomiakov d941cdac7a Change network protocol selections at runtime
Protocol selection previously belonged to tsng up and therefore to the
service command line. A blocked or degraded carrier could only be removed
by editing the service and restarting the whole agent. Make the ordered
selection a local per-network setting instead.

Add Agent::set_protocols and a local control request shared by CLI and tray.
Keep tsng protocols without arguments as the protocol catalog; naming a
network shows its selection and supplying a comma-separated list replaces
it. Accept exact names, full ids and unique prefixes using shared selector
rules; ambiguous selectors and invalid lists change nothing. An explicit
none selects control-only operation. Offline edits take the directory lock
and apply on the next start; live edits never write behind the daemon.

Register every built-in plugin and transport in the CLI. Treat up --protocol
as ordered initial defaults for networks without an override, allowing a
protocol absent from the unit to be enabled later. Retain the targeting of
unqualified startup options and allow explicitly scoped options to configure
initially disabled protocols. Persist NULL for inherited preferences and an
empty selection distinctly in SQLite schema 8; migration preserves existing
identities, secrets, signed records and counters.

Serialize changes with network lifecycle operations and commit preferences
before applying or announcing them. Retire only the affected network's data
links and E2E channels, cancel pending opens, and reject obsolete completion
results by attempt id. Clear peer data channels when authenticated protocol
capabilities change while retaining control sessions and topology. Replay
allocations and peer identities after asynchronously prepared plugins become
ready. A control-only start defers creation of the agent's single interface
until data is enabled; plugins receive a sink that can use it later. Automatic
carrier fallback continues to preserve E2E state; explicit policy changes
renegotiate it. Identity derivation and peer wire formats are unchanged.

Add a collapsible protocols row to each tray network tile, with a catalog
provided by the daemon, descriptions, immediately applied checkboxes and
preference-order buttons. Keep editing disabled until the worker refreshes
status so a second click cannot overwrite an edit using a stale snapshot.
Local IPC wire version is 20 for the new request and report fields; upgrade
and restart agent, CLI and tray together. Crate release versions remain unchanged; the requested RC tag is v0.1.0-rc.30.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets: 509 passed, 2 existing ignored
  tests, with inherited TSUNAGI_CONTROL_SOCKET and TSUNAGI_CONTROL_GROUP removed

Tests use real iroh membership, SQLite, UDP, AWG and WireGuard encryption,
QUIC carriers and memory TUNs. Cover either endpoint-id role, full-size packet
delivery after switching, an unaffected second active network, saved selection
after restart, empty/invalid selections, first enablement without restart,
rapid changes racing old opens, live and offline CLI changes, startup-absent
protocols and preservation of signed state through migration. Earlier compile
and focused-test iterations caught initializer mistakes, a short test secret,
asynchronous plugin allocation readiness, and an inherited system socket
path; corrected before the successful final checks. Plain cargo was unavailable
in the host shell, so validation used the Nix toolchain. Manual tray interaction,
macOS/Windows, privileged host TUNs and public-network traversal were not run.
2026-10-12 16:19:18 +01:00
Aleksandr Bogomiakov e701986a39 Add ephemeral pairwise Amnezia transport and runtime fallback
Plain WG and QUIC can be blocked or severely degraded while the iroh control
connection still works. Add an awg carrier with one independent UDP socket,
WireGuard engine and randomized masking profile per network/peer attempt.
The smaller endpoint id negotiates fresh outer keys, PSK, header protection,
header ranges, padding and signature templates through private authenticated
control messages. Restart or fallback reentry generates a new profile;
ordinary rekeying preserves the current profile. No pair parameters enter
signed state, capabilities, persistent storage or public message events.
Private payloads and serialized control buffers redact and zeroize themselves.

Adapt the bounded packet masking engine from RunaRoo/AmneziaWG-RS at commit
51579a7d6e8882d1d8d6d00822f09e18a9423b73, retaining its Apache license and
provenance. Declare WTFPL AND Apache-2.0 in package metadata to cover both
new glue and the adapted source. Reuse boringtun instead of upstream's Node/global executors and
interface machinery. This is Tsunagi AWG wire version 1, with standard WG
headers restored before authentication; external Amnezia server compatibility
is not implemented. DNS/SIP signature generators vary fields instead of
replaying captures. Each attempt closes independently, bounds allocation,
queues, candidates and fragment assemblies, and supports the full 64 KiB
carrier contract using UDP packets at most 1200 bytes. Yield between fragment
batches so large messages do not starve local socket readers.

Keep carrier encryption separate from end-to-end tunnels so multihop relays
still forward opaque ciphertext. The system owns the sole interface, signed
addresses and admission. Plugins receive neither network secrets nor paths.
Expose a bounded opaque control sender at the transport boundary; core control
messages remain outside protocol crates. Optional STUN on the pair's actual
socket adds IPv4 public candidates in CLI direct/relay mode, with a finite
query deadline and an off/alternate-server option. Local mode remains offline.
Symmetric NAT, IPv6-only paths and arbitrary UDP blocking can still require
fallback; no AWG-over-QUIC or hidden AWG relay is introduced.

Default carrier order is wg, awg, wg-quic, tcp-tls. Advance the fallback cycle
after setup failures and sustained runtime loss, even after earlier cooldowns
expire. Authenticated delivery probes traverse the actual encrypted tunnel,
fit the interface MTU and never enter the TUN. Retire after a 20-second warmup
and at least 60% loss over at least 15 completed recent observations. Preserve
logical tunnels and control sessions while explicitly closing physical links,
including TCP reader/writer tasks. Retain the newest private probe settings
across independently queued control negotiation and inbound installation.
CLI/tray reports keep end-to-end protocol and actual carrier separate.

Control ALPN/version becomes tsunagi/ctrl/6 for the new private message;
upgrade all members together. Data ALPN/envelopes remain version 5. Network
identity derivation, transcript encoding, device identities, signed records,
saved network configuration and release versions are unchanged.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets, with the inherited
  TSUNAGI_CONTROL_SOCKET override removed: 504 passed, 2 pre-existing ignored

Tests use real iroh membership, SQLite, UDP sockets and WireGuard encryption
with memory TUNs. Cover three independent pairs, full-size IP and carrier
frames, restart freshness/socket release, 80% carrier datagram loss, fallback
to real iroh, fresh retries with working delivery checks, unequal small MTUs,
malformed negotiations/profiles, role and resource bounds, probe forgery and
STUN parsing. Earlier compile/lint iterations and focused tests caught RNG
API/initializer mistakes, a retry assertion racing path installation, and a
large-fragment burst overflowing loopback buffers; corrected before these
successful checks. Normalized upstream license line endings for diff checks.
Public NAT/STUN connectivity, actual DPI effectiveness, WAN performance,
per-pair memory measurement, macOS/Windows, privileged TUN operations and
manual tray interaction were not exercised.
2026-10-11 13:52:10 +01:00
Aleksandr Bogomiakov ed28e86748 Derive default network ranges and switch overlapping networks
Every first network previously proposed 10.13.37.0/24. Independently created
networks therefore collided when a device joined more than one of them.
Use the existing stable NetworkId-derived /24 in 10/8 for every new network,
independent of activation order or peer availability. Legacy and explicit
signed ranges outrank the derived default; remaining range disagreements
still use author-id ordering. The derivation, identity labels, handshake and
signed record encoding stay unchanged. There is no creator, timestamp or vote.

Reserve ranges before starting network actors and serialise start, stop and
switching through the agent lifecycle lock. Refuse equal or nested overlaps
without changing a network's subnet locally. A late authenticated range that
collides suspends only its network before updating plugins or the interface.
Coalesced watch notifications let the agent clean up outside the network actor
and reconsider suspended networks when ranges change.

An explicit switch stops every blocker before activating the chosen network.
Suspended networks retain their startup request and resume whenever their
ranges become free, including switching from a wide range to a narrow one.
A manual stop cancels that request even for an already suspended network.
Persist preference order and enabling together in an SQLite transaction so
the user's selection survives restart; no database schema migration is needed.

CLI status and network listing show blocker names, ids, ranges and the start
command. Network start switches explicitly and reports suspended networks;
joining an overlapping network reports it as configured but inactive. The
tray adds conflict details, an amber icon, Switch to this network and Keep
stopped actions. Local IPC wire version is 19 for the extended reports, so
restart the agent and tray together. Release versions and peer wire formats
are unchanged. Update the user documentation and compatibility tests.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets with TSUNAGI_CONTROL_SOCKET
  removed from the test environment: 486 passed, 2 pre-existing ignored tests
- final focused range_conflicts integration suite after making discovery
  fixtures deterministic: 6 passed

Earlier validation attempts caught outdated diagnostic assertions, a new
test helper scope error, and the inherited system control socket override.
Those were corrected before the successful runs. Plain cargo was unavailable
in the host shell, so validation used the Nix development toolchain. macOS,
Windows, privileged host TUN operations and manual tray interaction were not
exercised.
2026-10-11 11:58:27 +01:00
Aleksandr Bogomiakov 781e359775 Make tunnel and transport layers explicit in status
The tray called the end-to-end tunnel proto and the current route link, then put the tunnel handshake age beside the route. A valid wg tunnel over a tcp-tls transport consequently looked like contradictory state. The terminal status used similarly terse wording.\n\nThe tray now separates end-to-end from data path, renders direct paths as direct over TRANSPORT, keeps handshake or uptime beside the tunnel, and explains both layers in hover text. Terminal status uses the same end-to-end tunnel, data path, transport detail, and control path vocabulary. Routing, protocol selection, IPC, and wire behavior are unchanged.\n\nPresentation tests cover wg over tcp-tls, tcp-tls uptime, relayed and missing paths, a missing handshake, and offline devices. The WireGuard broadcast checksum test uses the current slice-array iterator spelling required by Clippy; its behavior is unchanged.\n\nChecks: cargo fmt --all -- --check; cargo clippy --locked --workspace --all-targets -- -D warnings; cargo test --locked --workspace --all-targets with TSUNAGI_CONTROL_SOCKET removed from the test environment so integration agents use their temporary control sockets. All passed on Linux.
2026-10-08 16:03:20 +01:00
Aleksandr Bogomiakov d5e02ec256 fixed gitignore 2026-10-08 15:35:38 +01:00
Aleksandr BogomiakovandClaude 97377e3703 Relay across any mix of links, one online answer for every client, first claim owns a hostname
A tunnel no longer depends on the kind of link under it. The relay hub keeps
one forwarding table per network instead of one per protocol, the envelope
names the end-to-end protocol, and a neighbour reached over several kinds of
transport is sent to over the best open one. A member in the middle needs no
protocol in common with either end. Which protocol a pair runs is settled by
the lower endpoint id's announced order, so both ends agree without asking.
A direct link that comes up later is shorter than the path through others, so
traffic moves to it with the same logical link.

The agent now answers "is it online" once: a live control session or any data
path. Members and peers report how they are reached (direct over a named
transport, relayed through a named member, or no data path yet), and `tsng
status` and the tray both read that instead of a session list and a WireGuard
handshake respectively.

A hostname belongs to whoever claimed it first. A claim carries the time its
author first claimed the name, signed with the record, and replicas order by
(time, id) so every member names the same holder. A later claimant keeps
working by address, is not found by the name, and is told so in its status,
`tsng status`, the tray and a HostnameTaken event.

Wire change: control and data ALPN 5, control PROTOCOL_VERSION 5, envelope
version 2, signing domain v3, state schema 7, local control protocol 18.
Upgrade every member together.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-05 11:39:33 +01:00
Aleksandr BogomiakovandClaude c184ef0914 Rewrite README as a short project front page; add a Nix flake
The README is now a landing page: the idea (a mesh VPN with no server, joined
with a network name and a secret), install and platform tables, exit nodes,
and a short build section. Implementation detail stays in docs/.

The flake builds tsng and the tray from source and ships a NixOS module
(services.tsunagi). Dist install notes now show the real `join -n -s` syntax,
and two links that pointed into the old README are redirected.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 17:47:44 +01:00
Aleksandr BogomiakovandClaude e6ec62bcba Windows installer: find the Wintun licence at the archive root
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 14:38:16 +01:00
UltradesuandClaude Sonnet 5.5 21328bf818 Windows installer: agent service, Wintun, tray and PATH in one setup program
Running on Windows needed wintun.dll by hand, an elevated console and a
manually started agent; without the DLL the agent silently ran with no
interface (and so no exit node or routing at all).

`tsng service` (hidden) runs the same `up` under the Windows service manager:
src/winservice.rs registers with the SCM through the windows-service crate,
reports Running/StopPending/Stopped, and maps a stop request onto the existing
shutdown path (stop_signal). The only unsafe is the extern "system" entry point
that define_windows_service! expands to, allowed in that module alone. A
service has no console, so it logs to %ProgramData%\Tsunagi\logs\tsng.log
(one older file kept past 8 MiB). Its state defaults to
%ProgramData%\Tsunagi\{data,cache} and its control socket to the system-wide
default every client already uses, so an unelevated `tsng status` and the tray
find it.

dist/windows/tsunagi.iss (Inno Setup) installs tsng.exe, the tray and Wintun
0.14.1 (pinned by SHA-256 in CI, licence text shipped), registers the
"Tsunagi" service as LocalSystem with auto start and restart-on-failure, locks
the data/cache/log directories to SYSTEM and Administrators by SID, and by
tasks adds tsng to the PATH, an inbound firewall rule for the agent and a tray
autostart. It stops the service, the tray and any hand-started agent before
replacing files; uninstall removes the service, rule and PATH entry and asks
before deleting the data (never on a silent uninstall). The release workflows
(GitHub and Gitea) build it on the Windows job and publish
tsunagi-setup-<version>-x86_64.exe.

.cargo/config.toml links the MSVC C runtime statically so nothing needs a
Visual C++ redistributable on a clean machine.

Compatibility: an agent run by hand keeps its data in the user profile; the
service has its own, so networks must be joined again once.

Checks: cargo fmt --check, clippy -D warnings, cargo test --workspace passed on
macOS (one unrelated wireguard restart test failed once under load and passed
on three reruns). winservice.rs was cargo-checked for x86_64-pc-windows-msvc in
a scratch crate; the whole tsunagi crate cannot be built for Windows from
macOS (aws-lc needs the Windows SDK). The .iss script and the CI step were NOT
compiled or run: no Inno Setup or Windows here.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 14:16:05 +01:00
UltradesuandClaude Sonnet 5.5 7c176e0b74 Exit nodes on Windows: use a member as exit node via destination bypass routes
Choosing an exit node on Windows did nothing: the using side was refused,
because Windows has no per-user routing and the agent's own traffic would loop
into the tunnel it carries.

Now the overlay interface gets 0.0.0.0/1 and 128.0.0.0/1 routes (and ::/1,
8000::/1 as an IPv6 blackhole), and every underlay address the agent talks to
directly gets a /32 route through the physical default gateway, marked with
route metric 7373 so stale ones are found and removed. Bypass routes are
written before the halves and removed after them; a failed step removes what
was written. All routes are in the active store.

The addresses come from the runtime: each network runtime hands the interface
the verified direct IPv4 paths of its control connections and the relay hosts
in use (Interface::set_underlay, refreshed every dial round); relay names are
resolved when the rules are applied. ExitHostRules::needs_bypass (true only on
Windows) gates it, so Linux and macOS plans never carry or re-apply on path
changes. ExitHostPlan gains `bypass` and `bypass_hosts`; networks leaving
forget their entry.

Limits: a path that appears after the last apply goes through the tunnel until
the next apply; data-plane-only addresses are not collected separately (the
data links ride the same iroh paths). Nothing was run on Windows.

Checks: cargo fmt --check, cargo clippy --locked --workspace --all-targets
-D warnings and cargo test --locked --workspace --all-targets passed on macOS;
scripts and ordering are unit-tested with an injected runner and resolver, and
Interface::set_underlay with the mock host.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 13:43:01 +01:00
UltradesuandClaude Sonnet 5.5 637915600e Exit nodes on Windows: offer through WinNAT; using is refused
Windows reported exit nodes as unsupported. The offering side now creates one
WinNAT object per offered range (New-NetNat, named tsunagi-exit:<if>:<range>),
prunes this interface's stale ones, reads (never writes) IPv4 forwarding of
the overlay interface, and removes everything on clear. All values are
single-quoted PowerShell literals.

The using side is deliberately not implemented: Windows has no per-user or
per-process routing, so the agent's own QUIC/relay traffic cannot be kept out
of a default route through the overlay and would loop. It reports that reason
and installs no route. The CLI/tray forwarding hint and the kernel-forwarding
warning name the Windows command; docs/exit-node.md has a Windows section.

Checks: cargo fmt --check, cargo clippy --locked --workspace --all-targets
-D warnings and cargo test --locked --workspace --all-targets passed on macOS.
The script builders and plan logic are unit-tested with an injected runner on
every platform; nothing was run against a real Windows host or cross-compiled
(no Windows target installed).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 12:33:22 +01:00
UltradesuandClaude Sonnet 5.5 47c5d38d0c Exit nodes on macOS: pf anchor for offering, routes plus route-to for using
The exit-node host rules existed only for Linux (iptables + policy routing);
macOS reported "only supported on Linux yet".

Offering: a pf anchor com.apple/tsunagi-exit-<if> (evaluated by the stock
pf.conf) holds a nat rule per offered range and a pass-in rule. pf is enabled
if needed and the pfctl -E reference is released when the rules are removed.
net.inet.ip.forwarding is only read, never written, as on Linux.

Using: macOS has no uidrange policy routing and the agent is root, so the
overlay gets 0.0.0.0/1 and 128.0.0.0/1 routes and a pf route-to rule (matched
by the agent's uid) sends the agent's own QUIC/relay traffic back out the
physical interface, translated to its address. The rules are loaded before the
routes, so a failed load never sends traffic into a tunnel it cannot exempt
the agent from. IPv6 is blocked with rejecting ::/1 and 8000::/1 routes.
ExitHostPlan gains `overlay` (every network range) for the route-to exemption;
Linux ignores it.

Known limits: the egress is the default route's interface at apply time; other
root processes also bypass the tunnel. Forwarding hints in the CLI and tray
name the macOS sysctl. The rule text and command order are unit-tested with a
scripted host; nothing was run against a live pf/route (needs root), so the
route-to + nat second-pass behaviour is unverified.

Checks: cargo fmt --check, cargo clippy --locked --workspace --all-targets
-D warnings and cargo test --locked --workspace --all-targets, run on macOS
only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 12:29:40 +01:00
Aleksandr BogomiakovandClaude 0cfc1cbafc Tray state icon and window picture; firewall rules without the comment match; macOS app bundle
The tray icon is dark red with no connection (or a lost exit node), blue when
connected and green while an exit node is in use; the windows get the logo and
an app id that matches the desktop entry, and the exit icon moves to the
bottom left of the tile. Exit-node and broadcast firewall rules fall back to
being recognised by their shape on kernels that have no `comment` match, as
small router builds do. The macOS archive carries Tsunagi.app for the Homebrew
cask, and the daemon plist opens the control socket to the admin group.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 03:30:47 +01:00
226 changed files with 59945 additions and 7023 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Windows: link the C runtime statically, so the agent and the tray start on a
# machine that has no Visual C++ redistributable installed. The installer ships
# nothing but our own files and Wintun.
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
+29 -1
View File
@@ -173,6 +173,10 @@ jobs:
macOS) macOS)
cp dist/macos/cy.hexor.tsunagi.plist "$stage/" cp dist/macos/cy.hexor.tsunagi.plist "$stage/"
cp dist/macos/README.md "$stage/INSTALL.md" cp dist/macos/README.md "$stage/INSTALL.md"
# The tray as an application bundle, for the Homebrew cask.
bash dist/macos/build-app.sh \
"target/${{ matrix.target }}/release/tsunagi-tray" \
"$version" "$stage/Tsunagi.app"
;; ;;
Windows) Windows)
cp dist/windows/README.md "$stage/INSTALL.md" cp dist/windows/README.md "$stage/INSTALL.md"
@@ -186,6 +190,29 @@ jobs:
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}" tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
fi fi
# The Windows installer: the agent as a service, Wintun, the tray and the
# PATH entry in one setup program. Wintun is the signed library from
# wintun.net, pinned by hash so a changed download fails the build.
- name: Build the Windows installer
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$version = $env:GITHUB_REF_NAME.TrimStart('v')
Invoke-WebRequest -Uri https://www.wintun.net/builds/wintun-0.14.1.zip -OutFile wintun.zip
$hash = (Get-FileHash wintun.zip -Algorithm SHA256).Hash
if ($hash -ne '07C256185D6EE3652E09FA55C0B673E2624B565E02C4B9091C79CA7D2F24EF51') {
throw "wintun.zip has an unexpected hash: $hash"
}
Expand-Archive wintun.zip -DestinationPath wintun-dl
choco install innosetup -y --no-progress
$iscc = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'
$stage = Join-Path $PWD 'dist/pkg/${{ matrix.asset }}'
& $iscc "/DAppVersion=$version" "/DSourceDir=$stage" `
"/DWintunDir=$(Join-Path $PWD 'wintun-dl/wintun/bin/amd64')" `
"/O$PWD" dist/windows/tsunagi.iss
if ($LASTEXITCODE -ne 0) { throw "ISCC failed with $LASTEXITCODE" }
# A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops # A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops
# the static binary, the systemd service and the polkit rule. Built with # the static binary, the systemd service and the polkit rule. Built with
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh. # dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
@@ -247,6 +274,7 @@ jobs:
${{ matrix.asset }}-*.* ${{ matrix.asset }}-*.*
*.deb *.deb
*.pkg.tar.zst *.pkg.tar.zst
tsunagi-setup-*.exe
if-no-files-found: error if-no-files-found: error
publish: publish:
@@ -318,5 +346,5 @@ jobs:
cat up.json >&2 || true cat up.json >&2 || true
rc=1 rc=1
fi fi
done < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' \) -print0) done < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' -o -name 'tsunagi-setup-*.exe' \) -print0)
exit "$rc" exit "$rc"
+29 -1
View File
@@ -175,6 +175,10 @@ jobs:
macOS) macOS)
cp dist/macos/cy.hexor.tsunagi.plist "$stage/" cp dist/macos/cy.hexor.tsunagi.plist "$stage/"
cp dist/macos/README.md "$stage/INSTALL.md" cp dist/macos/README.md "$stage/INSTALL.md"
# The tray as an application bundle, for the Homebrew cask.
bash dist/macos/build-app.sh \
"target/${{ matrix.target }}/release/tsunagi-tray" \
"$version" "$stage/Tsunagi.app"
;; ;;
Windows) Windows)
cp dist/windows/README.md "$stage/INSTALL.md" cp dist/windows/README.md "$stage/INSTALL.md"
@@ -188,6 +192,29 @@ jobs:
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}" tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
fi fi
# The Windows installer: the agent as a service, Wintun, the tray and the
# PATH entry in one setup program. Wintun is the signed library from
# wintun.net, pinned by hash so a changed download fails the build.
- name: Build the Windows installer
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$version = $env:GITHUB_REF_NAME.TrimStart('v')
Invoke-WebRequest -Uri https://www.wintun.net/builds/wintun-0.14.1.zip -OutFile wintun.zip
$hash = (Get-FileHash wintun.zip -Algorithm SHA256).Hash
if ($hash -ne '07C256185D6EE3652E09FA55C0B673E2624B565E02C4B9091C79CA7D2F24EF51') {
throw "wintun.zip has an unexpected hash: $hash"
}
Expand-Archive wintun.zip -DestinationPath wintun-dl
choco install innosetup -y --no-progress
$iscc = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'
$stage = Join-Path $PWD 'dist/pkg/${{ matrix.asset }}'
& $iscc "/DAppVersion=$version" "/DSourceDir=$stage" `
"/DWintunDir=$(Join-Path $PWD 'wintun-dl/wintun/bin/amd64')" `
"/O$PWD" dist/windows/tsunagi.iss
if ($LASTEXITCODE -ne 0) { throw "ISCC failed with $LASTEXITCODE" }
# A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops # A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops
# the static binary, the systemd service and the polkit rule. Built with # the static binary, the systemd service and the polkit rule. Built with
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh. # dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
@@ -246,6 +273,7 @@ jobs:
${{ matrix.asset }}-*.* ${{ matrix.asset }}-*.*
*.deb *.deb
*.pkg.tar.zst *.pkg.tar.zst
tsunagi-setup-*.exe
if-no-files-found: error if-no-files-found: error
publish: publish:
@@ -274,7 +302,7 @@ jobs:
# download-artifact nests each artifact under its own directory, so # download-artifact nests each artifact under its own directory, so
# the archives are artifacts/<asset>/<file>. # the archives are artifacts/<asset>/<file>.
mapfile -t files < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' \)) mapfile -t files < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' -o -name 'tsunagi-setup-*.exe' \))
printf 'asset: %s\n' "${files[@]}" printf 'asset: %s\n' "${files[@]}"
prerelease=() prerelease=()
+1
View File
@@ -1 +1,2 @@
/target /target
/.claude
+6 -4
View File
@@ -8,7 +8,7 @@ before touching the code. Do not restate the full design here; follow the links.
A proof-of-concept agent library for small private mesh networks. A user A proof-of-concept agent library for small private mesh networks. A user
supplies a network name and one shared secret; agents derive the same network supplies a network name and one shared secret; agents derive the same network
space independently, find each other, prove membership and exchange control space independently, find each other, prove membership and exchange control
messages. Scope and non-scope are in [README.md](README.md). messages. What it does and does not do is in [docs/architecture.md](docs/architecture.md) and [docs/threat-model.md](docs/threat-model.md).
There is no central server, no owner, no registration and no majority vote. There is no central server, no owner, no registration and no majority vote.
Design accordingly: a majority is not a root of trust. Design accordingly: a majority is not a root of trust.
@@ -59,8 +59,10 @@ Keep these separate. Crossing them is the main thing to review for.
other peers goes link in, link out: it is never written to the middle's 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; interface or decrypted there. Multihop routing has a bounded hop limit;
equal-cost next hops are chosen per flow. Reachability is first-hand and equal-cost next hops are chosen per flow. Reachability is first-hand and
volatile: each authenticated member advertises its own protocol-specific volatile: each authenticated member advertises its own direct links, of
links. Build routing tables on topology changes, never per packet. Transit whatever kind. A link is a way to reach a neighbour and a protocol is an
end-to-end tunnel to a peer; the two meet only in the envelope, so a
tunnel never depends on which transports carry it. Build routing tables on topology changes, never per packet. Transit
must not acquire a routing mutex or wait for a protocol/TUN reader. must not acquire a routing mutex or wait for a protocol/TUN reader.
- **Broadcast fanout belongs at local IP ingress.** Participation is local to - **Broadcast fanout belongs at local IP ingress.** Participation is local to
each network, enabled by default, persisted and advertised to authenticated each network, enabled by default, persisted and advertised to authenticated
@@ -160,7 +162,7 @@ Keep these separate. Crossing them is the main thing to review for.
| `crates/tsunagi-core/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks, broadcast host rules | | `crates/tsunagi-core/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks, broadcast host rules |
| `crates/tsunagi-core/src/dataplane/` | the protocol contract and the authenticated packet transport | | `crates/tsunagi-core/src/dataplane/` | the protocol contract and the authenticated packet transport |
| `crates/tsunagi-core/src/dns/` | the DNS view of a network: zone, server, resolver publication | | `crates/tsunagi-core/src/dns/` | the DNS view of a network: zone, server, resolver publication |
| `crates/tsunagi-wg-quic/` | the `wg-quic` protocol: its keys, its announcement, its tunnels | | `crates/tsunagi-wg-quic/` | the single internal WireGuard cipher, keys and opaque announcement; historical crate name |
| `crates/tsunagi/` | the command line agent, built as `tsng`; the only place that owns a runtime, a logger and signals | | `crates/tsunagi/` | the command line agent, built as `tsng`; the only place that owns a runtime, a logger and signals |
| `tests/` | integration tests; `tests/common/` is the shared harness | | `tests/` | integration tests; `tests/common/` is the shared harness |
Generated
+117 -16
View File
@@ -601,6 +601,12 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "byteorder-lite" name = "byteorder-lite"
version = "0.1.0" version = "0.1.0"
@@ -1171,6 +1177,46 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "defmt"
version = "0.3.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4deef977660e373c458df605a913d4ab121db0ccd18d1ad6976dff7d37cddb53"
dependencies = [
"defmt 1.2.0",
]
[[package]]
name = "defmt"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "defmt-parser"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0"
dependencies = [
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "der" name = "der"
version = "0.8.2" version = "0.8.2"
@@ -2366,6 +2412,15 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "hash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -2404,6 +2459,16 @@ dependencies = [
"hashbrown 0.17.1", "hashbrown 0.17.1",
] ]
[[package]]
name = "heapless"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
dependencies = [
"hash32",
"stable_deref_trait",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"
@@ -2806,8 +2871,6 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "iroh" name = "iroh"
version = "1.2.0" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2f8d1cfffc83efe39a1031aab423ce09cb8048071baba550508931e9a81ce46"
dependencies = [ dependencies = [
"backon", "backon",
"blake3", "blake3",
@@ -3317,6 +3380,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "managed"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -5518,6 +5587,20 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "smoltcp"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb"
dependencies = [
"bitflags 1.3.2",
"byteorder",
"cfg-if",
"defmt 0.3.101",
"heapless",
"managed",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.5" version = "0.6.5"
@@ -6241,15 +6324,33 @@ dependencies = [
"iroh", "iroh",
"netwatch", "netwatch",
"nix 0.31.3", "nix 0.31.3",
"serde_json",
"simple-dns", "simple-dns",
"tempfile", "tempfile",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tsunagi-awg",
"tsunagi-core", "tsunagi-core",
"tsunagi-tcp-tls", "tsunagi-tcp-tls",
"tsunagi-wg-quic", "tsunagi-wg-quic",
"tsunagi-wireguard", "tsunagi-wireguard",
"windows-service",
]
[[package]]
name = "tsunagi-awg"
version = "0.1.0"
dependencies = [
"bytes",
"iroh",
"rand 0.10.3",
"smoltcp",
"tempfile",
"tokio",
"tsunagi-core",
"tsunagi-wg-quic",
"tsunagi-wireguard",
] ]
[[package]] [[package]]
@@ -6300,27 +6401,18 @@ version = "0.1.0"
dependencies = [ dependencies = [
"bytes", "bytes",
"caps", "caps",
"chacha20poly1305",
"hex",
"hkdf",
"iroh", "iroh",
"netdev 0.46.3", "netdev 0.46.3",
"postcard", "postcard",
"rand 0.10.3",
"rcgen", "rcgen",
"rustls", "rustls",
"serde", "serde",
"sha2",
"subtle",
"tempfile", "tempfile",
"thiserror 2.0.20",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tsunagi-core", "tsunagi-core",
"x25519-dalek",
"zeroize",
] ]
[[package]] [[package]]
@@ -6360,6 +6452,8 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tsunagi-core", "tsunagi-core",
"tsunagi-tcp-tls",
"tsunagi-wireguard",
"zeroize", "zeroize",
] ]
@@ -6369,17 +6463,13 @@ version = "0.1.0"
dependencies = [ dependencies = [
"boringtun", "boringtun",
"bytes", "bytes",
"data-encoding", "chacha20 0.9.1",
"hex",
"iroh", "iroh",
"netdev 0.46.3", "netdev 0.46.3",
"postcard", "postcard",
"rand 0.10.3", "rand 0.10.3",
"rusqlite",
"serde", "serde",
"subtle",
"tempfile", "tempfile",
"thiserror 2.0.20",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -7135,6 +7225,17 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-service"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2"
dependencies = [
"bitflags 2.13.2",
"widestring",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "windows-strings" name = "windows-strings"
version = "0.1.0" version = "0.1.0"
+3 -1
View File
@@ -4,12 +4,14 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["crates/*"] members = ["crates/*"]
exclude = ["vendor/mainline"] exclude = ["vendor/mainline", "vendor/iroh"]
# Mainline 8.0.0 treats Windows UDP read timeouts as socket failures. # Mainline 8.0.0 treats Windows UDP read timeouts as socket failures.
# Keep the small receive-loop correction reproducible until an upstream fix. # Keep the small receive-loop correction reproducible until an upstream fix.
[patch.crates-io] [patch.crates-io]
mainline = { path = "vendor/mainline" } mainline = { path = "vendor/mainline" }
# Backport bounded path retries until an iroh release includes the OOM fix.
iroh = { path = "vendor/iroh" }
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
+209 -717
View File
@@ -1,766 +1,258 @@
# tsunagi <p align="center">
<img src="dist/linux/tsunagi.svg" width="96" alt="">
A proof-of-concept Rust library for **small private mesh networks** — a handful </p>
of friends, home machines, a few servers. Units to dozens of participants, not
thousands. <h1 align="center">tsunagi</h1>
The end user configures exactly two things: <p align="center">
<b>A mesh VPN with no server.</b><br>
```text Pick a network name, share a secret, and your machines are on one private network.
network_name </p>
secret # one shared secret; "password" and "secret" mean the same value
``` <p align="center">
<a href="https://github.com/house-of-vanity/tsunagi/releases/latest"><img alt="release" src="https://img.shields.io/github/v/release/house-of-vanity/tsunagi?include_prereleases"></a>
From those, every agent independently derives the same network space. There is <a href="https://github.com/house-of-vanity/tsunagi/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/house-of-vanity/tsunagi/actions/workflows/ci.yml/badge.svg"></a>
no central server, no network owner with special powers, no registration and no <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-WTFPL-blue"></a>
majority vote. Anyone who knows the parameters can join; nobody has to trust </p>
anybody else.
```console
## What this proof of concept actually does laptop$ tsng join -n home
joined `home`
A working library with **real iroh connections** and integration tests:
- persistent device identity stored in SQLite, stable across restarts;
- several independent networks at once in one agent;
- deterministic network identity derived from name + secret;
- candidates supplied by a replaceable discovery component;
- automatic first contact through the public Mainline DHT (BEP44);
- real iroh connections plus an explicit mutual proof of network membership;
- a small versioned control protocol: handshake, hostname/capability
announcement, ping/pong;
- automatic reconnect with bounded exponential backoff and jitter;
- status snapshots, an event stream and honest diagnostics;
- configuration restored after a restart;
- correct behaviour when the disposable cache is missing or corrupt;
- a **WireGuard data plane**, in userspace: its own key per network, an IPv6
overlay with deterministically derived addresses and optional IPv4, real
tunnels carried over iroh, and address ownership enforced rather than
believed;
- a **command line agent**, `tsng`, with a local control socket.
### What it deliberately does **not** do
Not implemented, and not pretended to be: DNS, routing through
intermediate participants, a full CRDT, dynamically loaded plugins, a system
service, a complete CLI, or a local control socket. Snapshot synchronisation
and signed revocations are designed for but not implemented — see
[docs/sync-model.md](docs/sync-model.md). The WireGuard plugin's own limits,
including that its system backend is Linux-only, are in
[docs/wireguard.md](docs/wireguard.md#limits-and-future-work).
**Control and data are separated logically, not physically.** Both ride on
iroh, on different ALPNs and different connections, so the data plane inherits
iroh's hole punching and relay fallback instead of reimplementing them — while
the control protocol still knows nothing about packets and can keep a different
transport underneath it later. Filtering user traffic remains the operating
system's and the user's responsibility, not this library's.
## Requirements
- Rust 1.91 or newer (iroh 1.2 requires it), edition 2024. Pinned dependencies
in `Cargo.lock`.
- No internet, no public DHT, no public relay, no administrator rights and no changes
to OS network settings are needed to build or test.
- WireGuard runs in userspace (boringtun): **no kernel module and no `wg`
tool**. Only creating a real network interface needs `CAP_NET_ADMIN`, and
`--no-tun` skips even that.
## Trying it on two machines
On the first machine:
```bash
cargo build --release
./target/release/tsng status # this device, the agent, and this host
./target/release/tsng network # the networks this device belongs to
```
Two commands do the work, and they are separate on purpose: `up` runs the
agent — one per device, for as long as you want it — and `join` decides
what it belongs to, at any time, while it runs.
```bash
# Terminal one: the agent. It prints its endpoint id and then serves.
./target/release/tsng up
# Terminal two: make a network. With no --secret it invents one and prints
# it, along with the line to send the other machine.
./target/release/tsng join --network lab
```
On the second machine, start its agent and join the same network with the
secret that was printed:
```bash
./target/release/tsng up
./target/release/tsng join --network lab --secret tsn1...
```
**Name and secret are enough for the first meeting.** Mainline DHT discovery
is enabled by default. Each active network publishes this device's endpoint
and searches until the first authenticated connection. While connected it
only republishes; after 60 seconds without any authenticated connection it
searches again. Publication is approximately every five minutes, and records
older than 15 minutes are ignored. DHT storage nodes may retain them longer.
Use `--no-dht` to disable DHT, or `--peer <endpoint-id>` for optional manual
bootstrap. `--reach local` disables public DHT regardless of the DHT flag.
Endpoint records are public, signed, and contain no network secret. Discovery
is not authentication: every connection must still prove network membership.
Details and failure behavior are in [docs/mainline-dht.md](docs/mainline-dht.md).
**One introduction is enough for the whole network.** Members tell each
other about the members they know, and every author of a signed record is
somebody to try, so a device pointed at one member ends up talking to all
of them rather than to the one that happened to be on its command line.
What travels is a candidate — an address somebody has seen — and it is
authenticated by the handshake like any other; being introduced grants
nothing. It is deliberately the same shape a lookup in a distributed hash
table would return, so that is a source to add beside this one rather than
a redesign.
An agent with nobody to contact says so in `status` rather than sitting
there looking patient.
Within a few seconds both print something like:
```text
+ peer b47c958462 connected over direct rtt=Some(4.5ms)
+ data link to b47c958462 for wg-quic: direct via 192.0.2.7:41234, datagram 1382
--- status ---
control: 1 peer(s), 0 dial failure(s), 0 handshake failure(s)
wg-quic: tsun0 on 10.13.37.69/24 mtu 1280, 1/1 tunnel(s) established
4jO4kx9Z 10.13.37.237 handshake 3s ago tx=0 rx=0 dropped=0 path=direct via 192.0.2.7:41234
```
`1/1 tunnel(s) established` means a real WireGuard handshake completed.
## Checking that it works
From another shell on either machine:
```bash
tsng status
```
```text
endpoint 7d76ccbbc21bf30767e14422c0494740a2cecc02aa9f82d5b8d57bdae350e7fc
hostname tsunagi-7d76ccbbc2
bound 0.0.0.0:41641
network lab (z2o4qwrvnj3zb6st2aoqg4abf342j662q2ujttqrsmz22argk2ba) active
peer b345d5271b tsunagi-b345d5271b Direct rtt 24ms
overlay tsunz2o4qwrvnj3 fd09:…:c1c6/64 and 100.110.49.177 mtu 1280 1/1 tunnel(s) up
SDsEb/WF fd09:…:c4b / 100.65.243.53 handshake 4s ago tx 0 rx 0 Direct via Ip(…)
```
`1/1 tunnel(s) up` and a recent handshake mean the tunnel is live. Then send
real traffic to the peer's overlay address:
```bash
ping6 fd09:…:c4b # or
ping 100.65.243.53
```
`tx` and `rx` in the status should start moving.
**Addresses are allocated and then remembered.** The default range is
`10.13.37.0/24`; the first member to join settles it and later members adopt
what they find, so `--ipv4-range` only matters for whoever starts the network:
```bash
tsng up --ipv4-range 10.44.0.0/16
tsng up --ipv4-range none # no data plane at all
```
One agent has one interface, so two of its networks cannot both use that
range. The second takes the range **derived from its own network id**: not
picked locally — every member derives the same one from something they all
already have — so it is an agreement rather than a guess, and a device in
several networks gets an address in each.
An address is claimed with a record signed by that member's persistent device
key, stored, and merged between every replica. A member that disappears for a
month comes back to the same address, because the claim outlived the session.
No vote is involved — see
[docs/wireguard.md](docs/wireguard.md#ipv4-allocated-signed-and-kept) and
[docs/sync-model.md](docs/sync-model.md).
Because the address is allocated at run time rather than derived, it is not
known until the agent has started and agreed with its peers. The agent then
assigns it to the interface itself.
## Levels
The command line is split the way the design is. A bare `tsng up` needs
only a network name and a secret; everything else sits under the level it
belongs to, which `--help` shows as two sections:
* **System** — what the agent itself does: how it reaches peers (`--reach`),
the one overlay interface it owns (`--interface`, `--mtu`, `--no-tun`), the
address range (`--ipv4-range`), and the local resolver (`--no-dns` to disable).
* **Transport** — which protocols carry packets (`--protocol`, a list) and
their own settings (`-o key=value`, or `-o protocol:key=value`).
```bash
tsng protocols # what this build can carry packets with
```
```text
wg-quic (wire version 4)
what WireGuard's cryptography carried in iroh's QUIC datagrams
-o keepalive=SECONDS keeps a tunnel and its link warm through a NAT
-o mtu=BYTES largest packet a tunnel will carry, at least 576
```
Each protocol declares its own settings, so the agent can list them without
knowing anything about the protocol, and a setting no selected protocol takes
is refused rather than ignored. `--protocol none` runs the control plane by
itself.
A pair of peers uses a protocol they both have **at the same wire version**.
That is not the software version: two peers on different builds carry traffic
for each other for as long as the bytes between them have not changed. A peer
with nothing in common keeps its control plane — messages and signed state
still flow — and simply has no data plane, which `tsng status` shows as a
session with no agreed protocol.
## Names
DNS is enabled by default and serves a local zone for **every network this device is in**,
each named after the network, so members can be reached by name instead of
by address:
```bash
tsng up # DNS is already enabled
dig @127.0.0.1 -p 5354 music.lab
```
An explicit choice is remembered across restarts. `--no-dns` or `tsng dns off`
disables it; `--dns` or `tsng dns on` enables it again. Existing saved opt-outs
remain respected. The `dns on` and `dns off` commands also take effect immediately
on a running agent. `--dns` and `--no-dns` cannot be combined.
The zone of a network is its name — `--dns-zone` is gone, because with
several networks there is no single zone to name. A network name may contain
dots, so `--network lab.internal` is how you get `music.lab.internal`.
Names come from signed state, which is the point: **a member that is
switched off still resolves**, because its claim outlived the session.
The answers are IPv4 addresses, because that is what the overlay is. The
*questions* are taken on `127.0.0.1` and `[::1]`, over UDP and TCP, so a
resolver reaches it over whichever family it uses; both are published to the
system resolver together. Loopback and nothing else: the zones are a view
for the host running the agent, and binding an overlay address would put
them in front of the whole mesh — an agent in two networks would then answer
one network's questions about the other's names.
The zone is the network name, and it is yours to choose. A name that
shadows a real public domain is reported and then used: a network called
`ru` warns that every public `.ru` name becomes unreachable from this host,
and then does it. Anything that collides with nothing — which is most names
— is said nothing about, because a warning that fires on every private name
anybody picks is how people learn to ignore warnings.
On Linux the agent tells systemd-resolved to send questions for that suffix
here, over D-Bus, scoped to the overlay interface and as a *routing* domain
so it never becomes the resolver for anything else. resolved drops the whole
setting when the interface goes, and the interface goes with the agent.
Only an interface the agent created, though. Under `--no-tun` there is no
host interface at all, and the agent says so and serves the zone on
loopback rather than configuring whatever else on the host happens to share
the name.
That last step needs permission that `CAP_NET_ADMIN` does not give:
systemd-resolved asks polkit, and polkit decides by **user**, not by
capability, so there is no way for the agent to arrange it from inside. On
a desktop the refusal reads `Interactive authentication required`.
Running as a system service is enough. Otherwise the agent prints the rule
that grants it — the four actions it calls and nothing else — ready to
paste:
```bash
sudo tee /etc/polkit-1/rules.d/50-tsunagi-resolved.rules > /dev/null <<'RULE'
polkit.addRule(function(action, subject) {
var allowed = [
"org.freedesktop.resolve1.set-dns-servers",
"org.freedesktop.resolve1.set-domains",
"org.freedesktop.resolve1.set-default-route",
"org.freedesktop.resolve1.revert"
];
if (allowed.indexOf(action.id) >= 0 && subject.user == "YOUR-USER") {
return polkit.Result.YES;
}
});
RULE
```
**Without it the server still runs** — `tsng status` prints where it is
listening and the exact `dig` line — so the automatic part is missing, not
the feature. The refusal is said once rather than on every pass, and retried
slowly, because nothing but a person will change it.
The server is authoritative for its zone and nothing else. No recursion, no
forwarding, no cache: pointing a resolver at it can never make it a route to
the outside.
## Privileges
On Linux the agent **manages its own overlay interface**. It creates the TUN
interface, sets the MTU, brings it up and assigns both overlay addresses, all
over netlink in process — no `ip` invocation, no shell, nothing that a
remote peer could influence.
That needs `CAP_NET_ADMIN`, granted once:
```bash
sudo setcap cap_net_admin+p /usr/local/bin/tsng
```
`+p` rather than `+ep`: the capability is then *permitted* but not
*effective*, and the agent raises it only around the handful of netlink calls
that need it — a few milliseconds at startup, and again if its address
allocation changes. Everything else, including every byte from the network,
is handled with it lowered. `+ep` works too; the agent lowers it on the way
in.
`tsng status` says which of these applies on the host it runs on, along
with what the agent is doing. It grades each finding: **ok** for what works,
**warn** for what the agent runs without and you can fix from the line it
prints, **FAIL** for what it cannot work around. The words carry the grade as
well as the colour, so the report reads the same piped to a file or on a
terminal without colour, and it honours `NO_COLOR`.
Members are listed online first, then the ones that are away. A member that
is away is reported plainly rather than flagged: in a mesh of laptops it is
the ordinary condition, not a fault. The signed state is what makes that
sayable — it remembers who belongs while they are gone, so the report can say
"offline, 10.13.37.99 still reserved for it" instead of leaving a
dial-failure counter to imply it. Counters are history and are never graded:
a peer that left and came back should not leave the report looking broken.
The other two commands split along the line the system itself draws. `id` is
this **device**: the key it signs with and the name it answers to. `network`
is what it **belongs to**: which networks, their secrets, joining and
leaving. A device outlives every network it is in, and a network outlives
any device in it, so a command that mixed them had to be read twice.
Every item takes the same shape, so there is nothing to remember: name it to
see it, name it with a value to change it.
```
tsng id everything about this device
tsng id hostname the name it answers to
tsng id hostname mango change it
tsng id key the key it signs with
tsng id key rotate replace that key
tsng network the networks this device belongs to
tsng network join -n lab -s tsn1… join one; adds it to a running agent
tsng network join -n lab resume one this device has, or make it on the spot
tsng network stop lab stop serving it, keeping everything
tsng network start lab serve it again, from where it left off
tsng network leave lab give up the address and name, then forget it
tsng network secret the secret of each joined network
tsng network secret lab just that one, for copying
tsng network secret generate a fresh secret for a network that does not exist yet
tsng dns whether the local resolver is serving, and what
tsng dns on start it, now and after every restart
tsng dns off stop it, now and after every restart
```
**A network without a secret makes one.** `tsng join --network lab`
resolves a bare name in the obvious way: if this device is already in
exactly one network called `lab`, that one — so the name alone resumes what
you have; if it is in none, a fresh random secret, printed in full with the
line to send the other machine:
```
joined `lab` (k2on43wadbi5x267vp6z3ogkm7nbjedfdoxyauxhtxhwqrprylba)
secret tsn1u7c… secret tsn1u7c…
Run this on the other machine: server$ tsng join -n home -s tsn1u7c…
server$ tsng id hostname server
tsng join --network lab --secret tsn1u7c… laptop$ ssh server.home
Its agent has to be running. If it is not:
tsng up --peer 91e83a6e2b7a…
``` ```
Two networks of one name is a thing that happens — a mistyped secret makes No account, no coordination server, no admin console, no keys to approve.
one — so joining says whether the network was already here, and warns when Anyone who knows the **name** and the **secret** is in. Nobody else can find
another configured network answers to the same name. A name is a label and the network, let alone enter it.
the id is the identity.
That is the ad-hoc case: one person makes a network and sends the command ## Why
round. The secret is printed *only* when the agent invented it — there is
nowhere else to read it from — and never when it was supplied, because then
it is already yours. Two networks of one name and no secret is the one case
with no answer, and it says so instead of choosing.
A secret is printed by `network secret` and nowhere else — not by `id`, not Tailscale, Headscale and ZeroTier are great, but each needs a control server:
by `status`, not in a log, a `Debug` rendering or anything sent to a peer. theirs, or one you have to run and keep alive. tsunagi doesn't. Machines find
Asking for it is deliberate, because these reports get pasted into chats. each other through the public BitTorrent DHT, connect directly (hole punching,
with relay fallback), and keep the network state among themselves.
**`up` takes no network at all** if you would rather decide later: it | | Tailscale | Headscale | ZeroTier | **tsunagi** |
brings up the agent and whatever it is already configured for, and waits. | ---------------------- | ------------------ | ---------------- | ------------------- | ------------------- |
That is the shape of a daemon in one terminal and `tsng network join` | Control server | hosted by vendor | you run it | hosted or self-run | **none** |
in another. | Account / sign-in | yes | yes | yes | **no** |
| To join a network | sign in, get approved | pre-auth key | network ID, approval | **name + secret** |
| Direct connections | yes | yes | yes | yes |
| Exit nodes | yes | yes | via routes | yes |
`network join` is also the answer to a question `up` cannot: a state ## Features
directory belongs to one live agent, so a second `tsng up` cannot add a
network to the one already running. This adds it over the control socket and
it starts at once. With no agent running it is written to the configuration
and starts with the next `up`.
The name is part of the signed state, so changing it revokes the previous - **Two things to know:** a network name and a secret. A secret is generated for you when you make a network.
one: there is one record per author, a new version replaces the whole claim, - **Direct, encrypted links** between members (WireGuard cryptography), with relay fallback behind strict NATs.
and no replica can keep the old name standing. Changing it while the agent - **Names for machines:** `server.home` just works, even while `server` is switched off. A name belongs to whoever claimed it first; a later device with the same name keeps working by address and is told so.
runs goes through the agent, which republishes and tells its peers straight - **Exit nodes:** send all your internet traffic through any member that offers one.
away. - **Multi-hop:** members that can't reach each other directly are routed through a member that can, over whatever kind of link each hop has. A member reached that way is online, and shown as relayed; traffic moves to a direct link by itself the moment one comes up.
- **LAN games:** UDP broadcasts are relayed, so "LAN" lobbies show up across the mesh.
- **Several networks at once** on one device.
- **Tray app** for Windows, macOS and Linux; a command line for everything.
Replacing the signing key makes this device a different member, and it loses ## Install
the address and name the old key held — nothing can sign on a retired key's
behalf, and by design there is no authority that could overrule an author. So
the outgoing key signs a release for every network on its way out, which
frees them for whoever wants them next, and the whole thing commits at once.
`status` and `id` both prefer a running agent, which is live and Grab a build from the [latest release](https://github.com/house-of-vanity/tsunagi/releases/latest).
authoritative, and fall back to reading the state store when there is none.
Reading takes no directory lock, so neither has to wait for the agent it is
asking about — nor does either need one to be running.
### It cleans up after itself | Platform | How |
| --------------------- | -------------------------------------------------------------------------------- |
| **Windows** | Run `tsunagi-setup-<version>-x86_64.exe`. Installs the service, the tray and `tsng`. |
| **macOS** | `brew tap house-of-vanity/tap && brew install --cask tsunagi-gui`<br>CLI only: `brew install tsunagi` |
| **Debian / Ubuntu** | `sudo apt install ./tsunagi-gui_<version>_amd64.deb` (`tsunagi_…` for no tray) |
| **Arch** | `sudo pacman -U tsunagi-gui-<version>-1-x86_64.pkg.tar.zst` |
| **NixOS** | [Flake and module](#nixos) below |
| **Any other Linux** | Unpack the tarball, then `sudo ./install.sh && sudo systemctl enable --now tsunagi` |
The interface is tied to an open file descriptor and is deliberately **not** Linux packages run the agent as an unprivileged service user. Add yourself to
made persistent, so the kernel removes it when the agent exits — on a clean the `tsunagi` group (`sudo usermod -aG tsunagi $USER`, then log in again) to
shutdown, on a panic, on `SIGKILL`, on power loss alike. Keeping it is what use `tsng` and the tray without `sudo`.
would take an action; removing it is the default.
If something is left behind anyway — an interface made by an older version's ## Quick start
manual recipe, or one from a run killed in the instant between creating it and
recording it — the next start **replaces it**, along with any stale addresses
it carried. Two things are never touched:
* an interface that is not a TUN, because the name colliding with somebody's ```sh
bridge is not a reason to destroy the bridge; tsng join -n home # first machine: makes the network and prints a secret
* a TUN that another process is holding open, because that is a working tsng join -n home -s tsn1… # every other machine
overlay belonging to somebody else — most likely a second agent on this tsng status # who is online, over which path
host, which should be given a different `--wg-prefix`.
Both of those refuse with an explanation rather than guessing.
Two settings the manual recipe used to need are gone with it.
`keep_addr_on_down` existed only because an interface nobody held open lost
carrier and had its IPv6 addresses flushed, and `nodad` only because duplicate
address detection can never finish without carrier. An interface held open for
its whole life has carrier for its whole life.
### The MTU is 1280
The default interface MTU stays at 1280 on direct paths, relays and paths
inside another VPN. Large encrypted packets are split into smaller QUIC
datagrams and reassembled before reaching WireGuard. SSH and full-size TCP
segments therefore do not require a manual MTU override or MSS adjustment.
The original IP packet, including its DF bit, is preserved. See
[docs/wireguard.md](docs/wireguard.md#mtu) for limits and compatibility.
### Summary
| approach | agent runs as | notes |
|---|---|---|
| `setcap cap_net_admin+p` | ordinary user, one capability | recommended: nothing to prepare, nothing left behind. Lost on every rebuild or copy of the binary. |
| systemd service | `User=`, `AmbientCapabilities=CAP_NET_ADMIN` | the same, for an installed service |
| `sudo tsng up` | root | everything works, nothing is isolated |
| macOS root LaunchDaemon | root | the native macOS deployment; see below |
| `--no-tun` | ordinary user, no capabilities | tunnels run and handshake, traffic never reaches the OS |
On Windows, put `wintun.dll` beside the executable and start `tsng up`
from PowerShell or Command Prompt opened with **Run as administrator**.
Run commands controlling that agent (`join`, `status`, `dns`, `network`)
as the same Windows user with the same elevation. If Windows denies access
to the agent's control pipe, the command reports a permission error instead
of claiming the agent is absent or trying to edit its locked state.
Creating a TUN without sufficient privileges also explains how to restart
the agent with the required permissions.
On macOS the agent creates a `utun` interface, configures it with `ifconfig`
and `route`, and points the system resolver at the overlay by writing
`/etc/resolver/<network>` files (which carry a port, so the DNS server needs no
privileged socket). All of this needs **root**: macOS has no per-capability
grant like Linux, and a `utun` plus `/etc/resolver` are root-only. The kernel
assigns the interface its `utunN` name, so the name derived from the network id
is only a request and the real one is adopted once the interface is up.
Run it as root — `sudo tsng up` for a quick start, or install it as a root
**LaunchDaemon** for the native always-on deployment. As a daemon the agent runs
unprivileged commands (`join`, `status`, `dns`, `network`) over its control
socket like any other platform; the socket lives at `/var/run/tsunagi/agent.sock`
and the stores at `/var/db/tsunagi`, both fixed system paths so they do not
depend on whose shell started `sudo`. A ready-to-edit plist and install notes
are in [dist/macos/](dist/macos/). The broadcast route is installed; the
inbound-UDP firewall allowance (a `pf` anchor) is not yet, so `status` reports
that half as incomplete and names what to allow by hand. The decision logic that
says *what* to change is shared and tested on every platform.
## Checks
```bash
cargo fmt --all -- --check
cargo clippy --locked --workspace --all-targets -- -D warnings
cargo test --locked --workspace --all-targets
``` ```
The whole suite runs offline on loopback. Set `TSUNAGI_TEST_LOG=tsunagi=debug` Every new network gets a stable pseudorandom `/24` in `10.0.0.0/8`, derived
to see agent logs while a test runs. from its network id. Its members agree on that range even when starting offline,
and each gets a name like `<host>.home`. Existing signed ranges are preserved.
There are also two runnable demos, which are demos and not substitutes for the If two configured networks have overlapping ranges, the agent runs only one.
tests: `tsng status`, `tsng network` and the tray show which network blocks the
other. `tsng network start <name-or-id>` or the tray’s “Switch to this network”
button suspends the conflicting networks. They resume automatically when the
selected network stops; the selection survives restarting the agent. Explicitly
stopping a suspended network cancels its automatic resume.
```bash Machines are called `tsunagi-<id>` until you rename them: `tsng id hostname laptop`.
cargo run --example two_agents # control plane only
### Virtual services
```sh
tsng vip create home api
tsng vip port add home api tcp/443 --backend server1:6443 --backend server2:7443
tsng vip status home api --watch
``` ```
Both run with no privileges and change nothing on the host. Use `api.home:443`. Each client selects the available backend with the lowest
measured data-plane RTT. A VIP can have multiple independent TCP and UDP ports.
Every member may edit it; the tray's **VIP** button opens a draft editor with
explicit Save. [Commands, health, metrics and limits](docs/vip.md).
## Usage ### Exit nodes
```rust,no_run ```sh
use std::sync::Arc; tsng network exit-node home on # on the machine that should be the exit
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy}; tsng exit-node # on another one: who offers an exit
use tsunagi::discovery::SharedMemoryDiscovery; tsng exit-node laptop # send everything through it
use tsunagi::identity::{NetworkName, NetworkSecret}; tsng exit-node off
use tsunagi::proto::ControlMessage; ```
use tsunagi::{Agent, Result};
// The library never starts a runtime, installs a logger, handles signals, The tray does the same with a checkbox. An exit node sees everything you send
// forks, or calls process::exit. The binary owns all of that. through it, so use only members you trust.
#[tokio::main] [Details and per-OS notes](docs/exit-node.md).
async fn main() -> Result<()> {
let config = AgentConfig::new(StoragePaths::user_default()?)
.with_transport(TransportPolicy::N0Defaults)
.with_discovery(Arc::new(SharedMemoryDiscovery::new()));
let agent = Agent::spawn(config).await?; The default data transport order is `wg,awg,wg-quic,tcp-tls`. AWG negotiates a
fresh masking profile and outer keys for each pair connection attempt, including
reentry after fallback. Sustained data loss triggers switching independently of
the iroh control connection. [Configuration and limits](docs/amnezia.md).
let name = NetworkName::new("kitchen-table")?; Change a network's protocol selection without editing its service or restarting:
let secret = NetworkSecret::generate(); // 32 random bytes
println!("share this: {}", secret.encode().as_str());
let network = agent.join_network(&name, &secret).await?; ```sh
tsng protocols # available protocols and settings
tsng protocols home # this device's choice for home
tsng protocols home awg,wg-quic # prefer AWG, retain QUIC as fallback
tsng protocols home none # control plane only
```
let mut events = agent.subscribe(); Exact network names, full ids and unique id prefixes work here. The choice is
tokio::spawn(async move { local to this device, saved per network and applied immediately; an offline
while let Ok(event) = events.recv().await { change applies on the next start. In the tray, expand **data transports** on a network
println!("{event:?}"); tile to toggle protocols or change their preference order. Adjacent peers need a common external transport at the same wire version.
Selection applies to this device's incoming and outgoing data links; control and
discovery are independent. A relay's next hop may use a different transport.
Every pair uses one internal WireGuard cipher, hidden from these controls.
Changing transport or moving direct → relay → direct preserves that channel,
addresses and interface. An existing TCP connection can resume after temporary
loss, subject to its own timeouts. `tsng up --protocol` supplies the
initial defaults for networks without a saved choice; it does not restrict
which built-in protocols can be enabled later.
Remove an obsolete offline device from a network's shared history:
```sh
tsng network forget-node home old-laptop
tsng network forget-node home 10.13.37.143
```
Networks accept exact names, full ids or unique id prefixes; nodes accept those
id selectors, exact hostnames or overlay IPv4 addresses. Ambiguous selectors
change nothing. Start the network first if it is stopped. In **Show devices**,
click **Forget** beside an offline node and confirm its identity.
Every member has the same cleanup right. The signed cleanup frees the observed
claim's name and IPv4 reservation, survives restarts and reaches offline replicas
when they reconnect. It does not ban the device: a newer signed claim can return.
Online nodes and the currently selected exit node cannot be forgotten.
[State and limits](docs/sync-model.md#offline-node-cleanup).
## Platforms
| Platform | Build | CLI `tsng` | Tray | Exit node: offer / use | Packages |
| ------------------------- | ------------ | :--------: | :--: | :--------------------: | ----------------------------------- |
| Linux x86_64 | release | ✔ | ✔ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball, Nix |
| Linux aarch64 | release | ✔ | –¹ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball, Nix |
| Linux armv7 | release | ✔ | –¹ | ✔ / ✔ | `.deb`, `.pkg.tar.zst`, tarball |
| macOS, Apple silicon | release | ✔ | ✔ | ✔ / ✔² | Homebrew, tarball |
| macOS, Intel | from source | ✔³ | ✔³ | ✔ / ✔²³ | – |
| Windows x86_64 | release | ✔ | ✔ | ✔ / ✔² | installer, zip |
<sup>¹ Not in the release; builds from source (and through Nix).
² Newer and less battle-tested than on Linux.
³ Builds from source, not covered by CI.</sup>
The Linux CLI is a single static binary: it runs on any distribution.
## NixOS
The repository is a flake with a package and a module.
```nix
# flake.nix
{
inputs.tsunagi.url = "github:house-of-vanity/tsunagi";
outputs = { nixpkgs, tsunagi, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
tsunagi.nixosModules.default
{
services.tsunagi = {
enable = true;
tray.enable = true; # optional, for desktops
users = [ "alice" ]; # may use tsng and the tray without sudo
};
} }
}); ];
};
for peer in agent.network_status(network).await?.connected_peers() { };
agent
.send(network, peer, ControlMessage::Ping { seq: 1, payload: vec![] })
.await?;
}
agent.shutdown().await;
Ok(())
} }
``` ```
`TransportPolicy::LocalOnly` is the default, so a plain `AgentConfig::new` never Just trying it: `nix run github:house-of-vanity/tsunagi#tsunagi-cli -- --help`.
reaches the internet by accident. Opt into `DirectOnly` or `N0Defaults` `packages.<system>.tsunagi` (with the tray) and `tsunagi-cli` are also exported,
explicitly. along with `overlays.default`.
### Through somebody in the middle For development from a checkout, `nix develop` also exposes the tray's native
runtime libraries, including AppIndicator and graphics/windowing libraries.
Cargo-built binaries such as `target/debug/tsunagi-tray` can run directly inside
this shell, without installing the packaged wrapper.
Tsunagi routes the overlay in userspace. A direct data link always wins; ## Build from source
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 Rust 1.91 or newer.
the intermediate host's TUN. The payload stays encrypted between the original
endpoints. TUN connects only the local OS to Tsunagi; the kernel needs only
the overlay route through that interface, with no IP forwarding configuration.
A 16-hop limit bounds loops while topology updates converge. Equal shortest
paths use a stable flow hash derived before encryption, so packets of a TCP
or UDP flow keep their path while topology is unchanged. Physical link changes
do not replace end-to-end WireGuard tunnels or change the 1280-byte default MTU.
`status` reports the hop count and first next hop for relayed paths.
The hot path reads an immutable routing snapshot and sends directly to a cached
transport handle. It takes no routing mutex, walks no graph and does not parse
the encrypted payload. See [routing.md](docs/routing.md) for the architecture,
limits and reproducible forwarding microbenchmark.
This wire format requires all members to upgrade together (control ALPN 3,
data ALPN 4); saved identities, network names, secrets and addresses survive.
### LAN game discovery
IPv4 UDP broadcast relay is enabled by default for each network. The IP router
sends one encrypted copy to each participating peer, including through multihop
paths. It supports `255.255.255.255` and the overlay subnet's broadcast address;
received broadcasts never trigger another fanout.
```sh ```sh
tsng join -n games --no-broadcast cargo build --release --bin tsng # the agent and CLI
tsng join -n games --broadcast cargo build --release --bin tsunagi-tray # the tray (Linux needs GTK 3 and libayatana-appindicator)
tsng network broadcast games off
tsng network broadcast games on
``` ```
The choice persists across restarts and a plain `join`. It can be changed while Windows needs [Wintun](https://www.wintun.net) next to `tsng.exe`.
the agent runs; `status` shows it for each network. While broadcast is on, the Per-platform notes: [Linux](dist/linux/README.md), [Arch](dist/arch/README.md),
agent routes `255.255.255.255` through the Tsunagi interface and allows inbound [macOS](dist/macos/README.md), [Windows](dist/windows/README.md).
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 ## How it works
Two different lookups are involved, and only one of them is this project's: - Your name and secret derive a network identity. Members publish signed
contact records to the public Mainline DHT under it, and find each other there.
- Every connection proves knowledge of the secret before it's accepted.
Traffic between members is end-to-end encrypted, relays included.
- Addresses and names are claimed with each device's own key and replicated
between members. There is no one in charge, so nothing to be taken down.
**1. Resolving one endpoint's address — iroh's, and it works today.** The secret is the only key, so make it a good one (the generated ones are).
With `--reach relay` or `--reach direct`, iroh publishes a signed The [threat model](docs/threat-model.md) says what this does and doesn't protect against.
record of this endpoint's addresses, keyed by its endpoint id, to the public
service run by Number 0 — "n0", the company behind iroh — at `dns.iroh.link`,
over pkarr and DNS, and resolves other endpoints the same way. That is why `--peer <endpoint-id>` works with no address attached:
iroh looks it up. None of that code is ours.
**2. Finding who is in a network — Mainline DHT by default.**
`NetworkDiscovery` supplies unverified candidates. The Mainline backend uses
a signing key derived from the network name and secret to publish signed
BEP44 records containing each writer's endpoint ID and addresses. Its 16
slots provide starting candidates, not a complete membership list or a limit
on network size. After the first authenticated connection, introductions and
signed state reveal the other members. `StaticBootstrap` (`--peer`) remains
available, and tests use either a local Mainline Testnet or in-memory discovery.
What this means in practice:
- With `relay` or `direct`, **your endpoint id and IP addresses are published
to a public third-party service** (Number 0's, unless you change it). They are not secret, and the network secret is
never published, but an observer of that service learns that your endpoint
exists and where it is. `--reach local` publishes nothing.
- With DHT enabled, endpoint records are also published to the public Mainline
DHT. They are signed, not encrypted; membership still requires the secret.
- A relay, when one is needed, sees the volume and timing of your traffic — not
its contents. The default relays are Number 0's, in the US, EU and
Asia-Pacific.
## Storage
Two physically separate SQLite files, placed wherever the library's
configuration says (`StoragePaths`). A future system service supplies its own
paths; tests always use temporary directories.
| file | holds | when damaged |
|----------------|--------------------------------------------------|-------------------------|
| `state.sqlite` | device identity, network configuration, hostname | clear error, never reset |
| `cache.sqlite` | address hints and other recoverable data | discarded and recreated |
The `wg-quic` protocol keeps its own keys in its own store under `wg-quic/`,
because a protocol's keys are neither the iroh identity nor the network
secret.
The command line agent puts everything under the platform's per-user
directories by default; `--state-dir` and `--cache-dir` override them.
One state directory belongs to one live agent, enforced with a real OS file
lock rather than an existence check. That directory *is* the identity: one
agent, one device key, one interface, and as many networks as you like on it
— `tsng network join` adds them to the agent that is already running,
which is why a second `tsng up` on the same directory is refused rather
than made to work. A second agent on the same host is a second identity, and
needs everything of its own: its own state and cache directories, its own
interface name and its own overlay range. The library owns no globals, so
several of them run side by side in one process as readily as in one host.
### Leaving, and starting over
Membership outlives a session, so it also has to be possible to end it.
```bash
tsng network # what this device belongs to
tsng network leave lab # give up the address and the name, then forget it
tsng wipe --yes # remove everything and be a stranger again
```
**Stopping is not leaving.** `stop` closes this network's sessions, takes
its address off the interface and keeps it from starting again, and that is
all: the configuration, the secret, the signed state and the protocol key
stay exactly as they are, nothing is announced, and to the others this
device is simply away — an ordinary condition they already handle, with its
address and name still reserved for it. `start` picks it up where it left
off. A network named on the `up` command line is started by that command
whatever its stored state, and the start-up banner says so rather than
letting a `stop` quietly come back.
`leave` is the other one, and it removes, locally: the configuration and
the secret, this network's signed records, its cached address hints and the
protocol key it used there. What it keeps is this author's version counter
for that network — a rejoin has to continue above the release, or every
replica would treat the new claim as stale. Everything about *other*
networks, and the device identity itself, is untouched.
`leave` publishes a signed `Release` **first**, while the agent is running and
its sessions are up, so the address and the name are freed for the others
instead of staying reserved to a member that has gone. They pass the tombstone
on, so a member that was away hears it from them. With no agent running,
nothing can sign or send it: the command says so and refuses, and `--offline`
drops the network locally while leaving the others holding the old claim. The
protocol key for that network goes too — rejoining is joining, not resuming.
`leave`, `stop`, `start`, `secret` and `broadcast` accept an exact stored network
name, its full id, or a unique id prefix (including a copied report ellipsis).
Names are case-sensitive. If several networks share a name, or a name matches
another network's id prefix, the command lists matching names and full ids and
changes nothing. Use a full id to choose explicitly; a full id always takes
precedence over a name. No secret is needed to select a configured network.
`wipe` removes both directories' contents: the device identity, every network,
every signed record and everything a protocol kept beside them. It refuses
while an agent is running, and refuses a directory with no `state.sqlite` in
it, so a mistyped `--state-dir` cannot take somebody's documents with it.
Without `--yes` it only says what it would remove. It is not a goodbye: nobody
is told, because after it there is no key left to sign anything with. Leave the
networks first if the addresses should be freed.
## Documentation ## Documentation
- [docs/architecture.md](docs/architecture.md) — module boundaries and runtime. [Architecture](docs/architecture.md) ·
- [docs/wireguard.md](docs/wireguard.md) — the WireGuard plugin: overlay [Protocol](docs/protocol.md) ·
addressing, announcements, backends, reconciliation. [WireGuard data plane](docs/wireguard.md) ·
- [docs/protocol.md](docs/protocol.md) — identity derivation, framing, handshake. [Pairwise Amnezia transport](docs/amnezia.md) ·
- [docs/sync-model.md](docs/sync-model.md) — the planned signed-state model and [Routing](docs/routing.md) ·
what is deliberately not built yet. [Exit nodes](docs/exit-node.md) ·
- [docs/exit-node.md](docs/exit-node.md) — exit nodes: sending all traffic through a member. [Virtual services](docs/vip.md) ·
- [docs/threat-model.md](docs/threat-model.md) — threat model and known limits. [Threat model](docs/threat-model.md) ·
- [docs/testing.md](docs/testing.md) — what the suite covers and what it does not. [Contributing](AGENTS.md)
- [AGENTS.md](AGENTS.md) — rules for anyone (human or agent) changing this repo.
## Security in one paragraph
Membership is proved by an HMAC over a transcript keyed by a value derived from
the shared secret, bound to the specific iroh connection through the TLS
exporter, to the network id, to both endpoint identities and to distinct role
labels. This targets high-entropy secrets: there is no PAKE here, so a short
human passphrase is guessable offline by anyone who can reach the handshake.
Anyone who knows the secret is a full participant and can create many
identities. Read [docs/threat-model.md](docs/threat-model.md) before relying on
any of this.
## Licence ## Licence
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "tsunagi-awg"
version = "0.1.0"
description = "Ephemeral pairwise Amnezia transport for Tsunagi."
edition.workspace = true
rust-version.workspace = true
license = "WTFPL AND Apache-2.0"
repository.workspace = true
[dependencies]
tsunagi-wireguard = { path = "../tsunagi-wireguard" }
[dev-dependencies]
tsunagi-wg-quic = { path = "../tsunagi-wg-quic" }
iroh.workspace = true
tokio = { workspace = true, features = ["net", "time", "sync", "macros"] }
bytes.workspace = true
rand = "0.10"
tsunagi-core = { path = "../tsunagi-core", features = ["testing"] }
tempfile.workspace = true
smoltcp = { version = "0.12", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "socket-tcp"] }
[lints]
workspace = true
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Friderika Coulson (NanoBee)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
# Amnezia packet engine
`../tsunagi-wireguard/src/masking.rs` is adapted from `src/core/amnezia.rs` in
https://github.com/RunaRoo/AmneziaWG-RS at
`51579a7d6e8882d1d8d6d00822f09e18a9423b73`, under Apache-2.0.
The upstream license is retained in both packages as `LICENSE-AmneziaWG-RS`.
The adaptation replaces configuration-file loading with bounded typed pair
profiles, updates RNG calls to rand 0.10, removes an unnecessary unwrap, and
does not embed upstream's Node, global executors, OS interface operations or
detached tasks. Noise/WireGuard is supplied by boringtun, already used by
Tsunagi. Each pair owns one socket and one cancellable packet-engine task.
This is Tsunagi AWG wire version 2, not a claim of compatibility with external
Amnezia servers. The masking engine substitutes message headers outside the
standard WireGuard state machine and restores them before Noise validation.
There are no remote file paths, shell hooks, or persisted pair profiles.
The adapted masking source remains Apache-2.0. Newly written Tsunagi code
follows the repository's WTFPL. This package declares both licenses in Cargo
metadata; the workspace license alone does not describe its copied source.
+5
View File
@@ -0,0 +1,5 @@
//! Amnezia-masked UDP carrier using the independent per-neighbour WireGuard engine.
pub use tsunagi_wireguard::{
AWG_PROTOCOL, AWG_WIRE_VERSION, PairOverview, UdpCarrier as AwgPlugin,
UdpTransport as AwgTransport, profile,
};
+221
View File
@@ -0,0 +1,221 @@
//! A real userspace TCP stack attached to each memory TUN, without host routes.
use bytes::Bytes;
use smoltcp::{
iface::{Config, Interface, SocketHandle, SocketSet},
phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken},
socket::tcp,
time::Instant as TcpInstant,
wire::{HardwareAddress, IpAddress, IpCidr},
};
use std::{
collections::VecDeque,
net::Ipv4Addr,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use tsunagi::overlay::MemoryTun;
struct TunDevice {
input: Arc<Mutex<VecDeque<Vec<u8>>>>,
tun: Arc<MemoryTun>,
}
struct Rx(Vec<u8>);
impl RxToken for Rx {
fn consume<R, F: FnOnce(&[u8]) -> R>(self, f: F) -> R {
f(&self.0)
}
}
struct Tx(Arc<MemoryTun>);
impl TxToken for Tx {
fn consume<R, F: FnOnce(&mut [u8]) -> R>(self, len: usize, f: F) -> R {
let mut packet = vec![0; len];
let result = f(&mut packet);
self.0.push_from_os(Bytes::from(packet));
result
}
}
impl Device for TunDevice {
type RxToken<'a>
= Rx
where
Self: 'a;
type TxToken<'a>
= Tx
where
Self: 'a;
fn receive(&mut self, _: TcpInstant) -> Option<(Rx, Tx)> {
self.input
.lock()
.unwrap()
.pop_front()
.map(|packet| (Rx(packet), Tx(self.tun.clone())))
}
fn transmit(&mut self, _: TcpInstant) -> Option<Tx> {
Some(Tx(self.tun.clone()))
}
fn capabilities(&self) -> DeviceCapabilities {
let mut caps = DeviceCapabilities::default();
caps.medium = Medium::Ip;
caps.max_transmission_unit = 1280;
caps
}
}
struct Stack {
iface: Interface,
sockets: SocketSet<'static>,
socket: SocketHandle,
device: TunDevice,
reader: tokio::task::JoinHandle<()>,
}
impl Stack {
fn new(tun: Arc<MemoryTun>, address: Ipv4Addr) -> Self {
let input = Arc::new(Mutex::new(VecDeque::new()));
let queue = input.clone();
let received = tun.clone();
let reader = tokio::spawn(async move {
while let Some(packet) = received.pop_to_os().await {
let mut queue = queue.lock().unwrap();
assert!(queue.len() < 256);
queue.push_back(packet.to_vec());
}
});
let mut device = TunDevice { input, tun };
let mut config = Config::new(HardwareAddress::Ip);
config.random_seed = rand::random();
let mut iface = Interface::new(config, &mut device, TcpInstant::from_millis(0));
let [a, b, c, d] = address.octets();
iface.update_ip_addrs(|addresses| {
addresses
.push(IpCidr::new(IpAddress::v4(a, b, c, d), 24))
.unwrap()
});
let mut sockets = SocketSet::new(Vec::new());
let socket = sockets.add(tcp::Socket::new(
tcp::SocketBuffer::new(vec![0; 65536]),
tcp::SocketBuffer::new(vec![0; 65536]),
));
Self {
iface,
sockets,
socket,
device,
reader,
}
}
fn tcp(&mut self) -> &mut tcp::Socket<'static> {
self.sockets.get_mut(self.socket)
}
fn poll(&mut self, now: TcpInstant) {
self.iface.poll(now, &mut self.device, &mut self.sockets);
}
}
impl Drop for Stack {
fn drop(&mut self) {
self.reader.abort();
}
}
pub(super) struct TcpPair {
client: Stack,
server: Stack,
started: Instant,
}
impl TcpPair {
pub(super) async fn connect(
a: Arc<MemoryTun>,
a_ip: Ipv4Addr,
b: Arc<MemoryTun>,
b_ip: Ipv4Addr,
) -> Self {
let mut pair = Self {
client: Stack::new(a, a_ip),
server: Stack::new(b, b_ip),
started: Instant::now(),
};
pair.server.tcp().listen(22).unwrap();
let [a, b, c, d] = b_ip.octets();
pair.client
.sockets
.get_mut::<tcp::Socket>(pair.client.socket)
.connect(
pair.client.iface.context(),
(IpAddress::v4(a, b, c, d), 22),
40000,
)
.unwrap();
pair.until(|p| {
p.client.tcp().state() == tcp::State::Established
&& p.server.tcp().state() == tcp::State::Established
})
.await;
pair
}
fn poll(&mut self) {
let now = TcpInstant::from_millis(self.started.elapsed().as_millis() as i64);
self.client.poll(now);
self.server.poll(now);
}
async fn until(&mut self, mut ready: impl FnMut(&mut Self) -> bool) {
tokio::time::timeout(Duration::from_secs(10), async {
let mut ticks = tokio::time::interval(Duration::from_millis(2));
loop {
ticks.tick().await;
self.poll();
if ready(self) {
break;
}
}
})
.await
.expect("same TCP connection must progress across carrier/route changes");
}
pub(super) fn queue_while_unreachable(&mut self, payload: &[u8]) {
assert_eq!(
self.client.tcp().send_slice(payload).unwrap(),
payload.len()
);
self.poll();
}
pub(super) async fn receive_queued(&mut self, payload: &[u8]) {
let mut received = Vec::new();
self.until(|p| {
if p.server.tcp().can_recv() {
p.server
.tcp()
.recv(|bytes| {
received.extend_from_slice(bytes);
(bytes.len(), ())
})
.unwrap();
}
received.len() >= payload.len()
})
.await;
assert_eq!(received, payload);
assert_eq!(self.client.tcp().state(), tcp::State::Established);
assert_eq!(self.server.tcp().state(), tcp::State::Established);
}
pub(super) async fn exchange(&mut self, payload: &[u8]) {
self.queue_while_unreachable(payload);
self.receive_queued(payload).await;
assert_eq!(
self.server.tcp().send_slice(payload).unwrap(),
payload.len()
);
let mut received = Vec::new();
self.until(|p| {
if p.client.tcp().can_recv() {
p.client
.tcp()
.recv(|bytes| {
received.extend_from_slice(bytes);
(bytes.len(), ())
})
.unwrap();
}
received.len() >= payload.len()
})
.await;
assert_eq!(received, payload);
}
}
+947
View File
@@ -0,0 +1,947 @@
//! Real membership, UDP sockets, pair negotiation and nested WireGuard encryption.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#[path = "common/tcp.rs"]
mod tcp;
use bytes::Bytes;
use std::{
net::Ipv4Addr,
path::Path,
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::Duration,
};
use tempfile::TempDir;
use tsunagi::dataplane::IpPlugin;
use tsunagi::dataplane::transport::{
ControlSender, PacketLink, PacketTransport, SharedLink, TransportError,
};
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::overlay::{MemoryTun, MemoryTunFactory};
use tsunagi::testing::{config_with, network, wait_for_peers, wait_until};
use tsunagi::{Agent, BoxFuture, NetworkId};
use tsunagi_awg::{AWG_PROTOCOL, AwgPlugin, AwgTransport};
use tsunagi_wg_quic::WireguardConfig;
struct Node {
agent: Agent,
plugin: Arc<tsunagi_wg_quic::WireguardPlugin>,
transport: Arc<AwgTransport>,
tuns: MemoryTunFactory,
loss: Arc<AtomicBool>,
}
impl Node {
async fn open(root: &Path, discovery: &SharedMemoryDiscovery, interface: &str) -> Self {
Self::open_with_fallback(root, discovery, interface, false).await
}
async fn open_with_fallback(
root: &Path,
discovery: &SharedMemoryDiscovery,
interface: &str,
fallback: bool,
) -> Self {
Self::open_config(root, discovery, interface, fallback, 1280).await
}
async fn open_config(
root: &Path,
discovery: &SharedMemoryDiscovery,
interface: &str,
fallback: bool,
mtu: u32,
) -> Self {
Self::open_selected(root, discovery, interface, fallback, mtu, None).await
}
async fn open_selected(
root: &Path,
discovery: &SharedMemoryDiscovery,
interface: &str,
fallback: bool,
mtu: u32,
default_protocols: Option<Vec<String>>,
) -> Self {
let identity =
tsunagi::storage::StateStore::open(tsunagi::StoragePaths::under(root).state_db())
.unwrap()
.load_or_create_device_identity()
.unwrap();
let transport = AwgTransport::new(identity.endpoint_id(), Ipv4Addr::LOCALHOST);
let plugin = tsunagi_wg_quic::WireguardPlugin::open(
WireguardConfig::new(root.join("awg"))
.with_mtu(mtu)
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
)
.await
.unwrap();
let loss = Arc::new(AtomicBool::new(false));
let faults = Arc::new(FaultTransport {
inner: transport.clone(),
loss: loss.clone(),
});
let tuns = MemoryTunFactory::new();
let mut config = config_with(root, discovery)
.with_interface(Arc::new(tuns.clone()), interface, mtu)
.with_tunnel(plugin.clone() as Arc<dyn IpPlugin>)
.with_carrier(AwgPlugin::new(transport.clone()))
.with_custom_transport(AWG_PROTOCOL, faults);
if fallback {
config = config.with_carrier(Arc::new(
tsunagi::dataplane::transport::iroh_link::IrohCarrier,
));
}
config.default_protocols = default_protocols;
let agent = Agent::spawn(config).await.unwrap();
let inbound_agent = agent.clone();
let inbound_transport = transport.clone();
let inbound_loss = loss.clone();
tokio::spawn(async move {
while let Some(mut link) = inbound_transport.next_inbound().await {
link.link = impaired(link.link, inbound_loss.clone());
let _ = inbound_agent.install_inbound_link(link).await;
}
});
Self {
agent,
plugin,
transport,
tuns,
loss,
}
}
async fn ready(&self, id: NetworkId, n: usize) {
wait_until("AWG outer paths and end-to-end handshakes", || async {
(self
.transport
.overview(id)
.iter()
.filter(|p| !p.closed && !p.path.contains("establishing"))
.count()
== n
&& self
.plugin
.overview(id)?
.peers
.iter()
.filter(|p| {
p.tunnel
.as_ref()
.is_some_and(|t| t.health.is_up() && t.path.contains("awg UDP via"))
})
.count()
== n)
.then_some(())
})
.await;
}
fn tun(&self) -> Arc<MemoryTun> {
self.tuns
.device(&self.agent.overlay().unwrap().interface)
.unwrap()
}
fn addr(&self, id: NetworkId) -> Ipv4Addr {
self.plugin
.overview(id)
.unwrap()
.overlay_address_v4
.unwrap()
}
}
fn packet(source: Ipv4Addr, dest: Ipv4Addr, len: usize) -> Bytes {
let mut bytes = vec![0; len];
bytes[0] = 0x45;
bytes[2..4].copy_from_slice(&(len as u16).to_be_bytes());
bytes[8] = 64;
bytes[9] = 253;
bytes[12..16].copy_from_slice(&source.octets());
bytes[16..20].copy_from_slice(&dest.octets());
rand::fill(&mut bytes[20..]);
Bytes::from(bytes)
}
async fn exchange(a: &Node, b: &Node, id: NetworkId) {
for len in [64, 1097, 1280] {
let bytes = packet(a.addr(id), b.addr(id), len);
a.tun().push_from_os(bytes.clone());
assert_eq!(
tokio::time::timeout(Duration::from_secs(5), b.tun().pop_to_os())
.await
.unwrap()
.unwrap(),
bytes
);
}
}
#[tokio::test]
async fn a_control_only_agent_enables_real_data_without_restart_and_cancels_stale_attempts() {
let discovery = SharedMemoryDiscovery::new();
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
let a = Node::open_selected(
roots[0].path(),
&discovery,
"initial-none-a",
true,
1280,
Some(vec![]),
)
.await;
let b = Node::open_selected(
roots[1].path(),
&discovery,
"initial-none-b",
true,
1280,
Some(vec![]),
)
.await;
let (name, secret) = network("enable-after-control-only");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
wait_for_peers(&a.agent, id, 1).await;
wait_for_peers(&b.agent, id, 1).await;
assert!(a.agent.overlay().is_none());
assert!(b.agent.overlay().is_none());
for node in [&a, &b] {
assert!(
node.agent
.network_status(id)
.await
.unwrap()
.protocols
.is_empty()
);
node.agent
.set_protocols(id, vec!["awg".into()])
.await
.unwrap();
assert!(node.agent.overlay().is_some());
}
a.ready(id, 1).await;
b.ready(id, 1).await;
exchange(&a, &b, id).await;
let before = a.agent.overlay().unwrap().interface;
// Changes race unfinished opens and prepared plugin callbacks; the last
// selection must win even if an earlier open has completed in its queue.
for selection in [
vec!["wg-quic".into()],
vec![],
vec!["awg".into()],
vec![],
vec!["awg".into()],
] {
a.agent.set_protocols(id, selection).await.unwrap();
}
a.ready(id, 1).await;
b.ready(id, 1).await;
exchange(&a, &b, id).await;
assert_eq!(a.agent.overlay().unwrap().interface, before);
for node in [&a, &b] {
let status = node.agent.network_status(id).await.unwrap();
assert_eq!(status.protocols, ["awg"]);
assert_eq!(status.metrics.sessions_established, 1);
assert_eq!(status.metrics.disconnects, 0);
}
a.agent.shutdown().await;
b.agent.shutdown().await;
}
#[tokio::test]
async fn runtime_protocol_selection_preserves_real_tunnels_and_survives_restart() {
let discovery = SharedMemoryDiscovery::new();
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
let a = Node::open_with_fallback(roots[0].path(), &discovery, "runtime-a", true).await;
let b = Node::open_with_fallback(roots[1].path(), &discovery, "runtime-b", true).await;
let (name, secret) = network("runtime-protocols");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
a.ready(id, 1).await;
b.ready(id, 1).await;
exchange(&a, &b, id).await;
let original_key = a.plugin.overview(id).unwrap().public_key;
let original_stats = a.plugin.overview(id).unwrap().peers[0]
.tunnel
.as_ref()
.unwrap()
.stats;
let (other_name, other_secret, other_id) = loop {
let (name, secret) = network("unchanged-runtime-network");
let other_id = tsunagi::identity::NetworkKeys::derive(&name, &secret).network_id();
if tsunagi::state::derived_ipv4_range(id) != tsunagi::state::derived_ipv4_range(other_id) {
break (name, secret, other_id);
}
};
a.agent
.join_network(&other_name, &other_secret)
.await
.unwrap();
b.agent
.join_network(&other_name, &other_secret)
.await
.unwrap();
a.ready(other_id, 1).await;
b.ready(other_id, 1).await;
let other_before = a
.agent
.network_status(other_id)
.await
.unwrap()
.metrics
.data_links_established;
for node in [&a, &b] {
let before = node.agent.network_status(id).await.unwrap().protocols;
for invalid in [vec!["unknown".into()], vec!["awg".into(), "awg".into()]] {
assert!(node.agent.set_protocols(id, invalid).await.is_err());
assert_eq!(
node.agent.network_status(id).await.unwrap().protocols,
before
);
}
}
// Exercise updates initiated by either endpoint-id role, including an
// empty set. Membership and the control session stay alive throughout.
for node in [&a, &b] {
node.agent.set_protocols(id, Vec::new()).await.unwrap();
wait_until("peer accepts control-only selection", || async {
let other = if node.agent.endpoint_id() == a.agent.endpoint_id() {
&b
} else {
&a
};
let status = other.agent.network_status(id).await.ok()?;
(status.peers.len() == 1
&& status.peers[0].protocols.is_empty()
&& matches!(status.peers[0].data_path, tsunagi::agent::DataPath::None))
.then_some(())
})
.await;
assert_eq!(node.agent.network_status(id).await.unwrap().peers.len(), 1);
node.agent
.set_protocols(id, vec!["wg-quic".into(), "awg".into()])
.await
.unwrap();
}
a.agent
.set_protocols(id, vec!["wg-quic".into()])
.await
.unwrap();
wait_until("real QUIC replaces AWG", || async {
let a_status = a.agent.network_status(id).await.ok()?;
let b_status = b.agent.network_status(id).await.ok()?;
(a_status.peers[0].protocols == ["wg-quic"]
&& b_status.peers[0].protocols == ["wg-quic"]
&& !matches!(a_status.peers[0].data_path, tsunagi::agent::DataPath::None)
&& !matches!(b_status.peers[0].data_path, tsunagi::agent::DataPath::None)
&& a.transport.overview(id).iter().all(|p| p.closed)
&& b.transport.overview(id).iter().all(|p| p.closed))
.then_some(())
})
.await;
assert_eq!(a.plugin.overview(id).unwrap().public_key, original_key);
let stats = a.plugin.overview(id).unwrap().peers[0]
.tunnel
.as_ref()
.unwrap()
.stats;
assert_eq!(stats.counter_epoch, original_stats.counter_epoch);
assert!(stats.tx_bytes >= original_stats.tx_bytes);
assert!(stats.rx_bytes >= original_stats.rx_bytes);
assert!(a.plugin.overview(id).unwrap().peers.iter().any(|peer| {
peer.tunnel
.as_ref()
.is_some_and(|tunnel| tunnel.health.is_up())
}));
// The same internal plugin supplies addresses and counters on the new carrier.
for (source, dest) in [(&a, &b), (&b, &a)] {
wait_until("QUIC end-to-end handshake", || async {
source
.plugin
.overview(id)?
.peers
.iter()
.any(|p| p.tunnel.as_ref().is_some_and(|t| t.health.is_up()))
.then_some(())
})
.await;
let addr = |n: &Node| n.plugin.overview(id).unwrap().overlay_address_v4.unwrap();
let bytes = packet(addr(source), addr(dest), 1280);
source.tun().push_from_os(bytes.clone());
assert_eq!(
tokio::time::timeout(Duration::from_secs(5), dest.tun().pop_to_os())
.await
.unwrap()
.unwrap(),
bytes
);
}
assert_eq!(
a.agent
.network_status(other_id)
.await
.unwrap()
.metrics
.data_links_established,
other_before
);
exchange(&a, &b, other_id).await;
a.agent.shutdown().await;
let restarted = Node::open_with_fallback(roots[0].path(), &discovery, "runtime-a", true).await;
wait_for_peers(&restarted.agent, id, 1).await;
assert_eq!(
restarted.agent.network_status(id).await.unwrap().protocols,
["wg-quic"]
);
assert_eq!(
b.agent.network_status(other_id).await.unwrap().protocols,
["awg", "wg-quic"]
);
restarted.ready(other_id, 1).await;
b.ready(other_id, 1).await;
exchange(&restarted, &b, other_id).await;
restarted
.agent
.set_protocols(id, vec!["awg".into()])
.await
.unwrap();
restarted.ready(id, 1).await;
b.ready(id, 1).await;
exchange(&restarted, &b, id).await;
restarted.agent.shutdown().await;
b.agent.shutdown().await;
}
#[tokio::test]
async fn real_pairs_carry_full_size_packets_and_restart_with_fresh_profiles() {
tsunagi::testing::init_tracing();
let discovery = SharedMemoryDiscovery::new();
let roots = [
TempDir::new().unwrap(),
TempDir::new().unwrap(),
TempDir::new().unwrap(),
];
let a = Node::open(roots[0].path(), &discovery, "awga").await;
let b = Node::open(roots[1].path(), &discovery, "awgb").await;
let c = Node::open(roots[2].path(), &discovery, "awgc").await;
let (name, secret) = network("awg-pairs");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
c.agent.join_network(&name, &secret).await.unwrap();
for node in [&a, &b, &c] {
wait_for_peers(&node.agent, id, 2).await;
node.ready(id, 2).await;
}
let ids: std::collections::HashSet<_> = [&a, &b, &c]
.into_iter()
.flat_map(|n| n.transport.overview(id).into_iter().map(|p| p.attempt))
.collect();
assert_eq!(
ids.len(),
3,
"one independent profile for each of three pairs"
);
exchange(&a, &b, id).await;
exchange(&b, &a, id).await;
exchange(&a, &c, id).await;
let old_ports: Vec<_> = a
.transport
.overview(id)
.into_iter()
.filter_map(|p| p.local_port)
.collect();
a.agent.shutdown().await;
for port in old_ports {
wait_until("old pair socket released", || async {
tokio::net::UdpSocket::bind((Ipv4Addr::LOCALHOST, port))
.await
.ok()
})
.await;
}
let restarted = Node::open(roots[0].path(), &discovery, "awga").await;
restarted.ready(id, 2).await;
assert!(
restarted
.transport
.overview(id)
.iter()
.all(|p| !ids.contains(&p.attempt))
);
exchange(&restarted, &b, id).await;
exchange(&c, &restarted, id).await;
restarted.agent.shutdown().await;
b.agent.shutdown().await;
c.agent.shutdown().await;
assert!(
tokio::time::timeout(Duration::from_secs(1), restarted.transport.next_inbound())
.await
.unwrap()
.is_none()
);
}
// Drops datagrams at the link boundary. Membership, control, sockets, crypto,
// addressing and storage remain real. This substitutes a lossy physical path.
#[derive(Debug)]
struct FaultTransport {
inner: Arc<AwgTransport>,
loss: Arc<AtomicBool>,
}
impl PacketTransport for FaultTransport {
fn name(&self) -> &str {
AWG_PROTOCOL
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn attach_control(&self, sender: Arc<dyn ControlSender>) {
self.inner.attach_control(sender);
}
fn on_control(
&self,
network: NetworkId,
peer: iroh::EndpointId,
protocol: &str,
bytes: &[u8],
) -> Result<(), TransportError> {
self.inner.on_control(network, peer, protocol, bytes)
}
fn open<'a>(
&'a self,
network: NetworkId,
peer: iroh::EndpointId,
protocol: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
Box::pin(async move {
Ok(impaired(
self.inner.open(network, peer, protocol).await?,
self.loss.clone(),
))
})
}
}
fn impaired(raw: SharedLink, loss: Arc<AtomicBool>) -> SharedLink {
Arc::new(FaultLink {
raw,
loss,
sequence: AtomicU64::new(0),
})
}
#[derive(Debug)]
struct FaultLink {
raw: SharedLink,
loss: Arc<AtomicBool>,
sequence: AtomicU64,
}
impl PacketLink for FaultLink {
fn network(&self) -> NetworkId {
self.raw.network()
}
fn peer(&self) -> iroh::EndpointId {
self.raw.peer()
}
fn max_datagram_size(&self) -> usize {
self.raw.max_datagram_size()
}
fn send(&self, bytes: Bytes) -> Result<(), TransportError> {
if self.loss.load(Ordering::Relaxed)
&& !self
.sequence
.fetch_add(1, Ordering::Relaxed)
.is_multiple_of(5)
{
Ok(())
} else {
self.raw.send(bytes)
}
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
self.raw.recv()
}
fn closed(&self) -> BoxFuture<'_, ()> {
self.raw.closed()
}
fn is_closed(&self) -> bool {
self.raw.is_closed()
}
fn close(&self) {
self.raw.close();
}
fn path_description(&self) -> String {
self.raw.path_description()
}
}
#[tokio::test]
async fn persistent_runtime_loss_retires_the_pair_and_negotiates_fresh_parameters() {
let discovery = SharedMemoryDiscovery::new();
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
let a = Node::open(roots[0].path(), &discovery, "lossa").await;
let b = Node::open(roots[1].path(), &discovery, "lossb").await;
let (name, secret) = network("awg-loss");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
a.ready(id, 1).await;
b.ready(id, 1).await;
exchange(&a, &b, id).await;
let old = a.transport.overview(id)[0].attempt;
let mut events = a.agent.subscribe();
a.loss.store(true, Ordering::Relaxed);
tsunagi::testing::wait_event(&mut events, |event| match event {
tsunagi::Event::DataLinkDown {
network, reason, ..
} if *network == id && reason.contains("delivery checks") => Some(()),
_ => None,
})
.await;
a.loss.store(false, Ordering::Relaxed);
wait_until("fresh AWG profile after runtime loss", || async {
a.transport
.overview(id)
.iter()
.any(|p| p.attempt != old && !p.closed && !p.path.contains("establishing"))
.then_some(())
})
.await;
a.ready(id, 1).await;
b.ready(id, 1).await;
let initiator = if a.agent.endpoint_id().as_bytes() < b.agent.endpoint_id().as_bytes() {
&a
} else {
&b
};
let attempt = initiator.transport.overview(id)[0].attempt;
let received = initiator.transport.overview(id)[0].delivery_checks;
wait_until(
"fresh physical delivery checks answered after installation",
|| async {
(initiator.transport.overview(id).first()?.delivery_checks >= received + 3)
.then_some(())
},
)
.await;
assert_eq!(initiator.transport.overview(id)[0].attempt, attempt);
wait_for_peers(&a.agent, id, 1).await;
exchange(&a, &b, id).await;
exchange(&b, &a, id).await;
a.agent.shutdown().await;
b.agent.shutdown().await;
}
#[tokio::test]
async fn runtime_loss_falls_back_to_real_iroh_without_interrupting_control_or_ip() {
let discovery = SharedMemoryDiscovery::new();
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
let a = Node::open_with_fallback(roots[0].path(), &discovery, "fallba", true).await;
let b = Node::open_with_fallback(roots[1].path(), &discovery, "fallbb", true).await;
let (name, secret) = network("awg-fallback");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
a.ready(id, 1).await;
b.ready(id, 1).await;
exchange(&a, &b, id).await;
let mut events = a.agent.subscribe();
a.loss.store(true, Ordering::Relaxed);
tsunagi::testing::wait_event(&mut events, |event| match event {
tsunagi::Event::DataLinkDown {
network, reason, ..
} if *network == id && reason.contains("delivery checks") => Some(()),
_ => None,
})
.await;
for node in [&a, &b] {
wait_until("fallback carrier is wg-quic",||async {
let status=node.agent.status().await.ok()?;
let network=status.networks.iter().find(|n|n.network_id==id)?;
(network.members.iter().any(|m|matches!(&m.data_path,tsunagi::agent::DataPath::Direct{transport} if transport=="wg-quic"))).then_some(())
}).await;
wait_for_peers(&node.agent, id, 1).await;
}
exchange(&a, &b, id).await;
exchange(&b, &a, id).await;
a.agent.shutdown().await;
b.agent.shutdown().await;
}
#[tokio::test]
async fn opaque_pair_carrier_fragments_its_full_advertised_capacity() {
let discovery = SharedMemoryDiscovery::new();
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
let mut nodes = Vec::new();
for root in &roots {
let identity = tsunagi::storage::StateStore::open(
tsunagi::StoragePaths::under(root.path()).state_db(),
)
.unwrap()
.load_or_create_device_identity()
.unwrap();
let transport = AwgTransport::new(identity.endpoint_id(), Ipv4Addr::LOCALHOST);
let agent = Agent::spawn(
config_with(root.path(), &discovery)
.with_custom_transport(AWG_PROTOCOL, transport.clone()),
)
.await
.unwrap();
nodes.push((agent, transport));
}
let (name, secret) = network("awg-capacity");
let id = nodes[0].0.join_network(&name, &secret).await.unwrap();
nodes[1].0.join_network(&name, &secret).await.unwrap();
for (i, (agent, transport)) in nodes.iter().enumerate() {
wait_for_peers(agent, id, 1).await;
// This fixture tests only the transport contract: register the member
// after the real core has authenticated it, without an IP plugin/TUN.
transport.allow_peer(id, nodes[1 - i].0.endpoint_id());
}
nodes.sort_by_key(|(agent, _)| *agent.endpoint_id().as_bytes());
let outgoing = nodes[0]
.1
.open(id, nodes[1].0.endpoint_id(), AWG_PROTOCOL)
.await
.unwrap();
let incoming = tokio::time::timeout(Duration::from_secs(5), nodes[1].1.next_inbound())
.await
.unwrap()
.unwrap()
.link;
for (from, to) in [(&outgoing, &incoming), (&incoming, &outgoing)] {
let mut bytes = vec![0; from.max_datagram_size()];
rand::fill(&mut bytes[..]);
let bytes = Bytes::from(bytes);
from.send(bytes.clone()).unwrap();
assert_eq!(
tokio::time::timeout(Duration::from_secs(5), to.recv())
.await
.unwrap()
.unwrap(),
bytes
);
assert!(matches!(
from.send(Bytes::from(vec![0; from.max_datagram_size() + 1])),
Err(TransportError::TooLarge { .. })
));
}
outgoing.close();
incoming.close();
for (agent, transport) in nodes {
transport.shutdown();
agent.shutdown().await;
}
}
#[tokio::test]
async fn encrypted_health_checks_fit_a_smaller_peers_configured_interface_mtu() {
let discovery = SharedMemoryDiscovery::new();
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
let a = Node::open_config(roots[0].path(), &discovery, "smalla", false, 1280).await;
let b = Node::open_config(roots[1].path(), &discovery, "smallb", false, 576).await;
let (name, secret) = network("awg-small-mtu");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
a.ready(id, 1).await;
b.ready(id, 1).await;
let bytes = packet(a.addr(id), b.addr(id), 576);
a.tun().push_from_os(bytes.clone());
assert_eq!(
tokio::time::timeout(Duration::from_secs(5), b.tun().pop_to_os())
.await
.unwrap()
.unwrap(),
bytes
);
wait_until(
"physical delivery checks do not depend on overlay MTU",
|| async {
(a.transport.overview(id).first()?.delivery_checks >= 4
&& b.transport.overview(id).first()?.delivery_checks >= 4)
.then_some(())
},
)
.await;
for node in [&a, &b] {
assert_eq!(
node.plugin.overview(id).unwrap().peers[0]
.tunnel
.as_ref()
.unwrap()
.stats
.dropped_oversize,
0
);
}
a.agent.shutdown().await;
b.agent.shutdown().await;
}
#[tokio::test]
async fn disjoint_external_policies_relay_the_same_internal_tunnel_and_keep_it_when_routes_change()
{
let discovery = SharedMemoryDiscovery::new();
let roots = [
TempDir::new().unwrap(),
TempDir::new().unwrap(),
TempDir::new().unwrap(),
];
let a = Node::open_selected(
roots[0].path(),
&discovery,
"only-awg",
true,
1280,
Some(vec!["awg".into()]),
)
.await;
let b = Node::open_with_fallback(roots[1].path(), &discovery, "mixed-relay", true).await;
let c = Node::open_selected(
roots[2].path(),
&discovery,
"only-quic",
true,
1280,
Some(vec!["wg-quic".into()]),
)
.await;
let (name, secret) = network("independent-internal-encryption");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
c.agent.join_network(&name, &secret).await.unwrap();
for node in [&a, &b, &c] {
wait_for_peers(&node.agent, id, 2).await;
}
wait_until("AWG-only to QUIC-only through an authenticated mixed relay", || async {
let status = a.agent.network_status(id).await.ok()?;
let peer = status.peers.iter().find(|p| p.endpoint_id == c.agent.endpoint_id())?;
(matches!(peer.data_path, tsunagi::agent::DataPath::Relayed { via, .. } if via == b.agent.endpoint_id()) && a.plugin.overview(id)?.established_peers() == 2 && c.plugin.overview(id)?.established_peers() == 2).then_some(())
}).await;
exchange(&a, &c, id).await;
exchange(&c, &a, id).await;
let mut tcp = tcp::TcpPair::connect(a.tun(), a.addr(id), c.tun(), c.addr(id)).await;
tcp.exchange(b"SSH-like TCP stream over the initial relay")
.await;
let key = a.plugin.overview(id).unwrap().public_key;
let before = a
.plugin
.overview(id)
.unwrap()
.peers
.iter()
.find(|p| p.endpoint_id == c.agent.endpoint_id())
.unwrap()
.tunnel
.as_ref()
.unwrap()
.stats
.tx_packets;
// A's additional permission creates a direct QUIC route without replacing its IP tunnel.
a.agent
.set_protocols(id, vec!["wg-quic".into(), "awg".into()])
.await
.unwrap();
wait_until("direct carrier replaces the relay", || async {
let status = a.agent.network_status(id).await.ok()?;
matches!(
status
.peers
.iter()
.find(|p| p.endpoint_id == c.agent.endpoint_id())?
.data_path,
tsunagi::agent::DataPath::Direct { .. }
)
.then_some(())
})
.await;
tcp.exchange(b"same TCP socket after changing the carrier and next hop")
.await;
assert_eq!(a.plugin.overview(id).unwrap().public_key, key);
assert!(
a.plugin
.overview(id)
.unwrap()
.peers
.iter()
.find(|p| p.endpoint_id == c.agent.endpoint_id())
.unwrap()
.tunnel
.as_ref()
.unwrap()
.stats
.tx_packets
> before
);
a.agent.set_protocols(id, Vec::new()).await.unwrap();
// Poll the actual TCP stack while the route is absent: its first transmission is lost.
let pending = b"TCP retransmission survives the temporary absence of every data carrier";
tcp.queue_while_unreachable(pending);
a.agent.set_protocols(id, vec!["awg".into()]).await.unwrap();
wait_until("forbidden QUIC removed and relay restored", || async {
let status = a.agent.network_status(id).await.ok()?;
matches!(
status
.peers
.iter()
.find(|p| p.endpoint_id == c.agent.endpoint_id())?
.data_path,
tsunagi::agent::DataPath::Relayed { .. }
)
.then_some(())
})
.await;
assert_eq!(a.plugin.overview(id).unwrap().public_key, key);
tcp.receive_queued(pending).await;
tcp.exchange(b"same TCP connection after returning to AWG and the relay")
.await;
drop(tcp);
// Transit ciphertext must never be delivered to the middle agent's IP interface.
assert!(
tokio::time::timeout(Duration::from_millis(150), b.tun().pop_to_os())
.await
.is_err()
);
for node in [&a, &b, &c] {
node.agent.shutdown().await;
}
}
#[tokio::test]
async fn no_common_carrier_is_an_actionable_policy_error_without_disabling_control() {
let discovery = SharedMemoryDiscovery::new();
let roots = [TempDir::new().unwrap(), TempDir::new().unwrap()];
let a = Node::open_selected(
roots[0].path(),
&discovery,
"awg-policy",
true,
1280,
Some(vec!["awg".into()]),
)
.await;
let b = Node::open_selected(
roots[1].path(),
&discovery,
"quic-policy",
true,
1280,
Some(vec!["wg-quic".into()]),
)
.await;
let (name, secret) = network("no-common-carrier");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
wait_until("explain independent external policies", || async {
let status = a.agent.network_status(id).await.ok()?;
let peer = status.peers.first()?;
peer.data_error
.as_ref()?
.contains("no common external transport")
.then_some(())
})
.await;
assert_eq!(
a.agent
.network_status(id)
.await
.unwrap()
.metrics
.data_links_established,
0
);
a.agent.shutdown().await;
b.agent.shutdown().await;
}
+36
View File
@@ -0,0 +1,36 @@
//! Fresh and bounded masking profiles, and rejection of foreign/truncated wire packets.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tsunagi_awg::profile::PairProfile;
#[test]
fn profiles_are_fresh_bounded_and_redacted() {
let profiles: Vec<_> = (0..128).map(|_| PairProfile::generate()).collect();
for (i, profile) in profiles.iter().enumerate() {
profile.validate().unwrap();
assert!(!format!("{profile:?}").contains(&format!("{:?}", profile.header_key())));
for other in &profiles[..i] {
assert_ne!(profile.header_key(), other.header_key());
assert_ne!(profile.id(), other.id());
}
for packet in profile.signature_packets() {
assert!(packet.len() < 1200);
}
}
}
#[test]
fn masked_packets_round_trip_and_reject_other_profiles_and_truncation() {
let profile = PairProfile::generate();
let other = PairProfile::generate();
for (kind, size) in [(1, 148), (2, 92), (3, 64), (4, 800)] {
let mut message = vec![0x7b; size];
message[..4].copy_from_slice(&(kind as u32).to_le_bytes());
let wire = profile.encode(&message).unwrap();
assert_ne!(&wire[..4], &message[..4]);
assert_eq!(profile.decode(&wire).unwrap(), message);
assert!(other.decode(&wire).is_none());
for length in 0..44 {
assert!(profile.decode(&wire[..length]).is_none());
}
}
}
+13
View File
@@ -129,6 +129,19 @@ pub enum Event {
/// Why it is not up. /// Why it is not up.
reason: String, reason: String,
}, },
/// The name this agent claimed was claimed first by another member.
///
/// Nothing stops working: this agent keeps its address and every
/// connection. It just is not found by that name until it is renamed or
/// the member that holds the name lets it go.
HostnameTaken {
/// The network.
network: NetworkId,
/// The name that is taken.
name: String,
/// The member that holds it.
holder: EndpointId,
},
/// An IP plugin reported an error. Never fatal. /// An IP plugin reported an error. Never fatal.
PluginError { PluginError {
/// The network the call was scoped to. /// The network the call was scoped to.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -70,7 +70,7 @@ pub(crate) struct Session {
pub(crate) conn: Connection, pub(crate) conn: Connection,
/// Already encoded frame payloads, so the runtime can account for the exact /// Already encoded frame payloads, so the runtime can account for the exact
/// number of control bytes it queues. /// number of control bytes it queues.
pub(crate) outbound: mpsc::Sender<Vec<u8>>, pub(crate) outbound: mpsc::Sender<zeroize::Zeroizing<Vec<u8>>>,
pub(crate) hostname: Option<String>, pub(crate) hostname: Option<String>,
pub(crate) capabilities: Vec<PluginCapability>, pub(crate) capabilities: Vec<PluginCapability>,
pub(crate) broadcast: bool, pub(crate) broadcast: bool,
@@ -244,7 +244,7 @@ pub(crate) fn spawn(
async fn writer_task( async fn writer_task(
mut send: SendStream, mut send: SendStream,
mut outbound: mpsc::Receiver<Vec<u8>>, mut outbound: mpsc::Receiver<zeroize::Zeroizing<Vec<u8>>>,
limits: Arc<Limits>, limits: Arc<Limits>,
shutdown: Shutdown, shutdown: Shutdown,
parent: Shutdown, parent: Shutdown,
@@ -331,6 +331,7 @@ async fn reader_task(
} }
}; };
let payload = zeroize::Zeroizing::new(payload);
let bytes = payload.len(); let bytes = payload.len();
let envelope: Envelope = match decode(&payload) { let envelope: Envelope = match decode(&payload) {
Ok(envelope) => envelope, Ok(envelope) => envelope,
+76 -6
View File
@@ -59,6 +59,43 @@ pub struct OverlayStatus {
pub broadcast_rules: Option<crate::overlay::BroadcastRulesReport>, pub broadcast_rules: Option<crate::overlay::BroadcastRulesReport>,
} }
/// How packets reach a member right now.
///
/// Being reachable is one thing and being reachable well is another, and
/// the second is never a reason to call a member away: a member that can only
/// be reached through somebody else is online, over a longer path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DataPath {
/// Nothing carries packets to it yet. The member is connected for
/// control only, which is the state of a data path being set up.
None,
/// A direct link, of the named kind of transport.
Direct {
/// The kind of transport carrying it, e.g. `wg`.
transport: String,
},
/// Through other members.
Relayed {
/// Number of links on the way, at least two.
hops: u8,
/// The member the first of them goes to.
via: EndpointId,
},
}
/// A name that a member claimed and somebody else holds.
///
/// A name belongs to whoever claimed it first. The member that claimed it
/// later is still a member and still reachable by its address; it is just
/// not found by that name, on any member of the network.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostnameConflict {
/// The name that is taken.
pub name: String,
/// The member that holds it.
pub holder: EndpointId,
}
/// A member the signed state knows about, connected or not. /// A member the signed state knows about, connected or not.
/// ///
/// This is the durable roster: it comes from signed records, so a member that /// This is the durable roster: it comes from signed records, so a member that
@@ -71,6 +108,8 @@ pub struct OverlayStatus {
/// connected. /// connected.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberStatus { pub struct MemberStatus {
/// Current signed claim version, used to bind cleanup confirmation.
pub record_version: u64,
/// The member's device identity. /// The member's device identity.
pub endpoint_id: EndpointId, pub endpoint_id: EndpointId,
/// The IPv4 overlay address it claimed and signed for. /// The IPv4 overlay address it claimed and signed for.
@@ -85,6 +124,15 @@ pub struct MemberStatus {
/// Not signed, so it is a convenience for naming a member that is away /// Not signed, so it is a convenience for naming a member that is away
/// and never a claim: the zone is served from `hostname`. /// and never a claim: the zone is served from `hostname`.
pub last_hostname: Option<String>, pub last_hostname: Option<String>,
/// The name it claimed, when somebody else claimed it first.
pub hostname_conflict: Option<HostnameConflict>,
/// Whether this agent can reach it now, by any path: a live control
/// session, or a data path through other members. The one answer to "is
/// it online" for every client, so that none of them has to work it out
/// from a different part of the picture. This agent counts as online.
pub online: bool,
/// How packets reach it. [`DataPath::None`] while it is offline.
pub data_path: DataPath,
} }
/// Status of one authenticated session. /// Status of one authenticated session.
@@ -110,6 +158,8 @@ pub struct PeerStatus {
pub protocols: Vec<String>, pub protocols: Vec<String>,
/// Capabilities the peer announced. Payloads stay opaque. /// Capabilities the peer announced. Payloads stay opaque.
pub capabilities: Vec<PluginCapability>, pub capabilities: Vec<PluginCapability>,
/// Why no data route is available, when known.
pub data_error: Option<String>,
/// How long the session has been up. /// How long the session has been up.
pub connected_for: Duration, pub connected_for: Duration,
/// Verified paths of the underlying connection. /// Verified paths of the underlying connection.
@@ -128,6 +178,8 @@ pub struct PeerStatus {
pub control_bytes_sent: u64, pub control_bytes_sent: u64,
/// Control payload bytes read from this session. /// Control payload bytes read from this session.
pub control_bytes_received: u64, pub control_bytes_received: u64,
/// How packets reach this peer right now.
pub data_path: DataPath,
} }
/// Counters scoped to one logical network. /// Counters scoped to one logical network.
@@ -161,9 +213,26 @@ pub struct NetworkMetrics {
pub data_link_failures: u64, pub data_link_failures: u64,
} }
/// An active network preventing another network from starting locally.
#[derive(Debug, Clone)]
pub struct RangeConflict {
/// The blocking network's public identifier.
pub network_id: NetworkId,
/// Its configured name.
pub name: NetworkName,
/// Its overlapping overlay range.
pub range: crate::state::Ipv4Range,
}
/// Status of one network. /// Status of one network.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NetworkStatus { pub struct NetworkStatus {
/// Shared service configuration with this client's routing observations.
pub vips: Vec<crate::vip::VipStatus>,
/// Enabled local data-plane protocols, in preference order.
pub protocols: Vec<String>,
/// Whether this network is requested to start or resume automatically.
pub auto_start: bool,
/// Local broadcast participation in this network. /// Local broadcast participation in this network.
pub broadcast: bool, pub broadcast: bool,
/// This agent offers itself as an exit node in this network. /// This agent offers itself as an exit node in this network.
@@ -188,15 +257,16 @@ pub struct NetworkStatus {
pub candidates: Vec<CandidateStatus>, pub candidates: Vec<CandidateStatus>,
/// Members the signed state knows about, whether connected or not. /// Members the signed state knows about, whether connected or not.
pub members: Vec<MemberStatus>, pub members: Vec<MemberStatus>,
/// This agent's own name, when another member claimed it first and so
/// holds it. This agent keeps working by address; nobody finds it by
/// this name until it is renamed or the other member lets it go.
pub hostname_conflict: Option<HostnameConflict>,
/// The overlay range this network uses, once it has one. /// The overlay range this network uses, once it has one.
pub range: Option<crate::state::Ipv4Range>, pub range: Option<crate::state::Ipv4Range>,
/// The range it could not have, because another network on this agent /// The first overlapping active range; `range_conflicts` identifies all blockers.
/// 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>, pub range_conflict: Option<crate::state::Ipv4Range>,
/// Active networks that would be suspended by switching to this network.
pub range_conflicts: Vec<RangeConflict>,
/// Per-network counters. /// Per-network counters.
pub metrics: NetworkMetrics, pub metrics: NetworkMetrics,
/// What has gone through a peer in the middle, in both directions. /// What has gone through a peer in the middle, in both directions.
+59 -10
View File
@@ -158,6 +158,22 @@ pub enum TransportPolicy {
/// object where that is possible (notably [`Limits::max_frame_len`]). /// object where that is possible (notably [`Limits::max_frame_len`]).
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Limits { pub struct Limits {
/// Maximum active VIP identities per network.
pub max_vips: usize,
/// Maximum permanent VIP deletion tombstones per network.
pub max_vip_tombstones: usize,
/// Maximum concurrent active TCP port checks across the agent.
pub max_vip_checks: usize,
/// Maximum signed fields per VIP, including removed fields.
pub max_vip_fields: usize,
/// Maximum listeners per VIP.
pub max_vip_listeners: usize,
/// Maximum backend endpoints per listener.
pub max_vip_backends: usize,
/// Maximum simultaneous local VIP flow bindings.
pub max_vip_flows: usize,
/// Maximum incomplete IPv4 datagrams per network in VIP reassembly.
pub max_vip_fragments: usize,
/// Largest accepted control frame payload, in bytes. /// Largest accepted control frame payload, in bytes.
pub max_frame_len: usize, pub max_frame_len: usize,
/// Largest accepted hostname, in bytes. /// Largest accepted hostname, in bytes.
@@ -166,12 +182,16 @@ pub struct Limits {
pub max_capabilities: usize, pub max_capabilities: usize,
/// Largest opaque plugin payload, in bytes. /// Largest opaque plugin payload, in bytes.
pub max_capability_data_len: usize, pub max_capability_data_len: usize,
/// Largest private transport negotiation payload (also hard-capped at 4096 bytes).
pub max_transport_payload_len: usize,
/// Largest accepted echo payload in a ping/pong exchange, in bytes. /// Largest accepted echo payload in a ping/pong exchange, in bytes.
pub max_echo_payload_len: usize, pub max_echo_payload_len: usize,
/// Largest accepted free-text reason string, in bytes. /// Largest accepted free-text reason string, in bytes.
pub max_reason_len: usize, pub max_reason_len: usize,
/// Largest number of signed records accepted in one snapshot. /// Largest number of signed records accepted in one snapshot.
pub max_state_records: usize, pub max_state_records: usize,
/// Maximum durable retirement markers retained per network.
pub max_retirements: usize,
/// Deadline for the whole handshake. /// Deadline for the whole handshake.
pub handshake_timeout: Duration, pub handshake_timeout: Duration,
/// Deadline for one outbound dial attempt. /// Deadline for one outbound dial attempt.
@@ -201,13 +221,23 @@ pub struct Limits {
impl Default for Limits { impl Default for Limits {
fn default() -> Self { fn default() -> Self {
Self { Self {
max_vips: 64,
max_vip_tombstones: 1024,
max_vip_checks: 32,
max_vip_fields: 192,
max_vip_listeners: 16,
max_vip_backends: 32,
max_vip_flows: 4096,
max_vip_fragments: 64,
max_frame_len: 64 * 1024, max_frame_len: 64 * 1024,
max_hostname_len: 255, max_hostname_len: 255,
max_capabilities: 16, max_capabilities: 16,
max_transport_payload_len: 4096,
max_capability_data_len: 4 * 1024, max_capability_data_len: 4 * 1024,
max_echo_payload_len: 4 * 1024, max_echo_payload_len: 4 * 1024,
max_reason_len: 256, max_reason_len: 256,
max_state_records: crate::state::MAX_RECORDS_PER_MESSAGE, max_state_records: crate::state::MAX_RECORDS_PER_MESSAGE,
max_retirements: 1024,
handshake_timeout: Duration::from_secs(10), handshake_timeout: Duration::from_secs(10),
dial_timeout: Duration::from_secs(10), dial_timeout: Duration::from_secs(10),
write_timeout: Duration::from_secs(30), write_timeout: Duration::from_secs(30),
@@ -323,8 +353,13 @@ pub struct AgentConfig {
pub limits: Limits, pub limits: Limits,
/// Reconnect backoff policy. /// Reconnect backoff policy.
pub reconnect: ReconnectPolicy, pub reconnect: ReconnectPolicy,
/// IP plugins whose capabilities are announced and dispatched. /// External data carriers whose capabilities are announced and dispatched.
pub plugins: Vec<SharedPlugin>, pub carriers: Vec<crate::dataplane::transport::SharedCarrier>,
/// The single internal end-to-end engine, independent of carrier selection.
pub tunnel: Option<SharedPlugin>,
/// Initial ordered selection for networks without a saved override.
/// `None` enables all registered carriers; an empty list is control-only.
pub default_protocols: Option<Vec<String>>,
/// Custom packet transports registered per protocol. /// Custom packet transports registered per protocol.
pub custom_transports: pub custom_transports:
std::collections::HashMap<String, Arc<dyn crate::dataplane::PacketTransport>>, std::collections::HashMap<String, Arc<dyn crate::dataplane::PacketTransport>>,
@@ -339,7 +374,11 @@ pub struct AgentConfig {
pub interface_name: String, pub interface_name: String,
/// The interface MTU. /// The interface MTU.
pub interface_mtu: u32, pub interface_mtu: u32,
/// The IPv4 overlay range this agent proposes. /// Whether new networks derive their IPv4 range from their network id.
///
/// Enabled by default. Explicit ranges and IPv4 opt-out disable this.
pub overlay_ipv4_auto: bool,
/// The IPv4 overlay range this agent proposes when automatic selection is off.
/// ///
/// Addresses are allocated from it and recorded in signed state, so a /// Addresses are allocated from it and recorded in signed state, so a
/// participant keeps the same one across restarts. A network that has /// participant keeps the same one across restarts. A network that has
@@ -368,12 +407,15 @@ impl AgentConfig {
discovery_interval: Duration::from_secs(5), discovery_interval: Duration::from_secs(5),
limits: Limits::default(), limits: Limits::default(),
reconnect: ReconnectPolicy::default(), reconnect: ReconnectPolicy::default(),
plugins: Vec::new(), carriers: Vec::new(),
tunnel: None,
default_protocols: None,
custom_transports: std::collections::HashMap::new(), custom_transports: std::collections::HashMap::new(),
tun_factory: None, tun_factory: None,
interface_name: "tsun0".to_string(), interface_name: "tsun0".to_string(),
interface_mtu: 1280, interface_mtu: 1280,
overlay_ipv4_range: Some(crate::state::DEFAULT_IPV4_RANGE), overlay_ipv4_auto: true,
overlay_ipv4_range: None,
} }
} }
@@ -445,13 +487,20 @@ impl AgentConfig {
/// Sets the IPv4 overlay range this agent proposes, or disables IPv4. /// Sets the IPv4 overlay range this agent proposes, or disables IPv4.
pub fn with_overlay_ipv4_range(mut self, range: Option<crate::state::Ipv4Range>) -> Self { pub fn with_overlay_ipv4_range(mut self, range: Option<crate::state::Ipv4Range>) -> Self {
self.overlay_ipv4_auto = false;
self.overlay_ipv4_range = range; self.overlay_ipv4_range = range;
self self
} }
/// Registers an IP plugin. /// Registers an external data carrier.
pub fn with_plugin(mut self, plugin: SharedPlugin) -> Self { pub fn with_carrier(mut self, plugin: crate::dataplane::transport::SharedCarrier) -> Self {
self.plugins.push(plugin); self.carriers.push(plugin);
self
}
/// Installs the internal encryption engine. It is never a selectable carrier.
pub fn with_tunnel(mut self, tunnel: SharedPlugin) -> Self {
self.tunnel = Some(tunnel);
self self
} }
@@ -505,9 +554,9 @@ impl std::fmt::Debug for AgentConfig {
.field("limits", &self.limits) .field("limits", &self.limits)
.field("reconnect", &self.reconnect) .field("reconnect", &self.reconnect)
.field( .field(
"plugins", "carriers",
&self &self
.plugins .carriers
.iter() .iter()
.map(|p| p.protocol_id().to_string()) .map(|p| p.protocol_id().to_string())
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
+40 -21
View File
@@ -1,28 +1,19 @@
//! Boundary between the control plane core and future IP plugins. //! Boundaries between the agent, one IP encryption tunnel, and external carriers.
//! //!
//! The data plane is where actual IP connectivity is created. WireGuard is the //! The core moves bounded opaque capabilities without parsing cipher payloads.
//! first planned plugin; none is implemented here. //! An [`IpPlugin`] sees routed IP packets and a logical [`PacketLink`] per peer;
//! it never learns reachability or the network secret. The agent configures one
//! such plugin. Built-in agents use the same internal WireGuard cipher for every
//! peer, regardless of which external transports carry the encrypted bytes.
//! //!
//! Two rules shape this module: //! [`transport::DataCarrier`] owns external capability negotiation and lifecycle.
//! //! It sees reachability but never plaintext overlay packets. A relay forwards
//! 1. **The core never parses plugin payloads.** A [`PluginCapability`] carries //! the encrypted inner bytes without entering an IP plugin or the TUN.
//! a protocol id, a version, an enabled flag and a bounded opaque blob. The //! Data-plane failure never stops control sessions or discovery.
//! core transports the blob and hands it to the matching plugin. It does not
//! know what a WireGuard configuration looks like.
//! 2. **Plugin keys and lifecycle are separate from iroh identity and from the
//! network secret.** A plugin owns its own keys and its own system objects.
//!
//! An iroh address is *not* automatically a WireGuard address. A future plugin
//! is expected to gather its own reachability information and ship it through
//! the control plane as its announcement payload.
//!
//! 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 relay;
pub mod routing; pub mod routing;
pub mod transport; pub mod transport;
pub mod tunnel;
use std::sync::Arc; use std::sync::Arc;
@@ -35,11 +26,37 @@ use crate::identity::NetworkId;
pub use transport::{ pub use transport::{
MultiPacketTransport, PacketLink, PacketTransport, SharedLink, TransportError, MultiPacketTransport, PacketLink, PacketTransport, SharedLink, TransportError,
}; };
pub use tunnel::{GenericTunnelPlugin, TunnelCodec};
/// Maximum length of a plugin protocol identifier. /// Maximum length of a plugin protocol identifier.
pub const MAX_PROTOCOL_ID_LEN: usize = 32; pub const MAX_PROTOCOL_ID_LEN: usize = 32;
/// Validates bounded local protocol preferences before storing or applying them.
pub(crate) fn validate_protocols(protocols: &[String], max: usize) -> crate::error::Result<()> {
if protocols.len() > max {
return Err(crate::Error::InvalidProtocols(format!(
"at most {max} protocols are allowed"
)));
}
for (index, name) in protocols.iter().enumerate() {
if name.is_empty()
|| name.len() > MAX_PROTOCOL_ID_LEN
|| !name
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
{
return Err(crate::Error::InvalidProtocols(
"names must be 1–32 ASCII letters, digits, hyphens or underscores".into(),
));
}
if protocols[..index].contains(name) {
return Err(crate::Error::InvalidProtocols(format!(
"`{name}` is listed more than once"
)));
}
}
Ok(())
}
/// An announcement of one IP plugin's capability. /// An announcement of one IP plugin's capability.
/// ///
/// `data` is opaque to the core. Nothing in it may be interpreted as a shell /// `data` is opaque to the core. Nothing in it may be interpreted as a shell
@@ -411,7 +428,9 @@ pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
/// Always preceded by [`IpPlugin::on_network_deactivated`], so this is /// Always preceded by [`IpPlugin::on_network_deactivated`], so this is
/// only about what outlives a session. Defaulted to nothing, for a /// only about what outlives a session. Defaulted to nothing, for a
/// plugin that stores nothing. /// plugin that stores nothing.
fn on_network_forgotten(&self, _network: NetworkId) {} fn on_network_forgotten(&self, _network: NetworkId) -> BoxFuture<'_, ()> {
Box::pin(async {})
}
/// Called once when the agent shuts down. /// Called once when the agent shuts down.
/// ///
+270 -130
View File
@@ -67,10 +67,41 @@ impl Tally {
} }
} }
/// The links to one neighbour, best first. Which transport carries a frame
/// is decided here and nowhere else: the end-to-end protocol is in the
/// envelope and never depends on it.
#[derive(Debug)] #[derive(Debug)]
struct NextHop { struct NextHop {
peer: PeerId, peer: PeerId,
link: SharedLink, links: Box<[SharedLink]>,
}
impl NextHop {
fn usable(&self) -> bool {
self.links.iter().any(|link| !link.is_closed())
}
fn first_open(&self) -> Option<&SharedLink> {
self.links.iter().find(|link| !link.is_closed())
}
/// Hands a frame to the best open link that accepts it. A link that
/// cannot carry the frame (too large for its path, closed a moment ago)
/// leaves it to the next one; the frame is only copied when there is
/// another to try.
fn send(&self, frame: Bytes) -> Result<(), TransportError> {
let mut open = self.links.iter().filter(|link| !link.is_closed());
let Some(mut current) = open.next() else {
return Err(TransportError::Closed);
};
for next in open {
if current.send(frame.clone()).is_ok() {
return Ok(());
}
current = next;
}
current.send(frame)
}
} }
#[derive(Debug)] #[derive(Debug)]
@@ -82,21 +113,22 @@ struct ForwardRoute {
impl ForwardRoute { impl ForwardRoute {
fn select(&self, source: &PeerId, flow: FlowId) -> Option<&NextHop> { fn select(&self, source: &PeerId, flow: FlowId) -> Option<&NextHop> {
if self.next.len() == 1 { if self.next.len() == 1 {
return self.next.first().filter(|hop| !hop.link.is_closed()); return self.next.first().filter(|hop| hop.usable());
} }
let seed = u64::from_le_bytes(source[..8].try_into().ok()?); let seed = u64::from_le_bytes(source[..8].try_into().ok()?);
let start = mix64(flow ^ seed) as usize % self.next.len(); let start = mix64(flow ^ seed) as usize % self.next.len();
// Stable fallback if a link closed just before its table was replaced. // Stable fallback if a link closed just before its table was replaced.
(0..self.next.len()) (0..self.next.len())
.map(|i| &self.next[(start + i) % self.next.len()]) .map(|i| &self.next[(start + i) % self.next.len()])
.find(|hop| !hop.link.is_closed()) .find(|hop| hop.usable())
} }
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct ForwardingTable { struct ForwardingTable {
routes: HashMap<PeerId, ForwardRoute>, routes: HashMap<PeerId, ForwardRoute>,
inboxes: HashMap<PeerId, mpsc::Sender<Bytes>>, /// Local delivery, by who sent it and which end-to-end protocol it is.
inboxes: HashMap<(PeerId, u64), mpsc::Sender<Bytes>>,
members: HashSet<PeerId>, members: HashSet<PeerId>,
} }
@@ -121,7 +153,7 @@ impl Plane {
return; return;
} }
if header.destination == self.local { if header.destination == self.local {
let Some(inbox) = table.inboxes.get(&header.source) else { let Some(inbox) = table.inboxes.get(&(header.source, header.protocol)) else {
self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed); self.tally.dropped_unknown.fetch_add(1, Ordering::Relaxed);
return; return;
}; };
@@ -144,7 +176,7 @@ impl Plane {
self.tally.dropped_no_link.fetch_add(1, Ordering::Relaxed); self.tally.dropped_no_link.fetch_add(1, Ordering::Relaxed);
return; return;
}; };
if hop.link.send(envelope::decrement(frame)).is_ok() { if hop.send(envelope::decrement(frame)).is_ok() {
self.tally.forwarded.fetch_add(1, Ordering::Relaxed); self.tally.forwarded.fetch_add(1, Ordering::Relaxed);
} else { } else {
self.tally.dropped_no_link.fetch_add(1, Ordering::Relaxed); self.tally.dropped_no_link.fetch_add(1, Ordering::Relaxed);
@@ -152,27 +184,65 @@ impl Plane {
} }
} }
/// One physical link to a neighbour, of whatever kind of transport.
#[derive(Debug)] #[derive(Debug)]
struct Protocol { struct RawLink {
plane: Arc<Plane>, kind: String,
graph: HashMap<PeerId, Vec<PeerId>>, link: SharedLink,
members: HashSet<PeerId>, reader: JoinHandle<()>,
raw: HashMap<PeerId, SharedLink>,
readers: HashMap<PeerId, JoinHandle<()>>,
links: HashMap<PeerId, Arc<PeerLink>>,
} }
impl Protocol { /// How a destination is reached right now.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RouteSummary {
/// Number of transport links to it. One is a direct link.
pub hops: u8,
/// The neighbour the first link goes to. For a direct link that is the
/// destination itself.
pub via: EndpointId,
}
#[derive(Debug)]
struct State {
plane: Arc<Plane>,
/// Transport kinds, best first. A neighbour with several links is
/// reached over the first of these it has.
priority: Vec<String>,
graph: HashMap<PeerId, Vec<PeerId>>,
members: HashSet<PeerId>,
raw: HashMap<PeerId, Vec<RawLink>>,
links: HashMap<(PeerId, String), Arc<PeerLink>>,
}
impl State {
fn rank(&self, kind: &str) -> usize {
self.priority
.iter()
.position(|known| known == kind)
.unwrap_or(usize::MAX)
}
/// A neighbour's links, best kind first.
fn ordered(&self, peer: &PeerId) -> Vec<&RawLink> {
let mut links: Vec<&RawLink> = self
.raw
.get(peer)
.map(|links| links.iter().collect())
.unwrap_or_default();
links.sort_by_key(|raw| (self.rank(&raw.kind), raw.kind.clone()));
links
}
fn publish(&self) { fn publish(&self) {
let mut graph = self.graph.clone(); let mut graph = self.graph.clone();
graph.insert( let mut neighbours: Vec<PeerId> = self
self.plane.local, .raw
self.raw .iter()
.iter() .filter(|(_, links)| links.iter().any(|raw| !raw.link.is_closed()))
.filter(|(_, link)| !link.is_closed()) .map(|(peer, _)| *peer)
.map(|(peer, _)| *peer) .collect();
.collect(), neighbours.sort_unstable();
); graph.insert(self.plane.local, neighbours);
let routing = RoutingTable::build(self.plane.local, &graph, ROUTING_HOP_LIMIT); let routing = RoutingTable::build(self.plane.local, &graph, ROUTING_HOP_LIMIT);
let routes = routing let routes = routing
.iter() .iter()
@@ -181,10 +251,12 @@ impl Protocol {
.next_hops .next_hops
.iter() .iter()
.filter_map(|id| { .filter_map(|id| {
self.raw.get(id).map(|link| NextHop { let links: Box<[_]> = self
peer: *id, .ordered(id)
link: link.clone(), .into_iter()
}) .map(|raw| raw.link.clone())
.collect();
(!links.is_empty()).then_some(NextHop { peer: *id, links })
}) })
.collect(); .collect();
(!next.is_empty()).then_some(( (!next.is_empty()).then_some((
@@ -204,17 +276,21 @@ impl Protocol {
inboxes: self inboxes: self
.links .links
.iter() .iter()
.map(|(peer, link)| (*peer, link.inbox_tx.clone())) .map(|((peer, _), link)| ((*peer, link.tag), link.inbox_tx.clone()))
.collect(), .collect(),
})); }));
} }
fn stop_readers(&mut self) {
for raw in self.raw.values().flatten() {
raw.reader.abort();
}
}
} }
impl Drop for Protocol { impl Drop for State {
fn drop(&mut self) { fn drop(&mut self) {
for task in self.readers.values() { self.stop_readers();
task.abort();
}
for link in self.links.values() { for link in self.links.values() {
link.close(); link.close();
} }
@@ -222,12 +298,17 @@ impl Drop for Protocol {
} }
} }
/// One network's transport links and independently published protocol tables. /// One network's transport links and its published forwarding table.
///
/// There is one table for the whole network, whatever protocols run over it.
/// A link is a way to reach a neighbour; a protocol is an end-to-end channel
/// to a peer. The two meet only in the envelope, which names the protocol, so
/// a tunnel keeps working over any mix of transports and a middle member
/// needs no protocol in common with either end.
#[derive(Debug)] #[derive(Debug)]
pub struct RelayHub { pub struct RelayHub {
network: NetworkId, network: NetworkId,
local: PeerId, state: Mutex<State>,
protocols: Mutex<HashMap<String, Protocol>>,
tally: Arc<Tally>, tally: Arc<Tally>,
changed: Arc<Notify>, changed: Arc<Notify>,
} }
@@ -235,40 +316,38 @@ pub struct RelayHub {
impl RelayHub { impl RelayHub {
/// Creates the router at this network's local endpoint. /// Creates the router at this network's local endpoint.
pub fn new(network: NetworkId, local: EndpointId) -> Arc<Self> { pub fn new(network: NetworkId, local: EndpointId) -> Arc<Self> {
let tally = Arc::new(Tally::default());
Arc::new(Self { Arc::new(Self {
network, network,
local: *local.as_bytes(), state: Mutex::new(State {
protocols: Mutex::default(), plane: Arc::new(Plane {
tally: Arc::default(), local: *local.as_bytes(),
table: ArcSwap::from_pointee(ForwardingTable::default()),
tally: tally.clone(),
}),
priority: Vec::new(),
graph: HashMap::new(),
members: HashSet::new(),
raw: HashMap::new(),
links: HashMap::new(),
}),
tally,
changed: Arc::default(), changed: Arc::default(),
}) })
} }
fn protocols(&self) -> std::sync::MutexGuard<'_, HashMap<String, Protocol>> { fn state(&self) -> std::sync::MutexGuard<'_, State> {
self.protocols self.state.lock().unwrap_or_else(|error| error.into_inner())
.lock()
.unwrap_or_else(|error| error.into_inner())
} }
fn protocol<'a>( /// Sets which kinds of transport are preferred, best first, when a
&self, /// neighbour can be reached over more than one.
protocols: &'a mut HashMap<String, Protocol>, pub fn set_priority(&self, kinds: Vec<String>) {
name: &str, let mut state = self.state();
) -> &'a mut Protocol { if state.priority != kinds {
protocols state.priority = kinds;
.entry(name.to_owned()) state.publish();
.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. /// Wakes the control loop promptly when a transport reader ends.
@@ -282,14 +361,11 @@ impl RelayHub {
} }
/// Publishes authenticated topology. Refreshing identical state is cheap. /// Publishes authenticated topology. Refreshing identical state is cheap.
pub fn set_topology( ///
&self, /// Every row of `graph` is one member's own account of who it has a link
protocol: &str, /// to; what kind of link it is does not matter here.
graph: HashMap<PeerId, Vec<PeerId>>, pub fn set_topology(&self, graph: HashMap<PeerId, Vec<PeerId>>, members: HashSet<PeerId>) {
members: HashSet<PeerId>, let mut state = self.state();
) {
let mut protocols = self.protocols();
let state = self.protocol(&mut protocols, protocol);
if state.graph != graph || state.members != members { if state.graph != graph || state.members != members {
state.graph = graph; state.graph = graph;
state.members = members; state.members = members;
@@ -298,22 +374,44 @@ impl RelayHub {
} }
/// Whether the current table has a path to this destination. /// Whether the current table has a path to this destination.
pub fn reachable(&self, peer: EndpointId, protocol: &str) -> bool { pub fn reachable(&self, peer: EndpointId) -> bool {
self.protocols().get(protocol).is_some_and(|state| { self.state()
state .plane
.plane .table
.table .load()
.load() .routes
.routes .contains_key(peer.as_bytes())
.contains_key(peer.as_bytes()) }
/// How a destination is reached right now, if it is.
pub fn route(&self, peer: EndpointId) -> Option<RouteSummary> {
let table = self.state().plane.table.load_full();
let route = table.routes.get(peer.as_bytes())?;
let via = EndpointId::from_bytes(&route.next.first()?.peer).ok()?;
Some(RouteSummary {
hops: route.hops,
via,
}) })
} }
/// Stable logical link, preserved across topology changes. /// The kinds of transport with an open direct link to a peer, best
/// first.
pub fn direct_kinds(&self, peer: EndpointId) -> Vec<String> {
let state = self.state();
state
.ordered(peer.as_bytes())
.into_iter()
.filter(|raw| !raw.link.is_closed())
.map(|raw| raw.kind.clone())
.collect()
}
/// Stable end-to-end channel for a protocol, preserved across topology
/// and transport changes.
pub fn link(&self, peer: EndpointId, protocol: &str) -> Arc<PeerLink> { pub fn link(&self, peer: EndpointId, protocol: &str) -> Arc<PeerLink> {
let mut protocols = self.protocols(); let mut state = self.state();
let state = self.protocol(&mut protocols, protocol); let key = (*peer.as_bytes(), protocol.to_owned());
if let Some(link) = state.links.get(peer.as_bytes()) { if let Some(link) = state.links.get(&key) {
return link.clone(); return link.clone();
} }
let (inbox_tx, inbox_rx) = mpsc::channel(INBOX); let (inbox_tx, inbox_rx) = mpsc::channel(INBOX);
@@ -321,88 +419,126 @@ impl RelayHub {
let link = Arc::new(PeerLink { let link = Arc::new(PeerLink {
network: self.network, network: self.network,
peer, peer,
tag: envelope::protocol_tag(protocol),
plane: state.plane.clone(), plane: state.plane.clone(),
inbox_tx, inbox_tx,
inbox_rx: tokio::sync::Mutex::new(inbox_rx), inbox_rx: tokio::sync::Mutex::new(inbox_rx),
closed, closed,
}); });
state.links.insert(*peer.as_bytes(), link.clone()); state.links.insert(key, link.clone());
state.publish(); state.publish();
link link
} }
/// Whether the protocol already owns its logical link. /// Whether the protocol already owns its end-to-end channel to a peer.
pub fn has_link(&self, peer: EndpointId, protocol: &str) -> bool { pub fn has_link(&self, peer: EndpointId, protocol: &str) -> bool {
self.protocols() self.state()
.get(protocol) .links
.is_some_and(|state| state.links.contains_key(peer.as_bytes())) .contains_key(&(*peer.as_bytes(), protocol.to_owned()))
} }
/// Installs a raw transport link and starts its independent ingress reader. /// Installs a raw transport link and starts its independent ingress reader.
pub fn set_direct(&self, peer: EndpointId, protocol: &str, raw: SharedLink) { ///
/// A neighbour may have one link per kind of transport; installing a
/// second of the same kind replaces the first.
pub fn set_direct(&self, peer: EndpointId, kind: &str, raw: SharedLink) {
if raw.network() != self.network || raw.peer() != peer { if raw.network() != self.network || raw.peer() != peer {
return; return;
} }
let mut protocols = self.protocols(); let mut state = self.state();
let state = self.protocol(&mut protocols, protocol);
let id = *peer.as_bytes(); 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 plane = state.plane.clone();
let changed = self.changed.clone(); let changed = self.changed.clone();
state.readers.insert( let reader_link = raw.clone();
id, let reader = tokio::spawn(async move {
tokio::spawn(async move { let mut batch = 0;
let mut batch = 0; while let Some(frame) = reader_link.recv().await {
while let Some(frame) = raw.recv().await { plane.receive(id, frame);
plane.receive(id, frame); batch += 1;
batch += 1; if batch == 64 {
if batch == 64 { tokio::task::yield_now().await;
tokio::task::yield_now().await; batch = 0;
batch = 0;
}
} }
changed.notify_one(); }
}), changed.notify_one();
); });
let links = state.raw.entry(id).or_default();
if let Some(position) = links.iter().position(|known| known.kind == kind) {
links.remove(position).reader.abort();
}
links.push(RawLink {
kind: kind.to_owned(),
link: raw,
reader,
});
state.publish();
} }
/// Removes a physical link without tearing down end-to-end protocol state. /// Removes one physical link without tearing down end-to-end protocol state.
pub fn clear_direct(&self, peer: EndpointId, protocol: &str) { pub fn clear_direct(&self, peer: EndpointId, kind: &str) {
if let Some(state) = self.protocols().get_mut(protocol) { let mut state = self.state();
state.raw.remove(peer.as_bytes()); let mut changed = false;
if let Some(task) = state.readers.remove(peer.as_bytes()) { if let Some(links) = state.raw.get_mut(peer.as_bytes()) {
task.abort(); if let Some(position) = links.iter().position(|known| known.kind == kind) {
links.remove(position).reader.abort();
changed = true;
} }
if links.is_empty() {
state.raw.remove(peer.as_bytes());
}
}
if changed {
state.publish(); state.publish();
} }
} }
/// Retires data channels without removing an authenticated peer's topology.
pub fn clear_peer_links(&self, peer: EndpointId) {
let mut state = self.state();
if let Some(links) = state.raw.remove(peer.as_bytes()) {
for raw in links {
raw.link.close();
raw.reader.abort();
}
}
let gone: Vec<_> = state
.links
.keys()
.filter(|(id, _)| id == peer.as_bytes())
.cloned()
.collect();
for key in gone {
if let Some(link) = state.links.remove(&key) {
link.close();
}
}
state.publish();
}
/// Revokes all links and topology involving a departed authenticated peer. /// Revokes all links and topology involving a departed authenticated peer.
pub fn remove_peer(&self, peer: EndpointId) { pub fn remove_peer(&self, peer: EndpointId) {
for state in self.protocols().values_mut() { self.clear_peer_links(peer);
state.raw.remove(peer.as_bytes()); let mut state = self.state();
if let Some(task) = state.readers.remove(peer.as_bytes()) { state.members.remove(peer.as_bytes());
task.abort(); state.graph.remove(peer.as_bytes());
} for neighbors in state.graph.values_mut() {
if let Some(link) = state.links.remove(peer.as_bytes()) { neighbors.retain(|id| id != 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();
} }
state.publish();
} }
/// Stops every reader and releases all transport handles. /// Stops every reader and releases all transport handles.
pub fn close(&self) { pub fn close(&self) {
self.protocols().clear(); let mut state = self.state();
state.stop_readers();
state.raw.clear();
for link in state.links.values() {
link.close();
}
state.links.clear();
state.graph.clear();
state.members.clear();
state.plane.table.store(Arc::default());
} }
} }
@@ -411,6 +547,7 @@ impl RelayHub {
pub struct PeerLink { pub struct PeerLink {
network: NetworkId, network: NetworkId,
peer: EndpointId, peer: EndpointId,
tag: u64,
plane: Arc<Plane>, plane: Arc<Plane>,
inbox_tx: mpsc::Sender<Bytes>, inbox_tx: mpsc::Sender<Bytes>,
inbox_rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>, inbox_rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
@@ -454,10 +591,11 @@ impl PacketLink for PeerLink {
let hop = route let hop = route
.select(&self.plane.local, flow) .select(&self.plane.local, flow)
.ok_or(TransportError::Closed)?; .ok_or(TransportError::Closed)?;
hop.link.send(envelope::encode( hop.send(envelope::encode(
self.plane.local, self.plane.local,
*self.peer.as_bytes(), *self.peer.as_bytes(),
flow, flow,
self.tag,
&payload, &payload,
))?; ))?;
if route.hops > 1 { if route.hops > 1 {
@@ -489,7 +627,9 @@ impl PacketLink for PeerLink {
fn path_description(&self) -> String { fn path_description(&self) -> String {
let table = self.plane.table.load(); let table = self.plane.table.load();
match table.routes.get(self.peer.as_bytes()) { match table.routes.get(self.peer.as_bytes()) {
Some(route) if route.hops == 1 => route.next[0].link.path_description(), Some(route) if route.hops == 1 => route.next[0]
.first_open()
.map_or_else(|| "unreachable".into(), |link| link.path_description()),
Some(route) => format!( Some(route) => format!(
"relay {} hops via {} ({} equal paths)", "relay {} hops via {} ({} equal paths)",
route.hops, route.hops,
+147 -16
View File
@@ -16,6 +16,9 @@ fn peer(seed: u8) -> EndpointId {
fn id(seed: u8) -> PeerId { fn id(seed: u8) -> PeerId {
*peer(seed).as_bytes() *peer(seed).as_bytes()
} }
fn tag() -> u64 {
envelope::protocol_tag("ip")
}
#[derive(Debug)] #[derive(Debug)]
struct Wire { struct Wire {
@@ -78,9 +81,8 @@ impl PacketLink for Wire {
} }
} }
fn topology(hub: &RelayHub, protocol: &str, edges: &[(u8, &[u8])], members: &[u8]) { fn topology(hub: &RelayHub, edges: &[(u8, &[u8])], members: &[u8]) {
hub.set_topology( hub.set_topology(
protocol,
edges edges
.iter() .iter()
.map(|(a, bs)| (id(*a), bs.iter().map(|b| id(*b)).collect())) .map(|(a, bs)| (id(*a), bs.iter().map(|b| id(*b)).collect()))
@@ -96,7 +98,7 @@ async fn ecmp_is_per_flow_direct_wins_and_existing_tunnel_survives_changes() {
let c = Wire::new(3, true); let c = Wire::new(3, true);
hub.set_direct(peer(2), "ip", b.clone()); hub.set_direct(peer(2), "ip", b.clone());
hub.set_direct(peer(3), "ip", c.clone()); hub.set_direct(peer(3), "ip", c.clone());
topology(&hub, "ip", &[(2, &[4]), (3, &[4])], &[1, 2, 3, 4]); topology(&hub, &[(2, &[4]), (3, &[4])], &[1, 2, 3, 4]);
let link = hub.link(peer(4), "ip"); let link = hub.link(peer(4), "ip");
let mut chosen = HashMap::new(); let mut chosen = HashMap::new();
for _ in 0..8 { for _ in 0..8 {
@@ -132,7 +134,7 @@ async fn ecmp_is_per_flow_direct_wins_and_existing_tunnel_survives_changes() {
} }
assert_eq!(c.take().len(), 100); assert_eq!(c.take().len(), 100);
hub.clear_direct(peer(2), "ip"); hub.clear_direct(peer(2), "ip");
topology(&hub, "ip", &[(3, &[])], &[1, 2, 3, 4]); topology(&hub, &[(3, &[])], &[1, 2, 3, 4]);
assert!(link.send(Bytes::new()).is_err()); assert!(link.send(Bytes::new()).is_err());
assert!(!link.is_closed()); assert!(!link.is_closed());
assert!(Arc::ptr_eq(&link, &hub.link(peer(4), "ip"))); assert!(Arc::ptr_eq(&link, &hub.link(peer(4), "ip")));
@@ -152,8 +154,8 @@ async fn transport_reader_forwards_without_any_plugin_reader_and_isolates_protoc
let c = Wire::new(3, true); let c = Wire::new(3, true);
hub.set_direct(peer(1), "ip", a.clone()); hub.set_direct(peer(1), "ip", a.clone());
hub.set_direct(peer(3), "ip", c.clone()); hub.set_direct(peer(3), "ip", c.clone());
topology(&hub, "ip", &[(3, &[4])], &[1, 2, 3, 4]); topology(&hub, &[(3, &[4])], &[1, 2, 3, 4]);
let frame = envelope::encode(id(1), id(4), 91, b"end-to-end encrypted"); let frame = envelope::encode(id(1), id(4), 91, tag(), b"end-to-end encrypted");
let pointer = frame.as_ptr(); let pointer = frame.as_ptr();
a.feed.send(frame).await.unwrap(); a.feed.send(frame).await.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(1), async { tokio::time::timeout(std::time::Duration::from_secs(1), async {
@@ -170,12 +172,136 @@ async fn transport_reader_forwards_without_any_plugin_reader_and_isolates_protoc
ROUTING_HOP_LIMIT - 1 ROUTING_HOP_LIMIT - 1
); );
assert_eq!(&sent[0][RELAY_OVERHEAD..], b"end-to-end encrypted"); 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); assert_eq!(hub.counters().forwarded, 1);
hub.close(); hub.close();
} }
#[tokio::test]
async fn a_frame_is_delivered_only_to_the_protocol_it_names() {
let hub = RelayHub::new(network(), peer(2));
hub.set_direct(peer(1), "ip", Wire::new(1, true));
let ip = hub.link(peer(1), "ip");
let other = hub.link(peer(1), "other-ip");
let plane = ip.plane.clone();
plane.receive(id(1), envelope::encode(id(1), id(2), 0, tag(), b"for ip"));
plane.receive(
id(1),
envelope::encode(
id(1),
id(2),
0,
envelope::protocol_tag("other-ip"),
b"for other",
),
);
plane.receive(
id(1),
envelope::encode(
id(1),
id(2),
0,
envelope::protocol_tag("nobody-runs-this"),
b"for nobody",
),
);
assert_eq!(ip.recv().await.unwrap(), Bytes::from_static(b"for ip"));
assert_eq!(
other.recv().await.unwrap(),
Bytes::from_static(b"for other")
);
assert_eq!(hub.counters().dropped_unknown, 1);
hub.close();
}
/// A middle member joins two neighbours over different transports, and the
/// two ends share a protocol the middle has never heard of.
#[tokio::test]
async fn a_tunnel_crosses_a_middle_member_whose_links_are_of_different_kinds() {
let a = RelayHub::new(network(), peer(1));
let c = RelayHub::new(network(), peer(3));
let b = RelayHub::new(network(), peer(2));
let a_to_c = Wire::new(3, true);
let c_to_a = Wire::new(1, true);
let c_to_b = Wire::new(2, true);
let b_to_c = Wire::new(3, true);
a.set_direct(peer(3), "wg", a_to_c.clone());
c.set_direct(peer(1), "wg", c_to_a);
c.set_direct(peer(2), "wg-quic", c_to_b.clone());
b.set_direct(peer(3), "wg-quic", b_to_c.clone());
topology(&a, &[(3, &[1, 2])], &[1, 2, 3]);
topology(&b, &[(3, &[1, 2])], &[1, 2, 3]);
topology(&c, &[(1, &[3]), (2, &[3])], &[1, 2, 3]);
let outgoing = a.link(peer(2), "tunnel");
let incoming = b.link(peer(1), "tunnel");
outgoing
.send_flow(Bytes::from_static(b"sealed"), 5)
.unwrap();
let frame = a_to_c.take().remove(0);
// The middle forwards it onto its other kind of link without looking
// inside.
c.state().plane.receive(id(1), frame);
let frame = c_to_b.take().remove(0);
assert_eq!(c.counters().forwarded, 1);
b.state().plane.receive(id(3), frame);
assert_eq!(
incoming.recv().await.unwrap(),
Bytes::from_static(b"sealed")
);
assert_eq!(b.counters().received_via, 1);
assert_eq!(
b.route(peer(1)),
Some(RouteSummary {
hops: 2,
via: peer(3)
})
);
a.close();
b.close();
c.close();
}
#[tokio::test]
async fn a_neighbour_is_reached_over_the_preferred_kind_and_falls_back_when_it_is_lost() {
let hub = RelayHub::new(network(), peer(1));
hub.set_priority(vec!["wg".into(), "tcp-tls".into(), "wg-quic".into()]);
let slow = Wire::new(2, true);
let fast = Wire::new(2, true);
hub.set_direct(peer(2), "wg-quic", slow.clone());
let link = hub.link(peer(2), "tunnel");
link.send(Bytes::from_static(b"one")).unwrap();
assert_eq!(slow.take().len(), 1);
assert_eq!(hub.direct_kinds(peer(2)), ["wg-quic"]);
// A better kind appears: traffic moves to it with nothing to redo.
hub.set_direct(peer(2), "wg", fast.clone());
assert_eq!(hub.direct_kinds(peer(2)), ["wg", "wg-quic"]);
link.send(Bytes::from_static(b"two")).unwrap();
assert_eq!(fast.take().len(), 1);
assert!(slow.take().is_empty());
// It dies: the worse one carries on, and the logical link is untouched.
fast.dead.store(true, Ordering::Relaxed);
link.send(Bytes::from_static(b"three")).unwrap();
assert_eq!(slow.take().len(), 1);
assert!(!link.is_closed());
assert_eq!(
hub.route(peer(2)),
Some(RouteSummary {
hops: 1,
via: peer(2)
})
);
hub.clear_direct(peer(2), "wg-quic");
hub.clear_direct(peer(2), "wg");
assert!(!hub.reachable(peer(2)));
assert!(Arc::ptr_eq(&link, &hub.link(peer(2), "tunnel")));
hub.close();
}
#[tokio::test] #[tokio::test]
async fn local_delivery_is_bounded_and_malformed_or_unknown_sources_are_dropped() { async fn local_delivery_is_bounded_and_malformed_or_unknown_sources_are_dropped() {
let hub = RelayHub::new(network(), peer(2)); let hub = RelayHub::new(network(), peer(2));
@@ -184,18 +310,21 @@ async fn local_delivery_is_bounded_and_malformed_or_unknown_sources_are_dropped(
let link = hub.link(peer(1), "ip"); let link = hub.link(peer(1), "ip");
let plane = link.plane.clone(); let plane = link.plane.clone();
for _ in 0..INBOX + 1 { for _ in 0..INBOX + 1 {
plane.receive(id(1), envelope::encode(id(1), id(2), 0, b"ip")); plane.receive(id(1), envelope::encode(id(1), id(2), 0, tag(), b"ip"));
} }
assert_eq!(hub.counters().dropped_congested, 1); assert_eq!(hub.counters().dropped_congested, 1);
for _ in 0..INBOX { for _ in 0..INBOX {
assert_eq!(link.recv().await.unwrap(), Bytes::from_static(b"ip")); assert_eq!(link.recv().await.unwrap(), Bytes::from_static(b"ip"));
} }
plane.receive(id(1), Bytes::from_static(b"broken")); plane.receive(id(1), Bytes::from_static(b"broken"));
plane.receive(id(1), envelope::encode(id(9), id(2), 0, b"unknown member")); plane.receive(
id(1),
envelope::encode(id(9), id(2), 0, tag(), b"unknown member"),
);
assert_eq!(hub.counters().dropped_unknown, 2); assert_eq!(hub.counters().dropped_unknown, 2);
plane.receive( plane.receive(
id(1), id(1),
envelope::encode(id(1), id(9), 0, b"unknown destination"), envelope::encode(id(1), id(9), 0, tag(), b"unknown destination"),
); );
assert_eq!(hub.counters().dropped_no_link, 1); assert_eq!(hub.counters().dropped_no_link, 1);
hub.close(); hub.close();
@@ -210,11 +339,11 @@ async fn inconsistent_tables_cannot_loop_beyond_the_hop_limit() {
a.set_direct(peer(2), "ip", ab.clone()); a.set_direct(peer(2), "ip", ab.clone());
b.set_direct(peer(1), "ip", ba.clone()); b.set_direct(peer(1), "ip", ba.clone());
// Deliberately inconsistent snapshots while updates are in flight. // Deliberately inconsistent snapshots while updates are in flight.
topology(&a, "ip", &[(2, &[4])], &[1, 2, 3, 4]); topology(&a, &[(2, &[4])], &[1, 2, 3, 4]);
topology(&b, "ip", &[(1, &[4])], &[1, 2, 3, 4]); topology(&b, &[(1, &[4])], &[1, 2, 3, 4]);
let ap = a.link(peer(4), "ip").plane.clone(); let ap = a.link(peer(4), "ip").plane.clone();
let bp = b.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"); let mut frame = envelope::encode(id(3), id(4), 7, tag(), b"transit");
for turn in 0..ROUTING_HOP_LIMIT { for turn in 0..ROUTING_HOP_LIMIT {
let (plane, incoming, wire) = if turn % 2 == 0 { let (plane, incoming, wire) = if turn % 2 == 0 {
(&ap, id(2), &ab) (&ap, id(2), &ab)
@@ -253,7 +382,7 @@ async fn forwarding_benchmark() {
hub.set_direct(peer(3), "ip", wire.clone()); hub.set_direct(peer(3), "ip", wire.clone());
let alternate = Wire::new(5, false); let alternate = Wire::new(5, false);
hub.set_direct(peer(5), "ip", alternate.clone()); hub.set_direct(peer(5), "ip", alternate.clone());
topology(&hub, "ip", &[(3, &[4]), (5, &[4])], &[1, 2, 3, 4, 5]); topology(&hub, &[(3, &[4]), (5, &[4])], &[1, 2, 3, 4, 5]);
let plane = hub.link(peer(4), "ip").plane.clone(); let plane = hub.link(peer(4), "ip").plane.clone();
let source = id(1); let source = id(1);
let destination = id(4); let destination = id(4);
@@ -264,7 +393,9 @@ async fn forwarding_benchmark() {
const BATCHES: usize = 100; const BATCHES: usize = 100;
for _ in 0..BATCHES { for _ in 0..BATCHES {
let frames: Vec<_> = (0..COUNT) let frames: Vec<_> = (0..COUNT)
.map(|flow| envelope::encode(source, destination, flow as u64, &vec![42; size])) .map(|flow| {
envelope::encode(source, destination, flow as u64, tag(), &vec![42; size])
})
.collect(); .collect();
let start = std::time::Instant::now(); let start = std::time::Instant::now();
for frame in frames { for frame in frames {
@@ -3,8 +3,8 @@ use super::{FlowId, PeerId};
use crate::config::{MAX_DATA_DATAGRAM, ROUTING_HOP_LIMIT}; use crate::config::{MAX_DATA_DATAGRAM, ROUTING_HOP_LIMIT};
use bytes::{BufMut, Bytes, BytesMut}; use bytes::{BufMut, Bytes, BytesMut};
pub(crate) const HEADER: usize = 2 + 32 + 32 + 8; pub(crate) const HEADER: usize = 2 + 32 + 32 + 8 + 8;
const VERSION: u8 = 1; const VERSION: u8 = 2;
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub(crate) struct Header { pub(crate) struct Header {
@@ -12,6 +12,15 @@ pub(crate) struct Header {
pub destination: PeerId, pub destination: PeerId,
pub remaining: u8, pub remaining: u8,
pub flow: FlowId, pub flow: FlowId,
/// Which end-to-end protocol the payload belongs to, whatever links carry it.
pub protocol: u64,
}
/// A stable 64-bit tag for a protocol id (FNV-1a), the same on every member.
pub(crate) fn protocol_tag(name: &str) -> u64 {
name.bytes().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
})
} }
pub(crate) fn decode(bytes: &[u8]) -> Option<Header> { pub(crate) fn decode(bytes: &[u8]) -> Option<Header> {
@@ -28,16 +37,24 @@ pub(crate) fn decode(bytes: &[u8]) -> Option<Header> {
destination: bytes[34..66].try_into().ok()?, destination: bytes[34..66].try_into().ok()?,
remaining: bytes[1], remaining: bytes[1],
flow: u64::from_be_bytes(bytes[66..74].try_into().ok()?), flow: u64::from_be_bytes(bytes[66..74].try_into().ok()?),
protocol: u64::from_be_bytes(bytes[74..82].try_into().ok()?),
}) })
} }
pub(crate) fn encode(source: PeerId, destination: PeerId, flow: FlowId, payload: &[u8]) -> Bytes { pub(crate) fn encode(
source: PeerId,
destination: PeerId,
flow: FlowId,
protocol: u64,
payload: &[u8],
) -> Bytes {
let mut bytes = BytesMut::with_capacity(HEADER + payload.len()); let mut bytes = BytesMut::with_capacity(HEADER + payload.len());
bytes.put_u8(VERSION); bytes.put_u8(VERSION);
bytes.put_u8(ROUTING_HOP_LIMIT); bytes.put_u8(ROUTING_HOP_LIMIT);
bytes.extend_from_slice(&source); bytes.extend_from_slice(&source);
bytes.extend_from_slice(&destination); bytes.extend_from_slice(&destination);
bytes.put_u64(flow); bytes.put_u64(flow);
bytes.put_u64(protocol);
bytes.extend_from_slice(payload); bytes.extend_from_slice(payload);
bytes.freeze() bytes.freeze()
} }
@@ -58,7 +75,7 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn transit_reuses_the_buffer_and_rejects_invalid_headers() { fn transit_reuses_the_buffer_and_rejects_invalid_headers() {
let frame = encode([1; 32], [2; 32], 17, &[42; 1280]); let frame = encode([1; 32], [2; 32], 17, protocol_tag("wg"), &[42; 1280]);
let address = frame.as_ptr(); let address = frame.as_ptr();
let frame = decrement(frame); let frame = decrement(frame);
assert_eq!( assert_eq!(
@@ -66,7 +83,10 @@ mod tests {
address, address,
"no payload copy for an owned buffer" "no payload copy for an owned buffer"
); );
assert_eq!(decode(&frame).unwrap().remaining, ROUTING_HOP_LIMIT - 1); let header = decode(&frame).unwrap();
assert_eq!(header.remaining, ROUTING_HOP_LIMIT - 1);
assert_eq!(header.protocol, protocol_tag("wg"));
assert_ne!(protocol_tag("wg"), protocol_tag("wg-quic"));
assert_eq!(&frame[HEADER..], &[42; 1280]); assert_eq!(&frame[HEADER..], &[42; 1280]);
for length in 0..HEADER { for length in 0..HEADER {
assert!(decode(&frame[..length]).is_none()); assert!(decode(&frame[..length]).is_none());
@@ -0,0 +1,44 @@
//! External link negotiation, deliberately separate from the IP encryption plugin.
use crate::dataplane::{PluginCapability, PluginError, ProtocolOption};
use crate::{BoxFuture, NetworkId};
use iroh::EndpointId;
use std::sync::Arc;
/// Capability and lifecycle of one external data carrier.
/// Only this layer sees reachability. It never receives plaintext overlay IP packets.
pub trait DataCarrier: Send + Sync + std::fmt::Debug + 'static {
/// External protocol identifier used in local policy and peer negotiation.
fn protocol_id(&self) -> &str;
/// Wire version, independent of software releases and the internal tunnel.
fn protocol_version(&self) -> u16;
/// Supported carrier settings.
fn options(&self) -> &'static [ProtocolOption] {
&[]
}
/// Bounded opaque reachability/negotiation capability for one network.
fn local_capability(&self, network: NetworkId)
-> Result<Option<PluginCapability>, PluginError>;
/// Installs a capability received through an authenticated membership session.
fn on_peer_capability(
&self,
network: NetworkId,
peer: EndpointId,
capability: &PluginCapability,
) -> Result<(), PluginError>;
/// Supplies the actual owned interface name, including kernel-assigned names.
fn on_interface_ready(&self, _name: &str) {}
/// Activates this carrier for one local network.
fn on_network_activated(&self, _network: NetworkId) {}
/// Retires all negotiation and links for this member.
fn on_peer_gone(&self, network: NetworkId, peer: EndpointId);
/// Retires this network's sockets and negotiations.
fn on_network_deactivated(&self, network: NetworkId);
/// Forgets durable carrier settings, if any.
fn on_network_forgotten(&self, _network: NetworkId) {}
/// Stops carrier tasks, with the agent enforcing a shutdown deadline.
fn shutdown(&self) -> BoxFuture<'_, ()> {
Box::pin(async {})
}
}
/// One shared carrier adapter.
pub type SharedCarrier = Arc<dyn DataCarrier>;
@@ -0,0 +1,286 @@
//! Authenticated probes of the actual data link, independent of control RTT.
//! Keys are negotiated privately for each physical link. No fixed wire magic.
use super::{PacketLink, SharedLink, TransportError};
use crate::{BoxFuture, NetworkId};
use bytes::Bytes;
use hmac::{Hmac, KeyInit, Mac};
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch};
#[derive(Clone, Serialize, Deserialize, zeroize::ZeroizeOnDrop)]
pub(crate) struct Settings {
marker: [u8; 16],
key: [u8; 32],
}
impl Settings {
pub(crate) fn fresh() -> Self {
Self {
marker: rand::random(),
key: rand::random(),
}
}
}
impl std::fmt::Debug for Settings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("HealthSettings([REDACTED])")
}
}
struct Samples {
pending: VecDeque<(u64, Instant)>,
recent: VecDeque<bool>,
}
impl Samples {
fn new() -> Self {
Self {
pending: VecDeque::new(),
recent: VecDeque::new(),
}
}
fn sent(&mut self, seq: u64, now: Instant) {
self.pending.push_back((seq, now));
}
fn acknowledge(&mut self, seq: u64) {
if let Some(i) = self.pending.iter().position(|(n, _)| *n == seq) {
self.pending.remove(i);
self.recent.push_back(true);
}
}
fn expire(&mut self, now: Instant) {
while self
.pending
.front()
.is_some_and(|(_, at)| now.duration_since(*at) >= Duration::from_secs(3))
{
self.pending.pop_front();
self.recent.push_back(false);
}
while self.recent.len() > 20 {
self.recent.pop_front();
}
}
fn degraded(&self) -> bool {
self.recent.len() >= 15
&& self.recent.iter().filter(|ok| !**ok).count() * 100 >= self.recent.len() * 60
}
}
/// A monitored physical link. It forwards application bytes unchanged.
pub(crate) struct HealthLink {
raw: SharedLink,
settings: Mutex<Option<Settings>>,
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
stop: watch::Sender<bool>,
reason: Mutex<Option<String>>,
}
impl std::fmt::Debug for HealthLink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HealthLink")
.field("raw", &self.raw)
.finish_non_exhaustive()
}
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match m.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
}
}
impl HealthLink {
pub(crate) fn new(raw: SharedLink, local: EndpointId, settings: Option<Settings>) -> Arc<Self> {
let (tx, rx) = mpsc::channel(64);
let (stop, _) = watch::channel(false);
let link = Arc::new(Self {
raw: raw.clone(),
settings: Mutex::new(settings),
rx: tokio::sync::Mutex::new(rx),
stop,
reason: Mutex::new(None),
});
let weak = Arc::downgrade(&link);
let mut stopped = link.stop.subscribe();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut samples = Samples::new();
let mut sequence = rand::random::<u64>();
let mut since: Option<Instant> = None;
let mut marker = None;
loop {
tokio::select! {
biased;
_=stopped.changed()=>break,
_=interval.tick()=>{
let Some(link)=weak.upgrade()else{break;};let Some(settings)=lock(&link.settings).clone()else{continue;};
if marker != Some(settings.marker) { marker = Some(settings.marker); samples = Samples::new(); since = None; }
let start=*since.get_or_insert_with(Instant::now);let now=Instant::now();samples.expire(now);
if now.duration_since(start)>=Duration::from_secs(20) && samples.degraded() {
*lock(&link.reason)=Some("data delivery checks: at least 60% loss over the last 20 probes".into());break;
}
sequence=sequence.wrapping_add(1);
let size=raw.max_datagram_size();
let packet=probe(&settings,local,1,sequence,size.min(1000));
samples.sent(sequence,now);
let _=raw.send(packet);
}
packet=raw.recv()=>{
let Some(packet)=packet else{break;};let Some(link)=weak.upgrade()else{break;};
let settings=lock(&link.settings).clone();
if let Some(settings)=settings && packet.starts_with(&settings.marker) {
if let Some((kind,seq))=verify(&settings,raw.peer(),&packet) {
if kind==1 {let _=raw.send(probe(&settings,local,2,seq,packet.len()));}
else if kind==2 {samples.acknowledge(seq);}
}
} else {let _=tx.try_send(packet);}
}
}
}
raw.close();
if let Some(link) = weak.upgrade() {
link.stop.send_replace(true);
}
});
link
}
pub(crate) fn configure(&self, settings: Settings) {
*lock(&self.settings) = Some(settings);
}
}
fn probe(settings: &Settings, sender: EndpointId, kind: u8, seq: u64, size: usize) -> Bytes {
let mut bytes = vec![0; size.max(57)];
bytes[..16].copy_from_slice(&settings.marker);
bytes[16] = kind;
bytes[17..25].copy_from_slice(&seq.to_be_bytes());
rand::fill(&mut bytes[25..size.max(57) - 32]);
let tail = bytes.len() - 32;
if let Ok(mut mac) = <Hmac<Sha256> as KeyInit>::new_from_slice(&settings.key) {
mac.update(sender.as_bytes());
mac.update(&bytes[..tail]);
bytes[tail..].copy_from_slice(&mac.finalize().into_bytes());
}
Bytes::from(bytes)
}
fn verify(settings: &Settings, sender: EndpointId, bytes: &[u8]) -> Option<(u8, u64)> {
if bytes.len() < 57 || !bytes.starts_with(&settings.marker) {
return None;
}
let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(&settings.key).ok()?;
let tail = bytes.len() - 32;
mac.update(sender.as_bytes());
mac.update(&bytes[..tail]);
mac.verify_slice(&bytes[tail..]).ok()?;
Some((
bytes[16],
u64::from_be_bytes(bytes.get(17..25)?.try_into().ok()?),
))
}
impl PacketLink for HealthLink {
fn network(&self) -> NetworkId {
self.raw.network()
}
fn peer(&self) -> EndpointId {
self.raw.peer()
}
fn max_datagram_size(&self) -> usize {
self.raw.max_datagram_size()
}
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
if self.is_closed() {
Err(TransportError::Closed)
} else {
self.raw.send(payload)
}
}
fn send_flow(
&self,
payload: Bytes,
flow: super::super::routing::FlowId,
) -> Result<(), TransportError> {
if self.is_closed() {
Err(TransportError::Closed)
} else {
self.raw.send_flow(payload, flow)
}
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move {
let mut stop = self.stop.subscribe();
if *stop.borrow() {
return None;
}
let mut rx = self.rx.lock().await;
tokio::select! {biased;_=stop.changed()=>None,packet=rx.recv()=>packet}
})
}
fn closed(&self) -> BoxFuture<'_, ()> {
Box::pin(async move {
let mut stop = self.stop.subscribe();
if !*stop.borrow() {
let _ = stop.changed().await;
}
})
}
fn is_closed(&self) -> bool {
*self.stop.borrow() || self.raw.is_closed()
}
fn close(&self) {
self.stop.send_replace(true);
self.raw.close();
}
fn failure_reason(&self) -> Option<String> {
lock(&self.reason)
.clone()
.or_else(|| self.raw.failure_reason())
}
fn path_description(&self) -> String {
self.raw.path_description()
}
}
impl Drop for HealthLink {
fn drop(&mut self) {
self.stop.send_replace(true);
self.raw.close();
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn no_false_failure_before_window_but_persistent_loss_is_detected() {
let mut samples = Samples::new();
let now = Instant::now();
for seq in 0..20 {
samples.sent(seq, now);
if seq % 5 == 0 {
samples.acknowledge(seq);
}
}
assert!(!samples.degraded());
samples.expire(now + Duration::from_secs(4));
assert!(samples.degraded());
for seq in 20..40 {
samples.sent(seq, now);
samples.acknowledge(seq);
}
samples.expire(now + Duration::from_secs(4));
assert!(!samples.degraded());
}
#[test]
fn a_forged_or_reflected_probe_cannot_claim_delivery() {
let a = crate::identity::DeviceIdentity::generate().endpoint_id();
let b = crate::identity::DeviceIdentity::generate().endpoint_id();
let settings = Settings::fresh();
let packet = probe(&settings, a, 1, 42, 1100);
assert_eq!(verify(&settings, a, &packet), Some((1, 42)));
assert!(verify(&settings, b, &packet).is_none());
let mut forged = packet.to_vec();
forged[16] = 2;
assert!(verify(&settings, a, &forged).is_none());
}
}
@@ -40,6 +40,49 @@ use crate::proto::{read_frame, write_frame};
use super::fragments::{self, Reassembler}; use super::fragments::{self, Reassembler};
use super::{InboundLink, PacketLink, PacketTransport, SharedLink, TransportError}; use super::{InboundLink, PacketLink, PacketTransport, SharedLink, TransportError};
/// QUIC carrier capability. Encryption of end-to-end IP packets is independent.
#[derive(Debug, Default)]
pub struct IrohCarrier;
impl super::DataCarrier for IrohCarrier {
fn protocol_id(&self) -> &str {
"wg-quic"
}
fn protocol_version(&self) -> u16 {
1
}
fn local_capability(
&self,
_: NetworkId,
) -> Result<Option<crate::dataplane::PluginCapability>, crate::dataplane::PluginError> {
Ok(Some(crate::dataplane::PluginCapability {
protocol: self.protocol_id().into(),
version: self.protocol_version(),
enabled: true,
data: Vec::new(),
}))
}
fn on_peer_capability(
&self,
_: NetworkId,
_: EndpointId,
capability: &crate::dataplane::PluginCapability,
) -> Result<(), crate::dataplane::PluginError> {
if capability.protocol != self.protocol_id()
|| capability.version != self.protocol_version()
|| !capability.enabled
|| !capability.data.is_empty()
{
return Err(crate::dataplane::PluginError::Rejected(
"unsupported QUIC carrier capability".into(),
));
}
Ok(())
}
fn on_peer_gone(&self, _: NetworkId, _: EndpointId) {}
fn on_network_deactivated(&self, _: NetworkId) {}
}
/// What the iroh transport needs from the agent. /// What the iroh transport needs from the agent.
/// ///
/// Implemented by the agent, which is the only thing that knows which networks /// Implemented by the agent, which is the only thing that knows which networks
@@ -97,6 +140,9 @@ impl IrohLink {
} }
impl PacketLink for IrohLink { impl PacketLink for IrohLink {
fn close(&self) {
self.conn.close(0u32.into(), b"data link retired");
}
fn network(&self) -> NetworkId { fn network(&self) -> NetworkId {
self.network self.network
} }
@@ -187,6 +233,10 @@ impl PacketLink for IrohLink {
self.conn.close_reason().is_some() self.conn.close_reason().is_some()
} }
fn failure_reason(&self) -> Option<String> {
self.conn.close_reason().map(|reason| reason.to_string())
}
fn path_description(&self) -> String { fn path_description(&self) -> String {
// Report what iroh actually knows, never a guess. // Report what iroh actually knows, never a guess.
let snapshot = crate::net::snapshot_connection(&self.conn); let snapshot = crate::net::snapshot_connection(&self.conn);
@@ -261,7 +311,8 @@ impl IrohTransport {
)); ));
} }
let serves = self.lookup.serves(outcome.network_id, &open.protocol).await; let serves = open.protocol == "wg-quic"
&& self.lookup.serves(outcome.network_id, &open.protocol).await;
if open.max_datagram == 0 || conn.max_datagram_size().is_none() { if open.max_datagram == 0 || conn.max_datagram_size().is_none() {
return Err(TransportError::Other( return Err(TransportError::Other(
"data channel has no datagram support".into(), "data channel has no datagram support".into(),
@@ -322,6 +373,12 @@ impl PacketTransport for IrohTransport {
"data channel protocol id is out of bounds".into(), "data channel protocol id is out of bounds".into(),
)); ));
} }
// This default must never impersonate a missing native carrier.
if protocol != "wg-quic" {
return Err(TransportError::Declined(format!(
"no native `{protocol}` data transport registered"
)));
}
let keys = self let keys = self
.lookup .lookup
.snapshot() .snapshot()
@@ -30,12 +30,17 @@
//! one plugin protocol. //! one plugin protocol.
mod fragments; mod fragments;
pub(crate) mod health;
pub mod iroh_link; pub mod iroh_link;
pub mod carrier;
pub use carrier::{DataCarrier, SharedCarrier};
use std::sync::Arc; use std::sync::Arc;
use bytes::Bytes; use bytes::Bytes;
use iroh::EndpointId; use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use crate::BoxFuture; use crate::BoxFuture;
use crate::identity::NetworkId; use crate::identity::NetworkId;
@@ -112,6 +117,14 @@ pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
/// keeping a task parked on [`PacketLink::closed`]. /// keeping a task parked on [`PacketLink::closed`].
fn is_closed(&self) -> bool; fn is_closed(&self) -> bool;
/// Explicitly retires a physical link. Implementations should wake readers.
fn close(&self) {}
/// Observed reason for retiring this link, if available.
fn failure_reason(&self) -> Option<String> {
None
}
/// A short description of the path in use, for diagnostics. /// A short description of the path in use, for diagnostics.
/// ///
/// Reports what the transport actually knows. It must not invent a value. /// Reports what the transport actually knows. It must not invent a value.
@@ -121,6 +134,76 @@ pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
/// A shared handle to a link. /// A shared handle to a link.
pub type SharedLink = std::sync::Arc<dyn PacketLink>; pub type SharedLink = std::sync::Arc<dyn PacketLink>;
/// Maximum opaque pair-negotiation payload.
pub const MAX_TRANSPORT_PAYLOAD: usize = 4096;
/// Private transport bytes, never included in Debug output.
#[derive(Clone, PartialEq, Eq, Serialize, zeroize::ZeroizeOnDrop)]
pub struct TransportPayload(Vec<u8>);
impl<'de> Deserialize<'de> for TransportPayload {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = TransportPayload;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "at most {MAX_TRANSPORT_PAYLOAD} private transport bytes")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut sequence: A,
) -> std::result::Result<Self::Value, A::Error> {
if sequence
.size_hint()
.is_some_and(|size| size > MAX_TRANSPORT_PAYLOAD)
{
return Err(serde::de::Error::custom("transport payload too large"));
}
let mut payload = TransportPayload(Vec::with_capacity(
sequence.size_hint().unwrap_or(0).min(MAX_TRANSPORT_PAYLOAD),
));
while let Some(byte) = sequence.next_element::<u8>()? {
if payload.0.len() == MAX_TRANSPORT_PAYLOAD {
return Err(serde::de::Error::custom("transport payload too large"));
}
payload.0.push(byte);
}
Ok(payload)
}
}
deserializer.deserialize_seq(Visitor)
}
}
impl TransportPayload {
/// Wraps bytes for one authenticated control session.
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
/// Borrows private bytes; callers must not log them.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl std::fmt::Debug for TransportPayload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("TransportPayload([REDACTED])")
}
}
/// Private control-plane sender for transports. Contains no network secret.
pub trait ControlSender: Send + Sync + std::fmt::Debug + 'static {
/// Queues a bounded message to one authenticated member of one network.
fn send<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
protocol: &'a str,
payload: TransportPayload,
) -> BoxFuture<'a, Result<(), TransportError>>;
}
/// An inbound link a peer opened towards us. /// An inbound link a peer opened towards us.
#[derive(Debug)] #[derive(Debug)]
pub struct InboundLink { pub struct InboundLink {
@@ -139,6 +222,18 @@ pub struct InboundLink {
/// The agent owns one of these and hands links to plugins; plugins never call /// The agent owns one of these and hands links to plugins; plugins never call
/// it directly. /// it directly.
pub trait PacketTransport: Send + Sync + std::fmt::Debug + 'static { pub trait PacketTransport: Send + Sync + std::fmt::Debug + 'static {
/// Supplies a private sender before network activation.
fn attach_control(&self, _control: Arc<dyn ControlSender>) {}
/// Validates and queues private negotiation received from an authenticated member.
fn on_control(
&self,
_network: NetworkId,
_peer: EndpointId,
_protocol: &str,
_payload: &[u8],
) -> Result<(), TransportError> {
Err(TransportError::Declined(self.name().into()))
}
/// A short name used in diagnostics. /// A short name used in diagnostics.
fn name(&self) -> &str; fn name(&self) -> &str;
@@ -184,6 +279,24 @@ impl MultiPacketTransport {
} }
impl PacketTransport for MultiPacketTransport { impl PacketTransport for MultiPacketTransport {
fn attach_control(&self, control: Arc<dyn ControlSender>) {
self.default.attach_control(control.clone());
for transport in self.by_protocol.values() {
transport.attach_control(control.clone());
}
}
fn on_control(
&self,
network: NetworkId,
peer: EndpointId,
protocol: &str,
payload: &[u8],
) -> Result<(), TransportError> {
match self.by_protocol.get(protocol) {
Some(transport) => transport.on_control(network, peer, protocol, payload),
None => Err(TransportError::Declined(protocol.into())),
}
}
fn name(&self) -> &str { fn name(&self) -> &str {
"multi" "multi"
} }
-380
View File
@@ -1,380 +0,0 @@
//! Generic harness for IP tunnel plugins.
//!
//! Implementing [`super::IpPlugin`] directly requires managing per-peer background
//! tasks, synchronization, packet queues, and graceful shutdown.
//!
//! [`TunnelCodec`] provides a simplified interface where a plugin author only
//! defines cryptographic transformation and handshake payloads.
//! [`GenericTunnelPlugin`] adapts any [`TunnelCodec`] into a full [`super::IpPlugin`].
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use bytes::Bytes;
use iroh::EndpointId;
use tokio::task::JoinHandle;
use super::{IpPlugin, PluginCapability, PluginContext, PluginError, ProtocolOption, SharedLink};
use crate::BoxFuture;
use crate::identity::NetworkId;
/// The simplified contract for an IP tunnel protocol implementation.
///
/// Implementors only handle key announcements, link lifecycle events,
/// and packet encrypt/decrypt. Concurrency and task management are handled
/// by [`GenericTunnelPlugin`].
pub trait TunnelCodec: Send + Sync + 'static {
/// Protocol identifier, e.g. `tcp-tls`.
fn protocol_id(&self) -> &str;
/// Protocol wire version.
fn protocol_version(&self) -> u16;
/// Options accepted by the protocol.
fn options(&self) -> &'static [ProtocolOption] {
&[]
}
/// Generates local capability announcement data for a network.
fn local_capability_data(&self, network: NetworkId) -> Result<Vec<u8>, PluginError>;
/// Handles a peer's announced capability payload.
fn on_peer_capability_data(
&self,
network: NetworkId,
peer: EndpointId,
data: &[u8],
) -> Result<(), PluginError>;
/// Called when an authenticated link to a peer is established.
fn on_peer_link_up(&self, network: NetworkId, peer: EndpointId, link: &SharedLink) {
let _ = (network, peer, link);
}
/// Called when a peer goes away.
fn on_peer_down(&self, network: NetworkId, peer: EndpointId) {
let _ = (network, peer);
}
/// Encrypts an outbound IP packet into a payload to send over the link.
fn encrypt(
&self,
network: NetworkId,
peer: EndpointId,
packet: &[u8],
) -> Result<Bytes, PluginError>;
/// Decrypts an inbound payload from the link into an IP packet.
fn decrypt(
&self,
network: NetworkId,
peer: EndpointId,
payload: &[u8],
) -> Result<Bytes, PluginError>;
/// Called during shutdown.
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
Box::pin(async {})
}
}
struct TunnelPeerState {
link: SharedLink,
tx_packets: AtomicU64,
rx_packets: AtomicU64,
tx_bytes: AtomicU64,
rx_bytes: AtomicU64,
dropped: AtomicU64,
established_at: std::time::Instant,
}
/// Statistics and status for one active peer in [`GenericTunnelPlugin`].
#[derive(Debug, Clone)]
pub struct TunnelPeerReport {
/// Peer endpoint identity.
pub peer: EndpointId,
/// Protocol identifier.
pub protocol: String,
/// Human-readable path description.
pub path: String,
/// Packets successfully encrypted and sent.
pub tx_packets: u64,
/// Packets successfully received and decrypted.
pub rx_packets: u64,
/// Plaintext bytes successfully encrypted and sent.
pub tx_bytes: u64,
/// Plaintext bytes successfully received and decrypted.
pub rx_bytes: u64,
/// Packets dropped due to encryption/decryption/send failures.
pub dropped: u64,
/// Uptime in seconds since the data link was established.
pub uptime_secs: u64,
}
/// Generic wrapper that turns a [`TunnelCodec`] into a full [`IpPlugin`].
pub struct GenericTunnelPlugin<C: TunnelCodec> {
codec: Arc<C>,
context: Mutex<Option<PluginContext>>,
links: RwLock<HashMap<(NetworkId, EndpointId), Arc<TunnelPeerState>>>,
tasks: Mutex<HashMap<(NetworkId, EndpointId), JoinHandle<()>>>,
}
impl<C: TunnelCodec> GenericTunnelPlugin<C> {
/// Wraps `codec` into an [`IpPlugin`].
pub fn new(codec: C) -> Self {
Self {
codec: Arc::new(codec),
context: Mutex::new(None),
links: RwLock::new(HashMap::new()),
tasks: Mutex::new(HashMap::new()),
}
}
/// Returns a reference to the underlying codec.
pub fn codec(&self) -> &C {
&self.codec
}
/// Returns the active peers with established data links for a network.
pub fn active_peers(&self, network: NetworkId) -> Vec<TunnelPeerReport> {
let guard = match self.links.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard
.iter()
.filter(|((net, _), _)| *net == network)
.map(|((_, peer), state)| TunnelPeerReport {
peer: *peer,
protocol: self.codec.protocol_id().to_string(),
path: state.link.path_description(),
tx_packets: state.tx_packets.load(Ordering::Relaxed),
rx_packets: state.rx_packets.load(Ordering::Relaxed),
tx_bytes: state.tx_bytes.load(Ordering::Relaxed),
rx_bytes: state.rx_bytes.load(Ordering::Relaxed),
dropped: state.dropped.load(Ordering::Relaxed),
uptime_secs: state.established_at.elapsed().as_secs(),
})
.collect()
}
}
impl<C: TunnelCodec> std::fmt::Debug for GenericTunnelPlugin<C> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GenericTunnelPlugin")
.field("protocol_id", &self.codec.protocol_id())
.field("protocol_version", &self.codec.protocol_version())
.finish()
}
}
impl<C: TunnelCodec> IpPlugin for GenericTunnelPlugin<C> {
fn protocol_id(&self) -> &str {
self.codec.protocol_id()
}
fn protocol_version(&self) -> u16 {
self.codec.protocol_version()
}
fn options(&self) -> &'static [ProtocolOption] {
self.codec.options()
}
fn attach(&self, context: PluginContext) {
if let Ok(mut guard) = self.context.lock() {
*guard = Some(context);
}
}
fn local_capability(
&self,
network: NetworkId,
) -> Result<Option<PluginCapability>, PluginError> {
let data = self.codec.local_capability_data(network)?;
Ok(Some(PluginCapability {
protocol: self.codec.protocol_id().to_string(),
version: self.codec.protocol_version(),
enabled: true,
data,
}))
}
fn on_peer_capability(
&self,
network: NetworkId,
peer: EndpointId,
capability: &PluginCapability,
) -> Result<(), PluginError> {
if capability.protocol != self.codec.protocol_id() {
return Ok(());
}
self.codec
.on_peer_capability_data(network, peer, &capability.data)
}
fn carry(&self, network: NetworkId, peer: EndpointId, packet: bytes::Bytes) -> bool {
let state = {
let guard = match self.links.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.get(&(network, peer)).cloned()
};
let Some(state) = state else {
return false;
};
match self.codec.encrypt(network, peer, &packet) {
Ok(ciphertext) => {
if state.link.send(ciphertext).is_ok() {
state.tx_packets.fetch_add(1, Ordering::Relaxed);
state
.tx_bytes
.fetch_add(packet.len() as u64, Ordering::Relaxed);
true
} else {
state.dropped.fetch_add(1, Ordering::Relaxed);
false
}
}
Err(err) => {
state.dropped.fetch_add(1, Ordering::Relaxed);
tracing::debug!(%peer, %err, "tunnel encrypt failed");
false
}
}
}
fn on_peer_link(&self, network: NetworkId, peer: EndpointId, link: SharedLink) {
let state = Arc::new(TunnelPeerState {
link: Arc::clone(&link),
tx_packets: AtomicU64::new(0),
rx_packets: AtomicU64::new(0),
tx_bytes: AtomicU64::new(0),
rx_bytes: AtomicU64::new(0),
dropped: AtomicU64::new(0),
established_at: std::time::Instant::now(),
});
{
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.insert((network, peer), Arc::clone(&state));
}
self.codec.on_peer_link_up(network, peer, &link);
let codec = Arc::clone(&self.codec);
let sink = match self.context.lock() {
Ok(g) => g.as_ref().map(|ctx| ctx.packet_sink()),
Err(p) => p.into_inner().as_ref().map(|ctx| ctx.packet_sink()),
};
let rx_link = Arc::clone(&link);
let rx_state = Arc::clone(&state);
let handle = tokio::spawn(async move {
while let Some(payload) = rx_link.recv().await {
match codec.decrypt(network, peer, &payload) {
Ok(packet) => {
rx_state.rx_packets.fetch_add(1, Ordering::Relaxed);
rx_state
.rx_bytes
.fetch_add(packet.len() as u64, Ordering::Relaxed);
if let Some(ref sink) = sink {
sink.deliver(network, peer, packet).await;
}
}
Err(err) => {
rx_state.dropped.fetch_add(1, Ordering::Relaxed);
tracing::debug!(%peer, %err, "tunnel decrypt failed");
}
}
}
});
let mut guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(old) = guard.insert((network, peer), handle) {
old.abort();
}
}
fn on_peer_gone(&self, network: NetworkId, peer: EndpointId) {
{
let mut guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(task) = guard.remove(&(network, peer)) {
task.abort();
}
}
{
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.remove(&(network, peer));
}
self.codec.on_peer_down(network, peer);
}
fn on_network_deactivated(&self, network: NetworkId) {
let keys_to_remove: Vec<(NetworkId, EndpointId)> = {
let guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard
.keys()
.filter(|(net, _)| *net == network)
.copied()
.collect()
};
{
let mut guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
for key in &keys_to_remove {
if let Some(task) = guard.remove(key) {
task.abort();
}
}
}
{
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.retain(|(net, _), _| *net != network);
}
}
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
Box::pin(async move {
let mut tasks = Vec::new();
{
let mut guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
for (_, task) in guard.drain() {
tasks.push(task);
}
}
for task in tasks {
task.abort();
}
self.codec.shutdown().await;
})
}
}
+19
View File
@@ -15,6 +15,12 @@ pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
#[non_exhaustive] #[non_exhaustive]
pub enum Error { pub enum Error {
/// A node-history cleanup is unsafe, stale or cannot be performed.
#[error("cannot forget node: {0}")]
ForgetNode(String),
/// An ordered local data-plane protocol selection is invalid.
#[error("invalid protocol selection: {0}")]
InvalidProtocols(String),
/// The supplied network name does not satisfy the documented rules. /// The supplied network name does not satisfy the documented rules.
#[error("invalid network name: {0}")] #[error("invalid network name: {0}")]
InvalidNetworkName(&'static str), InvalidNetworkName(&'static str),
@@ -97,6 +103,19 @@ pub enum Error {
#[error("network {0} is not configured")] #[error("network {0} is not configured")]
NetworkUnknown(NetworkId), NetworkUnknown(NetworkId),
/// An overlay range overlaps networks already active on this agent.
#[error(
"network {network} cannot start: {range} overlaps active networks {conflicts:?}; switch to it to suspend those networks"
)]
NetworkRangeConflict {
/// Network that could not start.
network: NetworkId,
/// Its agreed or proposed range.
range: crate::state::Ipv4Range,
/// Active networks that must be suspended first.
conflicts: Vec<NetworkId>,
},
/// No session with that peer exists in the given network. /// No session with that peer exists in the given network.
#[error("no authenticated session with peer {peer} in network {network}")] #[error("no authenticated session with peer {peer} in network {network}")]
NoSuchPeer { NoSuchPeer {
+400 -3
View File
@@ -171,6 +171,33 @@ pub fn default_control_socket_path() -> PathBuf {
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive] #[non_exhaustive]
pub enum Request { pub enum Request {
/// Save a complete version-checked service draft or its deletion.
SaveVip {
/// Exact network id.
network_id: String,
/// Entire desired configuration.
config: crate::vip::VipConfig,
/// Revision originally displayed; absent for creation.
expected: Option<String>,
/// Remove the service permanently.
delete: bool,
},
/// Retire the selected offline member's observed claim.
ForgetNode {
/// Exact public network id.
network_id: String,
/// Exact device identity selected by the client.
peer: String,
/// Claim version displayed when the user selected the node.
expected_version: u64,
},
/// Replace one network's ordered local data-plane selection.
SetProtocols {
/// Public network identifier.
network_id: String,
/// Protocol names, best first; empty selects control only.
protocols: Vec<String>,
},
/// Report what the agent is doing. /// Report what the agent is doing.
Status, Status,
/// Set local broadcast participation in one configured network. /// Set local broadcast participation in one configured network.
@@ -255,6 +282,24 @@ pub enum Request {
impl std::fmt::Debug for Request { impl std::fmt::Debug for Request {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Request::SaveVip {
network_id, config, ..
} => write!(f, "SaveVip {{ {network_id}, {} }}", config.id),
Request::ForgetNode {
network_id,
peer,
expected_version,
} => write!(
f,
"ForgetNode {{ {network_id}, peer: {peer}, version: {expected_version} }}"
),
Request::SetProtocols {
network_id,
protocols,
} => write!(
f,
"SetProtocols {{ {network_id}, protocols: {protocols:?} }}"
),
Request::Status => f.write_str("Status"), Request::Status => f.write_str("Status"),
Request::SetBroadcast { Request::SetBroadcast {
network_id, network_id,
@@ -286,6 +331,12 @@ impl std::fmt::Debug for Request {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive] #[non_exhaustive]
pub enum Response { pub enum Response {
/// Accepted configuration with fresh local routing observations.
Vips(Vec<crate::vip::VipStatus>),
/// An observed offline claim was durably retired.
NodeForgotten(ForgottenNodeReport),
/// Accepted ordered protocol selection.
Protocols(Vec<String>),
/// A status report. /// A status report.
Status(Box<StatusReport>), Status(Box<StatusReport>),
/// Accepted local broadcast participation. /// Accepted local broadcast participation.
@@ -312,6 +363,192 @@ pub enum Response {
Error(String), Error(String),
} }
/// The durable result of an offline-node cleanup.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForgottenNodeReport {
/// Selected device identity.
pub peer: String,
/// The retired claim version, not a future-version ban.
pub record_version: u64,
/// Peers to which cleanup was immediately queued.
pub peers_told: u32,
}
/// Saves a whole VIP draft through the running agent.
pub async fn save_vip(
socket: &Path,
network_id: &str,
config: crate::vip::VipConfig,
expected: Option<String>,
delete: bool,
) -> Result<Vec<crate::vip::VipStatus>> {
match exchange(
socket,
&Request::SaveVip {
network_id: network_id.into(),
config,
expected,
delete,
},
EXCHANGE_TIMEOUT,
)
.await?
{
Response::Vips(vips) => Ok(vips),
Response::Error(reason) => Err(Error::Rejected { reason }),
_ => Err(Error::Rejected {
reason: "unexpected VIP response".into(),
}),
}
}
/// Resolves a service by exact name, full id or unique id prefix.
pub fn resolve_vip<'a>(
network: &'a NetworkReport,
selector: &str,
) -> std::result::Result<&'a crate::vip::VipStatus, String> {
let matches: Vec<_> = network
.vips
.iter()
.filter(|v| v.config.name == selector || v.config.id.starts_with(selector))
.collect();
match matches.as_slice() {
[vip] => Ok(vip),
[] => Err(format!("no VIP matches `{selector}`")),
_ => Err(format!(
"ambiguous VIP `{selector}`: {}",
matches
.iter()
.map(|v| format!("{} ({})", v.config.name, v.config.id))
.collect::<Vec<_>>()
.join(", ")
)),
}
}
/// Resolves a node by exact hostname, full id, unique id prefix or overlay IPv4.
/// Ambiguity is reported with names and full ids and never guessed.
pub fn resolve_node<'a>(
network: &'a NetworkReport,
selector: &str,
) -> std::result::Result<&'a MemberReport, String> {
let selector = selector.trim();
if selector.is_empty() {
return Err("select a node by name, id or IPv4 address".into());
}
if let Some(member) = network.members.iter().find(|m| m.endpoint_id == selector) {
return Ok(member);
}
let prefix = selector.trim_end_matches('…');
let matches: Vec<_> = network
.members
.iter()
.filter(|m| {
m.hostname.as_deref() == Some(selector)
|| m.overlay_address_v4.as_deref() == Some(selector)
|| (!prefix.is_empty() && m.endpoint_id.starts_with(prefix))
})
.collect();
match matches.as_slice() {
[one] => Ok(one),
[] => Err(format!(
"no signed node matches `{selector}` in `{}`",
network.name
)),
many => Err(format!(
"`{selector}` is ambiguous; use a full id or unique id prefix:\n{}",
many.iter()
.map(|m| format!(
" {} {} {}",
m.hostname.as_deref().unwrap_or("unnamed"),
m.endpoint_id,
m.overlay_address_v4.as_deref().unwrap_or("no IPv4")
))
.collect::<Vec<_>>()
.join("\n")
)),
}
}
/// Retires an exact selected claim; an online or changed target is rejected.
pub async fn forget_node(
socket: &Path,
network: &str,
peer: &str,
expected_version: u64,
) -> Result<ForgottenNodeReport> {
let request = Request::ForgetNode {
network_id: network.into(),
peer: peer.into(),
expected_version,
};
match exchange(socket, &request, EXCHANGE_TIMEOUT).await? {
Response::NodeForgotten(report) => Ok(report),
Response::Error(reason) => Err(Error::ForgetNode(reason)),
other => Err(Error::Storage(format!("unexpected response: {other:?}"))),
}
}
#[cfg(test)]
mod node_selector_tests {
use super::*;
#[test]
fn exact_names_addresses_and_unique_prefixes_select_the_same_signed_node() {
let node = MemberReport {
endpoint_id: "abcdef01".into(),
hostname: Some("laptop".into()),
overlay_address_v4: Some("10.2.0.3".into()),
record_version: 42,
..Default::default()
};
let network = NetworkReport {
name: "home".into(),
members: vec![node.clone()],
..Default::default()
};
for selector in ["laptop", "10.2.0.3", "abcdef01", "abc…"] {
assert_eq!(resolve_node(&network, selector), Ok(&node));
}
for selector in ["", "LAPTOP", "missing"] {
assert!(resolve_node(&network, selector).is_err());
}
}
#[test]
fn collisions_between_names_addresses_and_prefixes_require_an_explicit_identity() {
let first = MemberReport {
endpoint_id: "abcdef01".into(),
hostname: Some("duplicate".into()),
overlay_address_v4: Some("10.2.0.3".into()),
..Default::default()
};
let second = MemberReport {
endpoint_id: "abcdef02".into(),
hostname: Some("duplicate".into()),
overlay_address_v4: Some("10.2.0.3".into()),
..Default::default()
};
let third = MemberReport {
endpoint_id: "12345678".into(),
hostname: Some("abc".into()),
..Default::default()
};
let network = NetworkReport {
name: "home".into(),
members: vec![first.clone(), second, third],
..Default::default()
};
for selector in ["duplicate", "10.2.0.3", "abc"] {
let result = resolve_node(&network, selector);
assert!(result.is_err_and(|error| error.contains("ambiguous")
&& error.contains("abcdef01")
&& error.contains("abcdef02")
&& error.contains("duplicate")));
}
assert_eq!(resolve_node(&network, "abcdef01"), Ok(&first));
}
}
/// What happened when a network was stopped or started. /// What happened when a network was stopped or started.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActiveReport { pub struct ActiveReport {
@@ -324,6 +561,9 @@ pub struct ActiveReport {
/// Stopping something already stopped is not an error — it is the state /// Stopping something already stopped is not an error — it is the state
/// asked for — but it is worth saying which happened. /// asked for — but it is worth saying which happened.
pub changed: bool, pub changed: bool,
/// Networks suspended by switching to this network.
#[serde(default)]
pub suspended: Vec<RangeConflictReport>,
} }
/// What happened when a network was joined. /// What happened when a network was joined.
@@ -345,6 +585,13 @@ pub struct JoinedReport {
/// Almost always a mistyped secret, and the one thing that makes two /// Almost always a mistyped secret, and the one thing that makes two
/// sections of a report look like one network. /// sections of a report look like one network.
pub name_shared_with: Option<String>, pub name_shared_with: Option<String>,
/// Whether it started; false means it is configured but blocked by overlap.
#[serde(default = "joined_active_default")]
pub active: bool,
}
fn joined_active_default() -> bool {
true
} }
/// What happened when a network was left. /// What happened when a network was left.
@@ -367,6 +614,8 @@ pub struct LeftReport {
/// Everything the agent is doing, in one snapshot. /// Everything the agent is doing, in one snapshot.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusReport { pub struct StatusReport {
/// Data-plane protocols this agent can enable at runtime.
pub available_protocols: Vec<ProtocolReport>,
/// This device's persistent endpoint id. /// This device's persistent endpoint id.
pub endpoint_id: String, pub endpoint_id: String,
/// Hostname announced to peers. /// Hostname announced to peers.
@@ -437,9 +686,37 @@ pub struct DnsReport {
pub names: u32, pub names: u32,
} }
/// An active network with an overlapping overlay range.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RangeConflictReport {
/// Public network id, for unambiguous selection.
pub network_id: String,
/// Configured network name.
pub name: String,
/// The overlapping range.
pub range: String,
}
/// A data-plane protocol available in the running agent.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtocolReport {
/// Stable protocol name used by the CLI and network negotiation.
pub name: String,
/// A short description of encryption and transport.
pub summary: String,
}
/// One network. /// One network.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NetworkReport { pub struct NetworkReport {
/// Shared VIPs and this client's routing observations.
#[serde(default)]
pub vips: Vec<crate::vip::VipStatus>,
/// Enabled local data-plane protocols, in preference order.
pub protocols: Vec<String>,
/// Whether the network starts or resumes automatically when its range is free.
#[serde(default)]
pub auto_start: bool,
/// Whether this agent participates in LAN broadcasts in this network. /// Whether this agent participates in LAN broadcasts in this network.
#[serde(default)] #[serde(default)]
pub broadcast: bool, pub broadcast: bool,
@@ -482,16 +759,53 @@ pub struct NetworkReport {
/// The range it could not have, because another network on this agent /// The range it could not have, because another network on this agent
/// already holds it. /// already holds it.
pub range_conflict: Option<String>, pub range_conflict: Option<String>,
/// Networks that switching to this network would suspend.
#[serde(default)]
pub range_conflicts: Vec<RangeConflictReport>,
/// The overlay, when a protocol is running one. /// The overlay, when a protocol is running one.
pub overlay: Option<OverlayReport>, pub overlay: Option<OverlayReport>,
/// This agent's own name, when another member claimed it first.
pub hostname_conflict: Option<HostnameConflictReport>,
/// Exit-node settings: whether this agent offers one, and whether it /// Exit-node settings: whether this agent offers one, and whether it
/// uses one. /// uses one.
pub exit: ExitReport, pub exit: ExitReport,
} }
/// How packets reach a member right now.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum DataPathReport {
/// Nothing carries packets to it: it is away, or only connected for
/// control while a data path is being set up.
#[default]
None,
/// A direct link.
Direct {
/// The kind of transport carrying it, e.g. `wg`.
transport: String,
},
/// Through other members.
Relayed {
/// Number of links on the way, at least two.
hops: u8,
/// The member the first of them goes to, as an endpoint id.
via: String,
},
}
/// A name somebody else claimed first.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostnameConflictReport {
/// The name that is taken.
pub name: String,
/// The member that holds it, as an endpoint id.
pub holder: String,
}
/// One member of the network, from signed state. /// One member of the network, from signed state.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemberReport { pub struct MemberReport {
/// Signed claim version used to reject stale cleanup requests.
pub record_version: u64,
/// The member's device identity. /// The member's device identity.
pub endpoint_id: String, pub endpoint_id: String,
/// The IPv4 overlay address it claimed and signed for. /// The IPv4 overlay address it claimed and signed for.
@@ -500,11 +814,22 @@ pub struct MemberReport {
pub failed_dials: u32, pub failed_dials: u32,
/// The last hostname this agent saw it announce, kept while it is away. /// The last hostname this agent saw it announce, kept while it is away.
pub hostname: Option<String>, pub hostname: Option<String>,
/// Whether this agent can reach it now, by any path. The one answer
/// every client gives to "is it online": a member reached through
/// others is online, over a longer path.
pub online: bool,
/// How packets reach it.
pub path: DataPathReport,
/// The name it claimed, when somebody else claimed it first. It still
/// works by address; it is not found by that name.
pub hostname_conflict: Option<HostnameConflictReport>,
} }
/// One control plane peer. /// One control plane peer.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerReport { pub struct PeerReport {
/// Actionable carrier failure or policy mismatch; absent for a usable route.
pub data_error: Option<String>,
/// The peer's endpoint id. /// The peer's endpoint id.
pub endpoint_id: String, pub endpoint_id: String,
/// Hostname it announced, if any. /// Hostname it announced, if any.
@@ -516,6 +841,8 @@ pub struct PeerReport {
pub rtt_ms: Option<u64>, pub rtt_ms: Option<u64>,
/// The peer offers itself as an exit node in this network. /// The peer offers itself as an exit node in this network.
pub exit_node: bool, pub exit_node: bool,
/// How packets reach it.
pub path: DataPathReport,
} }
/// The WireGuard overlay of one network. /// The WireGuard overlay of one network.
@@ -592,10 +919,12 @@ pub struct HostRulesReport {
/// One overlay peer. /// One overlay peer.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayPeerReport { pub struct OverlayPeerReport {
/// Opaque counter lifetime. A new tunnel or agent restart starts a new epoch.
pub counter_epoch: u64,
/// The peer's control plane identity, so a tunnel can be matched to the /// The peer's control plane identity, so a tunnel can be matched to the
/// session and the member it belongs to. /// session and the member it belongs to.
pub endpoint_id: String, pub endpoint_id: String,
/// Protocol carrying overlay traffic for this peer (e.g. "tcp-tls", "wg-quic"). /// Reserved legacy field; the internal encryption protocol is not user selectable.
pub protocol: String, pub protocol: String,
/// The peer's WireGuard public key (if applicable). /// The peer's WireGuard public key (if applicable).
pub public_key: String, pub public_key: String,
@@ -650,6 +979,33 @@ impl OverlayPeerReport {
/// running and what they can report or change. That is what keeps this module /// running and what they can report or change. That is what keeps this module
/// free of any knowledge of them. /// free of any knowledge of them.
pub trait ReportSource: Send + Sync + 'static { pub trait ReportSource: Send + Sync + 'static {
/// Atomically saves a complete VIP draft using its original revision.
fn save_vip(
&self,
_network: String,
_config: crate::vip::VipConfig,
_expected: Option<String>,
_delete: bool,
) -> BoxFuture<'_, std::result::Result<Vec<crate::vip::VipStatus>, String>> {
Box::pin(async { Err("this agent cannot manage VIPs".into()) })
}
/// Persists and announces a version-scoped node cleanup.
fn forget_node(
&self,
_network: String,
_peer: String,
_expected_version: u64,
) -> BoxFuture<'_, std::result::Result<ForgottenNodeReport, String>> {
Box::pin(async { Err("this agent cannot forget node history".into()) })
}
/// Saves and applies a local ordered protocol set for one network.
fn set_protocols(
&self,
_network_id: String,
_protocols: Vec<String>,
) -> BoxFuture<'_, std::result::Result<Vec<String>, String>> {
Box::pin(async { Err("this agent cannot change protocols".into()) })
}
/// Produces a fresh report. /// Produces a fresh report.
fn report(&self) -> BoxFuture<'_, StatusReport>; fn report(&self) -> BoxFuture<'_, StatusReport>;
@@ -779,7 +1135,7 @@ pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
/// ///
/// Bump it whenever [`Request`], [`Response`] or anything they contain /// Bump it whenever [`Request`], [`Response`] or anything they contain
/// changes shape. /// changes shape.
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 17]); pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 23]);
/// Reads one request off an accepted stream, answers it, writes the response. /// Reads one request off an accepted stream, answers it, writes the response.
/// ///
@@ -803,6 +1159,30 @@ where
} }
}; };
let response = match request { let response = match request {
Request::SaveVip {
network_id,
config,
expected,
delete,
} => match source.save_vip(network_id, config, expected, delete).await {
Ok(vips) => Response::Vips(vips),
Err(reason) => Response::Error(reason),
},
Request::ForgetNode {
network_id,
peer,
expected_version,
} => match source.forget_node(network_id, peer, expected_version).await {
Ok(report) => Response::NodeForgotten(report),
Err(reason) => Response::Error(reason),
},
Request::SetProtocols {
network_id,
protocols,
} => match source.set_protocols(network_id, protocols).await {
Ok(protocols) => Response::Protocols(protocols),
Err(reason) => Response::Error(reason),
},
Request::Status => Response::Status(Box::new(source.report().await)), Request::Status => Response::Status(Box::new(source.report().await)),
Request::SetBroadcast { Request::SetBroadcast {
network_id, network_id,
@@ -958,7 +1338,24 @@ pub async fn join_network_with_broadcast(
} }
} }
/// Changes one network's broadcast participation while the agent runs. /// Replaces the enabled data-plane protocols of one configured network.
pub async fn set_protocols(
socket: &Path,
network_id: &str,
protocols: Vec<String>,
) -> Result<Vec<String>> {
let request = Request::SetProtocols {
network_id: network_id.to_owned(),
protocols,
};
match exchange(socket, &request, EXCHANGE_TIMEOUT).await? {
Response::Protocols(protocols) => Ok(protocols),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected response: {other:?}"))),
}
}
/// Changes local broadcast participation in a network.
pub async fn set_broadcast( pub async fn set_broadcast(
path: impl AsRef<Path>, path: impl AsRef<Path>,
network_id: &str, network_id: &str,
+1
View File
@@ -51,6 +51,7 @@ pub mod storage;
pub mod task; pub mod task;
#[cfg(feature = "testing")] #[cfg(feature = "testing")]
pub mod testing; pub mod testing;
pub mod vip;
pub use agent::{Agent, AgentStatus, Event, NetworkStatus, PeerStatus}; pub use agent::{Agent, AgentStatus, Event, NetworkStatus, PeerStatus};
pub use config::{AgentConfig, Limits, ReconnectPolicy, StoragePaths, TransportPolicy}; pub use config::{AgentConfig, Limits, ReconnectPolicy, StoragePaths, TransportPolicy};
+29 -8
View File
@@ -22,6 +22,7 @@
//! own reachability data. //! own reachability data.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use iroh::endpoint::{ use iroh::endpoint::{
@@ -221,9 +222,18 @@ fn local_addr_string(addr: &iroh::endpoint::LocalTransportAddr) -> Option<String
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EndpointAdapter { pub struct EndpointAdapter {
endpoint: Endpoint, endpoint: Endpoint,
owned_interface: Arc<std::sync::RwLock<String>>,
} }
impl EndpointAdapter { impl EndpointAdapter {
/// Excludes the real name after interface creation (macOS assigns utunN).
pub(crate) fn exclude_interface(&self, name: &str) {
*self
.owned_interface
.write()
.unwrap_or_else(|p| p.into_inner()) = name.to_lowercase();
}
/// Binds an endpoint according to `config`, reusing the persistent device key. /// Binds an endpoint according to `config`, reusing the persistent device key.
pub async fn bind(config: &AgentConfig, identity: &DeviceIdentity) -> Result<Self> { pub async fn bind(config: &AgentConfig, identity: &DeviceIdentity) -> Result<Self> {
let mut builder = Endpoint::builder(presets::Minimal) let mut builder = Endpoint::builder(presets::Minimal)
@@ -240,11 +250,15 @@ impl EndpointAdapter {
TransportPolicy::N0Defaults => builder.preset(presets::N0), TransportPolicy::N0Defaults => builder.preset(presets::N0),
}; };
if matches!( let owned_interface =
config.transport, Arc::new(std::sync::RwLock::new(config.interface_name.to_lowercase()));
TransportPolicy::N0Defaults | TransportPolicy::DirectOnly {
) { let interface_filter = Arc::clone(&owned_interface);
let filter = iroh::address_lookup::AddrFilter::new(|addrs| { let filter = iroh::address_lookup::AddrFilter::new(move |addrs| {
let owned_interface = interface_filter
.read()
.unwrap_or_else(|p| p.into_inner())
.clone();
let mut overlay_ips = std::collections::HashSet::new(); let mut overlay_ips = std::collections::HashSet::new();
for iface in netdev::get_interfaces() { for iface in netdev::get_interfaces() {
let name = iface.name.to_lowercase(); let name = iface.name.to_lowercase();
@@ -258,7 +272,9 @@ impl EndpointAdapter {
.as_deref() .as_deref()
.map(str::to_lowercase) .map(str::to_lowercase)
.unwrap_or_default(); .unwrap_or_default();
if name.starts_with("tsun") if name == owned_interface
|| friendly == owned_interface
|| name.starts_with("tsun")
|| name.contains("tsunagi") || name.contains("tsunagi")
|| name.contains("wintun") || name.contains("wintun")
|| friendly.starts_with("tsun") || friendly.starts_with("tsun")
@@ -288,7 +304,9 @@ impl EndpointAdapter {
.collect(); .collect();
std::borrow::Cow::Owned(filtered) std::borrow::Cow::Owned(filtered)
}); });
builder = builder.addr_filter(filter); builder = builder
.direct_addr_filter(filter.clone())
.addr_filter(filter);
} }
#[cfg(feature = "testing")] #[cfg(feature = "testing")]
@@ -395,7 +413,10 @@ impl EndpointAdapter {
} }
}); });
Ok(Self { endpoint }) Ok(Self {
endpoint,
owned_interface,
})
} }
/// The underlying iroh endpoint. /// The underlying iroh endpoint.
@@ -35,6 +35,17 @@ pub struct ExitHostPlan {
pub offer: Vec<Ipv4Range>, pub offer: Vec<Ipv4Range>,
/// Whether this device sends its internet traffic through a member. /// Whether this device sends its internet traffic through a member.
pub client: bool, pub client: bool,
/// The range of every network on the interface. Only the using side
/// reads it, to keep this agent's own traffic *to* the overlay inside it
/// where the host has no per-user routing (macOS).
pub overlay: Vec<Ipv4Range>,
/// Underlay addresses this agent talks to directly and must keep reaching
/// outside the overlay's default route. Only filled while `client` is set
/// and only for hosts that report [`ExitHostRules::needs_bypass`].
pub bypass: Vec<std::net::Ipv4Addr>,
/// Host names (relay servers) that must stay reachable the same way; the
/// host rules resolve them.
pub bypass_hosts: Vec<String>,
} }
impl ExitHostPlan { impl ExitHostPlan {
@@ -72,6 +83,18 @@ pub trait ExitHostRules: Send + Sync + std::fmt::Debug + 'static {
/// when nothing is there: it runs on the shutdown path, and once at /// when nothing is there: it runs on the shutdown path, and once at
/// startup to take away what a crashed run left. /// startup to take away what a crashed run left.
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>; fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>;
/// Refreshes volatile local socket protection without replacing exit routes.
fn refresh<'a>(&'a self, _plan: &'a ExitHostPlan) -> BoxFuture<'a, Option<RuleOutcome>> {
Box::pin(async { None })
}
/// Whether using an exit node on this host needs the underlay addresses
/// the agent talks to, because the host cannot exempt the agent's own
/// traffic from the default route by user id (Windows). The default is no.
fn needs_bypass(&self) -> bool {
false
}
} }
/// The tag every firewall object of one offered range carries. /// The tag every firewall object of one offered range carries.
@@ -99,6 +122,7 @@ pub struct MockExitRules {
calls: Arc<Mutex<Vec<String>>>, calls: Arc<Mutex<Vec<String>>>,
forwarding: Arc<Mutex<Option<bool>>>, forwarding: Arc<Mutex<Option<bool>>>,
failure: Arc<Mutex<Option<String>>>, failure: Arc<Mutex<Option<String>>>,
needs_bypass: Arc<Mutex<bool>>,
} }
impl MockExitRules { impl MockExitRules {
@@ -114,6 +138,11 @@ impl MockExitRules {
*lock(&self.forwarding) = forwarding; *lock(&self.forwarding) = forwarding;
} }
/// Whether this host asks for the underlay addresses, like Windows.
pub fn set_needs_bypass(&self, needs: bool) {
*lock(&self.needs_bypass) = needs;
}
/// Makes every step fail with this reason from now on. /// Makes every step fail with this reason from now on.
pub fn fail_with(&self, reason: Option<String>) { pub fn fail_with(&self, reason: Option<String>) {
*lock(&self.failure) = reason; *lock(&self.failure) = reason;
@@ -138,6 +167,10 @@ fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
} }
impl ExitHostRules for MockExitRules { impl ExitHostRules for MockExitRules {
fn needs_bypass(&self) -> bool {
*lock(&self.needs_bypass)
}
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> { fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
Box::pin(async move { Box::pin(async move {
lock(&self.calls).push(format!("apply:{}", plan.interface)); lock(&self.calls).push(format!("apply:{}", plan.interface));
@@ -204,6 +237,8 @@ mod tests {
interface: "tsun0".into(), interface: "tsun0".into(),
offer: vec![range()], offer: vec![range()],
client: true, client: true,
overlay: vec![range()],
..Default::default()
}; };
let report = rules.apply(&plan).await; let report = rules.apply(&plan).await;
assert_eq!(report.offer[0].1, RuleOutcome::Applied); assert_eq!(report.offer[0].1, RuleOutcome::Applied);
@@ -218,6 +253,8 @@ mod tests {
interface: "tsun0".into(), interface: "tsun0".into(),
offer: vec![range()], offer: vec![range()],
client: false, client: false,
overlay: Vec::new(),
..Default::default()
}) })
.await; .await;
assert_eq!(report.forwarding, Some(false)); assert_eq!(report.forwarding, Some(false));
@@ -33,12 +33,13 @@ const CHAIN: &str = "INPUT";
/// Fixed places `iptables` is installed. Never `PATH`: the agent is /// Fixed places `iptables` is installed. Never `PATH`: the agent is
/// privileged and a directory a user controls must not decide what it runs. /// privileged and a directory a user controls must not decide what it runs.
const IPTABLES_DIRECTORIES: [&str; 5] = [ const IPTABLES_DIRECTORIES: [&str; 6] = [
"/usr/sbin/iptables", "/usr/sbin/iptables",
"/sbin/iptables", "/sbin/iptables",
"/usr/bin/iptables", "/usr/bin/iptables",
"/bin/iptables", "/bin/iptables",
"/usr/local/sbin/iptables", "/usr/local/sbin/iptables",
"/run/current-system/sw/bin/iptables",
]; ];
/// The first candidate that exists. /// The first candidate that exists.
@@ -66,24 +67,68 @@ pub(super) enum Ran {
pub(super) type Run<'a> = &'a dyn Fn(&[String]) -> Ran; pub(super) type Run<'a> = &'a dyn Fn(&[String]) -> Ran;
/// The match and target of the rule, without the chain or position. /// The match and target of the rule, without the chain or position.
fn rule_spec(plan: &BroadcastRulesPlan) -> Vec<String> { ///
[ /// `comment` false leaves the tag off, for a kernel that has no `comment`
"-i", /// match; the rule is then recognised by its shape, see [`tagged_deletions`].
plan.interface.as_str(), fn rule_spec(plan: &BroadcastRulesPlan, comment: bool) -> Vec<String> {
"-p", let range = plan.range.to_string();
"udp", let tag = plan.tag();
"-s", let mut words = vec!["-i", plan.interface.as_str(), "-p", "udp", "-s", &range];
&plan.range.to_string(), if comment {
"-m", words.extend(["-m", "comment", "--comment", &tag]);
"comment", }
"--comment", words.extend(["-j", "ACCEPT"]);
&plan.tag(), words.into_iter().map(str::to_string).collect()
"-j", }
"ACCEPT",
] /// Whether the kernel has the `comment` match, which a rule needs to carry
.into_iter() /// its tag. Asked of iptables itself: checking a rule that does not exist
.map(str::to_string) /// fails either way, but only a missing match says so.
.collect() pub(super) fn comment_supported(run: Run<'_>) -> bool {
let probe = locked(
[
"-t",
"filter",
"-C",
"FORWARD",
"-m",
"comment",
"--comment",
"tsunagi-probe",
"-j",
"ACCEPT",
]
.map(str::to_string),
);
match run(&probe) {
Ran::Exited {
success: false,
stderr,
..
} => !stderr.contains("No chain/target/match by that name"),
_ => true,
}
}
/// `iptables -S` words as option clauses: an option with its value, a negated
/// one with its `!`. It prints the options in its own order, not the one a
/// rule was written in, so rules are compared by their clauses, sorted.
fn clauses(words: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut at = 0;
while at < words.len() {
let take = if words[at] == "!" { 3 } else { 2 };
let end = (at + take).min(words.len());
out.push(words[at..end].join(" "));
at = end;
}
out.sort();
out
}
/// Whether two rules say the same thing, whatever order they list it in.
pub(super) fn same_rule(a: &[String], b: &[String]) -> bool {
clauses(a) == clauses(b)
} }
/// Waits briefly for the xtables lock instead of failing when another tool /// Waits briefly for the xtables lock instead of failing when another tool
@@ -96,19 +141,19 @@ pub(super) fn locked(args: impl IntoIterator<Item = String>) -> Vec<String> {
.collect() .collect()
} }
fn check_args(plan: &BroadcastRulesPlan) -> Vec<String> { fn check_args(plan: &BroadcastRulesPlan, comment: bool) -> Vec<String> {
locked( locked(
["-C".to_string(), CHAIN.to_string()] ["-C".to_string(), CHAIN.to_string()]
.into_iter() .into_iter()
.chain(rule_spec(plan)), .chain(rule_spec(plan, comment)),
) )
} }
fn insert_args(plan: &BroadcastRulesPlan) -> Vec<String> { fn insert_args(plan: &BroadcastRulesPlan, comment: bool) -> Vec<String> {
locked( locked(
["-I".to_string(), CHAIN.to_string(), "1".to_string()] ["-I".to_string(), CHAIN.to_string(), "1".to_string()]
.into_iter() .into_iter()
.chain(rule_spec(plan)), .chain(rule_spec(plan, comment)),
) )
} }
@@ -147,8 +192,28 @@ pub(super) fn split_rule_line(line: &str) -> Vec<String> {
words words
} }
/// Whether a rule is the one this module writes for some range of the
/// interface the tag belongs to, with no comment on it.
fn is_uncommented_rule(tag: &str, rest: &[String]) -> bool {
let Some(interface) = tag.strip_prefix("tsunagi:") else {
return false;
};
let Some(range) = rest
.windows(2)
.find(|pair| pair[0] == "-s")
.map(|pair| pair[1].as_str())
else {
return false;
};
let ours: Vec<String> = ["-i", interface, "-p", "udp", "-s", range, "-j", "ACCEPT"]
.map(str::to_string)
.to_vec();
same_rule(&ours, rest)
}
/// The delete commands for every rule in an `iptables -S INPUT` listing that /// 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. /// carries exactly this tag, or, with no `comment` match in the kernel, is
/// exactly the rule written for some range. Anything else is left alone.
fn tagged_deletions(listing: &str, tag: &str) -> Vec<Vec<String>> { fn tagged_deletions(listing: &str, tag: &str) -> Vec<Vec<String>> {
listing listing
.lines() .lines()
@@ -162,7 +227,8 @@ fn tagged_deletions(listing: &str, tag: &str) -> Vec<Vec<String>> {
} }
let tagged = rest let tagged = rest
.windows(2) .windows(2)
.any(|pair| pair[0] == "--comment" && pair[1] == tag); .any(|pair| pair[0] == "--comment" && pair[1] == tag)
|| is_uncommented_rule(tag, rest);
tagged.then(|| { tagged.then(|| {
locked( locked(
["-D".to_string(), CHAIN.to_string()] ["-D".to_string(), CHAIN.to_string()]
@@ -197,7 +263,8 @@ fn missing(plan: &BroadcastRulesPlan) -> String {
/// Makes the firewall hold exactly one rule for this interface, the planned one. /// Makes the firewall hold exactly one rule for this interface, the planned one.
fn apply_firewall(run: Run<'_>, plan: &BroadcastRulesPlan) -> RuleOutcome { fn apply_firewall(run: Run<'_>, plan: &BroadcastRulesPlan) -> RuleOutcome {
let present = match run(&check_args(plan)) { let comment = comment_supported(run);
let present = match run(&check_args(plan, comment)) {
Ran::Missing => return RuleOutcome::Failed(missing(plan)), Ran::Missing => return RuleOutcome::Failed(missing(plan)),
Ran::Exited { success, .. } => success, Ran::Exited { success, .. } => success,
}; };
@@ -227,7 +294,7 @@ fn apply_firewall(run: Run<'_>, plan: &BroadcastRulesPlan) -> RuleOutcome {
return RuleOutcome::Failed(failure(&stderr)); return RuleOutcome::Failed(failure(&stderr));
} }
} }
match run(&insert_args(plan)) { match run(&insert_args(plan, comment)) {
Ran::Missing => RuleOutcome::Failed(missing(plan)), Ran::Missing => RuleOutcome::Failed(missing(plan)),
Ran::Exited { success: true, .. } => RuleOutcome::Applied, Ran::Exited { success: true, .. } => RuleOutcome::Applied,
Ran::Exited { stderr, .. } => RuleOutcome::Failed(failure(&stderr)), Ran::Exited { stderr, .. } => RuleOutcome::Failed(failure(&stderr)),
@@ -262,7 +329,23 @@ fn clear_firewall(run: Run<'_>, interface: &str) {
/// Runs the real `iptables`. /// Runs the real `iptables`.
pub(super) fn run_iptables(args: &[String]) -> Ran { pub(super) fn run_iptables(args: &[String]) -> Ran {
let Some(program) = pick_iptables(|path| std::path::Path::new(path).is_file()) else { run_firewall(false, args)
}
pub(super) fn run_firewall(ipv6: bool, args: &[String]) -> Ran {
let v6_paths: Vec<_> = IPTABLES_DIRECTORIES
.iter()
.map(|p| p.replace("iptables", "ip6tables"))
.collect();
let v6_program = v6_paths
.iter()
.find(|path| std::path::Path::new(path).is_file());
let program = if ipv6 {
v6_program.map(String::as_str)
} else {
pick_iptables(|path| std::path::Path::new(path).is_file())
};
let Some(program) = program else {
return Ran::Missing; return Ran::Missing;
}; };
match std::process::Command::new(program).args(args).output() { match std::process::Command::new(program).args(args).output() {
@@ -284,11 +367,17 @@ pub(super) fn run_iptables(args: &[String]) -> Ran {
#[derive(Debug)] #[derive(Debug)]
pub struct LinuxHostRules { pub struct LinuxHostRules {
pub(super) route: RouteHandle, pub(super) route: RouteHandle,
/// Interface, sorted socket snapshot and whether exit routes committed.
pub(super) underlay_sockets:
std::sync::Mutex<Option<(String, Vec<super::linux_exit_mark::Listener>, bool)>>,
} }
impl LinuxHostRules { impl LinuxHostRules {
pub(crate) fn new(route: RouteHandle) -> Self { pub(crate) fn new(route: RouteHandle) -> Self {
Self { route } Self {
route,
underlay_sockets: std::sync::Mutex::new(None),
}
} }
async fn set_route(&self, interface: String, source: Ipv4Addr) -> RuleOutcome { async fn set_route(&self, interface: String, source: Ipv4Addr) -> RuleOutcome {
@@ -365,7 +454,7 @@ mod tests {
#[test] #[test]
fn the_rule_is_udp_only_from_the_overlay_range_on_this_interface() { fn the_rule_is_udp_only_from_the_overlay_range_on_this_interface() {
let spec = words(&rule_spec(&plan())); let spec = words(&rule_spec(&plan(), true));
assert_eq!( assert_eq!(
spec, spec,
"-i tsun0 -p udp -s 10.13.37.0/24 -m comment --comment tsunagi:tsun0 -j ACCEPT" "-i tsun0 -p udp -s 10.13.37.0/24 -m comment --comment tsunagi:tsun0 -j ACCEPT"
@@ -373,8 +462,8 @@ mod tests {
// Nothing here may widen to other protocols or every source. // Nothing here may widen to other protocols or every source.
assert!(spec.contains("-p udp")); assert!(spec.contains("-p udp"));
assert!(!spec.contains("0.0.0.0/0")); assert!(!spec.contains("0.0.0.0/0"));
assert!(words(&insert_args(&plan())).starts_with("-w 5 -I INPUT 1 -i tsun0")); assert!(words(&insert_args(&plan(), true)).starts_with("-w 5 -I INPUT 1 -i tsun0"));
assert!(words(&check_args(&plan())).starts_with("-w 5 -C INPUT -i tsun0")); assert!(words(&check_args(&plan(), true)).starts_with("-w 5 -C INPUT -i tsun0"));
} }
#[test] #[test]
@@ -408,6 +497,49 @@ mod tests {
assert!(tagged_deletions("", "tsunagi:tsun0").is_empty()); assert!(tagged_deletions("", "tsunagi:tsun0").is_empty());
} }
#[test]
fn a_kernel_without_the_comment_match_says_so_in_its_error() {
let answer = |stderr: &'static str| {
move |_: &[String]| Ran::Exited {
success: false,
stdout: String::new(),
stderr: stderr.to_string(),
}
};
assert!(!comment_supported(&answer(
"iptables: No chain/target/match by that name."
)));
assert!(comment_supported(&answer(
"iptables: Bad rule (does a matching rule exist in that chain?)."
)));
assert!(comment_supported(&|_: &[String]| Ran::Missing));
}
#[test]
fn a_rule_written_without_a_comment_is_found_by_its_shape() {
// Listed the way iptables prints it: its own option order.
let listing = "\
-P INPUT ACCEPT
-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -j ACCEPT
-A INPUT -s 10.99.0.0/16 -i tsun0 -p udp -j ACCEPT
-A INPUT -s 10.13.37.0/24 -i tsun1 -p udp -j ACCEPT
-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -j DROP
-A INPUT -s 10.13.37.0/24 -i tsun0 -p tcp -j ACCEPT
";
let deletions = tagged_deletions(listing, "tsunagi:tsun0");
assert_eq!(deletions.len(), 2, "{deletions:?}");
assert!(words(&deletions[0]).ends_with("-s 10.13.37.0/24 -i tsun0 -p udp -j ACCEPT"));
assert!(words(&deletions[1]).contains("10.99.0.0/16"));
}
#[test]
fn without_a_comment_the_rule_has_none() {
let with = words(&rule_spec(&plan(), true));
let without = words(&rule_spec(&plan(), false));
assert!(with.contains("-m comment --comment tsunagi:tsun0"));
assert_eq!(without, "-i tsun0 -p udp -s 10.13.37.0/24 -j ACCEPT");
}
/// A scripted `iptables`: answers by the leading verb, records every call. /// A scripted `iptables`: answers by the leading verb, records every call.
struct Fake { struct Fake {
calls: RefCell<Vec<String>>, calls: RefCell<Vec<String>>,
@@ -428,6 +560,14 @@ mod tests {
} }
} }
fn run(&self, args: &[String]) -> Ran { fn run(&self, args: &[String]) -> Ran {
// The probe for the `comment` match: not part of what is tested.
if args.iter().any(|word| word == "tsunagi-probe") {
return Ran::Exited {
success: false,
stdout: String::new(),
stderr: "Bad rule".into(),
};
}
self.calls.borrow_mut().push(words(args)); self.calls.borrow_mut().push(words(args));
if self.missing { if self.missing {
return Ran::Missing; return Ran::Missing;
@@ -25,18 +25,19 @@
//! //!
//! ```text //! ```text
//! table 0x7473: default dev <if> //! table 0x7473: default dev <if>
//! 5280: from all uidrange <euid>-<euid> lookup main //! 5205: from all fwmark 0x40000000/0x40000000 lookup main
//! 5206: from all uidrange <euid>-<euid> lookup main
//! 5290: from all lookup main suppress_prefixlength 0 //! 5290: from all lookup main suppress_prefixlength 0
//! 5300: from all lookup 0x7473 //! 5300: from all lookup 0x7473
//! ``` //! ```
//! //!
//! The first keeps the agent's *own* sockets off the tunnel: its QUIC, relay, //! The first two keep the agent's *own* sockets and marked replies off the tunnel: its QUIC, relay,
//! DHT and DNS traffic would otherwise be routed into the interface it is //! DHT and DNS traffic would otherwise be routed into the interface it is
//! carrying, a loop. The second lets every specific route — the LAN, another //! carrying, a loop. The specific-route rule lets the LAN, another
//! VPN, a tunnel to somewhere else — keep working, because only the default //! VPN, a tunnel to somewhere else — keep working, because only the default
//! route is suppressed. The priorities sit after Tailscale's 5270. //! route is suppressed. Mark/UID bypass precedes Tailscale; application rules follow it.
//! //!
//! The first rule exempts a *user id*, so it is only right when the agent has //! The UID rule exempts a *user id*, so it is only right when the agent has
//! one of its own, as the packaged service does. An agent started from a login //! one of its own, as the packaged service does. An agent started from a login
//! session would take that user's own applications out of the tunnel with it, //! session would take that user's own applications out of the tunnel with it,
//! and the exit node would silently do nothing for them. That is refused, not //! and the exit node would silently do nothing for them. That is refused, not
@@ -47,7 +48,11 @@ use crate::state::Ipv4Range;
use super::RuleOutcome; use super::RuleOutcome;
use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules, exit_tag, exit_tag_prefix}; use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules, exit_tag, exit_tag_prefix};
use super::linux::{LinuxHostRules, Ran, Run, failure, locked, run_iptables, split_rule_line}; use super::linux::{
LinuxHostRules, Ran, Run, comment_supported, failure, locked, run_firewall, run_iptables,
same_rule, split_rule_line,
};
use super::linux_exit_mark;
/// The chains the offering rules live in: `(table, chain)`. /// The chains the offering rules live in: `(table, chain)`.
const CHAINS: [(&str, &str); 2] = [("nat", "POSTROUTING"), ("filter", "FORWARD")]; const CHAINS: [(&str, &str); 2] = [("nat", "POSTROUTING"), ("filter", "FORWARD")];
@@ -65,14 +70,23 @@ fn owned(words: &[&str]) -> Vec<String> {
} }
/// The three rules of one offered range. /// The three rules of one offered range.
fn offer_rules(interface: &str, range: &Ipv4Range) -> Vec<Rule> { ///
/// Each carries a comment naming its interface and range, which is how it is
/// found again. A kernel without the `comment` match (small router builds
/// leave it out) cannot take that, so `comment` false leaves it off and the
/// rules are recognised by their shape instead; see [`tagged_rules`].
fn offer_rules(interface: &str, range: &Ipv4Range, comment: bool) -> Vec<Rule> {
let range_text = range.to_string(); let range_text = range.to_string();
let tag = exit_tag(interface, range); let tag = exit_tag(interface, range);
let comment = ["-m", "comment", "--comment", tag.as_str()]; let comment: Vec<String> = if comment {
owned(&["-m", "comment", "--comment", tag.as_str()])
} else {
Vec::new()
};
let spec = |head: &[&str], tail: &[&str]| -> Vec<String> { let spec = |head: &[&str], tail: &[&str]| -> Vec<String> {
owned(head) owned(head)
.into_iter() .into_iter()
.chain(owned(&comment)) .chain(comment.iter().cloned())
.chain(owned(tail)) .chain(owned(tail))
.collect() .collect()
}; };
@@ -142,9 +156,16 @@ struct Found {
rest: Vec<String>, rest: Vec<String>,
} }
/// Every rule in an `iptables -S <chain>` listing carrying a tag that starts /// Every rule in an `iptables -S <chain>` listing that is ours: carrying a
/// with `prefix`. Anything else in the chain is left alone. /// tag that starts with `prefix`, or, on a kernel with no `comment` match,
/// being exactly one of the three rules this module writes for some range.
/// Anything else in the chain is left alone.
fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec<Found> { fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec<Found> {
// `tsunagi-exit:<interface>:`
let interface = prefix
.strip_prefix("tsunagi-exit:")
.and_then(|rest| rest.strip_suffix(':'))
.unwrap_or_default();
listing listing
.lines() .lines()
.filter_map(|line| { .filter_map(|line| {
@@ -158,7 +179,8 @@ fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec<Found> {
let tag = rest let tag = rest
.windows(2) .windows(2)
.find(|pair| pair[0] == "--comment" && pair[1].starts_with(prefix)) .find(|pair| pair[0] == "--comment" && pair[1].starts_with(prefix))
.map(|pair| pair[1].clone())?; .map(|pair| pair[1].clone())
.or_else(|| uncommented_tag(interface, chain, rest))?;
Some(Found { Some(Found {
tag, tag,
rest: rest.to_vec(), rest: rest.to_vec(),
@@ -167,6 +189,22 @@ fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec<Found> {
.collect() .collect()
} }
/// The tag a rule would have carried, when it is one of ours written without
/// a comment: its words are those of the rule for the range it names.
fn uncommented_tag(interface: &str, chain: &str, rest: &[String]) -> Option<String> {
if interface.is_empty() {
return None;
}
let range: Ipv4Range = rest
.windows(2)
.find(|pair| pair[0] == "-s" || pair[0] == "-d")
.and_then(|pair| pair[1].parse().ok())?;
offer_rules(interface, &range, false)
.iter()
.any(|rule| rule.chain == chain && same_rule(&rule.spec, rest))
.then(|| exit_tag(interface, &range))
}
fn delete_args(table: &str, chain: &str, found: &Found) -> Vec<String> { fn delete_args(table: &str, chain: &str, found: &Found) -> Vec<String> {
locked( locked(
owned(&["-t", table, "-D", chain]) owned(&["-t", table, "-D", chain])
@@ -236,6 +274,7 @@ fn apply_offer(
}; };
let prefix = exit_tag_prefix(interface); let prefix = exit_tag_prefix(interface);
let wanted: Vec<String> = ranges.iter().map(|r| exit_tag(interface, r)).collect(); let wanted: Vec<String> = ranges.iter().map(|r| exit_tag(interface, r)).collect();
let comment = comment_supported(run);
// What is there now, per chain. // What is there now, per chain.
let mut present: Vec<(&str, &str, Vec<Found>)> = Vec::new(); let mut present: Vec<(&str, &str, Vec<Found>)> = Vec::new();
@@ -263,7 +302,7 @@ fn apply_offer(
ranges ranges
.iter() .iter()
.map(|range| { .map(|range| {
let rules = offer_rules(interface, range); let rules = offer_rules(interface, range, comment);
let tag = exit_tag(interface, range); let tag = exit_tag(interface, range);
let in_place = rules let in_place = rules
.iter() .iter()
@@ -340,6 +379,32 @@ fn read_sysctl(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok() std::fs::read_to_string(path).ok()
} }
fn check_kernel_rpf(overlay: &str) -> Result<(), String> {
let directory = "/proc/sys/net/ipv4/conf";
let value = |name: &str, setting: &str| -> Result<u32, String> {
read_sysctl(&format!("{directory}/{name}/{setting}"))
.and_then(|s| s.trim().parse().ok())
.ok_or_else(|| format!("cannot check {name}/{setting} before installing exit routes"))
};
let mut interfaces = Vec::new();
for entry in std::fs::read_dir(directory).map_err(|err| err.to_string())? {
let entry = entry.map_err(|err| err.to_string())?;
let name = entry.file_name().to_string_lossy().into_owned();
if name != "all" && name != "default" && name != "lo" && name != overlay {
interfaces.push((
name.clone(),
value(&name, "rp_filter")?,
value(&name, "src_valid_mark")?,
));
}
}
linux_exit_mark::check_kernel_rpf(
value("all", "rp_filter")?,
value("all", "src_valid_mark")?,
&interfaces,
)
}
/// The effective user id from the text of `/proc/self/status`. /// The effective user id from the text of `/proc/self/status`.
fn parse_euid(status: &str) -> Option<u32> { fn parse_euid(status: &str) -> Option<u32> {
status status
@@ -380,7 +445,7 @@ impl LinuxHostRules {
let xdg = std::env::var("XDG_RUNTIME_DIR").ok(); let xdg = std::env::var("XDG_RUNTIME_DIR").ok();
if let Some(reason) = client_refusal(xdg.as_deref()) { if let Some(reason) = client_refusal(xdg.as_deref()) {
// Whatever an earlier run left must not stay in force. // Whatever an earlier run left must not stay in force.
self.clear_client().await; let _ = self.clear_client(interface).await;
return RuleOutcome::Failed(reason); return RuleOutcome::Failed(reason);
} }
let Some(euid) = std::fs::read_to_string("/proc/self/status") let Some(euid) = std::fs::read_to_string("/proc/self/status")
@@ -389,24 +454,147 @@ impl LinuxHostRules {
else { else {
return RuleOutcome::Failed("cannot tell which user this agent runs as".to_string()); return RuleOutcome::Failed("cannot tell which user this agent runs as".to_string());
}; };
let installed = self
.underlay_sockets
.lock()
.unwrap_or_else(|p| p.into_inner())
.as_ref()
.is_some_and(|(name, _, routed)| name == interface && *routed);
if installed {
// A failed socket refresh must never remove an active exit route.
return self
.refresh_sockets(interface)
.await
.unwrap_or(RuleOutcome::Applied);
}
if let Err(reason) = self.clear_client(interface).await {
return RuleOutcome::Failed(reason);
}
let name = interface.to_string();
let prepared = Self::blocking(move || {
check_kernel_rpf(&name)?;
linux_exit_mark::install(
&run_firewall,
&name,
euid,
std::path::Path::new("/proc/sys/net/ipv6").is_dir(),
)
})
.await;
match prepared {
Ok(Ok(sockets)) => {
*self
.underlay_sockets
.lock()
.unwrap_or_else(|p| p.into_inner()) = Some((interface.into(), sockets, false));
}
Ok(Err(reason)) | Err(reason) => return RuleOutcome::Failed(reason),
}
let route = self.route.clone(); let route = self.route.clone();
let interface = interface.to_string(); let name = interface.to_string();
match Self::blocking(move || route.set_exit_client(&interface, euid)).await { let outcome = match Self::blocking(move || route.set_exit_client(&name, euid)).await {
Ok(Ok(())) => RuleOutcome::Applied, Ok(Ok(())) => RuleOutcome::Applied,
Ok(Err(err)) => RuleOutcome::Failed(err.to_string()), Ok(Err(err)) => RuleOutcome::Failed(err.to_string()),
Err(reason) => RuleOutcome::Failed(reason), Err(reason) => RuleOutcome::Failed(reason),
};
if let RuleOutcome::Failed(reason) = outcome {
let reason = match self.clear_client(interface).await {
Ok(()) => reason,
Err(cleanup) => format!("{reason}; cannot roll back exit rules: {cleanup}"),
};
RuleOutcome::Failed(reason)
} else {
if let Some((_, _, routed)) = self
.underlay_sockets
.lock()
.unwrap_or_else(|p| p.into_inner())
.as_mut()
{
*routed = true;
}
outcome
} }
} }
async fn clear_client(&self) { async fn clear_client(&self, interface: &str) -> Result<(), String> {
let route = self.route.clone(); let route = self.route.clone();
if let Ok(Err(err)) = Self::blocking(move || route.clear_exit_client()).await { Self::blocking(move || route.clear_exit_client())
tracing::warn!(%err, "cannot remove the exit node routing rules"); .await?
.map_err(|err| err.to_string())?;
// Route removal must precede removing reply protection.
let name = interface.to_string();
Self::blocking(move || {
linux_exit_mark::clear(
&run_firewall,
&name,
std::path::Path::new("/proc/sys/net/ipv6").is_dir(),
)
})
.await??;
*self
.underlay_sockets
.lock()
.unwrap_or_else(|p| p.into_inner()) = None;
Ok(())
}
async fn refresh_sockets(&self, interface: &str) -> Option<RuleOutcome> {
let previous = self
.underlay_sockets
.lock()
.unwrap_or_else(|p| p.into_inner())
.clone();
if previous
.as_ref()
.is_none_or(|(name, _, routed)| name != interface || !routed)
{
return None;
}
let name = interface.to_string();
let work_name = name.clone();
let result = Self::blocking(move || {
let uid = std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|s| parse_euid(&s))
.ok_or("cannot read agent UID")?;
check_kernel_rpf(&work_name)?;
let v6 = std::path::Path::new("/proc/sys/net/ipv6").is_dir();
let sockets = linux_exit_mark::local_sockets(uid, v6)?;
if previous
.as_ref()
.is_some_and(|(name, old, _)| name == &work_name && old == &sockets)
{
return Ok(None);
}
linux_exit_mark::refresh(&run_firewall, &work_name, v6, &sockets)?;
Ok(Some(sockets))
})
.await;
match result {
Ok(Ok(None)) => None,
Ok(Ok(Some(sockets))) => {
*self
.underlay_sockets
.lock()
.unwrap_or_else(|p| p.into_inner()) = Some((name, sockets, true));
Some(RuleOutcome::Applied)
}
Ok(Err(reason)) | Err(reason) => Some(RuleOutcome::Failed(reason)),
} }
} }
} }
impl ExitHostRules for LinuxHostRules { impl ExitHostRules for LinuxHostRules {
fn refresh<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, Option<RuleOutcome>> {
Box::pin(async move {
if plan.client {
self.refresh_sockets(&plan.interface).await
} else {
None
}
})
}
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> { fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
Box::pin(async move { Box::pin(async move {
let interface = plan.interface.clone(); let interface = plan.interface.clone();
@@ -430,7 +618,9 @@ impl ExitHostRules for LinuxHostRules {
let client = if plan.client { let client = if plan.client {
Some(self.apply_client(&interface).await) Some(self.apply_client(&interface).await)
} else { } else {
self.clear_client().await; if let Err(reason) = self.clear_client(&interface).await {
tracing::warn!(%reason, "cannot remove exit-client rules");
}
None None
}; };
ExitHostReport { ExitHostReport {
@@ -445,7 +635,9 @@ impl ExitHostRules for LinuxHostRules {
Box::pin(async move { Box::pin(async move {
let name = interface.to_string(); let name = interface.to_string();
let _ = Self::blocking(move || clear_offer(&run_iptables, &name)).await; let _ = Self::blocking(move || clear_offer(&run_iptables, &name)).await;
self.clear_client().await; if let Err(reason) = self.clear_client(interface).await {
tracing::warn!(%reason, "cannot remove exit-client rules");
}
}) })
} }
} }
@@ -468,7 +660,7 @@ mod tests {
#[test] #[test]
fn the_rules_masquerade_the_range_out_of_every_other_interface_and_forward_it() { fn the_rules_masquerade_the_range_out_of_every_other_interface_and_forward_it() {
let rules = offer_rules("tsun0", &range()); let rules = offer_rules("tsun0", &range(), true);
assert_eq!(rules.len(), 3); assert_eq!(rules.len(), 3);
assert_eq!( assert_eq!(
words(&rules[0].spec), words(&rules[0].spec),
@@ -521,6 +713,8 @@ mod tests {
listing: RefCell<Vec<(String, String)>>, listing: RefCell<Vec<(String, String)>>,
missing: bool, missing: bool,
check_ok: bool, check_ok: bool,
/// A kernel with no `comment` match, as small router builds have.
no_comment: bool,
} }
impl Fake { impl Fake {
@@ -530,6 +724,7 @@ mod tests {
listing: RefCell::new(Vec::new()), listing: RefCell::new(Vec::new()),
missing: false, missing: false,
check_ok, check_ok,
no_comment: false,
} }
} }
@@ -556,6 +751,13 @@ mod tests {
stdout, stdout,
stderr: String::new(), stderr: String::new(),
}; };
if self.no_comment && line.contains("-m comment") {
return Ran::Exited {
success: false,
stdout: String::new(),
stderr: "iptables: No chain/target/match by that name.".to_string(),
};
}
if line.contains(" -S ") { if line.contains(" -S ") {
let listing: Vec<String> = self let listing: Vec<String> = self
.listing .listing
@@ -586,6 +788,44 @@ mod tests {
} }
} }
#[test]
fn a_kernel_without_the_comment_match_gets_the_rules_without_comments() {
let mut fake = Fake::new(false);
fake.no_comment = true;
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]);
let inserted = fake.verbs("-I");
assert_eq!(inserted.len(), 3, "{inserted:?}");
assert!(
inserted.iter().all(|call| !call.contains("comment")),
"{inserted:?}"
);
}
#[test]
fn rules_written_without_a_comment_are_ours_and_are_removed_with_them() {
// The way iptables prints them: its own option order.
let listing = "\
-P FORWARD ACCEPT
-A FORWARD -s 10.13.37.0/24 -i tsun0 ! -o tsun0 -j ACCEPT
-A FORWARD -d 10.13.37.0/24 -o tsun0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -i tsun0 -j ACCEPT
-A FORWARD -s 10.13.37.0/24 -i tsun1 ! -o tsun1 -j ACCEPT
";
let found = tagged_rules(listing, "FORWARD", &exit_tag_prefix("tsun0"));
assert_eq!(found.len(), 2, "{found:?}");
assert!(
found
.iter()
.all(|rule| rule.tag == exit_tag("tsun0", &range()))
);
let nat = "-A POSTROUTING -s 10.13.37.0/24 ! -o tsun0 -j MASQUERADE\n";
assert_eq!(
tagged_rules(nat, "POSTROUTING", &exit_tag_prefix("tsun0")).len(),
1
);
}
#[test] #[test]
fn a_fresh_host_gets_the_three_rules() { fn a_fresh_host_gets_the_three_rules() {
let fake = Fake::new(false); let fake = Fake::new(false);
@@ -0,0 +1,439 @@
//! Privileged routing regression, isolated from the host in named namespaces.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream, UdpSocket};
use std::os::unix::process::CommandExt;
use std::process::{Child, Command};
use std::time::Duration;
use super::linux::run_firewall;
use super::linux_exit_mark::{clear, install, local_sockets, refresh};
use crate::overlay::NetlinkProvisioner;
const TEST: &str = "overlay::hostrules::linux_exit_kernel::strict_firewall_preserves_underlay_and_application_exit_routing";
const ROLE: &str = "TSUNAGI_EXIT_KERNEL_TEST_ROLE";
fn ip(arguments: &[&str]) -> String {
let result = Command::new("ip").args(arguments).output().unwrap();
assert!(
result.status.success(),
"ip {arguments:?}: {}",
String::from_utf8_lossy(&result.stderr)
);
String::from_utf8_lossy(&result.stdout).into_owned()
}
struct Namespace(String);
impl Drop for Namespace {
fn drop(&mut self) {
let _ = Command::new("ip").args(["netns", "del", &self.0]).status();
}
}
struct Echo(Child);
impl Drop for Echo {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn probes() {
let should_pass = std::env::var("TSUNAGI_EXIT_KERNEL_EXPECT").unwrap() == "pass";
for address in ["198.51.100.2:42000", "[2001:db8:2::2]:42000"] {
let address: SocketAddr = address.parse().unwrap();
let socket = UdpSocket::bind(if address.is_ipv4() {
"0.0.0.0:0"
} else {
"[::]:0"
})
.unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(1)))
.unwrap();
socket.send_to(b"rpf", address).unwrap();
let mut packet = [0u8; 3];
assert_eq!(
socket.recv(&mut packet).is_ok(),
should_pass,
"UDP {address}"
);
if should_pass {
assert_eq!(&packet, b"rpf");
}
let connected = TcpStream::connect_timeout(&address, Duration::from_secs(1));
assert_eq!(connected.is_ok(), should_pass, "TCP {address}");
if let Ok(mut stream) = connected {
stream
.set_read_timeout(Some(Duration::from_secs(1)))
.unwrap();
stream.write_all(b"rpf").unwrap();
stream.read_exact(&mut packet).unwrap();
assert_eq!(&packet, b"rpf");
}
}
}
fn run_probes(expect: &str) {
let result = Command::new(std::env::current_exe().unwrap())
.args(["--ignored", "--exact", TEST, "--nocapture"])
.env(ROLE, "probe")
.env("TSUNAGI_EXIT_KERNEL_EXPECT", expect)
.uid(990)
.gid(990)
.output()
.unwrap();
assert!(
result.status.success(),
"{}\n{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
}
fn passive_listener() {
let listeners: Vec<_> = ["192.0.2.1:42010", "[2001:db8:1::1]:42010"]
.into_iter()
.map(|address| TcpListener::bind(address).unwrap())
.collect();
println!("PASSIVE_READY");
let tasks: Vec<_> = listeners
.into_iter()
.map(|listener| {
std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut bytes = [0; 3];
socket.read_exact(&mut bytes).unwrap();
socket.write_all(&bytes).unwrap();
})
})
.collect();
for task in tasks {
task.join().unwrap();
}
}
fn udp_listener() {
let sockets: Vec<_> = ["192.0.2.1:42011", "[2001:db8:1::1]:42011"]
.into_iter()
.map(|a| UdpSocket::bind(a).unwrap())
.collect();
println!("PASSIVE_READY");
let tasks: Vec<_> = sockets
.into_iter()
.map(|socket| {
std::thread::spawn(move || {
socket
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut bytes = [0; 3];
let (len, peer) = socket.recv_from(&mut bytes).unwrap();
socket.send_to(&bytes[..len], peer).unwrap();
})
})
.collect();
for task in tasks {
task.join().unwrap();
}
}
fn spawn_listener(role: &str) -> Echo {
let process = Command::new(std::env::current_exe().unwrap())
.args(["--ignored", "--exact", TEST, "--nocapture"])
.env(ROLE, role)
.uid(990)
.gid(990)
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
let mut child = Echo(process);
let mut output = BufReader::new(child.0.stdout.take().unwrap());
loop {
let mut line = String::new();
assert_ne!(
output.read_line(&mut line).unwrap(),
0,
"listener exited before readiness"
);
if line.contains("PASSIVE_READY") {
// Keep the reader open for libtest's final status after the socket
// worker finishes; closing it here makes success a broken pipe.
child.0.stdout = Some(output.into_inner());
return child;
}
}
}
fn client() {
for setting in ["rp_filter", "src_valid_mark"] {
std::fs::write(format!("/proc/sys/net/ipv4/conf/all/{setting}"), b"1").unwrap();
}
std::fs::write("/proc/sys/net/ipv4/conf/rpf-c/rp_filter", b"1").unwrap();
for family in [false, true] {
let args: Vec<_> = [
"-t",
"mangle",
"-A",
"PREROUTING",
"-m",
"rpfilter",
"--invert",
"--validmark",
"-j",
"DROP",
]
.into_iter()
.map(str::to_string)
.collect();
let result = run_firewall(family, &args);
assert!(
matches!(result, super::linux::Ran::Exited { success: true, .. }),
"{result:?}"
);
}
let provisioner = NetlinkProvisioner::new().unwrap();
let route = provisioner.host_rules().route;
// Model Tailscale's earlier lookup, without running another VPN daemon.
ip(&["-4", "rule", "add", "priority", "5270", "table", "52"]);
ip(&[
"-4", "route", "add", "default", "dev", "tsun0", "table", "52",
]);
ip(&["-6", "rule", "add", "priority", "5270", "table", "52"]);
ip(&[
"-6",
"route",
"add",
"unreachable",
"default",
"table",
"52",
]);
// A foreign VPN must leave neighbour discovery and connected LAN paths
// usable; otherwise the fixture itself cannot resolve its uplink neighbour.
for prefix in ["fe80::/64", "2001:db8:1::/64"] {
ip(&["-6", "route", "add", "throw", prefix, "table", "52"]);
}
let mut passive = spawn_listener("listener");
route.set_exit_client("tsun0", 990).unwrap();
// uid bypass alone cannot protect reverse lookups, reproducing the bug.
run_probes("blocked");
install(&run_firewall, "tsun0", 990, true).unwrap();
run_probes("pass");
// Pair transports may bind fresh UDP ports after exit activation.
let mut dynamic_udp = spawn_listener("udp-listener");
refresh(
&run_firewall,
"tsun0",
true,
&local_sockets(990, true).unwrap(),
)
.unwrap();
let server = std::env::var("TSUNAGI_EXIT_KERNEL_SERVER").unwrap();
let incoming = Command::new("ip").args(["netns", "exec", &server, "python3", "-c", r#"import socket
for family,source,target in [(socket.AF_INET,'198.51.100.2','192.0.2.1'),(socket.AF_INET6,'2001:db8:2::2','2001:db8:1::1')]:
for kind,port in [(socket.SOCK_STREAM,42010),(socket.SOCK_DGRAM,42011)]:
s=socket.socket(family,kind); s.settimeout(2); s.bind((source,0)); s.connect((target,port)); s.sendall(b'rpf'); assert s.recv(3)==b'rpf'; s.close()
"#]).output().unwrap();
assert!(
incoming.status.success(),
"first inbound TCP/UDP must be protected: {}",
String::from_utf8_lossy(&incoming.stderr)
);
assert!(passive.0.wait().unwrap().success());
assert!(dynamic_udp.0.wait().unwrap().success());
// Repeated apply remains usable, and does not redirect application sockets.
install(&run_firewall, "tsun0", 990, true).unwrap();
run_probes("pass");
assert!(ip(&["-4", "route", "get", "198.51.100.2", "uid", "991"]).contains("dev tsun0"));
let blocked = Command::new("ip")
.args(["-6", "route", "get", "2001:db8:2::2", "uid", "991"])
.output()
.unwrap();
assert!(
!blocked.status.success(),
"application IPv6 must stay blocked"
);
route.clear_exit_client().unwrap();
clear(&run_firewall, "tsun0", true).unwrap();
for family in ["-4", "-6"] {
assert!(
ip(&[family, "rule", "show"]).contains("5270:"),
"foreign routing rules survive cleanup"
);
ip(&[family, "rule", "del", "priority", "5270"]);
}
assert!(ip(&["-4", "route", "get", "198.51.100.2", "uid", "991"]).contains("dev rpf-c"));
assert!(ip(&["-6", "route", "get", "2001:db8:2::2", "uid", "991"]).contains("dev rpf-c"));
for family in [false, true] {
let result = run_firewall(family, &["-t".into(), "mangle".into(), "-S".into()]);
if let super::linux::Ran::Exited { stdout, .. } = result {
assert!(!stdout.contains("tsunagi-exit-underlay:"));
assert!(!stdout.contains("tsunagi-exit-sockets:"));
assert!(
stdout.contains("rpfilter"),
"strict host filtering is retained"
);
}
}
}
#[test]
#[ignore = "needs root, iproute2, iptables/ip6tables and Python; changes only temporary namespaces"]
fn strict_firewall_preserves_underlay_and_application_exit_routing() {
match std::env::var(ROLE).as_deref() {
Ok("probe") => {
probes();
return;
}
Ok("client") => {
client();
return;
}
Ok("listener") => {
passive_listener();
return;
}
Ok("udp-listener") => {
udp_listener();
return;
}
_ => {}
}
let suffix = std::process::id();
let client_name = format!("tsng-rpf-c-{suffix}");
let server_name = format!("tsng-rpf-s-{suffix}");
ip(&["netns", "add", &client_name]);
let _client = Namespace(client_name.clone());
ip(&["netns", "add", &server_name]);
let _server = Namespace(server_name.clone());
ip(&[
"link",
"add",
"rpf-c",
"netns",
&client_name,
"type",
"veth",
"peer",
"name",
"rpf-s",
"netns",
&server_name,
]);
for (namespace, interface, v4, v6) in [
(&client_name, "rpf-c", "192.0.2.1/24", "2001:db8:1::1/64"),
(&server_name, "rpf-s", "192.0.2.2/24", "2001:db8:1::2/64"),
] {
ip(&["-n", namespace, "link", "set", "lo", "up"]);
ip(&["-n", namespace, "addr", "add", v4, "dev", interface]);
ip(&[
"-n", namespace, "-6", "addr", "add", v6, "dev", interface, "nodad",
]);
ip(&["-n", namespace, "link", "set", interface, "up"]);
}
ip(&[
"-n",
&server_name,
"addr",
"add",
"198.51.100.2/32",
"dev",
"lo",
]);
ip(&[
"-n",
&server_name,
"-6",
"addr",
"add",
"2001:db8:2::2/128",
"dev",
"lo",
"nodad",
]);
ip(&[
"-n",
&client_name,
"route",
"add",
"default",
"via",
"192.0.2.2",
]);
ip(&[
"-n",
&client_name,
"-6",
"route",
"add",
"default",
"via",
"2001:db8:1::2",
]);
ip(&["-n", &client_name, "link", "add", "tsun0", "type", "dummy"]);
ip(&[
"-n",
&client_name,
"addr",
"add",
"10.13.37.1/24",
"dev",
"tsun0",
]);
ip(&["-n", &client_name, "link", "set", "tsun0", "up"]);
let echo_code = r#"import socket,selectors,sys
selector=selectors.DefaultSelector()
for family in [socket.AF_INET,socket.AF_INET6]:
for kind in [socket.SOCK_DGRAM,socket.SOCK_STREAM]:
sock=socket.socket(family,kind)
if family==socket.AF_INET6: sock.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1)
sock.bind(('198.51.100.2' if family==socket.AF_INET else '2001:db8:2::2',42000))
if kind==socket.SOCK_STREAM: sock.listen()
selector.register(sock,selectors.EVENT_READ,kind)
print('ready',flush=True)
while True:
for key,_ in selector.select():
if key.data==socket.SOCK_DGRAM:
data,address=key.fileobj.recvfrom(128); key.fileobj.sendto(data,address)
else:
conn,_=key.fileobj.accept(); conn.settimeout(2)
try: conn.sendall(conn.recv(128))
finally: conn.close()
"#;
let child = Command::new("ip")
.args([
"netns",
"exec",
&server_name,
"python3",
"-u",
"-c",
echo_code,
])
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
let mut echo = Echo(child);
let mut ready = String::new();
BufReader::new(echo.0.stdout.take().unwrap())
.read_line(&mut ready)
.unwrap();
assert_eq!(ready.trim(), "ready");
let result = Command::new("ip")
.args(["netns", "exec", &client_name])
.arg(std::env::current_exe().unwrap())
.args(["--ignored", "--exact", TEST, "--nocapture"])
.env(ROLE, "client")
.env("TSUNAGI_EXIT_KERNEL_SERVER", &server_name)
.output()
.unwrap();
assert!(
result.status.success(),
"{}\n{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
}
@@ -0,0 +1,535 @@
//! Own-bit connection marks for exit-client underlay traffic.
//! Reply marks are restored before host reverse-path filtering, never accepted
//! around it. No peer address becomes a host route or firewall argument.
use super::linux::{Ran, failure, locked, split_rule_line};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
/// Outside Tailscale's 0xff0000 mask and ordinary low-bit WireGuard marks.
pub(crate) const UNDERLAY_MARK: u32 = 0x4000_0000;
pub(crate) const UNDERLAY_MASK: u32 = UNDERLAY_MARK;
const MARK: &str = "0x40000000/0x40000000";
const MASK: &str = "0x40000000";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(super) struct Listener {
address: IpAddr,
port: u16,
tcp: bool,
}
fn parse_listeners(listing: &str, uid: u32, v6: bool, tcp: bool) -> Result<Vec<Listener>, String> {
let mut result = Vec::new();
for line in listing.lines().skip(1) {
let fields: Vec<_> = line.split_whitespace().collect();
if (tcp && fields.get(3) != Some(&"0A"))
|| fields.get(7).and_then(|s| s.parse::<u32>().ok()) != Some(uid)
{
continue;
}
let (address, port) = fields
.get(1)
.and_then(|s| s.split_once(':'))
.ok_or("malformed local socket")?;
let port = u16::from_str_radix(port, 16).map_err(|e| e.to_string())?;
let address = if v6 {
let mut bytes = [0; 16];
for (word, chunk) in bytes.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let hex = address
.get(word * 8..word * 8 + 8)
.ok_or("malformed local IPv6 listener")?;
chunk.copy_from_slice(
&u32::from_str_radix(hex, 16)
.map_err(|e| e.to_string())?
.to_ne_bytes(),
);
}
IpAddr::V6(Ipv6Addr::from(bytes))
} else {
IpAddr::V4(Ipv4Addr::from(
u32::from_str_radix(address, 16)
.map_err(|e| e.to_string())?
.to_ne_bytes(),
))
};
if result.len() == 128 {
return Err("too many local sockets for exit protection".into());
}
if port != 0 {
result.push(Listener { address, port, tcp });
}
}
Ok(result)
}
pub(super) fn local_sockets(uid: u32, v6: bool) -> Result<Vec<Listener>, String> {
let mut listeners = Vec::new();
for (protocol, tcp) in [("tcp", true), ("udp", false)] {
for family in [false, true].into_iter().filter(|f| !f || v6) {
let path = format!("/proc/net/{protocol}{}", if family { "6" } else { "" });
listeners.extend(parse_listeners(
&std::fs::read_to_string(path).map_err(|e| e.to_string())?,
uid,
family,
tcp,
)?);
}
}
listeners.sort();
listeners.dedup();
Ok(listeners)
}
fn listener_rules(interface: &str, listener: &Listener) -> Vec<Vec<String>> {
let mut base = words(&[
"!",
"-i",
interface,
"-p",
if listener.tcp { "tcp" } else { "udp" },
"--dport",
&listener.port.to_string(),
]);
if !listener.address.is_unspecified() {
base.extend(words(&["-d", &listener.address.to_string()]));
}
// Match an actual local socket, not just a destination port. Listening
// sockets need protection on the first packet, before any outbound connmark.
base.extend(words(&[
"-m",
"socket",
"--nowildcard",
"-m",
"comment",
"--comment",
&listener_tag(interface),
]));
let save = base
.iter()
.cloned()
.chain(words(&[
"-m",
"mark",
"--mark",
MARK,
"-j",
"CONNMARK",
"--save-mark",
"--nfmask",
MASK,
"--ctmask",
MASK,
]))
.collect();
let mark = base
.into_iter()
.chain(words(&["-j", "MARK", "--set-xmark", MARK]))
.collect();
vec![save, mark]
}
pub(super) type RunFamily<'a> = &'a dyn Fn(bool, &[String]) -> Ran;
fn tag(interface: &str) -> String {
format!("tsunagi-exit-underlay:{interface}")
}
fn listener_tag(interface: &str) -> String {
format!("tsunagi-exit-sockets:{interface}")
}
fn words(values: &[&str]) -> Vec<String> {
values.iter().map(|s| (*s).into()).collect()
}
fn rules(interface: &str, uid: u32) -> Vec<(&'static str, Vec<String>)> {
let tag = tag(interface);
let uid = uid.to_string();
// Insert at position one in this order: final OUTPUT order is MARK, SAVE.
vec![
(
"PREROUTING",
words(&[
"!",
"-i",
interface,
"-m",
"connmark",
"--mark",
MARK,
"-m",
"comment",
"--comment",
&tag,
"-j",
"CONNMARK",
"--restore-mark",
"--nfmask",
MASK,
"--ctmask",
MASK,
]),
),
(
"OUTPUT",
words(&[
"-m",
"owner",
"--uid-owner",
&uid,
"-m",
"mark",
"--mark",
MARK,
"-m",
"comment",
"--comment",
&tag,
"-j",
"CONNMARK",
"--save-mark",
"--nfmask",
MASK,
"--ctmask",
MASK,
]),
),
(
"OUTPUT",
words(&[
"-m",
"owner",
"--uid-owner",
&uid,
"-m",
"comment",
"--comment",
&tag,
"-j",
"MARK",
"--set-xmark",
MARK,
]),
),
]
}
fn execute(run: RunFamily<'_>, v6: bool, args: Vec<String>) -> Result<String, String> {
match run(v6, &locked(args)) {
Ran::Exited {
success: true,
stdout,
..
} => Ok(stdout),
Ran::Exited { stderr, .. } => Err(failure(&stderr)),
Ran::Missing => Err(format!(
"{} is required for exit-node connection marks",
if v6 { "ip6tables" } else { "iptables" }
)),
}
}
/// Removes only exact interface tags, retaining other agents and host rules.
pub(super) fn clear(run: RunFamily<'_>, interface: &str, v6: bool) -> Result<(), String> {
clear_tags(run, interface, v6, false)
}
fn clear_tags(
run: RunFamily<'_>,
interface: &str,
v6: bool,
sockets_only: bool,
) -> Result<(), String> {
let wanted = tag(interface);
let sockets = listener_tag(interface);
for family in [false, true].into_iter().filter(|family| !family || v6) {
let chains: &[&str] = if sockets_only {
&["PREROUTING"]
} else {
&["OUTPUT", "PREROUTING"]
};
for chain in chains {
let listing = execute(run, family, words(&["-t", "mangle", "-S", chain]))?;
for line in listing.lines() {
let tokens = split_rule_line(line);
let [append, listed_chain, rest @ ..] = tokens.as_slice() else {
continue;
};
if append == "-A"
&& listed_chain == *chain
&& rest.windows(2).any(|pair| {
pair[0] == "--comment"
&& (pair[1] == sockets || (!sockets_only && pair[1] == wanted))
})
{
let args = words(&["-t", "mangle", "-D", chain])
.into_iter()
.chain(rest.iter().cloned())
.collect();
execute(run, family, args)?;
}
}
}
}
Ok(())
}
/// Establishes protection before routing may redirect application traffic.
pub(super) fn install(
run: RunFamily<'_>,
interface: &str,
uid: u32,
v6: bool,
) -> Result<Vec<Listener>, String> {
let listeners = local_sockets(uid, v6)?;
install_with_listeners(run, interface, uid, v6, &listeners)?;
Ok(listeners)
}
fn install_with_listeners(
run: RunFamily<'_>,
interface: &str,
uid: u32,
v6: bool,
listeners: &[Listener],
) -> Result<(), String> {
clear(run, interface, v6)?;
let result = (|| {
for family in [false, true].into_iter().filter(|family| !family || v6) {
for (chain, spec) in rules(interface, uid) {
let args = words(&["-t", "mangle", "-I", chain, "1"])
.into_iter()
.chain(spec)
.collect();
execute(run, family, args)?;
}
for listener in listeners.iter().filter(|s| s.address.is_ipv6() == family) {
for spec in listener_rules(interface, listener) {
execute(
run,
family,
words(&["-t", "mangle", "-I", "PREROUTING", "1"])
.into_iter()
.chain(spec)
.collect(),
)?;
}
}
}
Ok(())
})();
if result.is_err()
&& let Err(cleanup) = clear(run, interface, v6)
{
return Err(format!(
"{}; cannot roll back connection marks: {cleanup}",
result.err().unwrap_or_default()
));
}
result
}
/// Updates passive protection without interrupting established connmark rules.
pub(super) fn refresh(
run: RunFamily<'_>,
interface: &str,
v6: bool,
listeners: &[Listener],
) -> Result<(), String> {
clear_tags(run, interface, v6, true)?;
for family in [false, true].into_iter().filter(|f| !f || v6) {
for listener in listeners.iter().filter(|s| s.address.is_ipv6() == family) {
for spec in listener_rules(interface, listener) {
execute(
run,
family,
words(&["-t", "mangle", "-I", "PREROUTING", "1"])
.into_iter()
.chain(spec)
.collect(),
)?;
}
}
}
Ok(())
}
/// Kernel strict RPF must include the restored mark, independently of iptables.
pub(super) fn check_kernel_rpf(
all_rpf: u32,
all_valid: u32,
interfaces: &[(String, u32, u32)],
) -> Result<(), String> {
for (interface, rpf, valid) in interfaces {
if all_rpf.max(*rpf) == 1 && all_valid.max(*valid) == 0 {
return Err(format!(
"strict rp_filter on `{interface}` ignores connection marks; enable net.ipv4.conf.all.src_valid_mark=1 before using an exit node (rp_filter stays enabled)"
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::cell::RefCell;
#[test]
fn both_families_restore_before_firewall_and_preserve_foreign_mark_bits() {
let calls = RefCell::new(Vec::new());
let run = |v6, args: &[String]| {
calls.borrow_mut().push((v6, args.to_vec()));
Ran::Exited {
success: true,
stdout: String::new(),
stderr: String::new(),
}
};
install_with_listeners(&run, "tsun0", 990, true, &[]).unwrap();
let calls = calls.borrow();
for family in [false, true] {
let inserts: Vec<_> = calls
.iter()
.filter(|(v6, a)| *v6 == family && a.iter().any(|s| s == "-I"))
.map(|(_, a)| a.join(" "))
.collect();
assert_eq!(inserts.len(), 3);
assert!(inserts[0].contains("-I PREROUTING 1 ! -i tsun0 -m connmark"));
assert!(inserts[0].contains("--restore-mark --nfmask 0x40000000 --ctmask 0x40000000"));
assert!(inserts[1].contains("--uid-owner 990"));
assert!(inserts[1].contains("--save-mark"));
assert!(inserts[2].contains("--set-xmark 0x40000000/0x40000000"));
assert!(
!inserts
.iter()
.any(|s| s.contains("-j ACCEPT") || s.contains("-j RETURN"))
);
}
assert_eq!(UNDERLAY_MASK & 0x00ff_0000, 0);
}
#[test]
fn cleanup_retains_other_interfaces_and_rolls_back_partial_ipv6_installation() {
let calls = RefCell::new(Vec::new());
let run = |v6, args: &[String]| {
calls.borrow_mut().push((v6, args.to_vec()));
let listing = "-A OUTPUT -m comment --comment tsunagi-exit-underlay:tsun0 -j MARK --set-xmark 0x40000000/0x40000000\n-A OUTPUT -m comment --comment tsunagi-exit-underlay:tsun1 -j MARK --set-xmark 0x40000000/0x40000000\n-A OUTPUT -m comment --comment unrelated -j RETURN\n";
Ran::Exited {
success: !(v6 && args.iter().any(|s| s == "-I")),
stdout: if args.iter().any(|s| s == "-S") {
listing.into()
} else {
String::new()
},
stderr: "injected IPv6 failure".into(),
}
};
assert!(install_with_listeners(&run, "tsun0", 990, true, &[]).is_err());
let calls = calls.borrow();
let deletions: Vec<_> = calls
.iter()
.filter(|(_, a)| a.iter().any(|s| s == "-D"))
.collect();
assert!(
deletions.len() >= 4,
"both initial and rollback cleanup run"
);
assert!(
deletions
.iter()
.all(|(_, a)| a.iter().any(|s| s == "tsunagi-exit-underlay:tsun0"))
);
}
#[test]
fn kernel_strict_validation_requires_mark_awareness_without_weakening_rpf() {
let interfaces = vec![("eth0".into(), 1, 0)];
assert!(check_kernel_rpf(0, 0, &interfaces).is_err());
assert!(check_kernel_rpf(0, 1, &interfaces).is_ok());
assert!(check_kernel_rpf(2, 0, &interfaces).is_ok());
assert!(check_kernel_rpf(0, 0, &[("eth0".into(), 1, 1)]).is_ok());
}
#[test]
fn passive_tcp_protection_uses_only_locally_owned_listeners() {
let listing = "header\n0: 00000000:01BB 00000000:0000 0A 0:0 0:0 0 990 0 1\n1: 0100007F:1111 00000000:0000 0A 0:0 0:0 0 1000 0 2\n2: 0100007F:2222 00000000:0000 01 0:0 0:0 0 990 0 3\n";
let listeners = parse_listeners(listing, 990, false, true).unwrap();
assert_eq!(
listeners,
vec![Listener {
address: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
port: 443,
tcp: true
}]
);
let rules = listener_rules("tsun0", &listeners[0]);
assert!(
rules[0]
.join(" ")
.contains("-p tcp --dport 443 -m socket --nowildcard")
);
assert!(rules[0].join(" ").contains("--save-mark"));
assert!(rules[1].join(" ").contains("--set-xmark"));
}
#[test]
fn listener_parser_checks_family_encoding_and_bounds() {
let loopback = Ipv6Addr::LOCALHOST.octets();
let hex: String = loopback
.as_chunks::<4>()
.0
.iter()
.map(|bytes| format!("{:08X}", u32::from_ne_bytes(*bytes)))
.collect();
let listing = format!("header\n0: {hex}:01BB 0:0 0A 0:0 0:0 0 990 0 1\n");
assert_eq!(
parse_listeners(&listing, 990, true, true).unwrap(),
vec![Listener {
address: IpAddr::V6(Ipv6Addr::LOCALHOST),
port: 443,
tcp: true
}]
);
assert!(
parse_listeners(
"header\n0: BAD:01BB 0:0 0A 0:0 0:0 0 990 0 1",
990,
true,
true
)
.is_err()
);
let huge = format!(
"header\n{}",
"0: 00000000:01BB 0:0 0A 0:0 0:0 0 990 0 1\n".repeat(129)
);
assert!(parse_listeners(&huge, 990, false, true).is_err());
}
#[test]
fn udp_refresh_preserves_general_connmark_protection_and_other_interfaces() {
let listing = "header\n0: 00000000:ABA0 0:0 07 0:0 0:0 0 990 0 1\n1: 00000000:0000 0:0 07 0:0 0:0 0 990 0 2\n";
let sockets = parse_listeners(listing, 990, false, false).unwrap();
assert_eq!(sockets.len(), 1);
assert!(!sockets[0].tcp);
let calls = RefCell::new(Vec::new());
let run = |v6, args: &[String]| {
calls.borrow_mut().push((v6, args.join(" ")));
Ran::Exited { success: true, stdout: "-A PREROUTING -m comment --comment tsunagi-exit-underlay:tsun0 -j CONNMARK --restore-mark\n-A PREROUTING -m comment --comment tsunagi-exit-sockets:tsun0 -j MARK\n-A PREROUTING -m comment --comment tsunagi-exit-sockets:tsun1 -j MARK\n".into(), stderr: String::new() }
};
refresh(&run, "tsun0", false, &sockets).unwrap();
let calls = calls.borrow();
assert!(
calls
.iter()
.any(|(_, a)| a.contains("-I PREROUTING 1") && a.contains("-p udp"))
);
assert!(
calls
.iter()
.filter(|(_, a)| a.contains("-D"))
.all(|(_, a)| a.contains("tsunagi-exit-sockets:tsun0"))
);
}
}
@@ -19,6 +19,10 @@
use crate::BoxFuture; use crate::BoxFuture;
use std::sync::{Arc, Mutex};
use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules};
use super::macos_exit::{apply_plan, clear_all, run_tool};
use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome}; use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome};
/// The limited-broadcast destination routed through the overlay interface. /// The limited-broadcast destination routed through the overlay interface.
@@ -60,14 +64,19 @@ fn firewall_todo(plan: &BroadcastRulesPlan) -> String {
) )
} }
/// The macOS implementation: `route` for the route, no firewall yet. /// The macOS implementation: `route` for the route, no firewall yet, and
/// `pf` plus `route` for exit nodes (see [`super::macos_exit`]).
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct MacosHostRules; pub struct MacosHostRules {
/// The reference `pfctl -E` handed out, held while exit-node rules need
/// `pf` on and released when they are removed.
pub(super) pf_token: Arc<Mutex<Option<String>>>,
}
impl MacosHostRules { impl MacosHostRules {
/// Creates the host rules. /// Creates the host rules.
pub fn new() -> Self { pub fn new() -> Self {
Self Self::default()
} }
/// Installs the route, replacing one this agent added earlier so the call /// Installs the route, replacing one this agent added earlier so the call
@@ -82,11 +91,46 @@ impl MacosHostRules {
} }
} }
impl ExitHostRules for MacosHostRules {
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
Box::pin(async move {
let job_plan = plan.clone();
let token = Arc::clone(&self.pf_token);
tokio::task::spawn_blocking(move || apply_plan(&run_tool, &token, &job_plan))
.await
.unwrap_or_else(|err| {
let failed = RuleOutcome::Failed(format!("the task failed: {err}"));
ExitHostReport {
offer: plan
.offer
.iter()
.map(|range| (*range, failed.clone()))
.collect(),
forwarding: None,
client: plan.client.then_some(failed),
}
})
})
}
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
let name = interface.to_string();
let token = Arc::clone(&self.pf_token);
let _ = tokio::task::spawn_blocking(move || clear_all(&run_tool, &token, &name)).await;
})
}
}
impl BroadcastHostRules for MacosHostRules { impl BroadcastHostRules for MacosHostRules {
fn name(&self) -> &str { fn name(&self) -> &str {
"route" "route"
} }
fn exit_rules(&self) -> Option<&dyn ExitHostRules> {
Some(self)
}
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> { fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
Box::pin(async move { Box::pin(async move {
let route = self.set_route(plan.interface.clone()).await; let route = self.set_route(plan.interface.clone()).await;
@@ -0,0 +1,915 @@
//! Exit-node host rules on macOS: a `pf` anchor and `route` entries.
//!
//! Compiled on every platform so the rule text and the parsers are tested
//! everywhere; only `MacosHostRules` runs them, and only on macOS.
//!
//! # Offering
//!
//! Per offered range, in the anchor `com.apple/tsunagi-exit-<if>`:
//!
//! ```text
//! nat on <egress> inet from <range> to any -> (<egress>)
//! pass in quick on <if> inet from <range> to ! <range>
//! ```
//!
//! The anchor lives under `com.apple/` on purpose: the stock `/etc/pf.conf`
//! evaluates `nat-anchor "com.apple/*"` and `anchor "com.apple/*"`, so the
//! rules take effect without editing the user's ruleset. `pf` cannot say
//! "every interface but one" in a translation address, so the egress is the
//! interface of the default route **when the rules are applied**; after the
//! default route moves (Wi-Fi to Ethernet) the rules are re-applied by
//! switching the exit node off and on, or restarting the agent.
//!
//! The kernel's `net.inet.ip.forwarding` is only read, never written: it
//! changes how the whole host behaves, which is the owner's decision.
//!
//! # Using
//!
//! macOS has no `uidrange` policy routing, and the agent is root, so the
//! Linux scheme does not carry over. Instead:
//!
//! ```text
//! route add -net 0.0.0.0/1 -interface <if>
//! route add -net 128.0.0.0/1 -interface <if>
//! pass out quick on <if> route-to (<egress> <gateway>) inet proto { tcp udp }
//! from any to ! <overlay ranges> user <agent uid>
//! nat on <egress> inet from (<if>) to any -> (<egress>)
//! ```
//!
//! The two halves outrank the default route without replacing it, and every
//! more specific route (LAN, another VPN) keeps working. The `route-to` rule
//! sends the *agent's own* traffic — QUIC, relay, DHT, which would otherwise
//! loop into the tunnel it carries — back out the physical interface, and the
//! translation gives it that interface's address, since an unbound socket
//! picked the overlay address when the route pointed at the overlay. Like the
//! Linux exemption this is by user id, so anything else running as root also
//! bypasses the tunnel.
//!
//! IPv6 is blocked while an exit node is in use, as on Linux: `::/1` and
//! `8000::/1` are rejecting routes, so applications fall back to IPv4 at once
//! instead of leaking.
//!
//! Every object is derived from local state and the interface the agent owns;
//! none of it comes from a peer.
use std::net::Ipv4Addr;
use std::sync::Mutex;
use crate::state::Ipv4Range;
use super::RuleOutcome;
use super::exit::{ExitHostPlan, ExitHostReport};
/// The programs this module runs, always by absolute path.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Tool {
Route,
Pfctl,
Sysctl,
Id,
}
impl Tool {
fn path(self) -> &'static str {
match self {
Self::Route => "/sbin/route",
Self::Pfctl => "/sbin/pfctl",
Self::Sysctl => "/usr/sbin/sysctl",
Self::Id => "/usr/bin/id",
}
}
}
/// What running a tool came to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Ran {
/// The program is not there.
Missing,
/// It ran.
Exited {
success: bool,
stdout: String,
stderr: String,
},
}
/// Runs a tool with optional standard input; injectable so the logic is
/// tested without touching the host.
pub(super) type Run<'a> = &'a dyn Fn(Tool, &[String], Option<&str>) -> Ran;
/// Runs a real tool. Blocking: callers use `spawn_blocking`.
pub(super) fn run_tool(tool: Tool, args: &[String], stdin: Option<&str>) -> Ran {
use std::io::Write;
use std::process::{Command, Stdio};
let mut command = Command::new(tool.path());
command
.args(args)
.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ran::Missing,
Err(err) => {
return Ran::Exited {
success: false,
stdout: String::new(),
stderr: format!("could not run {}: {err}", tool.path()),
};
}
};
if let (Some(text), Some(mut pipe)) = (stdin, child.stdin.take()) {
// A write error shows up as the tool's own failure below.
let _ = pipe.write_all(text.as_bytes());
}
match child.wait_with_output() {
Ok(output) => Ran::Exited {
success: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
},
Err(err) => Ran::Exited {
success: false,
stdout: String::new(),
stderr: format!("could not run {}: {err}", tool.path()),
},
}
}
fn owned(words: &[&str]) -> Vec<String> {
words.iter().map(|word| word.to_string()).collect()
}
/// A name that is safe in a `pf` rule, an anchor path and a table name.
fn safe_name(name: &str) -> bool {
!name.is_empty() && name.len() <= 32 && name.chars().all(|c| c.is_ascii_alphanumeric())
}
/// The anchor holding every rule of one interface.
fn anchor_name(interface: &str) -> Result<String, String> {
if safe_name(interface) {
Ok(format!("com.apple/tsunagi-exit-{interface}"))
} else {
Err(format!(
"`{interface}` is not an interface name this agent can write a pf rule for"
))
}
}
/// The default route as `route -n get default` reports it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct DefaultRoute {
pub interface: String,
pub gateway: Option<Ipv4Addr>,
}
/// Reads `interface:` and `gateway:` out of `route -n get default`.
fn parse_default_route(text: &str) -> Option<DefaultRoute> {
let field = |name: &str| {
text.lines().find_map(|line| {
let (key, value) = line.split_once(':')?;
(key.trim() == name).then(|| value.trim().to_string())
})
};
let interface = field("interface").filter(|name| safe_name(name))?;
let gateway = field("gateway").and_then(|value| value.parse().ok());
Some(DefaultRoute { interface, gateway })
}
fn failure(stderr: &str, stdout: &str) -> String {
let text = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
if text.is_empty() {
"the command failed without saying why".to_string()
} else {
text.to_string()
}
}
/// Runs a tool and turns a failure into a sentence a person can act on.
fn run_ok(
run: Run<'_>,
tool: Tool,
args: &[String],
stdin: Option<&str>,
) -> Result<String, String> {
match run(tool, args, stdin) {
Ran::Missing => Err(format!("{} not found", tool.path())),
Ran::Exited {
success: true,
stdout,
..
} => Ok(stdout),
Ran::Exited { stderr, stdout, .. } => Err(format!(
"{} {} failed: {}",
tool.path(),
args.join(" "),
failure(&stderr, &stdout)
)),
}
}
/// The route traffic leaves by now, which is the egress for translation and
/// for the agent's own sockets. Read *before* the overlay routes are added.
fn default_route(run: Run<'_>, overlay: &str) -> Result<DefaultRoute, String> {
let out = run_ok(run, Tool::Route, &owned(&["-n", "get", "default"]), None)
.map_err(|why| format!("cannot find the default route: {why}"))?;
let route = parse_default_route(&out)
.ok_or_else(|| "the host has no default route to send traffic out of".to_string())?;
if route.interface == overlay {
return Err(format!(
"the default route already goes through `{overlay}`"
));
}
Ok(route)
}
/// Whether the kernel forwards IPv4. Read only.
fn forwarding_state(run: Run<'_>) -> Option<bool> {
let out = run_ok(
run,
Tool::Sysctl,
&owned(&["-n", "net.inet.ip.forwarding"]),
None,
)
.ok()?;
match out.trim() {
"1" => Some(true),
"0" => Some(false),
_ => None,
}
}
/// The effective user id of this process, from `id -u`.
fn agent_uid(run: Run<'_>) -> Result<u32, String> {
run_ok(run, Tool::Id, &owned(&["-u"]), None)
.ok()
.and_then(|out| out.trim().parse().ok())
.ok_or_else(|| "cannot tell which user this agent runs as".to_string())
}
/// What the using side adds to the rules.
#[derive(Debug, Clone, PartialEq, Eq)]
struct ClientRules<'a> {
uid: u32,
/// Every overlay range: the agent's own traffic *to* those still goes
/// through the interface.
overlay: &'a [Ipv4Range],
}
/// The text of the anchor: translation first, filtering after, which is the
/// order `pf` insists on.
fn pf_rules(
interface: &str,
egress: &DefaultRoute,
offer: &[Ipv4Range],
client: Option<&ClientRules<'_>>,
) -> String {
let out = &egress.interface;
let mut nat = Vec::new();
let mut filter = Vec::new();
for range in offer {
nat.push(format!("nat on {out} inet from {range} to any -> ({out})"));
filter.push(format!(
"pass in quick on {interface} inet from {range} to ! {range}"
));
}
let mut tables = Vec::new();
if let Some(client) = client {
nat.push(format!(
"nat on {out} inet from ({interface}) to any -> ({out})"
));
let target = if client.overlay.is_empty() {
"any".to_string()
} else {
let ranges: Vec<String> = client.overlay.iter().map(|r| r.to_string()).collect();
// A table, because `! { a, b }` expands to one rule per element
// in older `pf`, which matches everything.
tables.push(format!(
"table <tsunagi_overlay_{interface}> const {{ {} }}",
ranges.join(", ")
));
format!("! <tsunagi_overlay_{interface}>")
};
let via = match egress.gateway {
Some(gateway) => format!("({out} {gateway})"),
None => out.clone(),
};
filter.push(format!(
"pass out quick on {interface} route-to {via} inet proto {{ tcp udp }} \
from any to {target} user {}",
client.uid
));
}
tables
.into_iter()
.chain(nat)
.chain(filter)
.collect::<Vec<_>>()
.join("\n")
+ "\n"
}
/// Turns `pf` on if nobody has, holding the reference `pfctl` hands out so it
/// can be released when the agent is done. One reference at most.
fn acquire_pf(run: Run<'_>, slot: &Mutex<Option<String>>) -> Result<(), String> {
if lock(slot).is_some() {
return Ok(());
}
match run(Tool::Pfctl, &owned(&["-E"]), None) {
Ran::Missing => Err(format!("{} not found", Tool::Pfctl.path())),
Ran::Exited {
success,
stdout,
stderr,
} => {
// `pfctl -E` reports `Token : <n>` on stderr, and also says so
// when pf is already enabled.
let token = stdout.lines().chain(stderr.lines()).find_map(parse_token);
if !success && token.is_none() {
return Err(format!("cannot enable pf: {}", failure(&stderr, &stdout)));
}
*lock(slot) = token;
Ok(())
}
}
}
fn parse_token(line: &str) -> Option<String> {
let (key, value) = line.split_once(':')?;
let value = value.trim();
(key.trim().eq_ignore_ascii_case("token")
&& !value.is_empty()
&& value.chars().all(|c| c.is_ascii_digit()))
.then(|| value.to_string())
}
fn release_pf(run: Run<'_>, slot: &Mutex<Option<String>>) {
if let Some(token) = lock(slot).take()
&& let Err(why) = run_ok(run, Tool::Pfctl, &["-X".to_string(), token], None)
{
tracing::debug!(%why, "cannot release the pf reference");
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn flush_args(anchor: &str) -> Vec<String> {
owned(&["-q", "-a", anchor, "-F", "all"])
}
fn load_args(anchor: &str) -> Vec<String> {
owned(&["-q", "-a", anchor, "-f", "-"])
}
/// The two halves that out-rank the default route.
const HALVES: [&str; 2] = ["0.0.0.0/1", "128.0.0.0/1"];
/// The IPv6 halves, made unreachable.
const HALVES_V6: [&str; 2] = ["::/1", "8000::/1"];
fn route_add_args(half: &str, interface: &str) -> Vec<String> {
owned(&["-q", "-n", "add", "-net", half, "-interface", interface])
}
fn route_del_args(half: &str, interface: &str) -> Vec<String> {
owned(&["-q", "-n", "delete", "-net", half, "-interface", interface])
}
fn route_add_v6_args(half: &str) -> Vec<String> {
owned(&["-q", "-n", "add", "-inet6", "-net", half, "::1", "-reject"])
}
fn route_del_v6_args(half: &str) -> Vec<String> {
owned(&["-q", "-n", "delete", "-inet6", "-net", half, "::1"])
}
/// Takes away the overlay default routes. Best effort: what is not there is
/// the state wanted.
fn clear_routes(run: Run<'_>, interface: &str) {
for half in HALVES {
let _ = run(Tool::Route, &route_del_args(half, interface), None);
}
for half in HALVES_V6 {
let _ = run(Tool::Route, &route_del_v6_args(half), None);
}
}
fn set_routes(run: Run<'_>, interface: &str) -> Result<(), String> {
clear_routes(run, interface);
for half in HALVES {
if let Err(why) = run_ok(run, Tool::Route, &route_add_args(half, interface), None) {
clear_routes(run, interface);
return Err(why);
}
}
for half in HALVES_V6 {
// A host without IPv6 has nothing to block; not a failure.
if let Err(why) = run_ok(run, Tool::Route, &route_add_v6_args(half), None) {
tracing::debug!(%why, "cannot block IPv6 for the exit node");
}
}
Ok(())
}
/// Makes the host match the plan.
pub(super) fn apply_plan(
run: Run<'_>,
token: &Mutex<Option<String>>,
plan: &ExitHostPlan,
) -> ExitHostReport {
let forwarding = if plan.offer.is_empty() {
None
} else {
forwarding_state(run)
};
let outcome = apply_rules(run, token, plan);
let verdict = |ok: bool, why: &str| {
if ok {
RuleOutcome::Applied
} else {
RuleOutcome::Failed(why.to_string())
}
};
match outcome {
Ok(()) => ExitHostReport {
offer: plan
.offer
.iter()
.map(|range| (*range, RuleOutcome::Applied))
.collect(),
forwarding,
client: plan.client.then_some(RuleOutcome::Applied),
},
Err(why) => ExitHostReport {
offer: plan
.offer
.iter()
.map(|range| (*range, verdict(false, &why)))
.collect(),
forwarding,
client: plan.client.then(|| verdict(false, &why)),
},
}
}
fn apply_rules(
run: Run<'_>,
token: &Mutex<Option<String>>,
plan: &ExitHostPlan,
) -> Result<(), String> {
let interface = plan.interface.as_str();
let anchor = anchor_name(interface)?;
let egress = default_route(run, interface)?;
let uid = if plan.client {
Some(agent_uid(run)?)
} else {
None
};
let client = uid.map(|uid| ClientRules {
uid,
overlay: &plan.overlay,
});
let rules = pf_rules(interface, &egress, &plan.offer, client.as_ref());
acquire_pf(run, token)?;
run_ok(run, Tool::Pfctl, &load_args(&anchor), Some(&rules))
.map_err(|why| format!("cannot load the pf rules: {why}"))?;
// The routes come after the rules that keep the agent's own traffic off
// them: the other order would loop for as long as it takes.
if plan.client {
set_routes(run, interface)?;
} else {
clear_routes(run, interface);
}
Ok(())
}
/// Removes everything this agent put there for the interface.
pub(super) fn clear_all(run: Run<'_>, token: &Mutex<Option<String>>, interface: &str) {
clear_routes(run, interface);
if let Ok(anchor) = anchor_name(interface) {
// Flushing an anchor nothing was ever loaded into is not an error
// worth reporting.
if let Err(why) = run_ok(run, Tool::Pfctl, &flush_args(&anchor), None) {
tracing::debug!(%why, "cannot flush the exit node pf anchor");
}
}
release_pf(run, token);
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::cell::RefCell;
use super::*;
fn range() -> Ipv4Range {
"10.13.37.0/24".parse().unwrap()
}
const DEFAULT_ROUTE: &str = " route to: default\n\
destination: default\n\
mask: default\n\
gateway: 192.168.1.1\n\
interface: en0\n\
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>\n";
fn egress() -> DefaultRoute {
DefaultRoute {
interface: "en0".into(),
gateway: Some(Ipv4Addr::new(192, 168, 1, 1)),
}
}
/// A scripted host that records every call.
struct Fake {
calls: RefCell<Vec<String>>,
stdins: RefCell<Vec<String>>,
default_route: Option<String>,
forwarding: &'static str,
pf_missing: bool,
pf_load_fails: bool,
route_add_fails: bool,
pf_enable_output: &'static str,
}
impl Fake {
fn new() -> Self {
Self {
calls: RefCell::new(Vec::new()),
stdins: RefCell::new(Vec::new()),
default_route: Some(DEFAULT_ROUTE.to_string()),
forwarding: "1\n",
pf_missing: false,
pf_load_fails: false,
route_add_fails: false,
pf_enable_output: "pf enabled\nToken : 4242\n",
}
}
fn run(&self, tool: Tool, args: &[String], stdin: Option<&str>) -> Ran {
let line = format!("{:?} {}", tool, args.join(" "));
self.calls.borrow_mut().push(line);
if let Some(text) = stdin {
self.stdins.borrow_mut().push(text.to_string());
}
let ok = |stdout: &str, stderr: &str| Ran::Exited {
success: true,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
};
let fail = |why: &str| Ran::Exited {
success: false,
stdout: String::new(),
stderr: why.to_string(),
};
match tool {
Tool::Id => ok("0\n", ""),
Tool::Sysctl => ok(self.forwarding, ""),
Tool::Route if args.first().map(String::as_str) == Some("-n") => {
match &self.default_route {
Some(text) => ok(text, ""),
None => fail("route: writing to routing socket: not in table"),
}
}
Tool::Route if args.contains(&"add".to_string()) && self.route_add_fails => {
fail("route: writing to routing socket: File exists")
}
Tool::Route => ok("", ""),
Tool::Pfctl if self.pf_missing => Ran::Missing,
Tool::Pfctl if args == ["-E"] => ok("", self.pf_enable_output),
Tool::Pfctl if args.contains(&"-f".to_string()) && self.pf_load_fails => {
fail("pfctl: Syntax error")
}
Tool::Pfctl => ok("", ""),
}
}
fn calls(&self) -> Vec<String> {
self.calls.borrow().clone()
}
fn position(&self, needle: &str) -> Option<usize> {
self.calls.borrow().iter().position(|c| c.contains(needle))
}
fn count(&self, needle: &str) -> usize {
self.calls
.borrow()
.iter()
.filter(|c| c.contains(needle))
.count()
}
}
fn plan(offer: bool, client: bool) -> ExitHostPlan {
ExitHostPlan {
interface: "utun5".into(),
offer: if offer { vec![range()] } else { Vec::new() },
client,
overlay: vec![range()],
..Default::default()
}
}
#[test]
fn the_default_route_is_read_from_route_output() {
assert_eq!(parse_default_route(DEFAULT_ROUTE), Some(egress()));
// A point-to-point default has no gateway line to use.
let ppp = "destination: default\n interface: ppp0\n";
assert_eq!(
parse_default_route(ppp),
Some(DefaultRoute {
interface: "ppp0".into(),
gateway: None
})
);
assert_eq!(parse_default_route("route: not in table"), None);
// An interface name that is not plain letters and digits never ends
// up in a rule.
assert_eq!(parse_default_route("interface: en0; pass all\n"), None);
}
#[test]
fn the_anchor_is_under_com_apple_and_only_for_plain_names() {
assert_eq!(
anchor_name("utun5").unwrap(),
"com.apple/tsunagi-exit-utun5"
);
assert!(anchor_name("").is_err());
assert!(anchor_name("utun5 pass all").is_err());
assert!(anchor_name("../x").is_err());
}
#[test]
fn offering_translates_the_range_and_admits_it_in() {
let text = pf_rules("utun5", &egress(), &[range()], None);
assert_eq!(
text,
"nat on en0 inet from 10.13.37.0/24 to any -> (en0)\n\
pass in quick on utun5 inet from 10.13.37.0/24 to ! 10.13.37.0/24\n"
);
// Translation precedes filtering, as pf requires.
assert!(text.find("nat on").unwrap() < text.find("pass in").unwrap());
}
#[test]
fn using_sends_the_agents_own_traffic_back_out_the_physical_interface() {
let other: Ipv4Range = "10.99.0.0/16".parse().unwrap();
let overlay = [range(), other];
let client = ClientRules {
uid: 0,
overlay: &overlay,
};
let text = pf_rules("utun5", &egress(), &[], Some(&client));
assert!(
text.starts_with(
"table <tsunagi_overlay_utun5> const { 10.13.37.0/24, 10.99.0.0/16 }\n"
),
"{text}"
);
assert!(
text.contains("nat on en0 inet from (utun5) to any -> (en0)\n"),
"{text}"
);
assert!(
text.contains(
"pass out quick on utun5 route-to (en0 192.168.1.1) inet proto { tcp udp } \
from any to ! <tsunagi_overlay_utun5> user 0\n"
),
"{text}"
);
// Only the agent's user, and only what is not the overlay.
assert!(!text.contains("from any to any"));
let direct = DefaultRoute {
interface: "ppp0".into(),
gateway: None,
};
let text = pf_rules(
"utun5",
&direct,
&[],
Some(&ClientRules {
uid: 501,
overlay: &[],
}),
);
assert!(text.contains("route-to ppp0 inet"), "{text}");
assert!(text.contains("to any user 501"), "{text}");
assert!(!text.contains("table"), "{text}");
}
#[test]
fn the_overlay_default_routes_are_halves_and_ipv6_is_rejected() {
assert_eq!(
route_add_args("0.0.0.0/1", "utun5").join(" "),
"-q -n add -net 0.0.0.0/1 -interface utun5"
);
assert_eq!(
route_del_args("128.0.0.0/1", "utun5").join(" "),
"-q -n delete -net 128.0.0.0/1 -interface utun5"
);
assert_eq!(
route_add_v6_args("8000::/1").join(" "),
"-q -n add -inet6 -net 8000::/1 ::1 -reject"
);
assert_eq!(
route_del_v6_args("::/1").join(" "),
"-q -n delete -inet6 -net ::/1 ::1"
);
}
#[test]
fn the_pf_token_is_found_in_either_stream() {
assert_eq!(parse_token("Token : 1234"), Some("1234".into()));
assert_eq!(parse_token("token: 9"), Some("9".into()));
assert_eq!(parse_token("pf enabled"), None);
assert_eq!(parse_token("Token : x; rm"), None);
}
#[test]
fn offering_loads_the_anchor_and_reads_but_never_writes_forwarding() {
let fake = Fake::new();
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
assert_eq!(report.offer, vec![(range(), RuleOutcome::Applied)]);
assert_eq!(report.forwarding, Some(true));
assert_eq!(report.client, None);
let calls = fake.calls();
assert!(
calls
.iter()
.any(|c| c == "Pfctl -q -a com.apple/tsunagi-exit-utun5 -f -"),
"{calls:?}"
);
assert_eq!(
fake.stdins.borrow()[0],
"nat on en0 inet from 10.13.37.0/24 to any -> (en0)\n\
pass in quick on utun5 inet from 10.13.37.0/24 to ! 10.13.37.0/24\n"
);
// sysctl is only ever asked, never told.
assert!(
calls
.iter()
.filter(|c| c.starts_with("Sysctl"))
.all(|c| c == "Sysctl -n net.inet.ip.forwarding"),
"{calls:?}"
);
// No client routes were added for an offer alone.
assert_eq!(fake.count(" add "), 0);
assert_eq!(token.lock().unwrap().as_deref(), Some("4242"));
}
#[test]
fn forwarding_off_is_reported_and_unreadable_is_none() {
let mut fake = Fake::new();
fake.forwarding = "0\n";
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
assert_eq!(report.forwarding, Some(false));
fake.forwarding = "junk";
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
assert_eq!(report.forwarding, None);
}
#[test]
fn the_client_rules_are_loaded_before_the_routes_that_need_them() {
let fake = Fake::new();
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(false, true));
assert_eq!(report.client, Some(RuleOutcome::Applied));
assert!(report.offer.is_empty());
let load = fake.position("-f -").unwrap();
let add = fake.position(" add -net 0.0.0.0/1").unwrap();
assert!(load < add, "{:?}", fake.calls());
assert!(fake.position("add -net 128.0.0.0/1").is_some());
assert!(fake.position("-inet6 -net ::/1 ::1 -reject").is_some());
assert!(fake.stdins.borrow()[0].contains("user 0"));
// The default route was read before any overlay route existed.
assert!(fake.position("-n get default").unwrap() < add);
}
#[test]
fn no_routes_are_added_when_the_rules_did_not_load() {
let mut fake = Fake::new();
fake.pf_load_fails = true;
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, true));
let Some(RuleOutcome::Failed(why)) = report.client else {
panic!("{report:?}");
};
assert!(why.contains("cannot load the pf rules"), "{why}");
assert!(why.contains("Syntax error"), "{why}");
assert!(matches!(report.offer[0].1, RuleOutcome::Failed(_)));
// Nothing sent the host's traffic into a tunnel it could not exempt
// the agent from.
assert_eq!(fake.count(" add "), 0);
}
#[test]
fn a_failing_route_is_a_failure_and_leaves_nothing_behind() {
let mut fake = Fake::new();
fake.route_add_fails = true;
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(false, true));
let Some(RuleOutcome::Failed(why)) = report.client else {
panic!("{report:?}");
};
assert!(why.contains("File exists"), "{why}");
// The cleanup after the failed add removed whatever did land.
let last_add = fake
.calls()
.iter()
.rposition(|c| c.contains(" add "))
.unwrap();
assert!(
fake.calls()[last_add..]
.iter()
.any(|c| c.contains("delete"))
);
}
#[test]
fn a_missing_pfctl_or_default_route_says_what_is_wrong() {
let mut fake = Fake::new();
fake.pf_missing = true;
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
panic!("{report:?}");
};
assert!(why.contains("/sbin/pfctl not found"), "{why}");
let mut fake = Fake::new();
fake.default_route = None;
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
panic!("{report:?}");
};
assert!(why.contains("default route"), "{why}");
assert_eq!(fake.count("-f -"), 0);
}
#[test]
fn a_default_route_through_the_overlay_is_refused() {
let mut fake = Fake::new();
fake.default_route = Some("gateway: 10.13.37.1\ninterface: utun5\n".into());
let token = Mutex::new(None);
let report = apply_plan(&|t, a, s| fake.run(t, a, s), &token, &plan(true, false));
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
panic!("{report:?}");
};
assert!(why.contains("already goes through"), "{why}");
}
#[test]
fn pf_is_enabled_once_and_released_once_on_clear() {
let fake = Fake::new();
let token = Mutex::new(None);
let run = |t, a: &[String], s: Option<&str>| fake.run(t, a, s);
apply_plan(&run, &token, &plan(true, false));
apply_plan(&run, &token, &plan(true, true));
assert_eq!(fake.count("Pfctl -E"), 1, "{:?}", fake.calls());
clear_all(&run, &token, "utun5");
assert_eq!(fake.count("Pfctl -X 4242"), 1);
assert!(
fake.position("-a com.apple/tsunagi-exit-utun5 -F all")
.is_some()
);
assert!(
fake.position("delete -net 0.0.0.0/1 -interface utun5")
.is_some()
);
assert_eq!(*token.lock().unwrap(), None);
// A second clear has nothing to release and still succeeds.
clear_all(&run, &token, "utun5");
assert_eq!(fake.count("Pfctl -X"), 1);
}
#[test]
fn dropping_the_client_removes_its_routes() {
let fake = Fake::new();
let token = Mutex::new(None);
let run = |t, a: &[String], s: Option<&str>| fake.run(t, a, s);
apply_plan(&run, &token, &plan(true, false));
assert!(fake.position("delete -net 0.0.0.0/1").is_some());
assert_eq!(fake.count(" add "), 0);
}
}
@@ -55,17 +55,26 @@ pub use exit::{
mod linux; mod linux;
#[cfg(all(feature = "tun-device", target_os = "linux"))] #[cfg(all(feature = "tun-device", target_os = "linux"))]
mod linux_exit; mod linux_exit;
#[cfg(all(test, feature = "tun-device", target_os = "linux"))]
mod linux_exit_kernel;
#[cfg(all(feature = "tun-device", target_os = "linux"))]
pub(in crate::overlay) mod linux_exit_mark;
#[cfg(all(feature = "tun-device", target_os = "linux"))] #[cfg(all(feature = "tun-device", target_os = "linux"))]
pub use linux::LinuxHostRules; pub use linux::LinuxHostRules;
// Compiled everywhere so the scripts it builds are tested everywhere; only // Compiled everywhere so the scripts it builds are tested everywhere; only
// the type that runs them is exported on Windows. // the type that runs them is exported on Windows.
mod windows; mod windows;
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
mod windows_exit;
#[cfg(all(feature = "tun-device", target_os = "windows"))] #[cfg(all(feature = "tun-device", target_os = "windows"))]
pub use windows::WindowsHostRules; pub use windows::WindowsHostRules;
// Compiled everywhere so its rule text and parsers are tested everywhere.
#[cfg(all(feature = "tun-device", target_os = "macos"))] #[cfg(all(feature = "tun-device", target_os = "macos"))]
mod macos; mod macos;
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
mod macos_exit;
#[cfg(all(feature = "tun-device", target_os = "macos"))] #[cfg(all(feature = "tun-device", target_os = "macos"))]
pub use macos::MacosHostRules; pub use macos::MacosHostRules;
@@ -29,6 +29,8 @@
use crate::BoxFuture; use crate::BoxFuture;
use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules};
use super::windows_exit;
use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome, tag_for}; use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome, tag_for};
/// A PowerShell single-quoted literal, with any embedded quote doubled. /// A PowerShell single-quoted literal, with any embedded quote doubled.
@@ -113,6 +115,11 @@ fn powershell_path() -> std::path::PathBuf {
/// Runs a script and returns what it said on failure. /// Runs a script and returns what it said on failure.
fn run_powershell(script: &str) -> Result<(), String> { fn run_powershell(script: &str) -> Result<(), String> {
run_powershell_output(script).map(|_| ())
}
/// Runs a script and returns what it printed, or what it said on failure.
fn run_powershell_output(script: &str) -> Result<String, String> {
let output = std::process::Command::new(powershell_path()) let output = std::process::Command::new(powershell_path())
.args([ .args([
"-NoProfile", "-NoProfile",
@@ -125,7 +132,7 @@ fn run_powershell(script: &str) -> Result<(), String> {
.output() .output()
.map_err(|err| format!("could not run PowerShell: {err}"))?; .map_err(|err| format!("could not run PowerShell: {err}"))?;
if output.status.success() { if output.status.success() {
return Ok(()); return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
} }
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
let text = if stderr.trim().is_empty() { let text = if stderr.trim().is_empty() {
@@ -155,11 +162,73 @@ impl WindowsHostRules {
} }
} }
/// The IPv4 addresses a host name resolves to; empty when it does not.
fn resolve_host(host: &str) -> Vec<std::net::Ipv4Addr> {
use std::net::{IpAddr, ToSocketAddrs};
(host, 443)
.to_socket_addrs()
.map(|addrs| {
addrs
.filter_map(|addr| match addr.ip() {
IpAddr::V4(ip) => Some(ip),
IpAddr::V6(_) => None,
})
.collect()
})
.unwrap_or_default()
}
impl ExitHostRules for WindowsHostRules {
fn needs_bypass(&self) -> bool {
true
}
fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> {
Box::pin(async move {
let job = plan.clone();
tokio::task::spawn_blocking(move || {
windows_exit::apply_plan(
&|script| run_powershell_output(script),
&resolve_host,
&job,
)
})
.await
.unwrap_or_else(|err| {
let failed = RuleOutcome::Failed(format!("the task failed: {err}"));
ExitHostReport {
offer: plan
.offer
.iter()
.map(|range| (*range, failed.clone()))
.collect(),
forwarding: None,
client: plan.client.then_some(failed),
}
})
})
}
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
let name = interface.to_string();
let _ = tokio::task::spawn_blocking(move || {
windows_exit::clear_all(&|script| run_powershell_output(script), &name)
})
.await;
})
}
}
impl BroadcastHostRules for WindowsHostRules { impl BroadcastHostRules for WindowsHostRules {
fn name(&self) -> &str { fn name(&self) -> &str {
"windows-netsecurity" "windows-netsecurity"
} }
fn exit_rules(&self) -> Option<&dyn ExitHostRules> {
Some(self)
}
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> { fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
Box::pin(async move { Box::pin(async move {
let route = step("route", route_script(plan)).await; let route = step("route", route_script(plan)).await;
@@ -0,0 +1,603 @@
//! Exit-node host rules on Windows: WinNAT through PowerShell.
//!
//! Compiled on every platform so the scripts and the plan logic are tested
//! everywhere; only `WindowsHostRules` runs them, and only on Windows. It
//! could not be run against a real Windows host while it was written.
//!
//! # Offering
//!
//! One NAT object per offered range, named with the usual tag
//! `tsunagi-exit:<if>:<range>`:
//!
//! ```text
//! New-NetNat -Name <tag> -InternalIPInterfaceAddressPrefix <range>
//! ```
//!
//! WinNAT translates that range out of whichever interface the traffic leaves
//! by, which is the Windows counterpart of the Linux masquerade. Windows
//! forwards per interface, and the egress is not known here, so forwarding is
//! only *read* for the overlay interface; turning it on for the interfaces
//! involved is the owner's decision (`Set-NetIPInterface -Forwarding Enabled`).
//! Some Windows versions allow a single NAT per host: a second offered range
//! then reports its own failure.
//!
//! # Using
//!
//! Windows has no per-user or per-process routing, so the Linux scheme (an
//! exemption for the agent's user) does not carry over. Instead the agent's
//! own underlay traffic is exempted by *destination*:
//!
//! ```text
//! New-NetRoute 0.0.0.0/1 on the overlay interface, metric 1
//! New-NetRoute 128.0.0.0/1 on the overlay interface, metric 1
//! New-NetRoute <ip>/32 via the physical default gateway, metric 7373
//! ```
//!
//! The halves out-rank the default route without replacing it, so every more
//! specific route (LAN, another VPN) keeps working. A `/32` per address the
//! agent talks to directly (the verified direct paths of its connections, and
//! the relays it uses, resolved when the rules are applied) keeps that traffic
//! on the physical network instead of looping into the tunnel it carries. The
//! metric 7373 marks those routes as ours, which is how stale ones are found
//! and removed. Everything is written to the active store, so a reboot clears
//! it. IPv6 is blackholed with `::/1` and `8000::/1` on the overlay interface,
//! which has no IPv6: applications fall back to IPv4 after a delay, where
//! Linux and macOS answer at once. The bypass follows the agent's paths as
//! they change; a path that appears after the last apply is reached through
//! the tunnel until the next one.
//!
//! The bypass routes are written before the halves, and removed after them.
use super::RuleOutcome;
use std::net::Ipv4Addr;
use super::exit::{ExitHostPlan, ExitHostReport, exit_tag, exit_tag_prefix};
use crate::state::Ipv4Range;
/// The route metric that marks a bypass route as this agent's.
const BYPASS_METRIC: u32 = 7373;
/// Runs a PowerShell script and returns what it printed.
pub(super) type RunScript<'a> = &'a dyn Fn(&str) -> Result<String, String>;
/// Resolves a host name to its IPv4 addresses.
pub(super) type Resolve<'a> = &'a dyn Fn(&str) -> Vec<Ipv4Addr>;
/// A PowerShell single-quoted literal, with any embedded quote doubled.
fn ps_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
/// Creates the NAT of one range, replacing one this agent made before.
fn nat_script(interface: &str, range: &Ipv4Range) -> String {
let name = ps_literal(&exit_tag(interface, range));
let prefix = ps_literal(&range.to_string());
format!(
"$ErrorActionPreference = 'Stop'\n\
$name = {name}\n\
Get-NetNat -Name $name -ErrorAction SilentlyContinue | \
Remove-NetNat -Confirm:$false -ErrorAction SilentlyContinue\n\
New-NetNat -Name $name -InternalIPInterfaceAddressPrefix {prefix} | Out-Null\n"
)
}
/// Removes this interface's NAT objects except those named in `keep`.
fn prune_script(interface: &str, keep: &[Ipv4Range]) -> String {
let prefix = ps_literal(&exit_tag_prefix(interface));
let keep: Vec<String> = keep
.iter()
.map(|range| ps_literal(&exit_tag(interface, range)))
.collect();
format!(
"$ErrorActionPreference = 'SilentlyContinue'\n\
$prefix = {prefix}\n\
$keep = @({})\n\
Get-NetNat | Where-Object {{ $_.Name.StartsWith($prefix) -and \
($keep -notcontains $_.Name) }} | Remove-NetNat -Confirm:$false\n\
exit 0\n",
keep.join(", ")
)
}
/// Prints `Enabled` or `Disabled` for the interface's IPv4 forwarding.
fn forwarding_script(interface: &str) -> String {
let alias = ps_literal(interface);
format!(
"$ErrorActionPreference = 'Stop'\n\
(Get-NetIPInterface -InterfaceAlias {alias} -AddressFamily IPv4).Forwarding\n"
)
}
/// Prints `<interface index> <next hop>` of the best default route that is
/// not through the overlay interface.
fn gateway_script(interface: &str) -> String {
let alias = ps_literal(interface);
format!(
"$ErrorActionPreference = 'Stop'\n\
$alias = {alias}\n\
$best = Get-NetRoute -DestinationPrefix '0.0.0.0/0' -AddressFamily IPv4 \
-PolicyStore ActiveStore | Where-Object {{ $_.InterfaceAlias -ne $alias -and \
$_.NextHop -ne '0.0.0.0' }} | Sort-Object {{ $_.RouteMetric + \
(Get-NetIPInterface -InterfaceIndex $_.InterfaceIndex -AddressFamily IPv4).InterfaceMetric }} | \
Select-Object -First 1\n\
if (-not $best) {{ throw 'the host has no default gateway outside the overlay' }}\n\
\"$($best.InterfaceIndex) $($best.NextHop)\"\n"
)
}
/// The default gateway as the script above printed it.
fn parse_gateway(text: &str) -> Option<(u32, Ipv4Addr)> {
let mut words = text.split_whitespace();
let index = words.next()?.parse().ok()?;
let hop = words.next()?.parse().ok()?;
words.next().is_none().then_some((index, hop))
}
/// Replaces the bypass routes with one per address, through the gateway.
fn bypass_script(index: u32, gateway: Ipv4Addr, addresses: &[Ipv4Addr]) -> String {
let list: Vec<String> = addresses
.iter()
.map(|address| ps_literal(&address.to_string()))
.collect();
format!(
"$ErrorActionPreference = 'Stop'\n\
Get-NetRoute -AddressFamily IPv4 -PolicyStore ActiveStore -ErrorAction SilentlyContinue | \
Where-Object {{ $_.RouteMetric -eq {BYPASS_METRIC} -and $_.DestinationPrefix.EndsWith('/32') }} | \
Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
foreach ($ip in @({})) {{\n\
New-NetRoute -DestinationPrefix \"$ip/32\" -InterfaceIndex {index} -NextHop '{gateway}' \
-RouteMetric {BYPASS_METRIC} -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Out-Null\n\
}}\n",
list.join(", ")
)
}
/// Sends everything else through the overlay interface.
fn halves_script(interface: &str) -> String {
let alias = ps_literal(interface);
format!(
"$ErrorActionPreference = 'Stop'\n\
$alias = {alias}\n\
foreach ($p in '0.0.0.0/1', '128.0.0.0/1') {{\n\
Get-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -PolicyStore ActiveStore \
-ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
New-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -NextHop '0.0.0.0' \
-RouteMetric 1 -PolicyStore ActiveStore | Out-Null\n\
}}\n\
foreach ($p in '::/1', '8000::/1') {{\n\
Get-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -PolicyStore ActiveStore \
-ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
New-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -NextHop '::' \
-RouteMetric 1 -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Out-Null\n\
}}\n"
)
}
/// Removes the halves and every bypass route. What is not there is the state
/// wanted.
fn remove_client_script(interface: &str) -> String {
let alias = ps_literal(interface);
format!(
"$ErrorActionPreference = 'SilentlyContinue'\n\
$alias = {alias}\n\
foreach ($p in '0.0.0.0/1', '128.0.0.0/1', '::/1', '8000::/1') {{\n\
Get-NetRoute -DestinationPrefix $p -InterfaceAlias $alias -PolicyStore ActiveStore | \
Remove-NetRoute -Confirm:$false\n\
}}\n\
Get-NetRoute -AddressFamily IPv4 -PolicyStore ActiveStore | \
Where-Object {{ $_.RouteMetric -eq {BYPASS_METRIC} -and $_.DestinationPrefix.EndsWith('/32') }} | \
Remove-NetRoute -Confirm:$false\n\
exit 0\n"
)
}
/// Every address to keep direct: the ones given and what the names resolve
/// to, sorted and without duplicates.
fn bypass_addresses(
resolve: Resolve<'_>,
addresses: &[Ipv4Addr],
hosts: &[String],
) -> Vec<Ipv4Addr> {
let mut all: std::collections::BTreeSet<Ipv4Addr> = addresses.iter().copied().collect();
for host in hosts {
all.extend(resolve(host));
}
all.into_iter()
.filter(|a| !a.is_loopback() && !a.is_unspecified() && !a.is_multicast())
.collect()
}
fn apply_client(
run: RunScript<'_>,
resolve: Resolve<'_>,
plan: &ExitHostPlan,
) -> Result<(), String> {
let interface = plan.interface.as_str();
let gateway = run(&gateway_script(interface))?;
let (index, hop) = parse_gateway(&gateway)
.ok_or_else(|| format!("cannot read the default gateway from `{}`", gateway.trim()))?;
let addresses = bypass_addresses(resolve, &plan.bypass, &plan.bypass_hosts);
run(&bypass_script(index, hop, &addresses))?;
if let Err(why) = run(&halves_script(interface)) {
// Do not leave a half-installed set: bypass routes alone are harmless
// but the next apply starts clean.
let _ = run(&remove_client_script(interface));
return Err(why);
}
Ok(())
}
fn parse_forwarding(text: &str) -> Option<bool> {
match text.trim().to_ascii_lowercase().as_str() {
"enabled" | "1" => Some(true),
"disabled" | "0" => Some(false),
_ => None,
}
}
/// Makes the host match the plan.
pub(super) fn apply_plan(
run: RunScript<'_>,
resolve: Resolve<'_>,
plan: &ExitHostPlan,
) -> ExitHostReport {
let interface = plan.interface.as_str();
// What an earlier plan left and this one no longer wants.
let pruned = run(&prune_script(interface, &plan.offer));
let offer = plan
.offer
.iter()
.map(|range| {
let outcome = match &pruned {
Err(why) => RuleOutcome::Failed(why.clone()),
Ok(_) => match run(&nat_script(interface, range)) {
Ok(_) => RuleOutcome::Applied,
Err(why) => RuleOutcome::Failed(why),
},
};
(*range, outcome)
})
.collect();
let client = if plan.client {
Some(match apply_client(run, resolve, plan) {
Ok(()) => RuleOutcome::Applied,
Err(why) => RuleOutcome::Failed(why),
})
} else {
// Whatever an earlier plan left must not stay in force.
let _ = run(&remove_client_script(interface));
None
};
let forwarding = if plan.offer.is_empty() {
None
} else {
run(&forwarding_script(interface))
.ok()
.and_then(|text| parse_forwarding(&text))
};
ExitHostReport {
offer,
forwarding,
client,
}
}
/// Removes every NAT object of this agent's for the interface.
pub(super) fn clear_all(run: RunScript<'_>, interface: &str) {
if let Err(why) = run(&prune_script(interface, &[])) {
tracing::warn!(interface, "cannot remove the exit node NAT: {why}");
}
if let Err(why) = run(&remove_client_script(interface)) {
tracing::warn!(interface, "cannot remove the exit node routes: {why}");
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::cell::RefCell;
use super::*;
fn range() -> Ipv4Range {
"10.13.37.0/24".parse().unwrap()
}
fn plan(client: bool) -> ExitHostPlan {
ExitHostPlan {
interface: "tsun0".into(),
offer: vec![range()],
client,
overlay: vec![range()],
..Default::default()
}
}
#[test]
fn the_nat_is_named_with_the_tag_and_replaces_its_own() {
let script = nat_script("tsun0", &range());
assert!(
script.contains("$name = 'tsunagi-exit:tsun0:10.13.37.0/24'"),
"{script}"
);
assert!(script.contains("-InternalIPInterfaceAddressPrefix '10.13.37.0/24'"));
assert!(
script.find("Remove-NetNat").unwrap() < script.find("New-NetNat").unwrap(),
"{script}"
);
}
#[test]
fn a_quote_in_a_value_cannot_escape_the_literal() {
let script = nat_script("x'; Remove-Item C:\\ #", &range());
assert!(script.contains("'tsunagi-exit:x''; Remove-Item C:\\ #:10.13.37.0/24'"));
assert!(prune_script("x'y", &[]).contains("'tsunagi-exit:x''y:'"));
assert!(forwarding_script("x'y").contains("'x''y'"));
}
#[test]
fn pruning_keeps_wanted_ranges_and_only_selects_this_interfaces_nats() {
let script = prune_script("tsun0", &[range()]);
assert!(script.contains("$prefix = 'tsunagi-exit:tsun0:'"));
assert!(script.contains("$keep = @('tsunagi-exit:tsun0:10.13.37.0/24')"));
assert!(script.contains("StartsWith($prefix)"));
assert!(script.contains("exit 0"));
assert!(prune_script("tsun0", &[]).contains("$keep = @()"));
}
#[test]
fn forwarding_text_is_read_never_written() {
assert_eq!(parse_forwarding("Enabled\r\n"), Some(true));
assert_eq!(parse_forwarding("Disabled"), Some(false));
assert_eq!(parse_forwarding("what"), None);
let script = forwarding_script("tsun0");
assert!(script.contains("Get-NetIPInterface"));
assert!(!script.contains("Set-NetIPInterface"));
}
#[test]
fn offering_prunes_then_creates_and_reads_forwarding() {
let calls = RefCell::new(Vec::new());
let run = |script: &str| -> Result<String, String> {
calls.borrow_mut().push(script.to_string());
Ok(if script.contains("Get-NetIPInterface") {
"Disabled\n".to_string()
} else {
String::new()
})
};
let report = apply_plan(&run, &no_names, &plan(false));
assert_eq!(report.offer, vec![(range(), RuleOutcome::Applied)]);
assert_eq!(report.forwarding, Some(false));
assert_eq!(report.client, None);
let calls = calls.borrow();
assert!(calls[0].contains("StartsWith"), "{calls:?}");
assert!(calls[1].contains("New-NetNat"), "{calls:?}");
// Without a client, the routes of an earlier plan are taken away.
assert!(calls[2].contains("RouteMetric -eq 7373"), "{calls:?}");
assert!(calls[3].contains("Get-NetIPInterface"), "{calls:?}");
}
#[test]
fn a_failing_nat_is_that_ranges_failure() {
let run = |script: &str| -> Result<String, String> {
if script.contains("New-NetNat") {
Err("A NAT with this prefix already exists".into())
} else {
Ok(String::new())
}
};
let report = apply_plan(&run, &no_names, &plan(false));
let RuleOutcome::Failed(why) = &report.offer[0].1 else {
panic!("{report:?}");
};
assert!(why.contains("already exists"), "{why}");
}
fn no_names(_: &str) -> Vec<Ipv4Addr> {
Vec::new()
}
fn client_plan() -> ExitHostPlan {
ExitHostPlan {
interface: "tsun0".into(),
offer: Vec::new(),
client: true,
overlay: vec![range()],
bypass: vec![Ipv4Addr::new(138, 201, 61, 182)],
bypass_hosts: vec!["relay.example".into()],
}
}
#[test]
fn the_gateway_is_read_from_the_script_output() {
assert_eq!(
parse_gateway("12 192.168.1.1\r\n"),
Some((12, Ipv4Addr::new(192, 168, 1, 1)))
);
assert_eq!(parse_gateway(""), None);
assert_eq!(parse_gateway("12"), None);
assert_eq!(parse_gateway("12 gw"), None);
assert_eq!(parse_gateway("12 1.1.1.1 extra"), None);
let script = gateway_script("tsun0");
assert!(script.contains("-ne $alias"));
assert!(script.contains("-ne '0.0.0.0'"));
}
#[test]
fn bypass_routes_are_marked_host_routes_through_the_physical_gateway() {
let script = bypass_script(
12,
Ipv4Addr::new(192, 168, 1, 1),
&[Ipv4Addr::new(1, 2, 3, 4), Ipv4Addr::new(5, 6, 7, 8)],
);
assert!(script.contains("@('1.2.3.4', '5.6.7.8')"), "{script}");
assert!(script.contains("-InterfaceIndex 12 -NextHop '192.168.1.1'"));
assert!(script.contains("-RouteMetric 7373"));
assert!(script.contains("-PolicyStore ActiveStore"));
// Stale marked routes go first, and only /32 ones with our metric.
assert!(script.find("Remove-NetRoute").unwrap() < script.find("New-NetRoute").unwrap());
assert!(script.contains("RouteMetric -eq 7373"));
assert!(script.contains("EndsWith('/32')"));
}
#[test]
fn the_halves_go_through_the_overlay_and_ipv6_is_blackholed() {
let script = halves_script("tsun0");
assert!(script.contains("'0.0.0.0/1', '128.0.0.0/1'"));
assert!(script.contains("-InterfaceAlias $alias -NextHop '0.0.0.0'"));
assert!(script.contains("'::/1', '8000::/1'"));
assert!(script.contains("-PolicyStore ActiveStore"));
assert!(
!script.contains("0.0.0.0/0"),
"the default route is left alone"
);
assert!(halves_script("x'y").contains("'x''y'"));
}
#[test]
fn addresses_are_resolved_merged_and_cleaned() {
let resolve = |host: &str| -> Vec<Ipv4Addr> {
match host {
"relay.example" => vec![Ipv4Addr::new(9, 9, 9, 9), Ipv4Addr::new(1, 2, 3, 4)],
_ => Vec::new(),
}
};
let all = bypass_addresses(
&resolve,
&[
Ipv4Addr::new(1, 2, 3, 4),
Ipv4Addr::LOCALHOST,
Ipv4Addr::UNSPECIFIED,
],
&["relay.example".into(), "gone.example".into()],
);
assert_eq!(
all,
vec![Ipv4Addr::new(1, 2, 3, 4), Ipv4Addr::new(9, 9, 9, 9)]
);
}
#[test]
fn the_bypass_is_written_before_the_halves_that_need_it() {
let calls = RefCell::new(Vec::new());
let run = |script: &str| -> Result<String, String> {
calls.borrow_mut().push(script.to_string());
Ok(if script.contains("Select-Object -First 1") {
"12 192.168.1.1\n".to_string()
} else {
String::new()
})
};
let resolve = |_: &str| vec![Ipv4Addr::new(9, 9, 9, 9)];
let report = apply_plan(&run, &resolve, &client_plan());
assert_eq!(report.client, Some(RuleOutcome::Applied));
let calls = calls.borrow();
let at = |needle: &str| calls.iter().position(|c| c.contains(needle)).unwrap();
assert!(at("Select-Object -First 1") < at("-RouteMetric 7373"));
assert!(at("-RouteMetric 7373") < at("'0.0.0.0/1', '128.0.0.0/1'"));
let bypass = &calls[at("-RouteMetric 7373")];
assert!(
bypass.contains("'9.9.9.9'") && bypass.contains("'138.201.61.182'"),
"{bypass}"
);
}
#[test]
fn nothing_goes_through_the_overlay_when_the_bypass_cannot_be_written() {
let run = |script: &str| -> Result<String, String> {
if script.contains("Select-Object -First 1") {
Ok("12 192.168.1.1".to_string())
} else if script.contains("-RouteMetric 7373") && script.contains("foreach ($ip") {
Err("Access is denied".to_string())
} else {
assert!(!script.contains("-NextHop '0.0.0.0'"), "{script}");
Ok(String::new())
}
};
let report = apply_plan(&run, &no_names, &client_plan());
assert_eq!(
report.client,
Some(RuleOutcome::Failed("Access is denied".into()))
);
}
#[test]
fn a_missing_gateway_is_a_failure_that_installs_no_route() {
let run = |script: &str| -> Result<String, String> {
assert!(!script.contains("New-NetRoute"), "{script}");
if script.contains("Select-Object -First 1") {
Err("the host has no default gateway outside the overlay".into())
} else {
Ok(String::new())
}
};
let report = apply_plan(&run, &no_names, &client_plan());
let Some(RuleOutcome::Failed(why)) = report.client else {
panic!("{report:?}");
};
assert!(why.contains("no default gateway"), "{why}");
}
#[test]
fn a_failed_halves_step_removes_what_was_written() {
let calls = RefCell::new(Vec::new());
let run = |script: &str| -> Result<String, String> {
calls.borrow_mut().push(script.to_string());
if script.contains("Select-Object -First 1") {
Ok("12 192.168.1.1".to_string())
} else if script.contains("'128.0.0.0/1'") && script.contains("New-NetRoute") {
Err("boom".to_string())
} else {
Ok(String::new())
}
};
let report = apply_plan(&run, &no_names, &client_plan());
assert!(matches!(report.client, Some(RuleOutcome::Failed(_))));
assert!(calls.borrow().last().unwrap().contains("Remove-NetRoute"));
}
#[test]
fn dropping_the_client_removes_its_routes_and_clearing_does_too() {
let calls = RefCell::new(Vec::new());
let run = |script: &str| -> Result<String, String> {
calls.borrow_mut().push(script.to_string());
Ok(String::new())
};
let report = apply_plan(&run, &no_names, &plan(false));
assert_eq!(report.client, None);
assert!(
calls
.borrow()
.iter()
.any(|c| c.contains("RouteMetric -eq 7373"))
);
let script = remove_client_script("tsun0");
assert!(script.contains("'0.0.0.0/1', '128.0.0.0/1', '::/1', '8000::/1'"));
assert!(script.contains("exit 0"));
// Only routes on our interface, or marked ours, are touched.
assert!(script.contains("-InterfaceAlias $alias"));
calls.borrow_mut().clear();
clear_all(&run, "tsun0");
assert!(
calls
.borrow()
.iter()
.any(|c| c.contains("RouteMetric -eq 7373"))
);
}
#[test]
fn clearing_prunes_everything_for_the_interface() {
let seen = RefCell::new(Vec::<String>::new());
let run = |script: &str| -> Result<String, String> {
seen.borrow_mut().push(script.to_string());
Ok(String::new())
};
clear_all(&run, "tsun0");
let seen = seen.borrow();
assert!(seen[0].contains("$keep = @()"));
assert!(seen[0].contains("'tsunagi-exit:tsun0:'"));
}
}
+251 -6
View File
@@ -145,18 +145,19 @@ impl Tally {
} }
/// The overlay interface. /// The overlay interface.
#[derive(Debug)]
pub struct Interface { pub struct Interface {
device: Arc<dyn TunDevice>, device: Arc<dyn TunDevice>,
factory: Arc<dyn TunFactory>, factory: Arc<dyn TunFactory>,
name: String, name: String,
mtu: u32, mtu: u32,
routes: Arc<RoutingTable>, routes: Arc<RoutingTable>,
carrier: Arc<dyn PacketCarrier>,
tally: Arc<Tally>, tally: Arc<Tally>,
/// What was last applied to the host, so an unchanged table is not /// What was last applied to the host, so an unchanged table is not
/// re-applied on every pass. /// re-applied on every pass.
applied: std::sync::Mutex<Vec<Cidr>>, applied: std::sync::Mutex<Vec<Cidr>>,
task: std::sync::Mutex<Option<JoinHandle<()>>>, task: std::sync::Mutex<Option<JoinHandle<()>>>,
vip_task: std::sync::Mutex<Option<JoinHandle<()>>>,
sync_lock: Arc<tokio::sync::Mutex<()>>, sync_lock: Arc<tokio::sync::Mutex<()>>,
/// The host rules for this interface, when the factory manages the host. /// The host rules for this interface, when the factory manages the host.
host_rules: Option<Arc<dyn BroadcastHostRules>>, host_rules: Option<Arc<dyn BroadcastHostRules>>,
@@ -164,6 +165,17 @@ pub struct Interface {
rules_report: std::sync::Mutex<Option<BroadcastRulesReport>>, rules_report: std::sync::Mutex<Option<BroadcastRulesReport>>,
/// What the exit-node rules were last asked for, and what came of it. /// What the exit-node rules were last asked for, and what came of it.
exit_state: std::sync::Mutex<ExitState>, exit_state: std::sync::Mutex<ExitState>,
/// Where each network's runtime reaches its peers and relays outside the
/// overlay; read only by hosts that cannot exempt the agent from the
/// exit node's default route.
underlay: std::sync::Mutex<std::collections::HashMap<NetworkId, Underlay>>,
}
/// Underlay destinations one network's runtime currently depends on.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct Underlay {
addresses: std::collections::BTreeSet<std::net::Ipv4Addr>,
hosts: std::collections::BTreeSet<String>,
} }
/// The exit-node host rules as this interface last left them. /// The exit-node host rules as this interface last left them.
@@ -189,6 +201,14 @@ impl ExitState {
} }
} }
impl std::fmt::Debug for Interface {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Interface")
.field("name", &self.name)
.finish_non_exhaustive()
}
}
impl Interface { impl Interface {
/// Creates the interface and starts moving packets. /// Creates the interface and starts moving packets.
pub async fn start( pub async fn start(
@@ -210,7 +230,29 @@ impl Interface {
let name = device.name().to_string(); let name = device.name().to_string();
let tally = Arc::new(Tally::default()); let tally = Arc::new(Tally::default());
let vip_task = {
let routes = Arc::clone(&routes);
let carrier = Arc::clone(&carrier);
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_millis(250));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut checks = tokio::task::JoinSet::new();
loop {
tokio::select! {
_=interval.tick()=>{
let (probes,jobs)=routes.vips.tick(routes.vips.check_capacity().saturating_sub(checks.len()));
for (route,packet) in probes {carrier.carry(route,packet);}
for job in jobs {checks.spawn(job.run());}
},
Some(result)=checks.join_next(),if !checks.is_empty()=>{
if let Ok((job,ok))=result {routes.vips.checked(job,ok);}
}
}
}
})
};
let task = { let task = {
let carrier = Arc::clone(&carrier);
let device = Arc::clone(&device); let device = Arc::clone(&device);
let routes = Arc::clone(&routes); let routes = Arc::clone(&routes);
let tally = Arc::clone(&tally); let tally = Arc::clone(&tally);
@@ -220,6 +262,26 @@ impl Interface {
tally.malformed.fetch_add(1, Ordering::Relaxed); tally.malformed.fetch_add(1, Ordering::Relaxed);
continue; continue;
}; };
match routes.vips.outgoing(packet.clone()) {
crate::vip::router::Decision::Send(route, packet) => {
for part in crate::vip::packet::fragments(packet, mtu) {
if carrier.carry(route, part) {
tally.sent.fetch_add(1, Ordering::Relaxed);
} else {
tally.undeliverable.fetch_add(1, Ordering::Relaxed);
}
}
continue;
}
crate::vip::router::Decision::Local(packet) => {
for part in crate::vip::packet::fragments(packet, mtu) {
let _ = device.send(part).await;
}
continue;
}
crate::vip::router::Decision::Drop => continue,
crate::vip::router::Decision::Pass => {}
}
let destination = header.destination(); let destination = header.destination();
if let (IpAddr::V4(source), IpAddr::V4(destination)) = if let (IpAddr::V4(source), IpAddr::V4(destination)) =
(header.source(), destination) (header.source(), destination)
@@ -268,13 +330,16 @@ impl Interface {
name, name,
mtu, mtu,
routes, routes,
carrier,
tally, tally,
applied: std::sync::Mutex::new(Vec::new()), applied: std::sync::Mutex::new(Vec::new()),
task: std::sync::Mutex::new(Some(task)), task: std::sync::Mutex::new(Some(task)),
vip_task: std::sync::Mutex::new(Some(vip_task)),
sync_lock: Arc::new(tokio::sync::Mutex::new(())), sync_lock: Arc::new(tokio::sync::Mutex::new(())),
host_rules, host_rules,
rules_report: std::sync::Mutex::new(None), rules_report: std::sync::Mutex::new(None),
exit_state: std::sync::Mutex::new(ExitState::default()), exit_state: std::sync::Mutex::new(ExitState::default()),
underlay: std::sync::Mutex::new(std::collections::HashMap::new()),
}) })
} }
@@ -350,10 +415,20 @@ impl Interface {
.collect(); .collect();
ranges.sort_by_key(|range| (range.base, range.prefix_len)); ranges.sort_by_key(|range| (range.base, range.prefix_len));
ranges.dedup(); ranges.dedup();
let client = self.routes.exit_via().is_some();
let overlay = self.routes.overlay_ranges();
let (bypass, bypass_hosts) = if client && exit.needs_bypass() {
self.underlay_targets(&overlay)
} else {
(Vec::new(), Vec::new())
};
let wanted = ExitHostPlan { let wanted = ExitHostPlan {
interface: self.name.clone(), interface: self.name.clone(),
offer: ranges, offer: ranges,
client: self.routes.exit_via().is_some(), client,
overlay,
bypass,
bypass_hosts,
}; };
let first = !std::mem::replace(&mut self.exit_state().cleaned, true); let first = !std::mem::replace(&mut self.exit_state().cleaned, true);
@@ -375,6 +450,14 @@ impl Interface {
return; return;
} }
if planned.as_ref() == Some(&wanted) && ok { if planned.as_ref() == Some(&wanted) && ok {
if let Some(outcome) = exit.refresh(&wanted).await {
if let RuleOutcome::Failed(reason) = &outcome {
tracing::warn!(interface = %self.name, %reason, "cannot refresh exit-client socket protection");
}
if let Some(report) = self.exit_state().report.as_mut() {
report.client = Some(outcome);
}
}
return; return;
} }
let report = exit.apply(&wanted).await; let report = exit.apply(&wanted).await;
@@ -398,9 +481,15 @@ impl Interface {
tracing::warn!( tracing::warn!(
interface = %self.name, interface = %self.name,
"kernel forwarding is off for this interface, so nothing is forwarded for the \ "kernel forwarding is off for this interface, so nothing is forwarded for the \
exit node yet: `sysctl -w net.ipv4.conf.{0}.forwarding=1` (or \ exit node yet: {}",
`net.ipv4.ip_forward=1`), and set it in sysctl.d to keep it", if cfg!(target_os = "windows") {
self.name "`Set-NetIPInterface -Forwarding Enabled` on the interfaces involved"
} else if cfg!(target_os = "macos") {
"`sysctl -w net.inet.ip.forwarding=1`, set again after every reboot"
} else {
"`sysctl -w net.ipv4.ip_forward=1` (or `net.ipv4.conf.<interface>.forwarding=1`), \
and set it in sysctl.d to keep it"
}
); );
} }
match &report.client { match &report.client {
@@ -419,6 +508,68 @@ impl Interface {
state.report = Some(report); state.report = Some(report);
} }
/// Tells the interface where a network's runtime reaches peers and
/// relays outside the overlay. When the host needs those kept out of the
/// exit node's default route and the set changed, the rules follow.
pub async fn set_underlay(
&self,
network: NetworkId,
addresses: impl IntoIterator<Item = std::net::Ipv4Addr>,
hosts: impl IntoIterator<Item = String>,
) {
let new = Underlay {
addresses: addresses.into_iter().collect(),
hosts: hosts.into_iter().collect(),
};
let changed = {
let mut map = match self.underlay.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if new == Underlay::default() {
map.remove(&network).is_some()
} else {
map.insert(network, new.clone()) != Some(new)
}
};
let needed = changed
&& self.routes.exit_via().is_some()
&& self
.host_rules
.as_ref()
.and_then(|rules| rules.exit_rules())
.is_some_and(|exit| exit.needs_bypass());
if needed {
self.sync_exit_rules().await;
}
}
/// Every underlay destination worth keeping direct, minus anything that
/// is the overlay itself, loopback or link-local.
fn underlay_targets(
&self,
overlay: &[crate::state::Ipv4Range],
) -> (Vec<std::net::Ipv4Addr>, Vec<String>) {
let map = match self.underlay.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let mut addresses = std::collections::BTreeSet::new();
let mut hosts = std::collections::BTreeSet::new();
for underlay in map.values() {
addresses.extend(underlay.addresses.iter().copied().filter(|address| {
!address.is_loopback()
&& !address.is_link_local()
&& !address.is_multicast()
&& !address.is_unspecified()
&& !address.is_broadcast()
&& !overlay.iter().any(|range| range.contains(*address))
}));
hosts.extend(underlay.hosts.iter().cloned());
}
(addresses.into_iter().collect(), hosts.into_iter().collect())
}
fn exit_state(&self) -> std::sync::MutexGuard<'_, ExitState> { fn exit_state(&self) -> std::sync::MutexGuard<'_, ExitState> {
match self.exit_state.lock() { match self.exit_state.lock() {
Ok(guard) => guard, Ok(guard) => guard,
@@ -443,7 +594,7 @@ impl Interface {
"this overlay interface is not on the host (no TUN device), so there is \ "this overlay interface is not on the host (no TUN device), so there is \
nothing to forward to", nothing to forward to",
), ),
Some(false) => Some("exit nodes are only supported on Linux yet"), Some(false) => Some("exit nodes are not supported on this platform yet"),
Some(true) => None, Some(true) => None,
}; };
let failed = |detail: &str| RuleSetReport { let failed = |detail: &str| RuleSetReport {
@@ -618,6 +769,15 @@ impl Interface {
task.abort(); task.abort();
let _ = task.await; let _ = task.await;
} }
let vip_task = self
.vip_task
.lock()
.unwrap_or_else(|e| e.into_inner())
.take();
if let Some(task) = vip_task {
task.abort();
let _ = task.await;
}
self.factory.destroy(self.device.name()).await; self.factory.destroy(self.device.name()).await;
} }
@@ -672,6 +832,24 @@ impl Interface {
return Err(Rejected::WrongDestination); return Err(Rejected::WrongDestination);
} }
}; };
// Source ownership was checked before any VIP reverse translation.
let packet = match self.routes.vips.incoming(network, peer, packet.clone()) {
crate::vip::router::Decision::Send(route, packet) => {
// Probe replies use the ordinary peer carrier, never the TUN.
self.carrier.carry(route, packet);
return Ok(());
}
crate::vip::router::Decision::Local(packet) => {
for part in crate::vip::packet::fragments(packet, self.mtu) {
if self.device.send(part).await.is_ok() {
self.tally.received.fetch_add(1, Ordering::Relaxed);
}
}
return Ok(());
}
crate::vip::router::Decision::Drop => return Ok(()),
crate::vip::router::Decision::Pass => packet,
};
// Remote broadcasts terminate here. Only local TUN ingress can fan out. // Remote broadcasts terminate here. Only local TUN ingress can fan out.
if self.device.send(packet).await.is_ok() { if self.device.send(packet).await.is_ok() {
if broadcast { if broadcast {
@@ -696,6 +874,14 @@ impl Interface {
impl Drop for Interface { impl Drop for Interface {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(task) = self
.vip_task
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
{
task.abort();
}
if let Ok(mut guard) = self.task.lock() if let Ok(mut guard) = self.task.lock()
&& let Some(task) = guard.take() && let Some(task) = guard.take()
{ {
@@ -1336,6 +1522,65 @@ mod tests {
assert_eq!(interface.counters().received, 1); assert_eq!(interface.counters().received, 1);
} }
#[tokio::test]
async fn the_underlay_reaches_the_rules_only_for_hosts_that_need_it_and_only_while_using_one() {
let rules = MockHostRules::new();
let (interface, routes, id) = managed_interface(&rules, false).await;
let relay = || vec!["relay.example".to_string()];
let direct = || {
vec![
Ipv4Addr::new(138, 201, 61, 182),
Ipv4Addr::new(10, 13, 37, 9), // the overlay itself
Ipv4Addr::LOCALHOST,
Ipv4Addr::new(169, 254, 1, 1),
]
};
// A host that does not ask never gets them, so Linux and macOS do not
// re-apply their rules whenever a path moves.
routes.set_exit(id, using(peer(2)));
interface.set_underlay(id, direct(), relay()).await;
interface.sync_exit_rules().await;
let plan = rules.exit().installed().unwrap();
assert!(plan.client);
assert!(plan.bypass.is_empty() && plan.bypass_hosts.is_empty());
// A host that does gets the usable ones, and follows changes.
rules.exit().set_needs_bypass(true);
interface.set_underlay(id, direct(), relay()).await;
interface.sync_exit_rules().await;
let plan = rules.exit().installed().unwrap();
assert_eq!(plan.bypass, vec![Ipv4Addr::new(138, 201, 61, 182)]);
assert_eq!(plan.bypass_hosts, relay());
let applies = || {
rules
.exit()
.calls()
.iter()
.filter(|c| c.starts_with("apply"))
.count()
};
let before = applies();
interface.set_underlay(id, direct(), relay()).await;
assert_eq!(applies(), before, "an unchanged set is not applied again");
interface
.set_underlay(id, vec![Ipv4Addr::new(5, 6, 7, 8)], relay())
.await;
assert_eq!(applies(), before + 1);
assert_eq!(
rules.exit().installed().unwrap().bypass,
vec![Ipv4Addr::new(5, 6, 7, 8)]
);
// Not using an exit node: nothing to keep direct.
routes.set_exit(id, Default::default());
interface.sync_exit_rules().await;
interface
.set_underlay(id, vec![Ipv4Addr::new(1, 1, 1, 1)], relay())
.await;
assert!(rules.exit().installed().is_none());
}
#[tokio::test] #[tokio::test]
async fn the_exit_rules_follow_the_routing_table_and_leave_with_the_interface() { async fn the_exit_rules_follow_the_routing_table_and_leave_with_the_interface() {
let rules = MockHostRules::new(); let rules = MockHostRules::new();
@@ -55,6 +55,7 @@ use crate::BoxFuture;
use crate::overlay::OverlayError; use crate::overlay::OverlayError;
use super::super::config::Cidr; use super::super::config::Cidr;
use super::super::hostrules::linux_exit_mark::{UNDERLAY_MARK, UNDERLAY_MASK};
use super::super::tun::{TunDevice, TunRequest}; use super::super::tun::{TunDevice, TunRequest};
use super::privilege::{NetAdmin, Privilege, probe_net_admin}; use super::privilege::{NetAdmin, Privilege, probe_net_admin};
use super::{ use super::{
@@ -641,8 +642,10 @@ async fn del_broadcast_route(name: &str) -> Result<(), OverlayError> {
const EXIT_TABLE: u32 = 0x7473; const EXIT_TABLE: u32 = 0x7473;
/// The main table. /// The main table.
const MAIN_TABLE: u32 = 254; const MAIN_TABLE: u32 = 254;
/// Where the exit rules sit, after Tailscale's 5270. /// Protect marked underlay and initial socket routing before Tailscale's rules.
const EXIT_BYPASS_PRIORITY: u32 = 5280; const EXIT_MARK_PRIORITY: u32 = 5205;
const EXIT_BYPASS_PRIORITY: u32 = 5206;
const LEGACY_EXIT_BYPASS_PRIORITY: u32 = 5280;
const EXIT_SPECIFIC_PRIORITY: u32 = 5290; const EXIT_SPECIFIC_PRIORITY: u32 = 5290;
const EXIT_DEFAULT_PRIORITY: u32 = 5300; const EXIT_DEFAULT_PRIORITY: u32 = 5300;
@@ -666,14 +669,23 @@ fn rule_priority(rule: &RuleMessage) -> Option<u32> {
}) })
} }
/// Whether a rule is one of the three this agent adds for an exit node. /// Whether a rule is one this agent adds for an exit node, including old UID priority.
/// ///
/// Recognised by shape as well as priority: those numbers are not reserved, /// Recognised by shape as well as priority: those numbers are not reserved,
/// and a rule somebody else put at one of them must not be removed. /// and a rule somebody else put at one of them must not be removed.
fn is_exit_rule(rule: &RuleMessage) -> bool { fn is_exit_rule(rule: &RuleMessage) -> bool {
let table = rule_table(rule); let table = rule_table(rule);
match rule_priority(rule) { match rule_priority(rule) {
Some(EXIT_BYPASS_PRIORITY) => { Some(EXIT_MARK_PRIORITY) => {
table == MAIN_TABLE
&& rule
.attributes
.contains(&RuleAttribute::FwMark(UNDERLAY_MARK))
&& rule
.attributes
.contains(&RuleAttribute::FwMask(UNDERLAY_MASK))
}
Some(EXIT_BYPASS_PRIORITY | LEGACY_EXIT_BYPASS_PRIORITY) => {
table == MAIN_TABLE table == MAIN_TABLE
&& rule && rule
.attributes .attributes
@@ -789,7 +801,8 @@ async fn remove_exit_client(handle: &rtnetlink::Handle) -> Result<(), OverlayErr
/// ///
/// ```text /// ```text
/// ip route replace default dev <if> table 0x7473 /// ip route replace default dev <if> table 0x7473
/// ip rule add priority 5280 uidrange <uid>-<uid> lookup main /// ip rule add priority 5205 fwmark 0x40000000/0x40000000 lookup main
/// ip rule add priority 5206 uidrange <uid>-<uid> lookup main
/// ip rule add priority 5290 lookup main suppress_prefixlength 0 /// ip rule add priority 5290 lookup main suppress_prefixlength 0
/// ip rule add priority 5300 lookup 0x7473 /// ip rule add priority 5300 lookup 0x7473
/// ``` /// ```
@@ -819,6 +832,8 @@ async fn set_exit_client(name: &str, uid: u32) -> Result<(), OverlayError> {
.await .await
.map_err(|err| fail("default route", err))?; .map_err(|err| fail("default route", err))?;
add_mark_bypass(&handle, false).await?;
let mut bypass = handle let mut bypass = handle
.rule() .rule()
.add() .add()
@@ -891,6 +906,7 @@ async fn set_exit_block_v6(handle: &rtnetlink::Handle, uid: u32) -> Result<(), O
}; };
} }
add_mark_bypass(handle, true).await?;
let mut bypass = handle let mut bypass = handle
.rule() .rule()
.add() .add()
@@ -938,6 +954,27 @@ async fn set_exit_block_v6(handle: &rtnetlink::Handle, uid: u32) -> Result<(), O
.map_err(|err| fail("rule for the default route", err)) .map_err(|err| fail("rule for the default route", err))
} }
async fn add_mark_bypass(handle: &rtnetlink::Handle, v6: bool) -> Result<(), OverlayError> {
let mut rule = handle
.rule()
.add()
.table_id(MAIN_TABLE)
.action(RuleAction::ToTable)
.priority(EXIT_MARK_PRIORITY)
.fw_mark(UNDERLAY_MARK);
rule.message_mut().header.family = if v6 {
rtnetlink::packet_route::AddressFamily::Inet6
} else {
rtnetlink::packet_route::AddressFamily::Inet
};
rule.message_mut()
.attributes
.push(RuleAttribute::FwMask(UNDERLAY_MASK));
rule.execute().await.map_err(|err| {
OverlayError::Unavailable(format!("cannot protect marked underlay traffic: {err}"))
})
}
async fn configure_link(configure: &Configure) -> Result<(), OverlayError> { async fn configure_link(configure: &Configure) -> Result<(), OverlayError> {
let name = configure.name.as_str(); let name = configure.name.as_str();
with_netlink(|handle| async move { with_netlink(|handle| async move {
@@ -1110,6 +1147,28 @@ mod tests {
assert!(!is_exit_rule(&rule(5280, 100, vec![uid]))); assert!(!is_exit_rule(&rule(5280, 100, vec![uid])));
} }
#[test]
fn underlay_bypass_recognises_only_our_exact_bit_and_mask() {
let rule = |table, mark, mask| {
let mut message = RuleMessage::default();
message.attributes.extend([
RuleAttribute::Table(table),
RuleAttribute::Priority(EXIT_MARK_PRIORITY),
RuleAttribute::FwMark(mark),
RuleAttribute::FwMask(mask),
]);
message
};
assert!(is_exit_rule(&rule(
MAIN_TABLE,
UNDERLAY_MARK,
UNDERLAY_MASK
)));
assert!(!is_exit_rule(&rule(MAIN_TABLE, 0x80000, 0xff0000)));
assert!(!is_exit_rule(&rule(MAIN_TABLE, UNDERLAY_MARK, u32::MAX)));
assert!(!is_exit_rule(&rule(52, UNDERLAY_MARK, UNDERLAY_MASK)));
}
#[test] #[test]
fn the_ipv6_default_of_the_exit_table_refuses_instead_of_routing() { fn the_ipv6_default_of_the_exit_table_refuses_instead_of_routing() {
let route = exit_blocking_route_v6(); let route = exit_blocking_route_v6();
+35 -2
View File
@@ -72,7 +72,7 @@ pub enum RouteError {
#[error( #[error(
"network {network} uses {range}, which overlaps {other_range} already in use by \ "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 \ {other}. One agent has one interface, so an address can belong to only one of \
them: give one network a different range." them: stop the other network before starting this one."
)] )]
Overlap { Overlap {
/// The network being added. /// The network being added.
@@ -89,6 +89,7 @@ pub enum RouteError {
/// Address ownership across every network this agent is in. /// Address ownership across every network this agent is in.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct RoutingTable { pub struct RoutingTable {
pub(crate) vips: Arc<crate::vip::VipRouter>,
broadcast: ArcSwap<BroadcastTable>, broadcast: ArcSwap<BroadcastTable>,
networks: RwLock<HashMap<NetworkId, NetworkRoutes>>, networks: RwLock<HashMap<NetworkId, NetworkRoutes>>,
} }
@@ -146,6 +147,7 @@ impl RoutingTable {
/// Forgets a network. /// Forgets a network.
pub fn remove_network(&self, network: NetworkId) { pub fn remove_network(&self, network: NetworkId) {
self.vips.remove_network(network);
let mut networks = self.write(); let mut networks = self.write();
networks.remove(&network); networks.remove(&network);
self.broadcast self.broadcast
@@ -293,6 +295,18 @@ impl RoutingTable {
offers offers
} }
/// The range of every network in the table that has agreed one, sorted.
pub fn overlay_ranges(&self) -> Vec<Ipv4Range> {
let mut ranges: Vec<Ipv4Range> = self
.read()
.values()
.filter_map(|routes| routes.range)
.collect();
ranges.sort_by_key(|range| (range.base, range.prefix_len));
ranges.dedup();
ranges
}
/// One network's exit-node settings and its range, if it is in the table. /// One network's exit-node settings and its range, if it is in the table.
pub fn exit_settings(&self, network: NetworkId) -> Option<(ExitPolicy, Option<Ipv4Range>)> { pub fn exit_settings(&self, network: NetworkId) -> Option<(ExitPolicy, Option<Ipv4Range>)> {
self.read() self.read()
@@ -353,6 +367,25 @@ impl RoutingTable {
}) })
} }
/// All active networks whose reserved ranges intersect this range.
pub fn overlapping_networks(
&self,
network: NetworkId,
range: Ipv4Range,
) -> Vec<(NetworkId, Ipv4Range)> {
let mut conflicts: Vec<_> = self
.read()
.iter()
.filter_map(|(other, routes)| {
let other_range = routes.range?;
(*other != network && ranges_overlap(range, other_range))
.then_some((*other, other_range))
})
.collect();
conflicts.sort_by_key(|(id, _)| *id);
conflicts
}
/// How many networks the table covers. /// How many networks the table covers.
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.read().len() self.read().len()
@@ -498,7 +531,7 @@ mod tests {
let err = table.set_network(second, routes()).unwrap_err(); let err = table.set_network(second, routes()).unwrap_err();
let RouteError::Overlap { other, .. } = err; let RouteError::Overlap { other, .. } = err;
assert_eq!(other, first); assert_eq!(other, first);
assert!(err.to_string().contains("different range"), "{err}"); assert!(err.to_string().contains("stop the other network"), "{err}");
assert_eq!(table.len(), 1, "the overlapping network is not added"); assert_eq!(table.len(), 1, "the overlapping network is not added");
// A range of its own is fine. // A range of its own is fine.
+73 -5
View File
@@ -20,7 +20,7 @@ use crate::error::ProtocolError;
/// The version in the ALPN is the wire-compatibility version of the control /// The version in the ALPN is the wire-compatibility version of the control
/// protocol. It is independent of the network identity scheme version, so /// protocol. It is independent of the network identity scheme version, so
/// bumping it must not change any existing [`crate::NetworkId`]. /// bumping it must not change any existing [`crate::NetworkId`].
pub const ALPN: &[u8] = b"tsunagi/ctrl/4"; pub const ALPN: &[u8] = b"tsunagi/ctrl/8";
/// ALPN of the tsunagi data plane. /// ALPN of the tsunagi data plane.
/// ///
@@ -30,9 +30,13 @@ pub const ALPN: &[u8] = b"tsunagi/ctrl/4";
/// transport underneath can be replaced without touching the control protocol. /// transport underneath can be replaced without touching the control protocol.
/// ///
/// Version 4 adds source, destination, hop limit and stable flow id above /// Version 4 adds source, destination, hop limit and stable flow id above
/// transport fragmentation. All members must upgrade together. Persistent /// transport fragmentation. Version 5 adds the end-to-end protocol to that
/// identities and network configuration do not change. /// envelope, so a tunnel no longer depends on the kind of link under it, and
pub const DATA_ALPN: &[u8] = b"tsunagi/data/4"; /// a first-claimed time to a signed hostname claim. All members must upgrade
/// together. Version 6 uses one fixed inner WireGuard channel and independent
/// external carrier capabilities. Control authentication, persistent identities
/// and network configuration do not change.
pub const DATA_ALPN: &[u8] = b"tsunagi/data/6";
/// Largest plugin protocol identifier accepted when opening a data channel. /// Largest plugin protocol identifier accepted when opening a data channel.
pub const MAX_DATA_PROTOCOL_LEN: usize = 32; pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
@@ -41,7 +45,9 @@ pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
pub const MAX_SIGNATURE_LEN: usize = 64; pub const MAX_SIGNATURE_LEN: usize = 64;
/// Control protocol version carried inside the handshake. /// Control protocol version carried inside the handshake.
pub const PROTOCOL_VERSION: u16 = 4; pub const PROTOCOL_VERSION: u16 = 8;
pub use crate::dataplane::transport::{MAX_TRANSPORT_PAYLOAD, TransportPayload};
/// First message of the handshake, sent by the initiator. /// First message of the handshake, sent by the initiator.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -112,6 +118,13 @@ pub struct DataOpenAck {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive] #[non_exhaustive]
pub enum ControlMessage { pub enum ControlMessage {
/// Private transport negotiation; never persisted or rebroadcast.
Transport {
/// Transport protocol id; the payload stays opaque to the core.
protocol: String,
/// Secret negotiation data, redacted in diagnostics.
payload: TransportPayload,
},
/// Hostname and capability announcement. /// Hostname and capability announcement.
Announce(Announcement), Announce(Announcement),
/// A small request used to verify that the exchange works. /// A small request used to verify that the exchange works.
@@ -170,6 +183,16 @@ pub enum ControlMessage {
/// Short free-text reason. /// Short free-text reason.
reason: String, reason: String,
}, },
/// One complete signed VIP field snapshot, merged atomically.
Vips {
/// Signed registers bounded by the per-VIP field limit.
entries: Vec<crate::vip::VipEntry>,
},
/// Signed cleanup markers, each proving the retired claim version.
Retirements {
/// Bounded by the per-message signed-state limit.
markers: Vec<crate::state::SignedRetirement>,
},
} }
/// Somewhere a member has been seen. /// Somewhere a member has been seen.
@@ -262,6 +285,48 @@ pub fn validate_capability(
/// network, the other networks or the agent. /// network, the other networks or the agent.
pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), ProtocolError> { pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), ProtocolError> {
match message { match message {
ControlMessage::Vips { entries } => {
check_len("vip.fields", entries.len(), limits.max_vip_fields)?;
for e in entries {
check_len("vip.id", e.vip.len(), 32)?;
check_len("vip.key", e.key.len(), 160)?;
check_len("vip.value", e.value.as_ref().map_or(0, Vec::len), 256)?;
check_len("vip.signature", e.signature.len(), 64)?;
}
}
ControlMessage::Retirements { markers } => {
check_len("retirements", markers.len(), limits.max_state_records)?;
for marker in markers {
check_len(
"retirement.signature",
marker.signature.len(),
MAX_SIGNATURE_LEN,
)?;
check_len(
"retirement.target.signature",
marker.target.signature.len(),
MAX_SIGNATURE_LEN,
)?;
if let Some(name) = marker.target.body.hostname() {
check_len(
"retirement.target.hostname",
name.len(),
limits.max_hostname_len,
)?;
}
}
}
ControlMessage::Transport { protocol, payload } => {
if protocol.is_empty() {
return Err(ProtocolError::Malformed("empty transport protocol"));
}
check_len("transport.protocol", protocol.len(), MAX_PROTOCOL_ID_LEN)?;
check_len(
"transport.payload",
payload.as_bytes().len(),
limits.max_transport_payload_len.min(MAX_TRANSPORT_PAYLOAD),
)?;
}
ControlMessage::Peers { peers } => { ControlMessage::Peers { peers } => {
check_len("peers", peers.len(), limits.max_state_records)?; check_len("peers", peers.len(), limits.max_state_records)?;
for hint in peers { for hint in peers {
@@ -318,10 +383,13 @@ pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), Protoco
/// A short, stable label for a message kind, for metrics and diagnostics. /// A short, stable label for a message kind, for metrics and diagnostics.
pub fn kind(message: &ControlMessage) -> &'static str { pub fn kind(message: &ControlMessage) -> &'static str {
match message { match message {
ControlMessage::Transport { .. } => "transport",
ControlMessage::Announce(_) => "announce", ControlMessage::Announce(_) => "announce",
ControlMessage::Ping { .. } => "ping", ControlMessage::Ping { .. } => "ping",
ControlMessage::Pong { .. } => "pong", ControlMessage::Pong { .. } => "pong",
ControlMessage::State { .. } => "state", ControlMessage::State { .. } => "state",
ControlMessage::Retirements { .. } => "retirements",
ControlMessage::Vips { .. } => "vips",
ControlMessage::Reachable { .. } => "reachable", ControlMessage::Reachable { .. } => "reachable",
ControlMessage::Peers { .. } => "peers", ControlMessage::Peers { .. } => "peers",
ControlMessage::Bye { .. } => "bye", ControlMessage::Bye { .. } => "bye",
+309 -34
View File
@@ -28,16 +28,25 @@
//! replica that has the record needs nothing else to interpret it, and //! replica that has the record needs nothing else to interpret it, and
//! recovery never depends on replaying a chain from the beginning. //! recovery never depends on replaying a chain from the beginning.
//! //!
//! Any member may also sign a separate retirement of another author's proved
//! claim version. It suppresses stale reservations without changing that
//! author's record, and a newer claim remains valid.
//!
//! # What this slice does not do yet //! # What this slice does not do yet
//! //!
//! Compaction, revocation of a whole author, and snapshots covering more than //! Compaction and permanent revocation of a whole author. See
//! one record type. See `docs/sync-model.md` for the shape those take. //! `docs/sync-model.md` for the implemented cleanup semantics.
pub mod allocator; pub mod allocator;
/// Signed shared virtual service state and its merge rules.
pub mod vip;
use std::collections::HashMap; use std::collections::HashMap;
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
mod retirement;
pub use retirement::{MAX_RETIREMENT_LEN, SignedRetirement};
use iroh::{EndpointId, SecretKey, Signature}; use iroh::{EndpointId, SecretKey, Signature};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -109,12 +118,8 @@ impl std::str::FromStr for Ipv4Range {
} }
} }
/// The IPv4 overlay range used unless something else is configured or agreed. /// The legacy default range, retained for existing networks and explicit use.
/// /// New networks use [`derived_ipv4_range`] by default.
/// A small, specific `/24`: memorable, and far less likely to overlap a
/// network the machine is already on than taking a whole `/8` or `/10` would
/// be. Because addresses are allocated rather than derived, 254 of them is
/// plenty for the size of network this is for.
pub const DEFAULT_IPV4_RANGE: Ipv4Range = Ipv4Range { pub const DEFAULT_IPV4_RANGE: Ipv4Range = Ipv4Range {
base: Ipv4Addr::new(10, 13, 37, 0), base: Ipv4Addr::new(10, 13, 37, 0),
prefix_len: 24, prefix_len: 24,
@@ -122,15 +127,10 @@ pub const DEFAULT_IPV4_RANGE: Ipv4Range = Ipv4Range {
/// A range derived from a network's own identity. /// A range derived from a network's own identity.
/// ///
/// One agent has one interface, so two of its networks cannot both use the /// Every member independently derives the same pseudorandom `/24` in 10/8,
/// configured range. The second needs another one — and it cannot be picked /// including isolated first members. Stored signed ranges take precedence.
/// locally, because every member has to arrive at the same answer without /// The derivation is kept stable for networks already using it; the legacy
/// being told. So it comes from the network id, which every member already /// default is excluded. Distinct network ids can still produce collisions.
/// has and nobody can choose: the same network derives the same range on
/// every device.
///
/// Inside 10/8 like the default, and never equal to it, so the first
/// network keeps what it has always had.
pub fn derived_ipv4_range(network: NetworkId) -> Ipv4Range { pub fn derived_ipv4_range(network: NetworkId) -> Ipv4Range {
let bytes = network.as_bytes(); let bytes = network.as_bytes();
let second = bytes[0]; let second = bytes[0];
@@ -150,7 +150,7 @@ pub fn derived_ipv4_range(network: NetworkId) -> Ipv4Range {
} }
/// Frozen domain separator for the bytes a record signature covers. /// Frozen domain separator for the bytes a record signature covers.
pub const RECORD_DOMAIN: &str = "tsunagi-signed-record-v2"; pub const RECORD_DOMAIN: &str = "tsunagi-signed-record-v3";
/// Longest hostname a record may carry. /// Longest hostname a record may carry.
/// ///
@@ -207,6 +207,18 @@ pub enum RecordBody {
range: Option<Ipv4Range>, range: Option<Ipv4Range>,
/// The name this author answers to. /// The name this author answers to.
hostname: Option<String>, hostname: Option<String>,
/// When this author first claimed that name, in milliseconds since
/// the Unix epoch, kept across every later version that keeps the
/// name. It is what makes a name belong to whoever claimed it first
/// rather than to whoever happens to sort lowest, so a member that
/// arrives later with the same name cannot take it over.
///
/// The author's own word: a member with a wrong clock can claim an
/// earlier time. That costs it nothing it could not do by other
/// means, since anybody who knows the network secret is a member,
/// and it cannot take an address or reach anything it was not
/// already entitled to.
hostname_since: Option<u64>,
}, },
/// This author gives up everything it claimed. /// This author gives up everything it claimed.
/// ///
@@ -243,6 +255,14 @@ impl RecordBody {
} }
} }
/// When the author first claimed the hostname, if it has one.
pub fn hostname_since(&self) -> Option<u64> {
match self {
RecordBody::Claim { hostname_since, .. } => *hostname_since,
RecordBody::Release => None,
}
}
fn canonical(&self) -> Vec<u8> { fn canonical(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(96); let mut out = Vec::with_capacity(96);
match self { match self {
@@ -250,6 +270,7 @@ impl RecordBody {
address, address,
range, range,
hostname, hostname,
hostname_since,
} => { } => {
push_lp(&mut out, b"claim"); push_lp(&mut out, b"claim");
push_opt( push_opt(
@@ -270,6 +291,13 @@ impl RecordBody {
.as_deref(), .as_deref(),
); );
push_opt(&mut out, hostname.as_deref().map(str::as_bytes)); push_opt(&mut out, hostname.as_deref().map(str::as_bytes));
push_opt(
&mut out,
hostname_since
.map(|since| since.to_be_bytes())
.as_ref()
.map(|bytes| &bytes[..]),
);
} }
RecordBody::Release => { RecordBody::Release => {
push_lp(&mut out, b"release"); push_lp(&mut out, b"release");
@@ -391,8 +419,12 @@ impl SignedRecord {
address, address,
range, range,
hostname, hostname,
hostname_since,
} = &self.body } = &self.body
{ {
if hostname.is_none() && hostname_since.is_some() {
return Err(StateError::Malformed("a time was claimed for no hostname"));
}
// Bounds before anything is believed, because all of this came // Bounds before anything is believed, because all of this came
// off the network. // off the network.
if let Some(range) = range { if let Some(range) = range {
@@ -465,6 +497,7 @@ pub enum Merged {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct StateSet { pub struct StateSet {
records: HashMap<EndpointId, SignedRecord>, records: HashMap<EndpointId, SignedRecord>,
retirements: HashMap<EndpointId, SignedRetirement>,
} }
impl StateSet { impl StateSet {
@@ -477,6 +510,7 @@ impl StateSet {
pub fn from_verified(records: impl IntoIterator<Item = (EndpointId, SignedRecord)>) -> Self { pub fn from_verified(records: impl IntoIterator<Item = (EndpointId, SignedRecord)>) -> Self {
Self { Self {
records: records.into_iter().collect(), records: records.into_iter().collect(),
retirements: HashMap::new(),
} }
} }
@@ -505,6 +539,52 @@ impl StateSet {
.collect() .collect()
} }
/// Visible records, excluding versions retired by authenticated members.
pub fn active_records(&self) -> Vec<SignedRecord> {
self.records()
.into_iter()
.filter(|r| r.author_id().is_ok_and(|id| !self.is_retired(&id)))
.collect()
}
/// Whether the current known version is at or below a proved retirement.
pub fn is_retired(&self, author: &EndpointId) -> bool {
self.retirements
.get(author)
.is_some_and(|m| self.version_of(author) <= m.target.version)
}
/// Strongest retained cleanup proof for an identity.
pub fn retirement_of(&self, target: &EndpointId) -> Option<&SignedRetirement> {
self.retirements.get(target)
}
/// Durable markers in target-id order, including markers surpassed by a fresh claim.
pub fn retirements(&self) -> Vec<SignedRetirement> {
let mut markers: Vec<_> = self.retirements.values().cloned().collect();
markers.sort_by_key(|m| m.target.author);
markers
}
/// Accepts any member's signed cleanup after checking both signatures.
pub fn merge_retirement(
&mut self,
network: NetworkId,
marker: SignedRetirement,
max: usize,
) -> Result<bool, StateError> {
let target = marker.verify(network)?;
if let Some(old) = self.retirements.get(&target) {
if !marker.supersedes(old) {
return Ok(false);
}
} else if self.retirements.len() >= max {
return Err(StateError::Malformed("retirement capacity reached"));
}
self.retirements.insert(target, marker);
Ok(true)
}
/// Merges one record, verifying it first. /// Merges one record, verifying it first.
/// ///
/// Merging is into the existing set, never a wholesale replacement, and an /// Merging is into the existing set, never a wholesale replacement, and an
@@ -568,6 +648,9 @@ impl StateSet {
pub fn address_holders(&self) -> HashMap<Ipv4Addr, EndpointId> { pub fn address_holders(&self) -> HashMap<Ipv4Addr, EndpointId> {
let mut holders: HashMap<Ipv4Addr, EndpointId> = HashMap::new(); let mut holders: HashMap<Ipv4Addr, EndpointId> = HashMap::new();
for (author, record) in &self.records { for (author, record) in &self.records {
if self.is_retired(author) {
continue;
}
let Some(address) = record.body.claimed_address() else { let Some(address) = record.body.claimed_address() else {
continue; continue;
}; };
@@ -585,25 +668,36 @@ impl StateSet {
/// Who currently holds each claimed hostname. /// Who currently holds each claimed hostname.
/// ///
/// The same rule as addresses, for the same reason: a name is owned, two /// A name belongs to the member that claimed it first, and the others
/// members may claim one, and every replica has to reach the same answer /// that claim it keep working by address but are not found by that name.
/// about who has it without asking anybody. /// "First" is the time each author signed into its own claim, with the
/// lower id deciding a tie and a claim with no time counting as last, so
/// every replica reaches the same answer about who has it without
/// asking anybody. It is a pure function of the records: nothing depends
/// on the order they arrived in, so there is no race to lose.
pub fn hostname_holders(&self) -> HashMap<String, EndpointId> { pub fn hostname_holders(&self) -> HashMap<String, EndpointId> {
let mut holders: HashMap<String, EndpointId> = HashMap::new(); let mut holders: HashMap<String, (u64, EndpointId)> = HashMap::new();
for (author, record) in &self.records { for (author, record) in &self.records {
if self.is_retired(author) {
continue;
}
let Some(hostname) = record.body.hostname() else { let Some(hostname) = record.body.hostname() else {
continue; continue;
}; };
let claim = (record.body.hostname_since().unwrap_or(u64::MAX), *author);
holders holders
.entry(hostname.to_string()) .entry(hostname.to_string())
.and_modify(|held| { .and_modify(|held| {
if author.as_bytes() < held.as_bytes() { if (claim.0, claim.1.as_bytes()) < (held.0, held.1.as_bytes()) {
*held = *author; *held = claim;
} }
}) })
.or_insert(*author); .or_insert(claim);
} }
holders holders
.into_iter()
.map(|(hostname, (_, author))| (hostname, author))
.collect()
} }
/// The hostname an author holds, if it holds one uncontested. /// The hostname an author holds, if it holds one uncontested.
@@ -612,6 +706,22 @@ impl StateSet {
(self.hostname_holders().get(hostname) == Some(author)).then_some(hostname) (self.hostname_holders().get(hostname) == Some(author)).then_some(hostname)
} }
/// The name an author claimed and the member that holds it instead, when
/// somebody who claimed it earlier does.
pub fn hostname_taken(&self, author: &EndpointId) -> Option<(&str, EndpointId)> {
if self.is_retired(author) {
return None;
}
let hostname = self.records.get(author)?.body.hostname()?;
let holder = *self.hostname_holders().get(hostname)?;
(holder != *author).then_some((hostname, holder))
}
/// When an author first claimed the name it holds now, if it has one.
pub fn hostname_since_of(&self, author: &EndpointId) -> Option<u64> {
self.records.get(author)?.body.hostname_since()
}
/// The address an author holds, if it holds one uncontested. /// The address an author holds, if it holds one uncontested.
pub fn address_of(&self, author: &EndpointId) -> Option<Ipv4Addr> { pub fn address_of(&self, author: &EndpointId) -> Option<Ipv4Addr> {
let address = self.records.get(author)?.body.claimed_address()?; let address = self.records.get(author)?.body.claimed_address()?;
@@ -620,21 +730,44 @@ impl StateSet {
/// The range the network settled on, if anybody has said. /// The range the network settled on, if anybody has said.
/// ///
/// When claims disagree, the one from the lowest author id wins, so every /// A legacy or explicit range takes precedence over the derived default,
/// replica reaches the same answer. A participant joining later therefore /// so a fresh member's default cannot renumber an established network.
/// adopts the range already in use instead of imposing its own. /// Other disagreements use the lowest author id, independently of arrival
/// order. The signed record format and network identity remain unchanged.
pub fn agreed_range(&self) -> Option<Ipv4Range> { pub fn agreed_range(&self) -> Option<Ipv4Range> {
let mut authors: Vec<&EndpointId> = self.records.keys().collect(); self.records
authors.sort_by_key(|author| *author.as_bytes()); .iter()
authors .chain(
.into_iter() self.retirements
.find_map(|author| self.records.get(author)?.body.range()) .iter()
.filter(|(author, _)| !self.records.contains_key(*author))
.map(|(author, marker)| (author, &marker.target)),
)
.filter_map(|(author, record)| {
let range = record.body.range()?;
let derived = derived_ipv4_range(NetworkId::from_bytes(record.network));
Some(((range == derived, *author.as_bytes()), range))
})
.min_by_key(|(priority, _)| *priority)
.map(|(_, range)| range)
} }
/// The highest version this author has published, as far as is known. /// The highest version this author has published, as far as is known.
pub fn version_of(&self, author: &EndpointId) -> u64 { pub fn version_of(&self, author: &EndpointId) -> u64 {
self.records.get(author).map_or(0, |record| record.version) self.records.get(author).map_or(0, |record| record.version)
} }
/// Highest witnessed author counter across claims and authored cleanup.
/// This differs from the claim version used to decide retirement.
pub fn authored_version(&self, author: &EndpointId) -> u64 {
self.retirements
.values()
.filter(|m| m.author == *author.as_bytes())
.map(|m| m.version)
.chain(std::iter::once(self.version_of(author)))
.max()
.unwrap_or(0)
}
} }
#[cfg(test)] #[cfg(test)]
@@ -661,6 +794,7 @@ mod tests {
address: Some(address.parse().unwrap()), address: Some(address.parse().unwrap()),
range: Some(range()), range: Some(range()),
hostname: None, hostname: None,
hostname_since: None,
} }
} }
@@ -720,6 +854,7 @@ mod tests {
address: Some("10.99.0.1".parse().unwrap()), address: Some("10.99.0.1".parse().unwrap()),
range: Some(range()), range: Some(range()),
hostname: None, hostname: None,
hostname_since: None,
}, },
); );
assert!(matches!(outside.verify(id), Err(StateError::Malformed(_)))); assert!(matches!(outside.verify(id), Err(StateError::Malformed(_))));
@@ -735,6 +870,7 @@ mod tests {
prefix_len: 31, prefix_len: 31,
}), }),
hostname: None, hostname: None,
hostname_since: None,
}, },
); );
assert!(matches!(no_hosts.verify(id), Err(StateError::Malformed(_)))); assert!(matches!(no_hosts.verify(id), Err(StateError::Malformed(_))));
@@ -776,6 +912,7 @@ mod tests {
address: None, address: None,
range: None, range: None,
hostname: Some(bad.to_string()), hostname: Some(bad.to_string()),
hostname_since: None,
}, },
); );
assert!( assert!(
@@ -797,6 +934,7 @@ mod tests {
address: Some("10.13.37.5".parse().unwrap()), address: Some("10.13.37.5".parse().unwrap()),
range: None, range: None,
hostname: None, hostname: None,
hostname_since: None,
}, },
); );
assert!(matches!(record.verify(id), Err(StateError::Malformed(_)))); assert!(matches!(record.verify(id), Err(StateError::Malformed(_))));
@@ -818,6 +956,7 @@ mod tests {
address: None, address: None,
range: None, range: None,
hostname: Some("music".into()), hostname: Some("music".into()),
hostname_since: None,
}, },
) )
}; };
@@ -860,6 +999,7 @@ mod tests {
address: None, address: None,
range: None, range: None,
hostname: Some(name.to_string()), hostname: Some(name.to_string()),
hostname_since: None,
}, },
) )
}; };
@@ -879,6 +1019,109 @@ mod tests {
assert_eq!(set.hostname_of(&secret.public()), Some("new")); assert_eq!(set.hostname_of(&secret.public()), Some("new"));
} }
fn named_at(
secret: &SecretKey,
id: NetworkId,
version: u64,
name: &str,
since: u64,
) -> SignedRecord {
SignedRecord::sign(
secret,
id,
version,
RecordBody::Claim {
address: None,
range: None,
hostname: Some(name.into()),
hostname_since: Some(since),
},
)
}
#[test]
fn a_name_belongs_to_whoever_claimed_it_first_whatever_their_ids_or_arrival_order() {
let id = network("naming");
let mut keys = [SecretKey::generate(), SecretKey::generate()];
keys.sort_by_key(|key| *key.public().as_bytes());
let [low, high] = keys;
// The member with the higher id claimed first, so it holds the name,
// and the lower id does not take it back by sorting first.
let early = named_at(&high, id, 1, "music", 1_000);
let late = named_at(&low, id, 1, "music", 2_000);
for order in [[&early, &late], [&late, &early]] {
let mut set = StateSet::new();
for record in order {
set.merge(id, (*record).clone()).unwrap();
}
assert_eq!(set.hostname_of(&high.public()), Some("music"));
assert_eq!(set.hostname_of(&low.public()), None);
assert_eq!(
set.hostname_taken(&low.public()),
Some(("music", high.public()))
);
assert_eq!(set.hostname_taken(&high.public()), None);
}
// At the same instant the lower id decides, the same way everywhere.
let mut set = StateSet::new();
set.merge(id, named_at(&high, id, 1, "music", 5)).unwrap();
set.merge(id, named_at(&low, id, 1, "music", 5)).unwrap();
assert_eq!(set.hostname_of(&low.public()), Some("music"));
assert_eq!(set.hostname_of(&high.public()), None);
}
#[test]
fn a_name_that_loses_is_found_again_once_the_holder_lets_it_go() {
let id = network("naming");
let first = SecretKey::generate();
let second = SecretKey::generate();
let mut set = StateSet::new();
set.merge(id, named_at(&first, id, 1, "music", 1)).unwrap();
set.merge(id, named_at(&second, id, 1, "music", 2)).unwrap();
assert_eq!(set.hostname_of(&second.public()), None);
// Renaming starts the clock afresh, so it cannot be used to jump the
// queue for a name somebody else already holds.
set.merge(id, named_at(&first, id, 2, "other", 3)).unwrap();
assert_eq!(set.hostname_of(&second.public()), Some("music"));
set.merge(id, named_at(&first, id, 3, "music", 4)).unwrap();
assert_eq!(set.hostname_of(&second.public()), Some("music"));
assert_eq!(set.hostname_of(&first.public()), None);
// And a release hands it on.
set.merge(id, SignedRecord::sign(&second, id, 2, RecordBody::Release))
.unwrap();
assert_eq!(set.hostname_of(&first.public()), Some("music"));
}
#[test]
fn a_time_with_no_name_is_not_a_valid_claim() {
let id = network("naming");
let secret = SecretKey::generate();
let record = SignedRecord::sign(
&secret,
id,
1,
RecordBody::Claim {
address: None,
range: None,
hostname: None,
hostname_since: Some(1),
},
);
assert!(matches!(record.verify(id), Err(StateError::Malformed(_))));
// The time is signed: moving it breaks the signature, so nobody can
// improve another member's place in the queue.
let mut forged = named_at(&secret, id, 1, "music", 9_000);
if let RecordBody::Claim { hostname_since, .. } = &mut forged.body {
*hostname_since = Some(1);
}
assert_eq!(forged.verify(id).unwrap_err(), StateError::BadSignature);
}
#[test] #[test]
fn a_release_gives_up_the_name_as_well_as_the_address() { fn a_release_gives_up_the_name_as_well_as_the_address() {
let secret = SecretKey::generate(); let secret = SecretKey::generate();
@@ -894,6 +1137,7 @@ mod tests {
address: Some("10.13.37.5".parse().unwrap()), address: Some("10.13.37.5".parse().unwrap()),
range: Some(range()), range: Some(range()),
hostname: Some("music".into()), hostname: Some("music".into()),
hostname_since: None,
}, },
), ),
) )
@@ -1057,6 +1301,7 @@ mod tests {
address: Some("10.99.0.7".parse().unwrap()), address: Some("10.99.0.7".parse().unwrap()),
range: Some(custom), range: Some(custom),
hostname: None, hostname: None,
hostname_since: None,
}, },
), ),
) )
@@ -1098,7 +1343,7 @@ mod tests {
assert_eq!(once.base.octets()[0], 10, "inside 10/8 like the default"); assert_eq!(once.base.octets()[0], 10, "inside 10/8 like the default");
assert_ne!( assert_ne!(
once, DEFAULT_IPV4_RANGE, once, DEFAULT_IPV4_RANGE,
"the default belongs to whichever network asked first" "the legacy default remains excluded"
); );
// A different network derives a different range, which is the // A different network derives a different range, which is the
@@ -1110,4 +1355,34 @@ mod tests {
.network_id(); .network_id();
assert_ne!(once, derived_ipv4_range(other)); assert_ne!(once, derived_ipv4_range(other));
} }
#[test]
fn a_derived_default_cannot_displace_a_legacy_or_explicit_range() {
let id = network("range-migration");
let a = SecretKey::generate();
let b = SecretKey::generate();
let (low, high) = if a.public().as_bytes() < b.public().as_bytes() {
(a, b)
} else {
(b, a)
};
for established in [DEFAULT_IPV4_RANGE, "10.90.0.0/16".parse().unwrap()] {
let body = |range: Ipv4Range| RecordBody::Claim {
address: Some(Ipv4Addr::from(u32::from(range.base) + 1)),
range: Some(range),
hostname: None,
hostname_since: None,
};
let fresh = SignedRecord::sign(&low, id, 1, body(derived_ipv4_range(id)));
let existing = SignedRecord::sign(&high, id, 1, body(established));
for records in [
vec![fresh.clone(), existing.clone()],
vec![existing.clone(), fresh.clone()],
] {
let mut state = StateSet::new();
state.merge_all(id, records);
assert_eq!(state.agreed_range(), Some(established));
}
}
}
} }
+219
View File
@@ -0,0 +1,219 @@
//! A member may retire an observed claim, never another identity's future.
use iroh::{EndpointId, SecretKey, Signature};
use serde::{Deserialize, Serialize};
use super::{RecordBody, SignedRecord, StateError, push_lp};
use crate::NetworkId;
/// Maximum encoded retirement size, including its bounded claim proof.
pub const MAX_RETIREMENT_LEN: usize = 1024;
/// A network member's signed retirement of a proved claim version.
///
/// The original author's signed claim proves the cutoff really existed.
/// Newer claims remain valid. This is neither a forged Release nor a ban.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedRetirement {
/// Device identity of the member requesting cleanup.
pub author: [u8; 32],
/// Network in which cleanup applies.
pub network: [u8; 32],
/// Requester's durable author counter, committed with this marker.
pub version: u64,
/// The target's original signed claim; bounds the retired version.
pub target: SignedRecord,
/// Requester's Ed25519 signature over this marker and its proof.
pub signature: Vec<u8>,
}
impl SignedRetirement {
fn canonical_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(768);
push_lp(&mut out, b"tsunagi-node-retirement/v1");
push_lp(&mut out, &self.network);
push_lp(&mut out, &self.author);
push_lp(&mut out, &self.version.to_be_bytes());
// Bind every field of the original signed claim, including its signature.
push_lp(&mut out, &self.target.network);
push_lp(&mut out, &self.target.author);
push_lp(&mut out, &self.target.version.to_be_bytes());
push_lp(&mut out, &self.target.body.canonical());
push_lp(&mut out, &self.target.signature);
out
}
/// Signs a marker with the requesting member's persistent device key.
pub fn sign(
secret: &SecretKey,
network: NetworkId,
version: u64,
target: SignedRecord,
) -> Self {
let mut marker = Self {
author: *secret.public().as_bytes(),
network: *network.as_bytes(),
version,
target,
signature: Vec::new(),
};
marker.signature = secret.sign(&marker.canonical_bytes()).to_bytes().to_vec();
marker
}
/// Verifies both authors, scope and a finite, genuinely signed cutoff.
pub fn verify(&self, network: NetworkId) -> Result<EndpointId, StateError> {
if self.network != *network.as_bytes() {
return Err(StateError::WrongNetwork);
}
if self.version == 0 || self.version > i64::MAX as u64 {
return Err(StateError::Malformed("retirement version is out of range"));
}
if !matches!(self.target.body, RecordBody::Claim { .. }) {
return Err(StateError::Malformed("only a claim can be retired"));
}
let target = self.target.verify(network)?;
let author = EndpointId::from_bytes(&self.author).map_err(|_| StateError::BadAuthor)?;
if author == target {
return Err(StateError::Malformed(
"use Release to retire your own claim",
));
}
let raw: [u8; Signature::LENGTH] =
self.signature
.as_slice()
.try_into()
.map_err(|_| StateError::BadSignatureLength {
expected: Signature::LENGTH,
})?;
author
.verify(&self.canonical_bytes(), &Signature::from_bytes(&raw))
.map_err(|_| StateError::BadSignature)?;
Ok(target)
}
/// A deterministic strongest marker per target, independent of arrival order.
pub(crate) fn supersedes(&self, other: &Self) -> bool {
self.target.version > other.target.version
|| (self.target.version == other.target.version
&& (self.canonical_bytes(), &self.signature)
< (other.canonical_bytes(), &other.signature))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::state::{Ipv4Range, StateSet};
fn claim(key: &SecretKey, id: NetworkId, version: u64) -> SignedRecord {
SignedRecord::sign(
key,
id,
version,
RecordBody::Claim {
address: Some("10.23.0.2".parse().unwrap()),
range: Some("10.23.0.0/24".parse::<Ipv4Range>().unwrap()),
hostname: Some("retired".into()),
hostname_since: Some(123),
},
)
}
#[test]
fn markers_release_claims_in_any_merge_order_and_older_snapshots_cannot_restore_them() {
let id = NetworkId::from_bytes([7; 32]);
let target = SecretKey::generate();
let cleaner = SecretKey::generate();
let record = claim(&target, id, 5);
let marker = SignedRetirement::sign(&cleaner, id, 8, record.clone());
for first in [true, false] {
let mut state = StateSet::new();
if first {
state.merge_retirement(id, marker.clone(), 16).unwrap();
}
state.merge(id, record.clone()).unwrap();
if !first {
state.merge_retirement(id, marker.clone(), 16).unwrap();
}
assert!(state.address_holders().is_empty());
assert!(state.hostname_holders().is_empty());
state.merge(id, claim(&target, id, 4)).unwrap();
assert!(state.is_retired(&target.public()));
assert!(
state.agreed_range().is_some(),
"cleanup does not renumber the network"
);
state.merge(id, claim(&target, id, 6)).unwrap();
assert!(!state.is_retired(&target.public()));
assert_eq!(state.hostname_of(&target.public()), Some("retired"));
}
}
#[test]
fn proof_signature_scope_tampering_future_cutoffs_and_capacity_are_checked() {
let id = NetworkId::from_bytes([7; 32]);
let target = SecretKey::generate();
let cleaner = SecretKey::generate();
let marker = SignedRetirement::sign(&cleaner, id, 8, claim(&target, id, 5));
assert!(marker.verify(NetworkId::from_bytes([8; 32])).is_err());
let mut forged = marker.clone();
forged.target.version = 99;
assert!(forged.verify(id).is_err());
let mut forged = marker.clone();
forged.signature[0] ^= 1;
assert!(forged.verify(id).is_err());
let mut proof = marker.target.clone();
proof.version = 99;
assert!(
SignedRetirement::sign(&cleaner, id, 8, proof)
.verify(id)
.is_err()
);
let mut state = StateSet::new();
assert!(state.merge_retirement(id, marker.clone(), 0).is_err());
assert!(state.merge_retirement(id, marker.clone(), 1).unwrap());
assert!(!state.merge_retirement(id, marker.clone(), 1).unwrap());
assert!(postcard::to_stdvec(&marker).unwrap().len() <= MAX_RETIREMENT_LEN);
let another = SecretKey::generate();
assert!(
state
.merge_retirement(
id,
SignedRetirement::sign(&cleaner, id, 9, claim(&another, id, 1)),
1
)
.is_err()
);
}
#[test]
fn competing_cleanup_markers_converge_and_keep_the_highest_observed_cutoff() {
let id = NetworkId::from_bytes([7; 32]);
let target = SecretKey::generate();
let a = SecretKey::generate();
let b = SecretKey::generate();
let markers = [
SignedRetirement::sign(&a, id, 1, claim(&target, id, 4)),
SignedRetirement::sign(&a, id, 2, claim(&target, id, 5)),
SignedRetirement::sign(&b, id, 3, claim(&target, id, 5)),
];
let mut forward = StateSet::new();
let mut reverse = StateSet::new();
for marker in &markers {
forward.merge_retirement(id, marker.clone(), 16).unwrap();
}
for marker in markers.iter().rev() {
reverse.merge_retirement(id, marker.clone(), 16).unwrap();
}
assert_eq!(forward.retirements(), reverse.retirements());
assert_eq!(
forward
.retirement_of(&target.public())
.unwrap()
.target
.version,
5
);
}
}
+439
View File
@@ -0,0 +1,439 @@
//! Signed multi-writer virtual service registers and deterministic merge rules.
use crate::error::Result;
use crate::vip::{Backend, Listener, Protocol, VipConfig, VipStatus, invalid};
use crate::{Limits, NetworkId};
use iroh::{EndpointId, SecretKey, Signature};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::net::Ipv4Addr;
/// A signed, independently mergeable field. A removed field keeps its tombstone.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VipEntry {
/// Network scope.
pub network: [u8; 32],
/// Stable service id.
pub vip: String,
/// Canonical field key.
pub key: String,
/// Encoded field value; absent means deletion.
pub value: Option<Vec<u8>>,
/// Lamport clock, independent of wall time.
pub clock: u64,
/// Persistent signing identity.
pub author: [u8; 32],
/// Author's durable network counter.
pub version: u64,
/// Ed25519 signature.
pub signature: Vec<u8>,
}
impl VipEntry {
fn bytes(&self) -> Vec<u8> {
let mut e = self.clone();
e.signature.clear();
let mut out = b"tsunagi-vip-field/v1\0".to_vec();
if let Ok(bytes) = postcard::to_stdvec(&e) {
out.extend(bytes);
}
out
}
/// Verifies bounds, network and authorship before the entry is merged.
pub fn verify(&self, network: NetworkId) -> Result<()> {
if self.network != *network.as_bytes()
|| self.vip.len() != 32
|| hex::decode(&self.vip).is_err()
|| self.key.len() > 160
|| self.value.as_ref().is_some_and(|v| v.len() > 256)
|| self.version == 0
|| self.version > i64::MAX as u64
|| self.clock == 0
|| self.clock > i64::MAX as u64
{
return Err(invalid("invalid VIP field bounds or network"));
}
let author =
EndpointId::from_bytes(&self.author).map_err(|_| invalid("invalid VIP author"))?;
let raw: [u8; 64] = self
.signature
.as_slice()
.try_into()
.map_err(|_| invalid("invalid VIP signature length"))?;
author
.verify(&self.bytes(), &Signature::from_bytes(&raw))
.map_err(|_| invalid("invalid VIP signature"))?;
if self.key != "deleted"
&& self.key != "name"
&& !self.key.starts_with("l/")
&& !self.key.starts_with("b/")
{
return Err(invalid("unknown VIP field"));
}
match self.key.as_str() {
"deleted" => {
if self.value.as_deref() != Some(&[1][..]) {
return Err(invalid("invalid VIP deletion tombstone"));
}
}
"name" => {
let value = self
.value
.as_ref()
.ok_or_else(|| invalid("VIP name cannot be absent"))?;
let name: String =
postcard::from_bytes(value).map_err(|_| invalid("invalid VIP name field"))?;
VipConfig {
id: self.vip.clone(),
name,
listeners: vec![],
}
.validate(&Limits::default())?;
}
key if key.starts_with("l/") => {
let parts: Vec<_> = key.split('/').collect();
if parts.len() != 3
|| parts[1].parse::<Protocol>().is_err()
|| parts[2].parse::<u16>().ok().is_none_or(|p| p == 0)
{
return Err(invalid("invalid listener field key"));
}
if let Some(value) = &self.value {
let l: Listener = postcard::from_bytes(value)
.map_err(|_| invalid("invalid listener field"))?;
if !l.backends.is_empty() || key != format!("l/{}/{}", l.protocol, l.port) {
return Err(invalid("listener key does not match its value"));
}
VipConfig {
id: self.vip.clone(),
name: "validation".into(),
listeners: vec![l],
}
.validate(&Limits::default())?;
}
}
key if key.starts_with("b/") => {
let parts: Vec<_> = key.split('/').collect();
if parts.len() != 6
|| parts[1].parse::<Protocol>().is_err()
|| parts[2].parse::<u16>().ok().is_none_or(|p| p == 0)
|| parts[3].len() != 32
|| hex::decode(parts[3]).is_err()
|| parts[4].parse::<EndpointId>().is_err()
|| parts[5].parse::<u16>().ok().is_none_or(|p| p == 0)
{
return Err(invalid("invalid backend field key"));
}
if let Some(value) = &self.value {
let b: Backend = postcard::from_bytes(value)
.map_err(|_| invalid("invalid backend field"))?;
if b.peer != parts[4] || b.port.to_string() != parts[5] {
return Err(invalid("backend key does not match its value"));
}
}
}
_ => return Err(invalid("unknown VIP field")),
}
Ok(())
}
fn rank(&self) -> (u64, [u8; 32], Vec<u8>) {
(self.clock, self.author, self.bytes())
}
}
/// Signed registers retained across absences, including deletion tombstones.
#[derive(Debug, Clone, Default)]
pub struct VipSet {
entries: BTreeMap<(String, String), VipEntry>,
}
impl VipSet {
/// Highest retained version genuinely signed by this author.
pub fn authored_version(&self, author: EndpointId) -> u64 {
self.entries
.values()
.filter(|e| e.author == *author.as_bytes())
.map(|e| e.version)
.max()
.unwrap_or(0)
}
/// Complete independently verifiable state of one service, including tombstones.
pub fn entries(&self, id: &str) -> Vec<VipEntry> {
self.entries
.values()
.filter(|e| e.vip == id)
.cloned()
.collect()
}
/// All retained service ids, including deleted services.
pub fn ids(&self) -> Vec<String> {
self.entries
.keys()
.map(|(id, _)| id.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
/// Opaque optimistic concurrency token for a whole service.
pub fn revision(&self, id: &str) -> String {
let mut hash = Sha256::new();
for e in self.entries(id) {
hash.update(e.bytes());
hash.update(&e.signature);
}
hex::encode(hash.finalize())
}
/// Atomically verifies and merges one complete service snapshot.
pub fn merge(
&mut self,
network: NetworkId,
entries: Vec<VipEntry>,
limits: &Limits,
) -> Result<bool> {
if entries.len() > limits.max_vip_fields {
return Err(invalid("too many VIP fields"));
}
let mut next = self.clone();
for e in entries {
e.verify(network)?;
let key = (e.vip.clone(), e.key.clone());
if next
.entries
.get(&key)
.is_none_or(|old| e.rank() > old.rank())
{
next.entries.insert(key, e);
}
}
let deleted: BTreeSet<_> = next
.entries
.values()
.filter(|e| e.key == "deleted" && e.value.is_some())
.map(|e| e.vip.clone())
.collect();
// A permanent service tombstone alone prevents every former field's replay.
next.entries
.retain(|(id, key), _| !deleted.contains(id) || key == "deleted");
let ids = next.ids();
if ids.len().saturating_sub(deleted.len()) > limits.max_vips
|| deleted.len() > limits.max_vip_tombstones
|| ids
.iter()
.any(|id| next.entries(id).len() > limits.max_vip_fields)
{
return Err(invalid(
"VIP state capacity exceeded; deletion protection is retained",
));
}
let changed = self.entries != next.entries;
*self = next;
Ok(changed)
}
/// Materialized configuration. A service tombstone permanently retires its id.
pub fn configs(&self) -> Vec<VipConfig> {
self.ids()
.into_iter()
.filter_map(|id| self.config(&id))
.collect()
}
fn config(&self, id: &str) -> Option<VipConfig> {
let fields = self.entries(id);
if fields
.iter()
.any(|e| e.key == "deleted" && e.value.is_some())
{
return None;
}
let name = fields
.iter()
.find(|e| e.key == "name")?
.value
.as_ref()
.and_then(|v| postcard::from_bytes::<String>(v).ok())?;
let mut listeners: Vec<Listener> = fields
.iter()
.filter(|e| e.key.starts_with("l/"))
.filter_map(|e| e.value.as_ref().and_then(|v| postcard::from_bytes(v).ok()))
.collect();
for l in &mut listeners {
l.backends = fields
.iter()
.filter(|e| {
e.key
.starts_with(&format!("b/{}/{}/{}/", l.protocol, l.port, l.id))
})
.filter_map(|e| e.value.as_ref().and_then(|v| postcard::from_bytes(v).ok()))
.collect();
l.backends.sort();
}
listeners.sort_by_key(|l| (l.protocol, l.port));
Some(VipConfig {
id: id.into(),
name,
listeners,
})
}
/// Builds a signed patch from the user's complete, version-checked draft.
pub fn edit(
&self,
network: NetworkId,
secret: &SecretKey,
version: u64,
config: &VipConfig,
expected: Option<&str>,
limits: &Limits,
) -> Result<Vec<VipEntry>> {
config.validate(limits)?;
if let Some(revision) = expected {
if revision != self.revision(&config.id) {
return Err(invalid("VIP changed while editing; reload before saving"));
}
} else if !self.entries(&config.id).is_empty() {
return Err(invalid("VIP id already exists"));
}
if self
.entries
.get(&(config.id.clone(), "deleted".into()))
.is_some_and(|e| e.value.is_some())
{
return Err(invalid("VIP was deleted; create a new VIP"));
}
let before = self
.config(&config.id)
.map(|c| flatten(&c))
.transpose()?
.unwrap_or_default();
let after = flatten(config)?;
let keys: BTreeSet<_> = before.keys().chain(after.keys()).cloned().collect();
let changes = keys
.into_iter()
.filter_map(|key| {
let value = after.get(&key).cloned();
(before.get(&key) != value.as_ref()).then_some((key, value))
})
.collect();
self.sign(network, secret, version, &config.id, changes)
}
/// Builds a permanent service tombstone after checking the selected revision.
pub fn delete(
&self,
network: NetworkId,
secret: &SecretKey,
version: u64,
id: &str,
expected: &str,
) -> Result<Vec<VipEntry>> {
if self.config(id).is_none() || self.revision(id) != expected {
return Err(invalid("VIP missing or changed while editing"));
}
self.sign(
network,
secret,
version,
id,
vec![("deleted".into(), Some(vec![1]))],
)
}
fn sign(
&self,
network: NetworkId,
secret: &SecretKey,
version: u64,
id: &str,
changes: Vec<(String, Option<Vec<u8>>)>,
) -> Result<Vec<VipEntry>> {
let clock = self
.entries
.values()
.map(|e| e.clock)
.max()
.unwrap_or(0)
.checked_add(1)
.filter(|n| *n <= i64::MAX as u64)
.ok_or_else(|| invalid("VIP clock exhausted"))?;
Ok(changes
.into_iter()
.map(|(key, value)| {
let mut e = VipEntry {
network: *network.as_bytes(),
vip: id.into(),
key,
value,
clock,
author: *secret.public().as_bytes(),
version,
signature: vec![],
};
e.signature = secret.sign(&e.bytes()).to_bytes().to_vec();
e
})
.collect())
}
}
pub(crate) fn flatten(cfg: &VipConfig) -> Result<BTreeMap<String, Vec<u8>>> {
let mut fields = BTreeMap::new();
fields.insert(
"name".into(),
postcard::to_stdvec(&cfg.name).map_err(|e| invalid(e.to_string()))?,
);
for l in &cfg.listeners {
let mut settings = l.clone();
settings.backends.clear();
fields.insert(
format!("l/{}/{}", l.protocol, l.port),
postcard::to_stdvec(&settings).map_err(|e| invalid(e.to_string()))?,
);
for b in &l.backends {
fields.insert(
format!("b/{}/{}/{}/{}/{}", l.protocol, l.port, l.id, b.peer, b.port),
postcard::to_stdvec(b).map_err(|e| invalid(e.to_string()))?,
);
}
}
Ok(fields)
}
impl VipSet {
/// Allocates internal addresses deterministically against durable node claims.
pub(crate) fn views(
&self,
range: Option<crate::state::Ipv4Range>,
mut taken: std::collections::HashSet<Ipv4Addr>,
hostnames: &BTreeSet<String>,
limits: &Limits,
) -> Vec<VipStatus> {
let mut names = hostnames.clone();
let mut result = Vec::new();
for config in self.configs() {
let mut error = config.validate(limits).err().map(|e| e.to_string());
if !names.insert(config.name.clone()) {
error = Some("name conflicts with another VIP or node".into());
}
let address = range.and_then(|r| {
let usable = crate::state::allocator::usable_addresses(r);
if usable == 0 {
return None;
}
let digest = Sha256::digest(config.id.as_bytes());
let seed = u64::from_be_bytes([
digest[0], digest[1], digest[2], digest[3], digest[4], digest[5], digest[6],
digest[7],
]);
let bits = 32 - u32::from(r.prefix_len);
let mask = if bits == 32 { 0 } else { u32::MAX << bits };
(0..usable).find_map(|step| {
let offset = (seed.wrapping_add(step) % usable + 1) as u32;
let ip = Ipv4Addr::from((u32::from(r.base) & mask) | offset);
taken.insert(ip).then_some(ip)
})
});
if address.is_none() {
error = Some("no free overlay address".into());
}
result.push(VipStatus {
revision: self.revision(&config.id),
config,
address,
error,
listeners: vec![],
});
}
result
}
}
+78
View File
@@ -254,6 +254,12 @@ impl Storage {
.await .await
} }
/// Persists the ordered protocol selection off the async executor.
pub async fn set_protocols(&self, network_id: NetworkId, protocols: Vec<String>) -> Result<()> {
self.with_state(move |state| state.set_protocols(network_id, &protocols))
.await
}
/// Persists whether this agent offers itself as an exit node in a network. /// Persists whether this agent offers itself as an exit node in a network.
pub async fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> { pub async fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
self.with_state(move |state| state.set_exit_node(network_id, enabled)) self.with_state(move |state| state.set_exit_node(network_id, enabled))
@@ -272,11 +278,39 @@ impl Storage {
.await .await
} }
/// Configured networks in the user's most recently selected order.
pub async fn network_start_order(&self) -> Result<Vec<NetworkId>> {
self.with_state(|state| {
let configured = state.list_networks()?;
let text = state
.get_setting("network_start_order")?
.unwrap_or_default();
let mut order = Vec::new();
for word in text.split(',') {
if let Ok(id) = word.parse::<NetworkId>()
&& configured.iter().any(|n| n.network_id == id)
&& !order.contains(&id)
{
order.push(id);
}
}
Ok(order)
})
.await
}
/// Prefers this network at startup without disabling suspended networks.
pub async fn prefer_network(&self, network: NetworkId) -> Result<()> {
self.with_state(move |state| state.prefer_network(network))
.await
}
/// Removes a network configuration and its cached hints. /// Removes a network configuration and its cached hints.
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> { pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
self.with_state(move |state| { self.with_state(move |state| {
state.remove_network(network_id)?; state.remove_network(network_id)?;
state.forget_signed_records(network_id)?; state.forget_signed_records(network_id)?;
state.forget_retirements(network_id)?;
state.forget_peer_hostnames(network_id) state.forget_peer_hostnames(network_id)
}) })
.await?; .await?;
@@ -336,6 +370,50 @@ impl Storage {
.await .await
} }
/// Loads mandatory signed VIP fields with a strict capacity bound.
pub async fn vip_fields(
&self,
network: NetworkId,
max: usize,
) -> Result<Vec<crate::vip::VipEntry>> {
self.with_state(move |state| state.vip_fields(network, max))
.await
}
/// Stores a complete merged service snapshot and optional own counter.
pub async fn put_vip_fields(
&self,
network: NetworkId,
entries: Vec<crate::vip::VipEntry>,
own: Option<([u8; 32], u64)>,
) -> Result<()> {
self.with_state(move |state| state.put_vip_fields(network, &entries, own))
.await
}
/// Loads verified durable cleanup proofs off the executor.
pub async fn retirements(
&self,
network: NetworkId,
max: usize,
) -> Result<Vec<crate::state::SignedRetirement>> {
self.with_state(move |state| state.retirements(network, max))
.await
}
/// Persists a received cleanup proof before applying it.
pub async fn put_retirement(&self, marker: crate::state::SignedRetirement) -> Result<()> {
self.with_state(move |state| state.put_retirement(&marker))
.await
}
/// Commits our cleanup and author counter together before announcement.
pub async fn publish_own_retirement(
&self,
marker: crate::state::SignedRetirement,
) -> Result<()> {
self.with_state(move |state| state.publish_own_retirement(&marker))
.await
}
/// Stores a record received from another replica. /// Stores a record received from another replica.
pub async fn put_signed_record(&self, record: SignedRecord) -> Result<()> { pub async fn put_signed_record(&self, record: SignedRecord) -> Result<()> {
self.with_state(move |state| state.put_signed_record(&record)) self.with_state(move |state| state.put_signed_record(&record))
+518 -5
View File
@@ -18,10 +18,10 @@ use rusqlite::{Connection, OptionalExtension, params};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret}; use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
use crate::state::{RecordBody, SignedRecord}; use crate::state::{MAX_RETIREMENT_LEN, RecordBody, SignedRecord, SignedRetirement};
/// Schema version written by this build. /// Schema version written by this build.
pub const SCHEMA_VERSION: i64 = 6; pub const SCHEMA_VERSION: i64 = 10;
/// Key of the stored hostname setting. /// Key of the stored hostname setting.
const SETTING_HOSTNAME: &str = "hostname"; const SETTING_HOSTNAME: &str = "hostname";
@@ -32,6 +32,8 @@ const SETTING_HOSTNAME: &str = "hostname";
/// and zeroizes on drop. /// and zeroizes on drop.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct StoredNetwork { pub struct StoredNetwork {
/// Ordered local protocol override; absent inherits the agent's defaults.
pub protocols: Option<Vec<String>>,
/// Derived public network identifier. /// Derived public network identifier.
pub network_id: NetworkId, pub network_id: NetworkId,
/// Network name. /// Network name.
@@ -221,6 +223,52 @@ impl StateStore {
) )
.map_err(|err| self.corrupt(format!("cannot migrate schema to 6: {err}")))?; .map_err(|err| self.corrupt(format!("cannot migrate schema to 6: {err}")))?;
} }
// Migration 6 -> 7: a claim gained the time its hostname was first
// claimed, which changed the signing domain. Records signed before
// no longer verify, so they are discarded rather than kept as rows
// every read has to reject; each member re-publishes its own claim on
// its next run and the others arrive again from their authors. The
// version counter is kept, so nothing is ever published twice at one
// version.
if found < 7 {
self.conn
.execute_batch(
"BEGIN;
DELETE FROM signed_records;
PRAGMA user_version = 7;
COMMIT;",
)
.map_err(|err| self.corrupt(format!("cannot migrate schema to 7: {err}")))?;
}
if found < 8 {
self.conn
.execute_batch(
"BEGIN;
ALTER TABLE networks ADD COLUMN protocols TEXT;
PRAGMA user_version = 8;
COMMIT;",
)
.map_err(|err| self.corrupt(format!("cannot migrate schema to 8: {err}")))?;
}
if found < 9 {
self.conn
.execute_batch(
"BEGIN;
CREATE TABLE IF NOT EXISTS retirements (
network_id BLOB NOT NULL, target BLOB NOT NULL, data BLOB NOT NULL,
PRIMARY KEY (network_id, target)
);
PRAGMA user_version = 9;
COMMIT;",
)
.map_err(|err| self.corrupt(format!("cannot migrate schema to 9: {err}")))?;
}
if found < 10 {
self.conn.execute_batch("BEGIN; CREATE TABLE IF NOT EXISTS vip_fields (
network_id BLOB NOT NULL, vip TEXT NOT NULL, field TEXT NOT NULL, data BLOB NOT NULL,
PRIMARY KEY(network_id, vip, field)); PRAGMA user_version = 10; COMMIT;")
.map_err(|err| self.corrupt(format!("cannot migrate VIP state: {err}")))?;
}
Ok(()) Ok(())
} }
@@ -233,6 +281,8 @@ impl StateStore {
"settings", "settings",
"signed_records", "signed_records",
"peer_hostnames", "peer_hostnames",
"retirements",
"vip_fields",
] { ] {
let present: Option<String> = self let present: Option<String> = self
.conn .conn
@@ -431,12 +481,16 @@ impl StateStore {
/// Sets the auto-start flag of a configured network. /// Sets the auto-start flag of a configured network.
pub fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> { pub fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
self.conn let changed = self
.conn
.execute( .execute(
"UPDATE networks SET auto_start = ?2 WHERE network_id = ?1", "UPDATE networks SET auto_start = ?2 WHERE network_id = ?1",
params![network_id.as_bytes().as_slice(), auto_start as i64], params![network_id.as_bytes().as_slice(), auto_start as i64],
) )
.map_err(|err| Error::Storage(format!("cannot update network: {err}")))?; .map_err(|err| Error::Storage(format!("cannot update network: {err}")))?;
if changed == 0 {
return Err(Error::NetworkUnknown(network_id));
}
Ok(()) Ok(())
} }
@@ -455,6 +509,25 @@ impl StateStore {
Ok(()) Ok(())
} }
/// Saves a local ordered protocol selection without changing membership.
pub fn set_protocols(&self, network_id: NetworkId, protocols: &[String]) -> Result<()> {
crate::dataplane::validate_protocols(
protocols,
crate::config::Limits::default().max_capabilities,
)?;
let changed = self
.conn
.execute(
"UPDATE networks SET protocols = ?2 WHERE network_id = ?1",
params![network_id.as_bytes().as_slice(), protocols.join(",")],
)
.map_err(|err| Error::Storage(format!("cannot update protocols: {err}")))?;
if changed == 0 {
return Err(Error::NetworkUnknown(network_id));
}
Ok(())
}
/// Removes a network configuration entirely. /// Removes a network configuration entirely.
pub fn remove_network(&self, network_id: NetworkId) -> Result<()> { pub fn remove_network(&self, network_id: NetworkId) -> Result<()> {
self.conn self.conn
@@ -515,7 +588,7 @@ impl StateStore {
pub fn list_networks(&self) -> Result<Vec<StoredNetwork>> { pub fn list_networks(&self) -> Result<Vec<StoredNetwork>> {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT network_id, name, secret, auto_start, broadcast, exit_node, exit_via FROM networks ORDER BY name") .prepare("SELECT network_id, name, secret, auto_start, broadcast, exit_node, exit_via, protocols FROM networks ORDER BY name")
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?; .map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
let rows = stmt let rows = stmt
.query_map([], |row| { .query_map([], |row| {
@@ -531,19 +604,37 @@ impl StateStore {
row.get::<_, bool>(4)?, row.get::<_, bool>(4)?,
row.get::<_, bool>(5)?, row.get::<_, bool>(5)?,
row.get::<_, Option<Vec<u8>>>(6)?, row.get::<_, Option<Vec<u8>>>(6)?,
row.get::<_, Option<String>>(7)?,
)) ))
}) })
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?; .map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
let mut out = Vec::new(); let mut out = Vec::new();
for row in rows { for row in rows {
let (id, name, secret, auto_start, broadcast, exit_node, exit_via) = let (id, name, secret, auto_start, broadcast, exit_node, exit_via, protocols) =
row.map_err(|err| Error::Storage(format!("cannot read network row: {err}")))?; row.map_err(|err| Error::Storage(format!("cannot read network row: {err}")))?;
let protocols = protocols
.map(|text| {
let max = crate::config::Limits::default().max_capabilities;
if text.len() > max * (crate::dataplane::MAX_PROTOCOL_ID_LEN + 1) {
return Err(self.corrupt("stored protocol selection is too long"));
}
let names = if text.is_empty() {
Vec::new()
} else {
text.split(',').map(str::to_owned).collect()
};
crate::dataplane::validate_protocols(&names, max)
.map_err(|err| self.corrupt(err))?;
Ok(names)
})
.transpose()?;
let id: [u8; 32] = id let id: [u8; 32] = id
.as_slice() .as_slice()
.try_into() .try_into()
.map_err(|_| self.corrupt("stored network id is not 32 bytes"))?; .map_err(|_| self.corrupt("stored network id is not 32 bytes"))?;
out.push(StoredNetwork { out.push(StoredNetwork {
protocols,
network_id: NetworkId::from_bytes(id), network_id: NetworkId::from_bytes(id),
name: NetworkName::new(name)?, name: NetworkName::new(name)?,
secret: NetworkSecret::from_bytes(secret)?, secret: NetworkSecret::from_bytes(secret)?,
@@ -715,6 +806,181 @@ impl StateStore {
Ok(version.unwrap_or(0).max(0) as u64) Ok(version.unwrap_or(0).max(0) as u64)
} }
/// Loads durable cleanup markers without allocating oversized stored blobs.
pub fn retirements(&self, network: NetworkId, max: usize) -> Result<Vec<SignedRetirement>> {
let mut stmt = self
.conn
.prepare(
"SELECT CASE WHEN length(data) <= ?2 THEN data ELSE NULL END
FROM retirements WHERE network_id = ?1 ORDER BY target",
)
.map_err(|err| self.corrupt(err))?;
let rows = stmt
.query_map(
params![network.as_bytes().as_slice(), MAX_RETIREMENT_LEN as i64],
|row| row.get::<_, Option<Vec<u8>>>(0),
)
.map_err(|err| self.corrupt(err))?;
let mut out = Vec::new();
for row in rows {
if out.len() >= max {
return Err(Error::Storage(
"stored retirements exceed the configured limit".into(),
));
}
let bytes = row
.map_err(|err| self.corrupt(err))?
.ok_or_else(|| self.corrupt("oversized retirement"))?;
let marker: SignedRetirement =
postcard::from_bytes(&bytes).map_err(|err| self.corrupt(err))?;
marker.verify(network).map_err(|err| self.corrupt(err))?;
out.push(marker);
}
Ok(out)
}
/// Persists a strongest verified marker received from an authenticated session.
pub fn put_retirement(&self, marker: &SignedRetirement) -> Result<()> {
let network = NetworkId::from_bytes(marker.network);
marker
.verify(network)
.map_err(|err| Error::Storage(err.to_string()))?;
let old: Option<Option<Vec<u8>>> = self.conn.query_row(
"SELECT CASE WHEN length(data) <= ?3 THEN data ELSE NULL END FROM retirements WHERE network_id = ?1 AND target = ?2",
params![marker.network.as_slice(), marker.target.author.as_slice(), MAX_RETIREMENT_LEN as i64], |row| row.get(0))
.optional().map_err(|err| self.corrupt(err))?;
if let Some(bytes) = old {
let bytes = bytes.ok_or_else(|| self.corrupt("oversized retirement"))?;
let old: SignedRetirement =
postcard::from_bytes(&bytes).map_err(|err| self.corrupt(err))?;
old.verify(network).map_err(|err| self.corrupt(err))?;
if !marker.supersedes(&old) {
return Ok(());
}
}
let bytes = postcard::to_stdvec(marker).map_err(|err| Error::Storage(err.to_string()))?;
if bytes.len() > MAX_RETIREMENT_LEN {
return Err(Error::Storage(
"retirement exceeds its encoded bound".into(),
));
}
self.conn
.execute(
"INSERT INTO retirements (network_id, target, data) VALUES (?1, ?2, ?3)
ON CONFLICT(network_id, target) DO UPDATE SET data = excluded.data",
params![
marker.network.as_slice(),
marker.target.author.as_slice(),
bytes
],
)
.map_err(|err| Error::Storage(format!("cannot store retirement: {err}")))?;
Ok(())
}
/// Commits our signed cleanup marker and author counter in one transaction.
pub fn publish_own_retirement(&self, marker: &SignedRetirement) -> Result<()> {
let transaction = self
.conn
.unchecked_transaction()
.map_err(|err| Error::Storage(err.to_string()))?;
self.put_retirement(marker)?;
transaction
.execute(
"INSERT INTO own_record_version (network_id, author, version) VALUES (?1, ?2, ?3)
ON CONFLICT(network_id, author) DO UPDATE SET version = max(version, excluded.version)",
params![
marker.network.as_slice(),
marker.author.as_slice(),
marker.version as i64
],
)
.map_err(|err| Error::Storage(format!("cannot store retirement counter: {err}")))?;
transaction
.commit()
.map_err(|err| Error::Storage(format!("cannot commit retirement: {err}")))
}
/// Reads bounded mandatory VIP state; corrupt records fail activation.
pub fn vip_fields(&self, network: NetworkId, max: usize) -> Result<Vec<crate::vip::VipEntry>> {
let mut stmt = self.conn.prepare("SELECT CASE WHEN length(data) <= 1024 THEN data ELSE NULL END FROM vip_fields WHERE network_id=?1 ORDER BY vip, field")
.map_err(|e| self.corrupt(e.to_string()))?;
let mut rows = stmt
.query(params![network.as_bytes().as_slice()])
.map_err(|e| self.corrupt(e.to_string()))?;
let mut entries = Vec::new();
while let Some(row) = rows.next().map_err(|e| self.corrupt(e.to_string()))? {
if entries.len() >= max {
return Err(self.corrupt("stored VIP state exceeds configured limits"));
}
let bytes: Option<Vec<u8>> = row.get(0).map_err(|e| self.corrupt(e.to_string()))?;
let bytes = bytes.ok_or_else(|| self.corrupt("oversized VIP field"))?;
let entry: crate::vip::VipEntry =
postcard::from_bytes(&bytes).map_err(|e| self.corrupt(e.to_string()))?;
entry
.verify(network)
.map_err(|e| self.corrupt(e.to_string()))?;
entries.push(entry);
}
Ok(entries)
}
/// Commits a validated VIP snapshot and optional author counter atomically.
pub fn put_vip_fields(
&self,
network: NetworkId,
entries: &[crate::vip::VipEntry],
own: Option<([u8; 32], u64)>,
) -> Result<()> {
let tx = self
.conn
.unchecked_transaction()
.map_err(|e| Error::Storage(e.to_string()))?;
if let Some(first) = entries.first() {
if entries.iter().any(|e| e.vip != first.vip) {
return Err(Error::Storage(
"VIP snapshot mixes service identities".into(),
));
}
tx.execute(
"DELETE FROM vip_fields WHERE network_id=?1 AND vip=?2",
params![network.as_bytes().as_slice(), first.vip],
)
.map_err(|e| Error::Storage(e.to_string()))?;
}
for e in entries {
e.verify(network)?;
let bytes = postcard::to_stdvec(e).map_err(|e| Error::Storage(e.to_string()))?;
if bytes.len() > 1024 {
return Err(Error::Storage("oversized VIP field".into()));
}
tx.execute("INSERT INTO vip_fields(network_id,vip,field,data) VALUES(?1,?2,?3,?4) ON CONFLICT(network_id,vip,field) DO UPDATE SET data=excluded.data",
params![network.as_bytes().as_slice(),e.vip,e.key,bytes]).map_err(|e|Error::Storage(e.to_string()))?;
}
if let Some((author, version)) = own {
tx.execute("INSERT INTO own_record_version(network_id,author,version) VALUES(?1,?2,?3) ON CONFLICT(network_id,author) DO UPDATE SET version=max(version,excluded.version)",
params![network.as_bytes().as_slice(),author.as_slice(),version as i64]).map_err(|e|Error::Storage(e.to_string()))?;
}
tx.commit().map_err(|e| Error::Storage(e.to_string()))
}
/// Drops a forgotten network's local cleanup proofs.
pub fn forget_retirements(&self, network: NetworkId) -> Result<()> {
self.conn
.execute(
"DELETE FROM vip_fields WHERE network_id = ?1",
params![network.as_bytes().as_slice()],
)
.map_err(|e| Error::Storage(e.to_string()))?;
self.conn
.execute(
"DELETE FROM retirements WHERE network_id = ?1",
params![network.as_bytes().as_slice()],
)
.map_err(|err| Error::Storage(err.to_string()))?;
Ok(())
}
/// Forgets every record of a network. /// Forgets every record of a network.
pub fn forget_signed_records(&self, network_id: NetworkId) -> Result<()> { pub fn forget_signed_records(&self, network_id: NetworkId) -> Result<()> {
self.conn self.conn
@@ -781,6 +1047,44 @@ impl StateStore {
Ok(()) Ok(())
} }
/// Atomically enables a network and puts it first in the startup order.
pub fn prefer_network(&self, network: NetworkId) -> Result<()> {
let configured = self.list_networks()?;
if !configured.iter().any(|n| n.network_id == network) {
return Err(Error::NetworkUnknown(network));
}
let existing = self.get_setting("network_start_order")?.unwrap_or_default();
let mut order = vec![network];
for word in existing.split(',') {
if let Ok(id) = word.parse::<NetworkId>()
&& configured.iter().any(|n| n.network_id == id)
&& !order.contains(&id)
{
order.push(id);
}
}
let value = order
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",");
let transaction = self
.conn
.unchecked_transaction()
.map_err(|err| Error::Storage(format!("cannot begin network preference: {err}")))?;
transaction
.execute(
"UPDATE networks SET auto_start = 1 WHERE network_id = ?1",
params![network.as_bytes().as_slice()],
)
.map_err(|err| Error::Storage(format!("cannot enable network: {err}")))?;
transaction.execute("INSERT INTO settings (key, value) VALUES ('network_start_order', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![value])
.map_err(|err| Error::Storage(format!("cannot store network preference: {err}")))?;
transaction
.commit()
.map_err(|err| Error::Storage(format!("cannot commit network preference: {err}")))
}
/// Reads an arbitrary setting. /// Reads an arbitrary setting.
pub fn get_setting(&self, key: &str) -> Result<Option<String>> { pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
self.conn self.conn
@@ -898,6 +1202,158 @@ mod broadcast_storage_tests {
assert!(store.peer_hostnames(id).unwrap().is_empty()); assert!(store.peer_hostnames(id).unwrap().is_empty());
} }
#[test]
fn retirement_migration_and_atomic_counter_commit_preserve_existing_state() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.sqlite");
let store = StateStore::open(&path).unwrap();
let identity = store.load_or_create_device_identity().unwrap();
let name = NetworkName::new("retirement-migration").unwrap();
let secret = NetworkSecret::from_bytes([6; 32]).unwrap();
let id = NetworkKeys::derive(&name, &secret).network_id();
store.upsert_network(id, &name, &secret, true).unwrap();
store.set_protocols(id, &["awg".into()]).unwrap();
let own = SignedRecord::sign(&identity.signing_key(), id, 8, RecordBody::Release);
store.publish_own_record(&own).unwrap();
store
.conn
.execute_batch("DROP TABLE retirements; PRAGMA user_version = 8;")
.unwrap();
drop(store);
let store = StateStore::open(&path).unwrap();
assert_eq!(
store.device_identity().unwrap().unwrap().endpoint_id(),
identity.endpoint_id()
);
assert_eq!(
store.list_networks().unwrap()[0].protocols,
Some(vec!["awg".into()])
);
assert_eq!(store.signed_records(id).unwrap(), vec![own]);
let target_key = iroh::SecretKey::generate();
let target = SignedRecord::sign(
&target_key,
id,
3,
RecordBody::Claim {
address: Some("10.13.37.2".parse().unwrap()),
range: Some(crate::state::DEFAULT_IPV4_RANGE),
hostname: Some("old-device".into()),
hostname_since: Some(123),
},
);
let marker = SignedRetirement::sign(&identity.signing_key(), id, 9, target);
store
.conn
.execute_batch(
"CREATE TRIGGER fail_retirement_counter BEFORE INSERT ON own_record_version
WHEN NEW.version = 9 BEGIN SELECT RAISE(ABORT, 'counter write refused'); END;",
)
.unwrap();
assert!(store.publish_own_retirement(&marker).is_err());
assert!(
store.retirements(id, 16).unwrap().is_empty(),
"marker must roll back with counter failure"
);
assert_eq!(
store
.own_record_version(id, identity.endpoint_id())
.unwrap(),
8
);
store
.conn
.execute_batch("DROP TRIGGER fail_retirement_counter;")
.unwrap();
store.publish_own_retirement(&marker).unwrap();
drop(store);
let store = StateStore::open(&path).unwrap();
assert_eq!(store.retirements(id, 16).unwrap(), vec![marker.clone()]);
assert_eq!(
store
.own_record_version(id, identity.endpoint_id())
.unwrap(),
9
);
assert!(
store.retirements(id, 0).is_err(),
"capacity changes must not silently drop markers"
);
store
.conn
.execute(
"UPDATE retirements SET data = ?1",
params![vec![0u8; MAX_RETIREMENT_LEN + 1]],
)
.unwrap();
assert!(matches!(
store.retirements(id, 16),
Err(Error::StateCorrupted { .. })
));
}
#[test]
fn v7_migration_keeps_signed_state_and_protocol_overrides_are_local_and_durable() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.sqlite");
let store = StateStore::open(&path).unwrap();
let identity = store.load_or_create_device_identity().unwrap();
let name = NetworkName::new("protocol-migration").unwrap();
let secret = NetworkSecret::from_bytes([4; 32]).unwrap();
let id = NetworkKeys::derive(&name, &secret).network_id();
store.upsert_network(id, &name, &secret, true).unwrap();
store.set_broadcast(id, false).unwrap();
let record = SignedRecord::sign(&identity.signing_key(), id, 17, RecordBody::Release);
store.publish_own_record(&record).unwrap();
store
.conn
.execute_batch("ALTER TABLE networks DROP COLUMN protocols; PRAGMA user_version=7;")
.unwrap();
drop(store);
let store = StateStore::open(&path).unwrap();
assert_eq!(
store
.load_or_create_device_identity()
.unwrap()
.endpoint_id(),
identity.endpoint_id()
);
assert_eq!(store.signed_records(id).unwrap(), vec![record]);
let saved = store.list_networks().unwrap();
assert!(saved[0].protocols.is_none());
assert!(!saved[0].broadcast);
assert!(saved[0].auto_start);
assert_eq!(saved[0].secret, secret);
let chosen = vec!["awg".into(), "wg-quic".into()];
store.set_protocols(id, &chosen).unwrap();
assert!(
store
.set_protocols(id, &["awg".into(), "awg".into()])
.is_err()
);
assert!(
store
.set_protocols(NetworkId::from_bytes([1; 32]), &[])
.is_err()
);
drop(store);
let store = StateStore::open(&path).unwrap();
assert_eq!(store.list_networks().unwrap()[0].protocols, Some(chosen));
store.upsert_network(id, &name, &secret, true).unwrap();
store.set_protocols(id, &[]).unwrap();
drop(store);
let store = StateStore::open(&path).unwrap();
assert_eq!(store.list_networks().unwrap()[0].protocols, Some(vec![]));
store
.conn
.execute("UPDATE networks SET protocols = 'awg,awg'", [])
.unwrap();
assert!(matches!(
store.list_networks(),
Err(Error::StateCorrupted { .. })
));
}
#[test] #[test]
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() { fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -918,6 +1374,7 @@ mod broadcast_storage_tests {
"ALTER TABLE networks DROP COLUMN broadcast; \ "ALTER TABLE networks DROP COLUMN broadcast; \
ALTER TABLE networks DROP COLUMN exit_node; \ ALTER TABLE networks DROP COLUMN exit_node; \
ALTER TABLE networks DROP COLUMN exit_via; \ ALTER TABLE networks DROP COLUMN exit_via; \
ALTER TABLE networks DROP COLUMN protocols; \
PRAGMA user_version=3;", PRAGMA user_version=3;",
) )
.unwrap(); .unwrap();
@@ -952,3 +1409,59 @@ mod broadcast_storage_tests {
); );
} }
} }
#[cfg(test)]
mod vip_tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn vip_migration_and_counter_failure_preserve_identity_and_rollback_fields() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("state.sqlite");
let store = StateStore::open(&path).unwrap();
let identity = store.load_or_create_device_identity().unwrap();
store
.conn
.execute_batch("DROP TABLE vip_fields; PRAGMA user_version=9;")
.unwrap();
drop(store);
let store = StateStore::open(&path).unwrap();
assert_eq!(
store.device_identity().unwrap().unwrap().endpoint_id(),
identity.endpoint_id()
);
let net = NetworkId::from_bytes([7; 32]);
let key = identity.signing_key();
let config = crate::vip::VipConfig::new("api");
let fields = crate::vip::VipSet::default()
.edit(net, &key, 7, &config, None, &crate::Limits::default())
.unwrap();
store.conn.execute_batch("CREATE TRIGGER fail_vip_counter BEFORE INSERT ON own_record_version BEGIN SELECT RAISE(ABORT,'counter unavailable'); END;").unwrap();
assert!(
store
.put_vip_fields(net, &fields, Some((*key.public().as_bytes(), 7)))
.is_err()
);
assert!(store.vip_fields(net, 192).unwrap().is_empty());
store
.conn
.execute_batch("DROP TRIGGER fail_vip_counter;")
.unwrap();
store
.put_vip_fields(net, &fields, Some((*key.public().as_bytes(), 7)))
.unwrap();
assert_eq!(store.vip_fields(net, 192).unwrap(), fields);
assert_eq!(store.own_record_version(net, key.public()).unwrap(), 7);
drop(store);
let reopened = StateStore::open(path).unwrap();
assert_eq!(reopened.vip_fields(net, 192).unwrap(), fields);
reopened
.conn
.execute("UPDATE vip_fields SET data=?1", params![vec![0xffu8; 1025]])
.unwrap();
assert!(matches!(
reopened.vip_fields(net, 192),
Err(Error::StateCorrupted { .. })
));
}
}
+239
View File
@@ -0,0 +1,239 @@
//! Named, shared virtual services. Signed field registers converge without an
//! owner; routing, health and connection state remain local to each client.
use crate::Limits;
use crate::error::{Error, Result};
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::net::Ipv4Addr;
pub(crate) mod packet;
pub(crate) mod router;
pub use crate::state::vip::{VipEntry, VipSet};
pub(crate) use router::VipRouter;
#[cfg(test)]
mod tests;
/// IP transport carried by a listener.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
/// TCP.
Tcp,
/// UDP.
Udp,
}
impl std::fmt::Display for Protocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Tcp => "tcp",
Self::Udp => "udp",
})
}
}
impl std::str::FromStr for Protocol {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, String> {
match s {
"tcp" => Ok(Self::Tcp),
"udp" => Ok(Self::Udp),
_ => Err("protocol must be tcp or udp".into()),
}
}
}
/// A node identity and its application port.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Backend {
/// Full device identity, resolved when editing.
pub peer: String,
/// Destination port.
pub port: u16,
}
/// One independently routed port of a VIP.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Listener {
/// Stable listener generation; prevents removed backend fields resurfacing on re-creation.
pub id: String,
/// TCP or UDP.
pub protocol: Protocol,
/// Client-facing port.
pub port: u16,
/// Enable active TCP connect checks; UDP uses path checks and ICMP errors.
pub port_check: bool,
/// Period between checks in milliseconds.
pub check_interval_ms: u32,
/// Deadline of a port check in milliseconds.
pub check_timeout_ms: u32,
/// Consecutive failed checks before exclusion.
pub failure_threshold: u8,
/// Consecutive successes before recovery.
pub recovery_threshold: u8,
/// Idle expiry for this listener's flows, in seconds.
pub idle_timeout_secs: u32,
/// Allowed destination nodes and ports.
pub backends: Vec<Backend>,
}
impl Listener {
/// Creates a listener with automatic recovery and bounded checks.
pub fn new(protocol: Protocol, port: u16) -> Self {
Self {
id: hex::encode(rand::random::<[u8; 16]>()),
protocol,
port,
port_check: true,
check_interval_ms: 3000,
check_timeout_ms: 2000,
failure_threshold: 2,
recovery_threshold: 1,
idle_timeout_secs: if protocol == Protocol::Tcp { 7200 } else { 120 },
backends: vec![],
}
}
}
/// Entire editable service; its address is an internal derived reservation.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VipConfig {
/// Stable random 128-bit id in hexadecimal.
pub id: String,
/// One DNS label, unique within the network.
pub name: String,
/// Independent listeners.
pub listeners: Vec<Listener>,
}
impl VipConfig {
/// Creates an empty named service with a fresh stable identity.
pub fn new(name: impl Into<String>) -> Self {
Self {
id: hex::encode(rand::random::<[u8; 16]>()),
name: name.into(),
listeners: vec![],
}
}
/// Validates a complete draft before any state is changed.
pub fn validate(&self, limits: &Limits) -> Result<()> {
if self.id.len() != 32 || hex::decode(&self.id).is_err() {
return Err(invalid("invalid VIP id"));
}
if self.name.is_empty()
|| self.name.len() > 63
|| self.name.starts_with('-')
|| self.name.ends_with('-')
|| !self
.name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err(invalid(
"VIP name must be a lowercase DNS label (letters, digits and hyphens)",
));
}
if self.listeners.len() > limits.max_vip_listeners {
return Err(invalid("too many VIP listeners"));
}
let mut seen = BTreeSet::new();
for l in &self.listeners {
if l.id.len() != 32 || hex::decode(&l.id).is_err() {
return Err(invalid("invalid listener identity"));
}
if l.port == 0 || !seen.insert((l.protocol, l.port)) {
return Err(invalid(
"listener ports must be nonzero and unique per protocol",
));
}
if !(250..=3_600_000).contains(&l.check_interval_ms)
|| !(100..=60_000).contains(&l.check_timeout_ms)
|| l.failure_threshold == 0
|| l.recovery_threshold == 0
|| !(1..=604800).contains(&l.idle_timeout_secs)
{
return Err(invalid(
"invalid check interval, timeout, thresholds or flow idle timeout",
));
}
if l.backends.len() > limits.max_vip_backends {
return Err(invalid("too many listener backends"));
}
let mut backends = BTreeSet::new();
for b in &l.backends {
if b.port == 0 || b.peer.parse::<EndpointId>().is_err() || !backends.insert(b) {
return Err(invalid(
"backend must have a unique node identity and a nonzero port",
));
}
}
}
if crate::state::vip::flatten(self)?.len() > limits.max_vip_fields {
return Err(invalid("VIP configuration exceeds signed field capacity"));
}
Ok(())
}
}
pub(crate) fn invalid(reason: impl Into<String>) -> Error {
Error::Rejected {
reason: reason.into(),
}
}
/// Cumulative IP traffic, excluding probes. Reset on agent restart.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Counters {
/// Outbound packets.
pub tx_packets: u64,
/// Outbound bytes.
pub tx_bytes: u64,
/// Inbound packets.
pub rx_packets: u64,
/// Inbound bytes.
pub rx_bytes: u64,
/// Created flow bindings.
pub flows: u64,
/// UDP failovers.
pub failovers: u64,
/// Packets rejected or lacking an eligible backend.
pub dropped: u64,
}
/// One backend, as seen by this client.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackendStatus {
/// Configured endpoint.
pub backend: Backend,
/// Eligibility and reason.
pub state: String,
/// Smoothed full data-plane RTT, microseconds; unknown stays absent.
pub rtt_us: Option<u64>,
/// Last successful path measurement age.
pub measurement_age_ms: Option<u64>,
/// Current bindings.
pub active_flows: u32,
/// Local traffic counters.
pub counters: Counters,
}
/// Runtime view of one listener.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListenerStatus {
/// Protocol.
pub protocol: Protocol,
/// Frontend port.
pub port: u16,
/// Best backend for a new flow, if measured and eligible.
pub selected: Option<Backend>,
/// Configured and draining endpoints.
pub backends: Vec<BackendStatus>,
/// Local counters.
pub counters: Counters,
}
/// Configuration and local diagnostics returned through the same API to CLI and GUI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VipStatus {
/// Persisted configuration.
pub config: VipConfig,
/// Opaque revision used when saving a draft.
pub revision: String,
/// Internal service address.
pub address: Option<Ipv4Addr>,
/// Conflict or allocation issue, if any.
pub error: Option<String>,
/// Local listener status.
pub listeners: Vec<ListenerStatus>,
}
+566
View File
@@ -0,0 +1,566 @@
//! Bounded IPv4 parsing, NAT checksums and fragment reassembly at local ingress.
use super::Protocol;
use bytes::Bytes;
use std::collections::HashMap;
use std::net::Ipv4Addr;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct Tuple {
pub src: Ipv4Addr,
pub dst: Ipv4Addr,
pub sport: u16,
pub dport: u16,
pub protocol: Protocol,
}
impl Tuple {
pub fn reverse(self) -> Self {
Self {
src: self.dst,
dst: self.src,
sport: self.dport,
dport: self.sport,
protocol: self.protocol,
}
}
}
pub(crate) fn header(p: &[u8]) -> Option<usize> {
if p.len() < 20 || p[0] >> 4 != 4 {
return None;
}
let n = usize::from(p[0] & 15) * 4;
let total = usize::from(u16::from_be_bytes([p[2], p[3]]));
(n >= 20 && n <= p.len() && total == p.len() && checksum(&p[..n]) == 0).then_some(n)
}
pub(crate) fn addresses(p: &[u8]) -> Option<(Ipv4Addr, Ipv4Addr)> {
header(p)?;
Some((
Ipv4Addr::new(p[12], p[13], p[14], p[15]),
Ipv4Addr::new(p[16], p[17], p[18], p[19]),
))
}
pub(crate) fn tuple(p: &[u8]) -> Option<Tuple> {
let n = header(p)?;
if u16::from_be_bytes([p[6], p[7]]) & 0x3fff != 0 {
return None;
}
let protocol = match p[9] {
6 => Protocol::Tcp,
17 => Protocol::Udp,
_ => return None,
};
let min = if protocol == Protocol::Tcp { 20 } else { 8 };
if p.len() < n + min {
return None;
}
if protocol == Protocol::Tcp {
let h = usize::from(p[n + 12] >> 4) * 4;
if h < 20 || n + h > p.len() {
return None;
}
} else if usize::from(u16::from_be_bytes([p[n + 4], p[n + 5]])) != p.len() - n {
return None;
}
let (src, dst) = addresses(p)?;
Some(Tuple {
src,
dst,
sport: u16::from_be_bytes([p[n], p[n + 1]]),
dport: u16::from_be_bytes([p[n + 2], p[n + 3]]),
protocol,
})
}
pub(crate) fn rewrite(p: &[u8], t: Tuple) -> Option<Bytes> {
let n = header(p)?;
if p.len() < n + if t.protocol == Protocol::Tcp { 20 } else { 8 } {
return None;
}
let mut out = p.to_vec();
out[12..16].copy_from_slice(&t.src.octets());
out[16..20].copy_from_slice(&t.dst.octets());
out[n..n + 2].copy_from_slice(&t.sport.to_be_bytes());
out[n + 2..n + 4].copy_from_slice(&t.dport.to_be_bytes());
finish(&mut out, n, t.protocol);
Some(Bytes::from(out))
}
fn finish(p: &mut [u8], n: usize, protocol: Protocol) {
p[10..12].fill(0);
let c = checksum(&p[..n]);
p[10..12].copy_from_slice(&c.to_be_bytes());
let offset = n + if protocol == Protocol::Tcp { 16 } else { 6 };
p[offset..offset + 2].fill(0);
let mut sum = 0u32;
for pair in p[12..20].as_chunks::<2>().0 {
sum += u32::from(u16::from_be_bytes([pair[0], pair[1]]));
}
sum += u32::from(p[9]) + (p.len() - n) as u32;
let c = checksum_seed(&p[n..], sum);
let c = if c == 0 && protocol == Protocol::Udp {
0xffff
} else {
c
};
p[offset..offset + 2].copy_from_slice(&c.to_be_bytes());
}
pub(crate) fn checksum(p: &[u8]) -> u16 {
checksum_seed(p, 0)
}
fn checksum_seed(p: &[u8], mut sum: u32) -> u16 {
for pair in p.chunks(2) {
sum += u32::from(u16::from_be_bytes([pair[0], *pair.get(1).unwrap_or(&0)]));
}
while sum >> 16 != 0 {
sum = (sum & 0xffff) + (sum >> 16);
}
!(sum as u16)
}
pub(crate) fn tcp_syn(p: &[u8]) -> bool {
header(p).is_some_and(|n| p[9] == 6 && p.get(n + 13).is_some_and(|f| f & 0x12 == 2))
}
pub(crate) fn syn_sequence(p: &[u8]) -> Option<u32> {
if !tcp_syn(p) {
return None;
}
let n = header(p)?;
let bytes = p.get(n + 4..n + 8)?;
Some(u32::from_be_bytes(bytes.try_into().ok()?))
}
pub(crate) fn empty_syn(p: &[u8]) -> bool {
header(p).is_some_and(|n| {
tcp_syn(p)
&& p.get(n + 12)
.is_some_and(|h| n + usize::from(h >> 4) * 4 == p.len())
})
}
pub(crate) fn syn_ack(p: &[u8]) -> bool {
header(p).is_some_and(|n| p[9] == 6 && p.get(n + 13).is_some_and(|f| f & 0x12 == 0x12))
}
pub(crate) fn tcp_reset(p: &[u8]) -> bool {
header(p).is_some_and(|n| p[9] == 6 && p.get(n + 13).is_some_and(|f| f & 4 != 0))
}
/// Rewrites an ICMP error and its quoted original header after NAT lookup.
pub(crate) fn icmp_quote(p: &[u8]) -> Option<Tuple> {
let n = header(p)?;
if p[9] != 1 || p.len() < n + 8 + 28 || ![3, 11, 12].contains(&p[n]) {
return None;
}
let q = &p[n + 8..];
let h = usize::from(q[0] & 15) * 4;
if q[0] >> 4 != 4 || h < 20 || q.len() < h + 8 {
return None;
}
let protocol = match q[9] {
6 => Protocol::Tcp,
17 => Protocol::Udp,
_ => return None,
};
Some(Tuple {
src: Ipv4Addr::new(q[12], q[13], q[14], q[15]),
dst: Ipv4Addr::new(q[16], q[17], q[18], q[19]),
sport: u16::from_be_bytes([q[h], q[h + 1]]),
dport: u16::from_be_bytes([q[h + 2], q[h + 3]]),
protocol,
})
}
pub(crate) fn rewrite_icmp(p: &[u8], original: Tuple) -> Option<Bytes> {
let n = header(p)?;
icmp_quote(p)?;
let mut out = p.to_vec();
out[12..16].copy_from_slice(&original.dst.octets());
out[16..20].copy_from_slice(&original.src.octets());
let q = &mut out[n + 8..];
let h = usize::from(q[0] & 15) * 4;
// Incrementally update the quoted transport checksum when it is present.
let off = h + if original.protocol == Protocol::Tcp {
16
} else {
6
};
if q.len() >= off + 2 {
let old = u16::from_be_bytes([q[off], q[off + 1]]);
if old != 0 || original.protocol == Protocol::Tcp {
let mut sum = u32::from(!old);
let mut replacement = Vec::from(original.src.octets());
replacement.extend(original.dst.octets());
replacement.extend(original.sport.to_be_bytes());
replacement.extend(original.dport.to_be_bytes());
let previous = [&q[12..20], &q[h..h + 4]].concat();
for (a, b) in previous
.as_chunks::<2>()
.0
.iter()
.zip(replacement.as_chunks::<2>().0)
{
sum += u32::from(!u16::from_be_bytes([a[0], a[1]]))
+ u32::from(u16::from_be_bytes([b[0], b[1]]));
}
while sum >> 16 != 0 {
sum = (sum & 0xffff) + (sum >> 16);
}
let c = !(sum as u16);
q[off..off + 2].copy_from_slice(
&(if c == 0 && original.protocol == Protocol::Udp {
0xffff
} else {
c
})
.to_be_bytes(),
);
}
}
q[12..16].copy_from_slice(&original.src.octets());
q[16..20].copy_from_slice(&original.dst.octets());
q[h..h + 2].copy_from_slice(&original.sport.to_be_bytes());
q[h + 2..h + 4].copy_from_slice(&original.dport.to_be_bytes());
q[10..12].fill(0);
let c = checksum(&q[..h]);
q[10..12].copy_from_slice(&c.to_be_bytes());
out[n + 2..n + 4].fill(0);
let c = checksum(&out[n..]);
out[n + 2..n + 4].copy_from_slice(&c.to_be_bytes());
out[10..12].fill(0);
let c = checksum(&out[..n]);
out[10..12].copy_from_slice(&c.to_be_bytes());
Some(Bytes::from(out))
}
pub(crate) fn reject(p: &[u8]) -> Option<Bytes> {
let t = tuple(p)?;
let n = header(p)?;
if t.protocol == Protocol::Tcp {
if !tcp_syn(p) {
return None;
}
let mut out = vec![0; 40];
out[0] = 0x45;
out[2..4].copy_from_slice(&40u16.to_be_bytes());
out[8] = 64;
out[9] = 6;
out[12..16].copy_from_slice(&t.dst.octets());
out[16..20].copy_from_slice(&t.src.octets());
out[20..22].copy_from_slice(&t.dport.to_be_bytes());
out[22..24].copy_from_slice(&t.sport.to_be_bytes());
let seq = u32::from_be_bytes(p[n + 4..n + 8].try_into().ok()?);
out[28..32].copy_from_slice(&seq.wrapping_add(1).to_be_bytes());
out[32] = 0x50;
out[33] = 0x14;
finish(&mut out, 20, Protocol::Tcp);
Some(Bytes::from(out))
} else {
let q = p.len().min(n + 8);
let mut out = vec![0; 28 + q];
out[0] = 0x45;
let len = out.len() as u16;
out[2..4].copy_from_slice(&len.to_be_bytes());
out[8] = 64;
out[9] = 1;
out[12..16].copy_from_slice(&t.dst.octets());
out[16..20].copy_from_slice(&t.src.octets());
out[20] = 3;
out[21] = 3;
out[28..].copy_from_slice(&p[..q]);
let c = checksum(&out[20..]);
out[22..24].copy_from_slice(&c.to_be_bytes());
let c = checksum(&out[..20]);
out[10..12].copy_from_slice(&c.to_be_bytes());
Some(Bytes::from(out))
}
}
#[derive(Debug)]
struct Datagram {
at: Instant,
header: Option<Vec<u8>>,
parts: std::collections::BTreeMap<usize, Vec<u8>>,
end: Option<usize>,
bytes: usize,
}
type FragmentKey = (Option<[u8; 32]>, Ipv4Addr, Ipv4Addr, u16, u8);
#[derive(Debug, Default)]
pub(crate) struct Reassembly {
pending: HashMap<FragmentKey, Datagram>,
}
impl Reassembly {
pub fn expire(&mut self, now: Instant) {
self.pending
.retain(|_, d| now.duration_since(d.at) < Duration::from_secs(5));
}
pub fn accept(&mut self, p: Bytes, max: usize, scope: Option<[u8; 32]>) -> Option<Bytes> {
let n = header(&p)?;
let flags = u16::from_be_bytes([p[6], p[7]]);
if flags & 0x3fff == 0 {
return Some(p);
}
let now = Instant::now();
self.pending
.retain(|_, d| now.duration_since(d.at) < Duration::from_secs(5));
let (src, dst) = addresses(&p)?;
let key = (scope, src, dst, u16::from_be_bytes([p[4], p[5]]), p[9]);
let start = usize::from(flags & 0x1fff) * 8;
let payload = &p[n..];
let end = start.checked_add(payload.len())?;
if payload.is_empty()
|| end > 65515
|| flags & 0x2000 != 0 && !payload.len().is_multiple_of(8)
{
self.pending.remove(&key);
return None;
}
if !self.pending.contains_key(&key) && self.pending.len() >= max {
return None;
}
let d = self.pending.entry(key).or_insert_with(|| Datagram {
at: now,
header: None,
parts: Default::default(),
end: None,
bytes: 0,
});
if d.parts
.get(&start)
.is_some_and(|bytes| bytes.as_slice() == payload)
{
return None;
}
if d.parts.iter().any(|(s, b)| *s < end && start < s + b.len()) {
self.pending.remove(&key);
return None;
}
if flags & 0x2000 == 0 {
if d.end.is_some_and(|e| e != end) {
self.pending.remove(&key);
return None;
}
d.end = Some(end);
}
if d.end.is_some_and(|e| end > e) {
self.pending.remove(&key);
return None;
}
if start == 0 {
d.header = Some(p[..n].to_vec());
}
d.bytes += payload.len();
d.parts.insert(start, payload.to_vec());
let complete = d.end.is_some_and(|e| e == d.bytes) && d.header.is_some();
if !complete {
return None;
}
let d = self.pending.remove(&key)?;
let mut out = d.header?;
for part in d.parts.values() {
out.extend(part);
}
if out.len() > 65535 {
return None;
}
let len = out.len() as u16;
out[2..4].copy_from_slice(&len.to_be_bytes());
out[6..8].fill(0);
out[10..12].fill(0);
let h = usize::from(out[0] & 15) * 4;
let c = checksum(&out[..h]);
out[10..12].copy_from_slice(&c.to_be_bytes());
Some(Bytes::from(out))
}
}
/// Re-fragments an assembled NAT datagram for the unchanged overlay MTU.
pub(crate) fn fragments(packet: Bytes, mtu: u32) -> Vec<Bytes> {
if packet.len() <= mtu as usize {
return vec![packet];
}
let Some(n) = header(&packet) else {
return vec![];
};
if u16::from_be_bytes([packet[6], packet[7]]) & 0x4000 != 0 {
return vec![];
}
let size = (mtu as usize).saturating_sub(n) / 8 * 8;
if size == 0 {
return vec![];
}
let mut copied = Vec::new();
let mut offset = 20;
while offset < n {
let kind = packet[offset];
if kind == 0 {
break;
}
if kind == 1 {
offset += 1;
continue;
}
let Some(&len) = packet.get(offset + 1) else {
return vec![];
};
let len = usize::from(len);
if len < 2 || offset + len > n {
return vec![];
}
if kind & 0x80 != 0 {
copied.extend_from_slice(&packet[offset..offset + len]);
}
offset += len;
}
while !copied.len().is_multiple_of(4) {
copied.push(0);
}
let mut result = Vec::new();
let payload = &packet[n..];
for (start, part) in payload.chunks(size).enumerate() {
let offset = start * size;
let mut out = if offset == 0 {
packet[..n].to_vec()
} else {
let mut h = packet[..20].to_vec();
h.extend_from_slice(&copied);
h[0] = 0x40 | (h.len() / 4) as u8;
h
};
let header_len = out.len();
out.extend(part);
let len = out.len() as u16;
out[2..4].copy_from_slice(&len.to_be_bytes());
let flags = (offset / 8) as u16
| if offset + part.len() < payload.len() {
0x2000
} else {
0
};
out[6..8].copy_from_slice(&flags.to_be_bytes());
out[10..12].fill(0);
let c = checksum(&out[..header_len]);
out[10..12].copy_from_slice(&c.to_be_bytes());
result.push(Bytes::from(out));
}
result
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn datagram() -> Bytes {
let mut p = vec![0; 3028];
p[0] = 0x45;
p[2..4].copy_from_slice(&3028u16.to_be_bytes());
p[8] = 64;
p[9] = 17;
p[12..16].copy_from_slice(&[10, 0, 0, 1]);
p[16..20].copy_from_slice(&[10, 0, 0, 2]);
p[20..22].copy_from_slice(&1000u16.to_be_bytes());
p[22..24].copy_from_slice(&2000u16.to_be_bytes());
p[24..26].copy_from_slice(&3008u16.to_be_bytes());
finish(&mut p, 20, Protocol::Udp);
Bytes::from(p)
}
#[test]
fn fragmented_udp_reassembles_out_of_order_and_nat_checksums_are_valid() {
let p = datagram();
let parts = fragments(p.clone(), 1280);
assert_eq!(parts.len(), 3);
let mut reassembly = Reassembly::default();
let mut result = None;
for part in parts.into_iter().rev() {
if let Some(p) = reassembly.accept(part, 8, None) {
result = Some(p);
}
}
assert_eq!(result, Some(p.clone()));
let mut t = tuple(&p).unwrap();
t.dst = Ipv4Addr::new(10, 0, 0, 240);
t.dport = 53;
let out = rewrite(&p, t).unwrap();
assert_eq!(header(&out), Some(20));
let mut sum = 0u32;
for pair in out[12..20].as_chunks::<2>().0 {
sum += u32::from(u16::from_be_bytes([pair[0], pair[1]]));
}
sum += 17 + (out.len() - 20) as u32;
assert_eq!(checksum_seed(&out[20..], sum), 0);
}
#[test]
fn overlaps_and_capacity_are_rejected_without_allocating_unbounded_state() {
let parts = fragments(datagram(), 1280);
let mut r = Reassembly::default();
assert!(r.accept(parts[0].clone(), 0, None).is_none());
assert!(r.pending.is_empty());
assert!(r.accept(parts[0].clone(), 1, None).is_none());
assert!(r.accept(parts[0].clone(), 1, None).is_none());
assert_eq!(r.pending.len(), 1);
let mut conflict = parts[0].to_vec();
conflict[20] ^= 1;
assert!(r.accept(Bytes::from(conflict), 1, None).is_none());
assert!(r.pending.is_empty());
for size in 0..80 {
let p = vec![0xff; size];
assert!(tuple(&p).is_none());
assert!(icmp_quote(&p).is_none());
}
}
#[test]
fn fragment_assembly_is_scoped_to_authenticated_sender_and_expires_when_idle() {
let packet = datagram();
let parts = fragments(packet.clone(), 1280);
let mut r = Reassembly::default();
assert!(r.accept(parts[0].clone(), 8, Some([1; 32])).is_none());
assert!(r.accept(parts[1].clone(), 8, Some([2; 32])).is_none());
assert!(r.accept(parts[2].clone(), 8, Some([1; 32])).is_none());
assert_eq!(r.accept(parts[1].clone(), 8, Some([1; 32])), Some(packet));
assert_eq!(r.pending.len(), 1);
r.expire(Instant::now() + Duration::from_secs(6));
assert!(r.pending.is_empty());
}
}
#[cfg(test)]
mod icmp_tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn pmtu_errors_restore_quoted_addresses_ports_and_checksums() {
let original = Tuple {
src: Ipv4Addr::new(10, 0, 0, 1),
dst: Ipv4Addr::new(10, 0, 0, 240),
sport: 40000,
dport: 53,
protocol: Protocol::Udp,
};
let mut p = vec![0; 36];
p[0] = 0x45;
p[2..4].copy_from_slice(&36u16.to_be_bytes());
p[8] = 64;
p[9] = 17;
p[24..26].copy_from_slice(&16u16.to_be_bytes());
let c = checksum(&p[..20]);
p[10..12].copy_from_slice(&c.to_be_bytes());
let p = rewrite(&p, original).unwrap();
let wire = Tuple {
dst: Ipv4Addr::new(10, 0, 0, 2),
sport: 50000,
dport: 5353,
..original
};
let translated = rewrite(&p, wire).unwrap();
let mut error = vec![0; 56];
error[0] = 0x45;
error[2..4].copy_from_slice(&56u16.to_be_bytes());
error[8] = 64;
error[9] = 1;
error[12..16].copy_from_slice(&wire.dst.octets());
error[16..20].copy_from_slice(&wire.src.octets());
error[20] = 3;
error[21] = 4;
error[26..28].copy_from_slice(&1200u16.to_be_bytes());
error[28..].copy_from_slice(&translated[..28]);
let c = checksum(&error[20..]);
error[22..24].copy_from_slice(&c.to_be_bytes());
let c = checksum(&error[..20]);
error[10..12].copy_from_slice(&c.to_be_bytes());
let restored = rewrite_icmp(&error, original).unwrap();
assert_eq!(icmp_quote(&restored), Some(original));
assert_eq!(checksum(&restored[20..]), 0);
assert_eq!(checksum(&restored[28..48]), 0);
assert_eq!(&restored[54..56], &p[26..28]);
assert_eq!(&restored[26..28], &1200u16.to_be_bytes());
assert_eq!(&restored[12..16], &original.dst.octets());
}
}
File diff suppressed because it is too large. Load diff
+144
View File
@@ -0,0 +1,144 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::NetworkId;
#[test]
fn concurrent_backend_edits_merge_and_deletion_cannot_resurrect() {
let net = NetworkId::from_bytes([7; 32]);
let a = iroh::SecretKey::generate();
let b = iroh::SecretKey::generate();
let limits = Limits::default();
let mut state = VipSet::default();
let id = hex::encode([9; 16]);
let initial = VipConfig {
id: id.clone(),
name: "api".into(),
listeners: vec![Listener::new(Protocol::Tcp, 443)],
};
let changes = state.edit(net, &a, 1, &initial, None, &limits).unwrap();
state.merge(net, changes, &limits).unwrap();
let revision = state.revision(&id);
let mut first = initial.clone();
first.listeners[0].backends.push(Backend {
peer: a.public().to_string(),
port: 6443,
});
let mut second = initial.clone();
second.listeners[0].backends.push(Backend {
peer: b.public().to_string(),
port: 7443,
});
let x = state
.edit(net, &a, 2, &first, Some(&revision), &limits)
.unwrap();
let y = state
.edit(net, &b, 1, &second, Some(&revision), &limits)
.unwrap();
let mut reverse = state.clone();
state.merge(net, x.clone(), &limits).unwrap();
state.merge(net, y.clone(), &limits).unwrap();
reverse.merge(net, y, &limits).unwrap();
reverse.merge(net, x, &limits).unwrap();
assert_eq!(state.configs(), reverse.configs());
assert_eq!(state.configs()[0].listeners[0].backends.len(), 2);
assert!(
state
.edit(net, &a, 3, &initial, Some(&revision), &limits)
.is_err()
);
let removed = state.delete(net, &a, 3, &id, &state.revision(&id)).unwrap();
let old = state.entries(&id);
state.merge(net, removed, &limits).unwrap();
state.merge(net, old, &limits).unwrap();
assert!(state.configs().is_empty());
}
#[test]
fn signed_fields_reject_tampering_wrong_network_and_limits() {
let net = NetworkId::from_bytes([2; 32]);
let key = iroh::SecretKey::generate();
let limits = Limits::default();
let state = VipSet::default();
let cfg = VipConfig {
id: hex::encode([3; 16]),
name: "dns".into(),
..Default::default()
};
let entries = state.edit(net, &key, 1, &cfg, None, &limits).unwrap();
let mut bad = entries.clone();
bad[0].clock += 1;
assert!(VipSet::default().merge(net, bad, &limits).is_err());
assert!(
VipSet::default()
.merge(NetworkId::from_bytes([4; 32]), entries.clone(), &limits)
.is_err()
);
let mut small = limits.clone();
small.max_vips = 0;
assert!(VipSet::default().merge(net, entries, &small).is_err());
}
#[test]
fn deletion_history_does_not_exhaust_active_capacity_or_accept_stale_fields() {
let network = NetworkId::from_bytes([9; 32]);
let key = iroh::SecretKey::generate();
let limits = Limits {
max_vips: 1,
max_vip_tombstones: 4,
..Default::default()
};
let mut state = VipSet::default();
for index in 0..4 {
let config = VipConfig::new("reused-name");
let fields = state
.edit(network, &key, index * 2 + 1, &config, None, &limits)
.unwrap();
state.merge(network, fields.clone(), &limits).unwrap();
let deletion = state
.delete(
network,
&key,
index * 2 + 2,
&config.id,
&state.revision(&config.id),
)
.unwrap();
state.merge(network, deletion, &limits).unwrap();
assert_eq!(state.entries(&config.id).len(), 1);
assert!(!state.merge(network, fields, &limits).unwrap());
}
let next = VipConfig::new("next");
let fields = state.edit(network, &key, 9, &next, None, &limits).unwrap();
state.merge(network, fields, &limits).unwrap();
assert_eq!(state.configs().len(), 1);
}
#[test]
fn reservations_avoid_nodes_and_name_conflicts_have_a_deterministic_holder() {
let network = NetworkId::from_bytes([4; 32]);
let key = iroh::SecretKey::generate();
let limits = Limits::default();
let mut state = VipSet::default();
for version in 1..=2 {
let config = VipConfig::new("shared-name");
let fields = state
.edit(network, &key, version, &config, None, &limits)
.unwrap();
state.merge(network, fields, &limits).unwrap();
}
let range = "10.0.0.0/29".parse().unwrap();
let taken = ["10.0.0.1".parse().unwrap()].into_iter().collect();
let views = state.views(Some(range), taken, &Default::default(), &limits);
assert!(views[0].error.is_none());
assert!(
views[1]
.error
.as_deref()
.unwrap()
.contains("name conflicts")
);
assert_ne!(views[0].address, views[1].address);
for v in views {
assert_ne!(v.address, Some("10.0.0.1".parse().unwrap()));
}
}
+358
View File
@@ -0,0 +1,358 @@
//! Equal-rights retirement uses real membership, signatures and persistent state.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::testing::{TestAgent, config_with, network, wait_for_peers, wait_until};
use tsunagi::{Agent, NetworkId};
#[tokio::test]
async fn bounded_marker_batches_eventually_reach_the_tail_of_a_small_send_queue() {
use tsunagi::config::StoragePaths;
use tsunagi::state::{RecordBody, SignedRecord, SignedRetirement};
use tsunagi::storage::StateStore;
let discovery = SharedMemoryDiscovery::new();
let cleaner = TestAgent::spawn(&discovery).await.unwrap();
let replica = TestAgent::spawn(&discovery).await.unwrap();
let (name, secret) = network("forget-batched-history");
let id = cleaner.agent.join_network(&name, &secret).await.unwrap();
replica.agent.join_network(&name, &secret).await.unwrap();
let cleaner_dir = cleaner.stop().await;
let replica_dir = replica.stop().await;
let cleaner_path = StoragePaths::under(cleaner_dir.path()).state_db();
let replica_path = StoragePaths::under(replica_dir.path()).state_db();
// Seed genuine signed offline history in closed stores. Delivery still uses
// real agents, authenticated loopback sessions and the persistent stores.
let targets = tokio::task::spawn_blocking(move || {
let cleaner = StateStore::open(&cleaner_path).unwrap();
let replica = StateStore::open(&replica_path).unwrap();
let author = cleaner.device_identity().unwrap().unwrap();
let signing_key = iroh::SecretKey::from_bytes(&author.secret_bytes());
let mut targets = Vec::new();
for index in 0..12 {
let target = iroh::SecretKey::generate();
let record = SignedRecord::sign(
&target,
id,
1,
RecordBody::Claim {
address: None,
range: None,
hostname: Some(format!("offline-{index}")),
hostname_since: Some(1),
},
);
cleaner.put_signed_record(&record).unwrap();
replica.put_signed_record(&record).unwrap();
cleaner
.publish_own_retirement(&SignedRetirement::sign(
&signing_key,
id,
100 + index,
record,
))
.unwrap();
targets.push(target.public());
}
targets
})
.await
.unwrap();
let replica = Agent::spawn(config_with(replica_dir.path(), &discovery))
.await
.unwrap();
assert_eq!(
replica
.network_status(id)
.await
.unwrap()
.members
.iter()
.filter(|m| targets.contains(&m.endpoint_id))
.count(),
12
);
let mut config = config_with(cleaner_dir.path(), &discovery);
config.limits.max_state_records = 1;
config.limits.session_send_queue = 2;
let cleaner = Agent::spawn(config).await.unwrap();
wait_for_peers(&cleaner, id, 1).await;
wait_until(
"all cleanup batches arrive despite send queue pressure",
|| async {
replica
.network_status(id)
.await
.ok()?
.members
.iter()
.all(|m| !targets.contains(&m.endpoint_id))
.then_some(())
},
)
.await;
cleaner.shutdown().await;
replica.shutdown().await;
let replica_path = StoragePaths::under(replica_dir.path()).state_db();
tokio::task::spawn_blocking(move || {
let replica = StateStore::open(&replica_path).unwrap();
assert_eq!(replica.retirements(id, 16).unwrap().len(), 12);
})
.await
.unwrap();
}
async fn member(
agent: &Agent,
network: NetworkId,
peer: iroh::EndpointId,
) -> tsunagi::agent::MemberStatus {
wait_until("signed member", || async {
agent
.network_status(network)
.await
.ok()?
.members
.into_iter()
.find(|m| m.endpoint_id == peer)
})
.await
}
#[tokio::test]
async fn retirement_survives_restart_and_stale_replicas_but_allows_a_new_claim() {
let discovery = SharedMemoryDiscovery::new();
let cleaner = TestAgent::spawn_with(|c| c.with_hostname("cleaner"), &discovery)
.await
.unwrap();
let target = TestAgent::spawn_with(|c| c.with_hostname("old-device"), &discovery)
.await
.unwrap();
let replica = TestAgent::spawn_with(|c| c.with_hostname("replica"), &discovery)
.await
.unwrap();
let (name, secret) = network("forget-propagation");
let id = cleaner.agent.join_network(&name, &secret).await.unwrap();
target.agent.join_network(&name, &secret).await.unwrap();
replica.agent.join_network(&name, &secret).await.unwrap();
wait_for_peers(&cleaner.agent, id, 2).await;
let peer = target.agent.endpoint_id();
let original = wait_until("all three agents agree on allocated claims", || async {
let a = cleaner.agent.network_status(id).await.ok()?;
let b = target.agent.network_status(id).await.ok()?;
let c = replica.agent.network_status(id).await.ok()?;
for status in [&a, &b, &c] {
if status.members.len() != 3 {
return None;
}
let addresses: std::collections::HashSet<_> = status
.members
.iter()
.filter_map(|m| m.overlay_address_v4)
.collect();
if addresses.len() != 3 {
return None;
}
}
let record = a.members.iter().find(|m| m.endpoint_id == peer)?;
(b.members
.iter()
.find(|m| m.endpoint_id == peer)?
.record_version
== record.record_version
&& c.members
.iter()
.find(|m| m.endpoint_id == peer)?
.record_version
== record.record_version)
.then(|| record.clone())
})
.await;
assert!(
cleaner
.agent
.forget_node(id, peer, original.record_version)
.await
.is_err(),
"online nodes are not history"
);
assert!(
cleaner
.agent
.forget_node(id, cleaner.agent.endpoint_id(), 1)
.await
.is_err()
);
let replica_dir = replica.stop().await;
let target_dir = target.stop().await;
wait_until("target offline", || async {
cleaner
.agent
.network_status(id)
.await
.ok()?
.members
.iter()
.find(|m| m.endpoint_id == peer)
.filter(|m| !m.online)
.map(|_| ())
})
.await;
assert!(
cleaner
.agent
.forget_node(id, peer, original.record_version + 1)
.await
.is_err(),
"stale confirmation changes nothing"
);
let result = cleaner
.agent
.forget_node(id, peer, original.record_version)
.await
.unwrap();
assert_eq!(result.target, peer);
assert_eq!(result.record_version, original.record_version);
assert_eq!(result.peers_told, 0);
assert!(
!cleaner
.agent
.network_status(id)
.await
.unwrap()
.members
.iter()
.any(|m| m.endpoint_id == peer)
);
let cleaner_dir = cleaner.stop().await;
let cleaner =
Agent::spawn(config_with(cleaner_dir.path(), &discovery).with_hostname("cleaner"))
.await
.unwrap();
let replica =
Agent::spawn(config_with(replica_dir.path(), &discovery).with_hostname("replica"))
.await
.unwrap();
wait_for_peers(&cleaner, id, 1).await;
wait_until("stale replica learns durable retirement", || async {
replica
.network_status(id)
.await
.ok()?
.members
.iter()
.all(|m| m.endpoint_id != peer)
.then_some(())
})
.await;
assert!(
!cleaner
.network_status(id)
.await
.unwrap()
.members
.iter()
.any(|m| m.endpoint_id == peer)
);
replica.set_hostname("old-device").await.unwrap();
let replica_id = replica.endpoint_id();
wait_until("released hostname is reclaimed", || async {
cleaner
.network_status(id)
.await
.ok()?
.members
.iter()
.find(|m| m.endpoint_id == replica_id)
.filter(|m| m.hostname.as_deref() == Some("old-device"))?
.hostname_conflict
.is_none()
.then_some(())
})
.await;
let returned =
Agent::spawn(config_with(target_dir.path(), &discovery).with_hostname("old-device"))
.await
.unwrap();
wait_for_peers(&returned, id, 2).await;
wait_until("returning identity publishes a fresh version", || async {
let m = cleaner
.network_status(id)
.await
.ok()?
.members
.into_iter()
.find(|m| m.endpoint_id == peer)?;
(m.online && m.record_version > original.record_version && m.hostname_conflict.is_some())
.then_some(())
})
.await;
cleaner.shutdown().await;
replica.shutdown().await;
returned.shutdown().await;
}
#[tokio::test]
async fn forgetting_is_network_scoped_and_every_member_has_the_same_right() {
let discovery = SharedMemoryDiscovery::new();
let a = TestAgent::spawn(&discovery).await.unwrap();
let b = TestAgent::spawn(&discovery).await.unwrap();
let target = TestAgent::spawn(&discovery).await.unwrap();
let (name, secret) = network("forget-one-network");
let (other_name, other_secret) = network("keep-other-network");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
target.agent.join_network(&name, &secret).await.unwrap();
let other = a
.agent
.join_network(&other_name, &other_secret)
.await
.unwrap();
target
.agent
.join_network(&other_name, &other_secret)
.await
.unwrap();
let peer = target.agent.endpoint_id();
let record = member(&b.agent, id, peer).await;
member(&a.agent, other, peer).await;
target.agent.shutdown().await;
wait_until("offline on B", || async {
b.agent
.network_status(id)
.await
.ok()?
.members
.iter()
.find(|m| m.endpoint_id == peer)
.filter(|m| !m.online)
.map(|_| ())
})
.await;
b.agent
.forget_node(id, peer, record.record_version)
.await
.unwrap();
wait_until("A learns B's retirement", || async {
a.agent
.network_status(id)
.await
.ok()?
.members
.iter()
.all(|m| m.endpoint_id != peer)
.then_some(())
})
.await;
assert!(
a.agent
.network_status(other)
.await
.unwrap()
.members
.iter()
.any(|m| m.endpoint_id == peer)
);
a.agent.shutdown().await;
b.agent.shutdown().await;
}
@@ -0,0 +1,135 @@
//! The local candidate filter must affect actual hole-punch addresses, not only publications.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use iroh::{
Endpoint, RelayMode, TransportAddr,
endpoint::{PortmapperConfig, presets},
};
use std::borrow::Cow;
#[tokio::test]
async fn direct_candidate_filter_changes_the_address_watch_used_by_connections() {
let observed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let called = observed.clone();
let endpoint = Endpoint::builder(presets::Minimal)
.relay_mode(RelayMode::Disabled)
.clear_address_lookup()
.portmapper_config(PortmapperConfig::Disabled)
.net_report_config(iroh::NetReportConfig::minimal())
.clear_ip_transports()
.bind_addr("127.0.0.1:0")
.unwrap()
// Stand in for addresses on the agent's own overlay interface.
.direct_addr_filter(iroh::address_lookup::AddrFilter::new(move |addrs| {
if addrs
.iter()
.any(|addr| matches!(addr, TransportAddr::Ip(_)))
{
called.store(true, std::sync::atomic::Ordering::Relaxed);
}
Cow::Owned(
addrs
.iter()
.filter(|addr| !matches!(addr, TransportAddr::Ip(_)))
.cloned()
.collect(),
)
}))
.bind()
.await
.unwrap();
tsunagi::testing::wait_until("filter applied to actual bound candidates", || async {
observed
.load(std::sync::atomic::Ordering::Relaxed)
.then_some(())
})
.await;
assert!(
!endpoint.bound_sockets().is_empty(),
"real UDP sockets are still bound"
);
assert!(
endpoint.addr().ip_addrs().next().is_none(),
"excluded local paths never reach iroh's connection candidates"
);
endpoint.close().await;
}
#[tokio::test]
async fn carriers_exclude_the_kernel_assigned_interface_name_instead_of_the_requested_alias() {
use std::sync::{Arc, Mutex};
use tsunagi::dataplane::transport::DataCarrier;
use tsunagi::dataplane::{PluginCapability, PluginError};
use tsunagi::overlay::{MemoryTunFactory, OverlayError, TunDevice, TunFactory, TunRequest};
use tsunagi::{BoxFuture, NetworkId};
#[derive(Debug)]
struct RenamingFactory(MemoryTunFactory);
impl TunFactory for RenamingFactory {
fn name(&self) -> &str {
"kernel-name-test"
}
fn on_host(&self) -> bool {
false
}
fn create<'a>(
&'a self,
mut request: TunRequest,
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>> {
request.name = "kernel-assigned".into();
self.0.create(request)
}
fn reconfigure<'a>(
&'a self,
request: TunRequest,
) -> BoxFuture<'a, Result<(), OverlayError>> {
self.0.reconfigure(request)
}
fn destroy<'a>(&'a self, name: &'a str) -> BoxFuture<'a, ()> {
self.0.destroy(name)
}
}
#[derive(Debug, Default)]
struct Carrier(Mutex<Option<String>>);
impl DataCarrier for Carrier {
fn protocol_id(&self) -> &str {
"candidate-test"
}
fn protocol_version(&self) -> u16 {
1
}
fn on_interface_ready(&self, name: &str) {
*self.0.lock().unwrap() = Some(name.into());
}
fn local_capability(&self, _: NetworkId) -> Result<Option<PluginCapability>, PluginError> {
Ok(None)
}
fn on_peer_capability(
&self,
_: NetworkId,
_: iroh::EndpointId,
_: &PluginCapability,
) -> Result<(), PluginError> {
Ok(())
}
fn on_peer_gone(&self, _: NetworkId, _: iroh::EndpointId) {}
fn on_network_deactivated(&self, _: NetworkId) {}
}
let root = tempfile::TempDir::new().unwrap();
let discovery = tsunagi::discovery::SharedMemoryDiscovery::new();
let carrier = Arc::new(Carrier::default());
let config = tsunagi::testing::config_with(root.path(), &discovery)
.with_interface(
Arc::new(RenamingFactory(MemoryTunFactory::new())),
"requested-alias",
1280,
)
.with_carrier(carrier.clone());
let agent = tsunagi::Agent::spawn(config).await.unwrap();
assert_eq!(agent.overlay().unwrap().interface, "kernel-assigned");
assert_eq!(
carrier.0.lock().unwrap().as_deref(),
Some("kernel-assigned")
);
agent.shutdown().await;
}
@@ -0,0 +1,4 @@
//! Exercise the vendored retry admission in the default workspace suite.
//! Transport integration tests still use real iroh endpoints.
#[path = "../../../vendor/iroh/src/socket/remote_map/remote_state/pending_paths.rs"]
mod pending_paths;
+1 -1
View File
@@ -162,7 +162,7 @@ async fn rejoining_after_leaving_is_not_taken_for_a_stale_record() {
}) })
.await; .await;
assert!( assert!(
tsunagi::state::DEFAULT_IPV4_RANGE.contains(again), tsunagi::state::derived_ipv4_range(network_id).contains(again),
"an address in the network's range: {again}" "an address in the network's range: {again}"
); );
@@ -0,0 +1,97 @@
//! Signed hostname ownership remains independent of connectivity and identity order.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tsunagi::{
discovery::SharedMemoryDiscovery,
testing::{TestAgent, network, wait_for_peers, wait_until},
};
#[tokio::test]
async fn a_name_belongs_to_the_member_that_claimed_it_first_on_every_member() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("first-claim");
let spawn = |hostname: &'static str| {
let discovery = discovery.clone();
async move {
TestAgent::spawn_with(move |config| config.with_hostname(hostname), &discovery)
.await
.unwrap()
}
};
let first = spawn("music").await;
let network_id = first.agent.join_network(&name, &secret).await.unwrap();
let first_id = first.agent.endpoint_id();
wait_until("the first claim is signed", || async {
let status = first.agent.network_status(network_id).await.ok()?;
status
.members
.iter()
.any(|m| m.endpoint_id == first_id && m.hostname.as_deref() == Some("music"))
.then_some(())
})
.await;
// Whatever the ids turn out to be, the later one does not take it.
let second = spawn("music").await;
let third = spawn("studio").await;
second.agent.join_network(&name, &secret).await.unwrap();
third.agent.join_network(&name, &secret).await.unwrap();
let second_id = second.agent.endpoint_id();
for agent in [&first, &second, &third] {
wait_for_peers(&agent.agent, network_id, 2).await;
}
for (viewer, label) in [(&first, "first"), (&second, "second"), (&third, "third")] {
let status = wait_until(&format!("{label} sees both claims"), || async {
let status = viewer.agent.network_status(network_id).await.ok()?;
// The roster is the authors of signed records, so a member
// being in it means its claim has arrived.
let both = [first_id, second_id]
.iter()
.all(|id| status.members.iter().any(|m| m.endpoint_id == *id));
both.then_some(status)
})
.await;
let holder = status
.members
.iter()
.find(|m| m.endpoint_id == first_id)
.unwrap();
let loser = status
.members
.iter()
.find(|m| m.endpoint_id == second_id)
.unwrap();
assert_eq!(holder.hostname.as_deref(), Some("music"), "{label}");
assert_eq!(loser.hostname, None, "{label}: no name for the later one");
let conflict = loser.hostname_conflict.as_ref().expect(label);
assert_eq!(conflict.name, "music");
assert_eq!(conflict.holder, first_id);
assert!(holder.hostname_conflict.is_none(), "{label}");
// Nothing about being second makes it less of a member.
assert!(
loser.online || viewer.agent.endpoint_id() == second_id,
"{label}"
);
}
// The later one is told so itself, and keeps working.
let own = second.agent.network_status(network_id).await.unwrap();
let conflict = own.hostname_conflict.expect("told its name is taken");
assert_eq!(
(conflict.name.as_str(), conflict.holder),
("music", first_id)
);
assert!(
first
.agent
.network_status(network_id)
.await
.unwrap()
.hostname_conflict
.is_none()
);
first.stop().await;
second.stop().await;
third.stop().await;
}
+2 -2
View File
@@ -132,7 +132,7 @@ async fn opaque_plugin_capabilities_cross_the_control_plane() {
let a = TestAgent::spawn_with( let a = TestAgent::spawn_with(
{ {
let plugin = Arc::clone(&plugin_a); let plugin = Arc::clone(&plugin_a);
move |cfg| cfg.with_plugin(plugin) move |cfg| cfg.with_tunnel(plugin)
}, },
&discovery, &discovery,
) )
@@ -141,7 +141,7 @@ async fn opaque_plugin_capabilities_cross_the_control_plane() {
let b = TestAgent::spawn_with( let b = TestAgent::spawn_with(
{ {
let plugin = Arc::clone(&plugin_b); let plugin = Arc::clone(&plugin_b);
move |cfg| cfg.with_plugin(plugin) move |cfg| cfg.with_tunnel(plugin)
}, },
&discovery, &discovery,
) )
@@ -265,10 +265,7 @@ async fn deactivating_one_network_leaves_the_agent_and_others_running() {
#[tokio::test] #[tokio::test]
async fn two_networks_on_one_agent_each_get_a_range_of_their_own() { async fn two_networks_on_one_agent_each_get_a_range_of_their_own() {
// One agent, one interface: the configured range can only belong to one // Each network derives its range independently of activation order.
// of them. The second does not go without — it takes the range derived
// from its own id, which every one of its members derives identically,
// so it is an agreement and not a local invention.
let discovery = SharedMemoryDiscovery::new(); let discovery = SharedMemoryDiscovery::new();
let (first, first_secret) = network("two-ranges-one"); let (first, first_secret) = network("two-ranges-one");
let (second, second_secret) = network("two-ranges-two"); let (second, second_secret) = network("two-ranges-two");
@@ -309,8 +306,8 @@ async fn two_networks_on_one_agent_each_get_a_range_of_their_own() {
"different networks, different addresses" "different networks, different addresses"
); );
assert!( assert!(
tsunagi::state::DEFAULT_IPV4_RANGE.contains(addresses.0), tsunagi::state::derived_ipv4_range(one).contains(addresses.0),
"the first keeps the configured range: {}", "the first uses its derived range: {}",
addresses.0 addresses.0
); );
assert!( assert!(
@@ -319,7 +316,7 @@ async fn two_networks_on_one_agent_each_get_a_range_of_their_own() {
addresses.1 addresses.1
); );
assert!( assert!(
!tsunagi::state::DEFAULT_IPV4_RANGE.contains(addresses.1), !tsunagi::state::derived_ipv4_range(one).contains(addresses.1),
"and not in the one the first network holds" "and not in the one the first network holds"
); );
@@ -0,0 +1,336 @@
//! Derived defaults and local exclusion of overlapping overlay networks.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tsunagi::Agent;
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::state::{DEFAULT_IPV4_RANGE, Ipv4Range, derived_ipv4_range};
use tsunagi::testing::{TestAgent, config_with, network, wait_until};
// These tests need discovery to happen before a collision, so keep the initial
// default outside the established ranges. Derived collisions have their own test.
fn network_outside(
name: &str,
ranges: &[Ipv4Range],
) -> (
tsunagi::identity::NetworkName,
tsunagi::identity::NetworkSecret,
) {
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
let name = NetworkName::new(name).unwrap();
let secret = (0..=255u8)
.map(|seed| NetworkSecret::from_bytes([seed; 32]).unwrap())
.find(|secret| {
let derived = derived_ipv4_range(NetworkKeys::derive(&name, secret).network_id());
ranges.iter().all(|range| !range.contains(derived.base))
})
.expect("a deterministic test secret with a non-overlapping default");
(name, secret)
}
#[tokio::test]
async fn colliding_derived_defaults_are_blocked_instead_of_locally_renumbered() {
use std::collections::HashMap;
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
let secret = NetworkSecret::from_bytes([19; 32]).unwrap();
let mut seen = HashMap::new();
let (first, second, range) = (0..=65_536)
.find_map(|index| {
let name = NetworkName::new(format!("derived-collision-{index}")).unwrap();
let range = derived_ipv4_range(NetworkKeys::derive(&name, &secret).network_id());
seen.insert(range.base, name.clone())
.map(|first| (first, name, range))
})
.expect("more network names than possible /24 ranges");
let agent = TestAgent::spawn(&SharedMemoryDiscovery::new())
.await
.unwrap();
let first = agent.agent.join_network(&first, &secret).await.unwrap();
let second = agent.agent.join_network(&second, &secret).await.unwrap();
assert!(agent.agent.is_active(first).await);
assert!(!agent.agent.is_active(second).await);
let status = agent.agent.status().await.unwrap();
let blocked = status.network(&second).unwrap();
assert_eq!(blocked.range, Some(range));
assert_eq!(blocked.range_conflicts[0].network_id, first);
assert_eq!(
agent.agent.switch_network(second).await.unwrap(),
vec![first]
);
assert_eq!(
agent.agent.network_status(second).await.unwrap().range,
Some(range)
);
agent.agent.shutdown().await;
}
#[tokio::test]
async fn isolated_first_members_choose_the_same_range_and_keep_it_after_restart() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("derived-first");
let a = TestAgent::spawn(&discovery).await.unwrap();
let b = TestAgent::spawn(&SharedMemoryDiscovery::new())
.await
.unwrap();
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
let expected = derived_ipv4_range(id);
for agent in [&a.agent, &b.agent] {
wait_until("isolated member allocates in its derived range", || async {
let status = agent.network_status(id).await.ok()?;
(status.range == Some(expected) && !status.members.is_empty()).then_some(())
})
.await;
}
let dir = a.stop().await;
let a = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
assert_eq!(a.network_status(id).await.unwrap().range, Some(expected));
a.shutdown().await;
b.agent.shutdown().await;
}
#[tokio::test]
async fn overlap_blocks_activation_switches_and_resumes_across_restart() {
let discovery = SharedMemoryDiscovery::new();
let a = TestAgent::spawn_with(
|c| c.with_overlay_ipv4_range(Some(DEFAULT_IPV4_RANGE)),
&discovery,
)
.await
.unwrap();
let (one_name, one_secret) = network("conflict-one");
let (two_name, two_secret) = network("conflict-two");
let one = a.agent.join_network(&one_name, &one_secret).await.unwrap();
a.agent.network_status(one).await.unwrap();
let two = a.agent.join_network(&two_name, &two_secret).await.unwrap();
assert!(a.agent.is_active(one).await);
assert!(!a.agent.is_active(two).await);
assert!(matches!(
a.agent.activate_network(two).await,
Err(tsunagi::Error::NetworkRangeConflict { .. })
));
let status = a.agent.status().await.unwrap();
let blocked = status.network(&two).unwrap();
assert_eq!(blocked.range, Some(DEFAULT_IPV4_RANGE));
assert_eq!(blocked.range_conflicts[0].network_id, one);
assert_eq!(blocked.range_conflicts[0].name, one_name);
assert_eq!(a.agent.switch_network(two).await.unwrap(), vec![one]);
assert!(a.agent.is_active(two).await);
assert!(!a.agent.is_active(one).await);
a.agent.network_status(two).await.unwrap();
let dir = a.stop().await;
let a = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
assert!(a.is_active(two).await, "the user's choice survives restart");
assert!(!a.is_active(one).await);
a.deactivate_network(two).await.unwrap();
assert!(
a.is_active(one).await,
"suspended networks resume when the blocker stops"
);
assert_eq!(
a.network_status(one).await.unwrap().range,
Some(DEFAULT_IPV4_RANGE),
"legacy ranges survive the new default"
);
a.shutdown().await;
}
#[tokio::test]
async fn late_authenticated_state_with_a_nested_range_suspends_only_that_network() {
let discovery = SharedMemoryDiscovery::new();
let broad: Ipv4Range = "10.90.0.0/16".parse().unwrap();
let narrow: Ipv4Range = "10.90.12.0/24".parse().unwrap();
let (name, secret) = network_outside("late-conflict", &[broad]);
let remote = TestAgent::spawn_with(|c| c.with_overlay_ipv4_range(Some(narrow)), &discovery)
.await
.unwrap();
let target = remote.agent.join_network(&name, &secret).await.unwrap();
wait_until("remote range is persisted", || async {
remote
.agent
.network_status(target)
.await
.ok()?
.members
.first()
.map(|_| ())
})
.await;
let (other_name, other_secret) = network("existing-broad");
// Seed an existing network using a real agent and its signed claim.
let seeded = TestAgent::spawn_with(
|c| c.with_overlay_ipv4_range(Some(broad)),
&SharedMemoryDiscovery::new(),
)
.await
.unwrap();
let other = seeded
.agent
.join_network(&other_name, &other_secret)
.await
.unwrap();
wait_until("broad range is persisted", || async {
seeded
.agent
.network_status(other)
.await
.ok()?
.members
.first()
.map(|_| ())
})
.await;
let dir = seeded.stop().await;
let seeded = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
seeded.join_network(&name, &secret).await.unwrap();
wait_until("late overlap suspends the joining network", || async {
let status = seeded.status().await.ok()?;
let blocked = status.network(&target)?;
(!seeded.is_active(target).await
&& blocked
.range_conflicts
.iter()
.any(|c| c.network_id == other))
.then_some(())
})
.await;
assert!(seeded.is_active(other).await);
assert_eq!(seeded.switch_network(target).await.unwrap(), vec![other]);
assert_eq!(
seeded.network_status(target).await.unwrap().range,
Some(narrow)
);
seeded.shutdown().await;
remote.agent.shutdown().await;
}
#[tokio::test]
async fn a_wide_range_suspends_all_overlaps_and_manual_stop_cancels_resume() {
let discovery = SharedMemoryDiscovery::new();
let hub = TestAgent::spawn(&discovery).await.unwrap();
let mut members = Vec::new();
let mut ids = Vec::new();
for (name, range) in [
("narrow-one", "10.81.0.0/24"),
("narrow-two", "10.81.1.0/24"),
] {
let peer = TestAgent::spawn_with(
|c| c.with_overlay_ipv4_range(Some(range.parse().unwrap())),
&discovery,
)
.await
.unwrap();
let (name, secret) = network_outside(
name,
&[
"10.81.0.0/24".parse().unwrap(),
"10.81.1.0/24".parse().unwrap(),
],
);
let id = peer.agent.join_network(&name, &secret).await.unwrap();
peer.agent.network_status(id).await.unwrap();
hub.agent.join_network(&name, &secret).await.unwrap();
wait_until("hub adopts the established narrow range", || async {
(hub.agent.network_status(id).await.ok()?.range == Some(range.parse().unwrap()))
.then_some(())
})
.await;
members.push(peer);
ids.push(id);
}
let (name, secret) = network_outside(
"wide-overlap",
&[
"10.81.0.0/24".parse().unwrap(),
"10.81.1.0/24".parse().unwrap(),
],
);
let wide = TestAgent::spawn_with(
|c| c.with_overlay_ipv4_range(Some("10.0.0.0/8".parse().unwrap())),
&discovery,
)
.await
.unwrap();
let id = wide.agent.join_network(&name, &secret).await.unwrap();
wide.agent.network_status(id).await.unwrap();
hub.agent.join_network(&name, &secret).await.unwrap();
wait_until("wide range is blocked by both narrow networks", || async {
let status = hub.agent.status().await.ok()?;
let blocked = status.network(&id)?;
(!hub.agent.is_active(id).await && blocked.range_conflicts.len() == 2).then_some(())
})
.await;
let stopped = hub.agent.switch_network(id).await.unwrap();
assert_eq!(stopped.len(), 2);
assert!(ids.iter().all(|id| stopped.contains(id)));
assert_eq!(hub.agent.switch_network(ids[0]).await.unwrap(), vec![id]);
assert!(
hub.agent.is_active(ids[1]).await,
"switching away from the wide range frees the other narrow range too"
);
assert_eq!(hub.agent.switch_network(id).await.unwrap().len(), 2);
hub.agent.pause_network(ids[0]).await.unwrap();
hub.agent.deactivate_network(id).await.unwrap();
assert!(
!hub.agent.is_active(ids[0]).await,
"explicitly stopped networks do not resume"
);
assert!(hub.agent.is_active(ids[1]).await);
assert!(
hub.agent
.switch_network(tsunagi::NetworkId::from_bytes([0; 32]))
.await
.is_err()
);
assert!(
hub.agent.is_active(ids[1]).await,
"unknown selectors do not stop any network"
);
hub.agent.shutdown().await;
wide.agent.shutdown().await;
for peer in members {
peer.agent.shutdown().await;
}
}
#[tokio::test]
async fn simultaneous_switches_never_leave_overlapping_networks_active() {
let discovery = SharedMemoryDiscovery::new();
let agent = TestAgent::spawn_with(
|c| c.with_overlay_ipv4_range(Some(DEFAULT_IPV4_RANGE)),
&discovery,
)
.await
.unwrap();
let (a, a_secret) = network("switch-a");
let (b, b_secret) = network("switch-b");
let a = agent.agent.join_network(&a, &a_secret).await.unwrap();
agent.agent.network_status(a).await.unwrap();
let b = agent.agent.join_network(&b, &b_secret).await.unwrap();
let (left, right) = tokio::join!(agent.agent.switch_network(b), agent.agent.switch_network(a));
left.unwrap();
right.unwrap();
assert_ne!(
agent.agent.is_active(a).await,
agent.agent.is_active(b).await
);
assert_eq!(
agent
.agent
.list_networks()
.await
.unwrap()
.iter()
.filter(|n| n.active)
.count(),
1
);
agent.agent.shutdown().await;
}
@@ -0,0 +1,130 @@
//! Opaque transport negotiation stays inside authenticated network sessions.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::{Arc, Mutex};
use tsunagi::dataplane::transport::{ControlSender, PacketTransport, SharedLink, TransportError};
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::proto::message::{ControlMessage, TransportPayload};
use tsunagi::testing::{TestAgent, network, wait_for_peers, wait_until};
use tsunagi::{BoxFuture, NetworkId};
#[derive(Debug, Default)]
struct Negotiator {
control: Mutex<Option<Arc<dyn ControlSender>>>,
received: Mutex<Vec<(NetworkId, iroh::EndpointId, Vec<u8>)>>,
}
impl PacketTransport for Negotiator {
fn name(&self) -> &str {
"negotiator"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn attach_control(&self, control: Arc<dyn ControlSender>) {
*self.control.lock().unwrap() = Some(control);
}
fn on_control(
&self,
network: NetworkId,
peer: iroh::EndpointId,
_: &str,
payload: &[u8],
) -> Result<(), TransportError> {
self.received
.lock()
.unwrap()
.push((network, peer, payload.to_vec()));
Ok(())
}
fn open<'a>(
&'a self,
_: NetworkId,
_: iroh::EndpointId,
_: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
Box::pin(async { Err(TransportError::Closed) })
}
}
#[tokio::test]
async fn negotiated_secrets_are_redacted_scoped_and_delivered_over_real_control() {
let discovery = SharedMemoryDiscovery::new();
let a_transport = Arc::new(Negotiator::default());
let b_transport = Arc::new(Negotiator::default());
let a = TestAgent::spawn_with(
|c| c.with_custom_transport("negotiator", a_transport.clone()),
&discovery,
)
.await
.unwrap();
let b = TestAgent::spawn_with(
|c| c.with_custom_transport("negotiator", b_transport.clone()),
&discovery,
)
.await
.unwrap();
let (name, secret) = network("private-negotiation");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
wait_for_peers(&a.agent, id, 1).await;
let message = ControlMessage::Transport {
protocol: "negotiator".into(),
payload: TransportPayload::new(b"never-export-this-secret".to_vec()),
};
assert!(!format!("{message:?}").contains("never-export"));
let sender = a_transport.control.lock().unwrap().clone().unwrap();
sender
.send(
id,
b.agent.endpoint_id(),
"negotiator",
TransportPayload::new(b"never-export-this-secret".to_vec()),
)
.await
.unwrap();
wait_until("private transport message", || async {
(!b_transport.received.lock().unwrap().is_empty()).then_some(())
})
.await;
assert_eq!(
b_transport.received.lock().unwrap()[0],
(
id,
a.agent.endpoint_id(),
b"never-export-this-secret".to_vec()
)
);
let unknown = NetworkId::from_bytes([0xa5; 32]);
assert!(
sender
.send(
unknown,
b.agent.endpoint_id(),
"negotiator",
TransportPayload::new(vec![1])
)
.await
.is_err()
);
a.agent.shutdown().await;
b.agent.shutdown().await;
}
#[test]
fn private_bytes_are_bounded_on_decode_and_by_local_policy() {
let limits = tsunagi::Limits {
max_transport_payload_len: 4,
..Default::default()
};
let message = |len| ControlMessage::Transport {
protocol: "awg".into(),
payload: TransportPayload::new(vec![42; len]),
};
assert!(tsunagi::proto::message::validate(&message(4), &limits).is_ok());
assert!(tsunagi::proto::message::validate(&message(5), &limits).is_err());
let bytes = postcard::to_stdvec(&TransportPayload::new(vec![1; 4097])).unwrap();
assert!(postcard::from_bytes::<TransportPayload>(&bytes).is_err());
let bytes = postcard::to_stdvec(&message(4)).unwrap();
let decoded: ControlMessage = postcard::from_bytes(&bytes).unwrap();
assert_eq!(decoded, message(4));
}
+207
View File
@@ -0,0 +1,207 @@
//! Shared services use real membership, signed state and mandatory SQLite.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::testing::{TestAgent, config_with, network, wait_for_peers, wait_until};
use tsunagi::vip::{Backend, Listener, Protocol, VipConfig, VipStatus};
use tsunagi::{Agent, NetworkId};
async fn view(agent: &Agent, network: NetworkId, id: &str) -> VipStatus {
wait_until("VIP configuration reaches the replica", || async {
agent
.network_status(network)
.await
.ok()?
.vips
.into_iter()
.find(|v| v.config.id == id)
})
.await
}
#[tokio::test]
async fn any_member_can_edit_services_and_an_offline_creator_is_not_required() {
let discovery = SharedMemoryDiscovery::new();
let a = TestAgent::spawn(&discovery).await.unwrap();
let b = TestAgent::spawn(&discovery).await.unwrap();
let (name, secret) = network("shared-vips");
let net = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
wait_for_peers(&a.agent, net, 1).await;
let cfg = VipConfig::new("api");
a.agent
.save_vip(net, cfg.clone(), None, false)
.await
.unwrap();
let before = view(&b.agent, net, &cfg.id).await;
let mut edited = before.config.clone();
let mut listener = Listener::new(Protocol::Tcp, 443);
listener.backends.push(Backend {
peer: b.agent.endpoint_id().to_string(),
port: 6443,
});
edited.listeners.push(listener);
b.agent
.save_vip(net, edited.clone(), Some(before.revision.clone()), false)
.await
.unwrap();
assert!(
b.agent
.save_vip(net, cfg.clone(), Some(before.revision), false)
.await
.is_err()
);
wait_until("creator observes another member's edit", || async {
(view(&a.agent, net, &cfg.id).await.config == edited).then_some(())
})
.await;
let a_dir = a.stop().await;
let c = TestAgent::spawn(&discovery).await.unwrap();
c.agent.join_network(&name, &secret).await.unwrap();
assert_eq!(view(&c.agent, net, &cfg.id).await.config, edited);
let a = Agent::spawn(config_with(a_dir.path(), &discovery))
.await
.unwrap();
assert_eq!(view(&a, net, &cfg.id).await.config, edited);
let current = view(&c.agent, net, &cfg.id).await;
c.agent
.save_vip(net, current.config, Some(current.revision), true)
.await
.unwrap();
wait_until("deletion reaches the original creator", || async {
a.network_status(net)
.await
.ok()?
.vips
.is_empty()
.then_some(())
})
.await;
a.shutdown().await;
b.stop().await;
c.stop().await;
}
#[tokio::test]
async fn invalid_drafts_are_atomic_and_services_do_not_cross_networks() {
let discovery = SharedMemoryDiscovery::new();
let a = TestAgent::spawn(&discovery).await.unwrap();
let (name, secret) = network("vip-one");
let net = a.agent.join_network(&name, &secret).await.unwrap();
let (other, secret) = network("vip-two");
let second = a.agent.join_network(&other, &secret).await.unwrap();
let mut cfg = VipConfig::new("api");
cfg.listeners.push(Listener::new(Protocol::Udp, 53));
a.agent
.save_vip(net, cfg.clone(), None, false)
.await
.unwrap();
let before = view(&a.agent, net, &cfg.id).await;
let mut invalid = before.config.clone();
invalid.name = "renamed".into();
invalid.listeners.push(invalid.listeners[0].clone());
assert!(
a.agent
.save_vip(net, invalid, Some(before.revision.clone()), false)
.await
.is_err()
);
let after = view(&a.agent, net, &cfg.id).await;
assert_eq!(after.config, before.config);
assert_eq!(after.revision, before.revision);
assert!(
a.agent
.network_status(second)
.await
.unwrap()
.vips
.is_empty()
);
a.stop().await;
}
#[tokio::test]
async fn a_restored_author_advances_its_counter_past_its_own_updates_held_by_a_replica() {
use tsunagi::config::StoragePaths;
use tsunagi::storage::StateStore;
use tsunagi::vip::VipSet;
let discovery = SharedMemoryDiscovery::new();
let a = TestAgent::spawn(&discovery).await.unwrap();
let b = TestAgent::spawn(&discovery).await.unwrap();
let (name, secret) = network("vip-author-recovery");
let net = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
wait_for_peers(&a.agent, net, 1).await;
let config = VipConfig::new("api");
a.agent
.save_vip(net, config.clone(), None, false)
.await
.unwrap();
view(&b.agent, net, &config.id).await;
let author = a.agent.endpoint_id();
let adir = a.stop().await;
let bdir = b.stop().await;
let apath = StoragePaths::under(adir.path()).state_db();
let bpath = StoragePaths::under(bdir.path()).state_db();
// Model an old author backup with a newer, genuinely signed update retained
// by another offline replica. Connections and recovery below remain real.
tokio::task::spawn_blocking(move || {
let a = StateStore::open(apath).unwrap();
let b = StateStore::open(bpath).unwrap();
let mut set = VipSet::default();
set.merge(
net,
a.vip_fields(net, 192).unwrap(),
&tsunagi::Limits::default(),
)
.unwrap();
let mut cfg = set.configs().remove(0);
cfg.name = "newer".into();
let key =
iroh::SecretKey::from_bytes(&a.device_identity().unwrap().unwrap().secret_bytes());
let fields = set
.edit(
net,
&key,
100,
&cfg,
Some(&set.revision(&cfg.id)),
&tsunagi::Limits::default(),
)
.unwrap();
set.merge(net, fields, &tsunagi::Limits::default()).unwrap();
b.put_vip_fields(net, &set.entries(&cfg.id), None).unwrap();
})
.await
.unwrap();
let b = Agent::spawn(config_with(bdir.path(), &discovery))
.await
.unwrap();
let a = Agent::spawn(config_with(adir.path(), &discovery))
.await
.unwrap();
let learned = wait_until(
"the restored author learns its newer signed service",
|| async {
let v = view(&a, net, &config.id).await;
(v.config.name == "newer").then_some(v)
},
)
.await;
let mut next = learned.config;
next.name = "latest".into();
a.save_vip(net, next, Some(learned.revision), false)
.await
.unwrap();
a.shutdown().await;
let path = StoragePaths::under(adir.path()).state_db();
let version = tokio::task::spawn_blocking(move || {
StateStore::open(path)
.unwrap()
.own_record_version(net, author)
.unwrap()
})
.await
.unwrap();
assert!(
version > 100,
"the observed author counter must not be reused"
);
b.shutdown().await;
}
+1 -10
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "tsunagi-tcp-tls" name = "tsunagi-tcp-tls"
version = "0.1.0" version = "0.1.0"
description = "TCP TLS 443 transport and E2EE tunnel protocol for tsunagi mesh networks." description = "TCP TLS carrier for independently encrypted tsunagi mesh traffic."
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
@@ -14,19 +14,10 @@ tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macro
rustls = "0.23" rustls = "0.23"
tokio-rustls = "0.26" tokio-rustls = "0.26"
rcgen = { version = "0.14", default-features = false, features = ["ring"] } 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 bytes.workspace = true
serde.workspace = true serde.workspace = true
postcard.workspace = true postcard.workspace = true
tracing.workspace = true tracing.workspace = true
thiserror.workspace = true
hex.workspace = true
netdev = "0.46" netdev = "0.46"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
-104
View File
@@ -1,104 +0,0 @@
//! 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))
}
}
+2 -5
View File
@@ -1,11 +1,8 @@
//! The `tcp-tls` protocol: TCP with TLS 1.3 on port 443 with Noise E2EE. //! TCP TLS 1.3 carrier for opaque, independently encrypted mesh datagrams.
pub mod cert; pub mod cert;
pub mod crypto;
pub mod plugin; pub mod plugin;
pub mod transport; pub mod transport;
pub use plugin::{ pub use plugin::{
TCP_TLS_PROTOCOL, TCP_TLS_VERSION, TcpTlsCodec, TcpTlsConfig, TcpTlsPlugin, local_ip_candidates, TCP_TLS_PROTOCOL, TCP_TLS_VERSION, TcpTlsCarrier, TcpTlsConfig, local_ip_candidates,
}; };
pub use transport::TcpTlsTransport; pub use transport::TcpTlsTransport;
+168 -297
View File
@@ -1,26 +1,14 @@
//! The `tcp-tls` IP plugin implementation. //! TLS carrier options and bounded reachability announcements. No IP cipher lives here.
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; use crate::transport::TcpTlsTransport;
use iroh::EndpointId;
/// The wire protocol identifier for `tcp-tls`. use serde::Deserialize;
use std::sync::Arc;
use tsunagi::NetworkId;
use tsunagi::dataplane::{PluginError, ProtocolOption};
/// External TLS carrier identifier.
pub const TCP_TLS_PROTOCOL: &str = "tcp-tls"; pub const TCP_TLS_PROTOCOL: &str = "tcp-tls";
/// Carrier announcement wire version.
/// Wire version for `tcp-tls`. pub const TCP_TLS_VERSION: u16 = 2;
pub const TCP_TLS_VERSION: u16 = 1;
/// Configuration for the `tcp-tls` protocol plugin. /// Configuration for the `tcp-tls` protocol plugin.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct TcpTlsConfig { pub struct TcpTlsConfig {
@@ -49,23 +37,22 @@ impl TcpTlsConfig {
} }
} }
/// 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. /// Discovers local network interface IPs to announce to peers.
pub fn local_ip_candidates() -> Vec<std::net::IpAddr> { pub fn local_ip_candidates() -> Vec<std::net::IpAddr> {
local_ip_candidates_excluding(None)
}
fn local_ip_candidates_excluding(excluded_interface: Option<&str>) -> Vec<std::net::IpAddr> {
let mut candidates = Vec::new(); let mut candidates = Vec::new();
for iface in netdev::get_interfaces() { for iface in netdev::get_interfaces() {
if !is_candidate_interface(&iface) { if excluded_interface.is_some_and(|name| {
iface.name.eq_ignore_ascii_case(name)
|| iface
.friendly_name
.as_deref()
.is_some_and(|friendly| friendly.eq_ignore_ascii_case(name))
}) || !is_candidate_interface(&iface)
{
continue; continue;
} }
for ip in &iface.ipv4 { for ip in &iface.ipv4 {
@@ -203,26 +190,7 @@ pub(crate) fn ip_priority(iface: &netdev::Interface, ip: std::net::IpAddr) -> u3
} }
} }
} }
impl TcpTlsConfig {
/// 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`. /// Supported options for `tcp-tls`.
pub const OPTIONS: &'static [ProtocolOption] = &[ pub const OPTIONS: &'static [ProtocolOption] = &[
ProtocolOption { ProtocolOption {
@@ -239,22 +207,6 @@ impl TcpTlsCodec {
}, },
]; ];
/// 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. /// Configures settings from parsed `-o` options.
pub fn configure( pub fn configure(
mut config: TcpTlsConfig, mut config: TcpTlsConfig,
@@ -281,250 +233,169 @@ impl TcpTlsCodec {
Ok(config) Ok(config)
} }
} }
/// TLS carrier announcements and lifecycle, independent of IP encryption.
impl TunnelCodec for TcpTlsCodec { #[derive(Debug)]
pub struct TcpTlsCarrier {
transport: Arc<TcpTlsTransport>,
excluded_interface: std::sync::Mutex<Option<String>>,
peers: std::sync::Mutex<
std::collections::HashMap<(NetworkId, EndpointId), Vec<std::net::SocketAddr>>,
>,
}
impl TcpTlsCarrier {
/// Shares the transport's listener; creates no end-to-end cipher.
pub fn new(transport: Arc<TcpTlsTransport>) -> Arc<Self> {
Arc::new(Self {
transport,
excluded_interface: std::sync::Mutex::new(None),
peers: Default::default(),
})
}
/// Registers a carrier while excluding the agent's own, possibly custom-named interface.
pub fn with_interface(transport: Arc<TcpTlsTransport>, interface: String) -> Arc<Self> {
Arc::new(Self {
transport,
excluded_interface: std::sync::Mutex::new(Some(interface)),
peers: Default::default(),
})
}
fn update_peer(
&self,
network: NetworkId,
peer: EndpointId,
addrs: Option<Vec<std::net::SocketAddr>>,
) {
let mut peers = self.peers.lock().unwrap_or_else(|p| p.into_inner());
match addrs {
Some(addrs) => {
peers.insert((network, peer), addrs);
}
None => {
peers.remove(&(network, peer));
}
}
let mut combined = peers
.iter()
.filter(|((_, id), _)| *id == peer)
.flat_map(|(_, addrs)| addrs.iter().copied())
.collect::<Vec<_>>();
combined.sort_unstable();
combined.dedup();
combined.truncate(32);
self.transport.replace_peer_addrs(peer, combined);
}
}
impl tsunagi::dataplane::transport::DataCarrier for TcpTlsCarrier {
fn protocol_id(&self) -> &str { fn protocol_id(&self) -> &str {
TCP_TLS_PROTOCOL TCP_TLS_PROTOCOL
} }
fn protocol_version(&self) -> u16 { fn protocol_version(&self) -> u16 {
TCP_TLS_VERSION 2
} }
fn on_interface_ready(&self, name: &str) {
fn options(&self) -> &'static [ProtocolOption] { *self
Self::OPTIONS .excluded_interface
.lock()
.unwrap_or_else(|p| p.into_inner()) = Some(name.into());
} }
fn local_capability(
fn local_capability_data(&self, _network: NetworkId) -> Result<Vec<u8>, PluginError> { &self,
let announcement = TcpTlsAnnouncement { _: NetworkId,
noise_public: *self.public_key.as_bytes(), ) -> Result<Option<tsunagi::dataplane::PluginCapability>, PluginError> {
port: self.transport.bound_port(), let data = postcard::to_allocvec(&(
addrs: local_ip_candidates(), self.transport.bound_port(),
}; local_ip_candidates_excluding(
postcard::to_allocvec(&announcement) self.excluded_interface
.map_err(|err| PluginError::Other(format!("encode announcement failed: {err}"))) .lock()
.unwrap_or_else(|p| p.into_inner())
.as_deref(),
)
.into_iter()
.take(32)
.collect::<Vec<_>>(),
))
.map_err(|_| PluginError::Other("cannot encode TLS addresses".into()))?;
Ok(Some(tsunagi::dataplane::PluginCapability {
protocol: TCP_TLS_PROTOCOL.into(),
version: 2,
enabled: true,
data,
}))
} }
fn on_peer_capability(
fn on_peer_capability_data(
&self, &self,
network: NetworkId, network: NetworkId,
peer: EndpointId, peer: EndpointId,
data: &[u8], cap: &tsunagi::dataplane::PluginCapability,
) -> Result<(), PluginError> { ) -> Result<(), PluginError> {
let announcement: TcpTlsAnnouncement = postcard::from_bytes(data) if !cap.enabled || cap.protocol != TCP_TLS_PROTOCOL || cap.version != 2 {
.map_err(|err| PluginError::Rejected(format!("invalid announcement: {err}")))?; return Err(PluginError::Rejected("unsupported TLS carrier".into()));
for ip in &announcement.addrs {
if is_candidate_ip(*ip) {
self.transport
.set_peer_addr(peer, std::net::SocketAddr::new(*ip, announcement.port));
}
} }
// Decode a fixed bounded array through the existing common announcement limit.
let peer_noise_pubkey = PublicKey::from(announcement.noise_public); #[derive(Deserialize)]
let cipher = Arc::new(PeerCipher::new( struct Addresses(
self.local_id, u16,
#[serde(deserialize_with = "bounded_ips")] Vec<std::net::IpAddr>,
);
let Addresses(port, ips) = postcard::from_bytes(&cap.data)
.map_err(|_| PluginError::Rejected("invalid TLS carrier addresses".into()))?;
if port == 0 {
return Err(PluginError::Rejected("invalid TLS carrier port".into()));
}
self.update_peer(
network,
peer, peer,
&self.static_secret, Some(
&peer_noise_pubkey, ips.into_iter()
)); .filter(|ip| is_candidate_ip(*ip))
.map(|ip| std::net::SocketAddr::new(ip, port))
let mut guard = match self.ciphers.write() { .collect(),
Ok(g) => g, ),
Err(p) => p.into_inner(), );
};
guard.insert((network, peer), cipher);
Ok(()) Ok(())
} }
fn on_peer_gone(&self, network: NetworkId, peer: EndpointId) {
fn on_peer_down(&self, network: NetworkId, peer: EndpointId) { self.update_peer(network, peer, None);
let mut guard = match self.ciphers.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.remove(&(network, peer));
} }
fn on_network_deactivated(&self, network: NetworkId) {
fn encrypt( let peers: Vec<_> = self
&self, .peers
network: NetworkId, .lock()
peer: EndpointId, .unwrap_or_else(|p| p.into_inner())
packet: &[u8], .keys()
) -> Result<Bytes, PluginError> { .filter(|(id, _)| *id == network)
let cipher = { .map(|(_, peer)| *peer)
let guard = match self.ciphers.read() { .collect();
Ok(g) => g, for peer in peers {
Err(p) => p.into_inner(), self.update_peer(network, peer, None);
}; }
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)
} }
} }
fn bounded_ips<'de, D: serde::Deserializer<'de>>(
/// The complete `tcp-tls` plugin. deserializer: D,
pub type TcpTlsPlugin = GenericTunnelPlugin<TcpTlsCodec>; ) -> Result<Vec<std::net::IpAddr>, D::Error> {
struct Visitor;
#[cfg(test)] impl<'de> serde::de::Visitor<'de> for Visitor {
mod tests { type Value = Vec<std::net::IpAddr>;
use super::*; fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use std::net::{Ipv4Addr, Ipv6Addr}; f.write_str("at most 32 IP candidates")
}
#[test] fn visit_seq<A: serde::de::SeqAccess<'de>>(
fn test_is_candidate_interface_filters_overlay_and_bridges() { self,
let mut iface = netdev::Interface::dummy(); mut seq: A,
iface.flags = 1; // UP ) -> Result<Self::Value, A::Error> {
if seq.size_hint().is_some_and(|n| n > 32) {
// Standard Ethernet - should pass return Err(serde::de::Error::custom("too many IP candidates"));
iface.name = "eth0".to_string(); }
assert!(is_candidate_interface(&iface)); let mut ips = Vec::new();
while let Some(ip) = seq.next_element()? {
// Windows Ethernet with GUID name - should pass if ips.len() == 32 {
iface.name = "{B6149D2C-1234-5678-9ABC-DEF012345678}".to_string(); return Err(serde::de::Error::custom("too many IP candidates"));
iface.friendly_name = Some("Ethernet".to_string()); }
iface.description = Some("Intel(R) Ethernet Connection".to_string()); ips.push(ip);
assert!(is_candidate_interface(&iface)); }
Ok(ips)
// 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);
} }
deserializer.deserialize_seq(Visitor)
} }
+27 -2
View File
@@ -115,6 +115,7 @@ pub struct TcpTlsLink {
peer: EndpointId, peer: EndpointId,
tx: mpsc::Sender<Bytes>, tx: mpsc::Sender<Bytes>,
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>, rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
tasks: [tokio::task::AbortHandle; 2],
closed_notify: Arc<Notify>, closed_notify: Arc<Notify>,
is_closed: Arc<AtomicBool>, is_closed: Arc<AtomicBool>,
remote_addr: String, remote_addr: String,
@@ -136,7 +137,7 @@ impl TcpTlsLink {
let closed_flag_w = Arc::clone(&is_closed); let closed_flag_w = Arc::clone(&is_closed);
let closed_notify_w = Arc::clone(&closed_notify); let closed_notify_w = Arc::clone(&closed_notify);
tokio::spawn(async move { let writer_task = tokio::spawn(async move {
while let Some(msg) = rx_out.recv().await { while let Some(msg) = rx_out.recv().await {
if msg.len() > MAX_FRAME_SIZE { if msg.len() > MAX_FRAME_SIZE {
continue; continue;
@@ -158,7 +159,7 @@ impl TcpTlsLink {
let closed_flag_r = Arc::clone(&is_closed); let closed_flag_r = Arc::clone(&is_closed);
let closed_notify_r = Arc::clone(&closed_notify); let closed_notify_r = Arc::clone(&closed_notify);
tokio::spawn(async move { let reader_task = tokio::spawn(async move {
let mut len_buf = [0u8; 2]; let mut len_buf = [0u8; 2];
loop { loop {
if reader.read_exact(&mut len_buf).await.is_err() { if reader.read_exact(&mut len_buf).await.is_err() {
@@ -185,6 +186,7 @@ impl TcpTlsLink {
peer, peer,
tx: tx_out, tx: tx_out,
rx: tokio::sync::Mutex::new(rx_in), rx: tokio::sync::Mutex::new(rx_in),
tasks: [writer_task.abort_handle(), reader_task.abort_handle()],
closed_notify, closed_notify,
is_closed, is_closed,
remote_addr, remote_addr,
@@ -192,7 +194,19 @@ impl TcpTlsLink {
} }
} }
impl Drop for TcpTlsLink {
fn drop(&mut self) {
self.close();
}
}
impl PacketLink for TcpTlsLink { impl PacketLink for TcpTlsLink {
fn close(&self) {
self.is_closed.store(true, Ordering::Release);
self.closed_notify.notify_waiters();
for task in &self.tasks {
task.abort();
}
}
fn network(&self) -> NetworkId { fn network(&self) -> NetworkId {
self.network self.network
} }
@@ -450,6 +464,17 @@ impl TcpTlsTransport {
} }
} }
/// Replaces a bounded candidate set learned through an authenticated session.
pub fn replace_peer_addrs(&self, peer: EndpointId, addresses: Vec<SocketAddr>) {
if let Ok(mut peers) = self.peer_addresses.write() {
if addresses.is_empty() {
peers.remove(&peer);
} else {
peers.insert(peer, addresses.into_iter().take(32).collect());
}
}
}
/// Runs the accept loop for inbound TCP TLS connections. /// Runs the accept loop for inbound TCP TLS connections.
pub async fn accept_loop<F>(&self, on_inbound: F) pub async fn accept_loop<F>(&self, on_inbound: F)
where where
@@ -1,73 +0,0 @@
#![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"
);
}
+27 -143
View File
@@ -1,149 +1,33 @@
#![allow(missing_docs)] //! Bounded TLS carrier announcements contain no end-to-end cipher selection.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::{net::IpAddr, sync::Arc};
use std::sync::Arc; use tsunagi::{
use std::time::Duration; dataplane::transport::DataCarrier,
identity::{DeviceIdentity, NetworkId},
use bytes::Bytes; };
use tokio::sync::mpsc; use tsunagi_tcp_tls::{TcpTlsCarrier, TcpTlsTransport};
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] #[tokio::test]
async fn test_tcp_tls_plugin_end_to_end() { async fn tls_capabilities_bound_addresses_and_reject_invalid_ports_and_versions() {
let identity_a = DeviceIdentity::generate(); let identity = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id(); let peer = DeviceIdentity::generate().endpoint_id();
let transport = Arc::new(
let identity_b = DeviceIdentity::generate(); TcpTlsTransport::bind(&identity, Some(0), None)
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 .await
.expect("bind B"), .unwrap(),
); );
let port_b = transport_b.bound_port(); let carrier = TcpTlsCarrier::new(transport);
let network = NetworkId::from_bytes([7; 32]);
let transport_a = Arc::new( let mut cap = carrier.local_capability(network).unwrap().unwrap();
TcpTlsTransport::bind(&identity_a, Some(0), None) assert_eq!(cap.protocol, "tcp-tls");
.await let addresses: Vec<IpAddr> = vec!["192.0.2.1".parse().unwrap(); 33];
.expect("bind A"), cap.data = postcard::to_allocvec(&(443u16, addresses)).unwrap();
); assert!(carrier.on_peer_capability(network, peer, &cap).is_err());
cap.data = postcard::to_allocvec(&(0u16, Vec::<IpAddr>::new())).unwrap();
// Codecs and plugins assert!(carrier.on_peer_capability(network, peer, &cap).is_err());
let codec_a = TcpTlsCodec::new(peer_a, Arc::clone(&transport_a)); cap.data =
let plugin_a = Arc::new(TcpTlsPlugin::new(codec_a)); postcard::to_allocvec(&(443u16, vec!["192.0.2.1".parse::<IpAddr>().unwrap()])).unwrap();
carrier.on_peer_capability(network, peer, &cap).unwrap();
let codec_b = TcpTlsCodec::new(peer_b, Arc::clone(&transport_b)); cap.version += 1;
let plugin_b = Arc::new(TcpTlsPlugin::new(codec_b)); assert!(carrier.on_peer_capability(network, peer, &cap).is_err());
// 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);
} }
+116 -4
View File
@@ -21,6 +21,24 @@ const POLL_INTERVAL: Duration = Duration::from_secs(2);
/// A request from the UI to the agent. /// A request from the UI to the agent.
pub(crate) enum Command { pub(crate) enum Command {
SaveVip {
token: String,
network_id: String,
config: tsunagi::vip::VipConfig,
expected: Option<String>,
delete: bool,
},
/// Retire the exact offline-node claim selected in the devices window.
ForgetNode {
network_id: String,
peer: String,
expected_version: u64,
},
/// Replace the network's enabled protocols in preference order.
SetProtocols {
network_id: String,
protocols: Vec<String>,
},
/// Stop serving a network, or start serving it again. /// Stop serving a network, or start serving it again.
SetActive { network_id: String, active: bool }, SetActive { network_id: String, active: bool },
/// Turn local broadcast participation on or off for a network. /// Turn local broadcast participation on or off for a network.
@@ -44,8 +62,14 @@ pub(crate) enum Command {
} }
/// The latest the worker knows, read by the UI each frame. /// The latest the worker knows, read by the UI each frame.
#[derive(Clone)]
pub(crate) struct VipSaveResult {
pub token: String,
pub result: Result<Vec<tsunagi::vip::VipStatus>, String>,
}
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub(crate) struct Snapshot { pub(crate) struct Snapshot {
pub vip_result: Option<VipSaveResult>,
/// The last status, or the reason it could not be read (agent down, no /// The last status, or the reason it could not be read (agent down, no
/// permission on the socket, …). /// permission on the socket, …).
pub status: Option<Result<StatusReport, String>>, pub status: Option<Result<StatusReport, String>>,
@@ -73,6 +97,18 @@ pub(crate) struct AgentClient {
} }
impl AgentClient { impl AgentClient {
#[cfg(test)]
pub(crate) fn test_client() -> (Self, mpsc::UnboundedReceiver<Command>) {
let (tx, rx) = mpsc::unbounded_channel();
(
Self {
tx,
shared: Arc::new(Mutex::new(Snapshot::default())),
pending_copy: Arc::new(Mutex::new(None)),
},
rx,
)
}
/// Starts the worker on `runtime`, reporting to `ctx` (for repaints). /// Starts the worker on `runtime`, reporting to `ctx` (for repaints).
pub(crate) fn spawn(runtime: &tokio::runtime::Handle, ctx: egui::Context) -> Self { pub(crate) fn spawn(runtime: &tokio::runtime::Handle, ctx: egui::Context) -> Self {
let (tx, rx) = mpsc::unbounded_channel(); let (tx, rx) = mpsc::unbounded_channel();
@@ -106,7 +142,13 @@ impl AgentClient {
/// Sends a command; a dropped worker simply means nothing happens. /// Sends a command; a dropped worker simply means nothing happens.
pub(crate) fn send(&self, command: Command) { pub(crate) fn send(&self, command: Command) {
let _ = self.tx.send(command); // Disable protocol editing until the refreshed snapshot arrives;
// otherwise a second checkbox click could replace the first edit
// using the same stale network selection.
lock(&self.shared).busy = true;
if self.tx.send(command).is_err() {
lock(&self.shared).busy = false;
}
} }
/// A copy of the latest snapshot. /// A copy of the latest snapshot.
@@ -142,6 +184,8 @@ async fn worker(
let Some(command) = command else { return }; let Some(command) = command else { return };
run(&resolve_socket(), command, &shared, &pending_copy, &ctx).await; run(&resolve_socket(), command, &shared, &pending_copy, &ctx).await;
refresh(&shared, &ctx).await; refresh(&shared, &ctx).await;
lock(&shared).busy = false;
ctx.request_repaint();
} }
} }
} }
@@ -179,11 +223,70 @@ async fn run(
ctx.request_repaint(); ctx.request_repaint();
let outcome: Result<String, String> = match command { let outcome: Result<String, String> = match command {
Command::SaveVip {
token,
network_id,
config,
expected,
delete,
} => {
let result = ipc::save_vip(socket, &network_id, config, expected, delete)
.await
.map_err(|e| e.to_string());
lock(shared).vip_result = Some(VipSaveResult {
token,
result: result.clone(),
});
result.map(|_| "VIP configuration saved".into())
}
Command::ForgetNode {
network_id,
peer,
expected_version,
} => ipc::forget_node(socket, &network_id, &peer, expected_version)
.await
.map(|report| {
format!(
"forgot node claim v{}; cleanup queued to {} peers",
report.record_version, report.peers_told
)
})
.map_err(|err| err.to_string()),
Command::SetProtocols {
network_id,
protocols,
} => ipc::set_protocols(socket, &network_id, protocols)
.await
.map(|names| {
format!(
"protocols: {}",
if names.is_empty() {
"none".into()
} else {
names.join(", ")
}
)
})
.map_err(|err| err.to_string()),
Command::SetActive { network_id, active } => ipc::set_active(socket, &network_id, active) Command::SetActive { network_id, active } => ipc::set_active(socket, &network_id, active)
.await .await
.map(|report| { .map(|report| {
let state = if report.active { "started" } else { "stopped" }; let state = if report.active { "started" } else { "stopped" };
format!("{} {}", state, report.name) if report.suspended.is_empty() {
format!("{} {}", state, report.name)
} else {
format!(
"{} {}; suspended {} until it stops",
state,
report.name,
report
.suspended
.iter()
.map(|n| format!("{} ({})", n.name, n.network_id))
.collect::<Vec<_>>()
.join(", ")
)
}
}) })
.map_err(|err| err.to_string()), .map_err(|err| err.to_string()),
Command::SetBroadcast { Command::SetBroadcast {
@@ -211,7 +314,17 @@ async fn run(
} }
Command::Join { name, secret } => ipc::join_network(socket, &name, &secret) Command::Join { name, secret } => ipc::join_network(socket, &name, &secret)
.await .await
.map(|report| format!("joined {}", report.name)) .map(|report| {
format!(
"{} {}",
if report.active {
"joined"
} else {
"configured; stopped by range conflict:"
},
report.name
)
})
.map_err(|err| err.to_string()), .map_err(|err| err.to_string()),
Command::Leave { network_id } => ipc::leave_network(socket, &network_id) Command::Leave { network_id } => ipc::leave_network(socket, &network_id)
.await .await
@@ -236,7 +349,6 @@ async fn run(
{ {
let mut guard = lock(shared); let mut guard = lock(shared);
guard.busy = false;
guard.last_action = if matches!(&outcome, Ok(message) if message.is_empty()) { guard.last_action = if matches!(&outcome, Ok(message) if message.is_empty()) {
None None
} else { } else {
+78 -6
View File
@@ -15,6 +15,12 @@ use crate::devices;
use crate::stats::Traffic; use crate::stats::Traffic;
use crate::ui::{self, UiState}; use crate::ui::{self, UiState};
fn app_fonts() -> egui::FontDefinitions {
let mut fonts = egui::FontDefinitions::default();
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
fonts
}
/// The window's state. /// The window's state.
pub(crate) struct App { pub(crate) struct App {
agent: AgentClient, agent: AgentClient,
@@ -34,9 +40,7 @@ impl App {
let ctx = cc.egui_ctx.clone(); let ctx = cc.egui_ctx.clone();
// Add the Phosphor icon glyphs to the font set so the icon buttons // Add the Phosphor icon glyphs to the font set so the icon buttons
// render instead of showing as tofu. // render instead of showing as tofu.
let mut fonts = egui::FontDefinitions::default(); ctx.set_fonts(app_fonts());
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
ctx.set_fonts(fonts);
let agent = AgentClient::spawn(&runtime, ctx); let agent = AgentClient::spawn(&runtime, ctx);
Ok(Self { Ok(Self {
@@ -60,6 +64,7 @@ impl eframe::App for App {
ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic); ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic);
self.show_devices_windows(ctx, &snapshot); self.show_devices_windows(ctx, &snapshot);
self.show_vip_windows(ctx, &snapshot);
self.show_about_window(ctx, &snapshot); self.show_about_window(ctx, &snapshot);
// A secret the worker fetched for the clipboard: copy it here, on the // A secret the worker fetched for the clipboard: copy it here, on the
@@ -74,6 +79,51 @@ impl eframe::App for App {
} }
impl App { impl App {
fn show_vip_windows(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) {
let Some(Ok(report)) = &snapshot.status else {
return;
};
let ids: Vec<_> = self.state.vip_windows.keys().cloned().collect();
for id in ids {
let Some(network) = report.networks.iter().find(|n| n.network_id == id) else {
self.state.vip_windows.remove(&id);
continue;
};
let Some(state) = self.state.vip_windows.get_mut(&id) else {
continue;
};
let builder = egui::ViewportBuilder::default()
.with_title(format!("tsunagi · VIP · {}", network.name))
.with_app_id(crate::icon::APP_ID)
.with_icon(crate::icon::window())
.with_inner_size([1280., 860.])
.with_min_inner_size([650., 400.]);
let keep = ctx.show_viewport_immediate(
egui::ViewportId::from_hash_of(("vip", &id)),
builder,
|vctx, _| {
let close = crate::vip::show(vctx, &self.agent, state, network, snapshot);
if close {
return false;
}
if vctx.input(|i| i.viewport().close_requested()) {
if state.dirty() {
state.request_close();
vctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
true
} else {
false
}
} else {
true
}
},
);
if !keep {
self.state.vip_windows.remove(&id);
}
}
}
/// Renders the About window while it is open. /// Renders the About window while it is open.
fn show_about_window(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) { fn show_about_window(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) {
if !self.state.about_open { if !self.state.about_open {
@@ -81,6 +131,8 @@ impl App {
} }
let builder = egui::ViewportBuilder::default() let builder = egui::ViewportBuilder::default()
.with_title("About tsunagi") .with_title("About tsunagi")
.with_app_id(crate::icon::APP_ID)
.with_icon(crate::icon::window())
.with_inner_size([560.0, 520.0]) .with_inner_size([560.0, 520.0])
.with_min_inner_size([380.0, 280.0]); .with_min_inner_size([380.0, 280.0]);
let socket = self.agent.socket(); let socket = self.agent.socket();
@@ -112,20 +164,23 @@ impl App {
let viewport_id = egui::ViewportId::from_hash_of(("devices", &id)); let viewport_id = egui::ViewportId::from_hash_of(("devices", &id));
let builder = egui::ViewportBuilder::default() let builder = egui::ViewportBuilder::default()
.with_title(devices::title(network)) .with_title(devices::title(network))
.with_inner_size([940.0, 540.0]) .with_app_id(crate::icon::APP_ID)
.with_min_inner_size([480.0, 320.0]); .with_icon(crate::icon::window())
.with_inner_size([1440.0, 640.0])
.with_min_inner_size([800.0, 400.0]);
let agent = &self.agent; let agent = &self.agent;
let traffic = &self.traffic; let traffic = &self.traffic;
// The window shares the single unit setting, so its toggle and the // The window shares the single unit setting, so its toggle and the
// main window's stay in step. // main window's stay in step.
let unit = &mut self.state.unit; let unit = &mut self.state.unit;
let confirm = &mut self.state.exit_confirm; let confirm = &mut self.state.exit_confirm;
let forget_confirm = &mut self.state.forget_confirm;
let keep = ctx.show_viewport_immediate(viewport_id, builder, |vctx, _class| { let keep = ctx.show_viewport_immediate(viewport_id, builder, |vctx, _class| {
devices::show( devices::show(
vctx, vctx,
agent, agent,
unit, unit,
confirm, (confirm, forget_confirm),
network, network,
&report.endpoint_id, &report.endpoint_id,
traffic, traffic,
@@ -138,3 +193,20 @@ impl App {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_icons_have_an_embedded_font_in_the_app() {
let fonts = app_fonts();
assert!(fonts.font_data.contains_key("phosphor"));
assert!(
fonts
.families
.get(&egui::FontFamily::Proportional)
.is_some_and(|family| family.iter().any(|name| name == "phosphor"))
);
}
}
File diff suppressed because it is too large. Load diff
+7
View File
@@ -13,6 +13,12 @@ pub(crate) const GOOD: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a)
pub(crate) const LOUD: egui::Color32 = egui::Color32::from_rgb(0xe0, 0x4a, 0x3c); pub(crate) const LOUD: egui::Color32 = egui::Color32::from_rgb(0xe0, 0x4a, 0x3c);
/// What to run to turn kernel forwarding on, which the agent never does. /// What to run to turn kernel forwarding on, which the agent never does.
#[cfg(target_os = "macos")]
pub(crate) const FORWARDING_COMMAND: &str = "sudo sysctl -w net.inet.ip.forwarding=1";
#[cfg(target_os = "windows")]
pub(crate) const FORWARDING_COMMAND: &str =
"Get-NetIPInterface | Set-NetIPInterface -Forwarding Enabled";
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub(crate) const FORWARDING_COMMAND: &str = "sudo sysctl -w net.ipv4.ip_forward=1"; pub(crate) const FORWARDING_COMMAND: &str = "sudo sysctl -w net.ipv4.ip_forward=1";
/// One thing wrong, loud enough to read at a glance. /// One thing wrong, loud enough to read at a glance.
@@ -103,6 +109,7 @@ mod tests {
fn network(exit: ExitReport) -> NetworkReport { fn network(exit: ExitReport) -> NetworkReport {
NetworkReport { NetworkReport {
peers: vec![PeerReport { peers: vec![PeerReport {
data_error: None,
endpoint_id: "abcdef0123456789".into(), endpoint_id: "abcdef0123456789".into(),
hostname: Some("music".into()), hostname: Some("music".into()),
exit_node: true, exit_node: true,
-13
View File
@@ -24,19 +24,6 @@ pub(crate) fn short(id: &str) -> String {
} }
} }
/// A duration in seconds as a compact age (`5s`, `12m`, `3h 20m`, `2d 4h`).
pub(crate) fn age(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3_600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h {}m", secs / 3_600, (secs % 3_600) / 60)
} else {
format!("{}d {}h", secs / 86_400, (secs % 86_400) / 3_600)
}
}
/// A byte count in binary units. /// A byte count in binary units.
pub(crate) fn bytes(n: f64) -> String { pub(crate) fn bytes(n: f64) -> String {
const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"]; const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"];
+136
View File
@@ -0,0 +1,136 @@
//! The application's picture: the logo drawn in code, in the colour of the
//! state it stands for, so there is no asset to ship or to go missing.
//!
//! It is the same shape as `dist/linux/tsunagi.svg` (a disc, a ring, a dot).
use crate::watch::Health;
/// Normal operation. The colour of the logo itself.
const BLUE: [u8; 3] = [0x2f, 0x80, 0xd8];
/// An exit node is in use.
const GREEN: [u8; 3] = [0x3c, 0xb0, 0x4a];
/// Nothing is connected, or the exit node in use is gone.
const DARK_RED: [u8; 3] = [0x9a, 0x1f, 0x1f];
/// The window application id, which is also the name of the desktop entry
/// (`tsunagi-tray.desktop`). The compositor finds the icon through it.
pub(crate) const APP_ID: &str = "tsunagi-tray";
const SIZE: u32 = 64;
const SAMPLES: u32 = 4;
/// What the logo looks like at a point of its 128 × 128 design, as
/// premultiplied-free RGBA in `0.0..=1.0`.
fn shade(x: f32, y: f32, color: [u8; 3]) -> [f32; 4] {
let distance = ((x - 64.0).powi(2) + (y - 64.0).powi(2)).sqrt();
if distance > 56.0 {
return [0.0; 4];
}
let mut pixel = [
f32::from(color[0]) / 255.0,
f32::from(color[1]) / 255.0,
f32::from(color[2]) / 255.0,
];
let mut white = 0.0;
if (26.5..=33.5).contains(&distance) {
white = 0.9;
}
if distance <= 9.0 {
white = 1.0;
}
for channel in &mut pixel {
*channel = *channel * (1.0 - white) + white;
}
[pixel[0], pixel[1], pixel[2], 1.0]
}
/// The logo as RGBA pixels, antialiased by supersampling.
fn rgba(color: [u8; 3]) -> Vec<u8> {
let scale = 128.0 / SIZE as f32;
let mut out = Vec::with_capacity((SIZE * SIZE * 4) as usize);
for py in 0..SIZE {
for px in 0..SIZE {
let mut sum = [0.0f32; 4];
for sy in 0..SAMPLES {
for sx in 0..SAMPLES {
let x = (px as f32 + (sx as f32 + 0.5) / SAMPLES as f32) * scale;
let y = (py as f32 + (sy as f32 + 0.5) / SAMPLES as f32) * scale;
let [r, g, b, a] = shade(x, y, color);
sum[0] += r * a;
sum[1] += g * a;
sum[2] += b * a;
sum[3] += a;
}
}
let count = (SAMPLES * SAMPLES) as f32;
let alpha = sum[3] / count;
let unmultiply = if sum[3] > 0.0 { 1.0 / sum[3] } else { 0.0 };
for channel in &sum[..3] {
out.push((channel * unmultiply * 255.0).round() as u8);
}
out.push((alpha * 255.0).round() as u8);
}
}
out
}
fn colour_of(health: Health) -> [u8; 3] {
match health {
Health::Disconnected => DARK_RED,
Health::Connected => BLUE,
Health::RangeConflict => [0xd9, 0x91, 0x20],
Health::ExitNode => GREEN,
}
}
/// The tray icon for a state.
pub(crate) fn tray(health: Health) -> Option<tray_icon::Icon> {
tray_icon::Icon::from_rgba(rgba(colour_of(health)), SIZE, SIZE).ok()
}
/// The icon of the application's windows.
pub(crate) fn window() -> eframe::egui::IconData {
eframe::egui::IconData {
rgba: rgba(BLUE),
width: SIZE,
height: SIZE,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pixel(pixels: &[u8], x: u32, y: u32) -> [u8; 4] {
let at = ((y * SIZE + x) * 4) as usize;
[pixels[at], pixels[at + 1], pixels[at + 2], pixels[at + 3]]
}
#[test]
fn the_logo_is_a_coloured_disc_with_a_white_centre_and_clear_corners() {
let pixels = rgba(GREEN);
assert_eq!(pixels.len(), (SIZE * SIZE * 4) as usize);
assert_eq!(pixel(&pixels, 0, 0)[3], 0, "the corner is transparent");
let centre = pixel(&pixels, SIZE / 2, SIZE / 2);
assert_eq!(
centre,
[255, 255, 255, 255],
"the dot in the middle is white"
);
// Halfway between the ring and the edge is the colour itself.
let body = pixel(&pixels, SIZE / 2, SIZE / 2 - (SIZE * 44 / 128));
assert_eq!(body, [GREEN[0], GREEN[1], GREEN[2], 255]);
}
#[test]
fn each_state_has_its_own_colour() {
let colours = [
colour_of(Health::Disconnected),
colour_of(Health::Connected),
colour_of(Health::ExitNode),
];
assert_ne!(colours[0], colours[1]);
assert_ne!(colours[1], colours[2]);
assert_ne!(colours[0], colours[2]);
}
}
+7 -1
View File
@@ -15,9 +15,14 @@ mod app;
mod devices; mod devices;
mod exit; mod exit;
mod format; mod format;
mod icon;
mod stats; mod stats;
mod table;
mod tray; mod tray;
mod ui; mod ui;
mod vip;
mod vip_status;
mod watch;
use eframe::egui; use eframe::egui;
@@ -53,7 +58,8 @@ fn window() -> Result<(), Box<dyn std::error::Error>> {
// A fixed, small window; the whole layout targets this size. // A fixed, small window; the whole layout targets this size.
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_title("tsunagi") .with_title("tsunagi")
.with_app_id("tsunagi") .with_app_id(icon::APP_ID)
.with_icon(icon::window())
.with_inner_size([360.0, 600.0]) .with_inner_size([360.0, 600.0])
.with_resizable(false), .with_resizable(false),
..Default::default() ..Default::default()
+193 -22
View File
@@ -6,10 +6,10 @@
//! key: a network total, and each peer within it. Both packets and bytes are //! key: a network total, and each peer within it. Both packets and bytes are
//! tracked so the UI can switch between them. //! tracked so the UI can switch between them.
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
use std::time::Instant; use std::time::Instant;
use tsunagi::ipc::StatusReport; use tsunagi::ipc::{OverlayPeerReport, StatusReport};
/// How many rate samples to keep for a sparkline. /// How many rate samples to keep for a sparkline.
const HISTORY: usize = 60; const HISTORY: usize = 60;
@@ -26,7 +26,7 @@ pub(crate) enum Unit {
/// One series' rolling packet and byte rates. /// One series' rolling packet and byte rates.
#[derive(Default)] #[derive(Default)]
pub(crate) struct Series { pub(crate) struct Series {
last: Option<(Instant, u64, u64)>, last: Option<(Instant, Counters)>,
packets_rate: f32, packets_rate: f32,
bytes_rate: f32, bytes_rate: f32,
packets_history: VecDeque<f32>, packets_history: VecDeque<f32>,
@@ -34,17 +34,52 @@ pub(crate) struct Series {
} }
impl Series { impl Series {
fn sample(&mut self, now: Instant, packets: u64, bytes: u64) { /// Samples a single cumulative counter pair (VIP histories own their lifetime).
if let Some((then, prev_packets, prev_bytes)) = self.last { pub(crate) fn sample(&mut self, now: Instant, packets: u64, bytes: u64) {
self.sample_peer(
now,
Counters {
epoch: 0,
tx_packets: packets,
tx_bytes: bytes,
rx_packets: 0,
rx_bytes: 0,
},
);
}
fn sample_peer(&mut self, now: Instant, counters: Counters) {
if let Some((then, previous)) = self.last {
let dt = now.saturating_duration_since(then).as_secs_f32(); let dt = now.saturating_duration_since(then).as_secs_f32();
if dt > 0.0 { // Ignore duplicate/out-of-order observations without changing the baseline.
self.packets_rate = packets.saturating_sub(prev_packets) as f32 / dt; if dt <= 0.0 {
self.bytes_rate = bytes.saturating_sub(prev_bytes) as f32 / dt; return;
push(&mut self.packets_history, self.packets_rate); }
push(&mut self.bytes_history, self.bytes_rate); if counters.epoch == previous.epoch
&& counters.tx_packets >= previous.tx_packets
&& counters.rx_packets >= previous.rx_packets
&& counters.tx_bytes >= previous.tx_bytes
&& counters.rx_bytes >= previous.rx_bytes
{
self.record(
(counters.tx_packets - previous.tx_packets) as f32 / dt
+ (counters.rx_packets - previous.rx_packets) as f32 / dt,
(counters.tx_bytes - previous.tx_bytes) as f32 / dt
+ (counters.rx_bytes - previous.rx_bytes) as f32 / dt,
);
} else {
// A counter reset is a new baseline, never traffic.
*self = Self::default();
} }
} }
self.last = Some((now, packets, bytes)); self.last = Some((now, counters));
}
fn record(&mut self, packets: f32, bytes: f32) {
self.packets_rate = packets;
self.bytes_rate = bytes;
push(&mut self.packets_history, packets);
push(&mut self.bytes_history, bytes);
} }
/// The current rate in the chosen unit. /// The current rate in the chosen unit.
@@ -71,6 +106,27 @@ fn push(history: &mut VecDeque<f32>, value: f32) {
} }
} }
#[derive(Clone, Copy)]
struct Counters {
epoch: u64,
tx_packets: u64,
rx_packets: u64,
tx_bytes: u64,
rx_bytes: u64,
}
impl From<&OverlayPeerReport> for Counters {
fn from(peer: &OverlayPeerReport) -> Self {
Self {
epoch: peer.counter_epoch,
tx_packets: peer.tx_packets,
rx_packets: peer.rx_packets,
tx_bytes: peer.tx_bytes,
rx_bytes: peer.rx_bytes,
}
}
}
/// All traffic series, keyed by a stable string. /// All traffic series, keyed by a stable string.
#[derive(Default)] #[derive(Default)]
pub(crate) struct Traffic { pub(crate) struct Traffic {
@@ -80,26 +136,32 @@ pub(crate) struct Traffic {
impl Traffic { impl Traffic {
/// Folds a fresh snapshot into the rates. Call once per new generation. /// Folds a fresh snapshot into the rates. Call once per new generation.
pub(crate) fn observe(&mut self, report: &StatusReport, now: Instant) { pub(crate) fn observe(&mut self, report: &StatusReport, now: Instant) {
let mut active = HashSet::new();
for network in &report.networks { for network in &report.networks {
let Some(overlay) = &network.overlay else { let Some(overlay) = &network.overlay else {
continue; continue;
}; };
let (mut net_packets, mut net_bytes) = (0u64, 0u64); let (mut packets_rate, mut bytes_rate) = (0.0, 0.0);
for peer in &overlay.peers { for peer in &overlay.peers {
let packets = peer.tx_packets.saturating_add(peer.rx_packets); let key = peer_key(&network.network_id, &peer.public_key);
let bytes = peer.tx_bytes.saturating_add(peer.rx_bytes); // Duplicate rows are not additional traffic.
net_packets = net_packets.saturating_add(packets); if !active.insert(key.clone()) {
net_bytes = net_bytes.saturating_add(bytes); continue;
self.series }
.entry(peer_key(&network.network_id, &peer.public_key)) let series = self.series.entry(key).or_default();
.or_default() series.sample_peer(now, peer.into());
.sample(now, packets, bytes); packets_rate += series.rate(Unit::Packets);
bytes_rate += series.rate(Unit::Bytes);
} }
// Sum rates, not cumulative counters of a changing set of peers.
let key = net_key(&network.network_id);
active.insert(key.clone());
self.series self.series
.entry(net_key(&network.network_id)) .entry(key)
.or_default() .or_default()
.sample(now, net_packets, net_bytes); .record(packets_rate, bytes_rate);
} }
self.series.retain(|key, _| active.contains(key));
} }
/// The network-total series, if sampled. /// The network-total series, if sampled.
@@ -120,3 +182,112 @@ fn net_key(network_id: &str) -> String {
fn peer_key(network_id: &str, public_key: &str) -> String { fn peer_key(network_id: &str, public_key: &str) -> String {
format!("peer:{network_id}:{public_key}") format!("peer:{network_id}:{public_key}")
} }
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tsunagi::ipc::{NetworkReport, OverlayPeerReport, OverlayReport};
fn peer(key: &str, bytes: u64) -> OverlayPeerReport {
OverlayPeerReport {
public_key: key.into(),
tx_bytes: bytes,
..Default::default()
}
}
fn report(peers: Vec<OverlayPeerReport>) -> StatusReport {
StatusReport {
networks: vec![NetworkReport {
network_id: "network".into(),
overlay: Some(OverlayReport {
peers,
..Default::default()
}),
..Default::default()
}],
..Default::default()
}
}
#[test]
fn joining_peer_does_not_add_its_past_traffic_to_the_network_rate() {
let mut traffic = Traffic::default();
let now = Instant::now();
traffic.observe(&report(vec![peer("a", 100)]), now);
traffic.observe(
&report(vec![peer("a", 120), peer("b", 100_000_000_000)]),
now + Duration::from_secs(1),
);
assert_eq!(
traffic.network("network").map(|s| s.rate(Unit::Bytes)),
Some(20.0)
);
traffic.observe(
&report(vec![peer("a", 150), peer("b", 100_000_000_040)]),
now + Duration::from_secs(2),
);
assert_eq!(
traffic.network("network").map(|s| s.rate(Unit::Bytes)),
Some(70.0)
);
}
#[test]
fn departed_peers_and_removed_networks_do_not_keep_stale_rates() {
let mut traffic = Traffic::default();
let now = Instant::now();
traffic.observe(&report(vec![peer("a", 100)]), now);
traffic.observe(&report(vec![peer("a", 140)]), now + Duration::from_secs(1));
traffic.observe(&report(vec![]), now + Duration::from_secs(2));
assert!(traffic.peer("network", "a").is_none());
assert_eq!(
traffic.network("network").map(|s| s.rate(Unit::Bytes)),
Some(0.0)
);
traffic.observe(&StatusReport::default(), now + Duration::from_secs(3));
assert!(traffic.network("network").is_none());
}
#[test]
fn restarted_counters_are_a_new_baseline_even_if_they_already_exceed_the_old_value() {
let mut traffic = Traffic::default();
let now = Instant::now();
let mut old = peer("a", 100);
old.counter_epoch = 1;
traffic.observe(&report(vec![old]), now);
let mut restarted = peer("a", 100_000_000_000);
restarted.counter_epoch = 2;
traffic.observe(
&report(vec![restarted.clone()]),
now + Duration::from_secs(1),
);
assert_eq!(
traffic.peer("network", "a").map(|s| s.rate(Unit::Bytes)),
Some(0.0)
);
restarted.tx_bytes += 80;
traffic.observe(&report(vec![restarted]), now + Duration::from_secs(2));
assert_eq!(
traffic.network("network").map(|s| s.rate(Unit::Bytes)),
Some(80.0)
);
}
#[test]
fn reset_in_one_direction_cannot_be_hidden_by_growth_in_the_other() {
let mut traffic = Traffic::default();
let now = Instant::now();
let mut old = peer("a", 100);
old.rx_bytes = 1000;
traffic.observe(&report(vec![old]), now);
let mut reset = peer("a", 50);
reset.rx_bytes = 10_000;
traffic.observe(&report(vec![reset]), now + Duration::from_secs(1));
assert_eq!(
traffic.network("network").map(|s| s.rate(Unit::Bytes)),
Some(0.0)
);
}
}
+88
View File
@@ -0,0 +1,88 @@
//! Fixed row geometry for dense live tables. Children cannot resize a row or
//! paint into its neighbours; the enclosing scroll area owns overflow.
use eframe::egui;
pub(crate) const GAP: f32 = 10.0;
pub(crate) fn row(
ui: &mut egui::Ui,
id: impl std::hash::Hash,
widths: &[f32],
stripe: bool,
mut cell: impl FnMut(usize, &mut egui::Ui),
) {
let font = egui::FontSelection::Default.resolve(ui.style());
let text_height = ui.fonts(|fonts| fonts.row_height(&font));
let height = ui.spacing().interact_size.y.max(text_height) + 10.0;
let width = widths.iter().sum::<f32>() + GAP * widths.len().saturating_sub(1) as f32;
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
if stripe {
ui.painter()
.rect_filled(rect, 2.0, ui.visuals().faint_bg_color);
}
let mut x = rect.left();
for (column, width) in widths.iter().enumerate() {
let bounds = egui::Rect::from_min_size(
egui::pos2(x, rect.top() + 3.0),
egui::vec2(*width, height - 6.0),
);
let mut child = ui.new_child(
egui::UiBuilder::new()
.id_salt((&id, column))
.max_rect(bounds)
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
child.set_clip_rect(ui.clip_rect().intersect(bounds));
child.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
cell(column, &mut child);
x += width + GAP;
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn oversized_content_is_clipped_to_its_cell_at_every_scale() {
for size in [13.0, 20.0, 28.0] {
let ctx = egui::Context::default();
ctx.style_mut(|style| {
style.override_font_id = Some(egui::FontId::proportional(size));
});
let output = ctx.run(egui::RawInput::default(), |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
for index in 0..4 {
row(ui, index, &[90.0, 120.0], index % 2 == 0, |column, ui| {
if column == 0 {
ui.horizontal(|ui| {
ui.label("very long device name that cannot fit");
ui.button("Forget").on_hover_text("Forget");
});
} else {
ui.label(format!("row {index}"));
}
});
}
});
});
let mut previous_bottom = 0.0;
for index in 0..4 {
let shape = output.shapes.iter().find(|shape| matches!(&shape.shape, egui::Shape::Text(t) if t.galley.text() == format!("row {index}"))).unwrap();
let egui::Shape::Text(text) = &shape.shape else {
unreachable!()
};
assert!(text.visual_bounding_rect().top() >= previous_bottom);
assert!(
shape.clip_rect.contains_rect(text.visual_bounding_rect()),
"font {size}: clip {:?}, text {:?}",
shape.clip_rect,
text.visual_bounding_rect()
);
assert!(shape.clip_rect.left() >= 108.0);
previous_bottom = shape.clip_rect.bottom();
}
}
}
}
+45 -27
View File
@@ -8,7 +8,10 @@ use std::process::{Child, Command};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem}; use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; use tray_icon::{TrayIcon, TrayIconBuilder};
use crate::icon;
use crate::watch::{self, Health};
/// The window process, when there is one. /// The window process, when there is one.
struct Window { struct Window {
@@ -69,9 +72,9 @@ fn build() -> Result<Built, String> {
menu.append(&quit).map_err(|err| err.to_string())?; menu.append(&quit).map_err(|err| err.to_string())?;
let icon = TrayIconBuilder::new() let icon = TrayIconBuilder::new()
.with_tooltip("tsunagi") .with_tooltip(Health::Disconnected.label())
.with_menu(Box::new(menu)) .with_menu(Box::new(menu))
.with_icon(make_icon()?) .with_icon(icon::tray(Health::Disconnected).ok_or("cannot draw the tray icon")?)
.build() .build()
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
Ok(Built { Ok(Built {
@@ -101,6 +104,16 @@ fn build_checked() -> Result<Built, String> {
}) })
} }
/// Shows a state on the icon, if it is a different one than is showing.
fn show(icon: &TrayIcon, shown: &mut Option<Health>, health: Health) {
if *shown == Some(health) {
return;
}
*shown = Some(health);
let _ = icon.set_icon(icon::tray(health));
let _ = icon.set_tooltip(Some(health.label()));
}
/// Acts on menu choices as the platform's loop delivers them. /// Acts on menu choices as the platform's loop delivers them.
fn handle_menu(built: &Built, window: Arc<Window>) { fn handle_menu(built: &Built, window: Arc<Window>) {
let (open, quit) = (built.open.clone(), built.quit.clone()); let (open, quit) = (built.open.clone(), built.quit.clone());
@@ -128,8 +141,22 @@ pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
child: Mutex::new(None), child: Mutex::new(None),
}), }),
); );
// The icon belongs to this thread, so the answers come to it: the poller
// queues them and the loop here takes them off once a second.
let (tx, rx) = std::sync::mpsc::channel();
watch::spawn(move |health| {
let _ = tx.send(health);
});
let icon = built.icon;
let mut shown = None;
gtk::glib::timeout_add_seconds_local(1, move || {
while let Ok(health) = rx.try_recv() {
show(&icon, &mut shown, health);
}
gtk::glib::ControlFlow::Continue
});
gtk::main(); gtk::main();
drop(built.icon);
Ok(()) Ok(())
} }
@@ -148,9 +175,16 @@ pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
struct Pump { struct Pump {
window: Arc<Window>, window: Arc<Window>,
icon: Option<TrayIcon>, icon: Option<TrayIcon>,
shown: Option<Health>,
} }
impl ApplicationHandler for Pump { impl ApplicationHandler<Health> for Pump {
fn user_event(&mut self, _event_loop: &ActiveEventLoop, health: Health) {
if let Some(icon) = &self.icon {
show(icon, &mut self.shown, health);
}
}
fn resumed(&mut self, _event_loop: &ActiveEventLoop) { fn resumed(&mut self, _event_loop: &ActiveEventLoop) {
if self.icon.is_some() { if self.icon.is_some() {
return; return;
@@ -170,7 +204,7 @@ pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {} fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
} }
let mut builder = EventLoop::builder(); let mut builder = EventLoop::<Health>::with_user_event();
// macOS: live in the menu bar with no Dock icon. // macOS: live in the menu bar with no Dock icon.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
@@ -178,33 +212,17 @@ pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
builder.with_activation_policy(ActivationPolicy::Accessory); builder.with_activation_policy(ActivationPolicy::Accessory);
} }
let event_loop = builder.build()?; let event_loop = builder.build()?;
let proxy = event_loop.create_proxy();
watch::spawn(move |health| {
let _ = proxy.send_event(health);
});
let mut pump = Pump { let mut pump = Pump {
window: Arc::new(Window { window: Arc::new(Window {
child: Mutex::new(None), child: Mutex::new(None),
}), }),
icon: None, icon: None,
shown: None,
}; };
event_loop.run_app(&mut pump)?; event_loop.run_app(&mut pump)?;
Ok(()) Ok(())
} }
/// A small round icon, drawn in code so there is no asset to ship yet.
fn make_icon() -> Result<Icon, String> {
const SIZE: u32 = 32;
let centre = (SIZE as f32 - 1.0) / 2.0;
let radius = SIZE as f32 * 0.46;
let mut rgba = vec![0u8; (SIZE * SIZE * 4) as usize];
for y in 0..SIZE {
for x in 0..SIZE {
let (dx, dy) = (x as f32 - centre, y as f32 - centre);
if dx * dx + dy * dy <= radius * radius {
let i = ((y * SIZE + x) * 4) as usize;
rgba[i] = 0x2f;
rgba[i + 1] = 0x80;
rgba[i + 2] = 0xd8;
rgba[i + 3] = 0xff;
}
}
}
Icon::from_rgba(rgba, SIZE, SIZE).map_err(|err| err.to_string())
}
+139 -35
View File
@@ -41,10 +41,13 @@ pub(crate) struct UiState {
pub(crate) host_edit: Option<String>, pub(crate) host_edit: Option<String>,
/// Network ids whose devices window is open. /// Network ids whose devices window is open.
pub(crate) open_devices: BTreeSet<String>, pub(crate) open_devices: BTreeSet<String>,
pub(crate) vip_windows: std::collections::BTreeMap<String, crate::vip::Window>,
/// Whether the About window is open. /// Whether the About window is open.
pub(crate) about_open: bool, pub(crate) about_open: bool,
/// An exit-node choice waiting for the user to confirm it. /// An exit-node choice waiting for the user to confirm it.
pub(crate) exit_confirm: Option<devices::ExitConfirm>, pub(crate) exit_confirm: Option<devices::ExitConfirm>,
/// A selected offline claim waiting for cleanup confirmation.
pub(crate) forget_confirm: Option<devices::ForgetConfirm>,
} }
impl Default for UiState { impl Default for UiState {
@@ -54,8 +57,10 @@ impl Default for UiState {
unit: Unit::Packets, unit: Unit::Packets,
host_edit: None, host_edit: None,
open_devices: BTreeSet::new(), open_devices: BTreeSet::new(),
vip_windows: Default::default(),
about_open: false, about_open: false,
exit_confirm: None, exit_confirm: None,
forget_confirm: None,
} }
} }
} }
@@ -123,7 +128,7 @@ pub(crate) fn draw(
ui.weak("No networks yet — join one below."); ui.weak("No networks yet — join one below.");
} }
for network in &report.networks { for network in &report.networks {
draw_network(ui, agent, state, traffic, network, &report.endpoint_id); draw_network(ui, agent, state, traffic, network, report);
} }
}); });
} }
@@ -202,12 +207,13 @@ fn draw_network(
state: &mut UiState, state: &mut UiState,
traffic: &Traffic, traffic: &Traffic,
network: &NetworkReport, network: &NetworkReport,
own_id: &str, report: &StatusReport,
) { ) {
ui.add_space(6.0); ui.add_space(6.0);
egui::Frame::group(ui.style()).show(ui, |ui| { egui::Frame::group(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
format::copy_field(ui, network.name.as_str(), &network.name); format::copy_field(ui, network.name.as_str(), &network.name);
if ui.button("VIP").clicked(){state.vip_windows.entry(network.network_id.clone()).or_default();}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let leave = let leave =
egui::Button::new(egui::RichText::new("Leave").color(egui::Color32::WHITE)) egui::Button::new(egui::RichText::new("Leave").color(egui::Color32::WHITE))
@@ -243,22 +249,41 @@ fn draw_network(
} }
ui.weak("peers"); ui.weak("peers");
ui.label(format!( ui.label(format!(
"{} connected · {} known", "{} online · {} known",
network.peers.len(), devices::online_count(network, &report.endpoint_id),
devices::known_count(network, own_id) devices::known_count(network, &report.endpoint_id)
)); ));
ui.end_row(); ui.end_row();
}); });
draw_protocols(ui, agent, network, &report.available_protocols);
format::sparkline(ui, series, state.unit, 34.0); format::sparkline(ui, series, state.unit, 34.0);
if !network.range_conflicts.is_empty() {
ui.colored_label(BAD, "Stopped: overlapping network ranges");
for conflict in &network.range_conflicts {
ui.label(format!("{} ({}) uses {}", conflict.name, conflict.network_id, conflict.range));
}
ui.label("Switching suspends these networks until this network stops, then resumes them automatically.");
if network.auto_start { ui.weak("This network will resume automatically when its range is free."); }
}
ui.horizontal(|ui| { ui.horizontal(|ui| {
let mut active = network.active; if !network.active && !network.range_conflicts.is_empty() {
if ui.checkbox(&mut active, "active").changed() { if ui.button("Switch to this network").clicked() {
agent.send(Command::SetActive { agent.send(Command::SetActive { network_id: network.network_id.clone(), active: true });
network_id: network.network_id.clone(), }
active, if network.auto_start && ui.button("Keep stopped").clicked() {
}); agent.send(Command::SetActive { network_id: network.network_id.clone(), active: false });
}
} else {
let mut active = network.active;
if ui.checkbox(&mut active, "active").changed() {
agent.send(Command::SetActive {
network_id: network.network_id.clone(),
active,
});
}
} }
let mut broadcast = network.broadcast; let mut broadcast = network.broadcast;
if ui.checkbox(&mut broadcast, "broadcast").changed() { if ui.checkbox(&mut broadcast, "broadcast").changed() {
@@ -283,32 +308,31 @@ fn draw_network(
} }
}); });
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // In the bottom left corner while this device sends its traffic through
// In the corner while this device sends its traffic through // somebody: green while that works, red while it does not.
// somebody: green while that works, red while it does not. if let Some(via) = &network.exit.via {
if let Some(via) = &network.exit.via { let color = if network.exit.via_online {
let color = if network.exit.via_online { exit::GOOD
exit::GOOD } else {
} else { exit::LOUD
exit::LOUD };
}; let response = ui
let response = ui .add(
.add( egui::Label::new(
egui::Label::new( egui::RichText::new(icon::SIGN_OUT).size(18.0).color(color),
egui::RichText::new(icon::SIGN_OUT).size(18.0).color(color),
)
.sense(egui::Sense::click()),
) )
.on_hover_cursor(egui::CursorIcon::PointingHand) .sense(egui::Sense::click()),
.on_hover_text(format!( )
"exit node: {} — click to stop", .on_hover_cursor(egui::CursorIcon::PointingHand)
exit::name_of(network, via) .on_hover_text(format!(
)); "exit node: {} — click to stop",
if response.clicked() { exit::name_of(network, via)
state.exit_confirm = ));
Some(devices::ExitConfirm::stop_from_tile(network, via)); if response.clicked() {
} state.exit_confirm = Some(devices::ExitConfirm::stop_from_tile(network, via));
} }
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Show devices").clicked() { if ui.button("Show devices").clicked() {
state.open_devices.insert(network.network_id.clone()); state.open_devices.insert(network.network_id.clone());
} }
@@ -329,6 +353,86 @@ fn draw_network(
}); });
} }
fn draw_protocols(
ui: &mut egui::Ui,
agent: &AgentClient,
network: &NetworkReport,
available: &[tsunagi::ipc::ProtocolReport],
) {
let summary = if network.protocols.is_empty() {
"none".into()
} else {
network.protocols.join(", ")
};
egui::CollapsingHeader::new(format!("data transports {summary}"))
.id_salt(("protocols", &network.network_id))
.show(ui, |ui| {
ui.weak("Preference order; changes apply immediately and are saved.");
let mut selected = network.protocols.clone();
let mut changed = false;
ui.add_enabled_ui(!agent.snapshot().busy, |ui| {
// Enabled items follow the user's preference; unchecked items
// follow the agent's catalog. The worker refreshes after edits.
let ordered: Vec<_> = network
.protocols
.iter()
.filter_map(|name| available.iter().find(|p| &p.name == name))
.chain(
available
.iter()
.filter(|p| !network.protocols.contains(&p.name)),
)
.collect();
for protocol in ordered {
ui.horizontal(|ui| {
let mut on = selected.contains(&protocol.name);
if ui.checkbox(&mut on, &protocol.name).changed() {
if on {
selected.push(protocol.name.clone());
} else {
selected.retain(|p| p != &protocol.name);
}
changed = true;
}
if let Some(index) = selected.iter().position(|p| p == &protocol.name) {
if ui
.add_enabled(index > 0, egui::Button::new(icon::ARROW_UP))
.on_hover_text("prefer earlier")
.clicked()
{
selected.swap(index, index - 1);
changed = true;
}
if ui
.add_enabled(
index + 1 < selected.len(),
egui::Button::new(icon::ARROW_DOWN),
)
.on_hover_text("prefer later")
.clicked()
{
selected.swap(index, index + 1);
changed = true;
}
}
});
ui.indent(("description", &network.network_id, &protocol.name), |ui| {
ui.weak(&protocol.summary);
});
}
});
if selected.is_empty() {
ui.weak("Control only; no IP traffic in this network.");
}
if changed {
agent.send(Command::SetProtocols {
network_id: network.network_id.clone(),
protocols: selected,
});
}
});
}
/// A prominent line about an exit node. One that carries a command copies it /// A prominent line about an exit node. One that carries a command copies it
/// when clicked. /// when clicked.
fn draw_exit_warning(ui: &mut egui::Ui, warning: &exit::Warning) { fn draw_exit_warning(ui: &mut egui::Ui, warning: &exit::Warning) {
+402
View File
@@ -0,0 +1,402 @@
//! Per-network VIP editor. Drafts never issue commands until Save is pressed.
use crate::agent::{AgentClient, Command, Snapshot};
use crate::vip_status;
use eframe::egui;
use tsunagi::ipc::NetworkReport;
use tsunagi::vip::{Backend, Listener, Protocol, VipConfig};
#[derive(Clone)]
struct Draft {
config: VipConfig,
original: VipConfig,
revision: Option<String>,
delete: bool,
}
impl Draft {
fn new(config: VipConfig, revision: Option<String>) -> Self {
Self {
original: config.clone(),
config,
revision,
delete: false,
}
}
fn dirty(&self) -> bool {
self.revision.is_none() || self.delete || self.config != self.original
}
}
#[derive(Default)]
pub(crate) struct Window {
draft: Option<Draft>,
pending: Option<String>,
error: Option<String>,
close_question: bool,
close_after_save: bool,
live: vip_status::View,
configure: bool,
generation: u64,
acknowledged_generation: Option<u64>,
}
impl Window {
pub fn request_close(&mut self) {
self.close_question = true;
self.close_after_save = true;
}
pub fn dirty(&self) -> bool {
self.draft.as_ref().is_some_and(Draft::dirty) || self.pending.is_some()
}
}
fn peer_name<'a>(n: &'a NetworkReport, id: &'a str) -> &'a str {
n.members
.iter()
.find(|m| m.endpoint_id == id)
.and_then(|m| m.hostname.as_deref())
.unwrap_or(id)
}
pub(crate) fn show(
ctx: &egui::Context,
agent: &AgentClient,
state: &mut Window,
n: &NetworkReport,
snapshot: &Snapshot,
) -> bool {
if state.generation != snapshot.generation {
state.generation = snapshot.generation;
if let Some(at) = snapshot.at {
state.live.observe(n, at);
}
}
if let Some(result) = &snapshot.vip_result
&& state.pending.as_deref() == Some(result.token.as_str())
{
state.pending = None;
state.acknowledged_generation = Some(snapshot.generation);
match &result.result {
Ok(vips) => {
if let Some(draft) = &state.draft {
state.draft = vips
.iter()
.find(|v| v.config.id == draft.config.id)
.map(|v| Draft::new(v.config.clone(), Some(v.revision.clone())));
}
state.error = None;
}
Err(error) => {
state.error = Some(error.clone());
state.close_after_save = false;
}
}
}
let mut close = state.close_after_save && !state.dirty();
let mut discard_new = false;
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading(format!("VIP · {}", n.name));
ui.weak("Configuration is shared with every member. Changes take effect only after Save.");
if state.close_question {
ui.horizontal(|ui| {
ui.label("Unsaved changes.");
if ui
.add_enabled(
state.pending.is_none(),
egui::Button::new("Discard and close"),
)
.clicked()
{
state.draft = None;
state.close_question = false;
close = true;
}
if ui.button("Keep editing").clicked() {
state.close_question = false;
state.close_after_save = false;
}
});
}
if let Some(error) = &state.error {
ui.colored_label(egui::Color32::LIGHT_RED, error);
}
ui.separator();
ui.horizontal_wrapped(|ui| {
for vip in &n.vips {
let selected = state
.draft
.as_ref()
.is_some_and(|d| d.config.id == vip.config.id);
if ui.selectable_label(selected, &vip.config.name).clicked() {
if state.dirty() {
state.error = Some(
"Save or discard the current draft before selecting another VIP."
.into(),
);
} else {
state.draft =
Some(Draft::new(vip.config.clone(), Some(vip.revision.clone())));
state.error = None;
}
}
}
if ui.button("+ New VIP").clicked() {
if state.dirty() {
state.error = Some("Save or discard the current draft first.".into());
} else {
state.draft = Some(Draft::new(VipConfig::new(""), None));
state.configure = true;
state.error = None;
}
}
});
let Some(draft) = &mut state.draft else {
ui.label("Select a VIP or create one.");
return;
};
let current = n.vips.iter().find(|v| v.config.id == draft.config.id);
if state.pending.is_none()
&& state.acknowledged_generation != Some(snapshot.generation)
&& !draft.dirty()
&& let Some(current) = current
&& draft.revision.as_ref() != Some(&current.revision)
{
*draft = Draft::new(current.config.clone(), Some(current.revision.clone()));
}
let changed =
current.is_some_and(|v| draft.revision.as_ref().is_some_and(|r| r != &v.revision));
if changed {
ui.colored_label(
egui::Color32::YELLOW,
"This VIP changed on the network. Reload before saving; your draft is retained.",
);
}
if draft.revision.is_some() && current.is_none() {
ui.colored_label(
egui::Color32::YELLOW,
"This VIP was removed on the network. The draft cannot overwrite its deletion.",
);
}
let busy = state.pending.is_some();
ui.horizontal(|ui| {
let valid = draft.config.validate(&tsunagi::Limits::default());
if !n.active {
ui.weak("Start the network to save changes.");
}
if ui
.add_enabled(
n.active
&& !busy
&& draft.dirty()
&& !changed
&& (valid.is_ok() || draft.delete),
egui::Button::new("Save"),
)
.clicked()
{
let token = VipConfig::new("").id;
state.pending = Some(token.clone());
agent.send(Command::SaveVip {
token,
network_id: n.network_id.clone(),
config: draft.config.clone(),
expected: draft.revision.clone(),
delete: draft.delete,
});
}
if ui
.add_enabled(!busy, egui::Button::new("Discard / reload"))
.clicked()
{
if let Some(v) = current {
*draft = Draft::new(v.config.clone(), Some(v.revision.clone()));
} else {
discard_new = true;
}
state.error = None;
}
if busy {
ui.spinner();
ui.label("Saving…");
} else if draft.dirty() {
ui.weak("Unsaved changes");
}
if let Err(error) = valid {
ui.colored_label(egui::Color32::LIGHT_RED, error.to_string());
}
});
ui.separator();
ui.horizontal(|ui| {
ui.selectable_value(&mut state.configure, false, "Overview");
ui.selectable_value(&mut state.configure, true, "Configuration");
});
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if state.configure {
ui.add_enabled_ui(!busy, |ui| {
ui.horizontal(|ui| {
ui.label("Name");
ui.add(
egui::TextEdit::singleline(&mut draft.config.name)
.id(egui::Id::new(("vip-name", &draft.config.id))),
);
ui.weak(format!(".{}", n.name));
});
if draft.revision.is_some() {
ui.checkbox(&mut draft.delete, "Delete this VIP when saved");
}
if !draft.delete {
edit_listeners(ui, &mut draft.config, n);
}
});
} else if let Some(current) = current {
vip_status::show(ui, current, n, &mut state.live);
} else {
ui.weak("Save this VIP to see its live status.");
}
});
});
if discard_new {
state.draft = None;
state.error = None;
}
close
}
fn edit_listeners(ui: &mut egui::Ui, config: &mut VipConfig, n: &NetworkReport) {
let mut remove = None;
for (index, l) in config.listeners.iter_mut().enumerate() {
ui.push_id(index,|ui|{egui::Frame::group(ui.style()).show(ui,|ui|{
ui.horizontal(|ui|{egui::ComboBox::from_id_salt("protocol").selected_text(l.protocol.to_string()).show_ui(ui,|ui|{ui.selectable_value(&mut l.protocol,Protocol::Tcp,"TCP");ui.selectable_value(&mut l.protocol,Protocol::Udp,"UDP");});ui.label("Port");ui.add(egui::DragValue::new(&mut l.port).range(1..=65535));if ui.button("Remove listener").clicked(){remove=Some(index);}});
if l.protocol==Protocol::Tcp{ui.checkbox(&mut l.port_check,"Active TCP port checks");}else{ui.weak("UDP health: encrypted path probes and explicit port errors; application readiness is unknown.");}
ui.collapsing("Checks and flow expiry",|ui|{egui::Grid::new("settings").show(ui,|ui|{
ui.label("Check interval, ms");ui.add(egui::DragValue::new(&mut l.check_interval_ms).range(250..=3_600_000));ui.end_row();
ui.label("Check timeout, ms");ui.add(egui::DragValue::new(&mut l.check_timeout_ms).range(100..=60_000));ui.end_row();
ui.label("Failures before exclusion");ui.add(egui::DragValue::new(&mut l.failure_threshold).range(1..=255));ui.end_row();
ui.label("Successes before recovery");ui.add(egui::DragValue::new(&mut l.recovery_threshold).range(1..=255));ui.end_row();
ui.label("Flow idle timeout, seconds");ui.add(egui::DragValue::new(&mut l.idle_timeout_secs).range(1..=604800));ui.end_row();
});});
let mut remove_backend=None;for(i,b)in l.backends.iter_mut().enumerate(){ui.push_id(i,|ui|{ui.horizontal(|ui|{
egui::ComboBox::from_id_salt("node").width(240.).selected_text(peer_name(n,&b.peer)).show_ui(ui,|ui|{for m in &n.members{let label=format!("{} ({})",m.hostname.as_deref().unwrap_or("unnamed"),m.endpoint_id.chars().take(8).collect::<String>());ui.selectable_value(&mut b.peer,m.endpoint_id.clone(),label);}});
ui.label("Target port");ui.add(egui::DragValue::new(&mut b.port).range(1..=65535));if ui.button("Remove").clicked(){remove_backend=Some(i);}
});});}if let Some(i)=remove_backend{l.backends.remove(i);}
if ui.button("+ Backend").clicked(){l.backends.push(Backend{peer:n.members.first().map(|m|m.endpoint_id.clone()).unwrap_or_default(),port:l.port});}
});});
}
if let Some(index) = remove {
config.listeners.remove(index);
}
if ui.button("+ Listener").clicked() {
config.listeners.push(Listener::new(Protocol::Tcp, 443));
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use tsunagi::vip::VipStatus;
#[test]
fn editing_the_actual_form_sends_nothing_until_save_is_clicked() {
let (agent, mut commands) = AgentClient::test_client();
let ctx = egui::Context::default();
let mut config = VipConfig::new("api");
config.listeners.push(Listener::new(Protocol::Tcp, 443));
let mut state = Window {
draft: Some(Draft::new(config.clone(), Some("revision".into()))),
configure: true,
..Default::default()
};
let network = NetworkReport {
active: true,
name: "lab".into(),
vips: vec![VipStatus {
config: config.clone(),
revision: "revision".into(),
address: None,
error: None,
listeners: vec![],
}],
..Default::default()
};
let snapshot = Snapshot::default();
let frame = |events: Vec<egui::Event>, state: &mut Window| {
ctx.run(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1000., 800.),
)),
events,
..Default::default()
},
|ctx| {
show(ctx, &agent, state, &network, &snapshot);
},
)
};
frame(vec![], &mut state);
ctx.memory_mut(|m| m.request_focus(egui::Id::new(("vip-name", &config.id))));
frame(vec![egui::Event::Text("new".into())], &mut state);
assert!(state.dirty());
assert_ne!(state.draft.as_ref().unwrap().config.name, config.name);
assert!(commands.try_recv().is_err());
assert_eq!(network.vips[0].config, config);
fn save_position(shape: &egui::Shape) -> Option<egui::Pos2> {
match shape {
egui::Shape::Text(text) if text.galley.text() == "Save" => {
Some(text.pos + text.galley.rect.center().to_vec2())
}
egui::Shape::Vec(shapes) => shapes.iter().find_map(save_position),
_ => None,
}
}
// Save was disabled before this edit; allow the next layout to register
// its now-enabled hit target before dispatching the pointer press.
let ready = frame(vec![], &mut state);
let position = ready
.shapes
.iter()
.find_map(|s| save_position(&s.shape))
.unwrap();
frame(
vec![
egui::Event::PointerMoved(position),
egui::Event::PointerButton {
pos: position,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::NONE,
},
],
&mut state,
);
frame(
vec![egui::Event::PointerButton {
pos: position,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::NONE,
}],
&mut state,
);
let Command::SaveVip {
config: saved,
expected,
delete,
..
} = commands.try_recv().unwrap()
else {
panic!("Save must submit one complete VIP draft");
};
assert_ne!(saved.name, config.name);
assert_eq!(expected.as_deref(), Some("revision"));
assert!(!delete);
assert!(commands.try_recv().is_err());
}
#[test]
fn all_listener_settings_render_without_a_live_agent() {
let ctx = egui::Context::default();
let mut cfg = VipConfig::new("api");
cfg.listeners.push(Listener::new(Protocol::Tcp, 443));
cfg.listeners.push(Listener::new(Protocol::Udp, 53));
let n = NetworkReport::default();
let _ = ctx.run(egui::RawInput::default(), |ctx| {
egui::CentralPanel::default().show(ctx, |ui| edit_listeners(ui, &mut cfg, &n));
});
assert_eq!(cfg.listeners.len(), 2);
}
}
+885
View File
@@ -0,0 +1,885 @@
//! Local VIP observations, kept apart from editable configuration drafts.
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use eframe::egui;
use tsunagi::ipc::NetworkReport;
use tsunagi::vip::{Backend, BackendStatus, Counters, ListenerStatus, Protocol, VipStatus};
use crate::{
format,
stats::{Series, Unit},
table,
};
const ACTIVE: egui::Color32 = egui::Color32::from_rgb(73, 190, 133);
const NEXT: egui::Color32 = egui::Color32::from_rgb(78, 157, 237);
const WARN: egui::Color32 = egui::Color32::from_rgb(225, 170, 73);
const BAD: egui::Color32 = egui::Color32::from_rgb(228, 112, 112);
#[derive(Default)]
struct Rates {
tx: Series,
rx: Series,
total: Series,
at: Option<Instant>,
}
impl Rates {
fn sample(&mut self, at: Instant, c: &Counters) {
self.tx.sample(at, c.tx_packets, c.tx_bytes);
self.rx.sample(at, c.rx_packets, c.rx_bytes);
self.total.sample(
at,
c.tx_packets.saturating_add(c.rx_packets),
c.tx_bytes.saturating_add(c.rx_bytes),
);
self.at = Some(at);
}
fn active(&self, now: Instant) -> bool {
self.at
.is_some_and(|at| now.saturating_duration_since(at) <= Duration::from_secs(5))
&& self.total.rate(Unit::Packets) > 0.0
}
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct Key(String, String, u16, String, u16);
fn key(v: &VipStatus, l: &ListenerStatus, b: &Backend) -> Key {
// Listener identity separates counter histories when a port is recreated.
let listener = v
.config
.listeners
.iter()
.find(|entry| entry.protocol == l.protocol && entry.port == l.port)
.map(|entry| entry.id.clone())
.unwrap_or_else(|| l.protocol.to_string());
Key(
v.config.id.clone(),
listener,
l.port,
b.peer.clone(),
b.port,
)
}
struct Camera {
rect: egui::Rect,
members: Vec<Backend>,
}
impl Default for Camera {
fn default() -> Self {
Self {
rect: egui::Rect::NOTHING,
members: Vec::new(),
}
}
}
pub(crate) struct View {
totals: BTreeMap<String, Rates>,
backends: BTreeMap<Key, Rates>,
cameras: BTreeMap<(String, String, u16), Camera>,
selected: Option<Key>,
unit: Unit,
}
impl Default for View {
fn default() -> Self {
Self {
totals: BTreeMap::new(),
backends: BTreeMap::new(),
cameras: BTreeMap::new(),
selected: None,
unit: Unit::Bytes,
}
}
}
impl View {
pub(crate) fn observe(&mut self, network: &NetworkReport, at: Instant) {
self.totals
.retain(|id, _| network.vips.iter().any(|v| &v.config.id == id));
let mut present = std::collections::BTreeSet::new();
for v in &network.vips {
let mut counters = Counters::default();
for l in &v.listeners {
counters.tx_packets = counters.tx_packets.saturating_add(l.counters.tx_packets);
counters.rx_packets = counters.rx_packets.saturating_add(l.counters.rx_packets);
counters.tx_bytes = counters.tx_bytes.saturating_add(l.counters.tx_bytes);
counters.rx_bytes = counters.rx_bytes.saturating_add(l.counters.rx_bytes);
for b in &l.backends {
let key = key(v, l, &b.backend);
self.backends
.entry(key.clone())
.or_default()
.sample(at, &b.counters);
present.insert(key);
}
}
self.totals
.entry(v.config.id.clone())
.or_default()
.sample(at, &counters);
}
self.backends.retain(|key, _| present.contains(key));
if self
.selected
.as_ref()
.is_some_and(|key| !present.contains(key))
{
self.selected = None;
}
self.cameras.retain(|(id, protocol, port), _| {
network.vips.iter().any(|v| {
&v.config.id == id
&& v.listeners
.iter()
.any(|l| l.protocol.to_string() == *protocol && l.port == *port)
})
});
}
}
fn name<'a>(n: &'a NetworkReport, id: &'a str) -> &'a str {
n.members
.iter()
.find(|m| m.endpoint_id == id)
.and_then(|m| m.hostname.as_deref())
.unwrap_or(id)
}
fn backend_name(n: &NetworkReport, b: &Backend) -> String {
format!("{}:{}", name(n, &b.peer), b.port)
}
fn rtt(b: &BackendStatus) -> String {
b.rtt_us
.map_or_else(|| "—".into(), |us| format!("{:.2} ms", us as f64 / 1000.0))
}
fn health_color(state: &str) -> egui::Color32 {
match state {
"ready" => ACTIVE,
"checking port" | "measuring RTT" | "draining" => WARN,
_ => BAD,
}
}
pub(crate) fn show(ui: &mut egui::Ui, v: &VipStatus, n: &NetworkReport, view: &mut View) {
ui.horizontal_wrapped(|ui| {
ui.heading(format!("{}.{}", v.config.name, n.name));
ui.weak("Traffic seen by this device");
ui.selectable_value(&mut view.unit, Unit::Bytes, "bytes");
ui.selectable_value(&mut view.unit, Unit::Packets, "pkts");
});
if let Some(error) = &v.error {
ui.colored_label(BAD, error);
}
let total = view.totals.get(&v.config.id);
ui.horizontal_wrapped(|ui| {
metric(
ui,
"TX",
&format::series_rate(total.map(|r| &r.tx), view.unit),
);
metric(
ui,
"RX",
&format::series_rate(total.map(|r| &r.rx), view.unit),
);
let flows: u64 = v
.listeners
.iter()
.flat_map(|l| &l.backends)
.map(|b| u64::from(b.active_flows))
.sum();
metric(ui, "Open flows", &flows.to_string());
});
format::sparkline(ui, total.map(|r| &r.total), view.unit, 64.0);
ui.add_space(8.0);
if v.listeners.is_empty() {
ui.weak("No listeners configured. Add one in Configuration.");
}
for l in &v.listeners {
ui.push_id(
(
"vip live listener",
&v.config.id,
l.protocol.to_string(),
l.port,
),
|ui| {
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.set_min_width((ui.available_width() - 2.0).max(0.0));
ui.horizontal_wrapped(|ui| {
ui.heading(format!("{}/{}", l.protocol, l.port));
ui.weak("New connections:");
if let Some(b) = &l.selected {
ui.colored_label(NEXT, backend_name(n, b));
} else {
ui.colored_label(WARN, "No eligible backend");
}
});
ui.horizontal_wrapped(|ui| {
metric(ui, "TX", &format::bytes(l.counters.tx_bytes as f64));
metric(ui, "RX", &format::bytes(l.counters.rx_bytes as f64));
metric(
ui,
"Packets TX / RX",
&format!("{} / {}", l.counters.tx_packets, l.counters.rx_packets),
);
metric(ui, "Drops", &l.counters.dropped.to_string());
if l.protocol == Protocol::Udp {
metric(ui, "Failovers", &l.counters.failovers.to_string());
}
});
ui.separator();
diagram(ui, v, l, n, view);
ui.add_space(6.0);
egui::ScrollArea::horizontal()
.id_salt("backend table scroll")
.auto_shrink([false, true])
.show(ui, |ui| {
backend_table(ui, v, l, n, view);
});
if let Some(b) = l
.backends
.iter()
.find(|b| view.selected.as_ref() == Some(&key(v, l, &b.backend)))
{
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.strong(backend_name(n, &b.backend));
ui.colored_label(health_color(&b.state), &b.state);
ui.weak(format!("{} open flows", b.active_flows));
if let Some(age) = b.measurement_age_ms {
ui.weak(format!(
"RTT measured {:.1}s ago",
age as f64 / 1000.0
));
} else {
ui.weak("No RTT measurement yet");
}
});
ui.horizontal_wrapped(|ui| {
ui.weak("Node");
format::copy_field(
ui,
&format::short(&b.backend.peer),
&b.backend.peer,
);
if let Some(address) = n
.members
.iter()
.find(|m| m.endpoint_id == b.backend.peer)
.and_then(|m| m.overlay_address_v4.as_deref())
{
ui.weak("Address");
format::copy_field(ui, address, address);
}
});
});
}
});
},
);
ui.add_space(8.0);
}
ui.collapsing("Internal address and identity", |ui| {
egui::Grid::new(("vip identity", &v.config.id)).show(ui, |ui| {
ui.weak("Address");
if let Some(address) = v.address {
format::copy_field(ui, &address.to_string(), &address.to_string());
} else {
ui.weak("Not allocated");
}
ui.end_row();
ui.weak("ID");
format::copy_field(ui, &v.config.id, &v.config.id);
ui.end_row();
});
});
}
fn metric(ui: &mut egui::Ui, title: &str, value: &str) {
ui.horizontal(|ui| {
ui.weak(title);
ui.strong(value);
});
ui.add_space(8.0);
}
fn backend_table(
ui: &mut egui::Ui,
v: &VipStatus,
l: &ListenerStatus,
n: &NetworkReport,
view: &mut View,
) {
let headers = [
"Backend",
"Health",
"Route",
"RTT",
"Flows",
"TX / RX",
"TX / RX rate",
"Traffic",
];
let mut widths = [170.0_f32, 110.0, 100.0, 90.0, 50.0, 160.0, 160.0, 160.0];
// Measure every value before layout, including long identities and counters.
let values: Vec<_> = l
.backends
.iter()
.map(|b| {
let rates = view.backends.get(&key(v, l, &b.backend));
let route = if rates.is_some_and(|r| r.active(Instant::now())) {
"Traffic"
} else if l.selected.as_ref() == Some(&b.backend) {
"Next flow"
} else {
"—"
};
[
backend_name(n, &b.backend),
b.state.clone(),
route.into(),
rtt(b),
b.active_flows.to_string(),
format!(
"{} / {}",
format::bytes(b.counters.tx_bytes as f64),
format::bytes(b.counters.rx_bytes as f64)
),
format!(
"{} / {}",
format::series_rate(rates.map(|r| &r.tx), view.unit),
format::series_rate(rates.map(|r| &r.rx), view.unit)
),
]
})
.collect();
for (column, width) in widths.iter_mut().enumerate().take(7) {
let font = egui::FontSelection::Default.resolve(ui.style());
for text in
std::iter::once(headers[column]).chain(values.iter().map(|row| row[column].as_str()))
{
*width = width.max(
ui.fonts(|f| {
f.layout_no_wrap(text.to_owned(), font.clone(), egui::Color32::WHITE)
.size()
.x
}) + 12.0,
);
}
}
let widths_id = ui.id().with("backend column widths");
if let Some(previous) = ui.memory(|memory| memory.data.get_temp::<[f32; 8]>(widths_id)) {
for (width, previous) in widths.iter_mut().zip(previous) {
*width = width.max(previous);
}
}
ui.memory_mut(|memory| memory.data.insert_temp(widths_id, widths));
let fixed = widths.iter().sum::<f32>() + table::GAP * 7.0;
widths[7] += (ui.available_width() - fixed).max(0.0);
table::row(ui, "backend headers", &widths, false, |column, ui| {
ui.weak(headers[column]);
});
for (index, b) in l.backends.iter().enumerate() {
let key = key(v, l, &b.backend);
let rates = view.backends.get(&key);
table::row(
ui,
&key,
&widths,
index % 2 == 0,
|column, ui| match column {
0 => {
if ui
.selectable_label(view.selected.as_ref() == Some(&key), &values[index][0])
.clicked()
{
view.selected = Some(key.clone());
}
}
1 => {
ui.colored_label(health_color(&b.state), &values[index][1])
.on_hover_text(&b.state);
}
2 => {
ui.colored_label(
if values[index][2] == "Traffic" {
ACTIVE
} else {
NEXT
},
&values[index][2],
)
.on_hover_text(
if l.selected.as_ref() == Some(&b.backend) {
"Selected for new connections. Existing flows can use other backends."
} else {
"Traffic during the latest sample interval."
},
);
}
3..=6 => {
ui.label(&values[index][column]);
}
_ => format::sparkline(ui, rates.map(|r| &r.total), view.unit, 24.0),
},
);
}
if l.backends.is_empty() {
ui.weak("No backends. Add a node and target port in Configuration.");
}
}
#[derive(Clone, Copy)]
struct Branch {
active: bool,
next: bool,
}
fn branch(l: &ListenerStatus, b: &BackendStatus, rates: Option<&Rates>, now: Instant) -> Branch {
Branch {
active: rates.is_some_and(|r| r.active(now)),
next: l.selected.as_ref() == Some(&b.backend),
}
}
fn diagram(
ui: &mut egui::Ui,
v: &VipStatus,
l: &ListenerStatus,
n: &NetworkReport,
view: &mut View,
) {
let camera = view
.cameras
.entry((v.config.id.clone(), l.protocol.to_string(), l.port))
.or_default();
let members: Vec<_> = l.backends.iter().map(|b| b.backend.clone()).collect();
let height = (members.len().max(1) as f32 * 92.0 + 32.0).max(180.0);
let bounds = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(860.0, height));
if camera.members != members || !camera.rect.is_positive() {
camera.rect = bounds;
camera.members = members;
}
ui.horizontal_wrapped(|ui| {
legend(ui, "Traffic", ACTIVE, true);
legend(ui, "Next new flow", NEXT, false);
ui.weak("Click a backend · drag to pan · Ctrl+scroll to zoom");
if ui.small_button("Fit").clicked() {
camera.rect = bounds;
}
});
let size = egui::vec2(ui.available_width(), height.clamp(180.0, 360.0));
let (outer, _) = ui.allocate_exact_size(size, egui::Sense::hover());
ui.painter()
.rect_filled(outer, 6.0, ui.visuals().extreme_bg_color);
let mut child = ui.new_child(egui::UiBuilder::new().id_salt("vip graph").max_rect(outer));
egui::Scene::new()
.zoom_range(0.05..=1.5)
.max_inner_size(bounds.size())
.show(&mut child, &mut camera.rect, |ui| {
ui.set_min_size(bounds.size());
let center = height / 2.0;
let source =
egui::Rect::from_min_size(egui::pos2(18.0, center - 32.0), egui::vec2(204.0, 64.0));
let listener = egui::Rect::from_min_size(
egui::pos2(290.0, center - 32.0),
egui::vec2(146.0, 64.0),
);
let active = l.backends.iter().any(|b| {
view.backends
.get(&key(v, l, &b.backend))
.is_some_and(|r| r.active(Instant::now()))
});
edge(
ui,
source.right_center(),
listener.left_center(),
if active { ACTIVE } else { NEXT },
active,
);
node(
ui,
source,
&format!("{}.{}", v.config.name, n.name),
"This device",
NEXT,
false,
);
node(
ui,
listener,
&format!("{}/{}", l.protocol, l.port),
"Listener",
NEXT,
false,
);
for (index, b) in l.backends.iter().enumerate() {
let key = key(v, l, &b.backend);
let rates = view.backends.get(&key);
let state = branch(l, b, rates, Instant::now());
let rect = egui::Rect::from_min_size(
egui::pos2(558.0, 24.0 + index as f32 * 92.0),
egui::vec2(278.0, 68.0),
);
let color = if state.active {
ACTIVE
} else if state.next {
NEXT
} else if b.state == "ready" {
ui.visuals().weak_text_color()
} else {
health_color(&b.state)
};
edge(
ui,
listener.right_center(),
rect.left_center(),
color,
state.active,
);
if state.next {
ui.painter().circle_stroke(
rect.left_center(),
5.0,
egui::Stroke::new(2.0_f32, NEXT),
);
}
let response = ui
.interact(rect, ui.id().with((&key.3, key.4)), egui::Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(format!(
"{}\n{} · RTT {}\n{} open flows\n{}{}",
backend_name(n, &b.backend),
b.state,
rtt(b),
b.active_flows,
if state.active {
"Traffic in latest sample. "
} else {
""
},
if state.next {
"Selected for new connections."
} else {
""
}
));
if response.clicked() {
view.selected = Some(key.clone());
}
let detail = format!("{} · {} · {} flows", b.state, rtt(b), b.active_flows);
node(
ui,
rect,
&backend_name(n, &b.backend),
&detail,
color,
view.selected.as_ref() == Some(&key) || response.hovered(),
);
}
if l.backends.is_empty() {
ui.painter().text(
egui::pos2(570.0, center),
egui::Align2::LEFT_CENTER,
"No backends",
egui::FontId::proportional(15.0),
ui.visuals().weak_text_color(),
);
}
});
}
fn legend(ui: &mut egui::Ui, text: &str, color: egui::Color32, filled: bool) {
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover());
if filled {
ui.painter().circle_filled(rect.center(), 4.0, color);
} else {
ui.painter()
.circle_stroke(rect.center(), 4.0, egui::Stroke::new(1.5_f32, color));
}
ui.colored_label(color, text);
});
}
fn node(
ui: &egui::Ui,
rect: egui::Rect,
title: &str,
detail: &str,
color: egui::Color32,
selected: bool,
) {
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 8.0, ui.visuals().widgets.inactive.bg_fill);
painter.rect_stroke(
rect.shrink(1.0),
8.0,
egui::Stroke::new(if selected { 3.0_f32 } else { 1.5_f32 }, color),
egui::StrokeKind::Inside,
);
text_line(
&painter,
rect.min + egui::vec2(12.0, 9.0),
title,
16.0,
ui.visuals().strong_text_color(),
rect.width() - 24.0,
);
text_line(
&painter,
rect.min + egui::vec2(12.0, 34.0),
detail,
12.0,
ui.visuals().text_color(),
rect.width() - 24.0,
);
}
fn text_line(
painter: &egui::Painter,
position: egui::Pos2,
text: &str,
size: f32,
color: egui::Color32,
width: f32,
) {
let mut job = egui::text::LayoutJob::simple_singleline(
text.into(),
egui::FontId::proportional(size),
color,
);
job.wrap.max_width = width;
job.wrap.max_rows = 1;
job.wrap.break_anywhere = true;
painter.galley(position, painter.layout_job(job), color);
}
fn edge(ui: &egui::Ui, start: egui::Pos2, end: egui::Pos2, color: egui::Color32, active: bool) {
let bend = (end.x - start.x) * 0.5;
let curve = egui::epaint::CubicBezierShape::from_points_stroke(
[
start,
start + egui::vec2(bend, 0.0),
end - egui::vec2(bend, 0.0),
end,
],
false,
egui::Color32::TRANSPARENT,
egui::Stroke::new(if active { 3.0_f32 } else { 1.5_f32 }, color),
);
ui.painter().add(curve.clone());
if active {
let t = ui.input(|i| i.time) as f32 * 0.5 % 1.0;
let position = curve.sample(t);
ui.painter().circle_filled(position, 4.0, color);
ui.ctx().request_repaint_after(Duration::from_millis(40));
}
ui.painter().arrow(
end - egui::vec2(12.0, 0.0),
egui::vec2(10.0, 0.0),
egui::Stroke::new(1.5_f32, color),
);
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use tsunagi::vip::{Listener, VipConfig};
fn fixture() -> (NetworkReport, VipStatus) {
let mut config = VipConfig::new("k8s");
config.listeners.push(Listener::new(Protocol::Tcp, 6443));
let backends: Vec<_> = ["ai", "master", "music"]
.into_iter()
.map(|peer| BackendStatus {
backend: Backend {
peer: peer.into(),
port: 7443,
},
state: "ready".into(),
rtt_us: Some(4500),
measurement_age_ms: Some(500),
active_flows: 5,
counters: Counters::default(),
})
.collect();
let v = VipStatus {
config,
revision: "r1".into(),
address: None,
error: None,
listeners: vec![ListenerStatus {
protocol: Protocol::Tcp,
port: 6443,
selected: Some(backends[0].backend.clone()),
backends,
counters: Counters::default(),
}],
};
let n = NetworkReport {
name: "test".into(),
vips: vec![v.clone()],
..Default::default()
};
(n, v)
}
#[test]
fn traffic_branches_are_sampled_independently_of_new_flow_selection_and_open_flows() {
let (mut n, _) = fixture();
let now = Instant::now();
let mut view = View::default();
view.observe(&n, now);
n.vips[0].listeners[0].backends[2].counters.tx_packets = 20;
n.vips[0].listeners[0].backends[2].counters.tx_bytes = 4000;
n.vips[0].listeners[0].counters.tx_packets = 20;
n.vips[0].listeners[0].counters.tx_bytes = 4000;
view.observe(&n, now + Duration::from_secs(2));
let v = &n.vips[0];
let l = &v.listeners[0];
let selected = branch(
l,
&l.backends[0],
view.backends.get(&key(v, l, &l.backends[0].backend)),
now + Duration::from_secs(2),
);
let carrying = branch(
l,
&l.backends[2],
view.backends.get(&key(v, l, &l.backends[2].backend)),
now + Duration::from_secs(2),
);
assert!(selected.next && !selected.active);
assert!(carrying.active && !carrying.next);
assert_eq!(view.totals[&v.config.id].total.rate(Unit::Bytes), 2000.0);
assert!(
!view.backends[&key(v, l, &l.backends[2].backend)].active(now + Duration::from_secs(8))
);
view.observe(&n, now + Duration::from_secs(4));
assert!(
!view.backends[&key(v, l, &l.backends[2].backend)].active(now + Duration::from_secs(4))
);
}
#[test]
fn removed_services_and_backends_release_history_selection_and_cameras() {
let (mut n, v) = fixture();
let mut view = View::default();
view.observe(&n, Instant::now());
view.selected = Some(key(
&v,
&v.listeners[0],
&v.listeners[0].backends[0].backend,
));
n.vips.clear();
view.observe(&n, Instant::now());
assert!(
view.backends.is_empty()
&& view.totals.is_empty()
&& view.selected.is_none()
&& view.cameras.is_empty()
);
}
#[test]
fn clicking_a_scene_node_selects_its_backend_without_changing_configuration() {
let (n, v) = fixture();
let ctx = egui::Context::default();
let mut view = View::default();
let frame = |view: &mut View, events| {
ctx.run(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1000.0, 600.0),
)),
events,
..Default::default()
},
|ctx| {
egui::CentralPanel::default()
.show(ctx, |ui| diagram(ui, &v, &v.listeners[0], &n, view));
},
)
};
frame(&mut view, vec![]);
let output = frame(&mut view, vec![]);
let pos = output
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == "music:7443" => {
Some(t.pos + egui::vec2(20.0, 10.0))
}
_ => None,
})
.expect("scene draws the backend node");
for pressed in [true, false] {
frame(
&mut view,
vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
},
],
);
}
assert!(
view.selected
== Some(key(
&v,
&v.listeners[0],
&v.listeners[0].backends[2].backend
))
);
assert_eq!(n.vips[0].config, v.config);
}
#[test]
fn reset_counters_and_recreated_listeners_do_not_inherit_active_traffic() {
let (mut n, _) = fixture();
let mut view = View::default();
let at = Instant::now();
n.vips[0].listeners[0].backends[0].counters.tx_packets = 100;
view.observe(&n, at);
n.vips[0].listeners[0].backends[0].counters.tx_packets = 0;
view.observe(&n, at + Duration::from_secs(2));
let v = &n.vips[0];
let l = &v.listeners[0];
assert!(
!view.backends[&key(v, l, &l.backends[0].backend)].active(at + Duration::from_secs(2))
);
n.vips[0].config.listeners[0].id = "new listener".into();
n.vips[0].listeners[0].backends[0].counters.tx_packets = 200;
view.observe(&n, at + Duration::from_secs(4));
let v = &n.vips[0];
let l = &v.listeners[0];
assert!(
!view.backends[&key(v, l, &l.backends[0].backend)].active(at + Duration::from_secs(4))
);
assert_eq!(view.backends.len(), 3);
}
#[test]
fn live_tables_and_scenes_render_at_narrow_and_wide_sizes() {
let (n, v) = fixture();
let ctx = egui::Context::default();
let mut view = View::default();
for width in [650.0, 1000.0, 1800.0, 650.0] {
for _ in 0..2 {
let _ = ctx.run(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(width, 1000.0),
)),
..Default::default()
},
|ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| show(ui, &v, &n, &mut view));
assert!(
ui.min_rect().right() <= width + 1.0,
"VIP content expands the window at {width}"
);
});
},
);
}
}
}
}
+174
View File
@@ -0,0 +1,174 @@
//! What the tray icon shows: whether the agent has anybody to talk to, and
//! whether this device sends its traffic through an exit node.
//!
//! The tray process has no window and no event loop of its own to poll from,
//! so a thread asks the agent on an interval and hands each answer to a
//! callback, which puts it where the platform wants it.
use std::time::Duration;
use tsunagi::ipc::{self, StatusReport};
use crate::agent::resolve_socket;
/// How often the agent is asked.
const INTERVAL: Duration = Duration::from_secs(3);
/// What the icon says.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Health {
/// Nobody is connected, or the agent cannot be reached, or the exit node
/// this device uses is gone, which blocks its traffic.
Disconnected,
/// Connected, nothing out of the ordinary.
Connected,
/// At least one configured network is blocked by an overlapping active range.
RangeConflict,
/// This device's traffic leaves through an exit node, and that works.
ExitNode,
}
impl Health {
/// The state a status report amounts to; `None` is an agent that did not
/// answer.
pub(crate) fn of(report: Option<&StatusReport>) -> Self {
let Some(report) = report else {
return Self::Disconnected;
};
if report
.networks
.iter()
.any(|network| network.auto_start && !network.range_conflicts.is_empty())
{
return Self::RangeConflict;
}
if !report
.networks
.iter()
.any(|network| !network.peers.is_empty())
{
return Self::Disconnected;
}
match report
.networks
.iter()
.find(|network| network.exit.via.is_some())
{
Some(network) if network.exit.via_online => Self::ExitNode,
Some(_) => Self::Disconnected,
None => Self::Connected,
}
}
/// The tooltip of the icon.
pub(crate) fn label(self) -> &'static str {
match self {
Self::Disconnected => "tsunagi: not connected",
Self::Connected => "tsunagi: connected",
Self::RangeConflict => "tsunagi: network range conflict; open to switch networks",
Self::ExitNode => "tsunagi: using an exit node",
}
}
}
/// Starts asking the agent, and calls `notify` with every answer.
pub(crate) fn spawn(notify: impl Fn(Health) + Send + 'static) {
let spawned = std::thread::Builder::new()
.name("tray-status".into())
.spawn(move || {
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
return;
};
loop {
let report = runtime.block_on(async {
tokio::time::timeout(INTERVAL, ipc::request_status(resolve_socket()))
.await
.ok()
.and_then(Result::ok)
});
notify(Health::of(report.as_ref()));
std::thread::sleep(INTERVAL);
}
});
if let Err(err) = spawned {
eprintln!("cannot watch the agent for the tray icon: {err}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use tsunagi::ipc::{ExitReport, NetworkReport, PeerReport};
fn network(peers: usize, exit: ExitReport) -> NetworkReport {
NetworkReport {
peers: vec![PeerReport::default(); peers],
exit,
..Default::default()
}
}
fn report(networks: Vec<NetworkReport>) -> StatusReport {
StatusReport {
networks,
..Default::default()
}
}
#[test]
fn overlapping_networks_are_visible_even_while_other_peers_are_connected() {
let mut blocked = NetworkReport {
auto_start: true,
..Default::default()
};
blocked
.range_conflicts
.push(tsunagi::ipc::RangeConflictReport {
name: "other".into(),
network_id: "other-id".into(),
range: "10.90.0.0/16".into(),
});
let status = report(vec![network(2, ExitReport::default()), blocked]);
assert_eq!(Health::of(Some(&status)), Health::RangeConflict);
assert!(Health::RangeConflict.label().contains("switch networks"));
}
#[test]
fn no_agent_or_nobody_connected_is_the_red_state() {
assert_eq!(Health::of(None), Health::Disconnected);
assert_eq!(Health::of(Some(&report(vec![]))), Health::Disconnected);
assert_eq!(
Health::of(Some(&report(vec![network(0, ExitReport::default())]))),
Health::Disconnected
);
}
#[test]
fn a_connection_in_any_network_is_the_blue_state() {
let status = report(vec![
network(0, ExitReport::default()),
network(2, ExitReport::default()),
]);
assert_eq!(Health::of(Some(&status)), Health::Connected);
}
#[test]
fn an_exit_node_in_use_is_green_while_it_works_and_red_once_it_is_gone() {
let using = |online| ExitReport {
via: Some("abc".into()),
via_online: online,
..Default::default()
};
assert_eq!(
Health::of(Some(&report(vec![network(1, using(true))]))),
Health::ExitNode
);
assert_eq!(
Health::of(Some(&report(vec![network(1, using(false))]))),
Health::Disconnected
);
}
}
+3 -1
View File
@@ -4,7 +4,7 @@ name = "tsunagi-wg-quic"
# negotiates: a release here does not stop it talking to a peer on an older # negotiates: a release here does not stop it talking to a peer on an older
# build, because what peers compare is `ANNOUNCEMENT_VERSION`. # build, because what peers compare is `ANNOUNCEMENT_VERSION`.
version = "0.1.0" version = "0.1.0"
description = "The wg-quic protocol for tsunagi: WireGuard's cryptography carried in iroh's QUIC datagrams." description = "The single internal WireGuard tunnel for tsunagi, independent of external carriers."
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
@@ -30,6 +30,8 @@ rand = "0.10"
boringtun = { version = "0.7.1", default-features = false } boringtun = { version = "0.7.1", default-features = false }
[dev-dependencies] [dev-dependencies]
tsunagi-wireguard = { path = "../tsunagi-wireguard" }
tsunagi-tcp-tls = { path = "../tsunagi-tcp-tls" }
tsunagi-core = { path = "../tsunagi-core", features = ["testing"] } tsunagi-core = { path = "../tsunagi-core", features = ["testing"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
tempfile.workspace = true tempfile.workspace = true
+54 -23
View File
@@ -59,7 +59,7 @@ const SCRATCH: usize = 4096;
/// All three operations share the same mutex as the cryptographic state. /// All three operations share the same mutex as the cryptographic state.
struct FlowTunnel { struct FlowTunnel {
tunn: Tunn, tunn: Tunn,
queued: VecDeque<FlowId>, queued: VecDeque<(FlowId, usize)>,
} }
fn is_data(result: &TunnResult<'_>) -> bool { fn is_data(result: &TunnResult<'_>) -> bool {
@@ -79,7 +79,7 @@ impl FlowTunnel {
} }
// MAX_QUEUE_DEPTH in boringtun 0.7: the newest packet is dropped at 256. // MAX_QUEUE_DEPTH in boringtun 0.7: the newest packet is dropped at 256.
if self.queued.len() < 256 { if self.queued.len() < 256 {
self.queued.push_back(flow); self.queued.push_back((flow, packet.len()));
} }
(result, 0) (result, 0)
} }
@@ -88,19 +88,22 @@ impl FlowTunnel {
&mut self, &mut self,
packet: &[u8], packet: &[u8],
scratch: &'a mut [u8], scratch: &'a mut [u8],
) -> (TunnResult<'a>, FlowId) { ) -> (TunnResult<'a>, FlowId, Option<usize>) {
let result = self.tunn.decapsulate(None, packet, scratch); let result = self.tunn.decapsulate(None, packet, scratch);
let mut flow = 0; let (mut flow, mut plaintext_len) = (0, None);
if packet.is_empty() { if packet.is_empty() {
if is_data(&result) { if is_data(&result) {
flow = self.queued.pop_front().unwrap_or(0); if let Some((tag, len)) = self.queued.pop_front() {
flow = tag;
plaintext_len = Some(len);
}
} else if let Some(pending) = self.queued.pop_front() { } else if let Some(pending) = self.queued.pop_front() {
// Without a session, draining pops the oldest IP packet and // Without a session, draining pops the oldest IP packet and
// encapsulate queues it again at the back. // encapsulate queues it again at the back.
self.queued.push_back(pending); self.queued.push_back(pending);
} }
} }
(result, flow) (result, flow, plaintext_len)
} }
fn update_timers<'a>(&mut self, scratch: &'a mut [u8]) -> TunnResult<'a> { fn update_timers<'a>(&mut self, scratch: &'a mut [u8]) -> TunnResult<'a> {
@@ -118,6 +121,7 @@ impl FlowTunnel {
/// Counters for one peer's tunnel. /// Counters for one peer's tunnel.
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct PeerCounters { struct PeerCounters {
epoch: u64,
tx_packets: AtomicU64, tx_packets: AtomicU64,
tx_bytes: AtomicU64, tx_bytes: AtomicU64,
rx_packets: AtomicU64, rx_packets: AtomicU64,
@@ -132,6 +136,8 @@ struct PeerCounters {
/// A snapshot of one peer's tunnel counters. /// A snapshot of one peer's tunnel counters.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PeerStats { pub struct PeerStats {
/// Identifies this counter lifetime; unchanged by rekeying or route switches.
pub counter_epoch: u64,
/// Plaintext packets encrypted and sent to this peer. /// Plaintext packets encrypted and sent to this peer.
pub tx_packets: u64, pub tx_packets: u64,
/// Plaintext bytes encrypted and sent to this peer. /// Plaintext bytes encrypted and sent to this peer.
@@ -209,6 +215,7 @@ impl Drop for Peer {
impl Peer { impl Peer {
fn stats(&self) -> PeerStats { fn stats(&self) -> PeerStats {
PeerStats { PeerStats {
counter_epoch: self.counters.epoch,
tx_packets: self.counters.tx_packets.load(Ordering::Relaxed), tx_packets: self.counters.tx_packets.load(Ordering::Relaxed),
tx_bytes: self.counters.tx_bytes.load(Ordering::Relaxed), tx_bytes: self.counters.tx_bytes.load(Ordering::Relaxed),
rx_packets: self.counters.rx_packets.load(Ordering::Relaxed), rx_packets: self.counters.rx_packets.load(Ordering::Relaxed),
@@ -328,26 +335,27 @@ impl WireguardDevice {
}; };
let (result, selected_flow) = tunn.encapsulate(packet, &mut scratch, flow); let (result, selected_flow) = tunn.encapsulate(packet, &mut scratch, flow);
match result { match result {
TunnResult::WriteToNetwork(out) => Some((out.len(), selected_flow)), TunnResult::WriteToNetwork(out) => {
Some((out.len(), selected_flow, out.starts_with(&[4, 0, 0, 0])))
}
// No session yet, so nothing to send. Counted as dropped // No session yet, so nothing to send. Counted as dropped
// rather than reported: the handshake is in flight and the // rather than reported: the handshake is in flight and the
// next packet will go. // next packet will go.
_ => None, _ => None,
} }
}; };
let Some((len, selected_flow)) = result else { let Some((len, selected_flow, data)) = result else {
peer.counters peer.counters
.dropped_no_session .dropped_no_session
.fetch_add(1, Ordering::Relaxed); .fetch_add(1, Ordering::Relaxed);
return false; return false;
}; };
send_to_peer_flow(&peer, &scratch[..len], selected_flow); let sent = send_to_peer_flow(&peer, &scratch[..len], selected_flow);
peer.counters.tx_packets.fetch_add(1, Ordering::Relaxed); if sent && data {
peer.counters count_sent(&peer, packet.len());
.tx_bytes }
.fetch_add(packet.len() as u64, Ordering::Relaxed); sent
true
} }
/// Adds or replaces a peer and starts its tunnel. /// Adds or replaces a peer and starts its tunnel.
@@ -388,7 +396,10 @@ impl WireguardDevice {
queued: VecDeque::new(), queued: VecDeque::new(),
}), }),
link, link,
counters: Arc::new(PeerCounters::default()), counters: Arc::new(PeerCounters {
epoch: rand::random(),
..Default::default()
}),
task: Mutex::new(None), task: Mutex::new(None),
}); });
@@ -501,9 +512,9 @@ fn send_to_peer(peer: &Peer, payload: &[u8]) {
send_to_peer_flow(peer, payload, 0); send_to_peer_flow(peer, payload, 0);
} }
fn send_to_peer_flow(peer: &Peer, payload: &[u8], flow: FlowId) { fn send_to_peer_flow(peer: &Peer, payload: &[u8], flow: FlowId) -> bool {
match peer.link.send_flow(Bytes::copy_from_slice(payload), flow) { match peer.link.send_flow(Bytes::copy_from_slice(payload), flow) {
Ok(()) => {} Ok(()) => return true,
Err(TransportError::TooLarge { .. }) => { Err(TransportError::TooLarge { .. }) => {
peer.counters peer.counters
.dropped_oversize .dropped_oversize
@@ -514,6 +525,14 @@ fn send_to_peer_flow(peer: &Peer, payload: &[u8], flow: FlowId) {
tracing::trace!(%err, "dropping a WireGuard packet the link refused"); tracing::trace!(%err, "dropping a WireGuard packet the link refused");
} }
} }
false
}
fn count_sent(peer: &Peer, plaintext_len: usize) {
peer.counters.tx_packets.fetch_add(1, Ordering::Relaxed);
peer.counters
.tx_bytes
.fetch_add(plaintext_len as u64, Ordering::Relaxed);
} }
async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) { async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
@@ -532,9 +551,12 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
Ok(guard) => guard, Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(), Err(poisoned) => poisoned.into_inner(),
}; };
let (result, flow) = tunn.decapsulate(input.unwrap_or(&[]), &mut scratch); let (result, flow, plaintext_len) =
tunn.decapsulate(input.unwrap_or(&[]), &mut scratch);
match result { match result {
TunnResult::WriteToNetwork(out) => Outcome::ToNetwork(out.len(), flow), TunnResult::WriteToNetwork(out) => {
Outcome::ToNetwork(out.len(), flow, plaintext_len)
}
// The source boringtun reports is not consulted here: // The source boringtun reports is not consulted here:
// whether the peer may use it is checked where the // whether the peer may use it is checked where the
// claims live. // claims live.
@@ -549,8 +571,12 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
}; };
match outcome { match outcome {
Outcome::ToNetwork(len, flow) => { Outcome::ToNetwork(len, flow, plaintext_len) => {
send_to_peer_flow(&peer, &scratch[..len], flow); if send_to_peer_flow(&peer, &scratch[..len], flow)
&& let Some(len) = plaintext_len
{
count_sent(&peer, len);
}
// Keep draining with an empty datagram, as boringtun asks. // Keep draining with an empty datagram, as boringtun asks.
input = None; input = None;
continue; continue;
@@ -584,7 +610,7 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
} }
enum Outcome { enum Outcome {
ToNetwork(usize, FlowId), ToNetwork(usize, FlowId, Option<usize>),
ToTunnel(usize), ToTunnel(usize),
Done, Done,
Failed, Failed,
@@ -671,7 +697,12 @@ mod flow_tests {
let keepalive = wire(a.decapsulate(&response, &mut scratch).0); let keepalive = wire(a.decapsulate(&response, &mut scratch).0);
let _ = b.decapsulate(&keepalive, &mut scratch); let _ = b.decapsulate(&keepalive, &mut scratch);
for expected in 100..356 { for expected in 100..356 {
let (result, flow) = a.decapsulate(&[], &mut scratch); let (result, flow, len) = a.decapsulate(&[], &mut scratch);
assert_eq!(
len,
Some(packet.len()),
"queued counters retain the exact unpadded IP length"
);
assert_eq!(flow, expected); assert_eq!(flow, expected);
let ciphertext = wire(result); let ciphertext = wire(result);
match b.decapsulate(&ciphertext, &mut scratch).0 { match b.decapsulate(&ciphertext, &mut scratch).0 {
+36 -18
View File
@@ -47,13 +47,9 @@ use crate::store::WgKeyStore;
use tsunagi::state::Ipv4Range; use tsunagi::state::Ipv4Range;
use tsunagi::task::{TASK_GRACE, wind_down}; use tsunagi::task::{TASK_GRACE, wind_down};
/// The protocol identifier this plugin announces. /// Fixed internal encryption capability, independent of external carrier names.
/// The protocol id of this plugin. /// The historical crate/storage name does not select a physical transport.
/// pub const WIREGUARD_PROTOCOL: &str = "wireguard-e2e";
/// `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. /// Smallest interface MTU the overlay accepts.
/// ///
@@ -79,6 +75,8 @@ pub const WIREGUARD_OVERHEAD: u32 = 32;
/// Configuration of the WireGuard plugin. /// Configuration of the WireGuard plugin.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct WireguardConfig { pub struct WireguardConfig {
/// Previous key stores to import before creating any network key.
pub legacy_key_stores: Vec<PathBuf>,
/// Directory for the plugin's own key store. Separate from agent state. /// Directory for the plugin's own key store. Separate from agent state.
pub state_dir: PathBuf, pub state_dir: PathBuf,
/// WireGuard keepalive, which keeps tunnels and their links warm. /// WireGuard keepalive, which keeps tunnels and their links warm.
@@ -100,6 +98,7 @@ impl WireguardConfig {
/// Creates a configuration rooted at `state_dir` with sensible defaults. /// Creates a configuration rooted at `state_dir` with sensible defaults.
pub fn new(state_dir: impl Into<PathBuf>) -> Self { pub fn new(state_dir: impl Into<PathBuf>) -> Self {
Self { Self {
legacy_key_stores: Vec::new(),
state_dir: state_dir.into(), state_dir: state_dir.into(),
keepalive: Some(25), keepalive: Some(25),
mtu: DEFAULT_MTU, mtu: DEFAULT_MTU,
@@ -299,9 +298,16 @@ impl WireguardPlugin {
} }
let path = config.key_store_path(); let path = config.key_store_path();
let store = tokio::task::spawn_blocking(move || WgKeyStore::open(path)) let legacy = config.legacy_key_stores.clone();
.await let store = tokio::task::spawn_blocking(move || {
.map_err(|err| PluginError::Other(format!("key store task failed: {err}")))??; let store = WgKeyStore::open(path)?;
for path in legacy {
store.import_legacy(&path)?;
}
Ok::<_, PluginError>(store)
})
.await
.map_err(|err| PluginError::Other(format!("key store task failed: {err}")))??;
let worker = Arc::new(Worker { let worker = Arc::new(Worker {
config, config,
@@ -792,14 +798,26 @@ impl IpPlugin for WireguardPlugin {
self.nudge(Command::Teardown(network)); self.nudge(Command::Teardown(network));
} }
fn on_network_forgotten(&self, network: NetworkId) { fn on_network_forgotten(&self, network: NetworkId) -> BoxFuture<'_, ()> {
// The key is this protocol's identity in that network and nothing let worker = Arc::clone(&self.worker);
// else's. Keeping it after leaving would keep a secret for a Box::pin(async move {
// network this agent is no longer in — and hand back the same // Remove old copies as well, otherwise the next startup's import
// overlay address on a rejoin that everyone else has moved past. // would resurrect a key that the user explicitly forgot.
if let Err(err) = self.worker.store.forget(network) { let result = tokio::task::spawn_blocking(move || {
tracing::warn!(%err, "cannot remove the WireGuard key of a network we left"); for path in &worker.config.legacy_key_stores {
} if path.exists() {
WgKeyStore::open(path)?.forget(network)?;
}
}
worker.store.forget(network)
})
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(err)) => tracing::warn!(%err, "cannot remove forgotten network data keys"),
Err(err) => tracing::warn!(%err, "data key cleanup task failed"),
}
})
} }
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> { fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
+109
View File
@@ -106,6 +106,49 @@ impl WgKeyStore {
} }
} }
/// Imports missing network keys from an older carrier-specific store, atomically.
/// The source is read-only and existing canonical keys always win.
pub fn import_legacy(&self, path: &Path) -> Result<(), PluginError> {
if !path.exists() || path == self.path {
return Ok(());
}
let fail = |err: rusqlite::Error| {
PluginError::Other(format!("cannot import legacy data keys: {err}"))
};
let source = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(fail)?;
let integrity: String = source
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
.map_err(fail)?;
let schema: i64 = source
.query_row("PRAGMA user_version", [], |row| row.get(0))
.map_err(fail)?;
if integrity != "ok" || schema != SCHEMA_VERSION {
return Err(PluginError::Other(
"legacy data key store is damaged or has an unsupported schema".into(),
));
}
let mut statement = source.prepare("SELECT network_id, secret, created_at, length(network_id), length(secret) FROM network_keys").map_err(fail)?;
let mut rows = statement.query([]).map_err(fail)?;
let mut target = self.lock();
let transaction = target.transaction().map_err(fail)?;
while let Some(row) = rows.next().map_err(fail)? {
let id_len: i64 = row.get(3).map_err(fail)?;
let key_len: i64 = row.get(4).map_err(fail)?;
if id_len != 32 || key_len != KEY_LEN as i64 {
return Err(PluginError::Other(
"legacy data key has an invalid length".into(),
));
}
let id: Vec<u8> = row.get(0).map_err(fail)?;
let key = zeroize::Zeroizing::new(row.get::<_, Vec<u8>>(1).map_err(fail)?);
let created: i64 = row.get(2).map_err(fail)?;
transaction.execute("INSERT OR IGNORE INTO network_keys(network_id, secret, created_at) VALUES (?1, ?2, ?3)", params![id, key.as_slice(), created]).map_err(fail)?;
}
transaction.commit().map_err(fail)?;
Ok(())
}
/// Returns this agent's key for a network, creating it on first use. /// Returns this agent's key for a network, creating it on first use.
pub fn load_or_create(&self, network: NetworkId) -> Result<WgSecretKey, PluginError> { pub fn load_or_create(&self, network: NetworkId) -> Result<WgSecretKey, PluginError> {
let conn = self.lock(); let conn = self.lock();
@@ -219,4 +262,70 @@ mod tests {
"a damaged key store must not silently mint a new identity (was {original})" "a damaged key store must not silently mint a new identity (was {original})"
); );
} }
#[tokio::test]
async fn forgetting_an_imported_key_cannot_resurrect_it_on_restart() {
use crate::{WireguardConfig, WireguardPlugin};
use tsunagi::dataplane::IpPlugin;
let dir = tempfile::TempDir::new().unwrap();
let id = network("forgotten-import");
let legacy_path = dir.path().join("old.sqlite");
let original = WgKeyStore::open(&legacy_path)
.unwrap()
.load_or_create(id)
.unwrap()
.public();
let mut config = WireguardConfig::new(dir.path().join("canonical"));
config.legacy_key_stores.push(legacy_path);
let plugin = WireguardPlugin::open(config.clone()).await.unwrap();
plugin.on_network_forgotten(id).await;
plugin.shutdown().await;
let restarted = WireguardPlugin::open(config.clone()).await.unwrap();
let key = WgKeyStore::open(config.key_store_path())
.unwrap()
.load_or_create(id)
.unwrap()
.public();
assert_ne!(key, original);
restarted.shutdown().await;
}
#[test]
fn legacy_import_preserves_keys_and_never_overwrites_the_canonical_identity() {
let dir = tempfile::TempDir::new().unwrap();
let legacy = WgKeyStore::open(dir.path().join("legacy.sqlite")).unwrap();
let canonical = WgKeyStore::open(dir.path().join("canonical.sqlite")).unwrap();
let imported = network("imported");
let existing = network("existing");
let imported_key = legacy.load_or_create(imported).unwrap().public();
legacy.load_or_create(existing).unwrap();
let existing_key = canonical.load_or_create(existing).unwrap().public();
canonical.import_legacy(legacy.path()).unwrap();
canonical.import_legacy(legacy.path()).unwrap();
assert_eq!(
canonical.load_or_create(imported).unwrap().public(),
imported_key
);
assert_eq!(
canonical.load_or_create(existing).unwrap().public(),
existing_key
);
assert_eq!(
legacy.load_or_create(imported).unwrap().public(),
imported_key
);
}
#[test]
fn a_damaged_legacy_store_aborts_import_without_regenerating_keys() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("legacy.sqlite");
std::fs::write(&path, b"damaged SQLite").unwrap();
let canonical = WgKeyStore::open(dir.path().join("canonical.sqlite")).unwrap();
assert!(canonical.import_legacy(&path).is_err());
canonical
.import_legacy(&dir.path().join("absent.sqlite"))
.unwrap();
assert!(!dir.path().join("absent.sqlite").exists());
}
} }
+285
View File
@@ -0,0 +1,285 @@
//! Real membership and one internal cipher over each independent external carrier.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use bytes::Bytes;
use iroh::EndpointId;
use std::{
net::{Ipv4Addr, SocketAddr},
sync::Arc,
time::Duration,
};
use tempfile::TempDir;
use tsunagi::dataplane::transport::{DataCarrier, PacketTransport, SharedLink, TransportError};
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::overlay::{MemoryTun, MemoryTunFactory};
use tsunagi::testing::{config_with, network, wait_for_peers, wait_until};
use tsunagi::{Agent, BoxFuture, NetworkId};
use tsunagi_tcp_tls::{TcpTlsCarrier, TcpTlsTransport};
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
use tsunagi_wireguard::{UdpCarrier, UdpTransport};
// Only address selection is constrained to loopback. TLS sockets, identity
// verification, framing, membership and both encryption layers remain real.
#[derive(Debug)]
struct LoopbackTls {
inner: Arc<TcpTlsTransport>,
ports: Arc<std::sync::Mutex<Vec<(EndpointId, u16)>>>,
}
impl PacketTransport for LoopbackTls {
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>> {
let port = self
.ports
.lock()
.unwrap()
.iter()
.find(|(id, _)| *id == peer)
.unwrap()
.1;
self.inner
.replace_peer_addrs(peer, vec![SocketAddr::from((Ipv4Addr::LOCALHOST, port))]);
self.inner.open(network, peer, protocol)
}
}
struct Node {
_root: TempDir,
agent: Agent,
tunnel: Arc<WireguardPlugin>,
tuns: MemoryTunFactory,
accept: tokio::task::JoinHandle<()>,
}
impl Node {
async fn new(
discovery: &SharedMemoryDiscovery,
protocol: &str,
ports: Arc<std::sync::Mutex<Vec<(EndpointId, u16)>>>,
) -> Self {
Self::configured(discovery, protocol, ports, true).await
}
async fn configured(
discovery: &SharedMemoryDiscovery,
protocol: &str,
ports: Arc<std::sync::Mutex<Vec<(EndpointId, u16)>>>,
register_transport: bool,
) -> Self {
let root = TempDir::new().unwrap();
let identity = tsunagi::storage::StateStore::open(
tsunagi::StoragePaths::under(root.path()).state_db(),
)
.unwrap()
.load_or_create_device_identity()
.unwrap();
let tunnel = WireguardPlugin::open(
WireguardConfig::new(root.path().join("cipher"))
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
)
.await
.unwrap();
let tuns = MemoryTunFactory::new();
let config = config_with(root.path(), discovery)
.with_interface(Arc::new(tuns.clone()), "carrier-test", 1280)
.with_tunnel(tunnel.clone());
let (agent, accept) = if protocol == "wg" {
let transport =
UdpTransport::wireguard(identity.endpoint_id(), Ipv4Addr::LOCALHOST, None);
let carrier: Arc<dyn DataCarrier> = UdpCarrier::new(transport.clone());
let mut config = config.with_carrier(carrier);
if register_transport {
config = config.with_custom_transport(protocol, transport.clone());
}
let agent = Agent::spawn(config).await.unwrap();
let incoming = agent.clone();
let accept = tokio::spawn(async move {
while let Some(link) = transport.next_inbound().await {
let _ = incoming.install_inbound_link(link).await;
}
});
(agent, accept)
} else {
let transport = Arc::new(
TcpTlsTransport::bind(&identity, Some(0), None)
.await
.unwrap(),
);
ports
.lock()
.unwrap()
.push((identity.endpoint_id(), transport.bound_port()));
let carrier: Arc<dyn DataCarrier> = TcpTlsCarrier::new(transport.clone());
let custom = Arc::new(LoopbackTls {
inner: transport.clone(),
ports,
});
let agent = Agent::spawn(
config
.with_carrier(carrier)
.with_custom_transport(protocol, custom),
)
.await
.unwrap();
let incoming = agent.clone();
let accept = tokio::spawn(async move {
transport
.accept_loop(move |link| {
let incoming = incoming.clone();
tokio::spawn(async move {
let _ = incoming.install_inbound_link(link).await;
});
})
.await;
});
(agent, accept)
};
Self {
_root: root,
agent,
tunnel,
tuns,
accept,
}
}
fn tun(&self) -> Arc<MemoryTun> {
self.tuns
.device(&self.agent.overlay().unwrap().interface)
.unwrap()
}
fn addr(&self, network: NetworkId) -> Ipv4Addr {
self.tunnel
.overview(network)
.unwrap()
.overlay_address_v4
.unwrap()
}
}
#[tokio::test]
async fn missing_native_transport_cannot_silently_turn_wireguard_into_quic() {
let discovery = SharedMemoryDiscovery::new();
let ports = Arc::default();
let a = Node::configured(&discovery, "wg", Arc::clone(&ports), false).await;
let b = Node::configured(&discovery, "wg", ports, false).await;
let (name, secret) = network("no-disguised-quic-fallback");
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
wait_for_peers(&a.agent, id, 1).await;
let initiator = if a.agent.endpoint_id().as_bytes() < b.agent.endpoint_id().as_bytes() {
&a
} else {
&b
};
wait_until(
"missing carrier implementation is explicitly refused",
|| async {
let status = initiator.agent.network_status(id).await.ok()?;
let peer = status.peers.first()?;
(peer
.data_error
.as_deref()
.is_some_and(|reason| reason.contains("registered"))
&& matches!(peer.data_path, tsunagi::agent::DataPath::None))
.then_some(())
},
)
.await;
a.agent.shutdown().await;
b.agent.shutdown().await;
a.accept.abort();
b.accept.abort();
}
#[tokio::test]
async fn wireguard_and_tls_carriers_both_carry_the_same_internal_cipher_and_exact_plaintext_counters()
{
for protocol in ["wg", "tcp-tls"] {
let discovery = SharedMemoryDiscovery::new();
let ports = Arc::default();
let a = Node::new(&discovery, protocol, Arc::clone(&ports)).await;
let b = Node::new(&discovery, protocol, ports).await;
let (name, secret) = network(protocol);
let id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
wait_for_peers(&a.agent, id, 1).await;
for node in [&a, &b] {
wait_until("real internal handshake over selected carrier", || async {
node.tunnel
.overview(id)?
.peers
.first()?
.tunnel
.as_ref()?
.health
.is_up()
.then_some(())
})
.await;
let status = node.agent.network_status(id).await.unwrap();
assert_eq!(status.protocols, [protocol]);
assert!(
matches!(&status.peers[0].data_path, tsunagi::agent::DataPath::Direct { transport } if transport == protocol)
);
}
for (source, destination) in [(&a, &b), (&b, &a)] {
let mut total = 0;
for len in [60, 1097, 1280] {
let mut packet = vec![0; len];
packet[0] = 0x45;
packet[2..4].copy_from_slice(&(len as u16).to_be_bytes());
packet[8] = 64;
packet[9] = 253;
packet[12..16].copy_from_slice(&source.addr(id).octets());
packet[16..20].copy_from_slice(&destination.addr(id).octets());
let packet = Bytes::from(packet);
source.tun().push_from_os(packet.clone());
assert_eq!(
tokio::time::timeout(tsunagi::testing::DEADLINE, destination.tun().pop_to_os())
.await
.unwrap()
.unwrap(),
packet
);
total += len as u64;
}
wait_until(
"plaintext bytes count once without health probes or nested encryption overhead",
|| async {
let tx = source
.tunnel
.overview(id)?
.peers
.first()?
.tunnel
.as_ref()?
.stats;
let rx = destination
.tunnel
.overview(id)?
.peers
.first()?
.tunnel
.as_ref()?
.stats;
(tx.tx_bytes == total
&& rx.rx_bytes == total
&& tx.tx_packets == 3
&& rx.rx_packets == 3)
.then_some(())
},
)
.await;
}
a.agent.shutdown().await;
b.agent.shutdown().await;
a.accept.abort();
b.accept.abort();
}
}
+4 -1
View File
@@ -101,7 +101,10 @@ impl Node {
tag, tag,
1280, 1280,
) )
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>), .with_tunnel(plugin.clone() as Arc<dyn IpPlugin>)
.with_carrier(Arc::new(
tsunagi::dataplane::transport::iroh_link::IrohCarrier,
)),
) )
.await .await
.unwrap(); .unwrap();
@@ -47,7 +47,10 @@ impl HostedAgent {
config_with(dir.path(), discovery) config_with(dir.path(), discovery)
.with_overlay_ipv4_range(Some(DEFAULT_IPV4_RANGE)) .with_overlay_ipv4_range(Some(DEFAULT_IPV4_RANGE))
.with_interface(factory, tag, 1280) .with_interface(factory, tag, 1280)
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>), .with_tunnel(plugin.clone() as Arc<dyn IpPlugin>)
.with_carrier(Arc::new(
tsunagi::dataplane::transport::iroh_link::IrohCarrier,
)),
) )
.await .await
.unwrap(); .unwrap();
+11 -2
View File
@@ -37,11 +37,13 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::R
.peers .peers
.iter() .iter()
.map(|peer| tsunagi::ipc::PeerReport { .map(|peer| tsunagi::ipc::PeerReport {
data_error: None,
endpoint_id: peer.endpoint_id.to_string(), endpoint_id: peer.endpoint_id.to_string(),
hostname: peer.hostname.clone(), hostname: peer.hostname.clone(),
transport: format!("{:?}", peer.transport), transport: format!("{:?}", peer.transport),
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64), rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
exit_node: peer.exit_node, exit_node: peer.exit_node,
path: Default::default(),
}) })
.collect(), .collect(),
overlay: plugin.overview(net.network_id).map(|view| { overlay: plugin.overview(net.network_id).map(|view| {
@@ -106,7 +108,10 @@ async fn a_client_sees_the_agent_and_its_overlay() {
let agent = Agent::spawn( let agent = Agent::spawn(
config_with(dir_a.path(), &discovery) config_with(dir_a.path(), &discovery)
.with_interface(Arc::new(tuns), "tca0", 1280) .with_interface(Arc::new(tuns), "tca0", 1280)
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>), .with_tunnel(plugin.clone() as Arc<dyn IpPlugin>)
.with_carrier(Arc::new(
tsunagi::dataplane::transport::iroh_link::IrohCarrier,
)),
) )
.await .await
.unwrap(); .unwrap();
@@ -121,7 +126,10 @@ async fn a_client_sees_the_agent_and_its_overlay() {
let agent_b = Agent::spawn( let agent_b = Agent::spawn(
config_with(dir_b.path(), &discovery) config_with(dir_b.path(), &discovery)
.with_interface(Arc::new(MemoryTunFactory::new()), "tcb0", 1280) .with_interface(Arc::new(MemoryTunFactory::new()), "tcb0", 1280)
.with_plugin(plugin_b.clone() as Arc<dyn IpPlugin>), .with_tunnel(plugin_b.clone() as Arc<dyn IpPlugin>)
.with_carrier(Arc::new(
tsunagi::dataplane::transport::iroh_link::IrohCarrier,
)),
) )
.await .await
.unwrap(); .unwrap();
@@ -276,6 +284,7 @@ impl tsunagi::ipc::ReportSource for Control {
network_id: network_id.to_string(), network_id: network_id.to_string(),
already_configured: already, already_configured: already,
name_shared_with: None, name_shared_with: None,
active: self.0.is_active(network_id).await,
}) })
}) })
} }
+308 -20
View File
@@ -109,6 +109,33 @@ impl WgAgent {
.await .await
} }
async fn spawn_auto(discovery: &SharedMemoryDiscovery, tag: &str) -> Self {
let dir = TempDir::new().unwrap();
let tuns = MemoryTunFactory::new();
let plugin = WireguardPlugin::open(
WireguardConfig::new(dir.path().join("wireguard"))
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
)
.await
.unwrap();
let agent = Agent::spawn(
config_with(dir.path(), discovery)
.with_interface(Arc::new(tuns.clone()), tag, 1280)
.with_tunnel(plugin.clone() as Arc<dyn IpPlugin>)
.with_carrier(Arc::new(
tsunagi::dataplane::transport::iroh_link::IrohCarrier,
)),
)
.await
.unwrap();
Self {
dir,
agent,
plugin,
tuns,
}
}
/// Starts an agent with an explicit overlay range, or none at all. /// Starts an agent with an explicit overlay range, or none at all.
async fn spawn_range( async fn spawn_range(
discovery: &SharedMemoryDiscovery, discovery: &SharedMemoryDiscovery,
@@ -169,7 +196,10 @@ impl WgAgent {
config_with(root, discovery) config_with(root, discovery)
.with_overlay_ipv4_range(range) .with_overlay_ipv4_range(range)
.with_interface(Arc::new(tuns.clone()), tag, 1280) .with_interface(Arc::new(tuns.clone()), tag, 1280)
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>), .with_tunnel(plugin.clone() as Arc<dyn IpPlugin>)
.with_carrier(Arc::new(
tsunagi::dataplane::transport::iroh_link::IrohCarrier,
)),
) )
.await .await
.unwrap(); .unwrap();
@@ -201,7 +231,10 @@ impl WgAgent {
.with_overlay_ipv4_range(Some(tsunagi::state::DEFAULT_IPV4_RANGE)) .with_overlay_ipv4_range(Some(tsunagi::state::DEFAULT_IPV4_RANGE))
.with_interface(Arc::new(tuns.clone()), tag, 1280) .with_interface(Arc::new(tuns.clone()), tag, 1280)
.with_unreachable_data_peers(blocked) .with_unreachable_data_peers(blocked)
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>), .with_tunnel(plugin.clone() as Arc<dyn IpPlugin>)
.with_carrier(Arc::new(
tsunagi::dataplane::transport::iroh_link::IrohCarrier,
)),
) )
.await .await
.unwrap(); .unwrap();
@@ -230,7 +263,10 @@ impl WgAgent {
config_with(dir.path(), discovery) config_with(dir.path(), discovery)
.with_overlay_ipv4_range(Some(tsunagi::state::DEFAULT_IPV4_RANGE)) .with_overlay_ipv4_range(Some(tsunagi::state::DEFAULT_IPV4_RANGE))
.with_interface(Arc::new(PretendHostTuns(tuns.clone())), tag, 1280) .with_interface(Arc::new(PretendHostTuns(tuns.clone())), tag, 1280)
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>), .with_tunnel(plugin.clone() as Arc<dyn IpPlugin>)
.with_carrier(Arc::new(
tsunagi::dataplane::transport::iroh_link::IrohCarrier,
)),
) )
.await .await
.unwrap(); .unwrap();
@@ -434,10 +470,52 @@ async fn two_agents_carry_real_ip_packets_through_a_wireguard_tunnel() {
let view = a.plugin.overview(network_id).unwrap(); let view = a.plugin.overview(network_id).unwrap();
let tunnel = view.peers[0].tunnel.as_ref().unwrap(); let tunnel = view.peers[0].tunnel.as_ref().unwrap();
assert!(tunnel.health.is_up()); assert!(tunnel.health.is_up());
assert!(tunnel.stats.tx_packets >= 1); wait_until(
assert!(tunnel.stats.rx_packets >= 1); "plaintext counters exclude carrier probes and encryption overhead",
|| async {
let view = a.plugin.overview(network_id)?;
let stats = view.peers[0].tunnel.as_ref()?.stats;
(stats.tx_packets == 1
&& stats.rx_packets == 1
&& stats.tx_bytes == (20 + payload.len()) as u64
&& stats.rx_bytes == (20 + b"and back".len()) as u64)
.then_some(())
},
)
.await;
assert_eq!(tunnel.stats.dropped_wrong_source, 0); assert_eq!(tunnel.stats.dropped_wrong_source, 0);
let before = a.plugin.overview(network_id).unwrap().peers[0]
.tunnel
.as_ref()
.unwrap()
.stats;
a.agent.set_protocols(network_id, Vec::new()).await.unwrap();
wait_until("no remaining physical path", || async {
matches!(
a.agent.network_status(network_id).await.ok()?.peers[0].data_path,
tsunagi::agent::DataPath::None
)
.then_some(())
})
.await;
assert!(!a.plugin.carry(
network_id,
b.agent.endpoint_id(),
ipv4_packet(addr_a, addr_b, b"refused by transport")
));
let after = a.plugin.overview(network_id).unwrap().peers[0]
.tunnel
.as_ref()
.unwrap()
.stats;
assert_eq!(after.counter_epoch, before.counter_epoch);
assert_eq!(
after.tx_bytes, before.tx_bytes,
"a refused send is not transmitted traffic"
);
assert_eq!(after.tx_packets, before.tx_packets);
a.shutdown().await; a.shutdown().await;
b.shutdown().await; b.shutdown().await;
} }
@@ -989,7 +1067,7 @@ async fn two_networks_share_one_interface_with_keys_and_ranges_of_their_own() {
let (name_b, secret_b) = network("wg-right"); let (name_b, secret_b) = network("wg-right");
let beta_range = Some("10.99.0.0/16".parse::<Ipv4Range>().unwrap()); let beta_range = Some("10.99.0.0/16".parse::<Ipv4Range>().unwrap());
let hub = WgAgent::spawn(&discovery, "th").await; let hub = WgAgent::spawn_auto(&discovery, "th").await;
let left = WgAgent::spawn(&discovery, "tl").await; let left = WgAgent::spawn(&discovery, "tl").await;
let right = WgAgent::spawn_range(&discovery, "tr", beta_range).await; let right = WgAgent::spawn_range(&discovery, "tr", beta_range).await;
@@ -1127,10 +1205,12 @@ async fn the_core_carries_the_payload_without_interpreting_it() {
let capability = wait_until("the peer's capability arrived", || async { let capability = wait_until("the peer's capability arrived", || async {
let status: NetworkStatus = a.agent.network_status(network_id).await.ok()?; let status: NetworkStatus = a.agent.network_status(network_id).await.ok()?;
status status.peers.first().and_then(|peer| {
.peers peer.capabilities
.first() .iter()
.and_then(|peer| peer.capabilities.first().cloned()) .find(|cap| cap.protocol == WIREGUARD_PROTOCOL)
.cloned()
})
}) })
.await; .await;
assert_eq!(capability.protocol, WIREGUARD_PROTOCOL); assert_eq!(capability.protocol, WIREGUARD_PROTOCOL);
@@ -1173,7 +1253,10 @@ async fn an_announcement_for_another_network_never_reaches_a_tunnel() {
let attacker_dir = TempDir::new().unwrap(); let attacker_dir = TempDir::new().unwrap();
let attacker = Agent::spawn( let attacker = Agent::spawn(
config_with(attacker_dir.path(), &discovery) config_with(attacker_dir.path(), &discovery)
.with_plugin(forger.clone() as Arc<dyn IpPlugin>), .with_tunnel(forger.clone() as Arc<dyn IpPlugin>)
.with_carrier(Arc::new(
tsunagi::dataplane::transport::iroh_link::IrohCarrier,
)),
) )
.await .await
.unwrap(); .unwrap();
@@ -1222,7 +1305,11 @@ async fn a_peer_at_another_protocol_version_gets_no_data_plane_and_keeps_the_con
let ahead = Arc::new(FromTheFuture); let ahead = Arc::new(FromTheFuture);
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let other = Agent::spawn( let other = Agent::spawn(
config_with(dir.path(), &discovery).with_plugin(ahead.clone() as Arc<dyn IpPlugin>), config_with(dir.path(), &discovery)
.with_tunnel(ahead.clone() as Arc<dyn IpPlugin>)
.with_carrier(Arc::new(
tsunagi::dataplane::transport::iroh_link::IrohCarrier,
)),
) )
.await .await
.unwrap(); .unwrap();
@@ -1249,10 +1336,16 @@ async fn a_peer_at_another_protocol_version_gets_no_data_plane_and_keeps_the_con
); );
let status = here.agent.network_status(network_id).await.unwrap(); let status = here.agent.network_status(network_id).await.unwrap();
assert_eq!(status.peers.len(), 1, "the session is up all the same"); assert_eq!(status.peers.len(), 1, "the session is up all the same");
assert_eq!(
status.peers[0].protocols,
["wg-quic"],
"external capability remains independent"
);
assert!( assert!(
status.peers[0].protocols.is_empty(), status.peers[0]
"nothing was agreed with it: {:?}", .data_error
status.peers[0].protocols .as_deref()
.is_some_and(|reason| reason.contains("update both agents"))
); );
// And the control plane carries a message to it regardless. // And the control plane carries a message to it regardless.
@@ -1293,7 +1386,11 @@ async fn agreement_turns_on_the_protocol_version_and_nothing_else() {
}); });
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let other = Agent::spawn( let other = Agent::spawn(
config_with(dir.path(), &discovery).with_plugin(stranger.clone() as Arc<dyn IpPlugin>), config_with(dir.path(), &discovery)
.with_tunnel(stranger.clone() as Arc<dyn IpPlugin>)
.with_carrier(Arc::new(
tsunagi::dataplane::transport::iroh_link::IrohCarrier,
)),
) )
.await .await
.unwrap(); .unwrap();
@@ -1308,8 +1405,12 @@ async fn agreement_turns_on_the_protocol_version_and_nothing_else() {
status status
.peers .peers
.first()? .first()?
.protocols .capabilities
.contains(&WIREGUARD_PROTOCOL.to_string()) .iter()
.any(|cap| {
cap.protocol == WIREGUARD_PROTOCOL
&& cap.version == tsunagi_wg_quic::ANNOUNCEMENT_VERSION
})
.then_some(()) .then_some(())
}) })
.await; .await;
@@ -1661,8 +1762,10 @@ async fn lan_broadcast_fanout_is_scoped_opt_in_and_does_not_reflood() {
bytes[28..].copy_from_slice(b"LAN GAME"); bytes[28..].copy_from_slice(b"LAN GAME");
// UDP checksum zero is valid for IPv4. // UDP checksum zero is valid for IPv4.
let sum: u32 = bytes[..20] let sum: u32 = bytes[..20]
.chunks_exact(2) .as_chunks::<2>()
.map(|w| u16::from_be_bytes([w[0], w[1]]) as u32) .0
.iter()
.map(|w| u16::from_be_bytes(*w) as u32)
.sum(); .sum();
let checksum = !((sum & 0xffff) + (sum >> 16)) as u16; let checksum = !((sum & 0xffff) + (sum >> 16)) as u16;
bytes[10..12].copy_from_slice(&checksum.to_be_bytes()); bytes[10..12].copy_from_slice(&checksum.to_be_bytes());
@@ -1744,3 +1847,188 @@ async fn lan_broadcast_fanout_is_scoped_opt_in_and_does_not_reflood() {
agent.shutdown().await; agent.shutdown().await;
} }
} }
fn vip_segment(
source: Ipv4Addr,
destination: Ipv4Addr,
sport: u16,
dport: u16,
udp: bool,
flags: u8,
) -> Bytes {
fn sum(bytes: &[u8]) -> u16 {
let mut s: u32 = bytes
.chunks(2)
.map(|b| u32::from(u16::from_be_bytes([b[0], *b.get(1).unwrap_or(&0)])))
.sum();
while s >> 16 != 0 {
s = (s & 0xffff) + (s >> 16);
}
!(s as u16)
}
let mut p = vec![0; if udp { 36 } else { 40 }];
p[0] = 0x45;
let len = p.len() as u16;
p[2..4].copy_from_slice(&len.to_be_bytes());
p[8] = 64;
p[9] = if udp { 17 } else { 6 };
p[12..16].copy_from_slice(&source.octets());
p[16..20].copy_from_slice(&destination.octets());
p[20..22].copy_from_slice(&sport.to_be_bytes());
p[22..24].copy_from_slice(&dport.to_be_bytes());
if udp {
p[24..26].copy_from_slice(&16u16.to_be_bytes());
} else {
p[32] = 0x50;
p[33] = flags;
p[24..28].copy_from_slice(&123u32.to_be_bytes());
}
let mut pseudo = p[12..20].to_vec();
pseudo.extend([0, p[9]]);
pseudo.extend(((p.len() - 20) as u16).to_be_bytes());
pseudo.extend(&p[20..]);
let c = sum(&pseudo);
let offset = if udp { 26 } else { 36 };
p[offset..offset + 2].copy_from_slice(&c.to_be_bytes());
let c = sum(&p[..20]);
p[10..12].copy_from_slice(&c.to_be_bytes());
Bytes::from(p)
}
#[tokio::test]
async fn vip_tcp_and_udp_remap_ports_over_real_encryption_and_preserve_source_admission() {
use tsunagi::vip::{Backend, Listener, Protocol, VipConfig};
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("vip-encrypted");
let a = WgAgent::spawn(&discovery, "vipa").await;
let b = WgAgent::spawn(&discovery, "vipb").await;
let net = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
a.wait_for_tunnels(net, 1).await;
b.wait_for_tunnels(net, 1).await;
let aa = a.overlay(net).await;
let ab = b.overlay(net).await;
let ta = a.tun(net).await;
let tb = b.tun(net).await;
let mut cfg = VipConfig::new("service");
for (protocol, port, backend) in [(Protocol::Tcp, 443, 6443), (Protocol::Udp, 53, 5353)] {
let mut l = Listener::new(protocol, port);
l.port_check = false;
l.backends.push(Backend {
peer: b.endpoint_id().to_string(),
port: backend,
});
cfg.listeners.push(l);
}
a.agent
.save_vip(net, cfg.clone(), None, false)
.await
.unwrap();
let vip = wait_until("data-plane probes measure the backend RTT", || async {
let status = a.agent.network_status(net).await.ok()?;
let v = status.vips.iter().find(|v| v.config.id == cfg.id)?;
(v.listeners.iter().all(|l| l.selected.is_some()))
.then_some(v.address?)
.filter(|_| v.listeners.len() == 2)
})
.await;
for (udp, port, target) in [(false, 443, 6443), (true, 53, 5353)] {
ta.push_from_os(vip_segment(aa, vip, 40000, port, udp, 2));
let received = tokio::time::timeout(Duration::from_secs(5), tb.pop_to_os())
.await
.unwrap()
.unwrap();
assert_eq!(&received[12..16], &aa.octets());
assert_eq!(&received[16..20], &ab.octets());
assert_eq!(u16::from_be_bytes([received[22], received[23]]), target);
let translated = u16::from_be_bytes([received[20], received[21]]);
tb.push_from_os(vip_segment(ab, aa, target, translated, udp, 0x12));
let reply = tokio::time::timeout(Duration::from_secs(5), ta.pop_to_os())
.await
.unwrap()
.unwrap();
assert_eq!(&reply[12..16], &vip.octets());
assert_eq!(u16::from_be_bytes([reply[20], reply[21]]), port);
assert_eq!(u16::from_be_bytes([reply[22], reply[23]]), 40000);
// A backend cannot send a VIP source over its authenticated tunnel.
let forged = vip_segment(vip, aa, port, 40000, udp, 0x12);
tb.push_from_os(forged);
}
wait_until("forged VIP sources are rejected before NAT", || async {
(a.agent.overlay()?.counters.wrong_source >= 2).then_some(())
})
.await;
a.shutdown().await;
b.shutdown().await;
}
#[tokio::test]
async fn vip_udp_reaches_another_backend_after_the_selected_agent_stops() {
use tsunagi::vip::{Backend, Listener, Protocol, VipConfig};
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("vip-failover");
let a = WgAgent::spawn(&discovery, "vipfa").await;
let b = WgAgent::spawn(&discovery, "vipfb").await;
let c = WgAgent::spawn(&discovery, "vipfc").await;
let net = a.agent.join_network(&name, &secret).await.unwrap();
for node in [&b, &c] {
node.agent.join_network(&name, &secret).await.unwrap();
}
for node in [&a, &b, &c] {
node.wait_for_tunnels(net, 2).await;
}
let mut config = VipConfig::new("datagrams");
let mut listener = Listener::new(Protocol::Udp, 8888);
listener.backends = vec![
Backend {
peer: b.endpoint_id().to_string(),
port: 9000,
},
Backend {
peer: c.endpoint_id().to_string(),
port: 9001,
},
];
config.listeners.push(listener);
a.agent
.save_vip(net, config.clone(), None, false)
.await
.unwrap();
let first = wait_until("both UDP backends have measured paths", || async {
let s = a.agent.network_status(net).await.ok()?;
let v = s.vips.iter().find(|v| v.config.id == config.id)?;
let l = v.listeners.first()?;
(l.backends.iter().all(|b| b.rtt_us.is_some())).then_some((v.address?, l.selected.clone()?))
})
.await;
let (dead, alive) = if first.1.peer == b.endpoint_id().to_string() {
(b, c)
} else {
(c, b)
};
let address = a.overlay(net).await;
let tun = a.tun(net).await;
let dest = dead.tun(net).await;
tun.push_from_os(vip_segment(address, first.0, 41000, 8888, true, 0));
tokio::time::timeout(Duration::from_secs(5), dest.pop_to_os())
.await
.unwrap()
.unwrap();
dead.shutdown().await;
wait_until("the remaining backend becomes the next choice", || async {
let s = a.agent.network_status(net).await.ok()?;
let v = s.vips.iter().find(|v| v.config.id == config.id)?;
(v.listeners.first()?.selected.as_ref()?.peer == alive.endpoint_id().to_string())
.then_some(())
})
.await;
tun.push_from_os(vip_segment(address, first.0, 41000, 8888, true, 0));
let packet = tokio::time::timeout(Duration::from_secs(5), alive.tun(net).await.pop_to_os())
.await
.unwrap()
.unwrap();
assert_eq!(&packet[16..20], &alive.overlay(net).await.octets());
let view = a.agent.network_status(net).await.unwrap();
assert_eq!(view.vips[0].listeners[0].counters.failovers, 1);
a.shutdown().await;
alive.shutdown().await;
}
+3 -7
View File
@@ -1,10 +1,10 @@
[package] [package]
name = "tsunagi-wireguard" name = "tsunagi-wireguard"
version = "0.1.0" version = "0.1.0"
description = "Pure WireGuard direct UDP transport and protocol plugin for tsunagi mesh networks." description = "Independent authenticated WireGuard and Amnezia UDP carriers for tsunagi."
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license = "WTFPL AND Apache-2.0"
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
@@ -14,14 +14,10 @@ tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macro
serde.workspace = true serde.workspace = true
postcard.workspace = true postcard.workspace = true
bytes.workspace = true bytes.workspace = true
thiserror.workspace = true
tracing.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"] } zeroize = { version = "1.9", features = ["derive"] }
rand = "0.10" rand = "0.10"
chacha20 = "0.9"
boringtun = { version = "0.7.1", default-features = false } boringtun = { version = "0.7.1", default-features = false }
netdev = "0.46" netdev = "0.46"
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Friderika Coulson (NanoBee)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+168
View File
@@ -0,0 +1,168 @@
//! Local underlay candidates, shared by the UDP carriers.
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// Discovers local network interface IPs to announce to peers, excluding loopback, overlay interfaces, and virtual bridges.
pub fn local_ip_candidates() -> Vec<IpAddr> {
local_ip_candidates_excluding(None)
}
pub(crate) fn local_ip_candidates_excluding(
excluded_interface: Option<&str>,
) -> Vec<std::net::IpAddr> {
let mut candidates = Vec::new();
for iface in netdev::get_interfaces() {
if excluded_interface.is_some_and(|name| {
iface.name.eq_ignore_ascii_case(name)
|| iface
.friendly_name
.as_deref()
.is_some_and(|friendly| friendly.eq_ignore_ascii_case(name))
}) || !is_candidate_interface(&iface)
{
continue;
}
for ip in &iface.ipv4 {
let addr = ip.addr();
if is_candidate_ipv4(addr) {
let priority = ip_priority(&iface, IpAddr::V4(addr));
candidates.push((priority, IpAddr::V4(addr)));
}
}
}
// Sort descending by priority so the most viable physical / WAN / LAN 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 interfaces and virtual bridges purely internal to container engines
if name.starts_with("docker")
|| name.starts_with("br-")
|| name.starts_with("veth")
|| 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("wsl")
{
return false;
}
true
}
/// Reorders candidate addresses so that candidates on the same local subnet as one of our
/// active network interfaces appear first.
pub fn prioritize_local_subnet(candidates: &mut [SocketAddr]) {
let local_subnets: Vec<(Ipv4Addr, u8)> = netdev::get_interfaces()
.into_iter()
.filter(|iface| iface.is_up() && !iface.is_loopback())
.flat_map(|iface| {
iface
.ipv4
.into_iter()
.map(|net| (net.addr(), net.prefix_len()))
})
.collect();
candidates.sort_by_key(|addr| {
let is_local = match addr.ip() {
IpAddr::V4(v4) => local_subnets
.iter()
.any(|(local_ip, prefix_len)| matches_subnet(*local_ip, *prefix_len, v4)),
IpAddr::V6(_) => false,
};
if is_local { 0 } else { 1 }
});
}
fn matches_subnet(local_ip: Ipv4Addr, prefix_len: u8, target_ip: Ipv4Addr) -> bool {
if prefix_len == 0 {
return false;
}
let mask = if prefix_len >= 32 {
!0u32
} else {
!0u32 << (32 - prefix_len)
};
(u32::from(local_ip) & mask) == (u32::from(target_ip) & mask)
}
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 ip_priority(iface: &netdev::Interface, ip: IpAddr) -> u32 {
let has_gateway = iface.gateway.is_some();
let base = if has_gateway { 100 } else { 0 };
match ip {
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 {
// Public / globally routable IP
base + 50
} else if has_gateway && is_private {
// Primary LAN IP with gateway (e.g. 192.168.1.117)
base + 40
} else if is_tailscale {
// Tailscale / CGNAT
base + 30
} else {
// Other private / virtual
base + 10
}
}
IpAddr::V6(v6) => {
let segments = v6.segments();
let is_global = (segments[0] & 0xe000) == 0x2000;
if is_global { base + 45 } else { base + 20 }
}
}
}
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff. Show more