Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2166ba4800 | ||
|
|
8a6216bccb | ||
|
|
2e7e238459 | ||
|
|
23af1a2d9d | ||
|
|
94318e7c16 | ||
|
|
2b0c938487 | ||
|
|
4e235cbac6 | ||
|
|
2631eb6975 | ||
|
|
90ef5cefec | ||
|
|
13bdcaf98d | ||
|
|
d941cdac7a | ||
|
|
e701986a39 | ||
|
|
ed28e86748 | ||
|
|
781e359775 | ||
|
|
d5e02ec256 | ||
|
|
97377e3703 |
No files matched your search
@@ -1 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
|
/.claude
|
||||||
@@ -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
+105
-16
@@ -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,11 +6324,13 @@ 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",
|
||||||
@@ -6253,6 +6338,21 @@ dependencies = [
|
|||||||
"windows-service",
|
"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]]
|
||||||
name = "tsunagi-core"
|
name = "tsunagi-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -6301,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]]
|
||||||
@@ -6361,6 +6452,8 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tsunagi-core",
|
"tsunagi-core",
|
||||||
|
"tsunagi-tcp-tls",
|
||||||
|
"tsunagi-wireguard",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6370,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",
|
||||||
|
|||||||
+3
-1
@@ -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"
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ with relay fallback), and keep the network state among themselves.
|
|||||||
|
|
||||||
- **Two things to know:** a network name and a secret. A secret is generated for you when you make a network.
|
- **Two things to know:** a network name and a secret. A secret is generated for you when you make a network.
|
||||||
- **Direct, encrypted links** between members (WireGuard cryptography), with relay fallback behind strict NATs.
|
- **Direct, encrypted links** between members (WireGuard cryptography), with relay fallback behind strict NATs.
|
||||||
- **Names for machines:** `server.home` just works, even while `server` is switched off.
|
- **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.
|
||||||
- **Exit nodes:** send all your internet traffic through any member that offers one.
|
- **Exit nodes:** send all your internet traffic through any member that offers one.
|
||||||
- **Multi-hop:** members that can't reach each other directly are routed through a member that can.
|
- **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.
|
- **LAN games:** UDP broadcasts are relayed, so "LAN" lobbies show up across the mesh.
|
||||||
- **Several networks at once** on one device.
|
- **Several networks at once** on one device.
|
||||||
- **Tray app** for Windows, macOS and Linux; a command line for everything.
|
- **Tray app** for Windows, macOS and Linux; a command line for everything.
|
||||||
@@ -81,9 +81,32 @@ tsng join -n home -s tsn1… # every other machine
|
|||||||
tsng status # who is online, over which path
|
tsng status # who is online, over which path
|
||||||
```
|
```
|
||||||
|
|
||||||
Every member gets an address in `10.13.37.0/24` and a name like `<host>.home`.
|
Every new network gets a stable pseudorandom `/24` in `10.0.0.0/8`, derived
|
||||||
|
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.
|
||||||
|
|
||||||
|
If two configured networks have overlapping ranges, the agent runs only one.
|
||||||
|
`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.
|
||||||
|
|
||||||
Machines are called `tsunagi-<id>` until you rename them: `tsng id hostname laptop`.
|
Machines are called `tsunagi-<id>` until you rename them: `tsng id hostname laptop`.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
### Exit nodes
|
### Exit nodes
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -97,6 +120,51 @@ The tray does the same with a checkbox. An exit node sees everything you send
|
|||||||
through it, so use only members you trust.
|
through it, so use only members you trust.
|
||||||
[Details and per-OS notes](docs/exit-node.md).
|
[Details and per-OS notes](docs/exit-node.md).
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
Change a network's protocol selection without editing its service or restarting:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
Exact network names, full ids and unique id prefixes work here. The choice is
|
||||||
|
local to this device, saved per network and applied immediately; an offline
|
||||||
|
change applies on the next start. In the tray, expand **data transports** on a network
|
||||||
|
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
|
## Platforms
|
||||||
|
|
||||||
| Platform | Build | CLI `tsng` | Tray | Exit node: offer / use | Packages |
|
| Platform | Build | CLI `tsng` | Tray | Exit node: offer / use | Packages |
|
||||||
@@ -144,6 +212,11 @@ Just trying it: `nix run github:house-of-vanity/tsunagi#tsunagi-cli -- --help`.
|
|||||||
`packages.<system>.tsunagi` (with the tray) and `tsunagi-cli` are also exported,
|
`packages.<system>.tsunagi` (with the tray) and `tsunagi-cli` are also exported,
|
||||||
along with `overlays.default`.
|
along with `overlays.default`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Build from source
|
## Build from source
|
||||||
|
|
||||||
Rust 1.91 or newer.
|
Rust 1.91 or newer.
|
||||||
@@ -174,8 +247,10 @@ The [threat model](docs/threat-model.md) says what this does and doesn't protect
|
|||||||
[Architecture](docs/architecture.md) ·
|
[Architecture](docs/architecture.md) ·
|
||||||
[Protocol](docs/protocol.md) ·
|
[Protocol](docs/protocol.md) ·
|
||||||
[WireGuard data plane](docs/wireguard.md) ·
|
[WireGuard data plane](docs/wireguard.md) ·
|
||||||
|
[Pairwise Amnezia transport](docs/amnezia.md) ·
|
||||||
[Routing](docs/routing.md) ·
|
[Routing](docs/routing.md) ·
|
||||||
[Exit nodes](docs/exit-node.md) ·
|
[Exit nodes](docs/exit-node.md) ·
|
||||||
|
[Virtual services](docs/vip.md) ·
|
||||||
[Threat model](docs/threat-model.md) ·
|
[Threat model](docs/threat-model.md) ·
|
||||||
[Contributing](AGENTS.md)
|
[Contributing](AGENTS.md)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -26,15 +26,15 @@ mod status;
|
|||||||
|
|
||||||
pub use events::Event;
|
pub use events::Event;
|
||||||
pub use status::{
|
pub use status::{
|
||||||
AgentStatus, CandidateStatus, MemberStatus, NetworkMetrics, NetworkState, NetworkStatus,
|
AgentStatus, CandidateStatus, DataPath, HostnameConflict, MemberStatus, NetworkMetrics,
|
||||||
OverlayStatus, PeerStatus,
|
NetworkState, NetworkStatus, OverlayStatus, PeerStatus, RangeConflict,
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Weak};
|
use std::sync::{Arc, Weak};
|
||||||
|
|
||||||
use iroh::{EndpointAddr, EndpointId};
|
use iroh::{EndpointAddr, EndpointId};
|
||||||
use tokio::sync::{RwLock, broadcast, mpsc, oneshot};
|
use tokio::sync::{Mutex, RwLock, broadcast, mpsc, oneshot, watch};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
use crate::BoxFuture;
|
use crate::BoxFuture;
|
||||||
@@ -58,6 +58,8 @@ use shutdown::Shutdown;
|
|||||||
/// Summary of a configured network, whether or not it is running.
|
/// Summary of a configured network, whether or not it is running.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ConfiguredNetwork {
|
pub struct ConfiguredNetwork {
|
||||||
|
/// Enabled local data-plane protocols, in preference order.
|
||||||
|
pub protocols: Vec<String>,
|
||||||
/// Saved local broadcast participation.
|
/// Saved local broadcast participation.
|
||||||
pub broadcast: bool,
|
pub broadcast: bool,
|
||||||
/// Public network identifier.
|
/// Public network identifier.
|
||||||
@@ -70,6 +72,17 @@ pub struct ConfiguredNetwork {
|
|||||||
pub active: bool,
|
pub active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A durable version-scoped cleanup, not an identity ban.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ForgetNodeOutcome {
|
||||||
|
/// Identity whose observed claim was retired.
|
||||||
|
pub target: EndpointId,
|
||||||
|
/// The retired claim version; future signed claims remain admissible.
|
||||||
|
pub record_version: u64,
|
||||||
|
/// Authenticated peers to which cleanup was queued immediately.
|
||||||
|
pub peers_told: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// A running agent.
|
/// A running agent.
|
||||||
///
|
///
|
||||||
/// Cloning gives another handle to the same agent.
|
/// Cloning gives another handle to the same agent.
|
||||||
@@ -90,6 +103,10 @@ struct Inner {
|
|||||||
events: broadcast::Sender<Event>,
|
events: broadcast::Sender<Event>,
|
||||||
networks: RwLock<HashMap<NetworkId, NetworkHandle>>,
|
networks: RwLock<HashMap<NetworkId, NetworkHandle>>,
|
||||||
shutdown: Shutdown,
|
shutdown: Shutdown,
|
||||||
|
/// Serialises start, stop, switching and conflict cleanup, including teardown.
|
||||||
|
lifecycle: Mutex<()>,
|
||||||
|
range_changed: watch::Sender<()>,
|
||||||
|
range_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||||
accept_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
accept_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||||
plugin_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
plugin_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||||
transport: std::sync::OnceLock<Arc<dyn PacketTransport>>,
|
transport: std::sync::OnceLock<Arc<dyn PacketTransport>>,
|
||||||
@@ -100,6 +117,41 @@ struct Inner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Inner {
|
impl Inner {
|
||||||
|
/// Called under the lifecycle lock after startup: an initially
|
||||||
|
/// control-only agent creates its one interface on first enablement.
|
||||||
|
async fn ensure_interface(self: &Arc<Self>) {
|
||||||
|
if self.interface.get().is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(factory) = self.config.tun_factory.clone() {
|
||||||
|
let carrier = Arc::new(Carrier(Arc::downgrade(self))) as Arc<dyn PacketCarrier>;
|
||||||
|
match crate::overlay::Interface::start(
|
||||||
|
factory,
|
||||||
|
self.config.interface_name.clone(),
|
||||||
|
self.config.interface_mtu,
|
||||||
|
Arc::clone(&self.routes),
|
||||||
|
carrier,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(interface) => {
|
||||||
|
self.adapter.exclude_interface(interface.name());
|
||||||
|
for carrier in &self.config.carriers {
|
||||||
|
carrier.on_interface_ready(interface.name());
|
||||||
|
}
|
||||||
|
let _ = self.interface.set(Arc::new(interface));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let _ = self.events.send(Event::PluginError {
|
||||||
|
network: NetworkId::from_bytes([0u8; 32]),
|
||||||
|
protocol: "overlay".into(),
|
||||||
|
reason: err.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The name this agent currently answers to.
|
/// The name this agent currently answers to.
|
||||||
fn read_hostname(&self) -> String {
|
fn read_hostname(&self) -> String {
|
||||||
match self.hostname.read() {
|
match self.hostname.read() {
|
||||||
@@ -140,14 +192,13 @@ impl PacketCarrier for Carrier {
|
|||||||
let Some(inner) = self.0.upgrade() else {
|
let Some(inner) = self.0.upgrade() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
// Offered to each protocol in turn. With one configured this is a
|
// Every routed IP packet enters the same internal cipher, whatever
|
||||||
// single call; the shape is what allows several to be live at once,
|
// physical carrier currently supplies the logical peer link.
|
||||||
// each carrying the peers it has a link to.
|
|
||||||
inner
|
inner
|
||||||
.config
|
.config
|
||||||
.plugins
|
.tunnel
|
||||||
.iter()
|
.as_ref()
|
||||||
.any(|plugin| plugin.carry(route.network, route.peer, packet.clone()))
|
.is_some_and(|tunnel| tunnel.carry(route.network, route.peer, packet))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +234,35 @@ impl PacketSink for Sink {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct TransportCtx(Weak<Inner>);
|
struct TransportCtx(Weak<Inner>);
|
||||||
|
|
||||||
|
impl crate::dataplane::transport::ControlSender for TransportCtx {
|
||||||
|
fn send<'a>(
|
||||||
|
&'a self,
|
||||||
|
network: NetworkId,
|
||||||
|
peer: EndpointId,
|
||||||
|
protocol: &'a str,
|
||||||
|
payload: crate::proto::message::TransportPayload,
|
||||||
|
) -> crate::BoxFuture<'a, std::result::Result<(), crate::dataplane::transport::TransportError>>
|
||||||
|
{
|
||||||
|
Box::pin(async move {
|
||||||
|
let inner = self
|
||||||
|
.0
|
||||||
|
.upgrade()
|
||||||
|
.ok_or(crate::dataplane::transport::TransportError::Closed)?;
|
||||||
|
Agent { inner }
|
||||||
|
.send(
|
||||||
|
network,
|
||||||
|
peer,
|
||||||
|
crate::proto::message::ControlMessage::Transport {
|
||||||
|
protocol: protocol.into(),
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| crate::dataplane::transport::TransportError::Other(err.to_string()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TransportContext for TransportCtx {
|
impl TransportContext for TransportCtx {
|
||||||
fn snapshot(&self) -> crate::BoxFuture<'_, HashMap<NetworkId, NetworkKeys>> {
|
fn snapshot(&self) -> crate::BoxFuture<'_, HashMap<NetworkId, NetworkKeys>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
@@ -209,7 +289,7 @@ impl TransportContext for TransportCtx {
|
|||||||
}
|
}
|
||||||
inner
|
inner
|
||||||
.config
|
.config
|
||||||
.plugins
|
.carriers
|
||||||
.iter()
|
.iter()
|
||||||
.any(|plugin| plugin.protocol_id() == protocol)
|
.any(|plugin| plugin.protocol_id() == protocol)
|
||||||
})
|
})
|
||||||
@@ -217,12 +297,136 @@ impl TransportContext for TransportCtx {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Agent {
|
impl Agent {
|
||||||
|
/// Atomically saves or removes a version-checked shared VIP draft.
|
||||||
|
/// Every authenticated network member may edit any service.
|
||||||
|
pub async fn save_vip(
|
||||||
|
&self,
|
||||||
|
network: NetworkId,
|
||||||
|
config: crate::vip::VipConfig,
|
||||||
|
expected: Option<String>,
|
||||||
|
delete: bool,
|
||||||
|
) -> Result<Vec<crate::vip::VipStatus>> {
|
||||||
|
let (reply, receive) = oneshot::channel();
|
||||||
|
self.command(
|
||||||
|
network,
|
||||||
|
NetCommand::SaveVip {
|
||||||
|
config,
|
||||||
|
expected,
|
||||||
|
delete,
|
||||||
|
reply,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
receive.await.map_err(|_| Error::Stopped)?
|
||||||
|
}
|
||||||
|
/// Retires one offline member's observed claim without revoking membership.
|
||||||
|
/// Every member has this right. The version must still match the selection.
|
||||||
|
pub async fn forget_node(
|
||||||
|
&self,
|
||||||
|
network: NetworkId,
|
||||||
|
peer: EndpointId,
|
||||||
|
expected_version: u64,
|
||||||
|
) -> Result<ForgetNodeOutcome> {
|
||||||
|
let _lifecycle = self.inner.lifecycle.lock().await;
|
||||||
|
let (reply, receive) = oneshot::channel();
|
||||||
|
self.command(
|
||||||
|
network,
|
||||||
|
NetCommand::ForgetNode {
|
||||||
|
peer,
|
||||||
|
expected_version,
|
||||||
|
reply,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
receive.await.map_err(|_| Error::Stopped)?
|
||||||
|
}
|
||||||
|
fn effective_protocols(&self, saved: Option<&Vec<String>>) -> Vec<String> {
|
||||||
|
saved
|
||||||
|
.or(self.inner.config.default_protocols.as_ref())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
self.inner
|
||||||
|
.config
|
||||||
|
.carriers
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.protocol_id().to_owned())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_carriers(
|
||||||
|
&self,
|
||||||
|
protocols: &[String],
|
||||||
|
) -> Vec<crate::dataplane::transport::SharedCarrier> {
|
||||||
|
protocols
|
||||||
|
.iter()
|
||||||
|
.filter_map(|name| {
|
||||||
|
self.inner
|
||||||
|
.config
|
||||||
|
.carriers
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.protocol_id() == name)
|
||||||
|
.cloned()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves and immediately applies a network's ordered local protocol set.
|
||||||
|
/// An empty set disables data traffic while retaining control sessions.
|
||||||
|
pub async fn set_protocols(&self, network_id: NetworkId, protocols: Vec<String>) -> Result<()> {
|
||||||
|
crate::dataplane::validate_protocols(&protocols, self.inner.limits.max_capabilities)?;
|
||||||
|
for name in &protocols {
|
||||||
|
if !self
|
||||||
|
.inner
|
||||||
|
.config
|
||||||
|
.carriers
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.protocol_id() == name)
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidProtocols(format!(
|
||||||
|
"this agent has no `{name}` plugin"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _lifecycle = self.inner.lifecycle.lock().await;
|
||||||
|
self.inner
|
||||||
|
.storage
|
||||||
|
.set_protocols(network_id, protocols.clone())
|
||||||
|
.await?;
|
||||||
|
if self.is_active(network_id).await {
|
||||||
|
let (reply, receive) = oneshot::channel();
|
||||||
|
if !protocols.is_empty() {
|
||||||
|
self.inner.ensure_interface().await;
|
||||||
|
}
|
||||||
|
self.command(
|
||||||
|
network_id,
|
||||||
|
NetCommand::SetProtocols {
|
||||||
|
protocols,
|
||||||
|
interface: self.inner.interface.get().cloned(),
|
||||||
|
reply,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
receive.await.map_err(|_| Error::Stopped)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
/// Starts an agent.
|
/// Starts an agent.
|
||||||
///
|
///
|
||||||
/// Opens the state store (taking its ownership lock), restores the
|
/// Opens the state store (taking its ownership lock), restores the
|
||||||
/// persistent device identity, binds the iroh endpoint and activates every
|
/// persistent device identity, binds the iroh endpoint and activates every
|
||||||
/// configured network whose auto-start flag is set.
|
/// configured network whose auto-start flag is set.
|
||||||
pub async fn spawn(config: AgentConfig) -> Result<Self> {
|
pub async fn spawn(config: AgentConfig) -> Result<Self> {
|
||||||
|
if let Some(protocols) = &config.default_protocols {
|
||||||
|
crate::dataplane::validate_protocols(protocols, config.limits.max_capabilities)?;
|
||||||
|
for name in protocols {
|
||||||
|
if !config.carriers.iter().any(|p| p.protocol_id() == name) {
|
||||||
|
return Err(Error::InvalidProtocols(format!(
|
||||||
|
"this agent has no `{name}` plugin"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let storage = Storage::open(&config.paths)?;
|
let storage = Storage::open(&config.paths)?;
|
||||||
let identity = storage.device_identity().await?;
|
let identity = storage.device_identity().await?;
|
||||||
let adapter = EndpointAdapter::bind(&config, &identity).await?;
|
let adapter = EndpointAdapter::bind(&config, &identity).await?;
|
||||||
@@ -234,6 +438,7 @@ impl Agent {
|
|||||||
let (events, _) = broadcast::channel(config.limits.event_buffer);
|
let (events, _) = broadcast::channel(config.limits.event_buffer);
|
||||||
let limits = Arc::new(config.limits.clone());
|
let limits = Arc::new(config.limits.clone());
|
||||||
|
|
||||||
|
let (range_changed, range_updates) = watch::channel(());
|
||||||
let inner = Arc::new(Inner {
|
let inner = Arc::new(Inner {
|
||||||
limits,
|
limits,
|
||||||
storage,
|
storage,
|
||||||
@@ -243,6 +448,9 @@ impl Agent {
|
|||||||
events,
|
events,
|
||||||
networks: RwLock::new(HashMap::new()),
|
networks: RwLock::new(HashMap::new()),
|
||||||
shutdown: Shutdown::new(),
|
shutdown: Shutdown::new(),
|
||||||
|
lifecycle: Mutex::new(()),
|
||||||
|
range_changed,
|
||||||
|
range_task: std::sync::Mutex::new(None),
|
||||||
accept_task: std::sync::Mutex::new(None),
|
accept_task: std::sync::Mutex::new(None),
|
||||||
plugin_task: std::sync::Mutex::new(None),
|
plugin_task: std::sync::Mutex::new(None),
|
||||||
transport: std::sync::OnceLock::new(),
|
transport: std::sync::OnceLock::new(),
|
||||||
@@ -251,31 +459,13 @@ impl Agent {
|
|||||||
config,
|
config,
|
||||||
});
|
});
|
||||||
|
|
||||||
// One interface for the agent, if it was given a way to make one.
|
if inner
|
||||||
// Failing to is reported and not fatal: the control plane works, and
|
.config
|
||||||
// so do the protocols, they just have nowhere to put packets.
|
.default_protocols
|
||||||
if let Some(factory) = inner.config.tun_factory.clone() {
|
.as_ref()
|
||||||
let carrier = Arc::new(Carrier(Arc::downgrade(&inner))) as Arc<dyn PacketCarrier>;
|
.is_none_or(|names| !names.is_empty())
|
||||||
match crate::overlay::Interface::start(
|
|
||||||
factory,
|
|
||||||
inner.config.interface_name.clone(),
|
|
||||||
inner.config.interface_mtu,
|
|
||||||
Arc::clone(&inner.routes),
|
|
||||||
carrier,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
Ok(interface) => {
|
inner.ensure_interface().await;
|
||||||
let _ = inner.interface.set(Arc::new(interface));
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
let _ = inner.events.send(Event::PluginError {
|
|
||||||
network: NetworkId::from_bytes([0u8; 32]),
|
|
||||||
protocol: "overlay".into(),
|
|
||||||
reason: err.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The data plane rides on iroh too, which is where it gets hole
|
// The data plane rides on iroh too, which is where it gets hole
|
||||||
@@ -294,6 +484,7 @@ impl Agent {
|
|||||||
inner.config.custom_transports.clone(),
|
inner.config.custom_transports.clone(),
|
||||||
))
|
))
|
||||||
};
|
};
|
||||||
|
transport.attach_control(Arc::new(TransportCtx(Arc::downgrade(&inner))));
|
||||||
let _ = inner.transport.set(transport);
|
let _ = inner.transport.set(transport);
|
||||||
|
|
||||||
if let CacheOutcome::Reset(reason) = inner.storage.cache_outcome().clone() {
|
if let CacheOutcome::Reset(reason) = inner.storage.cache_outcome().clone() {
|
||||||
@@ -308,15 +499,16 @@ impl Agent {
|
|||||||
// Plugins get a handle to ask for re-announcements and report errors.
|
// Plugins get a handle to ask for re-announcements and report errors.
|
||||||
// A bounded queue keeps a noisy plugin from growing memory without
|
// A bounded queue keeps a noisy plugin from growing memory without
|
||||||
// bound; overflow drops the request rather than stalling the plugin.
|
// bound; overflow drops the request rather than stalling the plugin.
|
||||||
if !inner.config.plugins.is_empty() {
|
if !inner.config.carriers.is_empty() || inner.config.tunnel.is_some() {
|
||||||
let (plugin_tx, plugin_rx) = mpsc::channel(64);
|
let (plugin_tx, plugin_rx) = mpsc::channel(64);
|
||||||
let sink = inner
|
let sink = inner
|
||||||
.interface
|
.config
|
||||||
.get()
|
.tun_factory
|
||||||
|
.as_ref()
|
||||||
.map(|_| Arc::new(Sink(Arc::downgrade(&inner))) as Arc<dyn PacketSink>);
|
.map(|_| Arc::new(Sink(Arc::downgrade(&inner))) as Arc<dyn PacketSink>);
|
||||||
let context = PluginContext::new(plugin_tx, inner.identity.endpoint_id(), sink);
|
let context = PluginContext::new(plugin_tx, inner.identity.endpoint_id(), sink);
|
||||||
for plugin in &inner.config.plugins {
|
if let Some(tunnel) = &inner.config.tunnel {
|
||||||
plugin.attach(context.clone());
|
tunnel.attach(context);
|
||||||
}
|
}
|
||||||
let task = tokio::spawn(plugin_request_loop(Arc::downgrade(&inner), plugin_rx));
|
let task = tokio::spawn(plugin_request_loop(Arc::downgrade(&inner), plugin_rx));
|
||||||
if let Ok(mut guard) = inner.plugin_task.lock() {
|
if let Ok(mut guard) = inner.plugin_task.lock() {
|
||||||
@@ -326,11 +518,16 @@ impl Agent {
|
|||||||
|
|
||||||
let agent = Self { inner };
|
let agent = Self { inner };
|
||||||
|
|
||||||
for stored in agent.inner.storage.list_networks().await? {
|
{
|
||||||
if stored.auto_start {
|
let _guard = agent.inner.lifecycle.lock().await;
|
||||||
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
agent.resume_networks_locked().await?;
|
||||||
agent.activate_with_keys(keys).await?;
|
|
||||||
}
|
}
|
||||||
|
let task = tokio::spawn(range_conflict_loop(
|
||||||
|
Arc::downgrade(&agent.inner),
|
||||||
|
range_updates,
|
||||||
|
));
|
||||||
|
if let Ok(mut guard) = agent.inner.range_task.lock() {
|
||||||
|
*guard = Some(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(agent)
|
Ok(agent)
|
||||||
@@ -374,101 +571,66 @@ impl Agent {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reserves the configured overlay range for a network, if it can.
|
/// Signed state always wins over a default or an explicit proposal.
|
||||||
///
|
async fn desired_range(&self, network: NetworkId) -> Result<Option<Ipv4Range>> {
|
||||||
/// Done here, where activations are serialised, rather than inside the
|
let records = self.inner.storage.signed_records(network).await?;
|
||||||
/// runtime: whether a range is free depends on what the other networks
|
let mut state = crate::state::StateSet::new();
|
||||||
/// took, and deciding that in a task would make the answer depend on
|
state.merge_all(network, records);
|
||||||
/// which task ran first.
|
Ok(state.agreed_range().or_else(|| {
|
||||||
///
|
if self.inner.config.overlay_ipv4_auto {
|
||||||
/// `None` means this agent will not propose a range for that network and
|
Some(crate::state::derived_ipv4_range(network))
|
||||||
/// waits to adopt whatever it settles on. One agent has one interface, so
|
|
||||||
/// proposing a range it could not route would be worse than having none:
|
|
||||||
/// the lowest author's range wins, and the collision would spread.
|
|
||||||
async fn reserve_range(&self, network: NetworkId) -> RangePlan {
|
|
||||||
let reserve = |wanted: Ipv4Range| {
|
|
||||||
let reservation = crate::overlay::NetworkRoutes {
|
|
||||||
exit: Default::default(),
|
|
||||||
broadcast: Default::default(),
|
|
||||||
range: Some(wanted),
|
|
||||||
local: None,
|
|
||||||
peers: Vec::new(),
|
|
||||||
};
|
|
||||||
self.inner.routes.set_network(network, reservation).is_ok()
|
|
||||||
};
|
|
||||||
|
|
||||||
// If this network already has signed records in storage, check if its
|
|
||||||
// members have already agreed on a range. If so, preserve and reserve
|
|
||||||
// THAT range rather than preemptively seizing the default configured range.
|
|
||||||
let stored_records = self
|
|
||||||
.inner
|
|
||||||
.storage
|
|
||||||
.signed_records(network)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
let mut state_set = crate::state::StateSet::new();
|
|
||||||
state_set.merge_all(network, stored_records);
|
|
||||||
if let Some(agreed) = state_set.agreed_range() {
|
|
||||||
if reserve(agreed) {
|
|
||||||
return RangePlan {
|
|
||||||
propose: if Some(agreed) == self.inner.config.overlay_ipv4_range {
|
|
||||||
Some(agreed)
|
|
||||||
} else {
|
} else {
|
||||||
None
|
self.inner.config.overlay_ipv4_range
|
||||||
},
|
|
||||||
..RangePlan::default()
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
%network,
|
|
||||||
range = %agreed,
|
|
||||||
"agreed range for this network overlaps another network already active on this interface"
|
|
||||||
);
|
|
||||||
return RangePlan {
|
|
||||||
conflict: Some(agreed),
|
|
||||||
..RangePlan::default()
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(configured) = self.inner.config.overlay_ipv4_range else {
|
async fn range_conflicts(
|
||||||
return RangePlan::default();
|
&self,
|
||||||
};
|
network: NetworkId,
|
||||||
|
range: Option<Ipv4Range>,
|
||||||
// The configured range first, so a network a device has always had
|
) -> Result<Vec<RangeConflict>> {
|
||||||
// keeps the addresses it has always had.
|
let Some(range) = range else {
|
||||||
if reserve(configured) {
|
return Ok(Vec::new());
|
||||||
return RangePlan {
|
|
||||||
propose: Some(configured),
|
|
||||||
..RangePlan::default()
|
|
||||||
};
|
};
|
||||||
|
let overlapping = self.inner.routes.overlapping_networks(network, range);
|
||||||
|
let stored = self.inner.storage.list_networks().await?;
|
||||||
|
Ok(overlapping
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(id, range)| {
|
||||||
|
let name = stored.iter().find(|n| n.network_id == id)?.name.clone();
|
||||||
|
Some(RangeConflict {
|
||||||
|
network_id: id,
|
||||||
|
name,
|
||||||
|
range,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
// A second network on the same agent cannot have it — one agent,
|
/// Starts requested networks in saved preference order, skipping overlaps.
|
||||||
// one interface — so it falls back to the range derived from its
|
/// Called with the lifecycle lock held; skipped networks remain requested.
|
||||||
// own id, which every one of its members derives identically
|
async fn resume_networks_locked(&self) -> Result<()> {
|
||||||
// without being told.
|
let order = self.inner.storage.network_start_order().await?;
|
||||||
let derived = crate::state::derived_ipv4_range(network);
|
let mut stored = self.inner.storage.list_networks().await?;
|
||||||
if reserve(derived) {
|
stored.sort_by_key(|n| {
|
||||||
tracing::info!(
|
order
|
||||||
%network,
|
.iter()
|
||||||
range = %derived,
|
.position(|id| *id == n.network_id)
|
||||||
"another network here holds the configured range; this one uses the \
|
.unwrap_or(usize::MAX)
|
||||||
range derived from its id unless its members settled another"
|
});
|
||||||
);
|
for network in stored {
|
||||||
return RangePlan {
|
if !network.auto_start || self.is_active(network.network_id).await {
|
||||||
fallback: Some(derived),
|
continue;
|
||||||
conflict: Some(configured),
|
|
||||||
..RangePlan::default()
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
let keys = NetworkKeys::derive(&network.name, &network.secret);
|
||||||
tracing::info!(%network, "not proposing a range for this network");
|
match self.activate_with_keys_locked(keys).await {
|
||||||
RangePlan {
|
Ok(()) | Err(Error::NetworkRangeConflict { .. }) => {}
|
||||||
conflict: Some(configured),
|
Err(err) => return Err(err),
|
||||||
..RangePlan::default()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// The overlay interface this agent owns, when it has one.
|
/// The overlay interface this agent owns, when it has one.
|
||||||
///
|
///
|
||||||
@@ -546,6 +708,9 @@ impl Agent {
|
|||||||
/// The same `(name, secret)` always produces the same [`NetworkId`], on
|
/// The same `(name, secret)` always produces the same [`NetworkId`], on
|
||||||
/// every device.
|
/// every device.
|
||||||
///
|
///
|
||||||
|
/// An overlapping network stays configured and requested, but inactive;
|
||||||
|
/// use [`Agent::switch_network`] to suspend the blockers explicitly.
|
||||||
|
///
|
||||||
/// This is declarative and therefore **idempotent**: joining a network
|
/// This is declarative and therefore **idempotent**: joining a network
|
||||||
/// that is already active succeeds and changes nothing. That matters
|
/// that is already active succeeds and changes nothing. That matters
|
||||||
/// because a configured network is activated automatically at startup, so
|
/// because a configured network is activated automatically at startup, so
|
||||||
@@ -599,7 +764,9 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
match self.activate_with_keys(keys).await {
|
match self.activate_with_keys(keys).await {
|
||||||
// Already a member of exactly this network space: nothing to do.
|
// Already a member of exactly this network space: nothing to do.
|
||||||
Ok(()) | Err(Error::NetworkAlreadyActive(_)) => Ok(network_id),
|
Ok(())
|
||||||
|
| Err(Error::NetworkAlreadyActive(_))
|
||||||
|
| Err(Error::NetworkRangeConflict { .. }) => Ok(network_id),
|
||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -703,7 +870,44 @@ impl Agent {
|
|||||||
self.activate_with_keys(keys).await
|
self.activate_with_keys(keys).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Switches to a configured network, suspending every overlapping network.
|
||||||
|
/// Suspended networks retain their auto-start request and resume when free.
|
||||||
|
/// Returns the networks suspended by this request.
|
||||||
|
pub async fn switch_network(&self, network_id: NetworkId) -> Result<Vec<NetworkId>> {
|
||||||
|
let _guard = self.inner.lifecycle.lock().await;
|
||||||
|
if self.inner.shutdown.is_triggered() {
|
||||||
|
return Err(Error::Stopped);
|
||||||
|
}
|
||||||
|
let stored = self
|
||||||
|
.inner
|
||||||
|
.storage
|
||||||
|
.list_networks()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.find(|n| n.network_id == network_id)
|
||||||
|
.ok_or(Error::NetworkUnknown(network_id))?;
|
||||||
|
let range = self.desired_range(network_id).await?;
|
||||||
|
let conflicts = self.range_conflicts(network_id, range).await?;
|
||||||
|
self.inner.storage.prefer_network(network_id).await?;
|
||||||
|
let mut stopped = Vec::new();
|
||||||
|
for conflict in conflicts {
|
||||||
|
self.stop_network_locked(conflict.network_id).await?;
|
||||||
|
stopped.push(conflict.network_id);
|
||||||
|
}
|
||||||
|
if !self.is_active(network_id).await {
|
||||||
|
self.activate_with_keys_locked(NetworkKeys::derive(&stored.name, &stored.secret))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
self.resume_networks_locked().await?;
|
||||||
|
Ok(stopped)
|
||||||
|
}
|
||||||
|
|
||||||
async fn activate_with_keys(&self, keys: NetworkKeys) -> Result<()> {
|
async fn activate_with_keys(&self, keys: NetworkKeys) -> Result<()> {
|
||||||
|
let _guard = self.inner.lifecycle.lock().await;
|
||||||
|
self.activate_with_keys_locked(keys).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn activate_with_keys_locked(&self, keys: NetworkKeys) -> Result<()> {
|
||||||
if self.inner.shutdown.is_triggered() {
|
if self.inner.shutdown.is_triggered() {
|
||||||
return Err(Error::Stopped);
|
return Err(Error::Stopped);
|
||||||
}
|
}
|
||||||
@@ -712,8 +916,70 @@ impl Agent {
|
|||||||
if networks.contains_key(&network_id) {
|
if networks.contains_key(&network_id) {
|
||||||
return Err(Error::NetworkAlreadyActive(network_id));
|
return Err(Error::NetworkAlreadyActive(network_id));
|
||||||
}
|
}
|
||||||
|
let stored = self
|
||||||
let range = self.reserve_range(network_id).await;
|
.inner
|
||||||
|
.storage
|
||||||
|
.list_networks()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.find(|stored| stored.network_id == network_id);
|
||||||
|
// Mandatory cleanup proofs must never be silently dropped on startup.
|
||||||
|
let retirements = self
|
||||||
|
.inner
|
||||||
|
.storage
|
||||||
|
.retirements(network_id, self.inner.limits.max_retirements)
|
||||||
|
.await?;
|
||||||
|
let vip_entries = self
|
||||||
|
.inner
|
||||||
|
.storage
|
||||||
|
.vip_fields(
|
||||||
|
network_id,
|
||||||
|
self.inner
|
||||||
|
.limits
|
||||||
|
.max_vips
|
||||||
|
.saturating_mul(self.inner.limits.max_vip_fields)
|
||||||
|
.saturating_add(self.inner.limits.max_vip_tombstones),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mut vips = crate::vip::VipSet::default();
|
||||||
|
for id in vip_entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.vip.clone())
|
||||||
|
.collect::<std::collections::BTreeSet<_>>()
|
||||||
|
{
|
||||||
|
vips.merge(
|
||||||
|
network_id,
|
||||||
|
vip_entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.vip == id)
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
&self.inner.limits,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let range = self.desired_range(network_id).await?;
|
||||||
|
if let Some(range) = range {
|
||||||
|
let conflicts = self.inner.routes.overlapping_networks(network_id, range);
|
||||||
|
if !conflicts.is_empty() {
|
||||||
|
return Err(Error::NetworkRangeConflict {
|
||||||
|
network: network_id,
|
||||||
|
range,
|
||||||
|
conflicts: conflicts.into_iter().map(|(id, _)| id).collect(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.inner
|
||||||
|
.routes
|
||||||
|
.set_network(
|
||||||
|
network_id,
|
||||||
|
crate::overlay::NetworkRoutes {
|
||||||
|
range: Some(range),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|err| Error::Rejected {
|
||||||
|
reason: err.to_string(),
|
||||||
|
})?;
|
||||||
|
}
|
||||||
let mut backends = Vec::new();
|
let mut backends = Vec::new();
|
||||||
if let Some(discovery) = &self.inner.config.discovery {
|
if let Some(discovery) = &self.inner.config.discovery {
|
||||||
backends.push(discovery.clone());
|
backends.push(discovery.clone());
|
||||||
@@ -729,20 +995,22 @@ impl Agent {
|
|||||||
as Arc<dyn crate::discovery::NetworkDiscovery>,
|
as Arc<dyn crate::discovery::NetworkDiscovery>,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let stored = self
|
|
||||||
.inner
|
|
||||||
.storage
|
|
||||||
.list_networks()
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.find(|stored| stored.network_id == network_id);
|
|
||||||
let broadcast = stored.as_ref().is_none_or(|stored| stored.broadcast);
|
let broadcast = stored.as_ref().is_none_or(|stored| stored.broadcast);
|
||||||
|
let protocols =
|
||||||
|
self.effective_protocols(stored.as_ref().and_then(|s| s.protocols.as_ref()));
|
||||||
|
let plugins = self.selected_carriers(&protocols);
|
||||||
|
if !plugins.is_empty() {
|
||||||
|
self.inner.ensure_interface().await;
|
||||||
|
}
|
||||||
let exit_offer = stored.as_ref().is_some_and(|stored| stored.exit_node);
|
let exit_offer = stored.as_ref().is_some_and(|stored| stored.exit_node);
|
||||||
let exit_via = stored
|
let exit_via = stored
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|stored| stored.exit_via)
|
.and_then(|stored| stored.exit_via)
|
||||||
.and_then(|bytes| EndpointId::from_bytes(&bytes).ok());
|
.and_then(|bytes| EndpointId::from_bytes(&bytes).ok());
|
||||||
let handle = network::spawn(RuntimeParams {
|
let handle = network::spawn(RuntimeParams {
|
||||||
|
vips,
|
||||||
|
retirements,
|
||||||
broadcast,
|
broadcast,
|
||||||
exit_offer,
|
exit_offer,
|
||||||
exit_via,
|
exit_via,
|
||||||
@@ -755,7 +1023,9 @@ impl Agent {
|
|||||||
discovery,
|
discovery,
|
||||||
discovery_interval: self.inner.config.discovery_interval,
|
discovery_interval: self.inner.config.discovery_interval,
|
||||||
discovery_policy: self.inner.config.discovery_policy.clone(),
|
discovery_policy: self.inner.config.discovery_policy.clone(),
|
||||||
plugins: self.inner.config.plugins.clone(),
|
available_carriers: self.inner.config.carriers.clone(),
|
||||||
|
tunnel: self.inner.config.tunnel.clone(),
|
||||||
|
carriers: plugins.clone(),
|
||||||
routes: Arc::clone(&self.inner.routes),
|
routes: Arc::clone(&self.inner.routes),
|
||||||
interface: self.inner.interface.get().cloned(),
|
interface: self.inner.interface.get().cloned(),
|
||||||
hostname: self.inner.read_hostname(),
|
hostname: self.inner.read_hostname(),
|
||||||
@@ -763,16 +1033,18 @@ impl Agent {
|
|||||||
device_secret: self.inner.identity.signing_key(),
|
device_secret: self.inner.identity.signing_key(),
|
||||||
#[cfg(feature = "testing")]
|
#[cfg(feature = "testing")]
|
||||||
unreachable_data_peers: Arc::clone(&self.inner.config.unreachable_data_peers),
|
unreachable_data_peers: Arc::clone(&self.inner.config.unreachable_data_peers),
|
||||||
ipv4_range: range.propose,
|
ipv4_range: range,
|
||||||
ipv4_fallback: range.fallback,
|
range_changed: self.inner.range_changed.clone(),
|
||||||
range_conflict: range.conflict,
|
|
||||||
});
|
});
|
||||||
networks.insert(network_id, handle);
|
networks.insert(network_id, handle);
|
||||||
drop(networks);
|
drop(networks);
|
||||||
|
|
||||||
for plugin in &self.inner.config.plugins {
|
for plugin in &plugins {
|
||||||
plugin.on_network_activated(network_id);
|
plugin.on_network_activated(network_id);
|
||||||
}
|
}
|
||||||
|
if let Some(tunnel) = &self.inner.config.tunnel {
|
||||||
|
tunnel.on_network_activated(network_id);
|
||||||
|
}
|
||||||
|
|
||||||
let _ = self.inner.events.send(Event::NetworkActivated {
|
let _ = self.inner.events.send(Event::NetworkActivated {
|
||||||
network: network_id,
|
network: network_id,
|
||||||
@@ -786,6 +1058,30 @@ impl Agent {
|
|||||||
/// it does not remove this agent from anyone else's view of the network.
|
/// it does not remove this agent from anyone else's view of the network.
|
||||||
/// Other networks keep running.
|
/// Other networks keep running.
|
||||||
pub async fn deactivate_network(&self, network_id: NetworkId) -> Result<()> {
|
pub async fn deactivate_network(&self, network_id: NetworkId) -> Result<()> {
|
||||||
|
let _guard = self.inner.lifecycle.lock().await;
|
||||||
|
if !self.is_active(network_id).await {
|
||||||
|
return Err(Error::NetworkNotActive(network_id));
|
||||||
|
}
|
||||||
|
self.inner.storage.set_auto_start(network_id, false).await?;
|
||||||
|
self.stop_network_locked(network_id).await?;
|
||||||
|
self.resume_networks_locked().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops a configured network, including one already suspended by overlap.
|
||||||
|
/// Clears its auto-start request so it will not resume behind the user.
|
||||||
|
pub async fn pause_network(&self, network_id: NetworkId) -> Result<()> {
|
||||||
|
let _guard = self.inner.lifecycle.lock().await;
|
||||||
|
if self.inner.shutdown.is_triggered() {
|
||||||
|
return Err(Error::Stopped);
|
||||||
|
}
|
||||||
|
self.inner.storage.set_auto_start(network_id, false).await?;
|
||||||
|
if self.is_active(network_id).await {
|
||||||
|
self.stop_network_locked(network_id).await?;
|
||||||
|
}
|
||||||
|
self.resume_networks_locked().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_network_locked(&self, network_id: NetworkId) -> Result<()> {
|
||||||
let handle = {
|
let handle = {
|
||||||
let mut networks = self.inner.networks.write().await;
|
let mut networks = self.inner.networks.write().await;
|
||||||
networks
|
networks
|
||||||
@@ -793,9 +1089,12 @@ impl Agent {
|
|||||||
.ok_or(Error::NetworkNotActive(network_id))?
|
.ok_or(Error::NetworkNotActive(network_id))?
|
||||||
};
|
};
|
||||||
handle.stop().await;
|
handle.stop().await;
|
||||||
for plugin in &self.inner.config.plugins {
|
for plugin in &self.inner.config.carriers {
|
||||||
plugin.on_network_deactivated(network_id);
|
plugin.on_network_deactivated(network_id);
|
||||||
}
|
}
|
||||||
|
if let Some(tunnel) = &self.inner.config.tunnel {
|
||||||
|
tunnel.on_network_deactivated(network_id);
|
||||||
|
}
|
||||||
// The network's addresses come off the interface; the interface
|
// The network's addresses come off the interface; the interface
|
||||||
// itself stays, because the agent owns it and other networks may
|
// itself stays, because the agent owns it and other networks may
|
||||||
// still be using it.
|
// still be using it.
|
||||||
@@ -814,7 +1113,6 @@ impl Agent {
|
|||||||
interface.sync_broadcast_rules().await;
|
interface.sync_broadcast_rules().await;
|
||||||
interface.sync_exit_rules().await;
|
interface.sync_exit_rules().await;
|
||||||
}
|
}
|
||||||
self.inner.storage.set_auto_start(network_id, false).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,17 +1122,18 @@ impl Agent {
|
|||||||
/// Local and silent: nobody is told. [`Agent::leave_network`] is the one
|
/// Local and silent: nobody is told. [`Agent::leave_network`] is the one
|
||||||
/// that says goodbye first, and is what a person means by leaving.
|
/// that says goodbye first, and is what a person means by leaving.
|
||||||
pub async fn forget_network(&self, network_id: NetworkId) -> Result<()> {
|
pub async fn forget_network(&self, network_id: NetworkId) -> Result<()> {
|
||||||
|
let _guard = self.inner.lifecycle.lock().await;
|
||||||
if self.is_active(network_id).await {
|
if self.is_active(network_id).await {
|
||||||
self.deactivate_network(network_id).await?;
|
self.stop_network_locked(network_id).await?;
|
||||||
}
|
}
|
||||||
self.inner.storage.remove_network(network_id).await?;
|
self.inner.storage.remove_network(network_id).await?;
|
||||||
// Whatever a protocol kept for this network goes with it. The
|
for plugin in &self.inner.config.carriers {
|
||||||
// agent cannot know what that is, only that there is no longer any
|
|
||||||
// reason to hold it.
|
|
||||||
for plugin in &self.inner.config.plugins {
|
|
||||||
plugin.on_network_forgotten(network_id);
|
plugin.on_network_forgotten(network_id);
|
||||||
}
|
}
|
||||||
Ok(())
|
if let Some(tunnel) = &self.inner.config.tunnel {
|
||||||
|
tunnel.on_network_forgotten(network_id).await;
|
||||||
|
}
|
||||||
|
self.resume_networks_locked().await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Leaves a network: gives up what was claimed, then forgets it.
|
/// Leaves a network: gives up what was claimed, then forgets it.
|
||||||
@@ -890,6 +1189,7 @@ impl Agent {
|
|||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|stored| ConfiguredNetwork {
|
.map(|stored| ConfiguredNetwork {
|
||||||
|
protocols: self.effective_protocols(stored.protocols.as_ref()),
|
||||||
broadcast: stored.broadcast,
|
broadcast: stored.broadcast,
|
||||||
network_id: stored.network_id,
|
network_id: stored.network_id,
|
||||||
name: stored.name,
|
name: stored.name,
|
||||||
@@ -958,13 +1258,54 @@ impl Agent {
|
|||||||
let mut networks = Vec::new();
|
let mut networks = Vec::new();
|
||||||
for stored in self.inner.storage.list_networks().await? {
|
for stored in self.inner.storage.list_networks().await? {
|
||||||
if active.contains(&stored.network_id)
|
if active.contains(&stored.network_id)
|
||||||
&& let Ok(status) = self.network_status(stored.network_id).await
|
&& let Ok(mut status) = self.network_status(stored.network_id).await
|
||||||
{
|
{
|
||||||
|
status.auto_start = stored.auto_start;
|
||||||
networks.push(status);
|
networks.push(status);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
||||||
|
let range = self.desired_range(stored.network_id).await?;
|
||||||
|
let range_conflicts = self.range_conflicts(stored.network_id, range).await?;
|
||||||
|
let fields = self
|
||||||
|
.inner
|
||||||
|
.storage
|
||||||
|
.vip_fields(
|
||||||
|
stored.network_id,
|
||||||
|
self.inner
|
||||||
|
.limits
|
||||||
|
.max_vips
|
||||||
|
.saturating_mul(self.inner.limits.max_vip_fields)
|
||||||
|
.saturating_add(self.inner.limits.max_vip_tombstones),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mut service_state = crate::vip::VipSet::default();
|
||||||
|
for id in fields
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.vip.clone())
|
||||||
|
.collect::<std::collections::BTreeSet<_>>()
|
||||||
|
{
|
||||||
|
service_state.merge(
|
||||||
|
stored.network_id,
|
||||||
|
fields.iter().filter(|e| e.vip == id).cloned().collect(),
|
||||||
|
&self.inner.limits,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let vips = service_state
|
||||||
|
.configs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|config| crate::vip::VipStatus {
|
||||||
|
revision: service_state.revision(&config.id),
|
||||||
|
config,
|
||||||
|
address: None,
|
||||||
|
error: Some("network is inactive".into()),
|
||||||
|
listeners: vec![],
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
networks.push(NetworkStatus {
|
networks.push(NetworkStatus {
|
||||||
|
vips,
|
||||||
|
protocols: self.effective_protocols(stored.protocols.as_ref()),
|
||||||
|
auto_start: stored.auto_start,
|
||||||
broadcast: stored.broadcast,
|
broadcast: stored.broadcast,
|
||||||
exit_offer: stored.exit_node,
|
exit_offer: stored.exit_node,
|
||||||
exit_via: stored
|
exit_via: stored
|
||||||
@@ -978,11 +1319,13 @@ impl Agent {
|
|||||||
state: NetworkState::Inactive,
|
state: NetworkState::Inactive,
|
||||||
peers: Vec::new(),
|
peers: Vec::new(),
|
||||||
candidates: Vec::new(),
|
candidates: Vec::new(),
|
||||||
// An inactive network has no runtime to ask; the roster and
|
// The range is available from signed state or the deterministic
|
||||||
// the range come from one. Empty, not invented.
|
// default even while the network has no runtime.
|
||||||
members: Vec::new(),
|
members: Vec::new(),
|
||||||
range: None,
|
hostname_conflict: None,
|
||||||
range_conflict: None,
|
range,
|
||||||
|
range_conflict: range_conflicts.first().map(|conflict| conflict.range),
|
||||||
|
range_conflicts,
|
||||||
metrics: NetworkMetrics::default(),
|
metrics: NetworkMetrics::default(),
|
||||||
relay: Default::default(),
|
relay: Default::default(),
|
||||||
});
|
});
|
||||||
@@ -1038,6 +1381,7 @@ impl Agent {
|
|||||||
/// After this returns, the state directory can be opened by another agent.
|
/// After this returns, the state directory can be opened by another agent.
|
||||||
pub async fn shutdown(&self) {
|
pub async fn shutdown(&self) {
|
||||||
self.inner.shutdown.trigger();
|
self.inner.shutdown.trigger();
|
||||||
|
let lifecycle_guard = self.inner.lifecycle.lock().await;
|
||||||
|
|
||||||
let handles: Vec<NetworkHandle> = {
|
let handles: Vec<NetworkHandle> = {
|
||||||
let mut networks = self.inner.networks.write().await;
|
let mut networks = self.inner.networks.write().await;
|
||||||
@@ -1047,6 +1391,7 @@ impl Agent {
|
|||||||
handle.stop().await;
|
handle.stop().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drop(lifecycle_guard);
|
||||||
if let Some(dht) = &self.inner.config.dht {
|
if let Some(dht) = &self.inner.config.dht {
|
||||||
dht.shutdown().await;
|
dht.shutdown().await;
|
||||||
}
|
}
|
||||||
@@ -1063,6 +1408,7 @@ impl Agent {
|
|||||||
for (what, handle) in [
|
for (what, handle) in [
|
||||||
("inbound connections", &self.inner.accept_task),
|
("inbound connections", &self.inner.accept_task),
|
||||||
("plugin requests", &self.inner.plugin_task),
|
("plugin requests", &self.inner.plugin_task),
|
||||||
|
("range conflicts", &self.inner.range_task),
|
||||||
] {
|
] {
|
||||||
let task = handle.lock().ok().and_then(|mut guard| guard.take());
|
let task = handle.lock().ok().and_then(|mut guard| guard.take());
|
||||||
if let Some(task) = task {
|
if let Some(task) = task {
|
||||||
@@ -1078,7 +1424,7 @@ impl Agent {
|
|||||||
|
|
||||||
// Plugins remove whatever system objects they created. A plugin that
|
// Plugins remove whatever system objects they created. A plugin that
|
||||||
// misbehaves here must not hold up the agent, so this is bounded.
|
// misbehaves here must not hold up the agent, so this is bounded.
|
||||||
for plugin in &self.inner.config.plugins {
|
for plugin in &self.inner.config.carriers {
|
||||||
if tokio::time::timeout(PLUGIN_SHUTDOWN_GRACE, plugin.shutdown())
|
if tokio::time::timeout(PLUGIN_SHUTDOWN_GRACE, plugin.shutdown())
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
@@ -1090,6 +1436,14 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(tunnel) = &self.inner.config.tunnel
|
||||||
|
&& tokio::time::timeout(PLUGIN_SHUTDOWN_GRACE, tunnel.shutdown())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
tracing::warn!("data encryption engine did not shut down in time");
|
||||||
|
}
|
||||||
|
|
||||||
// Release the directory so another instance can claim it right away.
|
// Release the directory so another instance can claim it right away.
|
||||||
self.inner.storage.release_ownership_lock();
|
self.inner.storage.release_ownership_lock();
|
||||||
}
|
}
|
||||||
@@ -1106,16 +1460,44 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a network may propose as its overlay range, and what it cannot have.
|
/// Processes coalesced conflict notifications without blocking a network actor.
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
async fn range_conflict_loop(inner: Weak<Inner>, mut updates: watch::Receiver<()>) {
|
||||||
struct RangePlan {
|
loop {
|
||||||
/// Proposed straight away: the configured range, when it is free here.
|
let Some(owner) = inner.upgrade() else {
|
||||||
propose: Option<Ipv4Range>,
|
return;
|
||||||
/// Proposed after a moment, when the configured range is another
|
};
|
||||||
/// network's: the range derived from this network's own id.
|
let shutdown = owner.shutdown.clone();
|
||||||
fallback: Option<Ipv4Range>,
|
drop(owner);
|
||||||
/// The configured range, when this network cannot have it.
|
tokio::select! {
|
||||||
conflict: Option<Ipv4Range>,
|
_ = shutdown.wait() => return,
|
||||||
|
result = updates.changed() => if result.is_err() { return; },
|
||||||
|
}
|
||||||
|
let Some(inner) = inner.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let agent = Agent { inner };
|
||||||
|
let _guard = agent.inner.lifecycle.lock().await;
|
||||||
|
if agent.inner.shutdown.is_triggered() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let blocked: Vec<_> = agent
|
||||||
|
.inner
|
||||||
|
.networks
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, h)| h.range_blocked.load(std::sync::atomic::Ordering::Acquire))
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect();
|
||||||
|
for id in blocked {
|
||||||
|
if let Err(err) = agent.stop_network_locked(id).await {
|
||||||
|
tracing::warn!(%err, "cannot suspend overlapping network");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(err) = agent.resume_networks_locked().await {
|
||||||
|
tracing::warn!(%err, "cannot resume suspended networks");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long a release is given to reach the sessions it was queued on.
|
/// How long a release is given to reach the sessions it was queued on.
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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<_>>(),
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
kind: String,
|
||||||
|
link: SharedLink,
|
||||||
|
reader: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>,
|
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>>,
|
graph: HashMap<PeerId, Vec<PeerId>>,
|
||||||
members: HashSet<PeerId>,
|
members: HashSet<PeerId>,
|
||||||
raw: HashMap<PeerId, SharedLink>,
|
raw: HashMap<PeerId, Vec<RawLink>>,
|
||||||
readers: HashMap<PeerId, JoinHandle<()>>,
|
links: HashMap<(PeerId, String), Arc<PeerLink>>,
|
||||||
links: HashMap<PeerId, 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
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Protocol {
|
|
||||||
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(|(_, link)| !link.is_closed())
|
.filter(|(_, links)| links.iter().any(|raw| !raw.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,
|
||||||
|
state: Mutex::new(State {
|
||||||
|
plane: Arc::new(Plane {
|
||||||
local: *local.as_bytes(),
|
local: *local.as_bytes(),
|
||||||
protocols: Mutex::default(),
|
table: ArcSwap::from_pointee(ForwardingTable::default()),
|
||||||
tally: Arc::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,43 +419,40 @@ 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) = raw.recv().await {
|
while let Some(frame) = reader_link.recv().await {
|
||||||
plane.receive(id, frame);
|
plane.receive(id, frame);
|
||||||
batch += 1;
|
batch += 1;
|
||||||
if batch == 64 {
|
if batch == 64 {
|
||||||
@@ -366,31 +461,64 @@ impl RelayHub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Revokes all links and topology involving a departed authenticated peer.
|
/// Retires data channels without removing an authenticated peer's topology.
|
||||||
pub fn remove_peer(&self, peer: EndpointId) {
|
pub fn clear_peer_links(&self, peer: EndpointId) {
|
||||||
for state in self.protocols().values_mut() {
|
let mut state = self.state();
|
||||||
state.raw.remove(peer.as_bytes());
|
if let Some(links) = state.raw.remove(peer.as_bytes()) {
|
||||||
if let Some(task) = state.readers.remove(peer.as_bytes()) {
|
for raw in links {
|
||||||
task.abort();
|
raw.link.close();
|
||||||
|
raw.reader.abort();
|
||||||
}
|
}
|
||||||
if let Some(link) = state.links.remove(peer.as_bytes()) {
|
}
|
||||||
|
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();
|
link.close();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
state.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revokes all links and topology involving a departed authenticated peer.
|
||||||
|
pub fn remove_peer(&self, peer: EndpointId) {
|
||||||
|
self.clear_peer_links(peer);
|
||||||
|
let mut state = self.state();
|
||||||
state.members.remove(peer.as_bytes());
|
state.members.remove(peer.as_bytes());
|
||||||
state.graph.remove(peer.as_bytes());
|
state.graph.remove(peer.as_bytes());
|
||||||
for neighbors in state.graph.values_mut() {
|
for neighbors in state.graph.values_mut() {
|
||||||
@@ -398,11 +526,19 @@ impl RelayHub {
|
|||||||
}
|
}
|
||||||
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,
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ pub trait ExitHostRules: Send + Sync + std::fmt::Debug + 'static {
|
|||||||
/// 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
|
/// 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
|
/// 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.
|
/// traffic from the default route by user id (Windows). The default is no.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -328,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() {
|
||||||
@@ -350,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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -48,9 +49,10 @@ 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::{
|
use super::linux::{
|
||||||
LinuxHostRules, Ran, Run, comment_supported, failure, locked, run_iptables, same_rule,
|
LinuxHostRules, Ran, Run, comment_supported, failure, locked, run_firewall, run_iptables,
|
||||||
split_rule_line,
|
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")];
|
||||||
@@ -377,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
|
||||||
@@ -417,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")
|
||||||
@@ -426,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();
|
||||||
@@ -467,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 {
|
||||||
@@ -482,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");
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>>,
|
||||||
@@ -200,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(
|
||||||
@@ -221,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);
|
||||||
@@ -231,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)
|
||||||
@@ -279,9 +330,11 @@ 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),
|
||||||
@@ -397,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;
|
||||||
@@ -708,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,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 {
|
||||||
@@ -786,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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -365,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()
|
||||||
@@ -510,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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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,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]
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -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.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Addresses(
|
||||||
|
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(
|
||||||
let peer_noise_pubkey = PublicKey::from(announcement.noise_public);
|
network,
|
||||||
let cipher = Arc::new(PeerCipher::new(
|
|
||||||
self.local_id,
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The complete `tcp-tls` plugin.
|
|
||||||
pub type TcpTlsPlugin = GenericTunnelPlugin<TcpTlsCodec>;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_candidate_interface_filters_overlay_and_bridges() {
|
|
||||||
let mut iface = netdev::Interface::dummy();
|
|
||||||
iface.flags = 1; // UP
|
|
||||||
|
|
||||||
// Standard Ethernet - should pass
|
|
||||||
iface.name = "eth0".to_string();
|
|
||||||
assert!(is_candidate_interface(&iface));
|
|
||||||
|
|
||||||
// Windows Ethernet with GUID name - should pass
|
|
||||||
iface.name = "{B6149D2C-1234-5678-9ABC-DEF012345678}".to_string();
|
|
||||||
iface.friendly_name = Some("Ethernet".to_string());
|
|
||||||
iface.description = Some("Intel(R) Ethernet Connection".to_string());
|
|
||||||
assert!(is_candidate_interface(&iface));
|
|
||||||
|
|
||||||
// Linux tsun0 overlay - must be filtered
|
|
||||||
iface.name = "tsun0".to_string();
|
|
||||||
iface.friendly_name = None;
|
|
||||||
iface.description = None;
|
|
||||||
assert!(!is_candidate_interface(&iface));
|
|
||||||
|
|
||||||
// Windows Wintun overlay (GUID name with tsun0 friendly name) - must be filtered!
|
|
||||||
iface.name = "{72D85D76-F1A5-42AC-BC49-2169527CDDDF}".to_string();
|
|
||||||
iface.friendly_name = Some("tsun0".to_string());
|
|
||||||
iface.description = Some("Wintun Userspace Tunnel".to_string());
|
|
||||||
assert!(!is_candidate_interface(&iface));
|
|
||||||
|
|
||||||
// Windows Wintun overlay with description containing Wintun - must be filtered!
|
|
||||||
iface.friendly_name = Some("Local Area Connection 2".to_string());
|
|
||||||
iface.description = Some("Wintun Userspace Tunnel".to_string());
|
|
||||||
assert!(!is_candidate_interface(&iface));
|
|
||||||
|
|
||||||
// Docker bridge - must be filtered
|
|
||||||
iface.name = "docker0".to_string();
|
|
||||||
iface.friendly_name = None;
|
|
||||||
iface.description = None;
|
|
||||||
assert!(!is_candidate_interface(&iface));
|
|
||||||
|
|
||||||
// Docker network bridge br-* - must be filtered
|
|
||||||
iface.name = "br-4f6d3a9b1c2e".to_string();
|
|
||||||
assert!(!is_candidate_interface(&iface));
|
|
||||||
|
|
||||||
// Virtual ethernet veth* - must be filtered
|
|
||||||
iface.name = "veth12345".to_string();
|
|
||||||
assert!(!is_candidate_interface(&iface));
|
|
||||||
|
|
||||||
// Libvirt NAT bridge virbr* - must be filtered
|
|
||||||
iface.name = "virbr0".to_string();
|
|
||||||
assert!(!is_candidate_interface(&iface));
|
|
||||||
|
|
||||||
// CNI bridge - must be filtered
|
|
||||||
iface.name = "cni0".to_string();
|
|
||||||
assert!(!is_candidate_interface(&iface));
|
|
||||||
|
|
||||||
// Hyper-V / WSL vEthernet switch on Windows - must be filtered
|
|
||||||
iface.name = "{A1B2C3D4-E5F6-7890-1234-567890ABCDEF}".to_string();
|
|
||||||
iface.friendly_name = Some("vEthernet (WSL)".to_string());
|
|
||||||
iface.description = Some("Hyper-V Virtual Ethernet Adapter".to_string());
|
|
||||||
assert!(!is_candidate_interface(&iface));
|
|
||||||
|
|
||||||
// Down interface - must be filtered
|
|
||||||
let mut down_iface = netdev::Interface::dummy();
|
|
||||||
down_iface.flags = 0; // Not UP
|
|
||||||
down_iface.name = "eth0".to_string();
|
|
||||||
assert!(!is_candidate_interface(&down_iface));
|
|
||||||
}
|
}
|
||||||
|
fn bounded_ips<'de, D: serde::Deserializer<'de>>(
|
||||||
#[test]
|
deserializer: D,
|
||||||
fn test_is_candidate_ip() {
|
) -> Result<Vec<std::net::IpAddr>, D::Error> {
|
||||||
// Valid LAN IP
|
struct Visitor;
|
||||||
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||||
192, 168, 1, 117
|
type Value = Vec<std::net::IpAddr>;
|
||||||
))));
|
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
// Valid Tailscale IP
|
f.write_str("at most 32 IP candidates")
|
||||||
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)));
|
|
||||||
}
|
}
|
||||||
|
fn visit_seq<A: serde::de::SeqAccess<'de>>(
|
||||||
#[test]
|
self,
|
||||||
fn test_ip_priority_ordering() {
|
mut seq: A,
|
||||||
let mut iface_lan = netdev::Interface::dummy();
|
) -> Result<Self::Value, A::Error> {
|
||||||
iface_lan.flags = 1;
|
if seq.size_hint().is_some_and(|n| n > 32) {
|
||||||
iface_lan.gateway = Some(netdev::net::device::NetworkDevice::new());
|
return Err(serde::de::Error::custom("too many IP candidates"));
|
||||||
|
}
|
||||||
let mut iface_vpn = netdev::Interface::dummy();
|
let mut ips = Vec::new();
|
||||||
iface_vpn.flags = 1;
|
while let Some(ip) = seq.next_element()? {
|
||||||
iface_vpn.name = "tailscale0".to_string();
|
if ips.len() == 32 {
|
||||||
|
return Err(serde::de::Error::custom("too many IP candidates"));
|
||||||
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));
|
ips.push(ip);
|
||||||
|
}
|
||||||
let p_lan = ip_priority(&iface_lan, lan_ip);
|
Ok(ips)
|
||||||
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)
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
@@ -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" };
|
||||||
|
if report.suspended.is_empty() {
|
||||||
format!("{} {}", state, report.name)
|
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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -116,20 +166,21 @@ impl App {
|
|||||||
.with_title(devices::title(network))
|
.with_title(devices::title(network))
|
||||||
.with_app_id(crate::icon::APP_ID)
|
.with_app_id(crate::icon::APP_ID)
|
||||||
.with_icon(crate::icon::window())
|
.with_icon(crate::icon::window())
|
||||||
.with_inner_size([940.0, 540.0])
|
.with_inner_size([1440.0, 640.0])
|
||||||
.with_min_inner_size([480.0, 320.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,
|
||||||
@@ -142,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"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+865
-128
File diff suppressed because it is too large.
Load diff
@@ -109,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,
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ fn colour_of(health: Health) -> [u8; 3] {
|
|||||||
match health {
|
match health {
|
||||||
Health::Disconnected => DARK_RED,
|
Health::Disconnected => DARK_RED,
|
||||||
Health::Connected => BLUE,
|
Health::Connected => BLUE,
|
||||||
|
Health::RangeConflict => [0xd9, 0x91, 0x20],
|
||||||
Health::ExitNode => GREEN,
|
Health::ExitNode => GREEN,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ mod exit;
|
|||||||
mod format;
|
mod format;
|
||||||
mod icon;
|
mod icon;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
mod table;
|
||||||
mod tray;
|
mod tray;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
mod vip;
|
||||||
|
mod vip_status;
|
||||||
mod watch;
|
mod watch;
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
|||||||
@@ -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))
|
|
||||||
.or_default()
|
|
||||||
.sample(now, packets, bytes);
|
|
||||||
}
|
}
|
||||||
self.series
|
let series = self.series.entry(key).or_default();
|
||||||
.entry(net_key(&network.network_id))
|
series.sample_peer(now, peer.into());
|
||||||
.or_default()
|
packets_rate += series.rate(Unit::Packets);
|
||||||
.sample(now, net_packets, net_bytes);
|
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
|
||||||
|
.entry(key)
|
||||||
|
.or_default()
|
||||||
|
.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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,16 +249,34 @@ 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| {
|
||||||
|
if !network.active && !network.range_conflicts.is_empty() {
|
||||||
|
if ui.button("Switch to this network").clicked() {
|
||||||
|
agent.send(Command::SetActive { network_id: network.network_id.clone(), active: true });
|
||||||
|
}
|
||||||
|
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;
|
let mut active = network.active;
|
||||||
if ui.checkbox(&mut active, "active").changed() {
|
if ui.checkbox(&mut active, "active").changed() {
|
||||||
agent.send(Command::SetActive {
|
agent.send(Command::SetActive {
|
||||||
@@ -260,6 +284,7 @@ fn draw_network(
|
|||||||
active,
|
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() {
|
||||||
agent.send(Command::SetBroadcast {
|
agent.send(Command::SetBroadcast {
|
||||||
@@ -328,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) {
|
||||||
|
|||||||
@@ -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(¤t.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ pub(crate) enum Health {
|
|||||||
Disconnected,
|
Disconnected,
|
||||||
/// Connected, nothing out of the ordinary.
|
/// Connected, nothing out of the ordinary.
|
||||||
Connected,
|
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.
|
/// This device's traffic leaves through an exit node, and that works.
|
||||||
ExitNode,
|
ExitNode,
|
||||||
}
|
}
|
||||||
@@ -33,6 +35,13 @@ impl Health {
|
|||||||
let Some(report) = report else {
|
let Some(report) = report else {
|
||||||
return Self::Disconnected;
|
return Self::Disconnected;
|
||||||
};
|
};
|
||||||
|
if report
|
||||||
|
.networks
|
||||||
|
.iter()
|
||||||
|
.any(|network| network.auto_start && !network.range_conflicts.is_empty())
|
||||||
|
{
|
||||||
|
return Self::RangeConflict;
|
||||||
|
}
|
||||||
if !report
|
if !report
|
||||||
.networks
|
.networks
|
||||||
.iter()
|
.iter()
|
||||||
@@ -56,6 +65,7 @@ impl Health {
|
|||||||
match self {
|
match self {
|
||||||
Self::Disconnected => "tsunagi: not connected",
|
Self::Disconnected => "tsunagi: not connected",
|
||||||
Self::Connected => "tsunagi: connected",
|
Self::Connected => "tsunagi: connected",
|
||||||
|
Self::RangeConflict => "tsunagi: network range conflict; open to switch networks",
|
||||||
Self::ExitNode => "tsunagi: using an exit node",
|
Self::ExitNode => "tsunagi: using an exit node",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,6 +118,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn no_agent_or_nobody_connected_is_the_red_state() {
|
fn no_agent_or_nobody_connected_is_the_red_state() {
|
||||||
assert_eq!(Health::of(None), Health::Disconnected);
|
assert_eq!(Health::of(None), Health::Disconnected);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,7 +298,14 @@ 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();
|
||||||
|
let store = tokio::task::spawn_blocking(move || {
|
||||||
|
let store = WgKeyStore::open(path)?;
|
||||||
|
for path in legacy {
|
||||||
|
store.import_legacy(&path)?;
|
||||||
|
}
|
||||||
|
Ok::<_, PluginError>(store)
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|err| PluginError::Other(format!("key store task failed: {err}")))??;
|
.map_err(|err| PluginError::Other(format!("key store task failed: {err}")))??;
|
||||||
|
|
||||||
@@ -792,15 +798,27 @@ 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, ()> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
//! What a pure WireGuard peer tells the network about itself.
|
|
||||||
//!
|
|
||||||
//! Exchanged via [`tsunagi::dataplane::PluginCapability`]. The announcement
|
|
||||||
//! carries the WireGuard public key, the listening UDP port, and candidate IP addresses
|
|
||||||
//! for direct UDP packet transport.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use tsunagi::dataplane::PluginError;
|
|
||||||
use tsunagi::identity::NetworkId;
|
|
||||||
|
|
||||||
use crate::keys::WgPublicKey;
|
|
||||||
|
|
||||||
/// The wire protocol identifier for pure WireGuard.
|
|
||||||
pub const WG_PROTOCOL: &str = "wg";
|
|
||||||
|
|
||||||
/// Wire version of the `wg` announcement format.
|
|
||||||
pub const ANNOUNCEMENT_VERSION: u16 = 1;
|
|
||||||
|
|
||||||
/// Wire version peers compare during capability exchange.
|
|
||||||
///
|
|
||||||
/// This is the same as [`ANNOUNCEMENT_VERSION`]; keeping a separate name
|
|
||||||
/// makes the intent explicit in the CLI protocol registry.
|
|
||||||
pub const WG_WIRE_VERSION: u16 = ANNOUNCEMENT_VERSION;
|
|
||||||
|
|
||||||
/// Maximum number of IP candidates allowed in an announcement.
|
|
||||||
pub const MAX_CANDIDATE_ADDRS: usize = 32;
|
|
||||||
|
|
||||||
/// What one participant advertises for the pure WireGuard UDP data plane.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct WgAnnouncement {
|
|
||||||
/// Announcement format version.
|
|
||||||
pub version: u16,
|
|
||||||
/// The peer's WireGuard public key.
|
|
||||||
pub public_key: [u8; 32],
|
|
||||||
/// The network this key is for.
|
|
||||||
pub network: [u8; 32],
|
|
||||||
/// The UDP port the peer is listening on for WireGuard packets.
|
|
||||||
pub port: u16,
|
|
||||||
/// Candidate IP addresses where this peer can be reached on `port`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub addrs: Vec<std::net::IpAddr>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A peer announcement that has been validated against a specific network.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ValidatedAnnouncement {
|
|
||||||
/// The peer's WireGuard public key.
|
|
||||||
pub public_key: WgPublicKey,
|
|
||||||
/// The UDP port the peer listens on.
|
|
||||||
pub port: u16,
|
|
||||||
/// Validated candidate IP addresses.
|
|
||||||
pub addrs: Vec<std::net::IpAddr>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WgAnnouncement {
|
|
||||||
/// Builds this agent's announcement.
|
|
||||||
pub fn new(
|
|
||||||
network: NetworkId,
|
|
||||||
public_key: &WgPublicKey,
|
|
||||||
port: u16,
|
|
||||||
addrs: Vec<std::net::IpAddr>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
version: ANNOUNCEMENT_VERSION,
|
|
||||||
public_key: *public_key.as_bytes(),
|
|
||||||
network: *network.as_bytes(),
|
|
||||||
port,
|
|
||||||
addrs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encodes the announcement into the opaque capability payload.
|
|
||||||
pub fn encode(&self) -> Result<Vec<u8>, PluginError> {
|
|
||||||
postcard::to_allocvec(self)
|
|
||||||
.map_err(|err| PluginError::Other(format!("cannot encode announcement: {err}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes and validates a payload received from a peer.
|
|
||||||
pub fn decode_and_validate(
|
|
||||||
payload: &[u8],
|
|
||||||
network: NetworkId,
|
|
||||||
local_key: &WgPublicKey,
|
|
||||||
) -> Result<ValidatedAnnouncement, PluginError> {
|
|
||||||
let announcement: Self = postcard::from_bytes(payload)
|
|
||||||
.map_err(|_| PluginError::Rejected("malformed WireGuard announcement".into()))?;
|
|
||||||
announcement.validate(network, local_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate(
|
|
||||||
self,
|
|
||||||
network: NetworkId,
|
|
||||||
local_key: &WgPublicKey,
|
|
||||||
) -> Result<ValidatedAnnouncement, PluginError> {
|
|
||||||
if self.version != ANNOUNCEMENT_VERSION {
|
|
||||||
return Err(PluginError::Rejected(format!(
|
|
||||||
"peer speaks WireGuard announcement version {} but this build speaks \
|
|
||||||
{ANNOUNCEMENT_VERSION}; one of the two needs updating",
|
|
||||||
self.version
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let public_key = WgPublicKey::from_bytes(self.public_key);
|
|
||||||
if public_key.is_zero() {
|
|
||||||
return Err(PluginError::Rejected(
|
|
||||||
"WireGuard public key is all zeroes".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if &public_key == local_key {
|
|
||||||
return Err(PluginError::Rejected(
|
|
||||||
"peer announced this agent's own WireGuard key".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if self.network != *network.as_bytes() {
|
|
||||||
return Err(PluginError::Rejected(
|
|
||||||
"announcement is for a different network".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if self.port == 0 {
|
|
||||||
return Err(PluginError::Rejected("WireGuard port cannot be 0".into()));
|
|
||||||
}
|
|
||||||
if self.addrs.len() > MAX_CANDIDATE_ADDRS {
|
|
||||||
return Err(PluginError::Rejected(format!(
|
|
||||||
"too many candidate addresses: {} > {MAX_CANDIDATE_ADDRS}",
|
|
||||||
self.addrs.len()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut valid_addrs = Vec::new();
|
|
||||||
for addr in self.addrs {
|
|
||||||
if !addr.is_unspecified() && !valid_addrs.contains(&addr) {
|
|
||||||
valid_addrs.push(addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ValidatedAnnouncement {
|
|
||||||
public_key,
|
|
||||||
port: self.port,
|
|
||||||
addrs: valid_addrs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,686 +0,0 @@
|
|||||||
//! Userspace WireGuard device implementation over direct UDP links.
|
|
||||||
//!
|
|
||||||
//! Uses [`boringtun::noise::Tunn`] state machine to encrypt and decrypt
|
|
||||||
//! standard WireGuard protocol packets directly over a [`SharedLink`].
|
|
||||||
|
|
||||||
use std::collections::{HashMap, VecDeque};
|
|
||||||
use std::net::Ipv4Addr;
|
|
||||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex, RwLock};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use boringtun::noise::{Tunn, TunnResult};
|
|
||||||
use bytes::Bytes;
|
|
||||||
use iroh::EndpointId;
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
|
|
||||||
use tsunagi::dataplane::routing::{FlowId, flow::ip_flow};
|
|
||||||
use tsunagi::dataplane::transport::{SharedLink, TransportError};
|
|
||||||
use tsunagi::dataplane::{PacketSink, PluginContext, PluginError};
|
|
||||||
use tsunagi::identity::NetworkId;
|
|
||||||
|
|
||||||
use crate::keys::{WgPublicKey, WgSecretKey};
|
|
||||||
use crate::transport::WireguardTransport;
|
|
||||||
|
|
||||||
const TIMER_INTERVAL: Duration = Duration::from_millis(250);
|
|
||||||
const SCRATCH: usize = 4096;
|
|
||||||
|
|
||||||
/// Maximum consecutive rekey timeout retransmissions before declaring a tunnel dead.
|
|
||||||
pub const MAX_REKEY_TIMEOUTS: u32 = 3;
|
|
||||||
|
|
||||||
/// Overall maximum duration trying to establish or rekey a handshake before declaring it dead.
|
|
||||||
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15);
|
|
||||||
|
|
||||||
struct FlowTunnel {
|
|
||||||
tunn: Tunn,
|
|
||||||
queued: VecDeque<FlowId>,
|
|
||||||
handshake_started_at: Option<std::time::Instant>,
|
|
||||||
rekey_timeouts: u32,
|
|
||||||
failed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_data(result: &TunnResult<'_>) -> bool {
|
|
||||||
matches!(result, TunnResult::WriteToNetwork(bytes) if bytes.starts_with(&[4, 0, 0, 0]))
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FlowTunnel {
|
|
||||||
fn encapsulate<'a>(
|
|
||||||
&mut self,
|
|
||||||
packet: &[u8],
|
|
||||||
scratch: &'a mut [u8],
|
|
||||||
flow: FlowId,
|
|
||||||
) -> (TunnResult<'a>, FlowId) {
|
|
||||||
let result = self.tunn.encapsulate(packet, scratch);
|
|
||||||
if is_data(&result) {
|
|
||||||
return (result, flow);
|
|
||||||
}
|
|
||||||
if matches!(result, TunnResult::WriteToNetwork(ref b) if !b.is_empty() && b[0] == 1)
|
|
||||||
&& self.handshake_started_at.is_none()
|
|
||||||
{
|
|
||||||
self.handshake_started_at = Some(std::time::Instant::now());
|
|
||||||
self.rekey_timeouts = 0;
|
|
||||||
self.failed = false;
|
|
||||||
}
|
|
||||||
if self.queued.len() < 256 {
|
|
||||||
self.queued.push_back(flow);
|
|
||||||
}
|
|
||||||
(result, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decapsulate<'a>(
|
|
||||||
&mut self,
|
|
||||||
packet: &[u8],
|
|
||||||
scratch: &'a mut [u8],
|
|
||||||
) -> (TunnResult<'a>, FlowId) {
|
|
||||||
let result = self.tunn.decapsulate(None, packet, scratch);
|
|
||||||
if self.tunn.time_since_last_handshake().is_some() {
|
|
||||||
self.handshake_started_at = None;
|
|
||||||
self.rekey_timeouts = 0;
|
|
||||||
self.failed = false;
|
|
||||||
}
|
|
||||||
let mut flow = 0;
|
|
||||||
if packet.is_empty() {
|
|
||||||
if is_data(&result) {
|
|
||||||
flow = self.queued.pop_front().unwrap_or(0);
|
|
||||||
} else if let Some(pending) = self.queued.pop_front() {
|
|
||||||
self.queued.push_back(pending);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(result, flow)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_timers<'a>(&mut self, scratch: &'a mut [u8]) -> (TunnResult<'a>, bool) {
|
|
||||||
if self.failed {
|
|
||||||
return (TunnResult::Done, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = self.tunn.update_timers(scratch);
|
|
||||||
if matches!(
|
|
||||||
result,
|
|
||||||
TunnResult::Err(boringtun::noise::errors::WireGuardError::ConnectionExpired)
|
|
||||||
) || self.tunn.is_expired()
|
|
||||||
{
|
|
||||||
self.failed = true;
|
|
||||||
self.queued.clear();
|
|
||||||
self.handshake_started_at = None;
|
|
||||||
self.rekey_timeouts = 0;
|
|
||||||
return (result, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let TunnResult::WriteToNetwork(ref bytes) = result
|
|
||||||
&& !bytes.is_empty()
|
|
||||||
&& bytes[0] == 1
|
|
||||||
&& self.handshake_started_at.is_none()
|
|
||||||
{
|
|
||||||
self.handshake_started_at = Some(std::time::Instant::now());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(started) = self.handshake_started_at
|
|
||||||
&& started.elapsed() >= HANDSHAKE_TIMEOUT
|
|
||||||
{
|
|
||||||
self.failed = true;
|
|
||||||
self.queued.clear();
|
|
||||||
self.handshake_started_at = None;
|
|
||||||
self.rekey_timeouts = 0;
|
|
||||||
return (result, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
(result, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct PeerCounters {
|
|
||||||
tx_packets: AtomicU64,
|
|
||||||
tx_bytes: AtomicU64,
|
|
||||||
rx_packets: AtomicU64,
|
|
||||||
rx_bytes: AtomicU64,
|
|
||||||
dropped_wrong_source: AtomicU64,
|
|
||||||
dropped_oversize: AtomicU64,
|
|
||||||
dropped_no_session: AtomicU64,
|
|
||||||
protocol_errors: AtomicU64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A snapshot of one peer's tunnel counters.
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
||||||
pub struct PeerStats {
|
|
||||||
/// Plaintext packets encrypted and sent to this peer.
|
|
||||||
pub tx_packets: u64,
|
|
||||||
/// Plaintext bytes encrypted and sent to this peer.
|
|
||||||
pub tx_bytes: u64,
|
|
||||||
/// Plaintext packets decrypted from this peer and given to the OS.
|
|
||||||
pub rx_packets: u64,
|
|
||||||
/// Plaintext bytes decrypted from this peer and given to the OS.
|
|
||||||
pub rx_bytes: u64,
|
|
||||||
/// Packets dropped because their source was not this peer's address.
|
|
||||||
pub dropped_wrong_source: u64,
|
|
||||||
/// Packets dropped because they did not fit in one link datagram.
|
|
||||||
pub dropped_oversize: u64,
|
|
||||||
/// Packets dropped because there was no session to encrypt with yet.
|
|
||||||
pub dropped_no_session: u64,
|
|
||||||
/// WireGuard protocol errors, including packets that failed to decrypt.
|
|
||||||
pub protocol_errors: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a peer's tunnel has completed a handshake.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct PeerHealth {
|
|
||||||
/// Time since the last successful WireGuard handshake.
|
|
||||||
pub since_handshake: Option<Duration>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PeerHealth {
|
|
||||||
/// Whether the tunnel has ever completed a handshake.
|
|
||||||
pub fn is_up(&self) -> bool {
|
|
||||||
self.since_handshake.is_some()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Peer {
|
|
||||||
endpoint_id: EndpointId,
|
|
||||||
public_key: WgPublicKey,
|
|
||||||
overlay_v4: Mutex<Option<Ipv4Addr>>,
|
|
||||||
tunn: Mutex<FlowTunnel>,
|
|
||||||
link: SharedLink,
|
|
||||||
counters: Arc<PeerCounters>,
|
|
||||||
task: Mutex<Option<JoinHandle<()>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for Peer {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.debug_struct("Peer")
|
|
||||||
.field("peer", &self.endpoint_id.fmt_short().to_string())
|
|
||||||
.field("public_key", &self.public_key)
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for Peer {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if let Ok(mut guard) = self.task.lock()
|
|
||||||
&& let Some(task) = guard.take()
|
|
||||||
{
|
|
||||||
task.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Peer {
|
|
||||||
fn stats(&self) -> PeerStats {
|
|
||||||
PeerStats {
|
|
||||||
tx_packets: self.counters.tx_packets.load(Ordering::Relaxed),
|
|
||||||
tx_bytes: self.counters.tx_bytes.load(Ordering::Relaxed),
|
|
||||||
rx_packets: self.counters.rx_packets.load(Ordering::Relaxed),
|
|
||||||
rx_bytes: self.counters.rx_bytes.load(Ordering::Relaxed),
|
|
||||||
dropped_wrong_source: self.counters.dropped_wrong_source.load(Ordering::Relaxed),
|
|
||||||
dropped_oversize: self.counters.dropped_oversize.load(Ordering::Relaxed),
|
|
||||||
dropped_no_session: self.counters.dropped_no_session.load(Ordering::Relaxed),
|
|
||||||
protocol_errors: self.counters.protocol_errors.load(Ordering::Relaxed),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn health(&self) -> PeerHealth {
|
|
||||||
let guard = match self.tunn.lock() {
|
|
||||||
Ok(guard) => guard,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
PeerHealth {
|
|
||||||
since_handshake: guard.tunn.time_since_last_handshake(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a peer's tunnel looks like from outside.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct PeerSummary {
|
|
||||||
/// The peer's control plane identity.
|
|
||||||
pub endpoint_id: EndpointId,
|
|
||||||
/// The peer's WireGuard public key.
|
|
||||||
pub public_key: WgPublicKey,
|
|
||||||
/// The overlay address it holds, once agreed.
|
|
||||||
pub overlay_address_v4: Option<Ipv4Addr>,
|
|
||||||
/// Whether the tunnel has handshaken.
|
|
||||||
pub health: PeerHealth,
|
|
||||||
/// Traffic counters.
|
|
||||||
pub stats: PeerStats,
|
|
||||||
/// What the transport reports about the path carrying this tunnel.
|
|
||||||
pub path: String,
|
|
||||||
/// Largest datagram the link accepts.
|
|
||||||
pub max_datagram: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Inner {
|
|
||||||
network: NetworkId,
|
|
||||||
private_key: WgSecretKey,
|
|
||||||
peers: RwLock<HashMap<WgPublicKey, Arc<Peer>>>,
|
|
||||||
sink: Arc<dyn PacketSink>,
|
|
||||||
transport: Arc<WireguardTransport>,
|
|
||||||
context: PluginContext,
|
|
||||||
next_index: AtomicU32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Inner {
|
|
||||||
fn on_tunnel_failed(&self, peer: EndpointId, public_key: WgPublicKey) {
|
|
||||||
self.transport.close_link(self.network, peer);
|
|
||||||
write_lock(&self.peers).remove(&public_key);
|
|
||||||
self.context.report_peer_protocol_failure(
|
|
||||||
self.network,
|
|
||||||
peer,
|
|
||||||
crate::announcement::WG_PROTOCOL,
|
|
||||||
"WireGuard rekey timeout",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for Inner {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.debug_struct("Inner")
|
|
||||||
.field("network", &self.network.fmt_short())
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The userspace WireGuard tunnels of one network.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct WireguardDevice {
|
|
||||||
inner: Arc<Inner>,
|
|
||||||
tasks: Vec<JoinHandle<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WireguardDevice {
|
|
||||||
/// Starts the WireGuard tunnels for one network.
|
|
||||||
pub fn start(
|
|
||||||
network: NetworkId,
|
|
||||||
private_key: WgSecretKey,
|
|
||||||
sink: Arc<dyn PacketSink>,
|
|
||||||
transport: Arc<WireguardTransport>,
|
|
||||||
context: PluginContext,
|
|
||||||
) -> Self {
|
|
||||||
let inner = Arc::new(Inner {
|
|
||||||
network,
|
|
||||||
private_key,
|
|
||||||
peers: RwLock::new(HashMap::new()),
|
|
||||||
sink,
|
|
||||||
transport,
|
|
||||||
context,
|
|
||||||
next_index: AtomicU32::new(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
let timers = tokio::spawn(drive_timers(Arc::clone(&inner)));
|
|
||||||
|
|
||||||
Self {
|
|
||||||
inner,
|
|
||||||
tasks: vec![timers],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encrypts an IP packet and sends it to a peer over its direct link.
|
|
||||||
pub fn carry(&self, peer: EndpointId, packet: &[u8]) -> bool {
|
|
||||||
let found = read_lock(&self.inner.peers)
|
|
||||||
.values()
|
|
||||||
.find(|candidate| candidate.endpoint_id == peer)
|
|
||||||
.cloned();
|
|
||||||
let Some(peer) = found else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut scratch = [0u8; SCRATCH];
|
|
||||||
let flow = ip_flow(packet);
|
|
||||||
let result = {
|
|
||||||
let mut tunn = match peer.tunn.lock() {
|
|
||||||
Ok(guard) => guard,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
let (result, selected_flow) = tunn.encapsulate(packet, &mut scratch, flow);
|
|
||||||
match result {
|
|
||||||
TunnResult::WriteToNetwork(out) => Some((out.len(), selected_flow)),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let Some((len, selected_flow)) = result else {
|
|
||||||
peer.counters
|
|
||||||
.dropped_no_session
|
|
||||||
.fetch_add(1, Ordering::Relaxed);
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
send_to_peer_flow(&peer, &scratch[..len], selected_flow);
|
|
||||||
peer.counters.tx_packets.fetch_add(1, Ordering::Relaxed);
|
|
||||||
peer.counters
|
|
||||||
.tx_bytes
|
|
||||||
.fetch_add(packet.len() as u64, Ordering::Relaxed);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds or replaces a peer and starts its tunnel.
|
|
||||||
pub fn add_peer(
|
|
||||||
&self,
|
|
||||||
endpoint_id: EndpointId,
|
|
||||||
public_key: WgPublicKey,
|
|
||||||
overlay_v4: Option<Ipv4Addr>,
|
|
||||||
link: SharedLink,
|
|
||||||
keepalive: Option<u16>,
|
|
||||||
) -> Result<(), PluginError> {
|
|
||||||
if public_key == self.inner.private_key.public() {
|
|
||||||
return Err(PluginError::Rejected(
|
|
||||||
"refusing to add ourselves as a WireGuard peer".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let index = self.inner.next_index.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let tunn = Tunn::new(
|
|
||||||
self.inner.private_key.to_static_secret(),
|
|
||||||
public_key.into_x25519(),
|
|
||||||
None,
|
|
||||||
keepalive,
|
|
||||||
index,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let peer = Arc::new(Peer {
|
|
||||||
endpoint_id,
|
|
||||||
public_key,
|
|
||||||
overlay_v4: Mutex::new(overlay_v4),
|
|
||||||
tunn: Mutex::new(FlowTunnel {
|
|
||||||
tunn,
|
|
||||||
queued: VecDeque::new(),
|
|
||||||
handshake_started_at: None,
|
|
||||||
rekey_timeouts: 0,
|
|
||||||
failed: false,
|
|
||||||
}),
|
|
||||||
link,
|
|
||||||
counters: Arc::new(PeerCounters::default()),
|
|
||||||
task: Mutex::new(None),
|
|
||||||
});
|
|
||||||
|
|
||||||
let task = tokio::spawn(read_from_link(Arc::clone(&self.inner), Arc::clone(&peer)));
|
|
||||||
if let Ok(mut guard) = peer.task.lock() {
|
|
||||||
*guard = Some(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
write_lock(&self.inner.peers).insert(public_key, Arc::clone(&peer));
|
|
||||||
kick_handshake(&peer);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes a peer and stops its tunnel.
|
|
||||||
pub fn remove_peer(&self, public_key: &WgPublicKey) {
|
|
||||||
write_lock(&self.inner.peers).remove(public_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes every peer whose key is not in `keep`.
|
|
||||||
pub fn retain_peers(&self, keep: &[WgPublicKey]) {
|
|
||||||
let stale: Vec<WgPublicKey> = read_lock(&self.inner.peers)
|
|
||||||
.keys()
|
|
||||||
.filter(|key| !keep.contains(key))
|
|
||||||
.copied()
|
|
||||||
.collect();
|
|
||||||
for key in stale {
|
|
||||||
self.remove_peer(&key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a peer's tunnel exists.
|
|
||||||
pub fn has_peer(&self, public_key: &WgPublicKey) -> bool {
|
|
||||||
read_lock(&self.inner.peers).contains_key(public_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A snapshot of every peer.
|
|
||||||
pub fn peers(&self) -> Vec<PeerSummary> {
|
|
||||||
let mut peers: Vec<PeerSummary> = read_lock(&self.inner.peers)
|
|
||||||
.values()
|
|
||||||
.map(|peer| {
|
|
||||||
let overlay_v4 = match peer.overlay_v4.lock() {
|
|
||||||
Ok(guard) => *guard,
|
|
||||||
Err(poisoned) => *poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
PeerSummary {
|
|
||||||
endpoint_id: peer.endpoint_id,
|
|
||||||
public_key: peer.public_key,
|
|
||||||
overlay_address_v4: overlay_v4,
|
|
||||||
health: peer.health(),
|
|
||||||
stats: peer.stats(),
|
|
||||||
path: peer.link.path_description(),
|
|
||||||
max_datagram: peer.link.max_datagram_size(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
peers.sort_by_key(|peer| peer.public_key);
|
|
||||||
peers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for WireguardDevice {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
for task in &self.tasks {
|
|
||||||
task.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
|
|
||||||
match lock.read() {
|
|
||||||
Ok(guard) => guard,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
|
|
||||||
match lock.write() {
|
|
||||||
Ok(guard) => guard,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kick_handshake(peer: &Peer) {
|
|
||||||
let mut scratch = vec![0u8; SCRATCH];
|
|
||||||
let len = {
|
|
||||||
let mut tunn = match peer.tunn.lock() {
|
|
||||||
Ok(guard) => guard,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
match tunn.tunn.format_handshake_initiation(&mut scratch, false) {
|
|
||||||
TunnResult::WriteToNetwork(out) => {
|
|
||||||
tunn.handshake_started_at = Some(std::time::Instant::now());
|
|
||||||
tunn.rekey_timeouts = 0;
|
|
||||||
tunn.failed = false;
|
|
||||||
Some(out.len())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Some(len) = len {
|
|
||||||
send_to_peer(peer, &scratch[..len]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_to_peer(peer: &Peer, payload: &[u8]) {
|
|
||||||
send_to_peer_flow(peer, payload, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_to_peer_flow(peer: &Peer, payload: &[u8], flow: FlowId) {
|
|
||||||
match peer.link.send_flow(Bytes::copy_from_slice(payload), flow) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(TransportError::TooLarge { .. }) => {
|
|
||||||
peer.counters
|
|
||||||
.dropped_oversize
|
|
||||||
.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
Err(TransportError::Closed) => {}
|
|
||||||
Err(err) => {
|
|
||||||
tracing::trace!(%err, "dropping a WireGuard packet the link refused");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
|
|
||||||
let mut scratch = vec![0u8; SCRATCH];
|
|
||||||
loop {
|
|
||||||
let Some(datagram) = peer.link.recv().await else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut input: Option<&[u8]> = Some(&datagram);
|
|
||||||
loop {
|
|
||||||
let outcome = {
|
|
||||||
let mut tunn = match peer.tunn.lock() {
|
|
||||||
Ok(guard) => guard,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
let (result, flow) = tunn.decapsulate(input.unwrap_or(&[]), &mut scratch);
|
|
||||||
match result {
|
|
||||||
TunnResult::WriteToNetwork(out) => Outcome::ToNetwork(out.len(), flow),
|
|
||||||
TunnResult::WriteToTunnelV6(out, _) => Outcome::ToTunnel(out.len()),
|
|
||||||
TunnResult::WriteToTunnelV4(out, _) => Outcome::ToTunnel(out.len()),
|
|
||||||
TunnResult::Done => Outcome::Done,
|
|
||||||
TunnResult::Err(err) => {
|
|
||||||
tracing::trace!(?err, "WireGuard decapsulation failed");
|
|
||||||
Outcome::Failed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match outcome {
|
|
||||||
Outcome::ToNetwork(len, flow) => {
|
|
||||||
send_to_peer_flow(&peer, &scratch[..len], flow);
|
|
||||||
input = None;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Outcome::ToTunnel(len) => {
|
|
||||||
let payload = Bytes::copy_from_slice(&scratch[..len]);
|
|
||||||
inner
|
|
||||||
.sink
|
|
||||||
.deliver(inner.network, peer.endpoint_id, payload)
|
|
||||||
.await;
|
|
||||||
peer.counters.rx_packets.fetch_add(1, Ordering::Relaxed);
|
|
||||||
peer.counters
|
|
||||||
.rx_bytes
|
|
||||||
.fetch_add(len as u64, Ordering::Relaxed);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Outcome::Failed => {
|
|
||||||
peer.counters
|
|
||||||
.protocol_errors
|
|
||||||
.fetch_add(1, Ordering::Relaxed);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Outcome::Done => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Outcome {
|
|
||||||
ToNetwork(usize, FlowId),
|
|
||||||
ToTunnel(usize),
|
|
||||||
Done,
|
|
||||||
Failed,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn drive_timers(inner: Arc<Inner>) {
|
|
||||||
let mut ticker = tokio::time::interval(TIMER_INTERVAL);
|
|
||||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
||||||
loop {
|
|
||||||
ticker.tick().await;
|
|
||||||
let peers: Vec<Arc<Peer>> = read_lock(&inner.peers).values().cloned().collect();
|
|
||||||
for peer in peers {
|
|
||||||
let mut scratch = vec![0u8; SCRATCH];
|
|
||||||
let (len, failed) = {
|
|
||||||
let mut tunn = match peer.tunn.lock() {
|
|
||||||
Ok(guard) => guard,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
let (result, failed) = tunn.update_timers(&mut scratch);
|
|
||||||
let len = match result {
|
|
||||||
TunnResult::WriteToNetwork(out) => Some(out.len()),
|
|
||||||
TunnResult::Err(err) => {
|
|
||||||
tracing::trace!(?err, "WireGuard timer error");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
(len, failed)
|
|
||||||
};
|
|
||||||
if let Some(len) = len {
|
|
||||||
send_to_peer(&peer, &scratch[..len]);
|
|
||||||
}
|
|
||||||
if failed {
|
|
||||||
tracing::warn!(
|
|
||||||
peer = %peer.endpoint_id.fmt_short(),
|
|
||||||
"WireGuard tunnel failed due to rekey timeout, triggering fallback"
|
|
||||||
);
|
|
||||||
inner.on_tunnel_failed(peer.endpoint_id, peer.public_key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_flow_tunnel_rekey_timeout_triggers_failure() {
|
|
||||||
let my_secret = WgSecretKey::generate();
|
|
||||||
let their_secret = WgSecretKey::generate();
|
|
||||||
let their_public = their_secret.public();
|
|
||||||
let tunn = Tunn::new(
|
|
||||||
my_secret.to_static_secret(),
|
|
||||||
their_public.into_x25519(),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
1,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut tunnel = FlowTunnel {
|
|
||||||
tunn,
|
|
||||||
queued: VecDeque::new(),
|
|
||||||
handshake_started_at: Some(std::time::Instant::now() - Duration::from_secs(16)),
|
|
||||||
rekey_timeouts: 0,
|
|
||||||
failed: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut scratch = vec![0u8; SCRATCH];
|
|
||||||
// When handshake timeout exceeds HANDSHAKE_TIMEOUT (15s), update_timers returns true (failed)
|
|
||||||
let (_, failed) = tunnel.update_timers(&mut scratch);
|
|
||||||
assert!(failed, "handshake timeout should report failure");
|
|
||||||
assert!(tunnel.handshake_started_at.is_none());
|
|
||||||
assert!(tunnel.failed, "tunnel should be marked as failed");
|
|
||||||
|
|
||||||
// Subsequent timer tick must not report failure again
|
|
||||||
let (_, failed_again) = tunnel.update_timers(&mut scratch);
|
|
||||||
assert!(
|
|
||||||
!failed_again,
|
|
||||||
"subsequent timer tick should not re-trigger failure"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_flow_tunnel_reset_on_handshake() {
|
|
||||||
let my_secret = WgSecretKey::generate();
|
|
||||||
let their_secret = WgSecretKey::generate();
|
|
||||||
let their_public = their_secret.public();
|
|
||||||
let tunn = Tunn::new(
|
|
||||||
my_secret.to_static_secret(),
|
|
||||||
their_public.into_x25519(),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
1,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut tunnel = FlowTunnel {
|
|
||||||
tunn,
|
|
||||||
queued: VecDeque::new(),
|
|
||||||
handshake_started_at: Some(std::time::Instant::now()),
|
|
||||||
rekey_timeouts: 2,
|
|
||||||
failed: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut scratch = vec![0u8; SCRATCH];
|
|
||||||
let _ = tunnel.decapsulate(&[], &mut scratch);
|
|
||||||
// decapsulate without handshake response doesn't establish session
|
|
||||||
assert_eq!(tunnel.rekey_timeouts, 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
//! WireGuard key material for pure WireGuard transport.
|
|
||||||
//!
|
|
||||||
//! Keys belong to this protocol plugin alone. They are not derived from
|
|
||||||
//! the iroh device key and not derived from the network secret, so compromising
|
|
||||||
//! or rotating one does not affect the others.
|
|
||||||
//!
|
|
||||||
//! Keys are X25519, encoded the way WireGuard encodes them: standard base64
|
|
||||||
//! with padding, 44 characters.
|
|
||||||
|
|
||||||
use boringtun::x25519;
|
|
||||||
use data_encoding::BASE64;
|
|
||||||
use zeroize::{Zeroize, Zeroizing};
|
|
||||||
|
|
||||||
use tsunagi::dataplane::PluginError;
|
|
||||||
|
|
||||||
/// Length of a raw WireGuard key in bytes.
|
|
||||||
pub const KEY_LEN: usize = 32;
|
|
||||||
|
|
||||||
/// Length of the base64 text form of a key.
|
|
||||||
pub const KEY_TEXT_LEN: usize = 44;
|
|
||||||
|
|
||||||
/// Applies the X25519 clamping WireGuard applies to private keys.
|
|
||||||
fn clamp(bytes: &mut [u8; KEY_LEN]) {
|
|
||||||
bytes[0] &= 248;
|
|
||||||
bytes[31] &= 127;
|
|
||||||
bytes[31] |= 64;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A WireGuard public key.
|
|
||||||
///
|
|
||||||
/// Public, safe to log, and the identity a peer is known by inside the overlay.
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
||||||
pub struct WgPublicKey([u8; KEY_LEN]);
|
|
||||||
|
|
||||||
impl WgPublicKey {
|
|
||||||
/// Wraps raw key bytes.
|
|
||||||
pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
|
|
||||||
Self(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The raw key bytes.
|
|
||||||
pub fn as_bytes(&self) -> &[u8; KEY_LEN] {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The key in the form the WireGuard implementation expects.
|
|
||||||
pub(crate) fn into_x25519(self) -> x25519::PublicKey {
|
|
||||||
x25519::PublicKey::from(self.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this is the all-zero key, which is never a valid peer.
|
|
||||||
pub fn is_zero(&self) -> bool {
|
|
||||||
self.0 == [0u8; KEY_LEN]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The base64 form WireGuard uses.
|
|
||||||
pub fn encode(&self) -> String {
|
|
||||||
BASE64.encode(&self.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses the base64 form WireGuard uses.
|
|
||||||
pub fn decode(text: &str) -> Result<Self, PluginError> {
|
|
||||||
if text.len() != KEY_TEXT_LEN {
|
|
||||||
return Err(PluginError::Rejected(format!(
|
|
||||||
"a WireGuard key is {KEY_TEXT_LEN} base64 characters, got {}",
|
|
||||||
text.len()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let raw = BASE64
|
|
||||||
.decode(text.as_bytes())
|
|
||||||
.map_err(|_| PluginError::Rejected("key is not valid base64".into()))?;
|
|
||||||
let bytes = <[u8; KEY_LEN]>::try_from(raw.as_slice())
|
|
||||||
.map_err(|_| PluginError::Rejected("key is not 32 bytes".into()))?;
|
|
||||||
Ok(Self(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A short prefix for logs and diagnostics.
|
|
||||||
pub fn fmt_short(&self) -> String {
|
|
||||||
self.encode().chars().take(8).collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for WgPublicKey {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_str(&self.encode())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for WgPublicKey {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "WgPublicKey({})", self.fmt_short())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A WireGuard private key.
|
|
||||||
///
|
|
||||||
/// Clamped on creation so it matches what `wg genkey` produces. Zeroized on
|
|
||||||
/// drop, and prints as `[redacted]` in Debug so it cannot leak into logs.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WgSecretKey {
|
|
||||||
bytes: Zeroizing<[u8; KEY_LEN]>,
|
|
||||||
public: WgPublicKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WgSecretKey {
|
|
||||||
/// Generates a fresh clamped random private key.
|
|
||||||
pub fn generate() -> Self {
|
|
||||||
let mut bytes = [0u8; KEY_LEN];
|
|
||||||
rand::fill(&mut bytes);
|
|
||||||
clamp(&mut bytes);
|
|
||||||
let secret = x25519::StaticSecret::from(bytes);
|
|
||||||
let public = WgPublicKey(*x25519::PublicKey::from(&secret).as_bytes());
|
|
||||||
Self {
|
|
||||||
bytes: Zeroizing::new(bytes),
|
|
||||||
public,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wraps existing key bytes, clamping them to a valid X25519 private key.
|
|
||||||
pub fn from_bytes(raw: &[u8; KEY_LEN]) -> Self {
|
|
||||||
let mut bytes = *raw;
|
|
||||||
clamp(&mut bytes);
|
|
||||||
let secret = x25519::StaticSecret::from(bytes);
|
|
||||||
let public = WgPublicKey(*x25519::PublicKey::from(&secret).as_bytes());
|
|
||||||
Self {
|
|
||||||
bytes: Zeroizing::new(bytes),
|
|
||||||
public,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The matching public key.
|
|
||||||
pub fn public(&self) -> WgPublicKey {
|
|
||||||
self.public
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The key in the form the WireGuard implementation expects.
|
|
||||||
pub(crate) fn to_static_secret(&self) -> x25519::StaticSecret {
|
|
||||||
x25519::StaticSecret::from(*self.bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The raw key bytes.
|
|
||||||
pub fn as_bytes(&self) -> &[u8; KEY_LEN] {
|
|
||||||
&self.bytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for WgSecretKey {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.bytes.zeroize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for WgSecretKey {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.debug_struct("WgSecretKey")
|
|
||||||
.field("bytes", &"[redacted]")
|
|
||||||
.field("public", &self.public)
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,21 @@
|
|||||||
//! The pure WireGuard protocol plugin for Tsunagi: WireGuard cryptography over direct UDP.
|
//! Authenticated, per-neighbour WireGuard UDP carriers. IP encryption is independent.
|
||||||
//!
|
|
||||||
//! Deliberately a separate crate from the core, ensuring clear boundaries:
|
|
||||||
//! - Direct UDP datagram transport without QUIC encapsulation or double-encryption.
|
|
||||||
//! - Standard WireGuard cryptography via `boringtun`.
|
|
||||||
//! - Peer discovery and key exchange managed through the Tsunagi control plane.
|
|
||||||
|
|
||||||
#![deny(unsafe_code)]
|
#![deny(unsafe_code)]
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
mod addresses;
|
||||||
pub mod announcement;
|
mod masking;
|
||||||
pub mod device;
|
mod plugin;
|
||||||
pub mod keys;
|
pub mod profile;
|
||||||
pub mod plugin;
|
mod session;
|
||||||
pub mod store;
|
mod stun;
|
||||||
pub mod transport;
|
mod transport;
|
||||||
|
pub use addresses::{local_ip_candidates, prioritize_local_subnet};
|
||||||
pub use announcement::{
|
pub use plugin::AwgPlugin as UdpCarrier;
|
||||||
ANNOUNCEMENT_VERSION, ValidatedAnnouncement, WG_PROTOCOL, WG_WIRE_VERSION, WgAnnouncement,
|
pub use transport::{AwgTransport as UdpTransport, PairOverview};
|
||||||
};
|
/// External ordinary WireGuard carrier identifier.
|
||||||
pub use device::{PeerHealth, PeerStats, PeerSummary, WireguardDevice};
|
pub const WG_PROTOCOL: &str = "wg";
|
||||||
pub use keys::{WgPublicKey, WgSecretKey};
|
/// Wire version of the private UDP negotiation and carrier frames.
|
||||||
pub use plugin::{
|
pub const WG_WIRE_VERSION: u16 = 2;
|
||||||
DEFAULT_MTU, MIN_MTU, NetworkOverview, PeerOverview, WIREGUARD_OVERHEAD, WireguardConfig,
|
/// Masked carrier identifier used by the shared pair engine.
|
||||||
WireguardPlugin, local_ip_candidates,
|
pub const AWG_PROTOCOL: &str = "awg";
|
||||||
};
|
/// Wire version of masked carrier frames and private negotiation.
|
||||||
pub use store::WgKeyStore;
|
pub const AWG_WIRE_VERSION: u16 = 2;
|
||||||
pub use transport::{DEFAULT_PORT, MAX_UDP_DATAGRAM_SIZE, WireguardLink, WireguardTransport};
|
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
// Adapted from RunaRoo/AmneziaWG-RS, commit 51579a7d6e8882d1d8d6d00822f09e18a9423b73.
|
||||||
|
// Apache-2.0; see LICENSE-AmneziaWG-RS and UPSTREAM.md.
|
||||||
|
#![allow(dead_code, missing_docs)]
|
||||||
|
// AmneziaWG configuration, header protection, and traffic shaping.
|
||||||
|
|
||||||
|
use bytes::{Bytes, BytesMut};
|
||||||
|
use chacha20::{
|
||||||
|
ChaCha20,
|
||||||
|
cipher::{KeyIvInit, StreamCipher},
|
||||||
|
};
|
||||||
|
use rand::RngExt;
|
||||||
|
|
||||||
|
pub const HEADER_PROTECTION_NONCE_SIZE: usize = 12;
|
||||||
|
pub const DEFAULT_UDP_WINDOW: usize = 500;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct Range {
|
||||||
|
pub min: u32,
|
||||||
|
pub max: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Range {
|
||||||
|
pub fn contains(&self, val: u32) -> bool {
|
||||||
|
val >= self.min && val <= self.max
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn random(&self) -> u32 {
|
||||||
|
if self.min >= self.max {
|
||||||
|
self.min
|
||||||
|
} else {
|
||||||
|
rand::rng().random_range(self.min..=self.max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_zero(&self) -> bool {
|
||||||
|
self.min == 0 && self.max == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum AwgKind {
|
||||||
|
Initiation,
|
||||||
|
Response,
|
||||||
|
Cookie,
|
||||||
|
Transport,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AwgKind {
|
||||||
|
pub fn fixed_size(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Initiation => 148,
|
||||||
|
Self::Response => 92,
|
||||||
|
Self::Cookie => 64,
|
||||||
|
Self::Transport => 32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn standard_type(self) -> u32 {
|
||||||
|
match self {
|
||||||
|
Self::Initiation => 1,
|
||||||
|
Self::Response => 2,
|
||||||
|
Self::Cookie => 3,
|
||||||
|
Self::Transport => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DecodedPacket {
|
||||||
|
pub kind: AwgKind,
|
||||||
|
pub message: Bytes,
|
||||||
|
pub wire_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AmneziaConfig {
|
||||||
|
pub jc: u32,
|
||||||
|
pub jmin: usize,
|
||||||
|
pub jmax: usize,
|
||||||
|
pub s1: usize,
|
||||||
|
pub s2: usize,
|
||||||
|
pub s3: usize,
|
||||||
|
pub s4: usize,
|
||||||
|
pub h1: Range,
|
||||||
|
pub h2: Range,
|
||||||
|
pub h3: Range,
|
||||||
|
pub h4: Range,
|
||||||
|
pub i1: String,
|
||||||
|
pub i2: String,
|
||||||
|
pub i3: String,
|
||||||
|
pub i4: String,
|
||||||
|
pub i5: String,
|
||||||
|
pub header_protection_key: Option<[u8; 32]>,
|
||||||
|
pub content_padding_addition: Range,
|
||||||
|
pub random_trailers: bool,
|
||||||
|
pub mtu: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AmneziaConfig {
|
||||||
|
pub fn random_header(&self, range: &Range) -> u32 {
|
||||||
|
range.random()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn random_padding(&self, len: usize) -> Vec<u8> {
|
||||||
|
let mut padding = vec![0u8; len];
|
||||||
|
if len > 0 {
|
||||||
|
rand::fill(&mut padding);
|
||||||
|
}
|
||||||
|
padding
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape(&self, kind: AwgKind) -> (usize, Range) {
|
||||||
|
match kind {
|
||||||
|
AwgKind::Initiation => (self.s1, self.h1),
|
||||||
|
AwgKind::Response => (self.s2, self.h2),
|
||||||
|
AwgKind::Cookie => (self.s3, self.h3),
|
||||||
|
AwgKind::Transport => (self.s4, self.h4),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode(&self, kind: AwgKind, payload: Bytes, udp_window: usize) -> Bytes {
|
||||||
|
let (padding_len, _) = self.shape(kind);
|
||||||
|
let trailer_len = if kind != AwgKind::Transport && self.random_trailers {
|
||||||
|
let base = padding_len.saturating_add(payload.len());
|
||||||
|
let room = udp_window.max(DEFAULT_UDP_WINDOW).saturating_sub(base);
|
||||||
|
if room == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
rand::rng().random_range(0..room)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = BytesMut::zeroed(padding_len + payload.len() + trailer_len);
|
||||||
|
if padding_len > 0 {
|
||||||
|
rand::fill(&mut out[..padding_len]);
|
||||||
|
}
|
||||||
|
out[padding_len..padding_len + payload.len()].copy_from_slice(&payload);
|
||||||
|
if trailer_len > 0 {
|
||||||
|
rand::fill(&mut out[padding_len + payload.len()..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.header_protection_key.is_some() {
|
||||||
|
let protected_len = if kind == AwgKind::Transport {
|
||||||
|
16
|
||||||
|
} else {
|
||||||
|
kind.fixed_size()
|
||||||
|
};
|
||||||
|
self.apply_header_cipher(&mut out, padding_len, protected_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.freeze()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(&self, datagram: &Bytes) -> Option<DecodedPacket> {
|
||||||
|
if datagram.len() < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
for kind in [
|
||||||
|
AwgKind::Initiation,
|
||||||
|
AwgKind::Response,
|
||||||
|
AwgKind::Cookie,
|
||||||
|
AwgKind::Transport,
|
||||||
|
] {
|
||||||
|
let (padding_len, headers) = self.shape(kind);
|
||||||
|
let minimum = padding_len + kind.fixed_size();
|
||||||
|
let size_ok = match kind {
|
||||||
|
AwgKind::Transport | AwgKind::Initiation => datagram.len() >= minimum,
|
||||||
|
AwgKind::Response | AwgKind::Cookie if self.random_trailers => {
|
||||||
|
datagram.len() >= minimum
|
||||||
|
}
|
||||||
|
AwgKind::Response | AwgKind::Cookie => datagram.len() == minimum,
|
||||||
|
};
|
||||||
|
if !size_ok || datagram.len() < padding_len + 4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let protected_header =
|
||||||
|
u32::from_le_bytes(datagram[padding_len..padding_len + 4].try_into().ok()?);
|
||||||
|
let candidate = if self.header_protection_key.is_some() {
|
||||||
|
protected_header ^ self.header_type_hash(datagram)?
|
||||||
|
} else {
|
||||||
|
protected_header
|
||||||
|
};
|
||||||
|
if !headers.contains(candidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message_len = match kind {
|
||||||
|
AwgKind::Transport | AwgKind::Initiation => datagram.len() - padding_len,
|
||||||
|
AwgKind::Response | AwgKind::Cookie => kind.fixed_size(),
|
||||||
|
};
|
||||||
|
let mut payload = BytesMut::from(&datagram[padding_len..padding_len + message_len]);
|
||||||
|
if self.header_protection_key.is_some() {
|
||||||
|
let protected_len = if kind == AwgKind::Transport {
|
||||||
|
16
|
||||||
|
} else {
|
||||||
|
kind.fixed_size()
|
||||||
|
};
|
||||||
|
self.apply_header_cipher_with_nonce(
|
||||||
|
&mut payload,
|
||||||
|
0,
|
||||||
|
protected_len,
|
||||||
|
&datagram[..HEADER_PROTECTION_NONCE_SIZE],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Some(DecodedPacket {
|
||||||
|
kind,
|
||||||
|
message: payload.freeze(),
|
||||||
|
wire_len: datagram.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepare_transport_plaintext(&self, packet: Bytes, udp_window: usize) -> Bytes {
|
||||||
|
if packet.is_empty() {
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let requested = if !self.content_padding_addition.is_zero() {
|
||||||
|
self.content_padding_addition.random() as usize
|
||||||
|
} else if self.random_trailers {
|
||||||
|
let wire_without_padding = self.s4 + AwgKind::Transport.fixed_size() + packet.len();
|
||||||
|
let room = udp_window
|
||||||
|
.max(DEFAULT_UDP_WINDOW)
|
||||||
|
.saturating_sub(wire_without_padding);
|
||||||
|
if room == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
rand::rng().random_range(0..room)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let aligned = (packet.len() + 15) & !15;
|
||||||
|
aligned.saturating_sub(packet.len())
|
||||||
|
};
|
||||||
|
|
||||||
|
let padding = self.clamp_padding_to_mtu(packet.len(), requested);
|
||||||
|
if padding == 0 {
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = BytesMut::with_capacity(packet.len() + padding);
|
||||||
|
out.extend_from_slice(&packet);
|
||||||
|
out.resize(packet.len() + padding, 0);
|
||||||
|
out.freeze()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transport_content_length(&self, plaintext: &[u8]) -> Option<usize> {
|
||||||
|
if plaintext.is_empty() || plaintext[0] == 0 {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
match plaintext[0] >> 4 {
|
||||||
|
4 if plaintext.len() >= 20 => {
|
||||||
|
let total = u16::from_be_bytes([plaintext[2], plaintext[3]]) as usize;
|
||||||
|
(total >= 20 && total <= plaintext.len()).then_some(total)
|
||||||
|
}
|
||||||
|
6 if plaintext.len() >= 40 => {
|
||||||
|
let payload = u16::from_be_bytes([plaintext[4], plaintext[5]]) as usize;
|
||||||
|
let total = 40 + payload;
|
||||||
|
(total <= plaintext.len()).then_some(total)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_padding_to_mtu(&self, packet_len: usize, requested: usize) -> usize {
|
||||||
|
if self.mtu == 0 {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
let normalized = if packet_len > self.mtu {
|
||||||
|
packet_len % self.mtu
|
||||||
|
} else {
|
||||||
|
packet_len
|
||||||
|
};
|
||||||
|
requested.min(self.mtu.saturating_sub(normalized))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_type_hash(&self, datagram: &Bytes) -> Option<u32> {
|
||||||
|
if datagram.len() < HEADER_PROTECTION_NONCE_SIZE {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut hash = [0u8; 4];
|
||||||
|
self.apply_keystream(&mut hash, &datagram[..HEADER_PROTECTION_NONCE_SIZE]);
|
||||||
|
Some(u32::from_le_bytes(hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_header_cipher(&self, packet: &mut [u8], offset: usize, len: usize) {
|
||||||
|
if packet.len() < HEADER_PROTECTION_NONCE_SIZE || packet.len() < offset + len {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let nonce: [u8; HEADER_PROTECTION_NONCE_SIZE] =
|
||||||
|
match packet[..HEADER_PROTECTION_NONCE_SIZE].try_into() {
|
||||||
|
Ok(nonce) => nonce,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
self.apply_header_cipher_with_nonce(packet, offset, len, &nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_header_cipher_with_nonce(
|
||||||
|
&self,
|
||||||
|
packet: &mut [u8],
|
||||||
|
offset: usize,
|
||||||
|
len: usize,
|
||||||
|
nonce: &[u8],
|
||||||
|
) {
|
||||||
|
if packet.len() < offset + len {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.apply_keystream(&mut packet[offset..offset + len], nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_keystream(&self, data: &mut [u8], nonce: &[u8]) {
|
||||||
|
let Some(key) = self.header_protection_key.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if nonce.len() != HEADER_PROTECTION_NONCE_SIZE {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(nonce) = <&[u8; HEADER_PROTECTION_NONCE_SIZE]>::try_from(nonce) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut cipher = ChaCha20::new(key.into(), nonce.into());
|
||||||
|
cipher.apply_keystream(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AmneziaConfig {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
use zeroize::Zeroize;
|
||||||
|
self.header_protection_key.zeroize();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,254 @@
|
|||||||
|
//! Bounded profiles, regenerated for every pair connection attempt.
|
||||||
|
use crate::masking::{AmneziaConfig, AwgKind, Range};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// An ephemeral masking profile. Secret fields are redacted and erased on drop.
|
||||||
|
#[derive(Clone, Serialize, Deserialize, zeroize::ZeroizeOnDrop)]
|
||||||
|
pub struct PairProfile {
|
||||||
|
id: [u8; 16],
|
||||||
|
masked: bool,
|
||||||
|
prefix: u16,
|
||||||
|
headers: [[u32; 2]; 4],
|
||||||
|
header_key: [u8; 32],
|
||||||
|
pub(crate) psk: [u8; 32],
|
||||||
|
junk_count: u8,
|
||||||
|
junk_min: u16,
|
||||||
|
junk_max: u16,
|
||||||
|
padding: u16,
|
||||||
|
signature: u8,
|
||||||
|
}
|
||||||
|
impl std::fmt::Debug for PairProfile {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("PairProfile([REDACTED])")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl PairProfile {
|
||||||
|
/// Generates a new safe profile; no deterministic seed or durable state.
|
||||||
|
pub fn generate() -> Self {
|
||||||
|
let headers = std::array::from_fn(|i| {
|
||||||
|
let base = (i as u32) * 0x4000_0000 + rand::random_range(1024..0x3fff_0000);
|
||||||
|
[base, base + rand::random_range(64..1024)]
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
id: rand::random(),
|
||||||
|
masked: true,
|
||||||
|
prefix: rand::random_range(12..49),
|
||||||
|
headers,
|
||||||
|
header_key: rand::random(),
|
||||||
|
psk: rand::random(),
|
||||||
|
junk_count: rand::random_range(4..9),
|
||||||
|
junk_min: rand::random_range(24..65),
|
||||||
|
junk_max: rand::random_range(80..161),
|
||||||
|
padding: rand::random_range(16..65),
|
||||||
|
signature: rand::random_range(0..2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A fresh ordinary WireGuard profile, without masking or signature packets.
|
||||||
|
pub fn wireguard() -> Self {
|
||||||
|
let mut profile = Self::generate();
|
||||||
|
profile.masked = false;
|
||||||
|
profile
|
||||||
|
}
|
||||||
|
/// Whether this profile uses Amnezia masking.
|
||||||
|
pub fn is_masked(&self) -> bool {
|
||||||
|
self.masked
|
||||||
|
}
|
||||||
|
/// Public attempt identifier, useful for distinguishing retries without revealing secrets.
|
||||||
|
pub fn id(&self) -> [u8; 16] {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
/// Header key, for packet engines and protocol tests; never log this value.
|
||||||
|
pub fn header_key(&self) -> &[u8; 32] {
|
||||||
|
&self.header_key
|
||||||
|
}
|
||||||
|
/// Rejects invalid remote profiles before allocating packets or entering the codec.
|
||||||
|
pub fn validate(&self) -> Result<(), &'static str> {
|
||||||
|
if !(12..=48).contains(&self.prefix)
|
||||||
|
|| !(4..=8).contains(&self.junk_count)
|
||||||
|
|| self.junk_min < 24
|
||||||
|
|| self.junk_min > self.junk_max
|
||||||
|
|| self.junk_max > 160
|
||||||
|
|| self.padding > 64
|
||||||
|
|| self.signature > 1
|
||||||
|
|| self.header_key == [0; 32]
|
||||||
|
|| self.psk == [0; 32]
|
||||||
|
{
|
||||||
|
return Err("invalid AWG profile bounds");
|
||||||
|
}
|
||||||
|
for (i, [lo, hi]) in self.headers.iter().copied().enumerate() {
|
||||||
|
if lo < 5 || lo > hi || hi - lo > 1024 {
|
||||||
|
return Err("invalid AWG header range");
|
||||||
|
}
|
||||||
|
for [other_lo, other_hi] in &self.headers[..i] {
|
||||||
|
if lo <= *other_hi && *other_lo <= hi {
|
||||||
|
return Err("overlapping AWG header ranges");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn config(&self) -> AmneziaConfig {
|
||||||
|
let range = |i: usize| Range {
|
||||||
|
min: self.headers[i][0],
|
||||||
|
max: self.headers[i][1],
|
||||||
|
};
|
||||||
|
AmneziaConfig {
|
||||||
|
jc: self.junk_count.into(),
|
||||||
|
jmin: self.junk_min.into(),
|
||||||
|
jmax: self.junk_max.into(),
|
||||||
|
s1: self.prefix.into(),
|
||||||
|
s2: self.prefix.into(),
|
||||||
|
s3: self.prefix.into(),
|
||||||
|
s4: self.prefix.into(),
|
||||||
|
h1: range(0),
|
||||||
|
h2: range(1),
|
||||||
|
h3: range(2),
|
||||||
|
h4: range(3),
|
||||||
|
i1: String::new(),
|
||||||
|
i2: String::new(),
|
||||||
|
i3: String::new(),
|
||||||
|
i4: String::new(),
|
||||||
|
i5: String::new(),
|
||||||
|
header_protection_key: Some(self.header_key),
|
||||||
|
content_padding_addition: Range {
|
||||||
|
min: 0,
|
||||||
|
max: self.padding.into(),
|
||||||
|
},
|
||||||
|
random_trailers: true,
|
||||||
|
mtu: 1050,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Masks one standard WireGuard message; invalid types and oversized packets are refused.
|
||||||
|
pub fn encode(&self, raw: &[u8]) -> Option<Bytes> {
|
||||||
|
if !self.masked {
|
||||||
|
return (raw.len() <= 1200).then(|| Bytes::copy_from_slice(raw));
|
||||||
|
}
|
||||||
|
let kind = match raw.get(..4)? {
|
||||||
|
[1, 0, 0, 0] => AwgKind::Initiation,
|
||||||
|
[2, 0, 0, 0] => AwgKind::Response,
|
||||||
|
[3, 0, 0, 0] => AwgKind::Cookie,
|
||||||
|
[4, 0, 0, 0] => AwgKind::Transport,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
if raw.len() < kind.fixed_size() || raw.len() > 1050 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let config = self.config();
|
||||||
|
let range = match kind {
|
||||||
|
AwgKind::Initiation => config.h1,
|
||||||
|
AwgKind::Response => config.h2,
|
||||||
|
AwgKind::Cookie => config.h3,
|
||||||
|
AwgKind::Transport => config.h4,
|
||||||
|
};
|
||||||
|
let mut payload = raw.to_vec();
|
||||||
|
payload[..4].copy_from_slice(&range.random().to_le_bytes());
|
||||||
|
Some(config.encode(kind, Bytes::from(payload), 1200))
|
||||||
|
}
|
||||||
|
/// Unmasks a packet and restores standard headers before WireGuard validation.
|
||||||
|
pub fn decode(&self, wire: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
if !self.masked {
|
||||||
|
return (wire.len() <= 1200).then(|| wire.to_vec());
|
||||||
|
}
|
||||||
|
if wire.len() > 1200 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let decoded = self.config().decode(&Bytes::copy_from_slice(wire))?;
|
||||||
|
let (kind, len) = match decoded.kind {
|
||||||
|
AwgKind::Initiation => (1, 148),
|
||||||
|
AwgKind::Response => (2, 92),
|
||||||
|
AwgKind::Cookie => (3, 64),
|
||||||
|
AwgKind::Transport => (4, decoded.message.len()),
|
||||||
|
};
|
||||||
|
let mut message = decoded.message.get(..len)?.to_vec();
|
||||||
|
message[..4].copy_from_slice(&(kind as u32).to_le_bytes());
|
||||||
|
Some(message)
|
||||||
|
}
|
||||||
|
pub(crate) fn pad(&self, packet: Bytes) -> Bytes {
|
||||||
|
if self.masked {
|
||||||
|
self.config().prepare_transport_plaintext(packet, 1200)
|
||||||
|
} else {
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Generates structured signature packets with fresh fields, never static captures.
|
||||||
|
/// These target the other Tsunagi peer, not DNS or SIP services.
|
||||||
|
pub fn signature_packets(&self) -> Vec<Vec<u8>> {
|
||||||
|
if !self.masked {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if self.signature == 0 {
|
||||||
|
// A complete DNS query. Counts, label lengths and qtype remain valid.
|
||||||
|
let label = format!("n{:016x}", rand::random::<u64>());
|
||||||
|
let mut packet = Vec::from(rand::random::<u16>().to_be_bytes());
|
||||||
|
packet.extend_from_slice(&[1, 0, 0, 1, 0, 0, 0, 0, 0, 0]);
|
||||||
|
packet.push(label.len() as u8);
|
||||||
|
packet.extend_from_slice(label.as_bytes());
|
||||||
|
packet.extend_from_slice(b"\x07example\x03net\0\0");
|
||||||
|
packet.push(if rand::random() { 1 } else { 28 });
|
||||||
|
packet.extend_from_slice(&[0, 1]);
|
||||||
|
vec![packet]
|
||||||
|
} else {
|
||||||
|
let id = rand::random::<u64>();
|
||||||
|
vec![format!("OPTIONS sip:n{id:x}@example.net SIP/2.0\r\nVia: SIP/2.0/UDP example.net;branch=z9hG4bK{id:x}\r\nMax-Forwards: 70\r\nFrom: <sip:a@example.net>;tag={id:x}\r\nTo: <sip:n{id:x}@example.net>\r\nCall-ID: {id:x}@example.net\r\nCSeq: 1 OPTIONS\r\nContent-Length: 0\r\n\r\n").into_bytes()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn junk_packets(&self) -> Vec<Vec<u8>> {
|
||||||
|
if !self.masked {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
(0..self.junk_count)
|
||||||
|
.map(|_| {
|
||||||
|
let mut packet = vec![0; rand::random_range(self.junk_min..=self.junk_max).into()];
|
||||||
|
rand::fill(&mut packet[..]);
|
||||||
|
packet
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn unsafe_peer_profiles_are_rejected_before_the_codec() {
|
||||||
|
let valid = PairProfile::generate();
|
||||||
|
let mut bad = valid.clone();
|
||||||
|
bad.prefix = 11;
|
||||||
|
assert!(bad.validate().is_err());
|
||||||
|
let mut bad = valid.clone();
|
||||||
|
bad.headers[1] = bad.headers[0];
|
||||||
|
assert!(bad.validate().is_err());
|
||||||
|
let mut bad = valid.clone();
|
||||||
|
bad.junk_max = u16::MAX;
|
||||||
|
assert!(bad.validate().is_err());
|
||||||
|
let mut bad = valid.clone();
|
||||||
|
bad.padding = u16::MAX;
|
||||||
|
assert!(bad.validate().is_err());
|
||||||
|
let mut bad = valid.clone();
|
||||||
|
bad.psk = [0; 32];
|
||||||
|
assert!(bad.validate().is_err());
|
||||||
|
let mut bad = valid.clone();
|
||||||
|
bad.signature = 2;
|
||||||
|
assert!(bad.validate().is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn signature_templates_have_consistent_framing_and_fresh_fields() {
|
||||||
|
let mut profile = PairProfile::generate();
|
||||||
|
profile.signature = 0;
|
||||||
|
let dns = profile.signature_packets().remove(0);
|
||||||
|
assert_eq!(&dns[2..12], &[1, 0, 0, 1, 0, 0, 0, 0, 0, 0]);
|
||||||
|
let mut at = 12;
|
||||||
|
while dns[at] != 0 {
|
||||||
|
at += 1 + usize::from(dns[at]);
|
||||||
|
}
|
||||||
|
assert_eq!(at + 5, dns.len());
|
||||||
|
assert_eq!(&dns[dns.len() - 2..], &[0, 1]);
|
||||||
|
profile.signature = 1;
|
||||||
|
let sip = profile.signature_packets().remove(0);
|
||||||
|
assert!(sip.starts_with(b"OPTIONS sip:"));
|
||||||
|
assert!(sip.ends_with(b"Content-Length: 0\r\n\r\n"));
|
||||||
|
assert_ne!(sip, profile.signature_packets().remove(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
//! One cancellable actor and UDP socket per pair. All payload and health frames
|
||||||
|
//! are inside the Amnezia-masked authenticated WireGuard tunnel.
|
||||||
|
use crate::profile::PairProfile;
|
||||||
|
use boringtun::noise::{Tunn, TunnResult};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use iroh::EndpointId;
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::net::{Ipv4Addr, SocketAddr};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
use tokio::sync::{mpsc, watch};
|
||||||
|
use tsunagi::dataplane::transport::{PacketLink, TransportError};
|
||||||
|
use tsunagi::{BoxFuture, NetworkId};
|
||||||
|
|
||||||
|
pub(crate) const MAX_PAYLOAD: usize = tsunagi::config::MAX_DATA_DATAGRAM;
|
||||||
|
const CHUNK: usize = 700;
|
||||||
|
const QUEUE: usize = 64;
|
||||||
|
const WINDOW: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
|
pub(crate) struct PairLink {
|
||||||
|
network: NetworkId,
|
||||||
|
protocol: &'static str,
|
||||||
|
peer: EndpointId,
|
||||||
|
pub(crate) attempt: [u8; 16],
|
||||||
|
tx: mpsc::Sender<Bytes>,
|
||||||
|
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
|
||||||
|
stop: watch::Sender<bool>,
|
||||||
|
closed: AtomicBool,
|
||||||
|
remote: Mutex<Option<SocketAddr>>,
|
||||||
|
reason: Mutex<Option<String>>,
|
||||||
|
checks: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
impl std::fmt::Debug for PairLink {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("AwgPair")
|
||||||
|
.field("network", &self.network)
|
||||||
|
.field("peer", &self.peer)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl PairLink {
|
||||||
|
pub(crate) fn delivery_checks(&self) -> u64 {
|
||||||
|
self.checks.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
pub(crate) fn has_authenticated_path(&self) -> bool {
|
||||||
|
match self.remote.lock() {
|
||||||
|
Ok(remote) => remote.is_some(),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl PacketLink for PairLink {
|
||||||
|
fn network(&self) -> NetworkId {
|
||||||
|
self.network
|
||||||
|
}
|
||||||
|
fn peer(&self) -> EndpointId {
|
||||||
|
self.peer
|
||||||
|
}
|
||||||
|
fn max_datagram_size(&self) -> usize {
|
||||||
|
MAX_PAYLOAD
|
||||||
|
}
|
||||||
|
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
|
||||||
|
if self.is_closed() {
|
||||||
|
return Err(TransportError::Closed);
|
||||||
|
}
|
||||||
|
if payload.len() > MAX_PAYLOAD {
|
||||||
|
return Err(TransportError::TooLarge {
|
||||||
|
size: payload.len(),
|
||||||
|
limit: MAX_PAYLOAD,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.tx
|
||||||
|
.try_send(payload)
|
||||||
|
.map_err(|_| TransportError::Other("AWG send queue full or closed".into()))
|
||||||
|
}
|
||||||
|
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.closed.load(Ordering::Acquire) || *self.stop.borrow()
|
||||||
|
}
|
||||||
|
fn close(&self) {
|
||||||
|
self.stop.send_replace(true);
|
||||||
|
}
|
||||||
|
fn failure_reason(&self) -> Option<String> {
|
||||||
|
match self.reason.lock() {
|
||||||
|
Ok(reason) => reason.clone(),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn path_description(&self) -> String {
|
||||||
|
match self.remote.lock() {
|
||||||
|
Ok(addr) => match *addr {
|
||||||
|
Some(addr) => format!("{} UDP via {addr}", self.protocol),
|
||||||
|
None => format!("{} UDP, establishing", self.protocol),
|
||||||
|
},
|
||||||
|
Err(_) => "awg UDP".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for PairLink {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop.send_replace(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn start(
|
||||||
|
pair: (NetworkId, EndpointId),
|
||||||
|
socket: UdpSocket,
|
||||||
|
tunn: Tunn,
|
||||||
|
profile: PairProfile,
|
||||||
|
candidates: Vec<SocketAddr>,
|
||||||
|
protocol: &'static str,
|
||||||
|
parent: watch::Receiver<bool>,
|
||||||
|
) -> Arc<PairLink> {
|
||||||
|
let (network, peer) = pair;
|
||||||
|
let (tx, rx) = mpsc::channel(QUEUE);
|
||||||
|
let (in_tx, in_rx) = mpsc::channel(QUEUE);
|
||||||
|
let (stop, _) = watch::channel(false);
|
||||||
|
let link = Arc::new(PairLink {
|
||||||
|
network,
|
||||||
|
protocol,
|
||||||
|
peer,
|
||||||
|
attempt: profile.id(),
|
||||||
|
tx,
|
||||||
|
rx: tokio::sync::Mutex::new(in_rx),
|
||||||
|
stop: stop.clone(),
|
||||||
|
closed: AtomicBool::new(false),
|
||||||
|
remote: Mutex::new(None),
|
||||||
|
reason: Mutex::new(None),
|
||||||
|
checks: Arc::new(AtomicU64::new(0)),
|
||||||
|
});
|
||||||
|
let weak = Arc::downgrade(&link);
|
||||||
|
let checks = link.checks.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut engine = Engine {
|
||||||
|
stop: stop.subscribe(),
|
||||||
|
socket,
|
||||||
|
tunn,
|
||||||
|
profile,
|
||||||
|
candidates,
|
||||||
|
remote: None,
|
||||||
|
inbox: in_tx,
|
||||||
|
rx,
|
||||||
|
counter: rand::random(),
|
||||||
|
sequence: 0,
|
||||||
|
pending: VecDeque::new(),
|
||||||
|
samples: VecDeque::new(),
|
||||||
|
assemblies: HashMap::new(),
|
||||||
|
started: Instant::now(),
|
||||||
|
last_authenticated: Instant::now(),
|
||||||
|
checks,
|
||||||
|
};
|
||||||
|
engine.run(parent, &weak).await;
|
||||||
|
if let Some(link) = weak.upgrade() {
|
||||||
|
link.closed.store(true, Ordering::Release);
|
||||||
|
link.stop.send_replace(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
link
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Assembly {
|
||||||
|
created: Instant,
|
||||||
|
count: u16,
|
||||||
|
chunks: Vec<Option<Bytes>>,
|
||||||
|
}
|
||||||
|
struct Engine {
|
||||||
|
stop: watch::Receiver<bool>,
|
||||||
|
socket: UdpSocket,
|
||||||
|
tunn: Tunn,
|
||||||
|
profile: PairProfile,
|
||||||
|
candidates: Vec<SocketAddr>,
|
||||||
|
remote: Option<SocketAddr>,
|
||||||
|
inbox: mpsc::Sender<Bytes>,
|
||||||
|
rx: mpsc::Receiver<Bytes>,
|
||||||
|
counter: u64,
|
||||||
|
sequence: u64,
|
||||||
|
pending: VecDeque<(u64, Instant)>,
|
||||||
|
samples: VecDeque<bool>,
|
||||||
|
assemblies: HashMap<u64, Assembly>,
|
||||||
|
started: Instant,
|
||||||
|
last_authenticated: Instant,
|
||||||
|
checks: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
enum ResultOwned {
|
||||||
|
Wire(Vec<u8>),
|
||||||
|
Plain(Vec<u8>),
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
fn own(result: TunnResult<'_>) -> ResultOwned {
|
||||||
|
match result {
|
||||||
|
TunnResult::WriteToNetwork(p) => ResultOwned::Wire(p.to_vec()),
|
||||||
|
TunnResult::WriteToTunnelV4(p, _) => ResultOwned::Plain(p.to_vec()),
|
||||||
|
_ => ResultOwned::Done,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn ip(payload: &[u8]) -> Bytes {
|
||||||
|
let mut packet = vec![0; 20];
|
||||||
|
packet[0] = 0x45;
|
||||||
|
packet[8] = 64;
|
||||||
|
packet[9] = 253;
|
||||||
|
packet[12..16].copy_from_slice(&Ipv4Addr::new(192, 0, 2, 1).octets());
|
||||||
|
packet[16..20].copy_from_slice(&Ipv4Addr::new(192, 0, 2, 2).octets());
|
||||||
|
packet[2..4].copy_from_slice(&((20 + payload.len()) as u16).to_be_bytes());
|
||||||
|
let sum = packet
|
||||||
|
.as_chunks::<2>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|b| u32::from(u16::from_be_bytes([b[0], b[1]])))
|
||||||
|
.sum::<u32>();
|
||||||
|
let sum = (sum & 0xffff) + (sum >> 16);
|
||||||
|
packet[10..12].copy_from_slice(&(!(sum as u16)).to_be_bytes());
|
||||||
|
packet.extend_from_slice(payload);
|
||||||
|
Bytes::from(packet)
|
||||||
|
}
|
||||||
|
impl Engine {
|
||||||
|
async fn run(&mut self, mut stop: watch::Receiver<bool>, link: &std::sync::Weak<PairLink>) {
|
||||||
|
if *self.stop.borrow() || *stop.borrow() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut buffer = [0; 1201];
|
||||||
|
let mut scratch = [0; 4096];
|
||||||
|
let mut timer = tokio::time::interval(Duration::from_millis(250));
|
||||||
|
timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
let mut last_probe = Instant::now() - Duration::from_secs(2);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
_=stop.changed()=>break,
|
||||||
|
_=self.stop.changed()=>break,
|
||||||
|
_=timer.tick()=>{
|
||||||
|
let result=own(self.tunn.update_timers(&mut scratch));self.process(result,None).await;
|
||||||
|
if last_probe.elapsed()>=Duration::from_secs(1) {
|
||||||
|
last_probe=Instant::now();self.sequence=self.sequence.wrapping_add(1);
|
||||||
|
self.pending.push_back((self.sequence,Instant::now()));
|
||||||
|
let mut probe=vec![1];probe.extend_from_slice(&self.sequence.to_be_bytes());
|
||||||
|
probe.resize(713,0);rand::fill(&mut probe[9..]);self.encrypt(&probe).await;
|
||||||
|
}
|
||||||
|
while self.pending.front().is_some_and(|(_,at)|at.elapsed()>=Duration::from_secs(3)) {
|
||||||
|
self.pending.pop_front();self.samples.push_back(false);
|
||||||
|
}
|
||||||
|
while self.samples.len()>20 {self.samples.pop_front();}
|
||||||
|
self.assemblies.retain(|_,a|a.created.elapsed()<Duration::from_secs(5));
|
||||||
|
if self.started.elapsed()>WINDOW && (self.last_authenticated.elapsed()>Duration::from_secs(10)
|
||||||
|
|| (self.samples.len()>=15&&self.samples.iter().filter(|ok|!**ok).count()*100>=self.samples.len()*60)) {
|
||||||
|
if let Some(link)=link.upgrade() && let Ok(mut reason)=link.reason.lock() {*reason=Some("AWG data delivery checks: persistent loss or timeout".into());}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packet=self.socket.recv_from(&mut buffer)=>{
|
||||||
|
if let Ok((len,source))=packet && let Some(message)=self.profile.decode(&buffer[..len]) {
|
||||||
|
let result=own(self.tunn.decapsulate(None,&message,&mut scratch));
|
||||||
|
if matches!(result,ResultOwned::Plain(_)) {
|
||||||
|
self.remote=Some(source);self.last_authenticated=Instant::now();
|
||||||
|
if let Some(link)=link.upgrade() && let Ok(mut remote)=link.remote.lock(){*remote=Some(source);}
|
||||||
|
}
|
||||||
|
self.process(result,Some(source)).await;
|
||||||
|
for _ in 0..16 {
|
||||||
|
let result=own(self.tunn.decapsulate(None,&[],&mut scratch));
|
||||||
|
if matches!(result,ResultOwned::Done){break;}
|
||||||
|
self.process(result,Some(source)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packet=self.rx.recv()=>{
|
||||||
|
let Some(packet)=packet else{break;};
|
||||||
|
let count=packet.len().max(1).div_ceil(CHUNK) as u16;
|
||||||
|
self.counter=self.counter.wrapping_add(1);
|
||||||
|
for (index,chunk) in packet.chunks(CHUNK).enumerate() {
|
||||||
|
let mut payload=vec![3];payload.extend_from_slice(&self.counter.to_be_bytes());
|
||||||
|
payload.extend_from_slice(&(index as u16).to_be_bytes());payload.extend_from_slice(&count.to_be_bytes());
|
||||||
|
payload.extend_from_slice(chunk);self.encrypt(&payload).await;
|
||||||
|
// Large logical datagrams must let local readers and other
|
||||||
|
// pairs drain their bounded socket/command queues.
|
||||||
|
if (index + 1).is_multiple_of(16) {tokio::task::yield_now().await;}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn encrypt(&mut self, payload: &[u8]) {
|
||||||
|
let packet = self.profile.pad(ip(payload));
|
||||||
|
let mut scratch = [0; 4096];
|
||||||
|
let result = own(self.tunn.encapsulate(&packet, &mut scratch));
|
||||||
|
self.process(result, None).await;
|
||||||
|
}
|
||||||
|
async fn wire(&self, raw: &[u8], target: Option<SocketAddr>) {
|
||||||
|
let Some(wire) = self.profile.encode(raw) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let targets: Vec<_> = target
|
||||||
|
.or(self.remote)
|
||||||
|
.map(|a| vec![a])
|
||||||
|
.unwrap_or_else(|| self.candidates.clone());
|
||||||
|
for addr in targets {
|
||||||
|
if raw.first() == Some(&1) {
|
||||||
|
for signature in self.profile.signature_packets() {
|
||||||
|
let _ = self.send_wire(&signature, addr).await;
|
||||||
|
}
|
||||||
|
for junk in self.profile.junk_packets() {
|
||||||
|
let _ = self.send_wire(&junk, addr).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = self.send_wire(&wire, addr).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn send_wire(&self, bytes: &[u8], address: SocketAddr) {
|
||||||
|
let mut stop = self.stop.clone();
|
||||||
|
if *stop.borrow() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::select! {biased; _=stop.changed()=>{}, _=tokio::time::timeout(Duration::from_millis(100),self.socket.send_to(bytes,address))=>{}}
|
||||||
|
}
|
||||||
|
async fn process(&mut self, result: ResultOwned, target: Option<SocketAddr>) {
|
||||||
|
match result {
|
||||||
|
ResultOwned::Wire(raw) => self.wire(&raw, target).await,
|
||||||
|
ResultOwned::Plain(packet) => {
|
||||||
|
let Some(payload) = packet.get(20..) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(seq) = payload
|
||||||
|
.get(1..9)
|
||||||
|
.and_then(|b| b.try_into().ok())
|
||||||
|
.map(u64::from_be_bytes)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match payload.first() {
|
||||||
|
Some(1) => {
|
||||||
|
let mut pong = vec![2];
|
||||||
|
pong.extend_from_slice(&seq.to_be_bytes());
|
||||||
|
pong.resize(713, 0);
|
||||||
|
let packet = self.profile.pad(ip(&pong));
|
||||||
|
let mut scratch = [0; 4096];
|
||||||
|
if let ResultOwned::Wire(raw) =
|
||||||
|
own(self.tunn.encapsulate(&packet, &mut scratch))
|
||||||
|
{
|
||||||
|
self.wire(&raw, target).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(2) => {
|
||||||
|
if let Some(index) =
|
||||||
|
self.pending.iter().position(|(pending, _)| *pending == seq)
|
||||||
|
{
|
||||||
|
self.pending.remove(index);
|
||||||
|
self.samples.push_back(true);
|
||||||
|
self.checks.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(3) => self.fragment(seq, payload),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResultOwned::Done => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn fragment(&mut self, id: u64, payload: &[u8]) {
|
||||||
|
let Some(header) = payload.get(9..13) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let index = u16::from_be_bytes([header[0], header[1]]);
|
||||||
|
let count = u16::from_be_bytes([header[2], header[3]]);
|
||||||
|
let chunk = &payload[13..];
|
||||||
|
if count == 0
|
||||||
|
|| usize::from(count) > MAX_PAYLOAD.div_ceil(CHUNK)
|
||||||
|
|| index >= count
|
||||||
|
|| chunk.len() > CHUNK
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !self.assemblies.contains_key(&id) && self.assemblies.len() >= 8 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let assembly = self.assemblies.entry(id).or_insert_with(|| Assembly {
|
||||||
|
created: Instant::now(),
|
||||||
|
count,
|
||||||
|
chunks: vec![None; count.into()],
|
||||||
|
});
|
||||||
|
if assembly.count != count {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assembly.chunks[usize::from(index)] = Some(Bytes::copy_from_slice(chunk));
|
||||||
|
if assembly.chunks.iter().all(Option::is_some)
|
||||||
|
&& let Some(assembly) = self.assemblies.remove(&id)
|
||||||
|
{
|
||||||
|
let mut packet = Vec::new();
|
||||||
|
for chunk in assembly.chunks.into_iter().flatten() {
|
||||||
|
packet.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
if packet.len() <= MAX_PAYLOAD {
|
||||||
|
let _ = self.inbox.try_send(Bytes::from(packet));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 207 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user