Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2166ba4800 | ||
|
|
8a6216bccb | ||
|
|
2e7e238459 | ||
|
|
23af1a2d9d | ||
|
|
94318e7c16 | ||
|
|
2b0c938487 | ||
|
|
4e235cbac6 | ||
|
|
2631eb6975 | ||
|
|
90ef5cefec | ||
|
|
13bdcaf98d | ||
|
|
d941cdac7a | ||
|
|
e701986a39 | ||
|
|
ed28e86748 | ||
|
|
781e359775 | ||
|
|
d5e02ec256 |
No files matched your search
@@ -1 +1,2 @@
|
||||
/target
|
||||
/.claude
|
||||
@@ -162,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/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-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 |
|
||||
| `tests/` | integration tests; `tests/common/` is the shared harness |
|
||||
|
||||
|
||||
Generated
+105
-16
@@ -601,6 +601,12 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
@@ -1171,6 +1177,46 @@ dependencies = [
|
||||
"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]]
|
||||
name = "der"
|
||||
version = "0.8.2"
|
||||
@@ -2366,6 +2412,15 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -2404,6 +2459,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -2806,8 +2871,6 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
[[package]]
|
||||
name = "iroh"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2f8d1cfffc83efe39a1031aab423ce09cb8048071baba550508931e9a81ce46"
|
||||
dependencies = [
|
||||
"backon",
|
||||
"blake3",
|
||||
@@ -3317,6 +3380,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "managed"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -5518,6 +5587,20 @@ dependencies = [
|
||||
"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]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
@@ -6241,11 +6324,13 @@ dependencies = [
|
||||
"iroh",
|
||||
"netwatch",
|
||||
"nix 0.31.3",
|
||||
"serde_json",
|
||||
"simple-dns",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tsunagi-awg",
|
||||
"tsunagi-core",
|
||||
"tsunagi-tcp-tls",
|
||||
"tsunagi-wg-quic",
|
||||
@@ -6253,6 +6338,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tsunagi-core"
|
||||
version = "0.1.0"
|
||||
@@ -6301,27 +6401,18 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"caps",
|
||||
"chacha20poly1305",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"iroh",
|
||||
"netdev 0.46.3",
|
||||
"postcard",
|
||||
"rand 0.10.3",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tsunagi-core",
|
||||
"x25519-dalek",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6361,6 +6452,8 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tsunagi-core",
|
||||
"tsunagi-tcp-tls",
|
||||
"tsunagi-wireguard",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -6370,17 +6463,13 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"boringtun",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"hex",
|
||||
"chacha20 0.9.1",
|
||||
"iroh",
|
||||
"netdev 0.46.3",
|
||||
"postcard",
|
||||
"rand 0.10.3",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
||||
+3
-1
@@ -4,12 +4,14 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*"]
|
||||
exclude = ["vendor/mainline"]
|
||||
exclude = ["vendor/mainline", "vendor/iroh"]
|
||||
|
||||
# Mainline 8.0.0 treats Windows UDP read timeouts as socket failures.
|
||||
# Keep the small receive-loop correction reproducible until an upstream fix.
|
||||
[patch.crates-io]
|
||||
mainline = { path = "vendor/mainline" }
|
||||
# Backport bounded path retries until an iroh release includes the OOM fix.
|
||||
iroh = { path = "vendor/iroh" }
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
@@ -81,9 +81,32 @@ tsng join -n home -s tsn1… # every other machine
|
||||
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`.
|
||||
|
||||
### 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
|
||||
|
||||
```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.
|
||||
[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
|
||||
|
||||
| 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,
|
||||
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
|
||||
|
||||
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) ·
|
||||
[Protocol](docs/protocol.md) ·
|
||||
[WireGuard data plane](docs/wireguard.md) ·
|
||||
[Pairwise Amnezia transport](docs/amnezia.md) ·
|
||||
[Routing](docs/routing.md) ·
|
||||
[Exit nodes](docs/exit-node.md) ·
|
||||
[Virtual services](docs/vip.md) ·
|
||||
[Threat model](docs/threat-model.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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,14 +27,14 @@ mod status;
|
||||
pub use events::Event;
|
||||
pub use status::{
|
||||
AgentStatus, CandidateStatus, DataPath, HostnameConflict, MemberStatus, NetworkMetrics,
|
||||
NetworkState, NetworkStatus, OverlayStatus, PeerStatus,
|
||||
NetworkState, NetworkStatus, OverlayStatus, PeerStatus, RangeConflict,
|
||||
};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
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 crate::BoxFuture;
|
||||
@@ -58,6 +58,8 @@ use shutdown::Shutdown;
|
||||
/// Summary of a configured network, whether or not it is running.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfiguredNetwork {
|
||||
/// Enabled local data-plane protocols, in preference order.
|
||||
pub protocols: Vec<String>,
|
||||
/// Saved local broadcast participation.
|
||||
pub broadcast: bool,
|
||||
/// Public network identifier.
|
||||
@@ -70,6 +72,17 @@ pub struct ConfiguredNetwork {
|
||||
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.
|
||||
///
|
||||
/// Cloning gives another handle to the same agent.
|
||||
@@ -90,6 +103,10 @@ struct Inner {
|
||||
events: broadcast::Sender<Event>,
|
||||
networks: RwLock<HashMap<NetworkId, NetworkHandle>>,
|
||||
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<()>>>,
|
||||
plugin_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||
transport: std::sync::OnceLock<Arc<dyn PacketTransport>>,
|
||||
@@ -100,6 +117,41 @@ struct 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.
|
||||
fn read_hostname(&self) -> String {
|
||||
match self.hostname.read() {
|
||||
@@ -140,14 +192,13 @@ impl PacketCarrier for Carrier {
|
||||
let Some(inner) = self.0.upgrade() else {
|
||||
return false;
|
||||
};
|
||||
// Offered to each protocol in turn. With one configured this is a
|
||||
// single call; the shape is what allows several to be live at once,
|
||||
// each carrying the peers it has a link to.
|
||||
// Every routed IP packet enters the same internal cipher, whatever
|
||||
// physical carrier currently supplies the logical peer link.
|
||||
inner
|
||||
.config
|
||||
.plugins
|
||||
.iter()
|
||||
.any(|plugin| plugin.carry(route.network, route.peer, packet.clone()))
|
||||
.tunnel
|
||||
.as_ref()
|
||||
.is_some_and(|tunnel| tunnel.carry(route.network, route.peer, packet))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +234,35 @@ impl PacketSink for Sink {
|
||||
#[derive(Debug)]
|
||||
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 {
|
||||
fn snapshot(&self) -> crate::BoxFuture<'_, HashMap<NetworkId, NetworkKeys>> {
|
||||
Box::pin(async move {
|
||||
@@ -209,7 +289,7 @@ impl TransportContext for TransportCtx {
|
||||
}
|
||||
inner
|
||||
.config
|
||||
.plugins
|
||||
.carriers
|
||||
.iter()
|
||||
.any(|plugin| plugin.protocol_id() == protocol)
|
||||
})
|
||||
@@ -217,12 +297,136 @@ impl TransportContext for TransportCtx {
|
||||
}
|
||||
|
||||
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.
|
||||
///
|
||||
/// Opens the state store (taking its ownership lock), restores the
|
||||
/// persistent device identity, binds the iroh endpoint and activates every
|
||||
/// configured network whose auto-start flag is set.
|
||||
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 identity = storage.device_identity().await?;
|
||||
let adapter = EndpointAdapter::bind(&config, &identity).await?;
|
||||
@@ -234,6 +438,7 @@ impl Agent {
|
||||
let (events, _) = broadcast::channel(config.limits.event_buffer);
|
||||
let limits = Arc::new(config.limits.clone());
|
||||
|
||||
let (range_changed, range_updates) = watch::channel(());
|
||||
let inner = Arc::new(Inner {
|
||||
limits,
|
||||
storage,
|
||||
@@ -243,6 +448,9 @@ impl Agent {
|
||||
events,
|
||||
networks: RwLock::new(HashMap::new()),
|
||||
shutdown: Shutdown::new(),
|
||||
lifecycle: Mutex::new(()),
|
||||
range_changed,
|
||||
range_task: std::sync::Mutex::new(None),
|
||||
accept_task: std::sync::Mutex::new(None),
|
||||
plugin_task: std::sync::Mutex::new(None),
|
||||
transport: std::sync::OnceLock::new(),
|
||||
@@ -251,31 +459,13 @@ impl Agent {
|
||||
config,
|
||||
});
|
||||
|
||||
// One interface for the agent, if it was given a way to make one.
|
||||
// Failing to is reported and not fatal: the control plane works, and
|
||||
// so do the protocols, they just have nowhere to put packets.
|
||||
if let Some(factory) = inner.config.tun_factory.clone() {
|
||||
let carrier = Arc::new(Carrier(Arc::downgrade(&inner))) as Arc<dyn PacketCarrier>;
|
||||
match crate::overlay::Interface::start(
|
||||
factory,
|
||||
inner.config.interface_name.clone(),
|
||||
inner.config.interface_mtu,
|
||||
Arc::clone(&inner.routes),
|
||||
carrier,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(interface) => {
|
||||
let _ = inner.interface.set(Arc::new(interface));
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = inner.events.send(Event::PluginError {
|
||||
network: NetworkId::from_bytes([0u8; 32]),
|
||||
protocol: "overlay".into(),
|
||||
reason: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if inner
|
||||
.config
|
||||
.default_protocols
|
||||
.as_ref()
|
||||
.is_none_or(|names| !names.is_empty())
|
||||
{
|
||||
inner.ensure_interface().await;
|
||||
}
|
||||
|
||||
// The data plane rides on iroh too, which is where it gets hole
|
||||
@@ -294,6 +484,7 @@ impl Agent {
|
||||
inner.config.custom_transports.clone(),
|
||||
))
|
||||
};
|
||||
transport.attach_control(Arc::new(TransportCtx(Arc::downgrade(&inner))));
|
||||
let _ = inner.transport.set(transport);
|
||||
|
||||
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.
|
||||
// A bounded queue keeps a noisy plugin from growing memory without
|
||||
// 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 sink = inner
|
||||
.interface
|
||||
.get()
|
||||
.config
|
||||
.tun_factory
|
||||
.as_ref()
|
||||
.map(|_| Arc::new(Sink(Arc::downgrade(&inner))) as Arc<dyn PacketSink>);
|
||||
let context = PluginContext::new(plugin_tx, inner.identity.endpoint_id(), sink);
|
||||
for plugin in &inner.config.plugins {
|
||||
plugin.attach(context.clone());
|
||||
if let Some(tunnel) = &inner.config.tunnel {
|
||||
tunnel.attach(context);
|
||||
}
|
||||
let task = tokio::spawn(plugin_request_loop(Arc::downgrade(&inner), plugin_rx));
|
||||
if let Ok(mut guard) = inner.plugin_task.lock() {
|
||||
@@ -326,11 +518,16 @@ impl Agent {
|
||||
|
||||
let agent = Self { inner };
|
||||
|
||||
for stored in agent.inner.storage.list_networks().await? {
|
||||
if stored.auto_start {
|
||||
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
||||
agent.activate_with_keys(keys).await?;
|
||||
}
|
||||
{
|
||||
let _guard = agent.inner.lifecycle.lock().await;
|
||||
agent.resume_networks_locked().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)
|
||||
@@ -374,100 +571,65 @@ impl Agent {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reserves the configured overlay range for a network, if it can.
|
||||
///
|
||||
/// Done here, where activations are serialised, rather than inside the
|
||||
/// runtime: whether a range is free depends on what the other networks
|
||||
/// took, and deciding that in a task would make the answer depend on
|
||||
/// which task ran first.
|
||||
///
|
||||
/// `None` means this agent will not propose a range for that network and
|
||||
/// waits to adopt whatever it settles on. One agent has one interface, so
|
||||
/// proposing a range it could not route would be worse than having none:
|
||||
/// the lowest author's range wins, and the collision would spread.
|
||||
async fn reserve_range(&self, network: NetworkId) -> RangePlan {
|
||||
let reserve = |wanted: Ipv4Range| {
|
||||
let reservation = crate::overlay::NetworkRoutes {
|
||||
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 {
|
||||
None
|
||||
},
|
||||
..RangePlan::default()
|
||||
};
|
||||
/// Signed state always wins over a default or an explicit proposal.
|
||||
async fn desired_range(&self, network: NetworkId) -> Result<Option<Ipv4Range>> {
|
||||
let records = self.inner.storage.signed_records(network).await?;
|
||||
let mut state = crate::state::StateSet::new();
|
||||
state.merge_all(network, records);
|
||||
Ok(state.agreed_range().or_else(|| {
|
||||
if self.inner.config.overlay_ipv4_auto {
|
||||
Some(crate::state::derived_ipv4_range(network))
|
||||
} 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()
|
||||
};
|
||||
self.inner.config.overlay_ipv4_range
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn range_conflicts(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
range: Option<Ipv4Range>,
|
||||
) -> Result<Vec<RangeConflict>> {
|
||||
let Some(range) = range else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
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())
|
||||
}
|
||||
|
||||
/// Starts requested networks in saved preference order, skipping overlaps.
|
||||
/// Called with the lifecycle lock held; skipped networks remain requested.
|
||||
async fn resume_networks_locked(&self) -> Result<()> {
|
||||
let order = self.inner.storage.network_start_order().await?;
|
||||
let mut stored = self.inner.storage.list_networks().await?;
|
||||
stored.sort_by_key(|n| {
|
||||
order
|
||||
.iter()
|
||||
.position(|id| *id == n.network_id)
|
||||
.unwrap_or(usize::MAX)
|
||||
});
|
||||
for network in stored {
|
||||
if !network.auto_start || self.is_active(network.network_id).await {
|
||||
continue;
|
||||
}
|
||||
let keys = NetworkKeys::derive(&network.name, &network.secret);
|
||||
match self.activate_with_keys_locked(keys).await {
|
||||
Ok(()) | Err(Error::NetworkRangeConflict { .. }) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
let Some(configured) = self.inner.config.overlay_ipv4_range else {
|
||||
return RangePlan::default();
|
||||
};
|
||||
|
||||
// The configured range first, so a network a device has always had
|
||||
// keeps the addresses it has always had.
|
||||
if reserve(configured) {
|
||||
return RangePlan {
|
||||
propose: Some(configured),
|
||||
..RangePlan::default()
|
||||
};
|
||||
}
|
||||
|
||||
// A second network on the same agent cannot have it — one agent,
|
||||
// one interface — so it falls back to the range derived from its
|
||||
// own id, which every one of its members derives identically
|
||||
// without being told.
|
||||
let derived = crate::state::derived_ipv4_range(network);
|
||||
if reserve(derived) {
|
||||
tracing::info!(
|
||||
%network,
|
||||
range = %derived,
|
||||
"another network here holds the configured range; this one uses the \
|
||||
range derived from its id unless its members settled another"
|
||||
);
|
||||
return RangePlan {
|
||||
fallback: Some(derived),
|
||||
conflict: Some(configured),
|
||||
..RangePlan::default()
|
||||
};
|
||||
}
|
||||
|
||||
tracing::info!(%network, "not proposing a range for this network");
|
||||
RangePlan {
|
||||
conflict: Some(configured),
|
||||
..RangePlan::default()
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// that is already active succeeds and changes nothing. That matters
|
||||
/// because a configured network is activated automatically at startup, so
|
||||
@@ -599,7 +764,9 @@ impl Agent {
|
||||
}
|
||||
match self.activate_with_keys(keys).await {
|
||||
// Already a member of exactly this network space: nothing to do.
|
||||
Ok(()) | Err(Error::NetworkAlreadyActive(_)) => Ok(network_id),
|
||||
Ok(())
|
||||
| Err(Error::NetworkAlreadyActive(_))
|
||||
| Err(Error::NetworkRangeConflict { .. }) => Ok(network_id),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
@@ -703,7 +870,44 @@ impl Agent {
|
||||
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<()> {
|
||||
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() {
|
||||
return Err(Error::Stopped);
|
||||
}
|
||||
@@ -712,8 +916,70 @@ impl Agent {
|
||||
if networks.contains_key(&network_id) {
|
||||
return Err(Error::NetworkAlreadyActive(network_id));
|
||||
}
|
||||
|
||||
let range = self.reserve_range(network_id).await;
|
||||
let stored = self
|
||||
.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();
|
||||
if let Some(discovery) = &self.inner.config.discovery {
|
||||
backends.push(discovery.clone());
|
||||
@@ -729,20 +995,22 @@ impl Agent {
|
||||
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 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_via = stored
|
||||
.as_ref()
|
||||
.and_then(|stored| stored.exit_via)
|
||||
.and_then(|bytes| EndpointId::from_bytes(&bytes).ok());
|
||||
let handle = network::spawn(RuntimeParams {
|
||||
vips,
|
||||
retirements,
|
||||
broadcast,
|
||||
exit_offer,
|
||||
exit_via,
|
||||
@@ -755,7 +1023,9 @@ impl Agent {
|
||||
discovery,
|
||||
discovery_interval: self.inner.config.discovery_interval,
|
||||
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),
|
||||
interface: self.inner.interface.get().cloned(),
|
||||
hostname: self.inner.read_hostname(),
|
||||
@@ -763,16 +1033,18 @@ impl Agent {
|
||||
device_secret: self.inner.identity.signing_key(),
|
||||
#[cfg(feature = "testing")]
|
||||
unreachable_data_peers: Arc::clone(&self.inner.config.unreachable_data_peers),
|
||||
ipv4_range: range.propose,
|
||||
ipv4_fallback: range.fallback,
|
||||
range_conflict: range.conflict,
|
||||
ipv4_range: range,
|
||||
range_changed: self.inner.range_changed.clone(),
|
||||
});
|
||||
networks.insert(network_id, handle);
|
||||
drop(networks);
|
||||
|
||||
for plugin in &self.inner.config.plugins {
|
||||
for plugin in &plugins {
|
||||
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 {
|
||||
network: network_id,
|
||||
@@ -786,6 +1058,30 @@ impl Agent {
|
||||
/// it does not remove this agent from anyone else's view of the network.
|
||||
/// Other networks keep running.
|
||||
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 mut networks = self.inner.networks.write().await;
|
||||
networks
|
||||
@@ -793,9 +1089,12 @@ impl Agent {
|
||||
.ok_or(Error::NetworkNotActive(network_id))?
|
||||
};
|
||||
handle.stop().await;
|
||||
for plugin in &self.inner.config.plugins {
|
||||
for plugin in &self.inner.config.carriers {
|
||||
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
|
||||
// itself stays, because the agent owns it and other networks may
|
||||
// still be using it.
|
||||
@@ -814,7 +1113,6 @@ impl Agent {
|
||||
interface.sync_broadcast_rules().await;
|
||||
interface.sync_exit_rules().await;
|
||||
}
|
||||
self.inner.storage.set_auto_start(network_id, false).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -824,17 +1122,18 @@ impl Agent {
|
||||
/// Local and silent: nobody is told. [`Agent::leave_network`] is the one
|
||||
/// that says goodbye first, and is what a person means by leaving.
|
||||
pub async fn forget_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
let _guard = self.inner.lifecycle.lock().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?;
|
||||
// Whatever a protocol kept for this network goes with it. The
|
||||
// agent cannot know what that is, only that there is no longer any
|
||||
// reason to hold it.
|
||||
for plugin in &self.inner.config.plugins {
|
||||
for plugin in &self.inner.config.carriers {
|
||||
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.
|
||||
@@ -890,6 +1189,7 @@ impl Agent {
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|stored| ConfiguredNetwork {
|
||||
protocols: self.effective_protocols(stored.protocols.as_ref()),
|
||||
broadcast: stored.broadcast,
|
||||
network_id: stored.network_id,
|
||||
name: stored.name,
|
||||
@@ -958,13 +1258,54 @@ impl Agent {
|
||||
let mut networks = Vec::new();
|
||||
for stored in self.inner.storage.list_networks().await? {
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
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 {
|
||||
vips,
|
||||
protocols: self.effective_protocols(stored.protocols.as_ref()),
|
||||
auto_start: stored.auto_start,
|
||||
broadcast: stored.broadcast,
|
||||
exit_offer: stored.exit_node,
|
||||
exit_via: stored
|
||||
@@ -978,12 +1319,13 @@ impl Agent {
|
||||
state: NetworkState::Inactive,
|
||||
peers: Vec::new(),
|
||||
candidates: Vec::new(),
|
||||
// An inactive network has no runtime to ask; the roster and
|
||||
// the range come from one. Empty, not invented.
|
||||
// The range is available from signed state or the deterministic
|
||||
// default even while the network has no runtime.
|
||||
members: Vec::new(),
|
||||
hostname_conflict: None,
|
||||
range: None,
|
||||
range_conflict: None,
|
||||
range,
|
||||
range_conflict: range_conflicts.first().map(|conflict| conflict.range),
|
||||
range_conflicts,
|
||||
metrics: NetworkMetrics::default(),
|
||||
relay: Default::default(),
|
||||
});
|
||||
@@ -1039,6 +1381,7 @@ impl Agent {
|
||||
/// After this returns, the state directory can be opened by another agent.
|
||||
pub async fn shutdown(&self) {
|
||||
self.inner.shutdown.trigger();
|
||||
let lifecycle_guard = self.inner.lifecycle.lock().await;
|
||||
|
||||
let handles: Vec<NetworkHandle> = {
|
||||
let mut networks = self.inner.networks.write().await;
|
||||
@@ -1048,6 +1391,7 @@ impl Agent {
|
||||
handle.stop().await;
|
||||
}
|
||||
|
||||
drop(lifecycle_guard);
|
||||
if let Some(dht) = &self.inner.config.dht {
|
||||
dht.shutdown().await;
|
||||
}
|
||||
@@ -1064,6 +1408,7 @@ impl Agent {
|
||||
for (what, handle) in [
|
||||
("inbound connections", &self.inner.accept_task),
|
||||
("plugin requests", &self.inner.plugin_task),
|
||||
("range conflicts", &self.inner.range_task),
|
||||
] {
|
||||
let task = handle.lock().ok().and_then(|mut guard| guard.take());
|
||||
if let Some(task) = task {
|
||||
@@ -1079,7 +1424,7 @@ impl Agent {
|
||||
|
||||
// Plugins remove whatever system objects they created. A plugin that
|
||||
// misbehaves here must not hold up the agent, so this is bounded.
|
||||
for plugin in &self.inner.config.plugins {
|
||||
for plugin in &self.inner.config.carriers {
|
||||
if tokio::time::timeout(PLUGIN_SHUTDOWN_GRACE, plugin.shutdown())
|
||||
.await
|
||||
.is_err()
|
||||
@@ -1091,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.
|
||||
self.inner.storage.release_ownership_lock();
|
||||
}
|
||||
@@ -1107,16 +1460,44 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a network may propose as its overlay range, and what it cannot have.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct RangePlan {
|
||||
/// Proposed straight away: the configured range, when it is free here.
|
||||
propose: Option<Ipv4Range>,
|
||||
/// Proposed after a moment, when the configured range is another
|
||||
/// network's: the range derived from this network's own id.
|
||||
fallback: Option<Ipv4Range>,
|
||||
/// The configured range, when this network cannot have it.
|
||||
conflict: Option<Ipv4Range>,
|
||||
/// Processes coalesced conflict notifications without blocking a network actor.
|
||||
async fn range_conflict_loop(inner: Weak<Inner>, mut updates: watch::Receiver<()>) {
|
||||
loop {
|
||||
let Some(owner) = inner.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let shutdown = owner.shutdown.clone();
|
||||
drop(owner);
|
||||
tokio::select! {
|
||||
_ = 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.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -70,7 +70,7 @@ pub(crate) struct Session {
|
||||
pub(crate) conn: Connection,
|
||||
/// Already encoded frame payloads, so the runtime can account for the exact
|
||||
/// 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) capabilities: Vec<PluginCapability>,
|
||||
pub(crate) broadcast: bool,
|
||||
@@ -244,7 +244,7 @@ pub(crate) fn spawn(
|
||||
|
||||
async fn writer_task(
|
||||
mut send: SendStream,
|
||||
mut outbound: mpsc::Receiver<Vec<u8>>,
|
||||
mut outbound: mpsc::Receiver<zeroize::Zeroizing<Vec<u8>>>,
|
||||
limits: Arc<Limits>,
|
||||
shutdown: Shutdown,
|
||||
parent: Shutdown,
|
||||
@@ -331,6 +331,7 @@ async fn reader_task(
|
||||
}
|
||||
};
|
||||
|
||||
let payload = zeroize::Zeroizing::new(payload);
|
||||
let bytes = payload.len();
|
||||
let envelope: Envelope = match decode(&payload) {
|
||||
Ok(envelope) => envelope,
|
||||
|
||||
@@ -108,6 +108,8 @@ pub struct HostnameConflict {
|
||||
/// connected.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemberStatus {
|
||||
/// Current signed claim version, used to bind cleanup confirmation.
|
||||
pub record_version: u64,
|
||||
/// The member's device identity.
|
||||
pub endpoint_id: EndpointId,
|
||||
/// The IPv4 overlay address it claimed and signed for.
|
||||
@@ -156,6 +158,8 @@ pub struct PeerStatus {
|
||||
pub protocols: Vec<String>,
|
||||
/// Capabilities the peer announced. Payloads stay opaque.
|
||||
pub capabilities: Vec<PluginCapability>,
|
||||
/// Why no data route is available, when known.
|
||||
pub data_error: Option<String>,
|
||||
/// How long the session has been up.
|
||||
pub connected_for: Duration,
|
||||
/// Verified paths of the underlying connection.
|
||||
@@ -209,9 +213,26 @@ pub struct NetworkMetrics {
|
||||
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.
|
||||
#[derive(Debug, Clone)]
|
||||
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.
|
||||
pub broadcast: bool,
|
||||
/// This agent offers itself as an exit node in this network.
|
||||
@@ -242,13 +263,10 @@ pub struct NetworkStatus {
|
||||
pub hostname_conflict: Option<HostnameConflict>,
|
||||
/// The overlay range this network uses, once it has one.
|
||||
pub range: Option<crate::state::Ipv4Range>,
|
||||
/// The range it could not have, because another network on this agent
|
||||
/// already holds it.
|
||||
///
|
||||
/// One agent has one interface, so an address belongs to one network.
|
||||
/// This network waits to adopt whatever its members settle on instead of
|
||||
/// proposing something it could not route.
|
||||
/// The first overlapping active range; `range_conflicts` identifies all blockers.
|
||||
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.
|
||||
pub metrics: NetworkMetrics,
|
||||
/// 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`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
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.
|
||||
pub max_frame_len: usize,
|
||||
/// Largest accepted hostname, in bytes.
|
||||
@@ -166,12 +182,16 @@ pub struct Limits {
|
||||
pub max_capabilities: usize,
|
||||
/// Largest opaque plugin payload, in bytes.
|
||||
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.
|
||||
pub max_echo_payload_len: usize,
|
||||
/// Largest accepted free-text reason string, in bytes.
|
||||
pub max_reason_len: usize,
|
||||
/// Largest number of signed records accepted in one snapshot.
|
||||
pub max_state_records: usize,
|
||||
/// Maximum durable retirement markers retained per network.
|
||||
pub max_retirements: usize,
|
||||
/// Deadline for the whole handshake.
|
||||
pub handshake_timeout: Duration,
|
||||
/// Deadline for one outbound dial attempt.
|
||||
@@ -201,13 +221,23 @@ pub struct Limits {
|
||||
impl Default for Limits {
|
||||
fn default() -> 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_hostname_len: 255,
|
||||
max_capabilities: 16,
|
||||
max_transport_payload_len: 4096,
|
||||
max_capability_data_len: 4 * 1024,
|
||||
max_echo_payload_len: 4 * 1024,
|
||||
max_reason_len: 256,
|
||||
max_state_records: crate::state::MAX_RECORDS_PER_MESSAGE,
|
||||
max_retirements: 1024,
|
||||
handshake_timeout: Duration::from_secs(10),
|
||||
dial_timeout: Duration::from_secs(10),
|
||||
write_timeout: Duration::from_secs(30),
|
||||
@@ -323,8 +353,13 @@ pub struct AgentConfig {
|
||||
pub limits: Limits,
|
||||
/// Reconnect backoff policy.
|
||||
pub reconnect: ReconnectPolicy,
|
||||
/// IP plugins whose capabilities are announced and dispatched.
|
||||
pub plugins: Vec<SharedPlugin>,
|
||||
/// External data carriers whose capabilities are announced and dispatched.
|
||||
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.
|
||||
pub custom_transports:
|
||||
std::collections::HashMap<String, Arc<dyn crate::dataplane::PacketTransport>>,
|
||||
@@ -339,7 +374,11 @@ pub struct AgentConfig {
|
||||
pub interface_name: String,
|
||||
/// The interface MTU.
|
||||
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
|
||||
/// participant keeps the same one across restarts. A network that has
|
||||
@@ -368,12 +407,15 @@ impl AgentConfig {
|
||||
discovery_interval: Duration::from_secs(5),
|
||||
limits: Limits::default(),
|
||||
reconnect: ReconnectPolicy::default(),
|
||||
plugins: Vec::new(),
|
||||
carriers: Vec::new(),
|
||||
tunnel: None,
|
||||
default_protocols: None,
|
||||
custom_transports: std::collections::HashMap::new(),
|
||||
tun_factory: None,
|
||||
interface_name: "tsun0".to_string(),
|
||||
interface_mtu: 1280,
|
||||
overlay_ipv4_range: Some(crate::state::DEFAULT_IPV4_RANGE),
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
/// Registers an IP plugin.
|
||||
pub fn with_plugin(mut self, plugin: SharedPlugin) -> Self {
|
||||
self.plugins.push(plugin);
|
||||
/// Registers an external data carrier.
|
||||
pub fn with_carrier(mut self, plugin: crate::dataplane::transport::SharedCarrier) -> Self {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -505,9 +554,9 @@ impl std::fmt::Debug for AgentConfig {
|
||||
.field("limits", &self.limits)
|
||||
.field("reconnect", &self.reconnect)
|
||||
.field(
|
||||
"plugins",
|
||||
"carriers",
|
||||
&self
|
||||
.plugins
|
||||
.carriers
|
||||
.iter()
|
||||
.map(|p| p.protocol_id().to_string())
|
||||
.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
|
||||
//! first planned plugin; none is implemented here.
|
||||
//! The core moves bounded opaque capabilities without parsing cipher payloads.
|
||||
//! 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:
|
||||
//!
|
||||
//! 1. **The core never parses plugin payloads.** A [`PluginCapability`] carries
|
||||
//! a protocol id, a version, an enabled flag and a bounded opaque blob. The
|
||||
//! 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.
|
||||
//! [`transport::DataCarrier`] owns external capability negotiation and lifecycle.
|
||||
//! It sees reachability but never plaintext overlay packets. A relay forwards
|
||||
//! the encrypted inner bytes without entering an IP plugin or the TUN.
|
||||
//! Data-plane failure never stops control sessions or discovery.
|
||||
|
||||
pub mod relay;
|
||||
pub mod routing;
|
||||
pub mod transport;
|
||||
pub mod tunnel;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -35,11 +26,37 @@ use crate::identity::NetworkId;
|
||||
pub use transport::{
|
||||
MultiPacketTransport, PacketLink, PacketTransport, SharedLink, TransportError,
|
||||
};
|
||||
pub use tunnel::{GenericTunnelPlugin, TunnelCodec};
|
||||
|
||||
/// Maximum length of a plugin protocol identifier.
|
||||
pub const MAX_PROTOCOL_ID_LEN: usize = 32;
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// `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
|
||||
/// only about what outlives a session. Defaulted to nothing, for a
|
||||
/// 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.
|
||||
///
|
||||
|
||||
@@ -492,11 +492,12 @@ impl RelayHub {
|
||||
}
|
||||
}
|
||||
|
||||
/// Revokes all links and topology involving a departed authenticated peer.
|
||||
pub fn remove_peer(&self, peer: EndpointId) {
|
||||
/// Retires data channels without removing an authenticated peer's topology.
|
||||
pub fn clear_peer_links(&self, peer: EndpointId) {
|
||||
let mut state = self.state();
|
||||
if let Some(links) = state.raw.remove(peer.as_bytes()) {
|
||||
for raw in links {
|
||||
raw.link.close();
|
||||
raw.reader.abort();
|
||||
}
|
||||
}
|
||||
@@ -511,6 +512,13 @@ impl RelayHub {
|
||||
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.graph.remove(peer.as_bytes());
|
||||
for neighbors in state.graph.values_mut() {
|
||||
|
||||
@@ -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::{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.
|
||||
///
|
||||
/// Implemented by the agent, which is the only thing that knows which networks
|
||||
@@ -97,6 +140,9 @@ impl IrohLink {
|
||||
}
|
||||
|
||||
impl PacketLink for IrohLink {
|
||||
fn close(&self) {
|
||||
self.conn.close(0u32.into(), b"data link retired");
|
||||
}
|
||||
fn network(&self) -> NetworkId {
|
||||
self.network
|
||||
}
|
||||
@@ -187,6 +233,10 @@ impl PacketLink for IrohLink {
|
||||
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 {
|
||||
// Report what iroh actually knows, never a guess.
|
||||
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() {
|
||||
return Err(TransportError::Other(
|
||||
"data channel has no datagram support".into(),
|
||||
@@ -322,6 +373,12 @@ impl PacketTransport for IrohTransport {
|
||||
"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
|
||||
.lookup
|
||||
.snapshot()
|
||||
|
||||
@@ -30,12 +30,17 @@
|
||||
//! one plugin protocol.
|
||||
|
||||
mod fragments;
|
||||
pub(crate) mod health;
|
||||
pub mod iroh_link;
|
||||
|
||||
pub mod carrier;
|
||||
pub use carrier::{DataCarrier, SharedCarrier};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::identity::NetworkId;
|
||||
@@ -112,6 +117,14 @@ pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// keeping a task parked on [`PacketLink::closed`].
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
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.
|
||||
#[derive(Debug)]
|
||||
pub struct InboundLink {
|
||||
@@ -139,6 +222,18 @@ pub struct InboundLink {
|
||||
/// The agent owns one of these and hands links to plugins; plugins never call
|
||||
/// it directly.
|
||||
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.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
@@ -184,6 +279,24 @@ impl 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 {
|
||||
"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)]
|
||||
#[non_exhaustive]
|
||||
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.
|
||||
#[error("invalid network name: {0}")]
|
||||
InvalidNetworkName(&'static str),
|
||||
@@ -97,6 +103,19 @@ pub enum Error {
|
||||
#[error("network {0} is not configured")]
|
||||
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.
|
||||
#[error("no authenticated session with peer {peer} in network {network}")]
|
||||
NoSuchPeer {
|
||||
|
||||
@@ -171,6 +171,33 @@ pub fn default_control_socket_path() -> PathBuf {
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
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.
|
||||
Status,
|
||||
/// Set local broadcast participation in one configured network.
|
||||
@@ -255,6 +282,24 @@ pub enum Request {
|
||||
impl std::fmt::Debug for Request {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
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::SetBroadcast {
|
||||
network_id,
|
||||
@@ -286,6 +331,12 @@ impl std::fmt::Debug for Request {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
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.
|
||||
Status(Box<StatusReport>),
|
||||
/// Accepted local broadcast participation.
|
||||
@@ -312,6 +363,192 @@ pub enum Response {
|
||||
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.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ActiveReport {
|
||||
@@ -324,6 +561,9 @@ pub struct ActiveReport {
|
||||
/// Stopping something already stopped is not an error — it is the state
|
||||
/// asked for — but it is worth saying which happened.
|
||||
pub changed: bool,
|
||||
/// Networks suspended by switching to this network.
|
||||
#[serde(default)]
|
||||
pub suspended: Vec<RangeConflictReport>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// sections of a report look like one network.
|
||||
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.
|
||||
@@ -367,6 +614,8 @@ pub struct LeftReport {
|
||||
/// Everything the agent is doing, in one snapshot.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StatusReport {
|
||||
/// Data-plane protocols this agent can enable at runtime.
|
||||
pub available_protocols: Vec<ProtocolReport>,
|
||||
/// This device's persistent endpoint id.
|
||||
pub endpoint_id: String,
|
||||
/// Hostname announced to peers.
|
||||
@@ -437,9 +686,37 @@ pub struct DnsReport {
|
||||
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.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
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.
|
||||
#[serde(default)]
|
||||
pub broadcast: bool,
|
||||
@@ -482,6 +759,9 @@ pub struct NetworkReport {
|
||||
/// The range it could not have, because another network on this agent
|
||||
/// already holds it.
|
||||
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.
|
||||
pub overlay: Option<OverlayReport>,
|
||||
/// This agent's own name, when another member claimed it first.
|
||||
@@ -524,6 +804,8 @@ pub struct HostnameConflictReport {
|
||||
/// One member of the network, from signed state.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MemberReport {
|
||||
/// Signed claim version used to reject stale cleanup requests.
|
||||
pub record_version: u64,
|
||||
/// The member's device identity.
|
||||
pub endpoint_id: String,
|
||||
/// The IPv4 overlay address it claimed and signed for.
|
||||
@@ -546,6 +828,8 @@ pub struct MemberReport {
|
||||
/// One control plane peer.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PeerReport {
|
||||
/// Actionable carrier failure or policy mismatch; absent for a usable route.
|
||||
pub data_error: Option<String>,
|
||||
/// The peer's endpoint id.
|
||||
pub endpoint_id: String,
|
||||
/// Hostname it announced, if any.
|
||||
@@ -635,10 +919,12 @@ pub struct HostRulesReport {
|
||||
/// One overlay peer.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
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
|
||||
/// session and the member it belongs to.
|
||||
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,
|
||||
/// The peer's WireGuard public key (if applicable).
|
||||
pub public_key: String,
|
||||
@@ -693,6 +979,33 @@ impl OverlayPeerReport {
|
||||
/// running and what they can report or change. That is what keeps this module
|
||||
/// free of any knowledge of them.
|
||||
pub trait ReportSource: Send + Sync + 'static {
|
||||
/// 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.
|
||||
fn report(&self) -> BoxFuture<'_, StatusReport>;
|
||||
|
||||
@@ -822,7 +1135,7 @@ pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
///
|
||||
/// Bump it whenever [`Request`], [`Response`] or anything they contain
|
||||
/// changes shape.
|
||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 18]);
|
||||
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.
|
||||
///
|
||||
@@ -846,6 +1159,30 @@ where
|
||||
}
|
||||
};
|
||||
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::SetBroadcast {
|
||||
network_id,
|
||||
@@ -1001,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(
|
||||
path: impl AsRef<Path>,
|
||||
network_id: &str,
|
||||
|
||||
@@ -51,6 +51,7 @@ pub mod storage;
|
||||
pub mod task;
|
||||
#[cfg(feature = "testing")]
|
||||
pub mod testing;
|
||||
pub mod vip;
|
||||
|
||||
pub use agent::{Agent, AgentStatus, Event, NetworkStatus, PeerStatus};
|
||||
pub use config::{AgentConfig, Limits, ReconnectPolicy, StoragePaths, TransportPolicy};
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
//! own reachability data.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use iroh::endpoint::{
|
||||
@@ -221,9 +222,18 @@ fn local_addr_string(addr: &iroh::endpoint::LocalTransportAddr) -> Option<String
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EndpointAdapter {
|
||||
endpoint: Endpoint,
|
||||
owned_interface: Arc<std::sync::RwLock<String>>,
|
||||
}
|
||||
|
||||
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.
|
||||
pub async fn bind(config: &AgentConfig, identity: &DeviceIdentity) -> Result<Self> {
|
||||
let mut builder = Endpoint::builder(presets::Minimal)
|
||||
@@ -240,11 +250,15 @@ impl EndpointAdapter {
|
||||
TransportPolicy::N0Defaults => builder.preset(presets::N0),
|
||||
};
|
||||
|
||||
if matches!(
|
||||
config.transport,
|
||||
TransportPolicy::N0Defaults | TransportPolicy::DirectOnly
|
||||
) {
|
||||
let filter = iroh::address_lookup::AddrFilter::new(|addrs| {
|
||||
let owned_interface =
|
||||
Arc::new(std::sync::RwLock::new(config.interface_name.to_lowercase()));
|
||||
{
|
||||
let interface_filter = Arc::clone(&owned_interface);
|
||||
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();
|
||||
for iface in netdev::get_interfaces() {
|
||||
let name = iface.name.to_lowercase();
|
||||
@@ -258,7 +272,9 @@ impl EndpointAdapter {
|
||||
.as_deref()
|
||||
.map(str::to_lowercase)
|
||||
.unwrap_or_default();
|
||||
if name.starts_with("tsun")
|
||||
if name == owned_interface
|
||||
|| friendly == owned_interface
|
||||
|| name.starts_with("tsun")
|
||||
|| name.contains("tsunagi")
|
||||
|| name.contains("wintun")
|
||||
|| friendly.starts_with("tsun")
|
||||
@@ -288,7 +304,9 @@ impl EndpointAdapter {
|
||||
.collect();
|
||||
std::borrow::Cow::Owned(filtered)
|
||||
});
|
||||
builder = builder.addr_filter(filter);
|
||||
builder = builder
|
||||
.direct_addr_filter(filter.clone())
|
||||
.addr_filter(filter);
|
||||
}
|
||||
|
||||
#[cfg(feature = "testing")]
|
||||
@@ -395,7 +413,10 @@ impl EndpointAdapter {
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self { endpoint })
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
owned_interface,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>;
|
||||
|
||||
/// Refreshes volatile local socket protection without replacing exit routes.
|
||||
fn refresh<'a>(&'a self, _plan: &'a ExitHostPlan) -> BoxFuture<'a, Option<RuleOutcome>> {
|
||||
Box::pin(async { None })
|
||||
}
|
||||
|
||||
/// Whether using an exit node on this host needs the underlay addresses
|
||||
/// the agent talks to, because the host cannot exempt the agent's own
|
||||
/// traffic from the default route by user id (Windows). The default is no.
|
||||
|
||||
@@ -33,12 +33,13 @@ const CHAIN: &str = "INPUT";
|
||||
|
||||
/// Fixed places `iptables` is installed. Never `PATH`: the agent is
|
||||
/// privileged and a directory a user controls must not decide what it runs.
|
||||
const IPTABLES_DIRECTORIES: [&str; 5] = [
|
||||
const IPTABLES_DIRECTORIES: [&str; 6] = [
|
||||
"/usr/sbin/iptables",
|
||||
"/sbin/iptables",
|
||||
"/usr/bin/iptables",
|
||||
"/bin/iptables",
|
||||
"/usr/local/sbin/iptables",
|
||||
"/run/current-system/sw/bin/iptables",
|
||||
];
|
||||
|
||||
/// The first candidate that exists.
|
||||
@@ -328,7 +329,23 @@ fn clear_firewall(run: Run<'_>, interface: &str) {
|
||||
|
||||
/// Runs the real `iptables`.
|
||||
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;
|
||||
};
|
||||
match std::process::Command::new(program).args(args).output() {
|
||||
@@ -350,11 +367,17 @@ pub(super) fn run_iptables(args: &[String]) -> Ran {
|
||||
#[derive(Debug)]
|
||||
pub struct LinuxHostRules {
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -25,18 +25,19 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! 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
|
||||
//! 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
|
||||
//! 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
|
||||
//! 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
|
||||
//! 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
|
||||
@@ -48,9 +49,10 @@ use crate::state::Ipv4Range;
|
||||
use super::RuleOutcome;
|
||||
use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules, exit_tag, exit_tag_prefix};
|
||||
use super::linux::{
|
||||
LinuxHostRules, Ran, Run, comment_supported, failure, locked, run_iptables, same_rule,
|
||||
split_rule_line,
|
||||
LinuxHostRules, Ran, Run, comment_supported, failure, locked, run_firewall, run_iptables,
|
||||
same_rule, split_rule_line,
|
||||
};
|
||||
use super::linux_exit_mark;
|
||||
|
||||
/// The chains the offering rules live in: `(table, chain)`.
|
||||
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()
|
||||
}
|
||||
|
||||
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`.
|
||||
fn parse_euid(status: &str) -> Option<u32> {
|
||||
status
|
||||
@@ -417,7 +445,7 @@ impl LinuxHostRules {
|
||||
let xdg = std::env::var("XDG_RUNTIME_DIR").ok();
|
||||
if let Some(reason) = client_refusal(xdg.as_deref()) {
|
||||
// Whatever an earlier run left must not stay in force.
|
||||
self.clear_client().await;
|
||||
let _ = self.clear_client(interface).await;
|
||||
return RuleOutcome::Failed(reason);
|
||||
}
|
||||
let Some(euid) = std::fs::read_to_string("/proc/self/status")
|
||||
@@ -426,24 +454,147 @@ impl LinuxHostRules {
|
||||
else {
|
||||
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 interface = interface.to_string();
|
||||
match Self::blocking(move || route.set_exit_client(&interface, euid)).await {
|
||||
let name = interface.to_string();
|
||||
let outcome = match Self::blocking(move || route.set_exit_client(&name, euid)).await {
|
||||
Ok(Ok(())) => RuleOutcome::Applied,
|
||||
Ok(Err(err)) => RuleOutcome::Failed(err.to_string()),
|
||||
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();
|
||||
if let Ok(Err(err)) = Self::blocking(move || route.clear_exit_client()).await {
|
||||
tracing::warn!(%err, "cannot remove the exit node routing rules");
|
||||
Self::blocking(move || route.clear_exit_client())
|
||||
.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 {
|
||||
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> {
|
||||
Box::pin(async move {
|
||||
let interface = plan.interface.clone();
|
||||
@@ -467,7 +618,9 @@ impl ExitHostRules for LinuxHostRules {
|
||||
let client = if plan.client {
|
||||
Some(self.apply_client(&interface).await)
|
||||
} else {
|
||||
self.clear_client().await;
|
||||
if let Err(reason) = self.clear_client(&interface).await {
|
||||
tracing::warn!(%reason, "cannot remove exit-client rules");
|
||||
}
|
||||
None
|
||||
};
|
||||
ExitHostReport {
|
||||
@@ -482,7 +635,9 @@ impl ExitHostRules for LinuxHostRules {
|
||||
Box::pin(async move {
|
||||
let name = interface.to_string();
|
||||
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;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
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"))]
|
||||
pub use linux::LinuxHostRules;
|
||||
|
||||
|
||||
@@ -145,18 +145,19 @@ impl Tally {
|
||||
}
|
||||
|
||||
/// The overlay interface.
|
||||
#[derive(Debug)]
|
||||
pub struct Interface {
|
||||
device: Arc<dyn TunDevice>,
|
||||
factory: Arc<dyn TunFactory>,
|
||||
name: String,
|
||||
mtu: u32,
|
||||
routes: Arc<RoutingTable>,
|
||||
carrier: Arc<dyn PacketCarrier>,
|
||||
tally: Arc<Tally>,
|
||||
/// What was last applied to the host, so an unchanged table is not
|
||||
/// re-applied on every pass.
|
||||
applied: std::sync::Mutex<Vec<Cidr>>,
|
||||
task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||
vip_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||
sync_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
/// The host rules for this interface, when the factory manages the host.
|
||||
host_rules: Option<Arc<dyn BroadcastHostRules>>,
|
||||
@@ -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 {
|
||||
/// Creates the interface and starts moving packets.
|
||||
pub async fn start(
|
||||
@@ -221,7 +230,29 @@ impl Interface {
|
||||
let name = device.name().to_string();
|
||||
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 carrier = Arc::clone(&carrier);
|
||||
let device = Arc::clone(&device);
|
||||
let routes = Arc::clone(&routes);
|
||||
let tally = Arc::clone(&tally);
|
||||
@@ -231,6 +262,26 @@ impl Interface {
|
||||
tally.malformed.fetch_add(1, Ordering::Relaxed);
|
||||
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();
|
||||
if let (IpAddr::V4(source), IpAddr::V4(destination)) =
|
||||
(header.source(), destination)
|
||||
@@ -279,9 +330,11 @@ impl Interface {
|
||||
name,
|
||||
mtu,
|
||||
routes,
|
||||
carrier,
|
||||
tally,
|
||||
applied: std::sync::Mutex::new(Vec::new()),
|
||||
task: std::sync::Mutex::new(Some(task)),
|
||||
vip_task: std::sync::Mutex::new(Some(vip_task)),
|
||||
sync_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
host_rules,
|
||||
rules_report: std::sync::Mutex::new(None),
|
||||
@@ -397,6 +450,14 @@ impl Interface {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
let report = exit.apply(&wanted).await;
|
||||
@@ -708,6 +769,15 @@ impl Interface {
|
||||
task.abort();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -762,6 +832,24 @@ impl Interface {
|
||||
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.
|
||||
if self.device.send(packet).await.is_ok() {
|
||||
if broadcast {
|
||||
@@ -786,6 +874,14 @@ impl Interface {
|
||||
|
||||
impl Drop for Interface {
|
||||
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()
|
||||
&& let Some(task) = guard.take()
|
||||
{
|
||||
|
||||
@@ -55,6 +55,7 @@ use crate::BoxFuture;
|
||||
use crate::overlay::OverlayError;
|
||||
|
||||
use super::super::config::Cidr;
|
||||
use super::super::hostrules::linux_exit_mark::{UNDERLAY_MARK, UNDERLAY_MASK};
|
||||
use super::super::tun::{TunDevice, TunRequest};
|
||||
use super::privilege::{NetAdmin, Privilege, probe_net_admin};
|
||||
use super::{
|
||||
@@ -641,8 +642,10 @@ async fn del_broadcast_route(name: &str) -> Result<(), OverlayError> {
|
||||
const EXIT_TABLE: u32 = 0x7473;
|
||||
/// The main table.
|
||||
const MAIN_TABLE: u32 = 254;
|
||||
/// Where the exit rules sit, after Tailscale's 5270.
|
||||
const EXIT_BYPASS_PRIORITY: u32 = 5280;
|
||||
/// Protect marked underlay and initial socket routing before Tailscale's rules.
|
||||
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_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,
|
||||
/// and a rule somebody else put at one of them must not be removed.
|
||||
fn is_exit_rule(rule: &RuleMessage) -> bool {
|
||||
let table = rule_table(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
|
||||
&& rule
|
||||
.attributes
|
||||
@@ -789,7 +801,8 @@ async fn remove_exit_client(handle: &rtnetlink::Handle) -> Result<(), OverlayErr
|
||||
///
|
||||
/// ```text
|
||||
/// 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 5300 lookup 0x7473
|
||||
/// ```
|
||||
@@ -819,6 +832,8 @@ async fn set_exit_client(name: &str, uid: u32) -> Result<(), OverlayError> {
|
||||
.await
|
||||
.map_err(|err| fail("default route", err))?;
|
||||
|
||||
add_mark_bypass(&handle, false).await?;
|
||||
|
||||
let mut bypass = handle
|
||||
.rule()
|
||||
.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
|
||||
.rule()
|
||||
.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))
|
||||
}
|
||||
|
||||
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> {
|
||||
let name = configure.name.as_str();
|
||||
with_netlink(|handle| async move {
|
||||
@@ -1110,6 +1147,28 @@ mod tests {
|
||||
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]
|
||||
fn the_ipv6_default_of_the_exit_table_refuses_instead_of_routing() {
|
||||
let route = exit_blocking_route_v6();
|
||||
|
||||
@@ -72,7 +72,7 @@ pub enum RouteError {
|
||||
#[error(
|
||||
"network {network} uses {range}, which overlaps {other_range} already in use by \
|
||||
{other}. One agent has one interface, so an address can belong to only one of \
|
||||
them: give one network a different range."
|
||||
them: stop the other network before starting this one."
|
||||
)]
|
||||
Overlap {
|
||||
/// The network being added.
|
||||
@@ -89,6 +89,7 @@ pub enum RouteError {
|
||||
/// Address ownership across every network this agent is in.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RoutingTable {
|
||||
pub(crate) vips: Arc<crate::vip::VipRouter>,
|
||||
broadcast: ArcSwap<BroadcastTable>,
|
||||
networks: RwLock<HashMap<NetworkId, NetworkRoutes>>,
|
||||
}
|
||||
@@ -146,6 +147,7 @@ impl RoutingTable {
|
||||
|
||||
/// Forgets a network.
|
||||
pub fn remove_network(&self, network: NetworkId) {
|
||||
self.vips.remove_network(network);
|
||||
let mut networks = self.write();
|
||||
networks.remove(&network);
|
||||
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.
|
||||
pub fn len(&self) -> usize {
|
||||
self.read().len()
|
||||
@@ -510,7 +531,7 @@ mod tests {
|
||||
let err = table.set_network(second, routes()).unwrap_err();
|
||||
let RouteError::Overlap { other, .. } = err;
|
||||
assert_eq!(other, first);
|
||||
assert!(err.to_string().contains("different range"), "{err}");
|
||||
assert!(err.to_string().contains("stop the other network"), "{err}");
|
||||
assert_eq!(table.len(), 1, "the overlapping network is not added");
|
||||
|
||||
// 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
|
||||
/// protocol. It is independent of the network identity scheme version, so
|
||||
/// bumping it must not change any existing [`crate::NetworkId`].
|
||||
pub const ALPN: &[u8] = b"tsunagi/ctrl/5";
|
||||
pub const ALPN: &[u8] = b"tsunagi/ctrl/8";
|
||||
|
||||
/// ALPN of the tsunagi data plane.
|
||||
///
|
||||
@@ -33,8 +33,10 @@ pub const ALPN: &[u8] = b"tsunagi/ctrl/5";
|
||||
/// transport fragmentation. Version 5 adds the end-to-end protocol to that
|
||||
/// envelope, so a tunnel no longer depends on the kind of link under it, and
|
||||
/// a first-claimed time to a signed hostname claim. All members must upgrade
|
||||
/// together. Persistent identities and network configuration do not change.
|
||||
pub const DATA_ALPN: &[u8] = b"tsunagi/data/5";
|
||||
/// 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.
|
||||
pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
|
||||
@@ -43,7 +45,9 @@ pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
|
||||
pub const MAX_SIGNATURE_LEN: usize = 64;
|
||||
|
||||
/// Control protocol version carried inside the handshake.
|
||||
pub const PROTOCOL_VERSION: u16 = 5;
|
||||
pub const PROTOCOL_VERSION: u16 = 8;
|
||||
|
||||
pub use crate::dataplane::transport::{MAX_TRANSPORT_PAYLOAD, TransportPayload};
|
||||
|
||||
/// First message of the handshake, sent by the initiator.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -114,6 +118,13 @@ pub struct DataOpenAck {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
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.
|
||||
Announce(Announcement),
|
||||
/// A small request used to verify that the exchange works.
|
||||
@@ -172,6 +183,16 @@ pub enum ControlMessage {
|
||||
/// Short free-text reason.
|
||||
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.
|
||||
@@ -264,6 +285,48 @@ pub fn validate_capability(
|
||||
/// network, the other networks or the agent.
|
||||
pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), ProtocolError> {
|
||||
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 } => {
|
||||
check_len("peers", peers.len(), limits.max_state_records)?;
|
||||
for hint in peers {
|
||||
@@ -320,10 +383,13 @@ pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), Protoco
|
||||
/// A short, stable label for a message kind, for metrics and diagnostics.
|
||||
pub fn kind(message: &ControlMessage) -> &'static str {
|
||||
match message {
|
||||
ControlMessage::Transport { .. } => "transport",
|
||||
ControlMessage::Announce(_) => "announce",
|
||||
ControlMessage::Ping { .. } => "ping",
|
||||
ControlMessage::Pong { .. } => "pong",
|
||||
ControlMessage::State { .. } => "state",
|
||||
ControlMessage::Retirements { .. } => "retirements",
|
||||
ControlMessage::Vips { .. } => "vips",
|
||||
ControlMessage::Reachable { .. } => "reachable",
|
||||
ControlMessage::Peers { .. } => "peers",
|
||||
ControlMessage::Bye { .. } => "bye",
|
||||
|
||||
@@ -28,16 +28,25 @@
|
||||
//! replica that has the record needs nothing else to interpret it, and
|
||||
//! 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
|
||||
//!
|
||||
//! Compaction, revocation of a whole author, and snapshots covering more than
|
||||
//! one record type. See `docs/sync-model.md` for the shape those take.
|
||||
//! Compaction and permanent revocation of a whole author. See
|
||||
//! `docs/sync-model.md` for the implemented cleanup semantics.
|
||||
|
||||
pub mod allocator;
|
||||
/// Signed shared virtual service state and its merge rules.
|
||||
pub mod vip;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
mod retirement;
|
||||
pub use retirement::{MAX_RETIREMENT_LEN, SignedRetirement};
|
||||
|
||||
use iroh::{EndpointId, SecretKey, Signature};
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
/// The legacy default range, retained for existing networks and explicit use.
|
||||
/// New networks use [`derived_ipv4_range`] by default.
|
||||
pub const DEFAULT_IPV4_RANGE: Ipv4Range = Ipv4Range {
|
||||
base: Ipv4Addr::new(10, 13, 37, 0),
|
||||
prefix_len: 24,
|
||||
@@ -122,15 +127,10 @@ pub const DEFAULT_IPV4_RANGE: Ipv4Range = Ipv4Range {
|
||||
|
||||
/// A range derived from a network's own identity.
|
||||
///
|
||||
/// One agent has one interface, so two of its networks cannot both use the
|
||||
/// configured range. The second needs another one — and it cannot be picked
|
||||
/// locally, because every member has to arrive at the same answer without
|
||||
/// being told. So it comes from the network id, which every member already
|
||||
/// 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.
|
||||
/// Every member independently derives the same pseudorandom `/24` in 10/8,
|
||||
/// including isolated first members. Stored signed ranges take precedence.
|
||||
/// The derivation is kept stable for networks already using it; the legacy
|
||||
/// default is excluded. Distinct network ids can still produce collisions.
|
||||
pub fn derived_ipv4_range(network: NetworkId) -> Ipv4Range {
|
||||
let bytes = network.as_bytes();
|
||||
let second = bytes[0];
|
||||
@@ -497,6 +497,7 @@ pub enum Merged {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StateSet {
|
||||
records: HashMap<EndpointId, SignedRecord>,
|
||||
retirements: HashMap<EndpointId, SignedRetirement>,
|
||||
}
|
||||
|
||||
impl StateSet {
|
||||
@@ -509,6 +510,7 @@ impl StateSet {
|
||||
pub fn from_verified(records: impl IntoIterator<Item = (EndpointId, SignedRecord)>) -> Self {
|
||||
Self {
|
||||
records: records.into_iter().collect(),
|
||||
retirements: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,6 +539,52 @@ impl StateSet {
|
||||
.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.
|
||||
///
|
||||
/// Merging is into the existing set, never a wholesale replacement, and an
|
||||
@@ -600,6 +648,9 @@ impl StateSet {
|
||||
pub fn address_holders(&self) -> HashMap<Ipv4Addr, EndpointId> {
|
||||
let mut holders: HashMap<Ipv4Addr, EndpointId> = HashMap::new();
|
||||
for (author, record) in &self.records {
|
||||
if self.is_retired(author) {
|
||||
continue;
|
||||
}
|
||||
let Some(address) = record.body.claimed_address() else {
|
||||
continue;
|
||||
};
|
||||
@@ -627,6 +678,9 @@ impl StateSet {
|
||||
pub fn hostname_holders(&self) -> HashMap<String, EndpointId> {
|
||||
let mut holders: HashMap<String, (u64, EndpointId)> = HashMap::new();
|
||||
for (author, record) in &self.records {
|
||||
if self.is_retired(author) {
|
||||
continue;
|
||||
}
|
||||
let Some(hostname) = record.body.hostname() else {
|
||||
continue;
|
||||
};
|
||||
@@ -655,6 +709,9 @@ impl StateSet {
|
||||
/// 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))
|
||||
@@ -673,21 +730,44 @@ impl StateSet {
|
||||
|
||||
/// The range the network settled on, if anybody has said.
|
||||
///
|
||||
/// When claims disagree, the one from the lowest author id wins, so every
|
||||
/// replica reaches the same answer. A participant joining later therefore
|
||||
/// adopts the range already in use instead of imposing its own.
|
||||
/// A legacy or explicit range takes precedence over the derived default,
|
||||
/// so a fresh member's default cannot renumber an established network.
|
||||
/// 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> {
|
||||
let mut authors: Vec<&EndpointId> = self.records.keys().collect();
|
||||
authors.sort_by_key(|author| *author.as_bytes());
|
||||
authors
|
||||
.into_iter()
|
||||
.find_map(|author| self.records.get(author)?.body.range())
|
||||
self.records
|
||||
.iter()
|
||||
.chain(
|
||||
self.retirements
|
||||
.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.
|
||||
pub fn version_of(&self, author: &EndpointId) -> u64 {
|
||||
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)]
|
||||
@@ -1263,7 +1343,7 @@ mod tests {
|
||||
assert_eq!(once.base.octets()[0], 10, "inside 10/8 like the default");
|
||||
assert_ne!(
|
||||
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
|
||||
@@ -1275,4 +1355,34 @@ mod tests {
|
||||
.network_id();
|
||||
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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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))
|
||||
@@ -272,11 +278,39 @@ impl Storage {
|
||||
.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.
|
||||
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.with_state(move |state| {
|
||||
state.remove_network(network_id)?;
|
||||
state.forget_signed_records(network_id)?;
|
||||
state.forget_retirements(network_id)?;
|
||||
state.forget_peer_hostnames(network_id)
|
||||
})
|
||||
.await?;
|
||||
@@ -336,6 +370,50 @@ impl Storage {
|
||||
.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.
|
||||
pub async fn put_signed_record(&self, record: SignedRecord) -> Result<()> {
|
||||
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::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.
|
||||
pub const SCHEMA_VERSION: i64 = 7;
|
||||
pub const SCHEMA_VERSION: i64 = 10;
|
||||
|
||||
/// Key of the stored hostname setting.
|
||||
const SETTING_HOSTNAME: &str = "hostname";
|
||||
@@ -32,6 +32,8 @@ const SETTING_HOSTNAME: &str = "hostname";
|
||||
/// and zeroizes on drop.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredNetwork {
|
||||
/// Ordered local protocol override; absent inherits the agent's defaults.
|
||||
pub protocols: Option<Vec<String>>,
|
||||
/// Derived public network identifier.
|
||||
pub network_id: NetworkId,
|
||||
/// Network name.
|
||||
@@ -238,6 +240,35 @@ impl StateStore {
|
||||
)
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -250,6 +281,8 @@ impl StateStore {
|
||||
"settings",
|
||||
"signed_records",
|
||||
"peer_hostnames",
|
||||
"retirements",
|
||||
"vip_fields",
|
||||
] {
|
||||
let present: Option<String> = self
|
||||
.conn
|
||||
@@ -448,12 +481,16 @@ impl StateStore {
|
||||
|
||||
/// Sets the auto-start flag of a configured network.
|
||||
pub fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
|
||||
self.conn
|
||||
let changed = self
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE networks SET auto_start = ?2 WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice(), auto_start as i64],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot update network: {err}")))?;
|
||||
if changed == 0 {
|
||||
return Err(Error::NetworkUnknown(network_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -472,6 +509,25 @@ impl StateStore {
|
||||
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.
|
||||
pub fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.conn
|
||||
@@ -532,7 +588,7 @@ impl StateStore {
|
||||
pub fn list_networks(&self) -> Result<Vec<StoredNetwork>> {
|
||||
let mut stmt = self
|
||||
.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}")))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
@@ -548,19 +604,37 @@ impl StateStore {
|
||||
row.get::<_, bool>(4)?,
|
||||
row.get::<_, bool>(5)?,
|
||||
row.get::<_, Option<Vec<u8>>>(6)?,
|
||||
row.get::<_, Option<String>>(7)?,
|
||||
))
|
||||
})
|
||||
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, secret, auto_start, 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}")))?;
|
||||
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
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| self.corrupt("stored network id is not 32 bytes"))?;
|
||||
out.push(StoredNetwork {
|
||||
protocols,
|
||||
network_id: NetworkId::from_bytes(id),
|
||||
name: NetworkName::new(name)?,
|
||||
secret: NetworkSecret::from_bytes(secret)?,
|
||||
@@ -732,6 +806,181 @@ impl StateStore {
|
||||
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.
|
||||
pub fn forget_signed_records(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.conn
|
||||
@@ -798,6 +1047,44 @@ impl StateStore {
|
||||
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.
|
||||
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
||||
self.conn
|
||||
@@ -915,6 +1202,158 @@ mod broadcast_storage_tests {
|
||||
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]
|
||||
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -935,6 +1374,7 @@ mod broadcast_storage_tests {
|
||||
"ALTER TABLE networks DROP COLUMN broadcast; \
|
||||
ALTER TABLE networks DROP COLUMN exit_node; \
|
||||
ALTER TABLE networks DROP COLUMN exit_via; \
|
||||
ALTER TABLE networks DROP COLUMN protocols; \
|
||||
PRAGMA user_version=3;",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -969,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;
|
||||
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}"
|
||||
);
|
||||
|
||||
|
||||
@@ -1,220 +1,9 @@
|
||||
//! Connectivity does not depend on what kind of link each hop happens to be,
|
||||
//! and a member is online however far away it is.
|
||||
|
||||
//! Signed hostname ownership remains independent of connectivity and identity order.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use tsunagi::NetworkId;
|
||||
use tsunagi::agent::DataPath;
|
||||
use tsunagi::dataplane::transport::SharedLink;
|
||||
use tsunagi::dataplane::{IpPlugin, PluginCapability, PluginError};
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::testing::{TestAgent, network, wait_for_peers, wait_until};
|
||||
|
||||
/// A protocol that moves bytes between two members unchanged and keeps the
|
||||
/// link it was handed, so a test can use the link directly.
|
||||
#[derive(Debug)]
|
||||
struct Carrier {
|
||||
protocol: String,
|
||||
links: Mutex<HashMap<EndpointId, SharedLink>>,
|
||||
}
|
||||
|
||||
impl Carrier {
|
||||
fn new(protocol: &str) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
protocol: protocol.into(),
|
||||
links: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn link(&self, peer: EndpointId) -> Option<SharedLink> {
|
||||
self.links.lock().unwrap().get(&peer).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl IpPlugin for Carrier {
|
||||
fn protocol_id(&self) -> &str {
|
||||
&self.protocol
|
||||
}
|
||||
|
||||
fn protocol_version(&self) -> u16 {
|
||||
1
|
||||
}
|
||||
|
||||
fn local_capability(
|
||||
&self,
|
||||
_network: NetworkId,
|
||||
) -> Result<Option<PluginCapability>, PluginError> {
|
||||
Ok(Some(PluginCapability {
|
||||
protocol: self.protocol.clone(),
|
||||
version: 1,
|
||||
enabled: true,
|
||||
data: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn on_peer_capability(
|
||||
&self,
|
||||
_network: NetworkId,
|
||||
_peer: EndpointId,
|
||||
_capability: &PluginCapability,
|
||||
) -> Result<(), PluginError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_peer_link(&self, _network: NetworkId, peer: EndpointId, link: SharedLink) {
|
||||
self.links.lock().unwrap().insert(peer, link);
|
||||
}
|
||||
|
||||
fn on_peer_gone(&self, _network: NetworkId, peer: EndpointId) {
|
||||
self.links.lock().unwrap().remove(&peer);
|
||||
}
|
||||
|
||||
fn on_network_deactivated(&self, _network: NetworkId) {}
|
||||
}
|
||||
|
||||
type Blocked = Arc<Mutex<HashSet<EndpointId>>>;
|
||||
|
||||
async fn member(
|
||||
discovery: &SharedMemoryDiscovery,
|
||||
plugins: &[Arc<Carrier>],
|
||||
blocked: &Blocked,
|
||||
) -> TestAgent {
|
||||
let plugins: Vec<_> = plugins.to_vec();
|
||||
let blocked = Arc::clone(blocked);
|
||||
TestAgent::spawn_with(
|
||||
move |config| {
|
||||
let config = config.with_unreachable_data_peers(blocked);
|
||||
plugins
|
||||
.into_iter()
|
||||
.fold(config, |config, plugin| config.with_plugin(plugin))
|
||||
},
|
||||
discovery,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn exchange(from: &SharedLink, to: &SharedLink, payload: &'static [u8]) {
|
||||
from.send(Bytes::from_static(payload)).unwrap();
|
||||
let got = tokio::time::timeout(tsunagi::testing::DEADLINE, to.recv())
|
||||
.await
|
||||
.expect("the datagram should arrive")
|
||||
.expect("the link stays open");
|
||||
assert_eq!(got, Bytes::from_static(payload));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_tunnel_crosses_a_member_that_shares_no_protocol_with_either_end_and_moves_to_a_direct_link_by_itself()
|
||||
{
|
||||
// A and B share one protocol and nothing else. C shares a different
|
||||
// one with each, so every link here is of a different kind and the
|
||||
// middle has never heard of what A and B speak. A and B cannot reach
|
||||
// each other directly, which is the case a blocked UDP path produces.
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("mixed-transports");
|
||||
|
||||
let blocked_a: Blocked = Arc::default();
|
||||
let blocked_b: Blocked = Arc::default();
|
||||
let a_shared = Carrier::new("shared");
|
||||
let b_shared = Carrier::new("shared");
|
||||
let a = member(
|
||||
&discovery,
|
||||
&[Carrier::new("a-and-c"), Arc::clone(&a_shared)],
|
||||
&blocked_a,
|
||||
)
|
||||
.await;
|
||||
let b = member(
|
||||
&discovery,
|
||||
&[Carrier::new("c-and-b"), Arc::clone(&b_shared)],
|
||||
&blocked_b,
|
||||
)
|
||||
.await;
|
||||
let c = member(
|
||||
&discovery,
|
||||
&[Carrier::new("a-and-c"), Carrier::new("c-and-b")],
|
||||
&Blocked::default(),
|
||||
)
|
||||
.await;
|
||||
let (a_id, b_id, c_id) = (
|
||||
a.agent.endpoint_id(),
|
||||
b.agent.endpoint_id(),
|
||||
c.agent.endpoint_id(),
|
||||
);
|
||||
blocked_a.lock().unwrap().insert(b_id);
|
||||
blocked_b.lock().unwrap().insert(a_id);
|
||||
|
||||
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
c.agent.join_network(&name, &secret).await.unwrap();
|
||||
for agent in [&a, &b, &c] {
|
||||
wait_for_peers(&agent.agent, network_id, 2).await;
|
||||
}
|
||||
|
||||
// The tunnel comes up through C, and data goes both ways.
|
||||
let to_b = wait_until("a has a link to b", || async { a_shared.link(b_id) }).await;
|
||||
let to_a = wait_until("b has a link to a", || async { b_shared.link(a_id) }).await;
|
||||
exchange(&to_b, &to_a, b"a to b, sealed").await;
|
||||
exchange(&to_a, &to_b, b"b to a, sealed").await;
|
||||
|
||||
// The status says what is true: online, and through whom.
|
||||
let status = a.agent.network_status(network_id).await.unwrap();
|
||||
let b_peer = status.peers.iter().find(|p| p.endpoint_id == b_id).unwrap();
|
||||
assert_eq!(b_peer.data_path, DataPath::Relayed { hops: 2, via: c_id });
|
||||
let c_peer = status.peers.iter().find(|p| p.endpoint_id == c_id).unwrap();
|
||||
assert!(matches!(&c_peer.data_path, DataPath::Direct { transport } if transport == "a-and-c"));
|
||||
let b_member = wait_until("a lists b as a member", || async {
|
||||
let status = a.agent.network_status(network_id).await.ok()?;
|
||||
status.members.into_iter().find(|m| m.endpoint_id == b_id)
|
||||
})
|
||||
.await;
|
||||
assert!(b_member.online);
|
||||
let carried = c.agent.network_status(network_id).await.unwrap().relay;
|
||||
assert!(carried.forwarded >= 2, "{carried:?}");
|
||||
|
||||
// The direct path becomes possible: the same link carries on, now
|
||||
// without C, and nobody has to do anything about it.
|
||||
blocked_a.lock().unwrap().clear();
|
||||
blocked_b.lock().unwrap().clear();
|
||||
wait_until("a reaches b directly", || async {
|
||||
let status = a.agent.network_status(network_id).await.ok()?;
|
||||
let peer = status.peers.iter().find(|p| p.endpoint_id == b_id)?;
|
||||
matches!(&peer.data_path, DataPath::Direct { transport } if transport == "shared")
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
let forwarded = c
|
||||
.agent
|
||||
.network_status(network_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.relay
|
||||
.forwarded;
|
||||
exchange(&to_b, &to_a, b"direct now").await;
|
||||
assert!(
|
||||
Arc::ptr_eq(&to_b, &a_shared.link(b_id).unwrap()),
|
||||
"the tunnel's link is the one it always had"
|
||||
);
|
||||
assert_eq!(
|
||||
c.agent
|
||||
.network_status(network_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.relay
|
||||
.forwarded,
|
||||
forwarded,
|
||||
"nothing goes through C any more"
|
||||
);
|
||||
|
||||
a.stop().await;
|
||||
b.stop().await;
|
||||
c.stop().await;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -132,7 +132,7 @@ async fn opaque_plugin_capabilities_cross_the_control_plane() {
|
||||
let a = TestAgent::spawn_with(
|
||||
{
|
||||
let plugin = Arc::clone(&plugin_a);
|
||||
move |cfg| cfg.with_plugin(plugin)
|
||||
move |cfg| cfg.with_tunnel(plugin)
|
||||
},
|
||||
&discovery,
|
||||
)
|
||||
@@ -141,7 +141,7 @@ async fn opaque_plugin_capabilities_cross_the_control_plane() {
|
||||
let b = TestAgent::spawn_with(
|
||||
{
|
||||
let plugin = Arc::clone(&plugin_b);
|
||||
move |cfg| cfg.with_plugin(plugin)
|
||||
move |cfg| cfg.with_tunnel(plugin)
|
||||
},
|
||||
&discovery,
|
||||
)
|
||||
|
||||
@@ -265,10 +265,7 @@ async fn deactivating_one_network_leaves_the_agent_and_others_running() {
|
||||
|
||||
#[tokio::test]
|
||||
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
|
||||
// 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.
|
||||
// Each network derives its range independently of activation order.
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (first, first_secret) = network("two-ranges-one");
|
||||
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"
|
||||
);
|
||||
assert!(
|
||||
tsunagi::state::DEFAULT_IPV4_RANGE.contains(addresses.0),
|
||||
"the first keeps the configured range: {}",
|
||||
tsunagi::state::derived_ipv4_range(one).contains(addresses.0),
|
||||
"the first uses its derived range: {}",
|
||||
addresses.0
|
||||
);
|
||||
assert!(
|
||||
@@ -319,7 +316,7 @@ async fn two_networks_on_one_agent_each_get_a_range_of_their_own() {
|
||||
addresses.1
|
||||
);
|
||||
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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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]
|
||||
name = "tsunagi-tcp-tls"
|
||||
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
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -14,19 +14,10 @@ tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macro
|
||||
rustls = "0.23"
|
||||
tokio-rustls = "0.26"
|
||||
rcgen = { version = "0.14", default-features = false, features = ["ring"] }
|
||||
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
|
||||
chacha20poly1305 = "0.10"
|
||||
sha2 = "0.11"
|
||||
hkdf = "0.13"
|
||||
rand = "0.10"
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.9", features = ["derive"] }
|
||||
bytes.workspace = true
|
||||
serde.workspace = true
|
||||
postcard.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
hex.workspace = true
|
||||
netdev = "0.46"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
|
||||
@@ -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 crypto;
|
||||
pub mod plugin;
|
||||
pub mod transport;
|
||||
|
||||
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;
|
||||
@@ -1,26 +1,14 @@
|
||||
//! The `tcp-tls` IP plugin implementation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
use tsunagi::dataplane::tunnel::{GenericTunnelPlugin, TunnelCodec};
|
||||
use tsunagi::dataplane::{PluginError, ProtocolOption};
|
||||
use tsunagi::identity::NetworkId;
|
||||
|
||||
use crate::crypto::PeerCipher;
|
||||
//! TLS carrier options and bounded reachability announcements. No IP cipher lives here.
|
||||
use crate::transport::TcpTlsTransport;
|
||||
|
||||
/// The wire protocol identifier for `tcp-tls`.
|
||||
use iroh::EndpointId;
|
||||
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";
|
||||
|
||||
/// Wire version for `tcp-tls`.
|
||||
pub const TCP_TLS_VERSION: u16 = 1;
|
||||
|
||||
/// Carrier announcement wire version.
|
||||
pub const TCP_TLS_VERSION: u16 = 2;
|
||||
/// Configuration for the `tcp-tls` protocol plugin.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
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.
|
||||
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();
|
||||
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;
|
||||
}
|
||||
for ip in &iface.ipv4 {
|
||||
@@ -203,26 +190,7 @@ pub(crate) fn ip_priority(iface: &netdev::Interface, ip: std::net::IpAddr) -> u3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
impl TcpTlsConfig {
|
||||
/// Supported options for `tcp-tls`.
|
||||
pub const OPTIONS: &'static [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.
|
||||
pub fn configure(
|
||||
mut config: TcpTlsConfig,
|
||||
@@ -281,250 +233,169 @@ impl TcpTlsCodec {
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
impl TunnelCodec for TcpTlsCodec {
|
||||
/// TLS carrier announcements and lifecycle, independent of IP encryption.
|
||||
#[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 {
|
||||
TCP_TLS_PROTOCOL
|
||||
}
|
||||
|
||||
fn protocol_version(&self) -> u16 {
|
||||
TCP_TLS_VERSION
|
||||
2
|
||||
}
|
||||
|
||||
fn options(&self) -> &'static [ProtocolOption] {
|
||||
Self::OPTIONS
|
||||
fn on_interface_ready(&self, name: &str) {
|
||||
*self
|
||||
.excluded_interface
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner()) = Some(name.into());
|
||||
}
|
||||
|
||||
fn local_capability_data(&self, _network: NetworkId) -> Result<Vec<u8>, PluginError> {
|
||||
let announcement = TcpTlsAnnouncement {
|
||||
noise_public: *self.public_key.as_bytes(),
|
||||
port: self.transport.bound_port(),
|
||||
addrs: local_ip_candidates(),
|
||||
};
|
||||
postcard::to_allocvec(&announcement)
|
||||
.map_err(|err| PluginError::Other(format!("encode announcement failed: {err}")))
|
||||
fn local_capability(
|
||||
&self,
|
||||
_: NetworkId,
|
||||
) -> Result<Option<tsunagi::dataplane::PluginCapability>, PluginError> {
|
||||
let data = postcard::to_allocvec(&(
|
||||
self.transport.bound_port(),
|
||||
local_ip_candidates_excluding(
|
||||
self.excluded_interface
|
||||
.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_data(
|
||||
fn on_peer_capability(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
data: &[u8],
|
||||
cap: &tsunagi::dataplane::PluginCapability,
|
||||
) -> Result<(), PluginError> {
|
||||
let announcement: TcpTlsAnnouncement = postcard::from_bytes(data)
|
||||
.map_err(|err| PluginError::Rejected(format!("invalid announcement: {err}")))?;
|
||||
|
||||
for ip in &announcement.addrs {
|
||||
if is_candidate_ip(*ip) {
|
||||
self.transport
|
||||
.set_peer_addr(peer, std::net::SocketAddr::new(*ip, announcement.port));
|
||||
}
|
||||
if !cap.enabled || cap.protocol != TCP_TLS_PROTOCOL || cap.version != 2 {
|
||||
return Err(PluginError::Rejected("unsupported TLS carrier".into()));
|
||||
}
|
||||
|
||||
let peer_noise_pubkey = PublicKey::from(announcement.noise_public);
|
||||
let cipher = Arc::new(PeerCipher::new(
|
||||
self.local_id,
|
||||
// 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(
|
||||
network,
|
||||
peer,
|
||||
&self.static_secret,
|
||||
&peer_noise_pubkey,
|
||||
));
|
||||
|
||||
let mut guard = match self.ciphers.write() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard.insert((network, peer), cipher);
|
||||
Some(
|
||||
ips.into_iter()
|
||||
.filter(|ip| is_candidate_ip(*ip))
|
||||
.map(|ip| std::net::SocketAddr::new(ip, port))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_peer_down(&self, network: NetworkId, peer: EndpointId) {
|
||||
let mut guard = match self.ciphers.write() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard.remove(&(network, peer));
|
||||
fn on_peer_gone(&self, network: NetworkId, peer: EndpointId) {
|
||||
self.update_peer(network, peer, None);
|
||||
}
|
||||
|
||||
fn encrypt(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
packet: &[u8],
|
||||
) -> Result<Bytes, PluginError> {
|
||||
let cipher = {
|
||||
let guard = match self.ciphers.read() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard.get(&(network, peer)).cloned()
|
||||
};
|
||||
|
||||
let Some(cipher) = cipher else {
|
||||
return Err(PluginError::Unavailable("no cipher for peer".into()));
|
||||
};
|
||||
|
||||
cipher.encrypt(packet)
|
||||
}
|
||||
|
||||
fn decrypt(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
payload: &[u8],
|
||||
) -> Result<Bytes, PluginError> {
|
||||
let cipher = {
|
||||
let guard = match self.ciphers.read() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard.get(&(network, peer)).cloned()
|
||||
};
|
||||
|
||||
let Some(cipher) = cipher else {
|
||||
return Err(PluginError::Unavailable("no cipher for peer".into()));
|
||||
};
|
||||
|
||||
cipher.decrypt(payload)
|
||||
fn on_network_deactivated(&self, network: NetworkId) {
|
||||
let peers: Vec<_> = self
|
||||
.peers
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.keys()
|
||||
.filter(|(id, _)| *id == network)
|
||||
.map(|(_, peer)| *peer)
|
||||
.collect();
|
||||
for peer in peers {
|
||||
self.update_peer(network, peer, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The complete `tcp-tls` plugin.
|
||||
pub type TcpTlsPlugin = GenericTunnelPlugin<TcpTlsCodec>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
#[test]
|
||||
fn test_is_candidate_interface_filters_overlay_and_bridges() {
|
||||
let mut iface = netdev::Interface::dummy();
|
||||
iface.flags = 1; // UP
|
||||
|
||||
// Standard Ethernet - should pass
|
||||
iface.name = "eth0".to_string();
|
||||
assert!(is_candidate_interface(&iface));
|
||||
|
||||
// Windows Ethernet with GUID name - should pass
|
||||
iface.name = "{B6149D2C-1234-5678-9ABC-DEF012345678}".to_string();
|
||||
iface.friendly_name = Some("Ethernet".to_string());
|
||||
iface.description = Some("Intel(R) Ethernet Connection".to_string());
|
||||
assert!(is_candidate_interface(&iface));
|
||||
|
||||
// Linux tsun0 overlay - must be filtered
|
||||
iface.name = "tsun0".to_string();
|
||||
iface.friendly_name = None;
|
||||
iface.description = None;
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Windows Wintun overlay (GUID name with tsun0 friendly name) - must be filtered!
|
||||
iface.name = "{72D85D76-F1A5-42AC-BC49-2169527CDDDF}".to_string();
|
||||
iface.friendly_name = Some("tsun0".to_string());
|
||||
iface.description = Some("Wintun Userspace Tunnel".to_string());
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Windows Wintun overlay with description containing Wintun - must be filtered!
|
||||
iface.friendly_name = Some("Local Area Connection 2".to_string());
|
||||
iface.description = Some("Wintun Userspace Tunnel".to_string());
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Docker bridge - must be filtered
|
||||
iface.name = "docker0".to_string();
|
||||
iface.friendly_name = None;
|
||||
iface.description = None;
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Docker network bridge br-* - must be filtered
|
||||
iface.name = "br-4f6d3a9b1c2e".to_string();
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Virtual ethernet veth* - must be filtered
|
||||
iface.name = "veth12345".to_string();
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Libvirt NAT bridge virbr* - must be filtered
|
||||
iface.name = "virbr0".to_string();
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// CNI bridge - must be filtered
|
||||
iface.name = "cni0".to_string();
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Hyper-V / WSL vEthernet switch on Windows - must be filtered
|
||||
iface.name = "{A1B2C3D4-E5F6-7890-1234-567890ABCDEF}".to_string();
|
||||
iface.friendly_name = Some("vEthernet (WSL)".to_string());
|
||||
iface.description = Some("Hyper-V Virtual Ethernet Adapter".to_string());
|
||||
assert!(!is_candidate_interface(&iface));
|
||||
|
||||
// Down interface - must be filtered
|
||||
let mut down_iface = netdev::Interface::dummy();
|
||||
down_iface.flags = 0; // Not UP
|
||||
down_iface.name = "eth0".to_string();
|
||||
assert!(!is_candidate_interface(&down_iface));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_candidate_ip() {
|
||||
// Valid LAN IP
|
||||
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||
192, 168, 1, 117
|
||||
))));
|
||||
// Valid Tailscale IP
|
||||
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||
100, 77, 155, 120
|
||||
))));
|
||||
// Valid Public IP
|
||||
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||
1, 1, 1, 1
|
||||
))));
|
||||
// Valid IPv6 GUA
|
||||
assert!(is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::new(
|
||||
0x2a01, 0x4b00, 0xb8e3, 0x4e00, 0, 0, 0, 1
|
||||
))));
|
||||
|
||||
// Loopback - must be rejected
|
||||
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||
127, 0, 0, 1
|
||||
))));
|
||||
assert!(!is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::LOCALHOST)));
|
||||
|
||||
// Unspecified - must be rejected
|
||||
assert!(!is_candidate_ip(std::net::IpAddr::V4(
|
||||
Ipv4Addr::UNSPECIFIED
|
||||
)));
|
||||
assert!(!is_candidate_ip(std::net::IpAddr::V6(
|
||||
Ipv6Addr::UNSPECIFIED
|
||||
)));
|
||||
|
||||
// Link-local - must be rejected
|
||||
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||
169, 254, 1, 1
|
||||
))));
|
||||
assert!(!is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::new(
|
||||
0xfe80, 0, 0, 0, 0, 0x1ff, 0xfe00, 1
|
||||
))));
|
||||
|
||||
// Broadcast - must be rejected
|
||||
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::BROADCAST)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ip_priority_ordering() {
|
||||
let mut iface_lan = netdev::Interface::dummy();
|
||||
iface_lan.flags = 1;
|
||||
iface_lan.gateway = Some(netdev::net::device::NetworkDevice::new());
|
||||
|
||||
let mut iface_vpn = netdev::Interface::dummy();
|
||||
iface_vpn.flags = 1;
|
||||
iface_vpn.name = "tailscale0".to_string();
|
||||
|
||||
let lan_ip = std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 1, 117));
|
||||
let tailscale_ip = std::net::IpAddr::V4(Ipv4Addr::new(100, 77, 155, 120));
|
||||
|
||||
let p_lan = ip_priority(&iface_lan, lan_ip);
|
||||
let p_ts = ip_priority(&iface_vpn, tailscale_ip);
|
||||
|
||||
// LAN IP with gateway should score higher than VPN IP without gateway
|
||||
assert!(p_lan > p_ts);
|
||||
fn bounded_ips<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Vec<std::net::IpAddr>, D::Error> {
|
||||
struct Visitor;
|
||||
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||
type Value = Vec<std::net::IpAddr>;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("at most 32 IP candidates")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(
|
||||
self,
|
||||
mut seq: A,
|
||||
) -> Result<Self::Value, A::Error> {
|
||||
if seq.size_hint().is_some_and(|n| n > 32) {
|
||||
return Err(serde::de::Error::custom("too many IP candidates"));
|
||||
}
|
||||
let mut ips = Vec::new();
|
||||
while let Some(ip) = seq.next_element()? {
|
||||
if ips.len() == 32 {
|
||||
return Err(serde::de::Error::custom("too many IP candidates"));
|
||||
}
|
||||
ips.push(ip);
|
||||
}
|
||||
Ok(ips)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_seq(Visitor)
|
||||
}
|
||||
@@ -115,6 +115,7 @@ pub struct TcpTlsLink {
|
||||
peer: EndpointId,
|
||||
tx: mpsc::Sender<Bytes>,
|
||||
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
|
||||
tasks: [tokio::task::AbortHandle; 2],
|
||||
closed_notify: Arc<Notify>,
|
||||
is_closed: Arc<AtomicBool>,
|
||||
remote_addr: String,
|
||||
@@ -136,7 +137,7 @@ impl TcpTlsLink {
|
||||
|
||||
let closed_flag_w = Arc::clone(&is_closed);
|
||||
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 {
|
||||
if msg.len() > MAX_FRAME_SIZE {
|
||||
continue;
|
||||
@@ -158,7 +159,7 @@ impl TcpTlsLink {
|
||||
|
||||
let closed_flag_r = Arc::clone(&is_closed);
|
||||
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];
|
||||
loop {
|
||||
if reader.read_exact(&mut len_buf).await.is_err() {
|
||||
@@ -185,6 +186,7 @@ impl TcpTlsLink {
|
||||
peer,
|
||||
tx: tx_out,
|
||||
rx: tokio::sync::Mutex::new(rx_in),
|
||||
tasks: [writer_task.abort_handle(), reader_task.abort_handle()],
|
||||
closed_notify,
|
||||
is_closed,
|
||||
remote_addr,
|
||||
@@ -192,7 +194,19 @@ impl TcpTlsLink {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TcpTlsLink {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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.
|
||||
pub async fn accept_loop<F>(&self, on_inbound: F)
|
||||
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)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::mpsc;
|
||||
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
|
||||
use tsunagi::dataplane::{IpPlugin, PacketSink, PluginContext};
|
||||
use tsunagi::identity::{DeviceIdentity, NetworkId};
|
||||
use tsunagi_tcp_tls::{TcpTlsCodec, TcpTlsPlugin, TcpTlsTransport};
|
||||
|
||||
struct TestSink {
|
||||
delivered: mpsc::Sender<(NetworkId, iroh::EndpointId, Bytes)>,
|
||||
}
|
||||
|
||||
impl PacketSink for TestSink {
|
||||
fn deliver(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
from: iroh::EndpointId,
|
||||
packet: Bytes,
|
||||
) -> tsunagi::BoxFuture<'_, ()> {
|
||||
let delivered = self.delivered.clone();
|
||||
Box::pin(async move {
|
||||
let _ = delivered.send((network, from, packet)).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
use std::{net::IpAddr, sync::Arc};
|
||||
use tsunagi::{
|
||||
dataplane::transport::DataCarrier,
|
||||
identity::{DeviceIdentity, NetworkId},
|
||||
};
|
||||
use tsunagi_tcp_tls::{TcpTlsCarrier, TcpTlsTransport};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tcp_tls_plugin_end_to_end() {
|
||||
let identity_a = DeviceIdentity::generate();
|
||||
let peer_a = identity_a.endpoint_id();
|
||||
|
||||
let identity_b = DeviceIdentity::generate();
|
||||
let peer_b = identity_b.endpoint_id();
|
||||
|
||||
let network = NetworkId::from_bytes([77u8; 32]);
|
||||
|
||||
// Transports
|
||||
let transport_b = Arc::new(
|
||||
TcpTlsTransport::bind(&identity_b, Some(0), None)
|
||||
async fn tls_capabilities_bound_addresses_and_reject_invalid_ports_and_versions() {
|
||||
let identity = DeviceIdentity::generate();
|
||||
let peer = DeviceIdentity::generate().endpoint_id();
|
||||
let transport = Arc::new(
|
||||
TcpTlsTransport::bind(&identity, Some(0), None)
|
||||
.await
|
||||
.expect("bind B"),
|
||||
.unwrap(),
|
||||
);
|
||||
let port_b = transport_b.bound_port();
|
||||
|
||||
let transport_a = Arc::new(
|
||||
TcpTlsTransport::bind(&identity_a, Some(0), None)
|
||||
.await
|
||||
.expect("bind A"),
|
||||
);
|
||||
|
||||
// Codecs and plugins
|
||||
let codec_a = TcpTlsCodec::new(peer_a, Arc::clone(&transport_a));
|
||||
let plugin_a = Arc::new(TcpTlsPlugin::new(codec_a));
|
||||
|
||||
let codec_b = TcpTlsCodec::new(peer_b, Arc::clone(&transport_b));
|
||||
let plugin_b = Arc::new(TcpTlsPlugin::new(codec_b));
|
||||
|
||||
// Attach sinks
|
||||
let (sink_a_tx, mut sink_a_rx) = mpsc::channel(10);
|
||||
plugin_a.attach(PluginContext::with_sink(Arc::new(TestSink {
|
||||
delivered: sink_a_tx,
|
||||
})));
|
||||
|
||||
let (sink_b_tx, mut sink_b_rx) = mpsc::channel(10);
|
||||
plugin_b.attach(PluginContext::with_sink(Arc::new(TestSink {
|
||||
delivered: sink_b_tx,
|
||||
})));
|
||||
|
||||
// Capability exchange
|
||||
let cap_a = plugin_a
|
||||
.local_capability(network)
|
||||
.expect("cap A")
|
||||
.expect("some cap A");
|
||||
let cap_b = plugin_b
|
||||
.local_capability(network)
|
||||
.expect("cap B")
|
||||
.expect("some cap B");
|
||||
|
||||
plugin_a
|
||||
.on_peer_capability(network, peer_b, &cap_b)
|
||||
.expect("A on cap B");
|
||||
plugin_b
|
||||
.on_peer_capability(network, peer_a, &cap_a)
|
||||
.expect("B on cap A");
|
||||
|
||||
// Spawn accept loop on B
|
||||
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
|
||||
let transport_b_clone = Arc::clone(&transport_b);
|
||||
tokio::spawn(async move {
|
||||
transport_b_clone
|
||||
.accept_loop(move |inbound| {
|
||||
let _ = inbound_tx.try_send(inbound);
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
// Make sure A knows B's loopback address
|
||||
transport_a.set_peer_addr(peer_b, format!("127.0.0.1:{port_b}").parse().unwrap());
|
||||
|
||||
// Connect link from A to B
|
||||
let link_a = transport_a
|
||||
.open(network, peer_b, "tcp-tls")
|
||||
.await
|
||||
.expect("open link from A");
|
||||
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
|
||||
.await
|
||||
.expect("recv timeout")
|
||||
.expect("recv inbound");
|
||||
let link_b = inbound_b.link;
|
||||
|
||||
// Install links into plugins
|
||||
plugin_a.on_peer_link(network, peer_b, link_a);
|
||||
plugin_b.on_peer_link(network, peer_a, link_b);
|
||||
|
||||
// Carry packet from A to B
|
||||
let ip_packet_a = Bytes::from_static(b"4500003c000100004011e0e8c0a80101c0a8010200000000");
|
||||
let carried_a = plugin_a.carry(network, peer_b, ip_packet_a.clone());
|
||||
assert!(carried_a, "plugin A should carry packet");
|
||||
|
||||
let (net_received, from_peer, delivered_pkt) =
|
||||
tokio::time::timeout(Duration::from_secs(3), sink_b_rx.recv())
|
||||
.await
|
||||
.expect("timeout waiting for delivery on B")
|
||||
.expect("packet on B");
|
||||
|
||||
assert_eq!(net_received, network);
|
||||
assert_eq!(from_peer, peer_a);
|
||||
assert_eq!(delivered_pkt, ip_packet_a);
|
||||
|
||||
// Carry reply packet from B to A
|
||||
let ip_packet_b = Bytes::from_static(b"reply-packet-from-b");
|
||||
let carried_b = plugin_b.carry(network, peer_a, ip_packet_b.clone());
|
||||
assert!(carried_b, "plugin B should carry packet");
|
||||
|
||||
let (net_received, from_peer, delivered_pkt) =
|
||||
tokio::time::timeout(Duration::from_secs(3), sink_a_rx.recv())
|
||||
.await
|
||||
.expect("timeout waiting for delivery on A")
|
||||
.expect("packet on A");
|
||||
|
||||
assert_eq!(net_received, network);
|
||||
assert_eq!(from_peer, peer_b);
|
||||
assert_eq!(delivered_pkt, ip_packet_b);
|
||||
let carrier = TcpTlsCarrier::new(transport);
|
||||
let network = NetworkId::from_bytes([7; 32]);
|
||||
let mut cap = carrier.local_capability(network).unwrap().unwrap();
|
||||
assert_eq!(cap.protocol, "tcp-tls");
|
||||
let addresses: Vec<IpAddr> = vec!["192.0.2.1".parse().unwrap(); 33];
|
||||
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();
|
||||
assert!(carrier.on_peer_capability(network, peer, &cap).is_err());
|
||||
cap.data =
|
||||
postcard::to_allocvec(&(443u16, vec!["192.0.2.1".parse::<IpAddr>().unwrap()])).unwrap();
|
||||
carrier.on_peer_capability(network, peer, &cap).unwrap();
|
||||
cap.version += 1;
|
||||
assert!(carrier.on_peer_capability(network, peer, &cap).is_err());
|
||||
}
|
||||
@@ -21,6 +21,24 @@ const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// A request from the UI to the agent.
|
||||
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.
|
||||
SetActive { network_id: String, active: bool },
|
||||
/// 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.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct VipSaveResult {
|
||||
pub token: String,
|
||||
pub result: Result<Vec<tsunagi::vip::VipStatus>, String>,
|
||||
}
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Snapshot {
|
||||
pub vip_result: Option<VipSaveResult>,
|
||||
/// The last status, or the reason it could not be read (agent down, no
|
||||
/// permission on the socket, …).
|
||||
pub status: Option<Result<StatusReport, String>>,
|
||||
@@ -73,6 +97,18 @@ pub(crate) struct 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).
|
||||
pub(crate) fn spawn(runtime: &tokio::runtime::Handle, ctx: egui::Context) -> Self {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
@@ -106,7 +142,13 @@ impl AgentClient {
|
||||
|
||||
/// Sends a command; a dropped worker simply means nothing happens.
|
||||
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.
|
||||
@@ -142,6 +184,8 @@ async fn worker(
|
||||
let Some(command) = command else { return };
|
||||
run(&resolve_socket(), command, &shared, &pending_copy, &ctx).await;
|
||||
refresh(&shared, &ctx).await;
|
||||
lock(&shared).busy = false;
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,11 +223,70 @@ async fn run(
|
||||
ctx.request_repaint();
|
||||
|
||||
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)
|
||||
.await
|
||||
.map(|report| {
|
||||
let state = if report.active { "started" } else { "stopped" };
|
||||
format!("{} {}", state, report.name)
|
||||
if report.suspended.is_empty() {
|
||||
format!("{} {}", state, report.name)
|
||||
} else {
|
||||
format!(
|
||||
"{} {}; suspended {} until it stops",
|
||||
state,
|
||||
report.name,
|
||||
report
|
||||
.suspended
|
||||
.iter()
|
||||
.map(|n| format!("{} ({})", n.name, n.network_id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
})
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::SetBroadcast {
|
||||
@@ -211,7 +314,17 @@ async fn run(
|
||||
}
|
||||
Command::Join { name, secret } => ipc::join_network(socket, &name, &secret)
|
||||
.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()),
|
||||
Command::Leave { network_id } => ipc::leave_network(socket, &network_id)
|
||||
.await
|
||||
@@ -236,7 +349,6 @@ async fn run(
|
||||
|
||||
{
|
||||
let mut guard = lock(shared);
|
||||
guard.busy = false;
|
||||
guard.last_action = if matches!(&outcome, Ok(message) if message.is_empty()) {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -15,6 +15,12 @@ use crate::devices;
|
||||
use crate::stats::Traffic;
|
||||
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.
|
||||
pub(crate) struct App {
|
||||
agent: AgentClient,
|
||||
@@ -34,9 +40,7 @@ impl App {
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
// Add the Phosphor icon glyphs to the font set so the icon buttons
|
||||
// render instead of showing as tofu.
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
||||
ctx.set_fonts(fonts);
|
||||
ctx.set_fonts(app_fonts());
|
||||
|
||||
let agent = AgentClient::spawn(&runtime, ctx);
|
||||
Ok(Self {
|
||||
@@ -60,6 +64,7 @@ impl eframe::App for App {
|
||||
|
||||
ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic);
|
||||
self.show_devices_windows(ctx, &snapshot);
|
||||
self.show_vip_windows(ctx, &snapshot);
|
||||
self.show_about_window(ctx, &snapshot);
|
||||
|
||||
// A secret the worker fetched for the clipboard: copy it here, on the
|
||||
@@ -74,6 +79,51 @@ impl eframe::App for 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.
|
||||
fn show_about_window(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) {
|
||||
if !self.state.about_open {
|
||||
@@ -116,20 +166,21 @@ impl App {
|
||||
.with_title(devices::title(network))
|
||||
.with_app_id(crate::icon::APP_ID)
|
||||
.with_icon(crate::icon::window())
|
||||
.with_inner_size([940.0, 540.0])
|
||||
.with_min_inner_size([480.0, 320.0]);
|
||||
.with_inner_size([1440.0, 640.0])
|
||||
.with_min_inner_size([800.0, 400.0]);
|
||||
let agent = &self.agent;
|
||||
let traffic = &self.traffic;
|
||||
// The window shares the single unit setting, so its toggle and the
|
||||
// main window's stay in step.
|
||||
let unit = &mut self.state.unit;
|
||||
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| {
|
||||
devices::show(
|
||||
vctx,
|
||||
agent,
|
||||
unit,
|
||||
confirm,
|
||||
(confirm, forget_confirm),
|
||||
network,
|
||||
&report.endpoint_id,
|
||||
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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
+760
-167
File diff suppressed because it is too large.
Load diff
@@ -109,6 +109,7 @@ mod tests {
|
||||
fn network(exit: ExitReport) -> NetworkReport {
|
||||
NetworkReport {
|
||||
peers: vec![PeerReport {
|
||||
data_error: None,
|
||||
endpoint_id: "abcdef0123456789".into(),
|
||||
hostname: Some("music".into()),
|
||||
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.
|
||||
pub(crate) fn bytes(n: f64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"];
|
||||
|
||||
@@ -78,6 +78,7 @@ fn colour_of(health: Health) -> [u8; 3] {
|
||||
match health {
|
||||
Health::Disconnected => DARK_RED,
|
||||
Health::Connected => BLUE,
|
||||
Health::RangeConflict => [0xd9, 0x91, 0x20],
|
||||
Health::ExitNode => GREEN,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,11 @@ mod exit;
|
||||
mod format;
|
||||
mod icon;
|
||||
mod stats;
|
||||
mod table;
|
||||
mod tray;
|
||||
mod ui;
|
||||
mod vip;
|
||||
mod vip_status;
|
||||
mod watch;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
//! key: a network total, and each peer within it. Both packets and bytes are
|
||||
//! tracked so the UI can switch between them.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::time::Instant;
|
||||
|
||||
use tsunagi::ipc::StatusReport;
|
||||
use tsunagi::ipc::{OverlayPeerReport, StatusReport};
|
||||
|
||||
/// How many rate samples to keep for a sparkline.
|
||||
const HISTORY: usize = 60;
|
||||
@@ -26,7 +26,7 @@ pub(crate) enum Unit {
|
||||
/// One series' rolling packet and byte rates.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Series {
|
||||
last: Option<(Instant, u64, u64)>,
|
||||
last: Option<(Instant, Counters)>,
|
||||
packets_rate: f32,
|
||||
bytes_rate: f32,
|
||||
packets_history: VecDeque<f32>,
|
||||
@@ -34,17 +34,52 @@ pub(crate) struct Series {
|
||||
}
|
||||
|
||||
impl Series {
|
||||
fn sample(&mut self, now: Instant, packets: u64, bytes: u64) {
|
||||
if let Some((then, prev_packets, prev_bytes)) = self.last {
|
||||
/// Samples a single cumulative counter pair (VIP histories own their lifetime).
|
||||
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();
|
||||
if dt > 0.0 {
|
||||
self.packets_rate = packets.saturating_sub(prev_packets) as f32 / dt;
|
||||
self.bytes_rate = bytes.saturating_sub(prev_bytes) as f32 / dt;
|
||||
push(&mut self.packets_history, self.packets_rate);
|
||||
push(&mut self.bytes_history, self.bytes_rate);
|
||||
// Ignore duplicate/out-of-order observations without changing the baseline.
|
||||
if dt <= 0.0 {
|
||||
return;
|
||||
}
|
||||
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.
|
||||
@@ -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.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Traffic {
|
||||
@@ -80,26 +136,32 @@ pub(crate) struct Traffic {
|
||||
impl Traffic {
|
||||
/// Folds a fresh snapshot into the rates. Call once per new generation.
|
||||
pub(crate) fn observe(&mut self, report: &StatusReport, now: Instant) {
|
||||
let mut active = HashSet::new();
|
||||
for network in &report.networks {
|
||||
let Some(overlay) = &network.overlay else {
|
||||
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 {
|
||||
let packets = peer.tx_packets.saturating_add(peer.rx_packets);
|
||||
let bytes = peer.tx_bytes.saturating_add(peer.rx_bytes);
|
||||
net_packets = net_packets.saturating_add(packets);
|
||||
net_bytes = net_bytes.saturating_add(bytes);
|
||||
self.series
|
||||
.entry(peer_key(&network.network_id, &peer.public_key))
|
||||
.or_default()
|
||||
.sample(now, packets, bytes);
|
||||
let key = peer_key(&network.network_id, &peer.public_key);
|
||||
// Duplicate rows are not additional traffic.
|
||||
if !active.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
let series = self.series.entry(key).or_default();
|
||||
series.sample_peer(now, peer.into());
|
||||
packets_rate += series.rate(Unit::Packets);
|
||||
bytes_rate += series.rate(Unit::Bytes);
|
||||
}
|
||||
// Sum rates, not cumulative counters of a changing set of peers.
|
||||
let key = net_key(&network.network_id);
|
||||
active.insert(key.clone());
|
||||
self.series
|
||||
.entry(net_key(&network.network_id))
|
||||
.entry(key)
|
||||
.or_default()
|
||||
.sample(now, net_packets, net_bytes);
|
||||
.record(packets_rate, bytes_rate);
|
||||
}
|
||||
self.series.retain(|key, _| active.contains(key));
|
||||
}
|
||||
|
||||
/// The network-total series, if sampled.
|
||||
@@ -120,3 +182,112 @@ fn net_key(network_id: &str) -> String {
|
||||
fn peer_key(network_id: &str, public_key: &str) -> String {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
-10
@@ -41,10 +41,13 @@ pub(crate) struct UiState {
|
||||
pub(crate) host_edit: Option<String>,
|
||||
/// Network ids whose devices window is open.
|
||||
pub(crate) open_devices: BTreeSet<String>,
|
||||
pub(crate) vip_windows: std::collections::BTreeMap<String, crate::vip::Window>,
|
||||
/// Whether the About window is open.
|
||||
pub(crate) about_open: bool,
|
||||
/// An exit-node choice waiting for the user to confirm it.
|
||||
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 {
|
||||
@@ -54,8 +57,10 @@ impl Default for UiState {
|
||||
unit: Unit::Packets,
|
||||
host_edit: None,
|
||||
open_devices: BTreeSet::new(),
|
||||
vip_windows: Default::default(),
|
||||
about_open: false,
|
||||
exit_confirm: None,
|
||||
forget_confirm: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,7 +128,7 @@ pub(crate) fn draw(
|
||||
ui.weak("No networks yet — join one below.");
|
||||
}
|
||||
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,
|
||||
traffic: &Traffic,
|
||||
network: &NetworkReport,
|
||||
own_id: &str,
|
||||
report: &StatusReport,
|
||||
) {
|
||||
ui.add_space(6.0);
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
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| {
|
||||
let leave =
|
||||
egui::Button::new(egui::RichText::new("Leave").color(egui::Color32::WHITE))
|
||||
@@ -244,21 +250,40 @@ fn draw_network(
|
||||
ui.weak("peers");
|
||||
ui.label(format!(
|
||||
"{} online · {} known",
|
||||
devices::online_count(network, own_id),
|
||||
devices::known_count(network, own_id)
|
||||
devices::online_count(network, &report.endpoint_id),
|
||||
devices::known_count(network, &report.endpoint_id)
|
||||
));
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
draw_protocols(ui, agent, network, &report.available_protocols);
|
||||
|
||||
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| {
|
||||
let mut active = network.active;
|
||||
if ui.checkbox(&mut active, "active").changed() {
|
||||
agent.send(Command::SetActive {
|
||||
network_id: network.network_id.clone(),
|
||||
active,
|
||||
});
|
||||
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;
|
||||
if ui.checkbox(&mut active, "active").changed() {
|
||||
agent.send(Command::SetActive {
|
||||
network_id: network.network_id.clone(),
|
||||
active,
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut broadcast = network.broadcast;
|
||||
if ui.checkbox(&mut broadcast, "broadcast").changed() {
|
||||
@@ -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
|
||||
/// when clicked.
|
||||
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,
|
||||
/// Connected, nothing out of the ordinary.
|
||||
Connected,
|
||||
/// At least one configured network is blocked by an overlapping active range.
|
||||
RangeConflict,
|
||||
/// This device's traffic leaves through an exit node, and that works.
|
||||
ExitNode,
|
||||
}
|
||||
@@ -33,6 +35,13 @@ impl Health {
|
||||
let Some(report) = report else {
|
||||
return Self::Disconnected;
|
||||
};
|
||||
if report
|
||||
.networks
|
||||
.iter()
|
||||
.any(|network| network.auto_start && !network.range_conflicts.is_empty())
|
||||
{
|
||||
return Self::RangeConflict;
|
||||
}
|
||||
if !report
|
||||
.networks
|
||||
.iter()
|
||||
@@ -56,6 +65,7 @@ impl Health {
|
||||
match self {
|
||||
Self::Disconnected => "tsunagi: not connected",
|
||||
Self::Connected => "tsunagi: connected",
|
||||
Self::RangeConflict => "tsunagi: network range conflict; open to switch networks",
|
||||
Self::ExitNode => "tsunagi: using an exit node",
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
fn no_agent_or_nobody_connected_is_the_red_state() {
|
||||
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
|
||||
# build, because what peers compare is `ANNOUNCEMENT_VERSION`.
|
||||
version = "0.1.0"
|
||||
description = "The wg-quic protocol for tsunagi: WireGuard's cryptography carried in iroh's QUIC datagrams."
|
||||
description = "The single internal WireGuard tunnel for tsunagi, independent of external carriers."
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -30,6 +30,8 @@ rand = "0.10"
|
||||
boringtun = { version = "0.7.1", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
tsunagi-wireguard = { path = "../tsunagi-wireguard" }
|
||||
tsunagi-tcp-tls = { path = "../tsunagi-tcp-tls" }
|
||||
tsunagi-core = { path = "../tsunagi-core", features = ["testing"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -59,7 +59,7 @@ const SCRATCH: usize = 4096;
|
||||
/// All three operations share the same mutex as the cryptographic state.
|
||||
struct FlowTunnel {
|
||||
tunn: Tunn,
|
||||
queued: VecDeque<FlowId>,
|
||||
queued: VecDeque<(FlowId, usize)>,
|
||||
}
|
||||
|
||||
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.
|
||||
if self.queued.len() < 256 {
|
||||
self.queued.push_back(flow);
|
||||
self.queued.push_back((flow, packet.len()));
|
||||
}
|
||||
(result, 0)
|
||||
}
|
||||
@@ -88,19 +88,22 @@ impl FlowTunnel {
|
||||
&mut self,
|
||||
packet: &[u8],
|
||||
scratch: &'a mut [u8],
|
||||
) -> (TunnResult<'a>, FlowId) {
|
||||
) -> (TunnResult<'a>, FlowId, Option<usize>) {
|
||||
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 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() {
|
||||
// Without a session, draining pops the oldest IP packet and
|
||||
// encapsulate queues it again at the back.
|
||||
self.queued.push_back(pending);
|
||||
}
|
||||
}
|
||||
(result, flow)
|
||||
(result, flow, plaintext_len)
|
||||
}
|
||||
|
||||
fn update_timers<'a>(&mut self, scratch: &'a mut [u8]) -> TunnResult<'a> {
|
||||
@@ -118,6 +121,7 @@ impl FlowTunnel {
|
||||
/// Counters for one peer's tunnel.
|
||||
#[derive(Debug, Default)]
|
||||
struct PeerCounters {
|
||||
epoch: u64,
|
||||
tx_packets: AtomicU64,
|
||||
tx_bytes: AtomicU64,
|
||||
rx_packets: AtomicU64,
|
||||
@@ -132,6 +136,8 @@ struct PeerCounters {
|
||||
/// A snapshot of one peer's tunnel counters.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
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.
|
||||
pub tx_packets: u64,
|
||||
/// Plaintext bytes encrypted and sent to this peer.
|
||||
@@ -209,6 +215,7 @@ impl Drop for Peer {
|
||||
impl Peer {
|
||||
fn stats(&self) -> PeerStats {
|
||||
PeerStats {
|
||||
counter_epoch: self.counters.epoch,
|
||||
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),
|
||||
@@ -328,26 +335,27 @@ impl WireguardDevice {
|
||||
};
|
||||
let (result, selected_flow) = tunn.encapsulate(packet, &mut scratch, flow);
|
||||
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
|
||||
// rather than reported: the handshake is in flight and the
|
||||
// next packet will go.
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let Some((len, selected_flow)) = result else {
|
||||
let Some((len, selected_flow, data)) = 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
|
||||
let sent = send_to_peer_flow(&peer, &scratch[..len], selected_flow);
|
||||
if sent && data {
|
||||
count_sent(&peer, packet.len());
|
||||
}
|
||||
sent
|
||||
}
|
||||
|
||||
/// Adds or replaces a peer and starts its tunnel.
|
||||
@@ -388,7 +396,10 @@ impl WireguardDevice {
|
||||
queued: VecDeque::new(),
|
||||
}),
|
||||
link,
|
||||
counters: Arc::new(PeerCounters::default()),
|
||||
counters: Arc::new(PeerCounters {
|
||||
epoch: rand::random(),
|
||||
..Default::default()
|
||||
}),
|
||||
task: Mutex::new(None),
|
||||
});
|
||||
|
||||
@@ -501,9 +512,9 @@ 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) {
|
||||
fn send_to_peer_flow(peer: &Peer, payload: &[u8], flow: FlowId) -> bool {
|
||||
match peer.link.send_flow(Bytes::copy_from_slice(payload), flow) {
|
||||
Ok(()) => {}
|
||||
Ok(()) => return true,
|
||||
Err(TransportError::TooLarge { .. }) => {
|
||||
peer.counters
|
||||
.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");
|
||||
}
|
||||
}
|
||||
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>) {
|
||||
@@ -532,9 +551,12 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
|
||||
Ok(guard) => guard,
|
||||
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 {
|
||||
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:
|
||||
// whether the peer may use it is checked where the
|
||||
// claims live.
|
||||
@@ -549,8 +571,12 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
|
||||
};
|
||||
|
||||
match outcome {
|
||||
Outcome::ToNetwork(len, flow) => {
|
||||
send_to_peer_flow(&peer, &scratch[..len], flow);
|
||||
Outcome::ToNetwork(len, flow, plaintext_len) => {
|
||||
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.
|
||||
input = None;
|
||||
continue;
|
||||
@@ -584,7 +610,7 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
ToNetwork(usize, FlowId),
|
||||
ToNetwork(usize, FlowId, Option<usize>),
|
||||
ToTunnel(usize),
|
||||
Done,
|
||||
Failed,
|
||||
@@ -671,7 +697,12 @@ mod flow_tests {
|
||||
let keepalive = wire(a.decapsulate(&response, &mut scratch).0);
|
||||
let _ = b.decapsulate(&keepalive, &mut scratch);
|
||||
for expected in 100..356 {
|
||||
let (result, flow) = a.decapsulate(&[], &mut scratch);
|
||||
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);
|
||||
let ciphertext = wire(result);
|
||||
match b.decapsulate(&ciphertext, &mut scratch).0 {
|
||||
|
||||
@@ -47,13 +47,9 @@ use crate::store::WgKeyStore;
|
||||
use tsunagi::state::Ipv4Range;
|
||||
use tsunagi::task::{TASK_GRACE, wind_down};
|
||||
|
||||
/// The protocol identifier this plugin announces.
|
||||
/// The protocol id of this plugin.
|
||||
///
|
||||
/// `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";
|
||||
/// Fixed internal encryption capability, independent of external carrier names.
|
||||
/// The historical crate/storage name does not select a physical transport.
|
||||
pub const WIREGUARD_PROTOCOL: &str = "wireguard-e2e";
|
||||
|
||||
/// Smallest interface MTU the overlay accepts.
|
||||
///
|
||||
@@ -79,6 +75,8 @@ pub const WIREGUARD_OVERHEAD: u32 = 32;
|
||||
/// Configuration of the WireGuard plugin.
|
||||
#[derive(Debug, Clone)]
|
||||
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.
|
||||
pub state_dir: PathBuf,
|
||||
/// 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.
|
||||
pub fn new(state_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
legacy_key_stores: Vec::new(),
|
||||
state_dir: state_dir.into(),
|
||||
keepalive: Some(25),
|
||||
mtu: DEFAULT_MTU,
|
||||
@@ -299,9 +298,16 @@ impl WireguardPlugin {
|
||||
}
|
||||
|
||||
let path = config.key_store_path();
|
||||
let store = tokio::task::spawn_blocking(move || WgKeyStore::open(path))
|
||||
.await
|
||||
.map_err(|err| PluginError::Other(format!("key store task failed: {err}")))??;
|
||||
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
|
||||
.map_err(|err| PluginError::Other(format!("key store task failed: {err}")))??;
|
||||
|
||||
let worker = Arc::new(Worker {
|
||||
config,
|
||||
@@ -792,14 +798,26 @@ impl IpPlugin for WireguardPlugin {
|
||||
self.nudge(Command::Teardown(network));
|
||||
}
|
||||
|
||||
fn on_network_forgotten(&self, network: NetworkId) {
|
||||
// The key is this protocol's identity in that network and nothing
|
||||
// else's. Keeping it after leaving would keep a secret for a
|
||||
// network this agent is no longer in — and hand back the same
|
||||
// overlay address on a rejoin that everyone else has moved past.
|
||||
if let Err(err) = self.worker.store.forget(network) {
|
||||
tracing::warn!(%err, "cannot remove the WireGuard key of a network we left");
|
||||
}
|
||||
fn on_network_forgotten(&self, network: NetworkId) -> BoxFuture<'_, ()> {
|
||||
let worker = Arc::clone(&self.worker);
|
||||
Box::pin(async move {
|
||||
// Remove old copies as well, otherwise the next startup's import
|
||||
// would resurrect a key that the user explicitly forgot.
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
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, ()> {
|
||||
|
||||
@@ -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.
|
||||
pub fn load_or_create(&self, network: NetworkId) -> Result<WgSecretKey, PluginError> {
|
||||
let conn = self.lock();
|
||||
@@ -219,4 +262,70 @@ mod tests {
|
||||
"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,
|
||||
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
|
||||
.unwrap();
|
||||
|
||||
@@ -47,7 +47,10 @@ impl HostedAgent {
|
||||
config_with(dir.path(), discovery)
|
||||
.with_overlay_ipv4_range(Some(DEFAULT_IPV4_RANGE))
|
||||
.with_interface(factory, tag, 1280)
|
||||
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
|
||||
.with_tunnel(plugin.clone() as Arc<dyn IpPlugin>)
|
||||
.with_carrier(Arc::new(
|
||||
tsunagi::dataplane::transport::iroh_link::IrohCarrier,
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -37,6 +37,7 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::R
|
||||
.peers
|
||||
.iter()
|
||||
.map(|peer| tsunagi::ipc::PeerReport {
|
||||
data_error: None,
|
||||
endpoint_id: peer.endpoint_id.to_string(),
|
||||
hostname: peer.hostname.clone(),
|
||||
transport: format!("{:?}", peer.transport),
|
||||
@@ -107,7 +108,10 @@ async fn a_client_sees_the_agent_and_its_overlay() {
|
||||
let agent = Agent::spawn(
|
||||
config_with(dir_a.path(), &discovery)
|
||||
.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
|
||||
.unwrap();
|
||||
@@ -122,7 +126,10 @@ async fn a_client_sees_the_agent_and_its_overlay() {
|
||||
let agent_b = Agent::spawn(
|
||||
config_with(dir_b.path(), &discovery)
|
||||
.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
|
||||
.unwrap();
|
||||
@@ -277,6 +284,7 @@ impl tsunagi::ipc::ReportSource for Control {
|
||||
network_id: network_id.to_string(),
|
||||
already_configured: already,
|
||||
name_shared_with: None,
|
||||
active: self.0.is_active(network_id).await,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -109,6 +109,33 @@ impl WgAgent {
|
||||
.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.
|
||||
async fn spawn_range(
|
||||
discovery: &SharedMemoryDiscovery,
|
||||
@@ -169,7 +196,10 @@ impl WgAgent {
|
||||
config_with(root, discovery)
|
||||
.with_overlay_ipv4_range(range)
|
||||
.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
|
||||
.unwrap();
|
||||
@@ -201,7 +231,10 @@ impl WgAgent {
|
||||
.with_overlay_ipv4_range(Some(tsunagi::state::DEFAULT_IPV4_RANGE))
|
||||
.with_interface(Arc::new(tuns.clone()), tag, 1280)
|
||||
.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
|
||||
.unwrap();
|
||||
@@ -230,7 +263,10 @@ impl WgAgent {
|
||||
config_with(dir.path(), discovery)
|
||||
.with_overlay_ipv4_range(Some(tsunagi::state::DEFAULT_IPV4_RANGE))
|
||||
.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
|
||||
.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 tunnel = view.peers[0].tunnel.as_ref().unwrap();
|
||||
assert!(tunnel.health.is_up());
|
||||
assert!(tunnel.stats.tx_packets >= 1);
|
||||
assert!(tunnel.stats.rx_packets >= 1);
|
||||
wait_until(
|
||||
"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);
|
||||
|
||||
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;
|
||||
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 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 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 status: NetworkStatus = a.agent.network_status(network_id).await.ok()?;
|
||||
status
|
||||
.peers
|
||||
.first()
|
||||
.and_then(|peer| peer.capabilities.first().cloned())
|
||||
status.peers.first().and_then(|peer| {
|
||||
peer.capabilities
|
||||
.iter()
|
||||
.find(|cap| cap.protocol == WIREGUARD_PROTOCOL)
|
||||
.cloned()
|
||||
})
|
||||
})
|
||||
.await;
|
||||
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 = Agent::spawn(
|
||||
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
|
||||
.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 dir = TempDir::new().unwrap();
|
||||
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
|
||||
.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();
|
||||
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!(
|
||||
status.peers[0].protocols.is_empty(),
|
||||
"nothing was agreed with it: {:?}",
|
||||
status.peers[0].protocols
|
||||
status.peers[0]
|
||||
.data_error
|
||||
.as_deref()
|
||||
.is_some_and(|reason| reason.contains("update both agents"))
|
||||
);
|
||||
|
||||
// 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 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
|
||||
.unwrap();
|
||||
@@ -1308,8 +1405,12 @@ async fn agreement_turns_on_the_protocol_version_and_nothing_else() {
|
||||
status
|
||||
.peers
|
||||
.first()?
|
||||
.protocols
|
||||
.contains(&WIREGUARD_PROTOCOL.to_string())
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|cap| {
|
||||
cap.protocol == WIREGUARD_PROTOCOL
|
||||
&& cap.version == tsunagi_wg_quic::ANNOUNCEMENT_VERSION
|
||||
})
|
||||
.then_some(())
|
||||
})
|
||||
.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");
|
||||
// UDP checksum zero is valid for IPv4.
|
||||
let sum: u32 = bytes[..20]
|
||||
.chunks_exact(2)
|
||||
.map(|w| u16::from_be_bytes([w[0], w[1]]) as u32)
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|w| u16::from_be_bytes(*w) as u32)
|
||||
.sum();
|
||||
let checksum = !((sum & 0xffff) + (sum >> 16)) as u16;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
name = "tsunagi-wireguard"
|
||||
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
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
license = "WTFPL AND Apache-2.0"
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -14,14 +14,10 @@ tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macro
|
||||
serde.workspace = true
|
||||
postcard.workspace = true
|
||||
bytes.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
data-encoding.workspace = true
|
||||
hex.workspace = true
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.9", features = ["derive"] }
|
||||
rand = "0.10"
|
||||
chacha20 = "0.9"
|
||||
boringtun = { version = "0.7.1", default-features = false }
|
||||
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.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
//! Authenticated, per-neighbour WireGuard UDP carriers. IP encryption is independent.
|
||||
#![deny(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod announcement;
|
||||
pub mod device;
|
||||
pub mod keys;
|
||||
pub mod plugin;
|
||||
pub mod store;
|
||||
pub mod transport;
|
||||
|
||||
pub use announcement::{
|
||||
ANNOUNCEMENT_VERSION, ValidatedAnnouncement, WG_PROTOCOL, WG_WIRE_VERSION, WgAnnouncement,
|
||||
};
|
||||
pub use device::{PeerHealth, PeerStats, PeerSummary, WireguardDevice};
|
||||
pub use keys::{WgPublicKey, WgSecretKey};
|
||||
pub use plugin::{
|
||||
DEFAULT_MTU, MIN_MTU, NetworkOverview, PeerOverview, WIREGUARD_OVERHEAD, WireguardConfig,
|
||||
WireguardPlugin, local_ip_candidates,
|
||||
};
|
||||
pub use store::WgKeyStore;
|
||||
pub use transport::{DEFAULT_PORT, MAX_UDP_DATAGRAM_SIZE, WireguardLink, WireguardTransport};
|
||||
mod addresses;
|
||||
mod masking;
|
||||
mod plugin;
|
||||
pub mod profile;
|
||||
mod session;
|
||||
mod stun;
|
||||
mod transport;
|
||||
pub use addresses::{local_ip_candidates, prioritize_local_subnet};
|
||||
pub use plugin::AwgPlugin as UdpCarrier;
|
||||
pub use transport::{AwgTransport as UdpTransport, PairOverview};
|
||||
/// External ordinary WireGuard carrier identifier.
|
||||
pub const WG_PROTOCOL: &str = "wg";
|
||||
/// Wire version of the private UDP negotiation and carrier frames.
|
||||
pub const WG_WIRE_VERSION: u16 = 2;
|
||||
/// Masked carrier identifier used by the shared pair engine.
|
||||
pub const AWG_PROTOCOL: &str = "awg";
|
||||
/// Wire version of masked carrier frames and private negotiation.
|
||||
pub const AWG_WIRE_VERSION: u16 = 2;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
//! The plugin's own key store.
|
||||
//!
|
||||
//! Deliberately a separate SQLite file from the agent's `state.sqlite`: plugin
|
||||
//! keys are not the iroh identity and not the network secret, and their
|
||||
//! lifecycle is the plugin's business alone.
|
||||
//!
|
||||
//! One key per network, so a participant presents a different WireGuard
|
||||
//! identity in each network it belongs to.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use tsunagi::dataplane::PluginError;
|
||||
use tsunagi::identity::NetworkId;
|
||||
|
||||
use crate::keys::{KEY_LEN, WgSecretKey};
|
||||
|
||||
/// Schema version written by this build.
|
||||
pub const SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
/// Per-network WireGuard private keys.
|
||||
#[derive(Debug)]
|
||||
pub struct WgKeyStore {
|
||||
conn: Mutex<Connection>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl WgKeyStore {
|
||||
/// Opens, creating the file and its directory if needed.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, PluginError> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
if let Some(parent) = path.parent() {
|
||||
tsunagi::storage::create_dir(parent)
|
||||
.map_err(|err| PluginError::Other(format!("cannot create {parent:?}: {err}")))?;
|
||||
}
|
||||
let existed = path.exists();
|
||||
let conn = Connection::open(&path).map_err(|err| {
|
||||
PluginError::Other(format!("cannot open the WireGuard key store: {err}"))
|
||||
})?;
|
||||
tsunagi::storage::restrict_path_permissions(&path)
|
||||
.map_err(|err| PluginError::Other(format!("cannot secure the key store: {err}")))?;
|
||||
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||
.and_then(|()| conn.pragma_update(None, "journal_mode", "WAL"))
|
||||
.and_then(|()| conn.pragma_update(None, "synchronous", "NORMAL"))
|
||||
.map_err(|err| PluginError::Other(format!("cannot configure the key store: {err}")))?;
|
||||
|
||||
if existed {
|
||||
let integrity: String = conn
|
||||
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
|
||||
.map_err(|err| {
|
||||
PluginError::Other(format!("WireGuard key store is unusable: {err}"))
|
||||
})?;
|
||||
if integrity != "ok" {
|
||||
return Err(PluginError::Other(format!(
|
||||
"WireGuard key store at {} is corrupt and will not be recreated: {integrity}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let found: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
||||
.map_err(|err| PluginError::Other(format!("cannot read the schema version: {err}")))?;
|
||||
if found > SCHEMA_VERSION {
|
||||
return Err(PluginError::Other(format!(
|
||||
"WireGuard key store schema {found} is newer than {SCHEMA_VERSION}"
|
||||
)));
|
||||
}
|
||||
if found < SCHEMA_VERSION {
|
||||
conn.execute_batch(
|
||||
"BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS network_keys (
|
||||
network_id BLOB PRIMARY KEY,
|
||||
secret BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 1;
|
||||
COMMIT;",
|
||||
)
|
||||
.map_err(|err| PluginError::Other(format!("cannot create the schema: {err}")))?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Path of the underlying file.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Loads the stored key for `network`, or generates, stores and returns a fresh one.
|
||||
pub fn load_or_create(&self, network: NetworkId) -> Result<WgSecretKey, PluginError> {
|
||||
let guard = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| PluginError::Other("key store lock is poisoned".into()))?;
|
||||
|
||||
let existing: Option<Vec<u8>> = guard
|
||||
.query_row(
|
||||
"SELECT secret FROM network_keys WHERE network_id = ?1",
|
||||
params![network.as_bytes().as_slice()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|err| PluginError::Other(format!("cannot read the key store: {err}")))?;
|
||||
|
||||
if let Some(secret) = existing {
|
||||
let bytes = <[u8; KEY_LEN]>::try_from(secret.as_slice()).map_err(|_| {
|
||||
PluginError::Other(format!(
|
||||
"stored WireGuard key for {} is {} bytes, expected {KEY_LEN}",
|
||||
network.fmt_short(),
|
||||
secret.len()
|
||||
))
|
||||
})?;
|
||||
return Ok(WgSecretKey::from_bytes(&bytes));
|
||||
}
|
||||
|
||||
let fresh = WgSecretKey::generate();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|err| PluginError::Other(format!("system clock is broken: {err}")))?
|
||||
.as_secs() as i64;
|
||||
|
||||
guard
|
||||
.execute(
|
||||
"INSERT INTO network_keys (network_id, secret, created_at) VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
network.as_bytes().as_slice(),
|
||||
fresh.as_bytes().as_slice(),
|
||||
now
|
||||
],
|
||||
)
|
||||
.map_err(|err| PluginError::Other(format!("cannot write the key store: {err}")))?;
|
||||
|
||||
Ok(fresh)
|
||||
}
|
||||
|
||||
/// Removes the stored key for `network`.
|
||||
pub fn forget(&self, network: NetworkId) -> Result<(), PluginError> {
|
||||
let guard = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| PluginError::Other("key store lock is poisoned".into()))?;
|
||||
guard
|
||||
.execute(
|
||||
"DELETE FROM network_keys WHERE network_id = ?1",
|
||||
params![network.as_bytes().as_slice()],
|
||||
)
|
||||
.map_err(|err| {
|
||||
PluginError::Other(format!("cannot delete from the key store: {err}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Optional, bounded server-reflexive discovery on the pair's own UDP socket.
|
||||
//! A STUN answer is a candidate only; WireGuard authentication verifies a path.
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
use tokio::net::UdpSocket;
|
||||
const COOKIE: [u8; 4] = [0x21, 0x12, 0xa4, 0x42];
|
||||
pub(crate) async fn mapped(socket: &UdpSocket, server: &str) -> Option<SocketAddr> {
|
||||
tokio::time::timeout(Duration::from_millis(1200), async {
|
||||
let address = tokio::net::lookup_host(server)
|
||||
.await
|
||||
.ok()?
|
||||
.find(SocketAddr::is_ipv4)?;
|
||||
let transaction: [u8; 12] = rand::random();
|
||||
let mut request = [0; 20];
|
||||
request[..2].copy_from_slice(&[0, 1]);
|
||||
request[4..8].copy_from_slice(&COOKIE);
|
||||
request[8..].copy_from_slice(&transaction);
|
||||
socket.send_to(&request, address).await.ok()?;
|
||||
let mut bytes = [0; 1024];
|
||||
loop {
|
||||
let (len, source) = socket.recv_from(&mut bytes).await.ok()?;
|
||||
if source == address
|
||||
&& let Some(mapped) = parse(&bytes[..len], &transaction)
|
||||
{
|
||||
return Some(mapped);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
fn parse(bytes: &[u8], transaction: &[u8; 12]) -> Option<SocketAddr> {
|
||||
if bytes.len() < 20
|
||||
|| bytes[..2] != [1, 1]
|
||||
|| bytes[4..8] != COOKIE
|
||||
|| &bytes[8..20] != transaction
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let declared = u16::from_be_bytes(bytes[2..4].try_into().ok()?) as usize;
|
||||
if !declared.is_multiple_of(4) || declared != bytes.len() - 20 {
|
||||
return None;
|
||||
}
|
||||
let mut at = 20;
|
||||
while at + 4 <= bytes.len() {
|
||||
let kind = u16::from_be_bytes(bytes[at..at + 2].try_into().ok()?);
|
||||
let len = u16::from_be_bytes(bytes[at + 2..at + 4].try_into().ok()?) as usize;
|
||||
at += 4;
|
||||
let attribute = bytes.get(at..at.checked_add(len)?)?;
|
||||
if kind == 0x0020 && attribute.len() == 8 && attribute[0..2] == [0, 1] {
|
||||
let port = u16::from_be_bytes([attribute[2] ^ COOKIE[0], attribute[3] ^ COOKIE[1]]);
|
||||
let ip = Ipv4Addr::new(
|
||||
attribute[4] ^ COOKIE[0],
|
||||
attribute[5] ^ COOKIE[1],
|
||||
attribute[6] ^ COOKIE[2],
|
||||
attribute[7] ^ COOKIE[3],
|
||||
);
|
||||
if port == 0 || ip.is_unspecified() || ip.is_multicast() || ip.is_broadcast() {
|
||||
return None;
|
||||
}
|
||||
return Some(SocketAddr::new(IpAddr::V4(ip), port));
|
||||
}
|
||||
at = at.checked_add((len + 3) & !3)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn mapping_uses_the_actual_socket_and_rejects_unrelated_or_truncated_answers() {
|
||||
let server = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
let address = server.local_addr().unwrap();
|
||||
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
let expected = socket.local_addr().unwrap();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut request = [0; 128];
|
||||
let (len, source) = server.recv_from(&mut request).await.unwrap();
|
||||
assert_eq!(len, 20);
|
||||
assert_eq!(source, expected);
|
||||
let tx: [u8; 12] = request[8..20].try_into().unwrap();
|
||||
let mut reply = request[..20].to_vec();
|
||||
reply[..4].copy_from_slice(&[1, 1, 0, 12]);
|
||||
reply.extend_from_slice(&[0, 0x20, 0, 8, 0, 1]);
|
||||
let port = source.port().to_be_bytes();
|
||||
reply.extend_from_slice(&[port[0] ^ COOKIE[0], port[1] ^ COOKIE[1]]);
|
||||
for (i, b) in Ipv4Addr::LOCALHOST.octets().into_iter().enumerate() {
|
||||
reply.push(b ^ COOKIE[i]);
|
||||
}
|
||||
assert!(parse(&reply, &[0; 12]).is_none());
|
||||
for len in 0..reply.len() {
|
||||
assert!(parse(&reply[..len], &tx).is_none());
|
||||
}
|
||||
server.send_to(&reply, source).await.unwrap();
|
||||
});
|
||||
assert_eq!(mapped(&socket, &address.to_string()).await, Some(expected));
|
||||
task.await.unwrap();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user