mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-31 03:56:15 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9121925d4b | ||
|
|
006c3314ad | ||
|
|
e8270970ee | ||
|
|
527514689f | ||
|
|
0aed417dbd | ||
|
|
3ae4ac725b | ||
|
|
081855e8e2 | ||
|
|
3d81f4b1f5 | ||
|
|
0450b11d5f | ||
|
|
4afa27f056 | ||
|
|
0aa50d245c | ||
|
|
abb0701048 | ||
|
|
5c02d33ad8 | ||
|
|
5083a09223 | ||
|
|
16ead248d0 | ||
|
|
9e59659073 | ||
|
|
1611282047 | ||
|
|
786758e8c3 | ||
|
|
0cf035a0e1 | ||
|
|
1dbfefc9d0 | ||
|
|
793f844448 | ||
|
|
243bd7985a | ||
|
|
30c5808e09 | ||
|
|
3c9a629ad4 |
@@ -13,60 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
## [0.4.1] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
|
||||
- `node.bloom.max_inbound_fpr` default raised from `0.10` to `0.20`. The
|
||||
cap rejects inbound `FilterAnnounce` whose FPR (`fill^k`) exceeds it. On
|
||||
the fixed 1 KB / k=5 filter, `0.10` corresponds to fill 0.631 (~1,630
|
||||
reachable entries), and the busiest nodes' aggregates had again begun to
|
||||
reach it as the mesh grew. `0.20` (fill 0.7248, ~2,114 entries) restores
|
||||
headroom without materially weakening the antipoison gate: a saturated or
|
||||
poisoned filter is ~100% FPR and still rejected. This is the second raise
|
||||
of this cap in two releases; the fixed 1 KB filter is the underlying
|
||||
constraint, and the structural remedy is the v2 filter work rather than a
|
||||
further raise. A node running this default accepts announcements that a
|
||||
v0.4.0 node drops, so during a rolling upgrade the two versions can
|
||||
disagree about mesh size.
|
||||
- Bloom filter probing computes its SHA-256 digest once per operation
|
||||
rather than once per hash function. All k indices were already derived
|
||||
from a single digest, but the digest was recomputed inside the
|
||||
per-function loop, so every insert and membership test hashed the same
|
||||
bytes `hash_count` times (5x at the default). Output is bit-for-bit
|
||||
identical; this is the hottest path in packet forwarding and mesh-size
|
||||
estimation.
|
||||
- Identity operations reuse one shared `secp256k1` context instead of
|
||||
constructing a fresh one at every sign, verify, and key-derive site.
|
||||
Each construction allocated a context and ran randomization and blinding
|
||||
table setup. Behavior is unchanged: the same API calls are made, only the
|
||||
context lifetime differs, and the shared context still performs the
|
||||
standard construction-time blinding.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Spanning tree: the coordinate cache is now invalidated when the parent
|
||||
link is lost through peer removal. That path reparents or self-roots the
|
||||
node but omitted the invalidation every other position-change path
|
||||
performs, so cached entries for downstream destinations kept the node's
|
||||
now-stale coordinate prefix. Because routing access refreshes an entry's
|
||||
TTL, an actively routed stale entry never self-expired and was corrected
|
||||
only by a fresh insert.
|
||||
- Discovery: applying a `LookupResponse` now keeps the tighter of the
|
||||
cached and received `path_mtu` rather than overwriting unconditionally.
|
||||
A looser estimate arriving in a later response could clobber a tighter
|
||||
value already learned from a reactive `MtuExceeded` or
|
||||
`PathMtuNotification`, loosening a clamp that had been correctly
|
||||
tightened.
|
||||
|
||||
### Removed
|
||||
|
||||
- The `parent_switched` spanning-tree metric counter. It was incremented on
|
||||
the line immediately before `parent_switches` at every site and never
|
||||
independently, so the two were always identical. `parent_switches`
|
||||
remains as the sole counter. Consumers reading `parent_switched` from the
|
||||
control socket or `fipstop` should use `parent_switches`.
|
||||
|
||||
## [0.4.0] - 2026-06-27
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+1
-1
@@ -1074,7 +1074,7 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
|
||||
|
||||
[[package]]
|
||||
name = "fips"
|
||||
version = "0.4.1"
|
||||
version = "0.5.0-dev"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bech32",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fips"
|
||||
version = "0.4.1"
|
||||
version = "0.5.0-dev"
|
||||
edition = "2024"
|
||||
description = "A distributed, decentralized network routing protocol for mesh nodes connecting over arbitrary transports"
|
||||
license = "MIT"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||

|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](#status--roadmap)
|
||||
[](#status--roadmap)
|
||||
|
||||
A self-organizing encrypted mesh network built on Nostr identities,
|
||||
capable of operating over arbitrary transports without central
|
||||
@@ -112,17 +112,19 @@ tutorial progression starting at
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Requires Rust 1.94.1+ (edition 2024). Linux, macOS, and Windows are
|
||||
supported; transport availability varies by platform.
|
||||
Requires Rust 1.94.1+ (edition 2024). Linux, macOS, and Windows run as
|
||||
standalone daemons; Android is supported as an embedded library (the host
|
||||
app owns the TUN, e.g. a `VpnService`). Transport availability varies by
|
||||
platform.
|
||||
|
||||
| Transport | Linux | macOS | Windows | OpenWrt |
|
||||
|-----------|:-----:|:-----:|:-------:|:-------:|
|
||||
| UDP | ✅ | ✅ | ✅ | ✅ |
|
||||
| TCP | ✅ | ✅ | ✅ | ✅ |
|
||||
| Ethernet | ✅ | ✅ | ❌ | ✅ |
|
||||
| Tor | ✅ | ✅ | ✅ | ✅ |
|
||||
| Nym | ✅ | ✅ | ✅ | ❌ |
|
||||
| BLE | ✅ | ❌ | ❌ | ❌ |
|
||||
| Transport | Linux | macOS | Windows | Android | OpenWrt |
|
||||
|-----------|:-----:|:-----:|:-------:|:-------:|:-------:|
|
||||
| UDP | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| TCP | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Ethernet | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
| Tor | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Nym | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| BLE | ✅ | ❌ | ❌ | ✅ | ❌ |
|
||||
|
||||
On Linux, a source build requires `libclang` — the LAN gateway's
|
||||
nftables bindings are generated by `bindgen` at build time, which
|
||||
@@ -210,10 +212,10 @@ testing/ Docker-based integration test harnesses + chaos simulation
|
||||
|
||||
## Status & roadmap
|
||||
|
||||
FIPS is at **v0.4.1** on the `maint` branch.
|
||||
[v0.4.1](https://github.com/jmcorgan/fips/releases/tag/v0.4.1) has
|
||||
shipped; this line carries patch-level fixes for the 0.4.x series. The
|
||||
core protocol works end-to-end over
|
||||
FIPS is at **v0.5.0-dev** on the `master` branch.
|
||||
[v0.4.0](https://github.com/jmcorgan/fips/releases/tag/v0.4.0) has
|
||||
shipped; this development line continues the testing-and-polishing
|
||||
track toward v0.5.0. The core protocol works end-to-end over
|
||||
UDP, TCP, Ethernet, Tor, Nym, and Bluetooth on a global, public test
|
||||
mesh of thousands of nodes. v0.4.0 added the Nym mixnet transport and
|
||||
mDNS LAN discovery alongside the existing Nostr-mediated peer discovery,
|
||||
|
||||
+284
-109
@@ -1,134 +1,302 @@
|
||||
# FIPS v0.4.1
|
||||
# FIPS v0.4.0
|
||||
|
||||
**Released**: 2026-07-19
|
||||
**Released**: 2026-06-21 (provisional)
|
||||
|
||||
v0.4.1 is a maintenance release on the v0.4.x line. It raises the default
|
||||
antipoison cap on inbound bloom filter announcements, removes a redundant
|
||||
spanning-tree metric counter, fixes two convergence and path-MTU bugs, and
|
||||
cuts per-packet CPU in the bloom and identity paths. There is no wire
|
||||
format change and no new feature surface.
|
||||
v0.4.0 is the throughput-and-observability release on the v0.3.x wire
|
||||
format. It adds two new ways for nodes to find and reach each other (the
|
||||
Nym mixnet transport and opt-in mDNS LAN discovery), overhauls the data
|
||||
plane for higher single-node throughput and lower per-packet CPU, moves
|
||||
the entire operator read surface off the data-plane hot path so
|
||||
observability stays responsive under load, ships a reworked `fipstop`
|
||||
TUI, and hardens FMP and FSP rekey to be hitless under packet loss in
|
||||
both directions. It also folds in the accumulated mesh-convergence,
|
||||
admission-control, and packaging fixes from the maintenance line.
|
||||
|
||||
v0.4.1 is wire-compatible with v0.4.0. Nodes can be upgraded one at a time
|
||||
with no coordinated restart, though one behavior change below is worth
|
||||
reading before you start a rolling upgrade.
|
||||
v0.4.0 is wire-compatible with v0.3.0. Mixed meshes interoperate; there
|
||||
is no flag-day upgrade. A deployed v0.3.0 node and an upgraded v0.4.0
|
||||
node peer, rekey, and route normally, so you can roll the upgrade out
|
||||
across a mesh in any order.
|
||||
|
||||
## At a glance
|
||||
|
||||
- `node.bloom.max_inbound_fpr` default moves from `0.10` to `0.20`.
|
||||
- The `parent_switched` metric counter is gone. Use `parent_switches`.
|
||||
- Spanning tree no longer serves stale coordinates after a parent link is
|
||||
lost through peer removal.
|
||||
- Discovery no longer loosens a path MTU clamp it had correctly tightened.
|
||||
- Bloom probing and identity operations do measurably less work per call,
|
||||
with identical results.
|
||||
- New outbound Nym mixnet transport with a single-container demo and a
|
||||
new mixnet-relay example.
|
||||
- Opt-in mDNS / DNS-SD discovery on the local link.
|
||||
- Data-plane overhaul: off-task encrypt and decrypt worker pools, GSO,
|
||||
connected-UDP send path, copy-avoidance on receive, batched macOS
|
||||
receive.
|
||||
- The full `show_*` read surface now serves off the receive loop, so
|
||||
`fipsctl` and `fipstop` stay responsive on loaded nodes; a new
|
||||
counter-only `show_metrics` query enables a Prometheus scraper at no
|
||||
hot-path cost.
|
||||
- Reworked `fipstop` TUI on a machine-verified render-snapshot base.
|
||||
- Rekey is now hitless under loss and reordering in both directions.
|
||||
- New packaging targets: an OpenWrt `.apk` for OpenWrt 25+ and a Nix
|
||||
flake for reproducible from-source builds on Nix/NixOS.
|
||||
- Six route-class transit counters partition forwarded traffic by its
|
||||
tree relationship to the next hop, visible via `show_routing` and
|
||||
`show_status`.
|
||||
|
||||
## What's new
|
||||
|
||||
### Nym mixnet transport
|
||||
|
||||
FIPS can now peer over the [Nym](https://nymtech.net/) mixnet for
|
||||
metadata-resistant connectivity. The new `transports.nym` transport
|
||||
makes outbound connections through a `nym-socks5-client` SOCKS5 proxy
|
||||
that you run alongside the daemon (for example as a service running
|
||||
alongside the fips daemon, or as a sidecar container). The transport
|
||||
waits at startup for the nym-socks5-client to become ready before giving
|
||||
up.
|
||||
|
||||
This is a privacy and anonymity deployment mode chosen for its own
|
||||
properties. It mixes your FIPS traffic into the Nym cover-traffic
|
||||
network so that link-level observers cannot correlate which mesh peers
|
||||
are talking. A new `examples/sidecar-nostr-mixnet-relay/` demonstrates a
|
||||
FIPS-reachable Nostr relay peered across the mixnet end to end, and a
|
||||
single-container demo ships with the transport.
|
||||
|
||||
Enable it by adding a `transports.nym` instance and pointing it at your
|
||||
running nym-socks5-client. See the transports reference for the field
|
||||
set.
|
||||
|
||||
### mDNS LAN discovery
|
||||
|
||||
Nodes on a shared local link can now find each other with zero address
|
||||
configuration. The opt-in `node.discovery.lan` path runs an mDNS /
|
||||
DNS-SD responder and browser: each node advertises a FIPS service record
|
||||
on the link and adopts the peers it discovers. This complements the
|
||||
existing Nostr-mediated overlay discovery for the common case where the
|
||||
peers are simply on the same LAN.
|
||||
|
||||
Turn it on with `node.discovery.lan.enabled: true`. `service_type` and
|
||||
`scope` tune the advertised service record and which interfaces
|
||||
participate. Discovery on the local link needs no relay and no STUN.
|
||||
|
||||
### Data-plane throughput overhaul
|
||||
|
||||
The receive and send paths were reworked for higher single-node
|
||||
throughput and lower per-packet CPU, building on the v0.3.0
|
||||
crypto-backend swap:
|
||||
|
||||
- **Off-task encrypt and decrypt.** Per-peer encrypt and decrypt now run
|
||||
on dedicated worker tasks rather than inline on the receive loop, so a
|
||||
single busy peer no longer serializes the whole node's crypto.
|
||||
- **GSO and connected-UDP send.** The Linux send path uses generic
|
||||
segmentation offload and a connected-UDP socket where available,
|
||||
cutting syscall overhead on bulk flows.
|
||||
- **Copy-avoidance on receive.** The receive hot path avoids buffer
|
||||
copies it previously made per packet.
|
||||
- **Batched macOS receive.** macOS gains a `recvmsg_x` batched receive,
|
||||
mirroring the Linux `recvmmsg` batching from v0.3.0.
|
||||
- **Shared immutable-state context and an atomic metric registry.**
|
||||
Immutable per-node state moved into a single shared context, and
|
||||
counters live in an atomic metric registry that the new `show_metrics`
|
||||
query reads without touching the hot path.
|
||||
|
||||
These are all internal to the data plane and require no operator action.
|
||||
|
||||
### Observability off the hot path
|
||||
|
||||
Every read-only control query now renders from a snapshot published once
|
||||
per tick into a lock-free `ArcSwap`, served from the control accept task
|
||||
instead of round-tripping the data-plane receive loop. This covers
|
||||
`show_status`, `show_stats_*`, `show_peers`, `show_sessions`,
|
||||
`show_links`, `show_connections`, `show_transports`, `show_mmp`,
|
||||
`show_tree`, `show_bloom`, `show_cache`, `show_routing`,
|
||||
`show_identity_cache`, `show_acl`, `show_listening_sockets`, and the new
|
||||
`show_metrics`. Only the mutating `connect` and `disconnect` commands
|
||||
still reach the loop.
|
||||
|
||||
The practical effect: on a loaded node where the receive loop was busy,
|
||||
`fipsctl` and `fipstop` queries previously stalled or timed out (the
|
||||
five-second query pattern operators saw). They now answer promptly
|
||||
regardless of data-plane load. Per-entity snapshots reuse unchanged rows
|
||||
by pointer, so the per-tick publish cost stays bounded as peer and
|
||||
session counts grow.
|
||||
|
||||
A new **`show_metrics`** query (surfaced as `fipsctl stats metrics`)
|
||||
returns a counter-only snapshot of every metric family. It is the
|
||||
enabler for a Prometheus scraper that pulls node counters at no hot-path
|
||||
cost.
|
||||
|
||||
Six **route-class transit counters** partition transit-forwarded packets
|
||||
by their tree relationship to the chosen next hop — tree-up, tree-down,
|
||||
tree-down-cross, cross-link descend, cross-link ascend, and direct-peer
|
||||
— and the six classes sum to `forwarded_packets`. They surface through
|
||||
`show_routing` and `show_status`, and the `fipstop` routing tab is
|
||||
reorganized so its two columns separate own/endpoint traffic from
|
||||
forwarded/transit traffic with the tree-down-cross line visually flagged.
|
||||
|
||||
### Reworked fipstop TUI
|
||||
|
||||
`fipstop` gets a rendering, navigation, and read-surface overhaul on a
|
||||
machine-verified base: a render-snapshot harness asserts the exact text
|
||||
grid and per-cell style of every view against canned control-socket
|
||||
output. New daemon-resolved fields surface through the snapshots,
|
||||
including effective persistence, root and is-root state, a
|
||||
per-transport-type peer-count map, per-peer effective depth, the root
|
||||
npub, and the last-sent uptree filter fill ratio with the subtree size
|
||||
estimate.
|
||||
|
||||
A separate fix clears a garbled-screen problem on startup and stray
|
||||
bytes on quit, most visible over SSH and inside tmux: startup now forces
|
||||
a full repaint before the first draw, and quit stops and joins the
|
||||
stdin-poll thread before restoring the terminal, so post-raw-mode
|
||||
keystrokes no longer echo onto the restored screen.
|
||||
|
||||
### Rekey reliability
|
||||
|
||||
FMP and FSP session rekey are now hitless under packet loss and
|
||||
reordering in both directions:
|
||||
|
||||
- Inbound frames are authenticated against the pending session before
|
||||
the K-bit cutover promotes it, so a spoofed or stale frame cannot
|
||||
derail a rekey in progress.
|
||||
- Rekey message-1 retransmission is bounded, and the link-dead heartbeat
|
||||
is rekey-aware so an in-flight rekey is not mistaken for a dead link.
|
||||
- FSP session rekey holds connectivity across the rekey window under
|
||||
loss and reordering.
|
||||
- Dual-initiation races (both peers starting a rekey at once on a
|
||||
high-latency link) are desynchronized with symmetric jitter so the two
|
||||
sides converge on one session rather than fighting.
|
||||
- An exhausted retransmission-budget abort, an expected and self-limiting
|
||||
outcome on lossy or high-latency links, is logged at debug rather than
|
||||
warn.
|
||||
|
||||
The net operator takeaway: rekey completes cleanly without dropping
|
||||
traffic, even on lossy or high-latency links, and the log no longer
|
||||
cries wolf when a rekey gives up and retries.
|
||||
|
||||
### New packaging targets
|
||||
|
||||
- **OpenWrt `.apk`.** A new `.apk` package targets OpenWrt 25+, where
|
||||
apk-tools is the mandatory package manager; the existing `.ipk`
|
||||
continues to cover OpenWrt 24.x and earlier. It is built SDK-free,
|
||||
reusing the `.ipk` cross-compile and installed-filesystem payload, and
|
||||
releases publish `.apk` artifacts and checksums alongside `.ipk`. Like
|
||||
the `.ipk`, the package is unsigned and installed with
|
||||
`apk add --allow-untrusted`.
|
||||
- **Nix flake.** A `flake.nix` at the project root builds all four
|
||||
binaries (`fips`, `fipsctl`, `fips-gateway`, `fipstop`) from source on
|
||||
Nix/NixOS, pinning the exact toolchain and wiring the native build
|
||||
dependencies so no host setup is needed beyond Nix with flakes
|
||||
enabled. It exposes `nix build`, `nix run`, a `nix develop` dev shell,
|
||||
and `nix flake check`, with `flake.lock` committed for reproducibility.
|
||||
|
||||
## Behavior changes worth flagging
|
||||
|
||||
### The inbound filter FPR cap default doubles again
|
||||
These affect operators on upgrade.
|
||||
|
||||
`node.bloom.max_inbound_fpr` goes from `0.10` to `0.20`. The cap rejects
|
||||
inbound `FilterAnnounce` frames whose advertised false positive rate
|
||||
exceeds it. On the fixed 1 KB, k=5 filter, `0.10` corresponds to a fill of
|
||||
0.631 and roughly 1,630 reachable entries, and the busiest nodes'
|
||||
aggregates had started reaching that ceiling as the mesh grew. `0.20`
|
||||
corresponds to a fill of 0.7248 and roughly 2,114 entries.
|
||||
|
||||
Be aware that this is the second time in two releases that this default
|
||||
has doubled, for the same reason both times. That is worth stating plainly
|
||||
rather than repeating the previous release's framing: raising the cap buys
|
||||
headroom, it does not fix anything. The real constraint is the fixed 1 KB
|
||||
filter size, which is a protocol constant. The structural remedy is the v2
|
||||
filter work, where filter capacity scales with the mesh instead of being
|
||||
pinned. This release is an interim step to keep legitimate aggregates from
|
||||
being rejected until that lands. It is not the start of a pattern of
|
||||
raising the cap once per release, and if you are sizing capacity planning
|
||||
around this number, plan against the v2 work rather than against a third
|
||||
raise.
|
||||
|
||||
The antipoison property the cap exists for is preserved. A saturated or
|
||||
deliberately poisoned filter still presents an FPR near 100% and is still
|
||||
rejected.
|
||||
|
||||
**This matters during a rolling upgrade.** A v0.4.1 node accepts a
|
||||
`FilterAnnounce` with a derived FPR between 0.10 and 0.20; a v0.4.0 node
|
||||
drops the same frame, and the drop is silent on the wire with no NACK. The
|
||||
cap also gates the mesh size estimator, which declines to produce a value
|
||||
when any contributing filter is over the cap. So while a mesh is partly
|
||||
upgraded, upgraded and not-yet-upgraded nodes can legitimately report
|
||||
different mesh sizes, or one can report a size while the other reports
|
||||
unknown. This resolves once every node is on v0.4.1. If you want to avoid
|
||||
the window entirely, set `node.bloom.max_inbound_fpr: 0.10` explicitly in
|
||||
your config before upgrading and remove it after the last node is done.
|
||||
|
||||
### The `parent_switched` counter is removed
|
||||
|
||||
`parent_switched` was incremented on the line immediately before
|
||||
`parent_switches` at every site and never independently, so the two
|
||||
counters always held the same value. `parent_switched` is now gone from
|
||||
the tree metrics, the control socket snapshot, and the `fipstop` tree
|
||||
view. `parent_switches` remains and is unchanged.
|
||||
|
||||
If you scrape the control socket, or have dashboards or alerts referencing
|
||||
`parent_switched`, point them at `parent_switches`. Anything still asking
|
||||
for `parent_switched` will find nothing rather than a zero.
|
||||
- **Bloom filter antipoison cap raised.** `node.bloom.max_inbound_fpr`
|
||||
moves from 0.05 to 0.10, accepting filters with a higher derived
|
||||
false-positive rate before rejecting them. This reduces spurious
|
||||
filter rejections on larger meshes while keeping the antipoison
|
||||
protection in place.
|
||||
- **TCP inbound cap honors `max_connections`.** The TCP inbound accept
|
||||
ceiling now resolves from explicit per-transport
|
||||
`max_inbound_connections`, then node-wide
|
||||
`node.limits.max_connections`, then the built-in default of 256.
|
||||
Previously the TCP inbound ceiling was hardwired to 256 and ignored
|
||||
`max_connections`, so raising it had no effect on inbound TCP.
|
||||
- **Static host aliases hot-reload.** `/etc/fips/hosts` now reloads on
|
||||
mtime change once per tick rather than only at startup, so display
|
||||
names in `fipsctl` and `fipstop` reflect edits without a daemon
|
||||
restart. The peer ACL reloads through the same lock-free snapshot
|
||||
mechanism.
|
||||
- **Quieter logs on busy public-mesh nodes.** Routine per-peer
|
||||
connection-lifecycle and capacity-cap events, no-route session-datagram
|
||||
drops, and exhausted rekey-budget aborts are demoted to debug, so
|
||||
genuinely notable info and warn lines are no longer drowned out.
|
||||
- **More visible drops.** Receive-path silent rejections now flow
|
||||
through typed reject-reason counters, and discovery counts requests
|
||||
dropped when the dedup cache is full (`req_dedup_cache_full`, visible
|
||||
via `show_routing`). Drops that were previously silent are now
|
||||
countable.
|
||||
- **Tor connect-refused accounting.** The Tor transport increments its
|
||||
`connect_refused` statistic (the "Refused" line in `fipstop`) on an
|
||||
actively-refused SOCKS5 connect, instead of recording every connect
|
||||
failure as a generic SOCKS5 error.
|
||||
|
||||
## Notable bug fixes
|
||||
|
||||
### Stale coordinates after losing a parent through peer removal
|
||||
The CHANGELOG has the exhaustive list. This is the operator-relevant
|
||||
subset of fixes for behavior that shipped in v0.3.0.
|
||||
|
||||
When a node's parent link dropped via peer removal, the node correctly
|
||||
reparented or self-rooted, but skipped the coordinate cache invalidation
|
||||
that every other position-change path performs. Cached entries for
|
||||
downstream destinations kept the node's old coordinate prefix. This did
|
||||
not self-correct the way a stale cache entry normally would: routing
|
||||
access refreshes an entry's TTL, so an entry that was actively being
|
||||
routed through never expired, and was only fixed by an unrelated fresh
|
||||
insert. Both invalidation classes now run on this path, matching the
|
||||
loop-detection branch.
|
||||
|
||||
### Discovery could loosen a tightened path MTU clamp
|
||||
|
||||
An originator handling a `LookupResponse` overwrote its cached path MTU
|
||||
unconditionally. If a reactive `MtuExceeded` or `PathMtuNotification` had
|
||||
already taught it a tighter value, a later, looser discovery estimate
|
||||
would clobber that and re-loosen the clamp, risking a return to dropped
|
||||
oversized packets. The cached and received values are now compared and the
|
||||
tighter one is kept.
|
||||
- **Symmetric peer teardown on manual disconnect.** A manual
|
||||
`fipsctl disconnect` now sends the peer a scoped Disconnect so both
|
||||
ends tear down and re-handshake cleanly. Previously a manual
|
||||
disconnect tore down only the local side, leaving the peer with a
|
||||
stale session that was never re-adopted as a child and whose bloom
|
||||
filter was never re-recorded.
|
||||
- **Gateway holds long-lived and DNS-cached mappings.** `fips-gateway`
|
||||
no longer drops a virtual-IP mapping while traffic is still flowing.
|
||||
The mapping TTL clock previously advanced only on DNS re-query, so a
|
||||
busy long-lived or DNS-cached client could have its mapping reclaimed
|
||||
mid-flow. The tick now refreshes the mapping whenever conntrack reports
|
||||
active sessions and recovers a draining mapping to active when traffic
|
||||
resumes; only genuinely idle mappings drain.
|
||||
- **Accurate mesh-size estimate under filter overlap.** The mesh-size
|
||||
estimator now estimates the cardinality of the OR-union of self plus
|
||||
every connected peer's inbound filter, instead of summing per-filter
|
||||
cardinalities of tree peers. Summing assumed the filters were disjoint,
|
||||
so a stale or oversized parent filter or a routing loop inflated the
|
||||
reported mesh size and a tree rebalance flapped the count. OR-union
|
||||
deduplicates overlap, equals the old result in the disjoint case, and
|
||||
removes the estimate's dependence on tree-declaration cache freshness.
|
||||
- **Single-uplink node reattaches within a round-trip.** A node with one
|
||||
tree peer, which has periodic parent re-evaluation disabled, was left
|
||||
self-rooted and unreachable if its one-shot attaching TreeAnnounce was
|
||||
lost, until the next periodic re-broadcast. Tree-position exchange is
|
||||
now self-healing on the receive path: a node that hears an announce
|
||||
advertising a strictly worse root echoes its own declaration back,
|
||||
provoking the better-rooted peer to re-push its real position
|
||||
immediately.
|
||||
- **macOS self-connections work end to end (#117).** Traffic a macOS
|
||||
node sends to its own `<npub>.fips` address is now delivered locally
|
||||
for full TCP/UDP, not just `ping6`. The point-to-point `utun` egresses
|
||||
self-addressed packets into the daemon with an unfinished transport
|
||||
checksum (macOS offloads it on the `lo0` loopback route), so
|
||||
re-injecting them verbatim made the local stack drop every segment the
|
||||
MSS-clamp rewrite did not happen to fix and self-connections
|
||||
half-opened and hung. The hairpin path now recomputes the TCP/UDP
|
||||
checksum before re-injection. Linux was unaffected.
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
This is a drop-in upgrade from v0.4.0 with no wire format change, no
|
||||
config migration, and no coordinated restart. Upgrade nodes in whatever
|
||||
order you like.
|
||||
Operator-actionable items moving from v0.3.0 to v0.4.0:
|
||||
|
||||
Two things to do rather than assume:
|
||||
- **Wire-compatible, no flag day.** v0.4.0 peers with v0.3.0. Upgrade
|
||||
nodes in any order. During a rolling upgrade you may see some log lines
|
||||
on the upgraded side as it interacts with not-yet-upgraded peers;
|
||||
behavior is correct, log noise only.
|
||||
- **Bloom antipoison cap default changed.** `node.bloom.max_inbound_fpr`
|
||||
now defaults to 0.10 (was 0.05). If you set this explicitly, review
|
||||
whether you still want the old value.
|
||||
- **New optional config surfaces.** `transports.nym` (outbound Nym
|
||||
mixnet) and `node.discovery.lan` (mDNS LAN discovery) are both opt-in
|
||||
and off by default. Adding them is the only way to turn the new paths
|
||||
on.
|
||||
- **TCP inbound cap.** If you relied on the old hardwired 256 inbound-TCP
|
||||
ceiling, note it now honors `max_inbound_connections` then
|
||||
`node.limits.max_connections` then 256.
|
||||
- **New observability query.** `fipsctl stats metrics` (the
|
||||
`show_metrics` control query) returns a counter-only snapshot suitable
|
||||
for a scraper.
|
||||
|
||||
1. If you monitor `parent_switched`, move to `parent_switches` before
|
||||
upgrading, or your dashboards will go blank rather than error.
|
||||
2. During the rolling window, expect upgraded and not-yet-upgraded nodes
|
||||
to potentially disagree about mesh size, per the FPR cap section above.
|
||||
This is expected and self-resolves. Do not chase it as a bug unless it
|
||||
persists after every node reports `0.4.1`.
|
||||
|
||||
If you have pinned `node.bloom.max_inbound_fpr` explicitly in your config,
|
||||
your setting is honored and nothing changes for you. The change only
|
||||
affects nodes taking the default.
|
||||
|
||||
Downgrading to v0.4.0 is supported and needs no special handling.
|
||||
|
||||
## Getting v0.4.1
|
||||
## Getting v0.4.0
|
||||
|
||||
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
|
||||
[v0.4.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.1).
|
||||
[v0.4.0 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.0).
|
||||
- **Arch Linux**: `fips` from the AUR.
|
||||
- **macOS**: `.pkg` at the v0.4.1 release page.
|
||||
- **Windows**: ZIP at the v0.4.1 release page.
|
||||
- **macOS**: `.pkg` at the v0.4.0 release page.
|
||||
- **Windows**: ZIP at the v0.4.0 release page.
|
||||
- **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
|
||||
at the v0.4.1 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.1
|
||||
at the v0.4.0 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.0
|
||||
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
|
||||
required Linux build prerequisite).
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.1 tag
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.0 tag
|
||||
builds the binaries from source with the pinned toolchain and no manual
|
||||
prerequisites (see the Nix section of `packaging/README.md`).
|
||||
|
||||
@@ -141,6 +309,13 @@ The full per-commit changelog lives in
|
||||
Thanks to everyone who contributed code, packaging work, bug reports, or
|
||||
reviews to this release.
|
||||
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, spanning-tree
|
||||
and discovery fixes, bloom and identity performance work, antipoison cap
|
||||
change, and testing.
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, high-level
|
||||
design, control read plane, rekey hardening, admission, bug fixes,
|
||||
testing, packaging, PR coordination, and issue resolution.
|
||||
- [@mmalmi](https://github.com/mmalmi): opt-in mDNS LAN discovery and
|
||||
data-plane performance work.
|
||||
- [@Origami74](https://github.com/Origami74): macOS packaging and
|
||||
website coordination.
|
||||
- [@dskvr](https://github.com/dskvr): AUR packaging.
|
||||
- [@oleksky](https://github.com/oleksky): Nym mixnet transport and the
|
||||
single-container mixnet demo.
|
||||
|
||||
@@ -41,9 +41,18 @@ fn main() {
|
||||
// satisfy libdbus-sys's pkg-config cross-compile requirement, and musl
|
||||
// router targets don't run BlueZ by default anyway.
|
||||
println!("cargo:rustc-check-cfg=cfg(bluer_available)");
|
||||
// `ble_available` gates the platform-agnostic BLE transport module (pool,
|
||||
// discovery, per-peer PSM, the generic `BleTransport`). The module compiles
|
||||
// on every platform that has — or will have — a concrete `BleIo` backend;
|
||||
// the backend is selected per-platform (BluerIo on linux-glibc, BluestIo on
|
||||
// macOS, AndroidIo on Android, else the in-memory MockBleIo fallback).
|
||||
println!("cargo:rustc-check-cfg=cfg(ble_available)");
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default();
|
||||
if target_os == "linux" && target_env != "musl" {
|
||||
println!("cargo:rustc-cfg=bluer_available");
|
||||
}
|
||||
if matches!(target_os.as_str(), "linux" | "macos" | "android") {
|
||||
println!("cargo:rustc-cfg=ble_available");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,14 +360,14 @@ control socket and `fipstop` dashboard. (See `compute_mesh_size()` in
|
||||
|
||||
The estimator refuses to produce a value when any contributing filter
|
||||
is above the antipoison FPR cap (`node.bloom.max_inbound_fpr`,
|
||||
default `0.20`); a partial aggregate would silently underestimate.
|
||||
default `0.10`); a partial aggregate would silently underestimate.
|
||||
Consumers handle the resulting `None` by displaying an "unknown"
|
||||
state rather than a misleading number.
|
||||
|
||||
## Antipoison: Inbound FPR Cap
|
||||
|
||||
Inbound `FilterAnnounce` payloads are checked against
|
||||
`node.bloom.max_inbound_fpr` (default `0.20`). Filters whose
|
||||
`node.bloom.max_inbound_fpr` (default `0.10`). Filters whose
|
||||
estimated false positive rate exceeds the cap are dropped silently
|
||||
(no NACK on the wire) — they would otherwise inflate downstream
|
||||
candidate evaluation cost without contributing useful discrimination.
|
||||
|
||||
@@ -302,6 +302,34 @@ alternative — running under a dedicated unprivileged service
|
||||
account with the capability granted on the binary — see
|
||||
[../how-to/run-as-unprivileged-user.md](../how-to/run-as-unprivileged-user.md).
|
||||
|
||||
### App-Owned TUN (embedded hosts)
|
||||
|
||||
On platforms where FIPS is embedded rather than run as a daemon — notably
|
||||
Android, where the `VpnService` owns the TUN fd and the app has no
|
||||
`CAP_NET_ADMIN` — FIPS does not create `fips0` itself. Instead the embedder owns
|
||||
the fd and exchanges IPv6 packet bytes with FIPS over channels.
|
||||
|
||||
`Node::enable_app_owned_tun()` sets this up. It is called after `Node::new` and
|
||||
before `start()` (and before the node is moved into a background task), mirroring
|
||||
`control_read_handle()`, and returns two app-side channel ends:
|
||||
|
||||
- **app → mesh** — the embedder pushes IPv6 packets read from its fd into
|
||||
`app_outbound_tx`. These are drained by `run_rx_loop` into `handle_tun_outbound`
|
||||
and routed exactly as the Reader Thread's output would be.
|
||||
- **mesh → app** — inbound mesh traffic on port 256 is reconstructed and written
|
||||
to the node's `tun_tx` (the same sink the Writer Thread reads); the embedder
|
||||
pulls from `app_inbound_rx` and writes to its fd.
|
||||
|
||||
With the channels installed, `start()` skips system-TUN creation (it gates on
|
||||
`tun_tx` being unset), so FIPS does no `CAP_NET_ADMIN` operations.
|
||||
|
||||
Because packets enter via `app_outbound_tx` rather than the Reader Thread, they
|
||||
**bypass `handle_tun_packet`** — the `fd00::/8` destination filter, the ICMPv6
|
||||
Destination Unreachable for off-mesh dests (see [Reader Thread](#reader-thread)),
|
||||
and the [TUN-Side TCP MSS Clamping](#tun-side-tcp-mss-clamping). The embedder is
|
||||
therefore responsible for routing only `fd00::/8` to its TUN (so only mesh-bound
|
||||
packets arrive) and for clamping TCP MSS on outbound SYNs.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Feature | Status |
|
||||
|
||||
@@ -899,7 +899,7 @@ transitions through `Starting` to `Up` (operational). `stop()` moves to
|
||||
| WiFi | **Implemented** (via Ethernet transport, infrastructure mode) | mac80211 translates 802.11↔802.3; broadcast beacons unreliable through APs |
|
||||
| Tor | **Implemented** | Outbound SOCKS5, inbound via onion service, .onion and clearnet addressing |
|
||||
| Nym | **Implemented** | Outbound-only SOCKS5 through nym-socks5-client, mixnet anonymity, IP/hostname addressing |
|
||||
| BLE | **Implemented** (Linux/glibc only; experimental) | L2CAP CoC, ATT_MTU negotiation, per-link MTU; musl/macOS/Windows skip |
|
||||
| BLE | **Implemented** (Linux/glibc and Android; experimental) | L2CAP CoC, ATT_MTU negotiation, per-link MTU; Linux via BlueZ, Android via the embedder radio bridge; macOS/Windows/musl skip |
|
||||
| Radio | Future direction | Constrained MTU (51–222 bytes) |
|
||||
| Serial | Future direction | SLIP/COBS framing, point-to-point |
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ crate accordingly).
|
||||
| -------- | -------------- |
|
||||
| Linux (glibc) | Supported. |
|
||||
| Linux (musl, OpenWrt) | Disabled at build time. |
|
||||
| Android | Supported (native Android BLE, via the embedder's radio bridge). |
|
||||
| macOS | Not supported. |
|
||||
| Windows | Not supported. |
|
||||
|
||||
|
||||
@@ -277,7 +277,7 @@ Controls tree construction and parent selection.
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `node.bloom.update_debounce_ms` | u64 | `500` | Debounce interval for filter update propagation |
|
||||
| `node.bloom.max_inbound_fpr` | f64 | `0.20` | Antipoison cap: reject inbound `FilterAnnounce` frames whose advertised false-positive rate exceeds this value. Valid range `(0.0, 1.0)`. The default `0.20` corresponds to fill 0.7248 at k=5 (≈2,114 entries on the 1 KB filter); a saturated/poisoned filter is still ~100% FPR and rejected |
|
||||
| `node.bloom.max_inbound_fpr` | f64 | `0.10` | Antipoison cap: reject inbound `FilterAnnounce` frames whose advertised false-positive rate exceeds this value. Valid range `(0.0, 1.0)`. The default `0.10` corresponds to fill 0.631 at k=5 (≈1,630 entries on the 1 KB filter); a saturated/poisoned filter is still ~100% FPR and rejected |
|
||||
|
||||
Bloom filter size (1 KB), hash count (5), and size classes are protocol
|
||||
constants and not configurable.
|
||||
@@ -944,7 +944,7 @@ node:
|
||||
flap_dampening_secs: 120 # extended hold-down on flap
|
||||
bloom:
|
||||
update_debounce_ms: 500
|
||||
max_inbound_fpr: 0.20 # antipoison cap on inbound FilterAnnounce FPR
|
||||
max_inbound_fpr: 0.10 # antipoison cap on inbound FilterAnnounce FPR
|
||||
session:
|
||||
default_ttl: 64
|
||||
pending_packets_per_dest: 16
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FIPS v0.4.0
|
||||
|
||||
**Released**: 2026-06-27
|
||||
**Released**: 2026-06-21 (provisional)
|
||||
|
||||
v0.4.0 is the throughput-and-observability release on the v0.3.x wire
|
||||
format. It adds two new ways for nodes to find and reach each other (the
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
# FIPS v0.4.1
|
||||
|
||||
**Released**: 2026-07-19
|
||||
|
||||
v0.4.1 is a maintenance release on the v0.4.x line. It raises the default
|
||||
antipoison cap on inbound bloom filter announcements, removes a redundant
|
||||
spanning-tree metric counter, fixes two convergence and path-MTU bugs, and
|
||||
cuts per-packet CPU in the bloom and identity paths. There is no wire
|
||||
format change and no new feature surface.
|
||||
|
||||
v0.4.1 is wire-compatible with v0.4.0. Nodes can be upgraded one at a time
|
||||
with no coordinated restart, though one behavior change below is worth
|
||||
reading before you start a rolling upgrade.
|
||||
|
||||
## At a glance
|
||||
|
||||
- `node.bloom.max_inbound_fpr` default moves from `0.10` to `0.20`.
|
||||
- The `parent_switched` metric counter is gone. Use `parent_switches`.
|
||||
- Spanning tree no longer serves stale coordinates after a parent link is
|
||||
lost through peer removal.
|
||||
- Discovery no longer loosens a path MTU clamp it had correctly tightened.
|
||||
- Bloom probing and identity operations do measurably less work per call,
|
||||
with identical results.
|
||||
|
||||
## Behavior changes worth flagging
|
||||
|
||||
### The inbound filter FPR cap default doubles again
|
||||
|
||||
`node.bloom.max_inbound_fpr` goes from `0.10` to `0.20`. The cap rejects
|
||||
inbound `FilterAnnounce` frames whose advertised false positive rate
|
||||
exceeds it. On the fixed 1 KB, k=5 filter, `0.10` corresponds to a fill of
|
||||
0.631 and roughly 1,630 reachable entries, and the busiest nodes'
|
||||
aggregates had started reaching that ceiling as the mesh grew. `0.20`
|
||||
corresponds to a fill of 0.7248 and roughly 2,114 entries.
|
||||
|
||||
Be aware that this is the second time in two releases that this default
|
||||
has doubled, for the same reason both times. That is worth stating plainly
|
||||
rather than repeating the previous release's framing: raising the cap buys
|
||||
headroom, it does not fix anything. The real constraint is the fixed 1 KB
|
||||
filter size, which is a protocol constant. The structural remedy is the v2
|
||||
filter work, where filter capacity scales with the mesh instead of being
|
||||
pinned. This release is an interim step to keep legitimate aggregates from
|
||||
being rejected until that lands. It is not the start of a pattern of
|
||||
raising the cap once per release, and if you are sizing capacity planning
|
||||
around this number, plan against the v2 work rather than against a third
|
||||
raise.
|
||||
|
||||
The antipoison property the cap exists for is preserved. A saturated or
|
||||
deliberately poisoned filter still presents an FPR near 100% and is still
|
||||
rejected.
|
||||
|
||||
**This matters during a rolling upgrade.** A v0.4.1 node accepts a
|
||||
`FilterAnnounce` with a derived FPR between 0.10 and 0.20; a v0.4.0 node
|
||||
drops the same frame, and the drop is silent on the wire with no NACK. The
|
||||
cap also gates the mesh size estimator, which declines to produce a value
|
||||
when any contributing filter is over the cap. So while a mesh is partly
|
||||
upgraded, upgraded and not-yet-upgraded nodes can legitimately report
|
||||
different mesh sizes, or one can report a size while the other reports
|
||||
unknown. This resolves once every node is on v0.4.1. If you want to avoid
|
||||
the window entirely, set `node.bloom.max_inbound_fpr: 0.10` explicitly in
|
||||
your config before upgrading and remove it after the last node is done.
|
||||
|
||||
### The `parent_switched` counter is removed
|
||||
|
||||
`parent_switched` was incremented on the line immediately before
|
||||
`parent_switches` at every site and never independently, so the two
|
||||
counters always held the same value. `parent_switched` is now gone from
|
||||
the tree metrics, the control socket snapshot, and the `fipstop` tree
|
||||
view. `parent_switches` remains and is unchanged.
|
||||
|
||||
If you scrape the control socket, or have dashboards or alerts referencing
|
||||
`parent_switched`, point them at `parent_switches`. Anything still asking
|
||||
for `parent_switched` will find nothing rather than a zero.
|
||||
|
||||
## Notable bug fixes
|
||||
|
||||
### Stale coordinates after losing a parent through peer removal
|
||||
|
||||
When a node's parent link dropped via peer removal, the node correctly
|
||||
reparented or self-rooted, but skipped the coordinate cache invalidation
|
||||
that every other position-change path performs. Cached entries for
|
||||
downstream destinations kept the node's old coordinate prefix. This did
|
||||
not self-correct the way a stale cache entry normally would: routing
|
||||
access refreshes an entry's TTL, so an entry that was actively being
|
||||
routed through never expired, and was only fixed by an unrelated fresh
|
||||
insert. Both invalidation classes now run on this path, matching the
|
||||
loop-detection branch.
|
||||
|
||||
### Discovery could loosen a tightened path MTU clamp
|
||||
|
||||
An originator handling a `LookupResponse` overwrote its cached path MTU
|
||||
unconditionally. If a reactive `MtuExceeded` or `PathMtuNotification` had
|
||||
already taught it a tighter value, a later, looser discovery estimate
|
||||
would clobber that and re-loosen the clamp, risking a return to dropped
|
||||
oversized packets. The cached and received values are now compared and the
|
||||
tighter one is kept.
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
This is a drop-in upgrade from v0.4.0 with no wire format change, no
|
||||
config migration, and no coordinated restart. Upgrade nodes in whatever
|
||||
order you like.
|
||||
|
||||
Two things to do rather than assume:
|
||||
|
||||
1. If you monitor `parent_switched`, move to `parent_switches` before
|
||||
upgrading, or your dashboards will go blank rather than error.
|
||||
2. During the rolling window, expect upgraded and not-yet-upgraded nodes
|
||||
to potentially disagree about mesh size, per the FPR cap section above.
|
||||
This is expected and self-resolves. Do not chase it as a bug unless it
|
||||
persists after every node reports `0.4.1`.
|
||||
|
||||
If you have pinned `node.bloom.max_inbound_fpr` explicitly in your config,
|
||||
your setting is honored and nothing changes for you. The change only
|
||||
affects nodes taking the default.
|
||||
|
||||
Downgrading to v0.4.0 is supported and needs no special handling.
|
||||
|
||||
## Getting v0.4.1
|
||||
|
||||
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
|
||||
[v0.4.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.1).
|
||||
- **Arch Linux**: `fips` from the AUR.
|
||||
- **macOS**: `.pkg` at the v0.4.1 release page.
|
||||
- **Windows**: ZIP at the v0.4.1 release page.
|
||||
- **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
|
||||
at the v0.4.1 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.1
|
||||
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
|
||||
required Linux build prerequisite).
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.1 tag
|
||||
builds the binaries from source with the pinned toolchain and no manual
|
||||
prerequisites (see the Nix section of `packaging/README.md`).
|
||||
|
||||
The full per-commit changelog lives in
|
||||
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
|
||||
[github.com/jmcorgan/fips](https://github.com/jmcorgan/fips).
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who contributed code, packaging work, bug reports, or
|
||||
reviews to this release.
|
||||
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, spanning-tree
|
||||
and discovery fixes, bloom and identity performance work, antipoison cap
|
||||
change, and testing.
|
||||
@@ -174,6 +174,10 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, scroll: u16, focused:
|
||||
&helpers::nested_u64(data, "stats", "sig_failed"),
|
||||
),
|
||||
helpers::kv_line("Stale", &helpers::nested_u64(data, "stats", "stale")),
|
||||
helpers::kv_line(
|
||||
"Parent Switched",
|
||||
&helpers::nested_u64(data, "stats", "parent_switched"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Loop Detected",
|
||||
&helpers::nested_u64(data, "stats", "loop_detected"),
|
||||
|
||||
+9
-19
@@ -69,18 +69,16 @@ impl BloomFilter {
|
||||
|
||||
/// Insert a NodeAddr into the filter.
|
||||
pub fn insert(&mut self, node_addr: &NodeAddr) {
|
||||
let (h1, h2) = Self::base_hashes(node_addr.as_bytes());
|
||||
for i in 0..self.hash_count {
|
||||
let bit_index = self.bit_index(h1, h2, i);
|
||||
let bit_index = self.hash(node_addr.as_bytes(), i);
|
||||
self.set_bit(bit_index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert raw bytes into the filter.
|
||||
pub fn insert_bytes(&mut self, data: &[u8]) {
|
||||
let (h1, h2) = Self::base_hashes(data);
|
||||
for i in 0..self.hash_count {
|
||||
let bit_index = self.bit_index(h1, h2, i);
|
||||
let bit_index = self.hash(data, i);
|
||||
self.set_bit(bit_index);
|
||||
}
|
||||
}
|
||||
@@ -95,9 +93,8 @@ impl BloomFilter {
|
||||
|
||||
/// Check if the filter might contain raw bytes.
|
||||
pub fn contains_bytes(&self, data: &[u8]) -> bool {
|
||||
let (h1, h2) = Self::base_hashes(data);
|
||||
for i in 0..self.hash_count {
|
||||
let bit_index = self.bit_index(h1, h2, i);
|
||||
let bit_index = self.hash(data, i);
|
||||
if !self.get_bit(bit_index) {
|
||||
return false;
|
||||
}
|
||||
@@ -199,28 +196,21 @@ impl BloomFilter {
|
||||
self.hash_count
|
||||
}
|
||||
|
||||
/// Compute the two base hashes for `data` with a single SHA-256 digest.
|
||||
/// Compute a hash index for the given data and hash function number.
|
||||
///
|
||||
/// Double hashing derives the k hash functions from two base hashes:
|
||||
/// h(x,i) = (h1(x) + i*h2(x)) mod m. Computing the digest once here and
|
||||
/// reusing `(h1, h2)` across all k functions avoids re-hashing per k.
|
||||
fn base_hashes(data: &[u8]) -> (u64, u64) {
|
||||
// Use first 16 bytes of SHA-256 for h1 and h2.
|
||||
/// Uses double hashing: h(x,i) = (h1(x) + i*h2(x)) mod m
|
||||
fn hash(&self, data: &[u8], k: u8) -> usize {
|
||||
// Use first 16 bytes of SHA-256 for h1 and h2
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
let hash = hasher.finalize();
|
||||
|
||||
// h1 from first 8 bytes, h2 from next 8 bytes (little-endian).
|
||||
// h1 from first 8 bytes
|
||||
let h1 = u64::from_le_bytes(hash[0..8].try_into().unwrap());
|
||||
// h2 from next 8 bytes
|
||||
let h2 = u64::from_le_bytes(hash[8..16].try_into().unwrap());
|
||||
(h1, h2)
|
||||
}
|
||||
|
||||
/// Derive the bit index for hash function `k` from the base hashes.
|
||||
///
|
||||
/// Uses double hashing: h(x,k) = (h1(x) + k*h2(x)) mod m.
|
||||
fn bit_index(&self, h1: u64, h2: u64, k: u8) -> usize {
|
||||
let combined = h1.wrapping_add((k as u64).wrapping_mul(h2));
|
||||
(combined as usize) % self.num_bits
|
||||
}
|
||||
|
||||
@@ -228,84 +228,6 @@ fn test_bloom_filter_insert_bytes_contains_bytes() {
|
||||
assert!(filter.contains_bytes(data2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_bit_indices_match_double_hashing_formula() {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Independently recompute the documented double-hashing bit indices:
|
||||
// one SHA-256 digest of the input, h1 = bytes[0..8] LE, h2 = bytes[8..16]
|
||||
// LE, then for k in 0..hash_count: (h1 + k*h2) mod num_bits. This pins
|
||||
// bit-identical behavior regardless of the internal implementation.
|
||||
fn expected_indices(data: &[u8], num_bits: usize, hash_count: u8) -> Vec<usize> {
|
||||
let digest = Sha256::digest(data);
|
||||
let h1 = u64::from_le_bytes(digest[0..8].try_into().unwrap());
|
||||
let h2 = u64::from_le_bytes(digest[8..16].try_into().unwrap());
|
||||
(0..hash_count)
|
||||
.map(|k| {
|
||||
let combined = h1.wrapping_add((k as u64).wrapping_mul(h2));
|
||||
(combined as usize) % num_bits
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bit_is_set(filter: &BloomFilter, index: usize) -> bool {
|
||||
let byte = filter.as_bytes()[index / 8];
|
||||
(byte >> (index % 8)) & 1 == 1
|
||||
}
|
||||
|
||||
let configs = [(1024usize, 5u8), (8192usize, 7u8)];
|
||||
let inputs: [&[u8]; 4] = [b"", b"alpha", b"the quick brown fox", &[0u8, 1, 2, 3, 255]];
|
||||
|
||||
for (num_bits, hash_count) in configs {
|
||||
for data in inputs {
|
||||
let mut filter = BloomFilter::with_params(num_bits, hash_count).unwrap();
|
||||
let expected = expected_indices(data, num_bits, hash_count);
|
||||
|
||||
filter.insert_bytes(data);
|
||||
|
||||
// Every expected bit is set.
|
||||
for &idx in &expected {
|
||||
assert!(
|
||||
bit_is_set(&filter, idx),
|
||||
"expected bit {} set for input {:?} (num_bits={}, k={})",
|
||||
idx,
|
||||
data,
|
||||
num_bits,
|
||||
hash_count
|
||||
);
|
||||
}
|
||||
|
||||
// No unexpected bits are set: the set-bit count never exceeds the
|
||||
// number of distinct expected indices.
|
||||
use std::collections::HashSet;
|
||||
let distinct: HashSet<usize> = expected.iter().copied().collect();
|
||||
assert_eq!(
|
||||
filter.count_ones(),
|
||||
distinct.len(),
|
||||
"unexpected bits set for input {:?}",
|
||||
data
|
||||
);
|
||||
|
||||
// contains reports the inserted item as present.
|
||||
assert!(filter.contains_bytes(data));
|
||||
}
|
||||
}
|
||||
|
||||
// NodeAddr path uses the same formula over its byte view.
|
||||
let node = make_node_addr(7);
|
||||
let mut filter = BloomFilter::with_params(1024, 5).unwrap();
|
||||
let expected = expected_indices(node.as_bytes(), 1024, 5);
|
||||
filter.insert(&node);
|
||||
for &idx in &expected {
|
||||
assert!(bit_is_set(&filter, idx));
|
||||
}
|
||||
assert!(filter.contains(&node));
|
||||
|
||||
// Spot-check a definitely-absent item is reported absent.
|
||||
let absent = make_node_addr(200);
|
||||
assert!(!filter.contains(&absent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_estimated_count_saturated() {
|
||||
// Create a small filter with all bits set
|
||||
|
||||
+5
-5
@@ -635,8 +635,8 @@ pub struct BloomConfig {
|
||||
pub update_debounce_ms: u64,
|
||||
/// Antipoison cap: reject inbound FilterAnnounce whose FPR exceeds
|
||||
/// this value (`node.bloom.max_inbound_fpr`). Valid range `(0.0, 1.0)`.
|
||||
/// Default `0.20` ≈ fill 0.7248 at k=5 ≈ ~2,114 entries on the 1 KB
|
||||
/// filter (Swamidass–Baldi). Raised from 0.10 so aggregates that are
|
||||
/// Default `0.10` ≈ fill 0.631 at k=5 ≈ ~1,630 entries on the 1 KB
|
||||
/// filter (Swamidass–Baldi). Raised from 0.05 so aggregates that are
|
||||
/// legitimately near their operating ceiling are not rejected before
|
||||
/// the network reaches the fixed-filter capacity limit; conceptually
|
||||
/// distinct from future autoscaling hysteresis setpoints — same unit,
|
||||
@@ -648,8 +648,8 @@ pub struct BloomConfig {
|
||||
impl Default for BloomConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
update_debounce_ms: Self::default_update_debounce_ms(),
|
||||
max_inbound_fpr: Self::default_max_inbound_fpr(),
|
||||
update_debounce_ms: 500,
|
||||
max_inbound_fpr: 0.10,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -659,7 +659,7 @@ impl BloomConfig {
|
||||
500
|
||||
}
|
||||
fn default_max_inbound_fpr() -> f64 {
|
||||
0.20
|
||||
0.10
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ use super::snapshot::{EntitySnapshot, RoutingSnapshot, StatsSnapshot};
|
||||
/// starting R1 as `show_*` queries cut over to off-loop rendering; until then
|
||||
/// they are wired but unread.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ControlReadHandle {
|
||||
pub struct ControlReadHandle {
|
||||
/// Effectively-immutable node context (config, identity, limits).
|
||||
context: Arc<NodeContext>,
|
||||
/// Metrics registry (counters / gauges) for `show_stats_*`.
|
||||
@@ -108,6 +108,40 @@ impl ControlReadHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal, public view of one peer for embedders (e.g. an app UI), read
|
||||
/// lock-free from the tick-published snapshot. See [`ControlReadHandle::peer_views`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeerView {
|
||||
/// The peer's `node_addr`, hex-encoded.
|
||||
pub node_addr_hex: String,
|
||||
/// Resolved npub (or the `node_addr` hex when not yet resolved to a peer).
|
||||
pub npub: String,
|
||||
/// Whether the peer is currently in the live authenticated-peer table.
|
||||
pub connected: bool,
|
||||
}
|
||||
|
||||
impl ControlReadHandle {
|
||||
/// A lock-free snapshot of known peers (node_addr / npub / connected),
|
||||
/// read from the tick-published stats snapshot.
|
||||
///
|
||||
/// Intended for embedders that run [`crate::Node::run_rx_loop`] on a
|
||||
/// background task (so the node is exclusively borrowed there) and poll peer
|
||||
/// state from a clone of this handle — the read touches only an `ArcSwap`
|
||||
/// load, never the `Node`. See the Myco app for the reference embedding.
|
||||
pub fn peer_views(&self) -> Vec<PeerView> {
|
||||
self.stats
|
||||
.load()
|
||||
.peer_meta
|
||||
.iter()
|
||||
.map(|(addr, meta)| PeerView {
|
||||
node_addr_hex: addr.to_string(),
|
||||
npub: meta.npub.clone(),
|
||||
connected: meta.is_active,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to serve a request entirely from the read handle, off the rx_loop.
|
||||
///
|
||||
/// Returns `Some(response)` when the command is a pure-snapshot query that has
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"loop_detected": 0,
|
||||
"outbound_sign_failed": 0,
|
||||
"parent_losses": 0,
|
||||
"parent_switched": 0,
|
||||
"parent_switches": 0,
|
||||
"rate_limited": 0,
|
||||
"received": 0,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod lan;
|
||||
pub mod nostr;
|
||||
pub mod platform;
|
||||
|
||||
use crate::config::UdpConfig;
|
||||
use crate::{NodeAddr, TransportId};
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Platform-pushed peer discovery.
|
||||
//!
|
||||
//! A generic seam for an embedding platform (e.g. an Android app layer that
|
||||
//! runs its own radio discovery, such as Wi-Fi Aware) to push "peer `npub` is
|
||||
//! reachable at `addr` over transport type `T`" events into a running node —
|
||||
//! the transport-agnostic generalization of the LAN mDNS drain
|
||||
//! (`poll_lan_discovery`), which delivers the same shape but is hardwired to
|
||||
//! UDP transports.
|
||||
//!
|
||||
//! The queue is a process-global, like the Android BLE bridge injection seam
|
||||
//! (`set_android_ble_bridge`): the embedder pushes without holding a `Node`
|
||||
//! handle, and the node drains once per tick in `poll_platform_discovery`.
|
||||
//! Events pushed while no node is running are retained up to [`QUEUE_CAP`]
|
||||
//! (oldest dropped first) so a push racing a node rebuild is not lost.
|
||||
//! With more than one node in a process, whichever drains first consumes
|
||||
//! the events (same caveat as the BLE bridge) — intended for the
|
||||
//! single-node embedding case.
|
||||
//!
|
||||
//! The pushed npub is only a routing hint: the Noise IK handshake is the
|
||||
//! authentication, exactly as with mDNS adverts — a spoofed push fails the
|
||||
//! IK exchange and is dropped.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Maximum retained events while undrained. Beyond this the oldest event is
|
||||
/// dropped: platform pushes are periodic (radio discovery re-fires), so a
|
||||
/// dropped event is re-learned, while an unbounded queue would grow forever
|
||||
/// if the node is stopped.
|
||||
const QUEUE_CAP: usize = 256;
|
||||
|
||||
/// A peer reachability event pushed by the embedding platform.
|
||||
///
|
||||
/// Addresses and identities are strings at this seam (it is crossed from
|
||||
/// JNI); they are parsed and validated at drain time, where a bad value is
|
||||
/// logged and skipped rather than surfaced to the pusher.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PlatformPeerEvent {
|
||||
/// The platform established reachability: dial `addr` on an operational
|
||||
/// transport whose type name matches `transport_type`. For `udp` the
|
||||
/// selection is family-aware — an IPv6 target picks an IPv6-capable
|
||||
/// socket, never a wildcard IPv4 one. For IPv6 link-local addresses the
|
||||
/// scope must be a numeric ifindex (`"[fe80::x%3]:4870"`) —
|
||||
/// interface-name scopes do not parse.
|
||||
Available {
|
||||
npub: String,
|
||||
addr: String,
|
||||
transport_type: String,
|
||||
},
|
||||
/// The platform observed the link go away (e.g. the Wi-Fi Aware data
|
||||
/// path was lost). The node closes any pooled connection it holds for
|
||||
/// the peer's current address on that transport so a dead socket is
|
||||
/// not re-used; reconnection is left to the ordinary machinery.
|
||||
Lost {
|
||||
npub: String,
|
||||
transport_type: String,
|
||||
},
|
||||
}
|
||||
|
||||
static QUEUE: Mutex<VecDeque<PlatformPeerEvent>> = Mutex::new(VecDeque::new());
|
||||
|
||||
fn push(event: PlatformPeerEvent) {
|
||||
let mut queue = QUEUE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if queue.len() >= QUEUE_CAP {
|
||||
queue.pop_front();
|
||||
}
|
||||
queue.push_back(event);
|
||||
}
|
||||
|
||||
/// Push "peer is reachable at `addr` over `transport_type`".
|
||||
pub fn platform_peer_available(npub: &str, addr: &str, transport_type: &str) {
|
||||
push(PlatformPeerEvent::Available {
|
||||
npub: npub.to_string(),
|
||||
addr: addr.to_string(),
|
||||
transport_type: transport_type.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Push "the platform-managed link to peer went away".
|
||||
pub fn platform_peer_lost(npub: &str, transport_type: &str) {
|
||||
push(PlatformPeerEvent::Lost {
|
||||
npub: npub.to_string(),
|
||||
transport_type: transport_type.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Drain all queued events. Called by the node once per tick.
|
||||
pub fn drain_platform_peer_events() -> Vec<PlatformPeerEvent> {
|
||||
let mut queue = QUEUE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
queue.drain(..).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The queue is a process-global, so tests touching it must not
|
||||
/// interleave across test threads.
|
||||
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn push_drain_roundtrip() {
|
||||
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
drain_platform_peer_events();
|
||||
platform_peer_available("npub1abc", "[fe80::1%3]:4870", "tcp");
|
||||
platform_peer_lost("npub1abc", "tcp");
|
||||
let events = drain_platform_peer_events();
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(
|
||||
events[0],
|
||||
PlatformPeerEvent::Available {
|
||||
npub: "npub1abc".into(),
|
||||
addr: "[fe80::1%3]:4870".into(),
|
||||
transport_type: "tcp".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
events[1],
|
||||
PlatformPeerEvent::Lost {
|
||||
npub: "npub1abc".into(),
|
||||
transport_type: "tcp".into(),
|
||||
}
|
||||
);
|
||||
assert!(drain_platform_peer_events().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_caps_by_dropping_oldest() {
|
||||
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
drain_platform_peer_events();
|
||||
for i in 0..(QUEUE_CAP + 10) {
|
||||
platform_peer_available(&format!("npub{i}"), "addr", "tcp");
|
||||
}
|
||||
let events = drain_platform_peer_events();
|
||||
assert_eq!(events.len(), QUEUE_CAP);
|
||||
match &events[0] {
|
||||
PlatformPeerEvent::Available { npub, .. } => assert_eq!(npub, "npub10"),
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Authentication challenge-response protocol.
|
||||
|
||||
use rand::Rng;
|
||||
use secp256k1::XOnlyPublicKey;
|
||||
use secp256k1::{Secp256k1, XOnlyPublicKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{IdentityError, NodeAddr};
|
||||
@@ -34,9 +34,9 @@ impl AuthChallenge {
|
||||
/// Verify a response to this challenge.
|
||||
pub fn verify(&self, response: &AuthResponse) -> Result<NodeAddr, IdentityError> {
|
||||
let digest = auth_challenge_digest(&self.0, response.timestamp);
|
||||
let secp = Secp256k1::new();
|
||||
|
||||
super::SECP
|
||||
.verify_schnorr(&response.signature, &digest, &response.pubkey)
|
||||
secp.verify_schnorr(&response.signature, &digest, &response.pubkey)
|
||||
.map_err(|_| IdentityError::SignatureVerificationFailed)?;
|
||||
|
||||
Ok(NodeAddr::from_pubkey(&response.pubkey))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Local node identity with signing capability.
|
||||
|
||||
use secp256k1::{Keypair, PublicKey, SecretKey, XOnlyPublicKey};
|
||||
use secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey};
|
||||
use std::fmt;
|
||||
|
||||
use super::auth::{AuthResponse, auth_challenge_digest};
|
||||
@@ -42,7 +42,8 @@ impl Identity {
|
||||
|
||||
/// Create an identity from a secret key.
|
||||
pub fn from_secret_key(secret_key: SecretKey) -> Self {
|
||||
let keypair = Keypair::from_secret_key(&super::SECP, &secret_key);
|
||||
let secp = Secp256k1::new();
|
||||
let keypair = Keypair::from_secret_key(&secp, &secret_key);
|
||||
Self::from_keypair(keypair)
|
||||
}
|
||||
|
||||
@@ -92,8 +93,9 @@ impl Identity {
|
||||
|
||||
/// Sign arbitrary data with this identity's secret key.
|
||||
pub fn sign(&self, data: &[u8]) -> secp256k1::schnorr::Signature {
|
||||
let secp = Secp256k1::new();
|
||||
let digest = sha256(data);
|
||||
super::SECP.sign_schnorr(&digest, &self.keypair)
|
||||
secp.sign_schnorr(&digest, &self.keypair)
|
||||
}
|
||||
|
||||
/// Create an authentication response for a challenge.
|
||||
@@ -101,7 +103,8 @@ impl Identity {
|
||||
/// The response signs: SHA256("fips-auth-v1" || challenge || timestamp)
|
||||
pub fn sign_challenge(&self, challenge: &[u8; 32], timestamp: u64) -> AuthResponse {
|
||||
let digest = auth_challenge_digest(challenge, timestamp);
|
||||
let signature = super::SECP.sign_schnorr(&digest, &self.keypair);
|
||||
let secp = Secp256k1::new();
|
||||
let signature = secp.sign_schnorr(&digest, &self.keypair);
|
||||
AuthResponse {
|
||||
pubkey: self.pubkey(),
|
||||
timestamp,
|
||||
|
||||
@@ -11,9 +11,6 @@ mod local;
|
||||
mod node_addr;
|
||||
mod peer;
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use secp256k1::{All, Secp256k1};
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -24,15 +21,6 @@ pub use local::Identity;
|
||||
pub use node_addr::NodeAddr;
|
||||
pub use peer::PeerIdentity;
|
||||
|
||||
/// Shared secp256k1 context reused across all identity operations.
|
||||
///
|
||||
/// `Secp256k1::new()` allocates a `Secp256k1<All>` and runs randomization /
|
||||
/// blinding table setup; it is designed to be created once and reused rather
|
||||
/// than rebuilt per sign / verify / key-derive call. This single `All` context
|
||||
/// serves both signing and verification across the identity module and still
|
||||
/// performs the standard construction-time blinding.
|
||||
pub(crate) static SECP: LazyLock<Secp256k1<All>> = LazyLock::new(Secp256k1::new);
|
||||
|
||||
/// FIPS address prefix (IPv6 ULA range).
|
||||
pub const FIPS_ADDRESS_PREFIX: u8 = 0xfd;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Remote peer identity (public key only, no signing capability).
|
||||
|
||||
use secp256k1::{Parity, PublicKey, XOnlyPublicKey};
|
||||
use secp256k1::{Parity, PublicKey, Secp256k1, XOnlyPublicKey};
|
||||
use std::fmt;
|
||||
|
||||
use super::encoding::{decode_npub, encode_npub};
|
||||
@@ -107,9 +107,9 @@ impl PeerIdentity {
|
||||
|
||||
/// Verify a signature from this peer.
|
||||
pub fn verify(&self, data: &[u8], signature: &secp256k1::schnorr::Signature) -> bool {
|
||||
let secp = Secp256k1::new();
|
||||
let digest = sha256(data);
|
||||
super::SECP
|
||||
.verify_schnorr(signature, &digest, &self.pubkey)
|
||||
secp.verify_schnorr(signature, &digest, &self.pubkey)
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::net::Ipv6Addr;
|
||||
|
||||
use secp256k1::{Keypair, SecretKey};
|
||||
use secp256k1::{Keypair, Secp256k1, SecretKey};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -161,10 +161,10 @@ fn test_identity_sign() {
|
||||
let sig = identity.sign(data);
|
||||
|
||||
// Verify the signature manually
|
||||
let secp = secp256k1::Secp256k1::new();
|
||||
let digest = super::sha256(data);
|
||||
assert!(
|
||||
super::SECP
|
||||
.verify_schnorr(&sig, &digest, &identity.pubkey())
|
||||
secp.verify_schnorr(&sig, &digest, &identity.pubkey())
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
@@ -580,12 +580,13 @@ fn test_peer_identity_pubkey_full_even_parity_fallback() {
|
||||
#[test]
|
||||
fn test_peer_identity_pubkey_full_preserved_parity() {
|
||||
// Create two identities and find one with odd parity to make this test meaningful
|
||||
let secp = Secp256k1::new();
|
||||
let secret_bytes: [u8; 32] = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
|
||||
0x1f, 0x20,
|
||||
];
|
||||
let keypair = Keypair::from_seckey_slice(&super::SECP, &secret_bytes).unwrap();
|
||||
let keypair = Keypair::from_seckey_slice(&secp, &secret_bytes).unwrap();
|
||||
let full_pubkey = keypair.public_key();
|
||||
|
||||
let peer = PeerIdentity::from_pubkey_full(full_pubkey);
|
||||
|
||||
@@ -240,32 +240,17 @@ impl Node {
|
||||
// map used by the TUN reader/writer at TCP MSS clamp time.
|
||||
let fips_addr = crate::FipsAddress::from_node_addr(&target);
|
||||
match self.path_mtu_lookup.write() {
|
||||
Ok(mut map) => match map.get(&fips_addr).copied() {
|
||||
Some(existing) if existing <= path_mtu => {
|
||||
// Keep the tighter learned value; never loosen the
|
||||
// clamp. A reactive MtuExceeded or PathMtuNotification
|
||||
// tighten takes precedence over a looser discovery
|
||||
// estimate (cross-carrier keep-tighter).
|
||||
debug!(
|
||||
target = %self.peer_display_name(&target),
|
||||
fips_addr = %fips_addr,
|
||||
path_mtu = path_mtu,
|
||||
existing = existing,
|
||||
"LookupResponse: keeping tighter existing path_mtu_lookup value"
|
||||
);
|
||||
}
|
||||
other => {
|
||||
map.insert(fips_addr, path_mtu);
|
||||
debug!(
|
||||
target = %self.peer_display_name(&target),
|
||||
fips_addr = %fips_addr,
|
||||
path_mtu = path_mtu,
|
||||
prior = ?other,
|
||||
map_len = map.len(),
|
||||
"Wrote path_mtu_lookup from discovery LookupResponse"
|
||||
);
|
||||
}
|
||||
},
|
||||
Ok(mut map) => {
|
||||
let prior = map.insert(fips_addr, path_mtu);
|
||||
debug!(
|
||||
target = %self.peer_display_name(&target),
|
||||
fips_addr = %fips_addr,
|
||||
path_mtu = path_mtu,
|
||||
prior = ?prior,
|
||||
map_len = map.len(),
|
||||
"Wrote path_mtu_lookup from discovery LookupResponse"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target = %self.peer_display_name(&target),
|
||||
@@ -454,14 +439,30 @@ impl Node {
|
||||
let origin_coords = self.tree_state().my_coords().clone();
|
||||
let request = LookupRequest::generate(*target, origin, origin_coords, ttl, 0);
|
||||
|
||||
// Send only to tree peers whose bloom filter contains the target
|
||||
let peer_addrs: Vec<NodeAddr> = self
|
||||
// Prefer tree peers whose bloom filter contains the target.
|
||||
let mut peer_addrs: Vec<NodeAddr> = self
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|(addr, peer)| self.is_tree_peer(addr) && peer.may_reach(target))
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect();
|
||||
|
||||
// Edge fallback: if no tree peer advertises the target — always the case
|
||||
// for a *directly connected* target, since a neighbour is never in another
|
||||
// peer's bloom — flood the request to EVERY peer we can send to, not just
|
||||
// tree peers. Crucially this includes the target itself when it's a direct
|
||||
// (non-tree) neighbour: a node answers a lookup for its own address, so the
|
||||
// querier learns its coordinates and can route to it. Without this a
|
||||
// mesh-edge node can't reach its own neighbours.
|
||||
if peer_addrs.is_empty() {
|
||||
peer_addrs = self
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|(_, peer)| peer.can_send())
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect();
|
||||
}
|
||||
|
||||
let peer_count = peer_addrs.len();
|
||||
|
||||
debug!(
|
||||
@@ -524,14 +525,22 @@ impl Node {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bloom filter pre-check: if no peer's filter contains the target,
|
||||
// it's not in the mesh — skip the lookup and record as failure.
|
||||
// Bloom filter pre-check: normally, if no peer's filter contains the
|
||||
// target we skip. But a *directly connected* peer is never in any other
|
||||
// peer's bloom (you reach it directly, not through them), so a mesh-edge
|
||||
// node — one whose only peers are direct links (BLE / Wi-Fi Aware / LAN),
|
||||
// with sparse blooms — could never discover coordinates for its own
|
||||
// neighbours and would fail to route to them. Only suppress on a bloom
|
||||
// miss when we have several peers whose blooms we can actually trust;
|
||||
// otherwise fall through and flood the lookup (bounded by TTL + the
|
||||
// `sent == 0` guard below).
|
||||
let reachable = self.peers.values().any(|peer| peer.may_reach(dest));
|
||||
if !reachable {
|
||||
if !reachable && self.peers.len() > 2 {
|
||||
self.metrics().discovery.req_bloom_miss.inc();
|
||||
self.discovery_backoff.record_failure(dest);
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(dest),
|
||||
peers = self.peers.len(),
|
||||
"Discovery skipped, target not in any peer bloom filter"
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -263,6 +263,11 @@ impl Node {
|
||||
let ce_flag = header.flags & FLAG_CE != 0;
|
||||
let sp_flag = header.flags & FLAG_SP != 0;
|
||||
|
||||
// Transport-preference cutover: gate roaming so a faster transport
|
||||
// (Wi-Fi Aware / UDP) isn't dragged back to a slower one (BLE) by a
|
||||
// stray packet. Computed before the peer borrow (needs `self`).
|
||||
let roam_pref = self.transport_preference(packet.transport_id);
|
||||
let roam_hysteresis = self.roam_hysteresis_ms();
|
||||
if let Some(peer) = self.peers.get_mut(&node_addr) {
|
||||
if let Some(mmp) = peer.mmp_mut() {
|
||||
mmp.receiver.record_recv(
|
||||
@@ -274,7 +279,13 @@ impl Node {
|
||||
);
|
||||
let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, header.counter, now);
|
||||
}
|
||||
peer.set_current_addr(packet.transport_id, packet.remote_addr.clone());
|
||||
peer.roam_current_addr(
|
||||
packet.transport_id,
|
||||
packet.remote_addr.clone(),
|
||||
roam_pref,
|
||||
packet.timestamp_ms,
|
||||
roam_hysteresis,
|
||||
);
|
||||
peer.link_stats_mut()
|
||||
.record_recv(packet.data.len(), packet.timestamp_ms);
|
||||
peer.touch(packet.timestamp_ms);
|
||||
@@ -356,10 +367,18 @@ impl Node {
|
||||
return;
|
||||
};
|
||||
let now = Instant::now();
|
||||
let roam_pref = self.transport_preference(transport_id);
|
||||
let roam_hysteresis = self.roam_hysteresis_ms();
|
||||
let mut address_changed = false;
|
||||
if let Some(peer) = self.peers.get_mut(node_addr) {
|
||||
peer.reset_decrypt_failures();
|
||||
address_changed = peer.set_current_addr(transport_id, remote_addr.clone());
|
||||
address_changed = peer.roam_current_addr(
|
||||
transport_id,
|
||||
remote_addr.clone(),
|
||||
roam_pref,
|
||||
packet_timestamp_ms,
|
||||
roam_hysteresis,
|
||||
);
|
||||
peer.link_stats_mut()
|
||||
.record_recv(packet_len, packet_timestamp_ms);
|
||||
peer.touch(packet_timestamp_ms);
|
||||
|
||||
@@ -173,6 +173,7 @@ impl Node {
|
||||
self.coord_cache
|
||||
.invalidate_via_node(our_identity.node_addr());
|
||||
self.reset_discovery_backoff();
|
||||
self.metrics().tree.parent_switched.inc();
|
||||
self.metrics().tree.parent_switches.inc();
|
||||
info!(
|
||||
new_parent = %self.peer_display_name(&new_parent),
|
||||
@@ -204,6 +205,7 @@ impl Node {
|
||||
self.coord_cache
|
||||
.invalidate_other_roots(our_identity.node_addr());
|
||||
self.reset_discovery_backoff();
|
||||
self.metrics().tree.parent_switched.inc();
|
||||
self.metrics().tree.parent_switches.inc();
|
||||
info!(
|
||||
new_root = %self.tree_state.root(),
|
||||
|
||||
@@ -264,6 +264,7 @@ impl Node {
|
||||
self.poll_pending_connects().await;
|
||||
self.poll_nostr_discovery().await;
|
||||
self.poll_lan_discovery().await;
|
||||
self.poll_platform_discovery().await;
|
||||
self.resend_pending_handshakes(now_ms).await;
|
||||
self.resend_pending_rekeys(now_ms).await;
|
||||
self.resend_pending_session_handshakes(now_ms).await;
|
||||
|
||||
@@ -2140,6 +2140,15 @@ impl Node {
|
||||
}
|
||||
if let Err(e) = self.send_ipv6_packet(&dest_addr, &ipv6_packet).await {
|
||||
debug!(dest = %self.peer_display_name(&dest_addr), error = %e, "Failed to send TUN packet via session");
|
||||
// An established session can still have no route when its
|
||||
// coordinates were never learned or went stale — e.g. a
|
||||
// platform-pushed peer (Wi-Fi Aware / LAN) whose Noise session
|
||||
// came up before tree discovery. Unlike session initiation
|
||||
// (below), this path didn't trigger a lookup, so the route
|
||||
// never warmed and every packet was silently dropped. Kick
|
||||
// discovery (rate-limited internally); the app's retransmit
|
||||
// then succeeds once coordinates arrive.
|
||||
self.maybe_initiate_lookup(&dest_addr).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
+186
-29
@@ -367,6 +367,27 @@ impl Node {
|
||||
.min_by_key(|(id, _)| id.as_u32())
|
||||
}
|
||||
|
||||
/// Resolve a discovered `(transport_type, addr)` pair to an operational
|
||||
/// transport. For `udp` addresses the socket family matters — a wildcard
|
||||
/// IPv4 socket cannot send to an IPv6 link-local target (e.g. a Wi-Fi
|
||||
/// Aware data path) — so when the address parses as a socket address the
|
||||
/// selection is family-aware and skips bootstrap-adopted sockets. All
|
||||
/// other transport types match by name alone.
|
||||
fn find_transport_for_discovered_addr(
|
||||
&self,
|
||||
transport_type: &str,
|
||||
addr: &str,
|
||||
) -> Option<TransportId> {
|
||||
if transport_type == "udp"
|
||||
&& let Ok(remote_addr) = addr.parse::<SocketAddr>()
|
||||
{
|
||||
return self
|
||||
.find_udp_transport_for_remote_addr(remote_addr)
|
||||
.map(|(id, _)| id);
|
||||
}
|
||||
self.find_transport_for_type(transport_type)
|
||||
}
|
||||
|
||||
/// Initiate a connection to a peer on a specific transport and address.
|
||||
///
|
||||
/// For connectionless transports (UDP, Ethernet): allocates a link, starts
|
||||
@@ -599,6 +620,18 @@ impl Node {
|
||||
let remote_addr = peer.addr;
|
||||
|
||||
if self.peers.contains_key(&node_addr) {
|
||||
// Don't re-probe an alternate path that is strictly worse
|
||||
// than the one the peer is already on: a peer settled on a
|
||||
// faster transport (Wi-Fi Aware / UDP) must not be pulled
|
||||
// back to BLE by BLE rediscovery. Equal-or-better candidates
|
||||
// still refresh, so BLE → Aware upgrades proceed.
|
||||
if let Some(cur) = self.peers.get(&node_addr).and_then(|p| p.transport_id()) {
|
||||
if self.transport_preference(cur)
|
||||
> self.transport_preference(candidate_transport_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let transport_name = transport.transport_type().name;
|
||||
let candidate = PeerAddress::new(transport_name, remote_addr.to_string());
|
||||
if self.active_peer_candidate_is_fresh_enough_to_skip(
|
||||
@@ -956,6 +989,139 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain platform-pushed peers and initiate Noise IK handshakes.
|
||||
///
|
||||
/// The transport-agnostic sibling of `poll_lan_discovery`: an embedding
|
||||
/// platform (e.g. the Android Wi-Fi Aware radio) pushes
|
||||
/// `(npub, addr, transport type)` events into the process-global queue
|
||||
/// (`crate::discovery::platform`) and this drains them once per tick.
|
||||
/// As with mDNS, the pushed npub is only a hint — the IK handshake is
|
||||
/// the authentication.
|
||||
///
|
||||
/// Unlike the LAN drain, an event for an already-active peer starts an
|
||||
/// alternate-path handshake (gated by the same freshness/in-flight
|
||||
/// checks as `poll_transport_discovery`), so a platform push can move a
|
||||
/// peer onto a faster transport — the BLE→Wi-Fi-Aware cutover.
|
||||
pub(super) async fn poll_platform_discovery(&mut self) {
|
||||
let events = crate::discovery::platform::drain_platform_peer_events();
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut connect_budget = self.discovery_connect_budget();
|
||||
for event in events {
|
||||
match event {
|
||||
crate::discovery::platform::PlatformPeerEvent::Available {
|
||||
npub,
|
||||
addr,
|
||||
transport_type,
|
||||
} => {
|
||||
let Some(transport_id) =
|
||||
self.find_transport_for_discovered_addr(&transport_type, &addr)
|
||||
else {
|
||||
debug!(
|
||||
npub = %npub,
|
||||
transport_type = %transport_type,
|
||||
addr = %addr,
|
||||
"platform: skip pushed peer with no compatible operational transport"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let identity = match crate::PeerIdentity::from_npub(&npub) {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
debug!(npub = %npub, error = %err, "platform: skip bad npub");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let peer_node_addr = *identity.node_addr();
|
||||
if peer_node_addr == *self.identity().node_addr() {
|
||||
continue;
|
||||
}
|
||||
let remote_addr = crate::transport::TransportAddr::from_string(&addr);
|
||||
|
||||
if self.peers.contains_key(&peer_node_addr) {
|
||||
// Active peer: this is a path upgrade, not a first
|
||||
// contact — apply the alternate-path gates.
|
||||
let candidate = PeerAddress::new(&transport_type, addr.clone());
|
||||
if self.active_peer_candidate_is_fresh_enough_to_skip(
|
||||
&peer_node_addr,
|
||||
std::slice::from_ref(&candidate),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if self.is_connecting_to_peer_on_path(
|
||||
&peer_node_addr,
|
||||
transport_id,
|
||||
&remote_addr,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if connect_budget == 0 {
|
||||
debug!(npub = %npub, "platform: connect budget exhausted");
|
||||
continue;
|
||||
}
|
||||
connect_budget = connect_budget.saturating_sub(1);
|
||||
info!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
transport_id = %transport_id,
|
||||
remote_addr = %remote_addr,
|
||||
"platform: initiating handshake to pushed peer"
|
||||
);
|
||||
if let Err(err) = self
|
||||
.initiate_connection(transport_id, remote_addr, identity)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
npub = %npub,
|
||||
error = %err,
|
||||
"platform: failed to initiate connection to pushed peer"
|
||||
);
|
||||
}
|
||||
}
|
||||
crate::discovery::platform::PlatformPeerEvent::Lost {
|
||||
npub,
|
||||
transport_type,
|
||||
} => {
|
||||
let Ok(identity) = crate::PeerIdentity::from_npub(&npub) else {
|
||||
continue;
|
||||
};
|
||||
let peer_node_addr = *identity.node_addr();
|
||||
let Some(peer) = self.peers.get(&peer_node_addr) else {
|
||||
continue;
|
||||
};
|
||||
// Only act if the peer currently sits on the named
|
||||
// transport type: close the pooled connection so the
|
||||
// dead socket is not re-used. Reconnection (including
|
||||
// falling back to another transport) is the ordinary
|
||||
// machinery's job.
|
||||
let (Some(transport_id), Some(current_addr)) =
|
||||
(peer.transport_id(), peer.current_addr().cloned())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let on_named_transport = self
|
||||
.transports
|
||||
.get(&transport_id)
|
||||
.map(|t| t.transport_type().name == transport_type)
|
||||
.unwrap_or(false);
|
||||
if !on_named_transport {
|
||||
continue;
|
||||
}
|
||||
info!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
transport_id = %transport_id,
|
||||
remote_addr = %current_addr,
|
||||
"platform: closing connection for lost pushed peer"
|
||||
);
|
||||
if let Some(transport) = self.transports.get(&transport_id) {
|
||||
transport.close_connection(¤t_addr).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll pending transport connects and initiate handshakes for ready ones.
|
||||
///
|
||||
/// Called from the tick handler. For each pending connect, queries the
|
||||
@@ -1076,6 +1242,12 @@ impl Node {
|
||||
|
||||
match handle.start().await {
|
||||
Ok(()) => {
|
||||
#[cfg(unix)]
|
||||
if transport_type == "udp"
|
||||
&& let (Some(tx), Some(fd)) = (&self.udp_bind_tx, handle.raw_fd())
|
||||
{
|
||||
let _ = tx.send(fd);
|
||||
}
|
||||
self.transports.insert(transport_id, handle);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1201,8 +1373,10 @@ impl Node {
|
||||
// This allows handshake messages to be sent before we start accepting packets
|
||||
self.initiate_peer_connections().await;
|
||||
|
||||
// Initialize TUN interface last, after transports and peers are ready
|
||||
if self.config().tun.enabled {
|
||||
// Initialize TUN interface last, after transports and peers are ready.
|
||||
// Skip when the TUN is app-owned (the embedder pre-set `tun_tx` via
|
||||
// `enable_app_owned_tun`) — then FIPS does no system-TUN ops.
|
||||
if self.config().tun.enabled && self.tun_tx.is_none() {
|
||||
let address = *self.identity().address();
|
||||
match TunDevice::create(&self.config().tun, address).await {
|
||||
Ok(device) => {
|
||||
@@ -1767,34 +1941,17 @@ impl Node {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
let tid = if addr.transport == "udp"
|
||||
&& let Ok(remote_socket_addr) = addr.addr.parse::<SocketAddr>()
|
||||
{
|
||||
match self.find_udp_transport_for_remote_addr(remote_socket_addr) {
|
||||
Some((id, _)) => id,
|
||||
None => {
|
||||
debug!(
|
||||
transport = %addr.transport,
|
||||
addr = %addr.addr,
|
||||
"No compatible operational UDP transport for address"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match self.find_transport_for_discovered_addr(&addr.transport, &addr.addr) {
|
||||
Some(tid) => (tid, TransportAddr::from_string(&addr.addr)),
|
||||
None => {
|
||||
debug!(
|
||||
transport = %addr.transport,
|
||||
addr = %addr.addr,
|
||||
"No compatible operational transport for address"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
match self.find_transport_for_type(&addr.transport) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
debug!(
|
||||
transport = %addr.transport,
|
||||
addr = %addr.addr,
|
||||
"No operational transport for address type"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
(tid, TransportAddr::from_string(&addr.addr))
|
||||
}
|
||||
};
|
||||
|
||||
if self.is_connecting_to_peer_on_path(&peer_node_addr, transport_id, &remote_addr) {
|
||||
|
||||
@@ -317,6 +317,7 @@ pub struct TreeMetrics {
|
||||
pub stale: Counter,
|
||||
pub ancestry_invalid: Counter,
|
||||
pub accepted: Counter,
|
||||
pub parent_switched: Counter,
|
||||
pub loop_detected: Counter,
|
||||
pub ancestry_changed: Counter,
|
||||
pub sent: Counter,
|
||||
@@ -350,6 +351,7 @@ impl TreeMetrics {
|
||||
stale: self.stale.get(),
|
||||
ancestry_invalid: self.ancestry_invalid.get(),
|
||||
accepted: self.accepted.get(),
|
||||
parent_switched: self.parent_switched.get(),
|
||||
loop_detected: self.loop_detected.get(),
|
||||
ancestry_changed: self.ancestry_changed.get(),
|
||||
sent: self.sent.get(),
|
||||
|
||||
+169
-9
@@ -41,14 +41,18 @@ use self::routing_error_rate_limit::RoutingErrorRateLimiter;
|
||||
/// `node.rekey.after_secs` remains the nominal interval (mean preserved).
|
||||
pub(crate) const REKEY_JITTER_SECS: i64 = 15;
|
||||
use self::wire::{
|
||||
ESTABLISHED_HEADER_SIZE, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, build_encrypted,
|
||||
build_established_header, prepend_inner_header,
|
||||
FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, build_encrypted, build_established_header,
|
||||
prepend_inner_header,
|
||||
};
|
||||
// Only referenced by the unix UDP fast-path block below; on Windows the wire
|
||||
// buffer is sized through build_encrypted, leaving this import otherwise unused.
|
||||
#[cfg(unix)]
|
||||
use self::wire::ESTABLISHED_HEADER_SIZE;
|
||||
use crate::bloom::{BloomFilter, BloomState};
|
||||
use crate::cache::CoordCache;
|
||||
use crate::node::session::SessionEntry;
|
||||
use crate::peer::{ActivePeer, PeerConnection};
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use crate::transport::ethernet::EthernetTransport;
|
||||
use crate::transport::nym::NymTransport;
|
||||
use crate::transport::tcp::TcpTransport;
|
||||
@@ -61,7 +65,7 @@ use crate::transport::{
|
||||
use crate::tree::TreeState;
|
||||
use crate::upper::hosts::HostMap;
|
||||
use crate::upper::icmp_rate_limit::IcmpRateLimiter;
|
||||
use crate::upper::tun::{TunError, TunOutboundRx, TunState, TunTx};
|
||||
use crate::upper::tun::{TunError, TunOutboundRx, TunOutboundTx, TunState, TunTx};
|
||||
use crate::utils::index::IndexAllocator;
|
||||
use crate::{Config, ConfigError, Identity, IdentityError, NodeAddr, PeerIdentity};
|
||||
use rand::Rng;
|
||||
@@ -436,6 +440,13 @@ pub struct Node {
|
||||
/// DNS responder task handle.
|
||||
dns_task: Option<tokio::task::JoinHandle<()>>,
|
||||
|
||||
// === App-owned UDP socket binding ===
|
||||
/// Fires once with the UDP transport's raw fd right after it starts, so an
|
||||
/// embedder can apply host-specific socket options (e.g. pinning it to one
|
||||
/// of several OS networks). See [`Self::enable_app_owned_udp_fd`].
|
||||
#[cfg(unix)]
|
||||
udp_bind_tx: Option<std::sync::mpsc::Sender<std::os::unix::io::RawFd>>,
|
||||
|
||||
// === Index-Based Session Dispatch ===
|
||||
/// Allocator for session indices.
|
||||
index_allocator: IndexAllocator,
|
||||
@@ -695,6 +706,8 @@ impl Node {
|
||||
tun_shutdown_fd: None,
|
||||
dns_identity_rx: None,
|
||||
dns_task: None,
|
||||
#[cfg(unix)]
|
||||
udp_bind_tx: None,
|
||||
index_allocator: IndexAllocator::new(),
|
||||
peers_by_index: HashMap::new(),
|
||||
pending_outbound: HashMap::new(),
|
||||
@@ -856,6 +869,8 @@ impl Node {
|
||||
tun_shutdown_fd: None,
|
||||
dns_identity_rx: None,
|
||||
dns_task: None,
|
||||
#[cfg(unix)]
|
||||
udp_bind_tx: None,
|
||||
index_allocator: IndexAllocator::new(),
|
||||
peers_by_index: HashMap::new(),
|
||||
pending_outbound: HashMap::new(),
|
||||
@@ -928,7 +943,7 @@ impl Node {
|
||||
}
|
||||
|
||||
// Create Ethernet transport instances (Unix only — requires raw sockets)
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
{
|
||||
let eth_instances: Vec<_> = self
|
||||
.config()
|
||||
@@ -1039,6 +1054,47 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
// Android BLE: the radio lives in Kotlin; build AndroidIo over the bridge
|
||||
// injected by the embedder (see ble::android_io::set_android_ble_bridge).
|
||||
#[cfg(all(target_os = "android", not(test)))]
|
||||
{
|
||||
let ble_instances: Vec<_> = self
|
||||
.config()
|
||||
.transports
|
||||
.ble
|
||||
.iter()
|
||||
.map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
|
||||
.collect();
|
||||
// Built whether or not a radio bridge exists yet, and without
|
||||
// capturing the one that does: `AndroidIo` resolves the process-wide
|
||||
// bridge per operation. The radio is owned by an Android foreground
|
||||
// service whose lifetime is independent of the node's — it can start
|
||||
// after the node, and it mints a fresh bridge every time it starts.
|
||||
// Binding either fact into the transport at construction meant the
|
||||
// only way to pick up a radio was to stop and rebuild the node,
|
||||
// which drops every peer and session. Operations attempted while no
|
||||
// radio is present fail as transport errors and recover on their own
|
||||
// once one appears.
|
||||
if !ble_instances.is_empty()
|
||||
&& crate::transport::ble::android_io::android_ble_bridge().is_none()
|
||||
{
|
||||
tracing::info!("BLE configured; waiting for the Android radio bridge");
|
||||
}
|
||||
for (name, ble_config) in ble_instances {
|
||||
let transport_id = self.allocate_transport_id();
|
||||
let io = crate::transport::ble::android_io::AndroidIo::from_global();
|
||||
let mut ble = crate::transport::ble::BleTransport::new(
|
||||
transport_id,
|
||||
name,
|
||||
ble_config,
|
||||
io,
|
||||
packet_tx.clone(),
|
||||
);
|
||||
ble.set_local_pubkey(self.identity().pubkey().serialize());
|
||||
transports.push(TransportHandle::Ble(ble));
|
||||
}
|
||||
}
|
||||
|
||||
transports
|
||||
}
|
||||
|
||||
@@ -1052,6 +1108,29 @@ impl Node {
|
||||
.map(|(id, _)| *id)
|
||||
}
|
||||
|
||||
/// Link preference of a transport instance by id (higher = preferred),
|
||||
/// from its transport type. See [`crate::transport::transport_link_preference`].
|
||||
/// Unknown ids get 0 so any real transport outranks a stale/removed one.
|
||||
pub(crate) fn transport_preference(&self, id: TransportId) -> u8 {
|
||||
self.transports
|
||||
.get(&id)
|
||||
.map(|h| crate::transport::transport_link_preference(h.transport_type().name))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// How long a preferred transport must be silent before a lower-preference
|
||||
/// transport may take over the peer's link (the roaming cutover hysteresis).
|
||||
/// ~2 heartbeat intervals so a single missed heartbeat can't cause a
|
||||
/// spurious downgrade; floored so a tiny configured interval can't flap.
|
||||
pub(crate) fn roam_hysteresis_ms(&self) -> u64 {
|
||||
(self
|
||||
.config()
|
||||
.node
|
||||
.heartbeat_interval_secs
|
||||
.saturating_mul(2_000))
|
||||
.max(15_000)
|
||||
}
|
||||
|
||||
/// Resolve an Ethernet peer address ("interface/mac") to a transport ID
|
||||
/// and binary TransportAddr.
|
||||
///
|
||||
@@ -1062,7 +1141,7 @@ impl Node {
|
||||
&self,
|
||||
addr_str: &str,
|
||||
) -> Result<(TransportId, TransportAddr), NodeError> {
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
{
|
||||
let (iface, mac_str) = addr_str.split_once('/').ok_or_else(|| {
|
||||
NodeError::NoTransportForType(format!(
|
||||
@@ -1094,7 +1173,7 @@ impl Node {
|
||||
|
||||
Ok((transport_id, TransportAddr::from_bytes(&mac)))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
Err(NodeError::NoTransportForType(
|
||||
"Ethernet transport is not supported on this platform".to_string(),
|
||||
@@ -1435,8 +1514,10 @@ impl Node {
|
||||
/// over this node's already-shared `NodeContext` and `MetricsRegistry`.
|
||||
///
|
||||
/// Used at control-socket spawn time so pure-snapshot `show_*` queries
|
||||
/// render off the rx_loop. Cloneable; cheap (all `Arc` clones).
|
||||
pub(crate) fn control_read_handle(&self) -> crate::control::read_handle::ControlReadHandle {
|
||||
/// render off the rx_loop. Cloneable; cheap (all `Arc` clones). Also the
|
||||
/// public seam for embedders that run [`Self::run_rx_loop`] on a background
|
||||
/// task and poll peer state via [`crate::control::read_handle::ControlReadHandle::peer_views`].
|
||||
pub fn control_read_handle(&self) -> crate::control::read_handle::ControlReadHandle {
|
||||
crate::control::read_handle::ControlReadHandle::new(
|
||||
self.context.clone(),
|
||||
self.metrics.clone(),
|
||||
@@ -2813,6 +2894,85 @@ impl Node {
|
||||
self.tun_tx.as_ref()
|
||||
}
|
||||
|
||||
/// Set up an **app-owned TUN**: rather than FIPS creating a system TUN
|
||||
/// device, the embedder (e.g. an Android `VpnService`) owns the fd and
|
||||
/// exchanges IPv6 packet bytes with FIPS over the returned channels. Call
|
||||
/// this after [`Node::new`] and **before** [`Self::start`] — and before
|
||||
/// moving the node into a background task — exactly like
|
||||
/// [`Self::control_read_handle`].
|
||||
///
|
||||
/// Returns `(app_outbound_tx, app_inbound_rx)`:
|
||||
/// - push IPv6 packets read from the app's TUN fd into `app_outbound_tx`
|
||||
/// (app → mesh); FIPS routes them to the destination node.
|
||||
/// - pull IPv6 packets destined for the app's TUN fd from `app_inbound_rx`
|
||||
/// (mesh → app) and write them to the fd (`recv_timeout` for clean stop).
|
||||
///
|
||||
/// With this set, [`Self::start`] skips system-TUN creation (it gates on
|
||||
/// `tun_tx` being unset). Packets pushed into `app_outbound_tx` bypass the
|
||||
/// system-TUN reader's `handle_tun_packet`, so the embedder must do what that
|
||||
/// path otherwise would: push only `fd::/8`-destined IPv6 packets — FIPS no
|
||||
/// longer filters the destination or emits ICMPv6 unreachable for off-mesh
|
||||
/// dests — and clamp TCP MSS on outbound SYNs.
|
||||
pub fn enable_app_owned_tun(&mut self) -> (TunOutboundTx, std::sync::mpsc::Receiver<Vec<u8>>) {
|
||||
let tun_channel_size = self.config().node.buffers.tun_channel;
|
||||
// app → mesh: the app pushes; `run_rx_loop` drains `tun_outbound_rx`.
|
||||
let (outbound_tx, outbound_rx) = tokio::sync::mpsc::channel(tun_channel_size);
|
||||
// mesh → app: the node writes inbound packets to `tun_tx`; the app pulls.
|
||||
let (tun_tx, tun_rx) = std::sync::mpsc::channel();
|
||||
self.tun_tx = Some(tun_tx);
|
||||
self.tun_outbound_rx = Some(outbound_rx);
|
||||
self.tun_state = TunState::Active;
|
||||
(outbound_tx, tun_rx)
|
||||
}
|
||||
|
||||
/// Set up an **app-owned DNS resolver**: an embedder that answers `.fips`
|
||||
/// queries itself (e.g. the Android VpnService packet pump, which has no
|
||||
/// system DNS socket) returns each resolved identity through the sender this
|
||||
/// returns. The `run_rx_loop` consumes them and calls
|
||||
/// [`Self::register_identity`] — the same identity-cache population (and hence
|
||||
/// route warming) the built-in [`crate::upper::dns::run_dns_responder`] does.
|
||||
///
|
||||
/// Without this the embedder's answers resolve the AAAA but leave the node's
|
||||
/// identity cache empty, so the first outbound packet to a freshly-resolved
|
||||
/// `<npub>.fips` has no cached pubkey to open a session with and is dropped.
|
||||
///
|
||||
/// Call after [`Node::new`] and **before** [`Self::start`], like
|
||||
/// [`Self::enable_app_owned_tun`].
|
||||
pub fn enable_app_owned_dns(&mut self) -> crate::upper::dns::DnsIdentityTx {
|
||||
let size = self.config().node.buffers.tun_channel.max(1);
|
||||
let (identity_tx, identity_rx) = tokio::sync::mpsc::channel(size);
|
||||
self.dns_identity_rx = Some(identity_rx);
|
||||
identity_tx
|
||||
}
|
||||
|
||||
/// Set up an **app-owned UDP socket binding**: the returned receiver gets
|
||||
/// the UDP transport's raw socket fd once, right after the transport opens
|
||||
/// inside [`Self::start`], so an embedder can apply a socket option FIPS
|
||||
/// itself has no way to choose — in particular pinning the socket to one
|
||||
/// of several networks the host OS offers.
|
||||
///
|
||||
/// FIPS opens a single wildcard UDP socket and picks the egress path per
|
||||
/// destination address, which assumes the OS routes by destination alone.
|
||||
/// Some hosts don't: where the OS binds each socket to one "network" and
|
||||
/// steers replies by that association, a peer reachable only over a
|
||||
/// secondary network (a link-local address on an interface that is not
|
||||
/// the default route) can receive our handshake and have its reply
|
||||
/// discarded before it reaches the socket. The fd is the only handle that
|
||||
/// lets the embedder correct this, and it is otherwise private to the
|
||||
/// transport.
|
||||
///
|
||||
/// Call after [`Node::new`] and before [`Self::start`], like
|
||||
/// [`Self::enable_app_owned_tun`]. Nothing is ever sent if no UDP
|
||||
/// transport is configured or it fails to start.
|
||||
#[cfg(unix)]
|
||||
pub fn enable_app_owned_udp_fd(
|
||||
&mut self,
|
||||
) -> std::sync::mpsc::Receiver<std::os::unix::io::RawFd> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
self.udp_bind_tx = Some(tx);
|
||||
rx
|
||||
}
|
||||
|
||||
// === Sending ===
|
||||
|
||||
/// Encrypt and send a link-layer message to an authenticated peer.
|
||||
|
||||
@@ -273,6 +273,7 @@ pub struct TreeStatsSnapshot {
|
||||
pub stale: u64,
|
||||
pub ancestry_invalid: u64,
|
||||
pub accepted: u64,
|
||||
pub parent_switched: u64,
|
||||
pub loop_detected: u64,
|
||||
pub ancestry_changed: u64,
|
||||
pub sent: u64,
|
||||
|
||||
@@ -912,45 +912,6 @@ async fn test_originator_stores_path_mtu_in_cache() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_originator_lookup_response_keeps_tighter_path_mtu_lookup() {
|
||||
// Regression: a LookupResponse carrying a looser (larger) path_mtu must
|
||||
// NOT clobber a tighter (smaller) value already in path_mtu_lookup that a
|
||||
// reactive MtuExceeded or PathMtuNotification learned. Cross-carrier
|
||||
// keep-tighter: the clamp must never loosen.
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
|
||||
let target_identity = Identity::generate();
|
||||
let target = *target_identity.node_addr();
|
||||
let root = make_node_addr(0xF0);
|
||||
let coords = TreeCoordinate::from_addrs(vec![target, root]).unwrap();
|
||||
|
||||
node.register_identity(target, target_identity.pubkey_full());
|
||||
|
||||
// Pre-seed a tighter value, as if a reactive signal already narrowed it.
|
||||
let target_fips = crate::FipsAddress::from_node_addr(&target);
|
||||
node.path_mtu_lookup_insert(target_fips, 1280);
|
||||
|
||||
let proof_data = LookupResponse::proof_bytes(800, &target, &coords);
|
||||
let proof = target_identity.sign(&proof_data);
|
||||
|
||||
let mut response = LookupResponse::new(800, target, coords.clone(), proof);
|
||||
// Looser discovery estimate that must be rejected in favor of the tighter
|
||||
// existing entry.
|
||||
response.path_mtu = 1500;
|
||||
|
||||
let payload = &response.encode()[1..];
|
||||
|
||||
node.handle_lookup_response(&from, payload).await;
|
||||
|
||||
assert_eq!(
|
||||
node.path_mtu_lookup_get(&target_fips),
|
||||
Some(1280),
|
||||
"LookupResponse must not loosen a tighter existing path_mtu_lookup value"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Open-Discovery Sweep — cache-injection unit test
|
||||
// ============================================================================
|
||||
|
||||
@@ -1995,3 +1995,58 @@ async fn handle_msg1_admits_existing_peer_at_cap() {
|
||||
"rate limiter must rebalance after the (bypass-admitted) handler returns"
|
||||
);
|
||||
}
|
||||
|
||||
/// App-owned TUN seam: `enable_app_owned_tun` wires the embedder's packet
|
||||
/// channels (an Android `VpnService` owns the fd) and marks the TUN active so
|
||||
/// `start()` skips system-TUN creation.
|
||||
#[test]
|
||||
fn app_owned_tun_seam_wires_channels() {
|
||||
let mut config = crate::Config::new();
|
||||
config.tun.enabled = true;
|
||||
let mut node = make_node_with(config);
|
||||
|
||||
let (outbound_tx, tun_rx) = node.enable_app_owned_tun();
|
||||
|
||||
// TUN is active and the inbound (mesh→app) sender is installed, so `start()`
|
||||
// will skip `TunDevice::create` (it gates on `tun_tx.is_none()`).
|
||||
assert_eq!(node.tun_state(), crate::upper::tun::TunState::Active);
|
||||
assert!(node.tun_tx().is_some(), "inbound sender installed");
|
||||
|
||||
// mesh → app: a packet the node delivers to its `tun_tx` reaches the app's rx.
|
||||
let pkt = vec![0x60u8, 0, 0, 0, 0, 0];
|
||||
node.tun_tx().unwrap().send(pkt.clone()).unwrap();
|
||||
assert_eq!(
|
||||
tun_rx
|
||||
.recv_timeout(std::time::Duration::from_millis(200))
|
||||
.unwrap(),
|
||||
pkt,
|
||||
"the app pulls the same bytes the node wrote",
|
||||
);
|
||||
|
||||
// app → mesh: the returned sender is live (its matching rx is held by the node
|
||||
// and drained by `run_rx_loop` → `handle_tun_outbound`).
|
||||
assert!(outbound_tx.try_send(vec![0x60]).is_ok());
|
||||
}
|
||||
|
||||
/// With an app-owned TUN configured, `start()` must NOT create a system TUN
|
||||
/// device: it leaves `tun_name` unset (a real device records its interface name)
|
||||
/// and keeps the TUN `Active` with the app-owned channels.
|
||||
#[tokio::test]
|
||||
async fn start_skips_system_tun_when_app_owned() {
|
||||
let mut config = crate::Config::new();
|
||||
config.tun.enabled = true;
|
||||
let mut node = make_node_with(config);
|
||||
|
||||
let (_outbound_tx, _tun_rx) = node.enable_app_owned_tun();
|
||||
node.start().await.unwrap();
|
||||
|
||||
// No system device was created (that path records the interface name); the
|
||||
// app-owned TUN stayed active.
|
||||
assert!(
|
||||
node.tun_name().is_none(),
|
||||
"app-owned TUN must not create a named system device",
|
||||
);
|
||||
assert_eq!(node.tun_state(), crate::upper::tun::TunState::Active);
|
||||
|
||||
node.stop().await.unwrap();
|
||||
}
|
||||
|
||||
@@ -300,6 +300,7 @@ impl Node {
|
||||
.invalidate_via_node(our_identity.node_addr());
|
||||
self.reset_discovery_backoff();
|
||||
|
||||
self.metrics().tree.parent_switched.inc();
|
||||
self.metrics().tree.parent_switches.inc();
|
||||
|
||||
info!(
|
||||
@@ -337,6 +338,7 @@ impl Node {
|
||||
self.coord_cache
|
||||
.invalidate_other_roots(our_identity.node_addr());
|
||||
self.reset_discovery_backoff();
|
||||
self.metrics().tree.parent_switched.inc();
|
||||
self.metrics().tree.parent_switches.inc();
|
||||
info!(
|
||||
new_root = %self.tree_state.root(),
|
||||
@@ -522,6 +524,7 @@ impl Node {
|
||||
.invalidate_via_node(our_identity.node_addr());
|
||||
self.reset_discovery_backoff();
|
||||
|
||||
self.metrics().tree.parent_switched.inc();
|
||||
self.metrics().tree.parent_switches.inc();
|
||||
|
||||
info!(
|
||||
@@ -557,6 +560,7 @@ impl Node {
|
||||
self.coord_cache
|
||||
.invalidate_other_roots(our_identity.node_addr());
|
||||
self.reset_discovery_backoff();
|
||||
self.metrics().tree.parent_switched.inc();
|
||||
self.metrics().tree.parent_switches.inc();
|
||||
info!(
|
||||
new_root = %self.tree_state.root(),
|
||||
|
||||
@@ -99,6 +99,14 @@ pub struct ActivePeer {
|
||||
transport_id: Option<TransportId>,
|
||||
/// Current transport address (for roaming support).
|
||||
current_addr: Option<TransportAddr>,
|
||||
/// Link preference of the current transport (higher = preferred). Gates
|
||||
/// roaming so a fast transport (e.g. Wi-Fi Aware / UDP) is not dragged back
|
||||
/// onto a slow one (BLE) by a stray packet — see `roam_current_addr`.
|
||||
current_transport_preference: u8,
|
||||
/// When the current transport last delivered an authenticated packet (Unix
|
||||
/// ms). Lets the roam gate detect that the preferred transport has gone
|
||||
/// silent, so a lower-preference transport may take over.
|
||||
current_transport_last_recv_ms: u64,
|
||||
|
||||
// === Spanning Tree ===
|
||||
/// Their latest parent declaration.
|
||||
@@ -232,6 +240,8 @@ impl ActivePeer {
|
||||
their_index: None,
|
||||
transport_id: None,
|
||||
current_addr: None,
|
||||
current_transport_preference: 0,
|
||||
current_transport_last_recv_ms: 0,
|
||||
declaration: None,
|
||||
ancestry: None,
|
||||
tree_announce_min_interval_ms: 500,
|
||||
@@ -318,6 +328,11 @@ impl ActivePeer {
|
||||
their_index: Some(their_index),
|
||||
transport_id: Some(transport_id),
|
||||
current_addr: Some(current_addr),
|
||||
// Preference 0 until the first authenticated data packet sets the
|
||||
// real value (via roam_current_addr); last-recv starts fresh at
|
||||
// authentication so the preferred link isn't seen as stale.
|
||||
current_transport_preference: 0,
|
||||
current_transport_last_recv_ms: authenticated_at,
|
||||
declaration: None,
|
||||
ancestry: None,
|
||||
tree_announce_min_interval_ms: 500,
|
||||
@@ -535,6 +550,45 @@ impl ActivePeer {
|
||||
changed
|
||||
}
|
||||
|
||||
/// Roam to `(transport_id, addr)` under the transport-preference cutover
|
||||
/// policy, returning whether `(transport_id, addr)` actually changed.
|
||||
///
|
||||
/// The new path is adopted when it is the current transport (ordinary
|
||||
/// address roaming, e.g. BLE MAC rotation), when its `preference` is `>=`
|
||||
/// the current transport's (eager upgrade to a faster link — BLE →
|
||||
/// Wi-Fi Aware), or when the current, higher-preference transport has gone
|
||||
/// silent for at least `hysteresis_ms` (the preferred link died — fall
|
||||
/// back). A lower-preference packet arriving on a still-live preferred
|
||||
/// transport is ignored for roaming, so e.g. a BLE keepalive cannot drag an
|
||||
/// active Wi-Fi Aware session back onto BLE. Equal preferences reduce to
|
||||
/// plain last-authenticated-packet-wins roaming.
|
||||
pub fn roam_current_addr(
|
||||
&mut self,
|
||||
transport_id: TransportId,
|
||||
addr: TransportAddr,
|
||||
preference: u8,
|
||||
now_ms: u64,
|
||||
hysteresis_ms: u64,
|
||||
) -> bool {
|
||||
let same_transport = self.transport_id == Some(transport_id);
|
||||
let adopt = self.transport_id.is_none()
|
||||
|| same_transport
|
||||
|| preference >= self.current_transport_preference
|
||||
|| now_ms.saturating_sub(self.current_transport_last_recv_ms) >= hysteresis_ms;
|
||||
if !adopt {
|
||||
// Stay on the preferred transport; do not refresh its last-recv,
|
||||
// so it keeps aging toward the hysteresis fallback if it is dead.
|
||||
return false;
|
||||
}
|
||||
let changed =
|
||||
self.transport_id != Some(transport_id) || self.current_addr.as_ref() != Some(&addr);
|
||||
self.transport_id = Some(transport_id);
|
||||
self.current_addr = Some(addr);
|
||||
self.current_transport_preference = preference;
|
||||
self.current_transport_last_recv_ms = now_ms;
|
||||
changed
|
||||
}
|
||||
|
||||
// === Handshake Resend ===
|
||||
|
||||
/// Store wire-format msg2 for resend on duplicate msg1.
|
||||
@@ -1245,6 +1299,40 @@ mod tests {
|
||||
assert!(!peer.can_send());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roam_transport_preference_cutover() {
|
||||
let mut peer = ActivePeer::new(make_peer_identity(), LinkId::new(1), 1000);
|
||||
let ble = TransportId::new(1);
|
||||
let udp = TransportId::new(2);
|
||||
let ble_addr = TransportAddr::from_string("ble0/AA:BB");
|
||||
let udp_addr = TransportAddr::from_string("[fe80::1%3]:4870");
|
||||
const BLE_PREF: u8 = 50;
|
||||
const UDP_PREF: u8 = 100;
|
||||
const HYST: u64 = 20_000;
|
||||
|
||||
// First packet (no current transport): adopted.
|
||||
assert!(peer.roam_current_addr(ble, ble_addr.clone(), BLE_PREF, 1000, HYST));
|
||||
assert_eq!(peer.transport_id(), Some(ble));
|
||||
|
||||
// Higher-preference transport: eager upgrade BLE -> Aware/UDP.
|
||||
assert!(peer.roam_current_addr(udp, udp_addr.clone(), UDP_PREF, 1100, HYST));
|
||||
assert_eq!(peer.transport_id(), Some(udp));
|
||||
|
||||
// Lower-preference keepalive while UDP is fresh: ignored, stays on UDP.
|
||||
assert!(!peer.roam_current_addr(ble, ble_addr.clone(), BLE_PREF, 1200, HYST));
|
||||
assert_eq!(peer.transport_id(), Some(udp));
|
||||
|
||||
// UDP silent past the hysteresis window: a BLE packet now wins (fall
|
||||
// back). Last UDP recv was 1100; this arrives at 1100 + HYST + 1.
|
||||
assert!(peer.roam_current_addr(ble, ble_addr.clone(), BLE_PREF, 1100 + HYST + 1, HYST));
|
||||
assert_eq!(peer.transport_id(), Some(ble));
|
||||
|
||||
// Same-transport address roaming is always allowed.
|
||||
let ble_addr2 = TransportAddr::from_string("ble0/CC:DD");
|
||||
assert!(peer.roam_current_addr(ble, ble_addr2.clone(), BLE_PREF, 1100 + HYST + 2, HYST));
|
||||
assert_eq!(peer.current_addr(), Some(&ble_addr2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tree_position() {
|
||||
let identity = make_peer_identity();
|
||||
|
||||
@@ -0,0 +1,766 @@
|
||||
//! Android BLE backend: a [`BleIo`] whose radio lives in Kotlin.
|
||||
//!
|
||||
//! Android's BLE APIs are Java-only, so Kotlin owns the radio (scan, advertise,
|
||||
//! L2CAP listen/connect, socket read/write) and exchanges **raw bytes** with this
|
||||
//! Rust backend over a byte-bridge — symmetric to how nostr-vpn's `MobileTunnel`
|
||||
//! exchanges TUN packet bytes across the FFI. FIPS keeps everything above the
|
||||
//! `BleIo` trait (the pool, the cross-probe tiebreaker, the pubkey exchange,
|
||||
//! Noise); this backend only moves bytes and surfaces adverts.
|
||||
//!
|
||||
//! ## Layering
|
||||
//!
|
||||
//! FIPS cannot depend on the app crate (`myco-core`), so the split is:
|
||||
//!
|
||||
//! - [`AndroidRadio`] — an object-safe trait for the few **commands** the radio
|
||||
//! must run (listen/connect/advertise/scan/close). `myco-core` implements it
|
||||
//! via JNI calls into the Kotlin radio object.
|
||||
//! - [`AndroidBleBridge`] — the channel machinery shared by this backend and the
|
||||
//! JNI layer. `myco-core` constructs it, injects it via
|
||||
//! [`set_android_ble_bridge`], and drives its `deliver_*` / `next_send`
|
||||
//! methods from its `Java_..._NativeCore_*` exports.
|
||||
//! - [`AndroidIo`] / [`AndroidStream`] / [`AndroidAcceptor`] / [`AndroidScanner`]
|
||||
//! — the `BleIo` impl, delegating to the bridge.
|
||||
//!
|
||||
//! ## Direction of blocking (matches nostr-vpn's MobileTunnel)
|
||||
//!
|
||||
//! - **Inbound** bytes/events (Kotlin → Rust) are **pushed** non-blocking into
|
||||
//! tokio channels (`deliver_recv`, `deliver_inbound`, `deliver_scan`,
|
||||
//! `deliver_connect_result`); the awaiting FIPS task wakes.
|
||||
//! - **Outbound** bytes (Rust → Kotlin) are **pulled, blocking with timeout**, by
|
||||
//! a per-channel Kotlin writer thread via [`AndroidBleBridge::next_send`].
|
||||
//! `BleStream::send` only pushes into a std channel — it never calls JNI — so
|
||||
//! the byte hot path never blocks a tokio worker on a JNI upcall.
|
||||
//!
|
||||
//! This module is platform-agnostic Rust (no JNI here — that lives in
|
||||
//! `myco-core`), so it compiles and unit-tests on the host with a mock radio.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU16, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::transport::TransportError;
|
||||
|
||||
use super::DEFAULT_PSM;
|
||||
use super::addr::BleAddr;
|
||||
use super::io::{BleAcceptor, BleIo, BleScanner, BleStream};
|
||||
use super::psm::PsmMap;
|
||||
|
||||
/// Synthetic adapter label (Android does not expose a BlueZ-style adapter name;
|
||||
/// identity is the pubkey, never the MAC — see ble-interop.md).
|
||||
const ANDROID_ADAPTER: &str = "ble0";
|
||||
|
||||
/// Bound on a per-channel inbound queue and the accept/scan fan-in. Generous so
|
||||
/// control events (accept/scan) are not dropped under burst; L2CAP data drops are
|
||||
/// tolerable since FMP/Noise above retransmits.
|
||||
const CHANNEL_CAP: usize = 256;
|
||||
|
||||
/// Bound on the **outbound byte** queue (Rust → Kotlin writer), fixed at channel
|
||||
/// creation. A bounded queue makes `BleStream::send` backpressure (the `SyncSender`
|
||||
/// blocks), propagating flow control up through FSP/MMP to the TUN and TCP rather
|
||||
/// than letting an unbounded queue bufferbloat the link.
|
||||
///
|
||||
/// 32 is empirical, swept against the peer speedtest: 8 starved the radio's
|
||||
/// connection events (~half throughput), 64 bufferbloated TCP (regressed), and 32
|
||||
/// was best (~200/500 kbps up/down). The sweep was noisy and non-monotonic across
|
||||
/// single runs, though — run-to-run BLE variance (RF, and whether the OS grants 2M
|
||||
/// PHY / high connection priority that session) rivals the effect of this knob — so
|
||||
/// 32 is the best-observed working value, to be re-validated with repeated runs +
|
||||
/// PHY/interval instrumentation, not a proven optimum.
|
||||
const SEND_QUEUE_CAP: usize = 32;
|
||||
|
||||
/// Transport default MTU, used when the OS reports an unknown (0) channel MTU.
|
||||
/// Matches `DEFAULT_BLE_MTU` in `config/transport.rs`.
|
||||
const DEFAULT_BLE_MTU: u16 = 2048;
|
||||
|
||||
// ============================================================================
|
||||
// AndroidRadio — the Kotlin-implemented command surface
|
||||
// ============================================================================
|
||||
|
||||
/// The radio commands the bridge issues to the platform. `myco-core` implements
|
||||
/// this via JNI `call_method` on the Kotlin `BleRadio` object. Object-safe so the
|
||||
/// bridge can hold `Arc<dyn AndroidRadio>`.
|
||||
///
|
||||
/// These are the **control** plane only — never the byte hot path. Outbound bytes
|
||||
/// are pulled by Kotlin via [`AndroidBleBridge::next_send`]; inbound bytes are
|
||||
/// pushed by Kotlin via [`AndroidBleBridge::deliver_recv`].
|
||||
pub trait AndroidRadio: Send + Sync {
|
||||
/// Open an insecure L2CAP listener and return the OS-assigned PSM (0 = failure).
|
||||
fn listen(&self) -> u16;
|
||||
/// Begin dialing `addr` at `psm`. The outcome is delivered asynchronously via
|
||||
/// [`AndroidBleBridge::deliver_connect_result`] keyed by `connect_id`.
|
||||
fn connect(&self, connect_id: i64, addr: &BleAddr, psm: u16);
|
||||
/// Advertise the FIPS service UUID plus our listener `psm` (16-bit LE
|
||||
/// service-data — see [`super::psm`]).
|
||||
fn start_advertising(&self, psm: u16);
|
||||
fn stop_advertising(&self);
|
||||
/// Scan for the FIPS UUID; deliver hits via [`AndroidBleBridge::deliver_scan`].
|
||||
fn start_scanning(&self);
|
||||
fn stop_scanning(&self);
|
||||
/// Close the L2CAP socket for `ch_id` (called when FIPS drops the stream).
|
||||
fn close_channel(&self, ch_id: i64);
|
||||
}
|
||||
|
||||
/// One discovered scan advert (address / learned PSM / RSSI), for the developer
|
||||
/// UI's "discovered devices" list.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdvertView {
|
||||
/// `BleAddr` string (`adapter/AA:BB:..`); the MAC rotates with privacy.
|
||||
pub addr: String,
|
||||
/// The peer's advertised listener PSM (0 if not present in the advert).
|
||||
pub psm: u16,
|
||||
/// Signal strength in dBm (negative; closer ≈ less negative).
|
||||
pub rssi: i32,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AndroidBleBridge — the shared channel machinery
|
||||
// ============================================================================
|
||||
|
||||
/// The half of a channel kept by the bridge (the JNI-facing ends).
|
||||
struct ChannelState {
|
||||
/// Kotlin-pushed inbound bytes land here; the stream's `recv` awaits them.
|
||||
recv_tx: mpsc::Sender<Vec<u8>>,
|
||||
/// `BleStream::send` pushes here; the Kotlin writer thread pulls via `next_send`.
|
||||
/// `Arc` so `next_send` can clone it out and release the channels lock before
|
||||
/// blocking on `recv_timeout`.
|
||||
send_rx: Arc<Mutex<std::sync::mpsc::Receiver<Vec<u8>>>>,
|
||||
closed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// The half of a channel handed to the `BleStream` (the FIPS-facing ends).
|
||||
struct StreamEndpoints {
|
||||
ch_id: i64,
|
||||
remote: BleAddr,
|
||||
send_mtu: u16,
|
||||
recv_mtu: u16,
|
||||
recv_rx: mpsc::Receiver<Vec<u8>>,
|
||||
send_tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
closed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// Channel machinery shared between [`AndroidIo`] and the JNI layer in `myco-core`.
|
||||
///
|
||||
/// Constructed by `myco-core` with a concrete [`AndroidRadio`], injected via
|
||||
/// [`set_android_ble_bridge`], and driven by its `deliver_*` / `next_send`
|
||||
/// methods from the JNI exports.
|
||||
pub struct AndroidBleBridge {
|
||||
radio: Arc<dyn AndroidRadio>,
|
||||
next_id: AtomicI64,
|
||||
/// Our own OS-assigned listener PSM, learned from `radio.listen()`.
|
||||
local_psm: AtomicU16,
|
||||
/// Learned peer PSMs (advert service-data), consulted on `connect`.
|
||||
psm_map: PsmMap,
|
||||
/// Latest scan advert per address (PSM + RSSI), for the developer UI's
|
||||
/// "discovered" list. Re-learned each scan cycle (addresses rotate).
|
||||
adverts: Mutex<HashMap<BleAddr, (u16, i32)>>,
|
||||
channels: Mutex<HashMap<i64, ChannelState>>,
|
||||
/// connect_id → result slot for an in-flight outbound dial.
|
||||
connects: Mutex<HashMap<i64, oneshot::Sender<StreamEndpoints>>>,
|
||||
/// Inbound-accept fan-in; the acceptor takes the receiver once.
|
||||
accept_tx: mpsc::Sender<StreamEndpoints>,
|
||||
accept_rx: Mutex<Option<mpsc::Receiver<StreamEndpoints>>>,
|
||||
/// Scan fan-in; the scanner takes the receiver once.
|
||||
scan_tx: mpsc::Sender<BleAddr>,
|
||||
scan_rx: Mutex<Option<mpsc::Receiver<BleAddr>>>,
|
||||
}
|
||||
|
||||
impl AndroidBleBridge {
|
||||
/// Build a bridge over a concrete radio.
|
||||
pub fn new(radio: Arc<dyn AndroidRadio>) -> Arc<Self> {
|
||||
let (accept_tx, accept_rx) = mpsc::channel(CHANNEL_CAP);
|
||||
let (scan_tx, scan_rx) = mpsc::channel(CHANNEL_CAP);
|
||||
Arc::new(Self {
|
||||
radio,
|
||||
next_id: AtomicI64::new(1),
|
||||
local_psm: AtomicU16::new(DEFAULT_PSM),
|
||||
psm_map: PsmMap::new(),
|
||||
adverts: Mutex::new(HashMap::new()),
|
||||
channels: Mutex::new(HashMap::new()),
|
||||
connects: Mutex::new(HashMap::new()),
|
||||
accept_tx,
|
||||
accept_rx: Mutex::new(Some(accept_rx)),
|
||||
scan_tx,
|
||||
scan_rx: Mutex::new(Some(scan_rx)),
|
||||
})
|
||||
}
|
||||
|
||||
fn lock_channels(&self) -> std::sync::MutexGuard<'_, HashMap<i64, ChannelState>> {
|
||||
self.channels.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Allocate a channel id and wire its two halves, registering the
|
||||
/// bridge-facing half and returning the FIPS-facing half.
|
||||
fn make_channel(&self, remote: BleAddr, send_mtu: u16, recv_mtu: u16) -> StreamEndpoints {
|
||||
let ch_id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (recv_tx, recv_rx) = mpsc::channel(CHANNEL_CAP);
|
||||
let (send_tx, send_rx) = std::sync::mpsc::sync_channel(SEND_QUEUE_CAP);
|
||||
let closed = Arc::new(AtomicBool::new(false));
|
||||
self.lock_channels().insert(
|
||||
ch_id,
|
||||
ChannelState {
|
||||
recv_tx,
|
||||
send_rx: Arc::new(Mutex::new(send_rx)),
|
||||
closed: Arc::clone(&closed),
|
||||
},
|
||||
);
|
||||
StreamEndpoints {
|
||||
ch_id,
|
||||
remote,
|
||||
send_mtu: if send_mtu == 0 {
|
||||
DEFAULT_BLE_MTU
|
||||
} else {
|
||||
send_mtu
|
||||
},
|
||||
recv_mtu: if recv_mtu == 0 {
|
||||
DEFAULT_BLE_MTU
|
||||
} else {
|
||||
recv_mtu
|
||||
},
|
||||
recv_rx,
|
||||
send_tx,
|
||||
closed,
|
||||
}
|
||||
}
|
||||
|
||||
// --- JNI-facing push/pull surface (called by myco-core's exports) ---
|
||||
|
||||
/// Kotlin accepted a new inbound L2CAP channel. Returns the allocated `ch_id`.
|
||||
pub fn deliver_inbound(&self, remote: BleAddr, send_mtu: u16, recv_mtu: u16) -> i64 {
|
||||
let ep = self.make_channel(remote, send_mtu, recv_mtu);
|
||||
let ch_id = ep.ch_id;
|
||||
if self.accept_tx.try_send(ep).is_err() {
|
||||
// Acceptor gone or saturated: reclaim the half-registered channel.
|
||||
self.lock_channels().remove(&ch_id);
|
||||
return 0;
|
||||
}
|
||||
ch_id
|
||||
}
|
||||
|
||||
/// Kotlin finished (or failed) an outbound dial started by `radio.connect`.
|
||||
/// Returns the allocated `ch_id` on success, else 0.
|
||||
pub fn deliver_connect_result(
|
||||
&self,
|
||||
connect_id: i64,
|
||||
ok: bool,
|
||||
remote: BleAddr,
|
||||
send_mtu: u16,
|
||||
recv_mtu: u16,
|
||||
) -> i64 {
|
||||
let waiter = self
|
||||
.connects
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(&connect_id);
|
||||
let Some(tx) = waiter else { return 0 };
|
||||
if !ok {
|
||||
drop(tx); // dropping the sender wakes the awaiting connect() as an error
|
||||
return 0;
|
||||
}
|
||||
let ep = self.make_channel(remote, send_mtu, recv_mtu);
|
||||
let ch_id = ep.ch_id;
|
||||
if tx.send(ep).is_err() {
|
||||
self.lock_channels().remove(&ch_id);
|
||||
return 0;
|
||||
}
|
||||
ch_id
|
||||
}
|
||||
|
||||
/// Kotlin discovered a FIPS peer advertising `psm` (its OS-assigned listener
|
||||
/// PSM) at signal strength `rssi` (dBm). Learns the per-peer PSM, records the
|
||||
/// advert for the developer UI, and surfaces the address to the scanner.
|
||||
pub fn deliver_scan(&self, addr: BleAddr, psm: u16, rssi: i32) {
|
||||
if psm != 0 {
|
||||
self.psm_map.learn(&addr, psm);
|
||||
}
|
||||
self.adverts
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(addr.clone(), (psm, rssi));
|
||||
let _ = self.scan_tx.try_send(addr);
|
||||
}
|
||||
|
||||
/// Snapshot of the current scan adverts (address / PSM / RSSI) for the
|
||||
/// developer UI.
|
||||
pub fn advert_views(&self) -> Vec<AdvertView> {
|
||||
self.adverts
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.map(|(addr, (psm, rssi))| AdvertView {
|
||||
addr: addr.to_string_repr(),
|
||||
psm: *psm,
|
||||
rssi: *rssi,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drop recorded adverts (called at the start of a scan cycle).
|
||||
pub fn clear_adverts(&self) {
|
||||
self.adverts
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clear();
|
||||
}
|
||||
|
||||
/// Kotlin read one L2CAP packet for `ch_id`. Returns false if the channel is
|
||||
/// unknown/closed (Kotlin should then stop its reader).
|
||||
pub fn deliver_recv(&self, ch_id: i64, data: &[u8]) -> bool {
|
||||
let tx = self.lock_channels().get(&ch_id).map(|c| c.recv_tx.clone());
|
||||
match tx {
|
||||
Some(tx) => tx.try_send(data.to_vec()).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Kotlin's per-channel writer thread pulls the next outbound packet, blocking
|
||||
/// up to `timeout`. `None` = timed out (Kotlin loops) or the channel is gone.
|
||||
pub fn next_send(&self, ch_id: i64, timeout: Duration) -> Option<Vec<u8>> {
|
||||
// Clone the per-channel receiver Arc + closed flag, then release the
|
||||
// channels lock before blocking on recv_timeout (so close/create on other
|
||||
// channels aren't stalled for up to `timeout`).
|
||||
let (send_rx, closed) = {
|
||||
let guard = self.lock_channels();
|
||||
let state = guard.get(&ch_id)?;
|
||||
(Arc::clone(&state.send_rx), Arc::clone(&state.closed))
|
||||
};
|
||||
let rx = send_rx.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => None,
|
||||
// The send half (the BleStream) was dropped — the stream is gone
|
||||
// (e.g. the node stopped). Mark closed so the next channel_open()
|
||||
// returns false and the Kotlin writer thread exits.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
closed.store(true, Ordering::Relaxed);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kotlin reports `ch_id` closed (EOF / socket gone). Wakes the stream's
|
||||
/// `recv` with a zero-length read (FIPS treats that as peer-closed).
|
||||
pub fn channel_closed(&self, ch_id: i64) {
|
||||
if let Some(state) = self.lock_channels().remove(&ch_id) {
|
||||
state.closed.store(true, Ordering::Relaxed);
|
||||
// Dropping recv_tx closes the stream's recv_rx → recv() returns Ok(0).
|
||||
drop(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `ch_id` is still open (registered and not marked closed). The JNI
|
||||
/// `next_send` export uses this to tell a timeout (loop again) from a closed
|
||||
/// channel (stop the writer thread).
|
||||
pub fn channel_open(&self, ch_id: i64) -> bool {
|
||||
self.lock_channels()
|
||||
.get(&ch_id)
|
||||
.map(|s| !s.closed.load(Ordering::Relaxed))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Global injection seam
|
||||
// ============================================================================
|
||||
|
||||
static BRIDGE: Mutex<Option<Arc<AndroidBleBridge>>> = Mutex::new(None);
|
||||
|
||||
/// Inject the process-wide bridge before `Node::new` / start (one radio per
|
||||
/// process). Replaceable so a stop-then-start cycle (BLE toggled off then on)
|
||||
/// can inject a fresh bridge for the rebuilt node.
|
||||
pub fn set_android_ble_bridge(bridge: Arc<AndroidBleBridge>) {
|
||||
*BRIDGE.lock().unwrap_or_else(|e| e.into_inner()) = Some(bridge);
|
||||
}
|
||||
|
||||
/// The injected bridge, if any. The node's BLE construction arm reads this.
|
||||
pub fn android_ble_bridge() -> Option<Arc<AndroidBleBridge>> {
|
||||
BRIDGE.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BleIo implementation
|
||||
// ============================================================================
|
||||
|
||||
/// macOS/Android-style external-radio backend over [`AndroidBleBridge`].
|
||||
pub struct AndroidIo {
|
||||
/// Bridge to use when one was supplied explicitly (tests). In the app this
|
||||
/// is `None` and every operation resolves [`android_ble_bridge`] instead.
|
||||
///
|
||||
/// Resolving per call is what lets the radio be replaced without rebuilding
|
||||
/// the node. The Android service mints a fresh bridge each time it starts
|
||||
/// (a new `BleRadio`, a new JNI handle); if this type captured that `Arc` at
|
||||
/// construction, a running node would keep driving the dead one, and the
|
||||
/// only way to adopt the new radio would be to stop and restart the node —
|
||||
/// dropping every peer and every session with it.
|
||||
bridge: Option<Arc<AndroidBleBridge>>,
|
||||
}
|
||||
|
||||
impl AndroidIo {
|
||||
/// Resolve the bridge from the process-wide slot on each operation.
|
||||
pub fn from_global() -> Self {
|
||||
Self { bridge: None }
|
||||
}
|
||||
|
||||
/// Pin a specific bridge, for tests that drive one directly.
|
||||
pub fn new(bridge: Arc<AndroidBleBridge>) -> Self {
|
||||
Self {
|
||||
bridge: Some(bridge),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bridge for this operation. `None` once the radio has gone away —
|
||||
/// callers surface that as a transport error rather than panicking, since
|
||||
/// it is reachable simply by toggling Bluetooth off mid-operation.
|
||||
fn bridge(&self) -> Option<Arc<AndroidBleBridge>> {
|
||||
match &self.bridge {
|
||||
Some(b) => Some(Arc::clone(b)),
|
||||
None => android_ble_bridge(),
|
||||
}
|
||||
}
|
||||
|
||||
fn require_bridge(&self) -> Result<Arc<AndroidBleBridge>, TransportError> {
|
||||
self.bridge()
|
||||
.ok_or_else(|| TransportError::Io(std::io::Error::other("BLE radio not available")))
|
||||
}
|
||||
}
|
||||
|
||||
/// One live L2CAP channel.
|
||||
pub struct AndroidStream {
|
||||
ch_id: i64,
|
||||
remote: BleAddr,
|
||||
send_mtu: u16,
|
||||
recv_mtu: u16,
|
||||
recv_rx: AsyncMutex<mpsc::Receiver<Vec<u8>>>,
|
||||
send_tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
closed: Arc<AtomicBool>,
|
||||
radio: Arc<dyn AndroidRadio>,
|
||||
}
|
||||
|
||||
impl AndroidStream {
|
||||
fn from_endpoints(ep: StreamEndpoints, radio: Arc<dyn AndroidRadio>) -> Self {
|
||||
Self {
|
||||
ch_id: ep.ch_id,
|
||||
remote: ep.remote,
|
||||
send_mtu: ep.send_mtu,
|
||||
recv_mtu: ep.recv_mtu,
|
||||
recv_rx: AsyncMutex::new(ep.recv_rx),
|
||||
send_tx: ep.send_tx,
|
||||
closed: ep.closed,
|
||||
radio,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AndroidStream {
|
||||
fn drop(&mut self) {
|
||||
self.closed.store(true, Ordering::Relaxed);
|
||||
self.radio.close_channel(self.ch_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl BleStream for AndroidStream {
|
||||
async fn send(&self, data: &[u8]) -> Result<(), TransportError> {
|
||||
if self.closed.load(Ordering::Relaxed) {
|
||||
return Err(TransportError::Io(std::io::Error::other(
|
||||
"BLE channel closed",
|
||||
)));
|
||||
}
|
||||
// Pure channel push — no JNI on the hot path. The Kotlin writer thread
|
||||
// pulls this via the bridge's next_send and writes the socket.
|
||||
//
|
||||
// Backpressure rather than drop on a full queue. The queue is deliberately
|
||||
// shallow (SEND_QUEUE_CAP) so it can't bufferbloat the BLE link; awaiting a
|
||||
// free slot here propagates flow control up through FSP/MMP to the TUN and
|
||||
// TCP — keeping RTT low *without* the loss-driven throughput collapse that a
|
||||
// shallow tail-drop queue causes.
|
||||
let mut payload = data.to_vec();
|
||||
loop {
|
||||
if self.closed.load(Ordering::Relaxed) {
|
||||
return Err(TransportError::Io(std::io::Error::other(
|
||||
"BLE channel closed",
|
||||
)));
|
||||
}
|
||||
match self.send_tx.try_send(payload) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(std::sync::mpsc::TrySendError::Full(p)) => {
|
||||
payload = p;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
|
||||
}
|
||||
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
|
||||
return Err(TransportError::Io(std::io::Error::other(
|
||||
"BLE send: peer closed",
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn recv(&self, buf: &mut [u8]) -> Result<usize, TransportError> {
|
||||
match self.recv_rx.lock().await.recv().await {
|
||||
Some(packet) => {
|
||||
let n = packet.len().min(buf.len());
|
||||
buf[..n].copy_from_slice(&packet[..n]);
|
||||
Ok(n)
|
||||
}
|
||||
// Sender dropped (channel closed) → peer-closed, per the BleStream
|
||||
// contract (a zero-length recv means the peer closed).
|
||||
None => Ok(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_mtu(&self) -> u16 {
|
||||
self.send_mtu
|
||||
}
|
||||
|
||||
fn recv_mtu(&self) -> u16 {
|
||||
self.recv_mtu
|
||||
}
|
||||
|
||||
fn remote_addr(&self) -> &BleAddr {
|
||||
&self.remote
|
||||
}
|
||||
}
|
||||
|
||||
/// Yields inbound channels Kotlin accepted.
|
||||
pub struct AndroidAcceptor {
|
||||
rx: Option<mpsc::Receiver<StreamEndpoints>>,
|
||||
radio: Arc<dyn AndroidRadio>,
|
||||
}
|
||||
|
||||
impl BleAcceptor for AndroidAcceptor {
|
||||
type Stream = AndroidStream;
|
||||
|
||||
async fn accept(&mut self) -> Result<AndroidStream, TransportError> {
|
||||
match self.rx.as_mut() {
|
||||
Some(rx) => match rx.recv().await {
|
||||
Some(ep) => Ok(AndroidStream::from_endpoints(ep, Arc::clone(&self.radio))),
|
||||
None => std::future::pending().await, // fan-in closed; idle
|
||||
},
|
||||
None => std::future::pending().await, // acceptor already consumed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Yields discovered FIPS peers (the learned PSM is captured into the bridge map).
|
||||
pub struct AndroidScanner {
|
||||
rx: Option<mpsc::Receiver<BleAddr>>,
|
||||
}
|
||||
|
||||
impl BleScanner for AndroidScanner {
|
||||
async fn next(&mut self) -> Option<BleAddr> {
|
||||
match self.rx.as_mut() {
|
||||
Some(rx) => rx.recv().await,
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BleIo for AndroidIo {
|
||||
type Stream = AndroidStream;
|
||||
type Acceptor = AndroidAcceptor;
|
||||
type Scanner = AndroidScanner;
|
||||
|
||||
async fn listen(&self, _psm: u16) -> Result<AndroidAcceptor, TransportError> {
|
||||
let bridge = self.require_bridge()?;
|
||||
// Android assigns the listener PSM; the `psm` arg from FIPS is ignored.
|
||||
let os_psm = bridge.radio.listen();
|
||||
if os_psm != 0 {
|
||||
bridge.local_psm.store(os_psm, Ordering::Relaxed);
|
||||
}
|
||||
let rx = bridge
|
||||
.accept_rx
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take();
|
||||
Ok(AndroidAcceptor {
|
||||
rx,
|
||||
radio: Arc::clone(&bridge.radio),
|
||||
})
|
||||
}
|
||||
|
||||
async fn connect(&self, addr: &BleAddr, psm: u16) -> Result<AndroidStream, TransportError> {
|
||||
let bridge = self.require_bridge()?;
|
||||
// Substitute the learned per-peer PSM for this address, if known.
|
||||
let dial_psm = bridge.psm_map.resolve(addr, psm);
|
||||
let connect_id = bridge.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
bridge
|
||||
.connects
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(connect_id, tx);
|
||||
bridge.radio.connect(connect_id, addr, dial_psm);
|
||||
// FIPS already wraps connect() in a timeout, so we just await the result.
|
||||
match rx.await {
|
||||
Ok(ep) => Ok(AndroidStream::from_endpoints(ep, Arc::clone(&bridge.radio))),
|
||||
Err(_) => {
|
||||
bridge
|
||||
.connects
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(&connect_id);
|
||||
Err(TransportError::Io(std::io::Error::other(format!(
|
||||
"BLE connect to {addr} failed"
|
||||
))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_advertising(&self) -> Result<(), TransportError> {
|
||||
let bridge = self.require_bridge()?;
|
||||
bridge
|
||||
.radio
|
||||
.start_advertising(bridge.local_psm.load(Ordering::Relaxed));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop_advertising(&self) -> Result<(), TransportError> {
|
||||
let bridge = self.require_bridge()?;
|
||||
bridge.radio.stop_advertising();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_scanning(&self) -> Result<AndroidScanner, TransportError> {
|
||||
let bridge = self.require_bridge()?;
|
||||
// Re-learn PSMs / adverts each scan cycle (addresses rotate with MAC).
|
||||
bridge.psm_map.clear();
|
||||
bridge.clear_adverts();
|
||||
bridge.radio.start_scanning();
|
||||
let rx = bridge
|
||||
.scan_rx
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take();
|
||||
Ok(AndroidScanner { rx })
|
||||
}
|
||||
|
||||
fn local_addr(&self) -> Result<BleAddr, TransportError> {
|
||||
Ok(BleAddr {
|
||||
adapter: ANDROID_ADAPTER.to_string(),
|
||||
device: [0, 0, 0, 0, 0, 0],
|
||||
})
|
||||
}
|
||||
|
||||
fn adapter_name(&self) -> &str {
|
||||
ANDROID_ADAPTER
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicU16 as TestAtomicU16;
|
||||
|
||||
/// A mock radio that records commands and lets the test drive the bridge.
|
||||
#[derive(Default)]
|
||||
struct MockRadio {
|
||||
listen_psm: TestAtomicU16,
|
||||
scanning: AtomicBool,
|
||||
advertising_psm: TestAtomicU16,
|
||||
}
|
||||
|
||||
impl AndroidRadio for MockRadio {
|
||||
fn listen(&self) -> u16 {
|
||||
self.listen_psm.load(Ordering::Relaxed)
|
||||
}
|
||||
fn connect(&self, _connect_id: i64, _addr: &BleAddr, _psm: u16) {}
|
||||
fn start_advertising(&self, psm: u16) {
|
||||
self.advertising_psm.store(psm, Ordering::Relaxed);
|
||||
}
|
||||
fn stop_advertising(&self) {}
|
||||
fn start_scanning(&self) {
|
||||
self.scanning.store(true, Ordering::Relaxed);
|
||||
}
|
||||
fn stop_scanning(&self) {}
|
||||
fn close_channel(&self, _ch_id: i64) {}
|
||||
}
|
||||
|
||||
fn addr(n: u8) -> BleAddr {
|
||||
BleAddr {
|
||||
adapter: ANDROID_ADAPTER.to_string(),
|
||||
device: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, n],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inbound_channel_recv_and_close() {
|
||||
let radio = Arc::new(MockRadio::default());
|
||||
let bridge = AndroidBleBridge::new(radio.clone());
|
||||
let io = AndroidIo::new(Arc::clone(&bridge));
|
||||
|
||||
let mut acceptor = io.listen(0).await.unwrap();
|
||||
|
||||
// Kotlin accepts an inbound channel, then pushes a packet.
|
||||
let ch_id = bridge.deliver_inbound(addr(1), 512, 512);
|
||||
assert!(ch_id > 0);
|
||||
assert!(bridge.deliver_recv(ch_id, b"hello"));
|
||||
|
||||
let stream = acceptor.accept().await.unwrap();
|
||||
assert_eq!(stream.remote_addr(), &addr(1));
|
||||
assert_eq!(stream.send_mtu(), 512);
|
||||
|
||||
let mut buf = [0u8; 64];
|
||||
let n = stream.recv(&mut buf).await.unwrap();
|
||||
assert_eq!(&buf[..n], b"hello");
|
||||
|
||||
// Closing the channel makes the next recv return 0 (peer closed).
|
||||
bridge.channel_closed(ch_id);
|
||||
let n = stream.recv(&mut buf).await.unwrap();
|
||||
assert_eq!(n, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_send_is_pulled_by_next_send() {
|
||||
let radio = Arc::new(MockRadio::default());
|
||||
let bridge = AndroidBleBridge::new(radio.clone());
|
||||
|
||||
// Simulate an accepted channel and grab its stream.
|
||||
let mut acceptor = AndroidIo::new(Arc::clone(&bridge)).listen(0).await.unwrap();
|
||||
let ch_id = bridge.deliver_inbound(addr(2), 0, 0);
|
||||
let stream = acceptor.accept().await.unwrap();
|
||||
// 0 MTU falls back to the transport default.
|
||||
assert!(stream.send_mtu() > 0);
|
||||
|
||||
stream.send(b"out").await.unwrap();
|
||||
let pulled = bridge.next_send(ch_id, Duration::from_millis(100)).unwrap();
|
||||
assert_eq!(pulled, b"out");
|
||||
// Nothing more queued → times out (None).
|
||||
assert!(bridge.next_send(ch_id, Duration::from_millis(10)).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scan_learns_psm_and_connect_substitutes_it() {
|
||||
let radio = Arc::new(MockRadio::default());
|
||||
let bridge = AndroidBleBridge::new(radio.clone());
|
||||
let io = AndroidIo::new(Arc::clone(&bridge));
|
||||
|
||||
let mut scanner = io.start_scanning().await.unwrap();
|
||||
assert!(radio.scanning.load(Ordering::Relaxed));
|
||||
|
||||
bridge.deliver_scan(addr(3), 0x00C1, -50);
|
||||
assert_eq!(scanner.next().await, Some(addr(3)));
|
||||
// The advert is also recorded for the developer UI.
|
||||
let adverts = bridge.advert_views();
|
||||
assert_eq!(adverts.len(), 1);
|
||||
assert_eq!(adverts[0].psm, 0x00C1);
|
||||
assert_eq!(adverts[0].rssi, -50);
|
||||
// The learned PSM is what a later dial would use (over FIPS's default).
|
||||
assert_eq!(bridge.psm_map.resolve(&addr(3), DEFAULT_PSM), 0x00C1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn advertise_uses_os_assigned_listen_psm() {
|
||||
let radio = Arc::new(MockRadio::default());
|
||||
radio.listen_psm.store(0x0099, Ordering::Relaxed);
|
||||
let bridge = AndroidBleBridge::new(radio.clone());
|
||||
let io = AndroidIo::new(Arc::clone(&bridge));
|
||||
|
||||
let _ = io.listen(0).await.unwrap(); // learns the OS PSM
|
||||
io.start_advertising().await.unwrap();
|
||||
assert_eq!(radio.advertising_psm.load(Ordering::Relaxed), 0x0099);
|
||||
}
|
||||
}
|
||||
+94
-50
@@ -1,9 +1,11 @@
|
||||
//! BLE L2CAP Transport Implementation
|
||||
//!
|
||||
//! Provides BLE-based transport for FIPS peer communication using L2CAP
|
||||
//! Connection-Oriented Channels (CoC) in SeqPacket mode. L2CAP CoC
|
||||
//! preserves message boundaries (unlike TCP byte streams), so no FMP
|
||||
//! framing is needed — each send/recv is one FIPS packet.
|
||||
//! Connection-Oriented Channels (CoC). BlueZ (SeqPacket) preserves message
|
||||
//! boundaries, but stream-oriented backends (Android `BluetoothSocket`,
|
||||
//! CoreBluetooth) do not, so the receive path recovers FIPS packet boundaries
|
||||
//! with the shared FMP framer (`tcp::stream::read_fmp_packet`) over a
|
||||
//! [`stream_read::BleStreamRead`] adapter — reliable on every platform.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
@@ -22,7 +24,15 @@ pub mod addr;
|
||||
pub mod discovery;
|
||||
pub mod io;
|
||||
pub mod pool;
|
||||
pub mod psm;
|
||||
pub mod stats;
|
||||
pub mod stream_read;
|
||||
|
||||
// The Android backend (radio in Kotlin, bytes over a bridge). Compiled on
|
||||
// Android, and under `cfg(test)` on any host so its channel logic is unit-tested
|
||||
// without a device. Not built into non-test desktop builds.
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
pub mod android_io;
|
||||
|
||||
use super::{
|
||||
ConnectionState, DiscoveredPeer, PacketTx, ReceivedPacket, Transport, TransportAddr,
|
||||
@@ -35,6 +45,9 @@ use discovery::DiscoveryBuffer;
|
||||
use io::{BleIo, BleScanner, BleStream};
|
||||
use pool::{BleConnection, ConnectionPool};
|
||||
use stats::BleStats;
|
||||
use stream_read::BleStreamRead;
|
||||
|
||||
use crate::transport::tcp::stream::{StreamError, read_fmp_packet};
|
||||
|
||||
use secp256k1::XOnlyPublicKey;
|
||||
use std::collections::HashMap;
|
||||
@@ -55,7 +68,12 @@ pub const DEFAULT_PSM: u16 = 0x0085;
|
||||
#[cfg(all(bluer_available, not(test)))]
|
||||
pub type DefaultBleTransport = BleTransport<io::BluerIo>;
|
||||
|
||||
#[cfg(any(not(bluer_available), test))]
|
||||
// Android: the Kotlin-radio backend over the byte-bridge.
|
||||
#[cfg(all(target_os = "android", not(test)))]
|
||||
pub type DefaultBleTransport = BleTransport<android_io::AndroidIo>;
|
||||
|
||||
// Everything else (macOS/musl/host) and all test builds: the in-memory mock.
|
||||
#[cfg(any(all(not(bluer_available), not(target_os = "android")), test))]
|
||||
pub type DefaultBleTransport = BleTransport<io::MockBleIo>;
|
||||
|
||||
// ============================================================================
|
||||
@@ -364,9 +382,15 @@ impl<I: BleIo> BleTransport<I> {
|
||||
}
|
||||
};
|
||||
|
||||
// One buffering reader per connection, shared by the pubkey exchange
|
||||
// and the receive loop so coalesced bytes are never dropped.
|
||||
let recv_mtu = stream.recv_mtu();
|
||||
let stream = Arc::new(stream);
|
||||
let mut reader = BleStreamRead::new(Arc::clone(&stream), recv_mtu);
|
||||
|
||||
// Pre-handshake pubkey exchange (temporary, pre-XX)
|
||||
if let Some(ref our_pubkey) = self.local_pubkey {
|
||||
match pubkey_exchange(&stream, our_pubkey).await {
|
||||
match pubkey_exchange(&mut reader, our_pubkey).await {
|
||||
Ok(peer_pubkey) => {
|
||||
debug!(addr = %addr, "BLE outbound pubkey exchange complete");
|
||||
self.discovery_buffer
|
||||
@@ -379,24 +403,26 @@ impl<I: BleIo> BleTransport<I> {
|
||||
}
|
||||
}
|
||||
|
||||
self.promote_connection(addr, &ble_addr, stream).await
|
||||
self.promote_connection(addr, &ble_addr, stream, reader)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Promote a newly established stream into the connection pool.
|
||||
///
|
||||
/// Spawns the receive loop and inserts into the pool with eviction.
|
||||
/// Spawns the receive loop (driven by `reader`) and inserts into the pool
|
||||
/// with eviction.
|
||||
async fn promote_connection(
|
||||
&self,
|
||||
addr: &TransportAddr,
|
||||
ble_addr: &BleAddr,
|
||||
stream: I::Stream,
|
||||
stream: Arc<I::Stream>,
|
||||
reader: BleStreamRead<I::Stream>,
|
||||
) -> Result<(), TransportError> {
|
||||
let send_mtu = stream.send_mtu();
|
||||
let recv_mtu = stream.recv_mtu();
|
||||
let stream = Arc::new(stream);
|
||||
|
||||
let recv_task = tokio::spawn(receive_loop(
|
||||
Arc::clone(&stream),
|
||||
reader,
|
||||
addr.clone(),
|
||||
Arc::clone(&self.pool),
|
||||
self.packet_tx.clone(),
|
||||
@@ -484,9 +510,16 @@ impl<I: BleIo> BleTransport<I> {
|
||||
|
||||
match result {
|
||||
Ok(Ok(stream)) => {
|
||||
let send_mtu = stream.send_mtu();
|
||||
let recv_mtu = stream.recv_mtu();
|
||||
let stream = Arc::new(stream);
|
||||
// Shared reader: pubkey exchange + receive loop read the
|
||||
// same buffer so coalesced bytes survive the handoff.
|
||||
let mut reader = BleStreamRead::new(Arc::clone(&stream), recv_mtu);
|
||||
|
||||
// Pre-handshake pubkey exchange (temporary, pre-XX)
|
||||
if let Some(ref our_pubkey) = local_pubkey {
|
||||
match pubkey_exchange(&stream, our_pubkey).await {
|
||||
match pubkey_exchange(&mut reader, our_pubkey).await {
|
||||
Ok(peer_pubkey) => {
|
||||
debug!(addr = %addr_clone, "BLE outbound pubkey exchange complete");
|
||||
discovery_buffer.add_peer_with_pubkey(&ble_addr, peer_pubkey);
|
||||
@@ -501,12 +534,8 @@ impl<I: BleIo> BleTransport<I> {
|
||||
}
|
||||
}
|
||||
|
||||
let send_mtu = stream.send_mtu();
|
||||
let recv_mtu = stream.recv_mtu();
|
||||
let stream = Arc::new(stream);
|
||||
|
||||
let recv_task = tokio::spawn(receive_loop(
|
||||
Arc::clone(&stream),
|
||||
reader,
|
||||
addr_clone.clone(),
|
||||
Arc::clone(&pool),
|
||||
packet_tx,
|
||||
@@ -677,31 +706,38 @@ const PUBKEY_EXCHANGE_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
/// Exchange public keys over a newly established L2CAP connection.
|
||||
///
|
||||
/// Both sides send `[0x00][our_pubkey:32]` and receive the peer's.
|
||||
/// Both sides send `[0x00][our_pubkey:32]` and receive the peer's. Reads go
|
||||
/// through the connection's [`BleStreamRead`] buffer so a peer that fragments
|
||||
/// the 33-byte message (stream-oriented backends) is read correctly, and any
|
||||
/// bytes it coalesced after the pubkey stay buffered for the receive loop.
|
||||
/// Returns the peer's XOnlyPublicKey on success.
|
||||
async fn pubkey_exchange<S: BleStream>(
|
||||
stream: &S,
|
||||
reader: &mut BleStreamRead<S>,
|
||||
local_pubkey: &[u8; 32],
|
||||
) -> Result<XOnlyPublicKey, TransportError> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
// Send our pubkey
|
||||
let mut msg = [0u8; PUBKEY_EXCHANGE_SIZE];
|
||||
msg[0] = PUBKEY_EXCHANGE_PREFIX;
|
||||
msg[1..].copy_from_slice(local_pubkey);
|
||||
stream.send(&msg).await?;
|
||||
reader.stream().send(&msg).await?;
|
||||
|
||||
// Receive peer's pubkey (with timeout to prevent indefinite blocking)
|
||||
// Receive peer's pubkey (with timeout to prevent indefinite blocking).
|
||||
// read_exact reassembles across fragmented recvs and leaves any trailing
|
||||
// bytes in the reader's buffer.
|
||||
let mut buf = [0u8; PUBKEY_EXCHANGE_SIZE];
|
||||
let timeout = std::time::Duration::from_secs(PUBKEY_EXCHANGE_TIMEOUT_SECS);
|
||||
let n = match tokio::time::timeout(timeout, stream.recv(&mut buf)).await {
|
||||
Ok(result) => result?,
|
||||
match tokio::time::timeout(timeout, reader.read_exact(&mut buf)).await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => {
|
||||
return Err(TransportError::RecvFailed(format!(
|
||||
"pubkey exchange: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
Err(_) => return Err(TransportError::Timeout),
|
||||
};
|
||||
if n != PUBKEY_EXCHANGE_SIZE {
|
||||
return Err(TransportError::RecvFailed(format!(
|
||||
"pubkey exchange: expected {} bytes, got {}",
|
||||
PUBKEY_EXCHANGE_SIZE, n
|
||||
)));
|
||||
}
|
||||
if buf[0] != PUBKEY_EXCHANGE_PREFIX {
|
||||
return Err(TransportError::RecvFailed(format!(
|
||||
"pubkey exchange: bad prefix 0x{:02X}",
|
||||
@@ -751,10 +787,13 @@ async fn accept_loop<A>(
|
||||
|
||||
let send_mtu = stream.send_mtu();
|
||||
let recv_mtu = stream.recv_mtu();
|
||||
let stream = Arc::new(stream);
|
||||
// Shared reader for pubkey exchange + receive loop.
|
||||
let mut reader = BleStreamRead::new(Arc::clone(&stream), recv_mtu);
|
||||
|
||||
// Pre-handshake pubkey exchange (temporary, pre-XX)
|
||||
if let Some(ref our_pubkey) = local_pubkey {
|
||||
match pubkey_exchange(&stream, our_pubkey).await {
|
||||
match pubkey_exchange(&mut reader, our_pubkey).await {
|
||||
Ok(peer_pubkey) => {
|
||||
debug!(addr = %ta, "BLE inbound pubkey exchange complete");
|
||||
discovery_buffer.add_peer_with_pubkey(&addr, peer_pubkey);
|
||||
@@ -780,11 +819,9 @@ async fn accept_loop<A>(
|
||||
}
|
||||
}
|
||||
|
||||
let stream = Arc::new(stream);
|
||||
|
||||
// Spawn receive loop
|
||||
let recv_task = tokio::spawn(receive_loop(
|
||||
Arc::clone(&stream),
|
||||
reader,
|
||||
ta.clone(),
|
||||
Arc::clone(&pool),
|
||||
packet_tx.clone(),
|
||||
@@ -829,8 +866,14 @@ async fn accept_loop<A>(
|
||||
}
|
||||
|
||||
/// Receive loop: reads packets from a BLE stream and delivers to node.
|
||||
async fn receive_loop<S: BleStream>(
|
||||
stream: Arc<S>,
|
||||
///
|
||||
/// Recovers FIPS packet boundaries from the byte stream via the shared FMP
|
||||
/// framer ([`read_fmp_packet`]) rather than assuming one `recv` is one packet.
|
||||
/// L2CAP only preserves SDU boundaries on BlueZ (SeqPacket); stream-oriented
|
||||
/// backends (Android, CoreBluetooth) fragment and coalesce, so the framer's
|
||||
/// length-prefixed reads are what make delivery reliable across platforms.
|
||||
async fn receive_loop<S: BleStream + 'static>(
|
||||
mut reader: BleStreamRead<S>,
|
||||
addr: TransportAddr,
|
||||
pool: Arc<Mutex<ConnectionPool<Arc<S>>>>,
|
||||
packet_tx: PacketTx,
|
||||
@@ -838,21 +881,21 @@ async fn receive_loop<S: BleStream>(
|
||||
stats: Arc<BleStats>,
|
||||
recv_mtu: u16,
|
||||
) {
|
||||
let mut buf = vec![0u8; recv_mtu as usize];
|
||||
loop {
|
||||
match stream.recv(&mut buf).await {
|
||||
Ok(0) => {
|
||||
debug!(addr = %addr, "BLE connection closed by peer");
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
stats.record_recv(n);
|
||||
let packet = ReceivedPacket::new(transport_id, addr.clone(), buf[..n].to_vec());
|
||||
if packet_tx.send(packet).await.is_err() {
|
||||
match read_fmp_packet(&mut reader, recv_mtu).await {
|
||||
Ok(packet) => {
|
||||
stats.record_recv(packet.len());
|
||||
let received = ReceivedPacket::new(transport_id, addr.clone(), packet);
|
||||
if packet_tx.send(received).await.is_err() {
|
||||
trace!("BLE packet_tx closed, stopping receive loop");
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Clean peer close (EOF at a packet boundary) is expected, not an error.
|
||||
Err(StreamError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
debug!(addr = %addr, "BLE connection closed by peer");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(addr = %addr, error = %e, "BLE receive error");
|
||||
stats.record_recv_error();
|
||||
@@ -986,7 +1029,12 @@ async fn scan_probe_loop<I: io::BleIo>(
|
||||
|
||||
// Pubkey exchange, then promote connection to pool
|
||||
let ta = addr.to_transport_addr();
|
||||
match pubkey_exchange(&stream, &our_pubkey).await {
|
||||
let send_mtu = stream.send_mtu();
|
||||
let recv_mtu = stream.recv_mtu();
|
||||
let stream = Arc::new(stream);
|
||||
// Shared reader for pubkey exchange + receive loop.
|
||||
let mut reader = BleStreamRead::new(Arc::clone(&stream), recv_mtu);
|
||||
match pubkey_exchange(&mut reader, &our_pubkey).await {
|
||||
Ok(peer_pubkey) => {
|
||||
debug!(addr = %addr, "BLE probe complete");
|
||||
|
||||
@@ -1005,12 +1053,8 @@ async fn scan_probe_loop<I: io::BleIo>(
|
||||
}
|
||||
|
||||
// Promote connection to pool — no second L2CAP connect needed
|
||||
let send_mtu = stream.send_mtu();
|
||||
let recv_mtu = stream.recv_mtu();
|
||||
let stream = Arc::new(stream);
|
||||
|
||||
let recv_task = tokio::spawn(receive_loop(
|
||||
Arc::clone(&stream),
|
||||
reader,
|
||||
ta.clone(),
|
||||
Arc::clone(&pool),
|
||||
packet_tx.clone(),
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Per-peer PSM discovery (BLE v2).
|
||||
//!
|
||||
//! On the platforms that matter the L2CAP listener PSM is **OS-assigned**, not
|
||||
//! chosen by the app (Android `listenUsingInsecureL2capChannel`, macOS
|
||||
//! `CBPeripheralManager.publishL2CAPChannel`); only BlueZ can bind a fixed one.
|
||||
//! So rather than relying on the fixed `DEFAULT_PSM` (0x0085), every node
|
||||
//! **advertises its own listener PSM** as a 16-bit little-endian value in the
|
||||
//! service-data field keyed on the FIPS service UUID, and every dialer **reads a
|
||||
//! peer's advertised PSM before `connect()`**. The fixed-PSM assumption was a
|
||||
//! BlueZ quirk; this makes discovery symmetric across all backends.
|
||||
//!
|
||||
//! This module holds the platform-agnostic pieces shared by every `BleIo`
|
||||
//! backend (`BluerIo`, `BluestIo`, `AndroidIo`): the service-data **codec** and
|
||||
//! the short-lived **`BleAddr → PSM` map**. The per-backend advertise/scan/dial
|
||||
//! wiring lives in the backends. See
|
||||
//! [`docs/reference/ble-wire.md`](../../../../docs/reference/ble-wire.md) and
|
||||
//! [`docs/design/ble-interop.md`](../../../../docs/design/ble-interop.md).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
|
||||
use super::addr::BleAddr;
|
||||
|
||||
/// Encode a listener PSM as the 2-byte little-endian service-data payload
|
||||
/// advertised under the FIPS service UUID.
|
||||
///
|
||||
/// The legacy advertising PDU caps at ~31 bytes, so a 128-bit UUID + this
|
||||
/// 2-byte value is the tight, legacy-safe layout (see ble-wire.md).
|
||||
pub fn encode_psm(psm: u16) -> [u8; 2] {
|
||||
psm.to_le_bytes()
|
||||
}
|
||||
|
||||
/// Decode a peer's advertised PSM from its FIPS service-data payload.
|
||||
///
|
||||
/// Returns `None` when fewer than 2 bytes are present — e.g. a legacy,
|
||||
/// UUID-only advert that carries no PSM, for which the dialer falls back to
|
||||
/// [`DEFAULT_PSM`](super::DEFAULT_PSM). Any bytes beyond the first two are
|
||||
/// ignored, so the encoding can grow without breaking older readers.
|
||||
pub fn decode_psm(data: &[u8]) -> Option<u16> {
|
||||
match data {
|
||||
[lo, hi, ..] => Some(u16::from_le_bytes([*lo, *hi])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Short-lived map of discovered peer addresses to their advertised listener
|
||||
/// PSM, populated by the scan loop and consulted by `connect()`.
|
||||
///
|
||||
/// Keyed on [`BleAddr`], which **rotates** with MAC randomization, so entries
|
||||
/// are transient: they are re-learned each scan cycle rather than cached
|
||||
/// durably. A stale entry merely causes one dial failure and a re-probe on the
|
||||
/// next scan tick (see "Why MAC randomization is harmless" in ble-interop.md).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PsmMap {
|
||||
inner: Mutex<HashMap<BleAddr, u16>>,
|
||||
/// The most-recently-learned PSM across all peers. A node advertises exactly
|
||||
/// one OS-assigned listener PSM, so when an exact-address lookup misses — the
|
||||
/// common case, since the peer's RPA rotates between the scan that learned the
|
||||
/// PSM and the dial — this is a far better guess than the fixed legacy default,
|
||||
/// which no one listens on. 0 = unset.
|
||||
last: AtomicU16,
|
||||
}
|
||||
|
||||
impl PsmMap {
|
||||
/// Create an empty map.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Record a peer's advertised PSM, learned from a scan advert. A later
|
||||
/// advert for the same address overwrites the earlier value.
|
||||
pub fn learn(&self, addr: &BleAddr, psm: u16) {
|
||||
self.lock().insert(addr.clone(), psm);
|
||||
if psm != 0 {
|
||||
self.last.store(psm, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a peer's learned PSM, if one has been seen this scan cycle.
|
||||
pub fn lookup(&self, addr: &BleAddr) -> Option<u16> {
|
||||
self.lock().get(addr).copied()
|
||||
}
|
||||
|
||||
/// Resolve the PSM to dial for `addr`: the learned per-peer PSM if known,
|
||||
/// otherwise `fallback` (the configured PSM, else the legacy
|
||||
/// [`DEFAULT_PSM`](super::DEFAULT_PSM)).
|
||||
pub fn resolve(&self, addr: &BleAddr, fallback: u16) -> u16 {
|
||||
if let Some(psm) = self.lookup(addr) {
|
||||
return psm;
|
||||
}
|
||||
// Exact-address miss — almost always because the peer's MAC rotated
|
||||
// between the scan that learned its PSM and this dial. Use the most
|
||||
// recently learned PSM rather than the legacy default (never a real
|
||||
// listener); a wrong guess only costs a dial-retry and a re-probe.
|
||||
match self.last.load(Ordering::Relaxed) {
|
||||
0 => fallback,
|
||||
last => last,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a single learned entry (e.g. after a dial failure).
|
||||
pub fn forget(&self, addr: &BleAddr) {
|
||||
self.lock().remove(addr);
|
||||
}
|
||||
|
||||
/// Drop all learned entries. Called at the start of a scan cycle, since
|
||||
/// addresses rotate and a dropped PSM only costs a dial-retry.
|
||||
pub fn clear(&self) {
|
||||
self.lock().clear();
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<BleAddr, u16>> {
|
||||
self.inner.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::transport::ble::DEFAULT_PSM;
|
||||
|
||||
fn addr(n: u8) -> BleAddr {
|
||||
BleAddr {
|
||||
adapter: "ble0".to_string(),
|
||||
device: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, n],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_roundtrips_little_endian() {
|
||||
// 0x0085 -> [0x85, 0x00]; explicit LE byte order matches the wire spec.
|
||||
assert_eq!(encode_psm(0x0085), [0x85, 0x00]);
|
||||
assert_eq!(encode_psm(0x1234), [0x34, 0x12]);
|
||||
for psm in [0u16, 1, 0x0085, 0x0080, 0x00FF, 0x1234, u16::MAX] {
|
||||
assert_eq!(decode_psm(&encode_psm(psm)), Some(psm));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_short_payload_but_ignores_trailing() {
|
||||
assert_eq!(decode_psm(&[]), None); // legacy UUID-only advert
|
||||
assert_eq!(decode_psm(&[0x85]), None); // truncated
|
||||
// Forward-compatible: trailing bytes beyond the PSM are ignored.
|
||||
assert_eq!(decode_psm(&[0x85, 0x00, 0xFF, 0xFF]), Some(0x0085));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn learn_lookup_and_overwrite() {
|
||||
let map = PsmMap::new();
|
||||
assert_eq!(map.lookup(&addr(1)), None);
|
||||
|
||||
map.learn(&addr(1), 0x0091);
|
||||
assert_eq!(map.lookup(&addr(1)), Some(0x0091));
|
||||
|
||||
// A later advert for the same address overwrites.
|
||||
map.learn(&addr(1), 0x00A0);
|
||||
assert_eq!(map.lookup(&addr(1)), Some(0x00A0));
|
||||
|
||||
// Distinct addresses are independent.
|
||||
map.learn(&addr(2), 0x00B0);
|
||||
assert_eq!(map.lookup(&addr(1)), Some(0x00A0));
|
||||
assert_eq!(map.lookup(&addr(2)), Some(0x00B0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_learned_then_falls_back() {
|
||||
let map = PsmMap::new();
|
||||
// No learned PSM yet -> legacy default.
|
||||
assert_eq!(map.resolve(&addr(1), DEFAULT_PSM), DEFAULT_PSM);
|
||||
|
||||
map.learn(&addr(1), 0x0091);
|
||||
assert_eq!(map.resolve(&addr(1), DEFAULT_PSM), 0x0091);
|
||||
// An unseen address (e.g. the peer's MAC rotated since we learned its PSM)
|
||||
// dials the most-recently-learned PSM, not the legacy default.
|
||||
assert_eq!(map.resolve(&addr(9), DEFAULT_PSM), 0x0091);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_and_clear_drop_entries() {
|
||||
let map = PsmMap::new();
|
||||
map.learn(&addr(1), 0x0091);
|
||||
map.learn(&addr(2), 0x0092);
|
||||
|
||||
map.forget(&addr(1));
|
||||
assert_eq!(map.lookup(&addr(1)), None);
|
||||
assert_eq!(map.lookup(&addr(2)), Some(0x0092));
|
||||
|
||||
map.clear();
|
||||
assert_eq!(map.lookup(&addr(2)), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! `AsyncRead` adapter over a datagram-shaped [`BleStream`].
|
||||
//!
|
||||
//! L2CAP delivers different boundary guarantees per platform:
|
||||
//!
|
||||
//! - **BlueZ** (`SOCK_SEQPACKET`) preserves SDU boundaries — one `recv` is
|
||||
//! exactly one FIPS packet.
|
||||
//! - **Android** (`BluetoothSocket` input stream) and **CoreBluetooth** are
|
||||
//! byte-stream oriented — a `recv` may return a fragment of a packet, or
|
||||
//! several packets coalesced.
|
||||
//!
|
||||
//! FIPS packets are self-delimiting via the 4-byte FMP common prefix, so the
|
||||
//! shared framer [`crate::transport::tcp::stream::read_fmp_packet`] can recover
|
||||
//! boundaries from any byte stream. This adapter turns a [`BleStream`] (whose
|
||||
//! `recv` fills a `&mut [u8]`) into the [`AsyncRead`] that framer expects. On a
|
||||
//! SeqPacket backend it's a no-op pass-through; on a stream backend it
|
||||
//! reassembles. Either way the layer above sees one whole packet per read.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use tokio::io::{AsyncRead, ReadBuf};
|
||||
|
||||
use super::io::BleStream;
|
||||
|
||||
/// An in-flight `recv`: owns its scratch buffer and yields an owned `Vec`, so
|
||||
/// the future is `'static` and can live across `poll_read` calls.
|
||||
type PendingRecv = Pin<Box<dyn Future<Output = std::io::Result<Vec<u8>>> + Send>>;
|
||||
|
||||
/// Buffers a [`BleStream`] into an [`AsyncRead`] byte stream.
|
||||
pub struct BleStreamRead<S: BleStream + 'static> {
|
||||
stream: Arc<S>,
|
||||
/// Per-`recv` scratch size; also the framer's MTU bound.
|
||||
mtu: u16,
|
||||
/// Bytes from the last `recv` not yet consumed by the framer.
|
||||
leftover: Vec<u8>,
|
||||
/// Read cursor into `leftover`.
|
||||
pos: usize,
|
||||
/// In-flight `recv`, if one is underway.
|
||||
pending: Option<PendingRecv>,
|
||||
}
|
||||
|
||||
impl<S: BleStream + 'static> BleStreamRead<S> {
|
||||
/// Wrap a stream. `mtu` is the per-`recv` scratch size (use the channel's
|
||||
/// recv MTU).
|
||||
pub fn new(stream: Arc<S>, mtu: u16) -> Self {
|
||||
Self {
|
||||
stream,
|
||||
mtu: mtu.max(1),
|
||||
leftover: Vec::new(),
|
||||
pos: 0,
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The wrapped stream, for sending on the same channel (e.g. the pubkey
|
||||
/// exchange writes here while reads come through the buffer).
|
||||
pub fn stream(&self) -> &S {
|
||||
&self.stream
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: BleStream + 'static> AsyncRead for BleStreamRead<S> {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
dst: &mut ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
// BleStreamRead is Unpin (all fields are), so get_mut is sound.
|
||||
let this = self.get_mut();
|
||||
loop {
|
||||
// Serve buffered bytes first.
|
||||
if this.pos < this.leftover.len() {
|
||||
let avail = &this.leftover[this.pos..];
|
||||
let n = avail.len().min(dst.remaining());
|
||||
dst.put_slice(&avail[..n]);
|
||||
this.pos += n;
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
// Buffer drained: pull the next datagram. The future owns its
|
||||
// scratch and returns it truncated, so it captures only `Arc<S>`.
|
||||
if this.pending.is_none() {
|
||||
let stream = Arc::clone(&this.stream);
|
||||
let mtu = this.mtu as usize;
|
||||
this.pending = Some(Box::pin(async move {
|
||||
let mut scratch = vec![0u8; mtu];
|
||||
let n = stream
|
||||
.recv(&mut scratch)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
scratch.truncate(n);
|
||||
Ok(scratch)
|
||||
}));
|
||||
}
|
||||
|
||||
match this.pending.as_mut().unwrap().as_mut().poll(cx) {
|
||||
Poll::Ready(Ok(buf)) => {
|
||||
this.pending = None;
|
||||
// A zero-length recv is the BleStream peer-closed signal;
|
||||
// leaving `dst` unfilled surfaces as EOF to the framer.
|
||||
if buf.is_empty() {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
this.leftover = buf;
|
||||
this.pos = 0;
|
||||
// loop to copy out
|
||||
}
|
||||
Poll::Ready(Err(e)) => {
|
||||
this.pending = None;
|
||||
return Poll::Ready(Err(e));
|
||||
}
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::transport::ble::addr::BleAddr;
|
||||
use crate::transport::ble::io::MockBleStream;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
fn addr(n: u8) -> BleAddr {
|
||||
BleAddr {
|
||||
adapter: "hci0".to_string(),
|
||||
device: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, n],
|
||||
}
|
||||
}
|
||||
|
||||
/// Several small recvs reassemble into one read_exact.
|
||||
#[tokio::test]
|
||||
async fn reassembles_fragmented_recv() {
|
||||
let (a, b) = MockBleStream::pair(addr(1), addr(2), 2048);
|
||||
// Peer sends three fragments that together form one 10-byte message.
|
||||
a.send(b"abc").await.unwrap();
|
||||
a.send(b"defg").await.unwrap();
|
||||
a.send(b"hij").await.unwrap();
|
||||
|
||||
let mut reader = BleStreamRead::new(Arc::new(b), 2048);
|
||||
let mut out = [0u8; 10];
|
||||
reader.read_exact(&mut out).await.unwrap();
|
||||
assert_eq!(&out, b"abcdefghij");
|
||||
}
|
||||
|
||||
/// A recv carrying several packets is served across multiple reads without
|
||||
/// dropping the tail (the coalescing case).
|
||||
#[tokio::test]
|
||||
async fn serves_coalesced_recv_in_pieces() {
|
||||
let (a, b) = MockBleStream::pair(addr(1), addr(2), 2048);
|
||||
a.send(b"0123456789").await.unwrap();
|
||||
|
||||
let mut reader = BleStreamRead::new(Arc::new(b), 2048);
|
||||
let mut first = [0u8; 4];
|
||||
reader.read_exact(&mut first).await.unwrap();
|
||||
assert_eq!(&first, b"0123");
|
||||
let mut rest = [0u8; 6];
|
||||
reader.read_exact(&mut rest).await.unwrap();
|
||||
assert_eq!(&rest, b"456789");
|
||||
}
|
||||
|
||||
/// A closed stream surfaces as EOF (read_exact errors with UnexpectedEof).
|
||||
#[tokio::test]
|
||||
async fn closed_stream_is_eof() {
|
||||
let (a, b) = MockBleStream::pair(addr(1), addr(2), 2048);
|
||||
drop(a); // peer closes
|
||||
let mut reader = BleStreamRead::new(Arc::new(b), 2048);
|
||||
let mut out = [0u8; 4];
|
||||
let err = reader.read_exact(&mut out).await.unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
|
||||
}
|
||||
}
|
||||
+83
-44
@@ -11,15 +11,15 @@ pub mod tcp;
|
||||
pub mod tor;
|
||||
pub mod udp;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub mod ethernet;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
pub mod ble;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
use ble::DefaultBleTransport;
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use ethernet::EthernetTransport;
|
||||
#[cfg(test)]
|
||||
use loopback::LoopbackTransport;
|
||||
@@ -280,6 +280,27 @@ impl fmt::Display for TransportType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Link preference for a transport type — higher is preferred for carrying a
|
||||
/// peer's traffic when that peer is reachable over more than one transport at
|
||||
/// once. Used by the roaming cutover (`ActivePeer::roam_current_addr`) so a
|
||||
/// peer on a fast transport (Wi-Fi Aware / UDP) is not dragged onto a slow
|
||||
/// one (BLE) by a stray packet. Equal preferences reduce to plain
|
||||
/// last-authenticated-packet-wins roaming, so single-transport deployments are
|
||||
/// unaffected.
|
||||
pub fn transport_link_preference(name: &str) -> u8 {
|
||||
match name {
|
||||
// High-bandwidth IP transports (incl. the Wi-Fi Aware data path, which
|
||||
// rides the UDP transport).
|
||||
"wifi" | "udp" | "tcp" => 100,
|
||||
"ethernet" => 90,
|
||||
// Always-on but low-bandwidth control link.
|
||||
"ble" => 50,
|
||||
// Anonymity overlays: high latency.
|
||||
"tor" | "nym" => 40,
|
||||
_ => 80,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Transport State
|
||||
// ============================================================================
|
||||
@@ -894,7 +915,7 @@ pub enum TransportHandle {
|
||||
/// UDP/IP transport.
|
||||
Udp(UdpTransport),
|
||||
/// Raw Ethernet transport.
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
Ethernet(EthernetTransport),
|
||||
/// TCP/IP transport.
|
||||
Tcp(TcpTransport),
|
||||
@@ -903,7 +924,7 @@ pub enum TransportHandle {
|
||||
/// Nym mixnet transport (via SOCKS5).
|
||||
Nym(NymTransport),
|
||||
/// BLE L2CAP transport.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
Ble(DefaultBleTransport),
|
||||
/// In-process loopback transport (test harness only).
|
||||
#[cfg(test)]
|
||||
@@ -915,12 +936,12 @@ impl TransportHandle {
|
||||
pub async fn start(&mut self) -> Result<(), TransportError> {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.start_async().await,
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => t.start_async().await,
|
||||
TransportHandle::Tcp(t) => t.start_async().await,
|
||||
TransportHandle::Tor(t) => t.start_async().await,
|
||||
TransportHandle::Nym(t) => t.start_async().await,
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.start_async().await,
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(t) => t.start_async().await,
|
||||
@@ -931,12 +952,12 @@ impl TransportHandle {
|
||||
pub async fn stop(&mut self) -> Result<(), TransportError> {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.stop_async().await,
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => t.stop_async().await,
|
||||
TransportHandle::Tcp(t) => t.stop_async().await,
|
||||
TransportHandle::Tor(t) => t.stop_async().await,
|
||||
TransportHandle::Nym(t) => t.stop_async().await,
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.stop_async().await,
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(t) => t.stop_async().await,
|
||||
@@ -947,12 +968,12 @@ impl TransportHandle {
|
||||
pub async fn send(&self, addr: &TransportAddr, data: &[u8]) -> Result<usize, TransportError> {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.send_async(addr, data).await,
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => t.send_async(addr, data).await,
|
||||
TransportHandle::Tcp(t) => t.send_async(addr, data).await,
|
||||
TransportHandle::Tor(t) => t.send_async(addr, data).await,
|
||||
TransportHandle::Nym(t) => t.send_async(addr, data).await,
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.send_async(addr, data).await,
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(t) => t.send_async(addr, data).await,
|
||||
@@ -963,12 +984,12 @@ impl TransportHandle {
|
||||
pub fn transport_id(&self) -> TransportId {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.transport_id(),
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => t.transport_id(),
|
||||
TransportHandle::Tcp(t) => t.transport_id(),
|
||||
TransportHandle::Tor(t) => t.transport_id(),
|
||||
TransportHandle::Nym(t) => t.transport_id(),
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.transport_id(),
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(t) => t.transport_id(),
|
||||
@@ -979,12 +1000,12 @@ impl TransportHandle {
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.name(),
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => t.name(),
|
||||
TransportHandle::Tcp(t) => t.name(),
|
||||
TransportHandle::Tor(t) => t.name(),
|
||||
TransportHandle::Nym(t) => t.name(),
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.name(),
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(_) => None,
|
||||
@@ -995,12 +1016,12 @@ impl TransportHandle {
|
||||
pub fn transport_type(&self) -> &TransportType {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.transport_type(),
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => t.transport_type(),
|
||||
TransportHandle::Tcp(t) => t.transport_type(),
|
||||
TransportHandle::Tor(t) => t.transport_type(),
|
||||
TransportHandle::Nym(t) => t.transport_type(),
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.transport_type(),
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(t) => t.transport_type(),
|
||||
@@ -1011,12 +1032,12 @@ impl TransportHandle {
|
||||
pub fn state(&self) -> TransportState {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.state(),
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => t.state(),
|
||||
TransportHandle::Tcp(t) => t.state(),
|
||||
TransportHandle::Tor(t) => t.state(),
|
||||
TransportHandle::Nym(t) => t.state(),
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.state(),
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(t) => t.state(),
|
||||
@@ -1027,12 +1048,12 @@ impl TransportHandle {
|
||||
pub fn mtu(&self) -> u16 {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.mtu(),
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => t.mtu(),
|
||||
TransportHandle::Tcp(t) => t.mtu(),
|
||||
TransportHandle::Tor(t) => t.mtu(),
|
||||
TransportHandle::Nym(t) => t.mtu(),
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.mtu(),
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(t) => t.mtu(),
|
||||
@@ -1046,12 +1067,12 @@ impl TransportHandle {
|
||||
pub fn link_mtu(&self, addr: &TransportAddr) -> u16 {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.link_mtu(addr),
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => t.link_mtu(addr),
|
||||
TransportHandle::Tcp(t) => t.link_mtu(addr),
|
||||
TransportHandle::Tor(t) => t.link_mtu(addr),
|
||||
TransportHandle::Nym(t) => t.link_mtu(addr),
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.link_mtu(addr),
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(t) => t.link_mtu(addr),
|
||||
@@ -1062,12 +1083,30 @@ impl TransportHandle {
|
||||
pub fn local_addr(&self) -> Option<std::net::SocketAddr> {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.local_addr(),
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(_) => None,
|
||||
TransportHandle::Tcp(t) => t.local_addr(),
|
||||
TransportHandle::Tor(_) => None,
|
||||
TransportHandle::Nym(_) => None,
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(_) => None,
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw fd of the bound socket (UDP only, returns None for other
|
||||
/// transports). See [`crate::transport::udp::UdpTransport::raw_fd`].
|
||||
#[cfg(unix)]
|
||||
pub fn raw_fd(&self) -> Option<std::os::unix::io::RawFd> {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.raw_fd(),
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(_) => None,
|
||||
TransportHandle::Tcp(_) => None,
|
||||
TransportHandle::Tor(_) => None,
|
||||
TransportHandle::Nym(_) => None,
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(_) => None,
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(_) => None,
|
||||
@@ -1078,12 +1117,12 @@ impl TransportHandle {
|
||||
pub fn interface_name(&self) -> Option<&str> {
|
||||
match self {
|
||||
TransportHandle::Udp(_) => None,
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => Some(t.interface_name()),
|
||||
TransportHandle::Tcp(_) => None,
|
||||
TransportHandle::Tor(_) => None,
|
||||
TransportHandle::Nym(_) => None,
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(_) => None,
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(_) => None,
|
||||
@@ -1118,12 +1157,12 @@ impl TransportHandle {
|
||||
pub fn discover(&self) -> Result<Vec<DiscoveredPeer>, TransportError> {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.discover(),
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => t.discover(),
|
||||
TransportHandle::Tcp(t) => t.discover(),
|
||||
TransportHandle::Tor(t) => t.discover(),
|
||||
TransportHandle::Nym(t) => t.discover(),
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.discover(),
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(t) => t.discover(),
|
||||
@@ -1134,12 +1173,12 @@ impl TransportHandle {
|
||||
pub fn auto_connect(&self) -> bool {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.auto_connect(),
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => t.auto_connect(),
|
||||
TransportHandle::Tcp(t) => t.auto_connect(),
|
||||
TransportHandle::Tor(t) => t.auto_connect(),
|
||||
TransportHandle::Nym(t) => t.auto_connect(),
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.auto_connect(),
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(t) => t.auto_connect(),
|
||||
@@ -1150,12 +1189,12 @@ impl TransportHandle {
|
||||
pub fn accept_connections(&self) -> bool {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.accept_connections(),
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => t.accept_connections(),
|
||||
TransportHandle::Tcp(t) => t.accept_connections(),
|
||||
TransportHandle::Tor(t) => t.accept_connections(),
|
||||
TransportHandle::Nym(t) => t.accept_connections(),
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.accept_connections(),
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(t) => t.accept_connections(),
|
||||
@@ -1172,12 +1211,12 @@ impl TransportHandle {
|
||||
pub async fn connect(&self, addr: &TransportAddr) -> Result<(), TransportError> {
|
||||
match self {
|
||||
TransportHandle::Udp(_) => Ok(()), // connectionless
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(_) => Ok(()), // connectionless
|
||||
TransportHandle::Tcp(t) => t.connect_async(addr).await,
|
||||
TransportHandle::Tor(t) => t.connect_async(addr).await,
|
||||
TransportHandle::Nym(t) => t.connect_async(addr).await,
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.connect_async(addr).await,
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(_) => Ok(()), // connectionless
|
||||
@@ -1192,12 +1231,12 @@ impl TransportHandle {
|
||||
pub fn connection_state(&self, addr: &TransportAddr) -> ConnectionState {
|
||||
match self {
|
||||
TransportHandle::Udp(_) => ConnectionState::Connected,
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(_) => ConnectionState::Connected,
|
||||
TransportHandle::Tcp(t) => t.connection_state_sync(addr),
|
||||
TransportHandle::Tor(t) => t.connection_state_sync(addr),
|
||||
TransportHandle::Nym(t) => t.connection_state_sync(addr),
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.connection_state_sync(addr),
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(_) => ConnectionState::Connected,
|
||||
@@ -1211,12 +1250,12 @@ impl TransportHandle {
|
||||
pub async fn close_connection(&self, addr: &TransportAddr) {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.close_connection(addr),
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => t.close_connection(addr),
|
||||
TransportHandle::Tcp(t) => t.close_connection_async(addr).await,
|
||||
TransportHandle::Tor(t) => t.close_connection_async(addr).await,
|
||||
TransportHandle::Nym(t) => t.close_connection_async(addr).await,
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => t.close_connection_async(addr).await,
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(_) => {} // connectionless no-op
|
||||
@@ -1236,12 +1275,12 @@ impl TransportHandle {
|
||||
pub fn congestion(&self) -> TransportCongestion {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.congestion(),
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(_) => TransportCongestion::default(),
|
||||
TransportHandle::Tcp(_) => TransportCongestion::default(),
|
||||
TransportHandle::Tor(_) => TransportCongestion::default(),
|
||||
TransportHandle::Nym(_) => TransportCongestion::default(),
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(_) => TransportCongestion::default(),
|
||||
#[cfg(test)]
|
||||
TransportHandle::Loopback(_) => TransportCongestion::default(),
|
||||
@@ -1256,7 +1295,7 @@ impl TransportHandle {
|
||||
TransportHandle::Udp(t) => {
|
||||
serde_json::to_value(t.stats().snapshot()).unwrap_or_default()
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
TransportHandle::Ethernet(t) => {
|
||||
let snap = t.stats().snapshot();
|
||||
serde_json::json!({
|
||||
@@ -1281,7 +1320,7 @@ impl TransportHandle {
|
||||
TransportHandle::Nym(t) => {
|
||||
serde_json::to_value(t.stats().snapshot()).unwrap_or_default()
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(ble_available)]
|
||||
TransportHandle::Ble(t) => {
|
||||
serde_json::to_value(t.stats().snapshot()).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -89,6 +89,17 @@ impl UdpTransport {
|
||||
self.local_addr
|
||||
}
|
||||
|
||||
/// Raw fd of the bound socket, so an embedder can apply socket options
|
||||
/// this transport does not manage itself — notably binding it to one of
|
||||
/// several networks a host OS offers, when destination-based routing alone
|
||||
/// does not reach a peer (see [`crate::Node::enable_app_owned_udp_fd`]).
|
||||
/// `None` before start.
|
||||
#[cfg(unix)]
|
||||
pub fn raw_fd(&self) -> Option<std::os::unix::io::RawFd> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
self.socket.as_ref().map(|s| s.as_raw_fd())
|
||||
}
|
||||
|
||||
/// Configured recv buffer size — used when opening per-peer
|
||||
/// `ConnectedPeerSocket`s so they get the same buffer ceiling as
|
||||
/// the wildcard listen socket.
|
||||
|
||||
@@ -606,7 +606,17 @@ mod platform {
|
||||
unsafe { &*(storage as *const _ as *const libc::sockaddr_in6) };
|
||||
let ip = std::net::Ipv6Addr::from(addr.sin6_addr.s6_addr);
|
||||
let port = u16::from_be(addr.sin6_port);
|
||||
Ok(SocketAddr::from((ip, port)))
|
||||
// Preserve sin6_scope_id: a link-local source (fe80::/10) is
|
||||
// only routable together with its interface scope, so dropping
|
||||
// it here breaks replies to a link-local peer (e.g. a Wi-Fi
|
||||
// Aware NDP interface). Non-link-local sources carry scope 0,
|
||||
// for which SocketAddrV6 is identical to the unscoped form.
|
||||
Ok(SocketAddr::V6(std::net::SocketAddrV6::new(
|
||||
ip,
|
||||
port,
|
||||
0,
|
||||
addr.sin6_scope_id,
|
||||
)))
|
||||
}
|
||||
family => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
|
||||
+1
-1
@@ -301,7 +301,7 @@ fn extract_pktinfo_ifindex(msg: &libc::msghdr) -> Option<u32> {
|
||||
if cmsg.cmsg_level == libc::IPPROTO_IPV6 && cmsg.cmsg_type == libc::IPV6_PKTINFO {
|
||||
let data_ptr = unsafe { libc::CMSG_DATA(cmsg_ptr) } as *const libc::in6_pktinfo;
|
||||
let pktinfo: libc::in6_pktinfo = unsafe { std::ptr::read_unaligned(data_ptr) };
|
||||
return Some(pktinfo.ipi6_ifindex);
|
||||
return Some(pktinfo.ipi6_ifindex as u32);
|
||||
}
|
||||
cmsg_ptr = unsafe { libc::CMSG_NXTHDR(msg, cmsg_ptr) };
|
||||
}
|
||||
|
||||
@@ -1224,6 +1224,32 @@ mod windows_tun {
|
||||
#[cfg(windows)]
|
||||
pub use windows_tun::{TunDevice, TunWriter, run_tun_reader, shutdown_tun_interface};
|
||||
|
||||
// Android uses an app-owned TUN (the embedder owns the fd, e.g. an Android
|
||||
// VpnService); FIPS never creates or configures a system TUN here. These no-op
|
||||
// stubs stand in for the platform ops so the shared TunDevice code compiles.
|
||||
#[cfg(target_os = "android")]
|
||||
mod platform {
|
||||
use super::TunError;
|
||||
use std::net::Ipv6Addr;
|
||||
|
||||
pub fn is_ipv6_disabled() -> bool {
|
||||
false
|
||||
}
|
||||
pub async fn interface_exists(_name: &str) -> bool {
|
||||
false
|
||||
}
|
||||
pub async fn delete_interface(_name: &str) -> Result<(), TunError> {
|
||||
Ok(())
|
||||
}
|
||||
pub async fn configure_interface(
|
||||
_name: &str,
|
||||
_addr: Ipv6Addr,
|
||||
_mtu: u16,
|
||||
) -> Result<(), TunError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod platform {
|
||||
use super::TunError;
|
||||
|
||||
@@ -28,7 +28,7 @@ x-fips-common: &fips-common
|
||||
services:
|
||||
service-a:
|
||||
<<: *fips-common
|
||||
container_name: fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-acl-container-a
|
||||
hostname: host-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -43,7 +43,7 @@ services:
|
||||
|
||||
service-b:
|
||||
<<: *fips-common
|
||||
container_name: fips-acl-container-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-acl-container-b
|
||||
hostname: host-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -58,7 +58,7 @@ services:
|
||||
|
||||
service-c:
|
||||
<<: *fips-common
|
||||
container_name: fips-acl-container-c${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-acl-container-c
|
||||
hostname: host-c
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -73,7 +73,7 @@ services:
|
||||
|
||||
service-d:
|
||||
<<: *fips-common
|
||||
container_name: fips-acl-container-d${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-acl-container-d
|
||||
hostname: host-d
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -88,7 +88,7 @@ services:
|
||||
|
||||
service-e:
|
||||
<<: *fips-common
|
||||
container_name: fips-acl-container-e${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-acl-container-e
|
||||
hostname: host-e
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -101,7 +101,7 @@ services:
|
||||
|
||||
service-f:
|
||||
<<: *fips-common
|
||||
container_name: fips-acl-container-f${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-acl-container-f
|
||||
hostname: host-f
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
|
||||
@@ -132,31 +132,31 @@ docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
|
||||
log "Waiting for expected peer convergence"
|
||||
wait_for_peers_exact fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} 3 40
|
||||
wait_for_peers_exact fips-acl-container-b${FIPS_CI_NAME_SUFFIX:-} 1 40
|
||||
wait_for_peers_exact fips-acl-container-c${FIPS_CI_NAME_SUFFIX:-} 0 5
|
||||
wait_for_peers_exact fips-acl-container-d${FIPS_CI_NAME_SUFFIX:-} 0 5
|
||||
wait_for_peers_exact fips-acl-container-e${FIPS_CI_NAME_SUFFIX:-} 1 40
|
||||
wait_for_peers_exact fips-acl-container-f${FIPS_CI_NAME_SUFFIX:-} 1 40
|
||||
wait_for_peers_exact fips-acl-container-a 3 40
|
||||
wait_for_peers_exact fips-acl-container-b 1 40
|
||||
wait_for_peers_exact fips-acl-container-c 0 5
|
||||
wait_for_peers_exact fips-acl-container-d 0 5
|
||||
wait_for_peers_exact fips-acl-container-e 1 40
|
||||
wait_for_peers_exact fips-acl-container-f 1 40
|
||||
|
||||
log "Verifying peer sets"
|
||||
assert_peer_set fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6 npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c"
|
||||
assert_peer_set fips-acl-container-b${FIPS_CI_NAME_SUFFIX:-} "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
||||
assert_peer_set fips-acl-container-c${FIPS_CI_NAME_SUFFIX:-} ""
|
||||
assert_peer_set fips-acl-container-d${FIPS_CI_NAME_SUFFIX:-} ""
|
||||
assert_peer_set fips-acl-container-e${FIPS_CI_NAME_SUFFIX:-} "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
||||
assert_peer_set fips-acl-container-f${FIPS_CI_NAME_SUFFIX:-} "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
||||
assert_peer_set fips-acl-container-a "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6 npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c"
|
||||
assert_peer_set fips-acl-container-b "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
||||
assert_peer_set fips-acl-container-c ""
|
||||
assert_peer_set fips-acl-container-d ""
|
||||
assert_peer_set fips-acl-container-e "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
||||
assert_peer_set fips-acl-container-f "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
||||
|
||||
log "Checking alias-based ACL resolution"
|
||||
assert_acl_field fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} allow_file_entries "node-a node-b node-e node-f"
|
||||
assert_acl_field fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} allow_entries "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6 npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c"
|
||||
assert_acl_field fips-acl-container-c${FIPS_CI_NAME_SUFFIX:-} allow_file_entries "node-a node-b node-c node-d node-e node-f"
|
||||
assert_acl_field fips-acl-container-c${FIPS_CI_NAME_SUFFIX:-} allow_entries "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6 npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6 npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c"
|
||||
assert_acl_field fips-acl-container-a allow_file_entries "node-a node-b node-e node-f"
|
||||
assert_acl_field fips-acl-container-a allow_entries "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6 npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c"
|
||||
assert_acl_field fips-acl-container-c allow_file_entries "node-a node-b node-c node-d node-e node-f"
|
||||
assert_acl_field fips-acl-container-c allow_entries "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6 npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6 npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c"
|
||||
|
||||
log "Checking ACL rejection logs"
|
||||
assert_log_contains fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6"
|
||||
assert_log_contains fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl"
|
||||
assert_log_contains fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} "context=inbound_handshake"
|
||||
assert_log_contains fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} "decision=denylist match"
|
||||
assert_log_contains fips-acl-container-a "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6"
|
||||
assert_log_contains fips-acl-container-a "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl"
|
||||
assert_log_contains fips-acl-container-a "context=inbound_handshake"
|
||||
assert_log_contains fips-acl-container-a "decision=denylist match"
|
||||
|
||||
log "ACL allowlist integration test passed"
|
||||
|
||||
@@ -27,7 +27,7 @@ x-boringtun-common: &boringtun-common
|
||||
services:
|
||||
alice:
|
||||
<<: *boringtun-common
|
||||
container_name: bt-alice${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: bt-alice
|
||||
hostname: alice
|
||||
environment:
|
||||
- ROLE=alice
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
|
||||
bob:
|
||||
<<: *boringtun-common
|
||||
container_name: bt-bob${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: bt-bob
|
||||
hostname: bob
|
||||
environment:
|
||||
- ROLE=bob
|
||||
|
||||
@@ -10,14 +10,14 @@ PARALLEL="${PARALLEL:-1}"
|
||||
echo "=== boringtun iperf3 throughput (single TCP stream, ${DURATION}s) ==="
|
||||
|
||||
# Run iperf3 server on alice (background), client on bob.
|
||||
docker exec -d bt-alice${FIPS_CI_NAME_SUFFIX:-} iperf3 -s -1 -B 10.99.0.1 -p 5201
|
||||
docker exec -d bt-alice iperf3 -s -1 -B 10.99.0.1 -p 5201
|
||||
sleep 1
|
||||
|
||||
# wait for tun handshake to settle (boringtun + WG keepalive)
|
||||
sleep 2
|
||||
|
||||
# Client: bob → alice over WG (10.99.0.1)
|
||||
OUT=$(docker exec bt-bob${FIPS_CI_NAME_SUFFIX:-} iperf3 -c 10.99.0.1 -p 5201 -t "$DURATION" -P "$PARALLEL" -J)
|
||||
OUT=$(docker exec bt-bob iperf3 -c 10.99.0.1 -p 5201 -t "$DURATION" -P "$PARALLEL" -J)
|
||||
|
||||
# Pull SUM bps.
|
||||
MBPS=$(echo "$OUT" | python3 -c "import json,sys; d=json.load(sys.stdin); print(f\"{d['end']['sum_received']['bits_per_second'] / 1_000_000:.2f}\")")
|
||||
|
||||
+6
-24
@@ -7,25 +7,18 @@
|
||||
# prior run died:
|
||||
#
|
||||
# 1. The CI label com.corganlabs.fips-ci=1 (attached to every direct
|
||||
# `docker run`/network/volume ci-local drives). Every run additionally
|
||||
# stamps com.corganlabs.fips-ci.run=<run-id> on the same resources.
|
||||
# `docker run`/network/volume ci-local drives).
|
||||
# 2. The compose project-name prefix fipsci_ (every compose project ci-local
|
||||
# starts is named fipsci_<run-id>_<suite>, so its containers/networks/
|
||||
# volumes all carry com.docker.compose.project=fipsci_... and are named
|
||||
# with that prefix).
|
||||
#
|
||||
# The generic CI label is shared by every run on the host, so an unscoped label
|
||||
# sweep would tear down a CONCURRENT run's resources. Pass --run-id to narrow
|
||||
# the label sweep to one run; a run's own teardown always does. Without it the
|
||||
# label sweep stays broad, which is what a manual "reap everything" wants.
|
||||
#
|
||||
# Usage:
|
||||
# ci-cleanup.sh Reap ALL fips-ci resources (any run)
|
||||
# ci-cleanup.sh --project-prefix P Restrict the compose-project sweep to
|
||||
# names starting with P (scopes the reap
|
||||
# to a single run)
|
||||
# ci-cleanup.sh --run-id ID Restrict the label sweep to the run
|
||||
# labelled ID (leaves other runs alone)
|
||||
# to a single run; the label sweep still
|
||||
# runs)
|
||||
# ci-cleanup.sh --label L Override the CI label (default above)
|
||||
# ci-cleanup.sh --images "a b,c" Also `docker rmi -f` these image tags
|
||||
# (space- or comma-separated)
|
||||
@@ -35,16 +28,13 @@
|
||||
set -uo pipefail
|
||||
|
||||
LABEL="com.corganlabs.fips-ci=1"
|
||||
RUN_LABEL_KEY="com.corganlabs.fips-ci.run"
|
||||
PROJECT_PREFIX="fipsci_" # broad default: every CI run
|
||||
RUN_ID="" # broad default: every CI run
|
||||
IMAGES=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--label) LABEL="$2"; shift 2 ;;
|
||||
--project-prefix) PROJECT_PREFIX="$2"; shift 2 ;;
|
||||
--run-id) RUN_ID="$2"; shift 2 ;;
|
||||
--images) IMAGES="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,/^set /{ /^set /d; s/^# \?//; p }' "$0"; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 2 ;;
|
||||
@@ -64,14 +54,6 @@ fi
|
||||
# never wedge a caller (ci-local's signal trap relies on this being bounded).
|
||||
TMO=30
|
||||
|
||||
# Selector for the label sweep. With --run-id it matches only the named run, so
|
||||
# a concurrent run's resources are left alone; without it, every CI run.
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
SWEEP_LABEL="${RUN_LABEL_KEY}=${RUN_ID}"
|
||||
else
|
||||
SWEEP_LABEL="$LABEL"
|
||||
fi
|
||||
|
||||
# Distinct compose project names (read off container labels) that start with
|
||||
# the configured prefix.
|
||||
ci_projects() {
|
||||
@@ -81,7 +63,7 @@ ci_projects() {
|
||||
|
||||
reap_containers() {
|
||||
# By CI label.
|
||||
docker ps -aq --filter "label=${SWEEP_LABEL}" 2>/dev/null \
|
||||
docker ps -aq --filter "label=${LABEL}" 2>/dev/null \
|
||||
| xargs -r timeout "$TMO" docker rm -f >/dev/null 2>&1 || true
|
||||
# By compose project (carried even when container_name is explicit).
|
||||
local p
|
||||
@@ -92,7 +74,7 @@ reap_containers() {
|
||||
}
|
||||
|
||||
reap_networks() {
|
||||
docker network ls -q --filter "label=${SWEEP_LABEL}" 2>/dev/null \
|
||||
docker network ls -q --filter "label=${LABEL}" 2>/dev/null \
|
||||
| xargs -r timeout "$TMO" docker network rm >/dev/null 2>&1 || true
|
||||
# Compose networks are named <project>_<net> → match by name prefix so
|
||||
# orphaned networks (whose containers are already gone) are still caught.
|
||||
@@ -101,7 +83,7 @@ reap_networks() {
|
||||
}
|
||||
|
||||
reap_volumes() {
|
||||
docker volume ls -q --filter "label=${SWEEP_LABEL}" 2>/dev/null \
|
||||
docker volume ls -q --filter "label=${LABEL}" 2>/dev/null \
|
||||
| xargs -r timeout "$TMO" docker volume rm >/dev/null 2>&1 || true
|
||||
docker volume ls --format '{{.Name}}' 2>/dev/null | grep -E "^${PROJECT_PREFIX}" \
|
||||
| xargs -r timeout "$TMO" docker volume rm >/dev/null 2>&1 || true
|
||||
|
||||
+2
-13
@@ -254,10 +254,6 @@ CI_PROJECT_PREFIX="fipsci_${CI_RUN_ID}"
|
||||
CI_IMAGE_TEST="fips-test:${CI_RUN_ID}"
|
||||
CI_IMAGE_APP="fips-test-app:${CI_RUN_ID}"
|
||||
CI_LABEL="com.corganlabs.fips-ci=1"
|
||||
# Run-scoped companion to CI_LABEL. The generic label is shared by every run on
|
||||
# the host, so teardown filters on this one instead — otherwise this run's reap
|
||||
# would force-remove a concurrent run's containers out from under it.
|
||||
CI_LABEL_RUN="com.corganlabs.fips-ci.run=${CI_RUN_ID}"
|
||||
|
||||
# Exported so child suite scripts + their compose/`docker run` invocations
|
||||
# inherit the run identity. Compose files read FIPS_TEST_IMAGE/FIPS_TEST_APP_IMAGE
|
||||
@@ -265,12 +261,6 @@ CI_LABEL_RUN="com.corganlabs.fips-ci.run=${CI_RUN_ID}"
|
||||
export FIPS_CI_RUN_ID="$CI_RUN_ID"
|
||||
export FIPS_TEST_IMAGE="$CI_IMAGE_TEST"
|
||||
export FIPS_TEST_APP_IMAGE="$CI_IMAGE_APP"
|
||||
# Docker container names are GLOBAL — a compose project name does not scope
|
||||
# them — so the suite compose files append this suffix to every explicit
|
||||
# container_name, and the suite scripts append it wherever they address a
|
||||
# container by name. Empty when unset, so a bare `docker compose up` outside
|
||||
# this harness still produces today's plain names.
|
||||
export FIPS_CI_NAME_SUFFIX="-${CI_RUN_ID}"
|
||||
|
||||
# Per-suite compose project name: ${prefix}_<suite-or-compose-basename>. Keeps
|
||||
# today's intra-run distinctness (one project per compose file / chaos child)
|
||||
@@ -305,7 +295,6 @@ ci_teardown() {
|
||||
# the whole sweep too so the trap can never wedge.
|
||||
timeout 150 bash "$SCRIPT_DIR/ci-cleanup.sh" \
|
||||
--label "$CI_LABEL" \
|
||||
--run-id "$CI_RUN_ID" \
|
||||
--project-prefix "$CI_PROJECT_PREFIX" \
|
||||
--images "$CI_IMAGE_TEST $CI_IMAGE_APP" >/dev/null 2>&1 || true
|
||||
}
|
||||
@@ -758,9 +747,9 @@ run_integration() {
|
||||
# reference fips-test:latest directly; the retag happens only after BOTH
|
||||
# builds succeed, so :latest never points at a half-built image.
|
||||
info "Building $CI_IMAGE_TEST Docker image"
|
||||
docker build -t "$CI_IMAGE_TEST" --label "$CI_LABEL" --label "$CI_LABEL_RUN" testing/docker --quiet \
|
||||
docker build -t "$CI_IMAGE_TEST" --label "$CI_LABEL" testing/docker --quiet \
|
||||
|| { record "docker-build" 1; return; }
|
||||
docker build -t "$CI_IMAGE_APP" --label "$CI_LABEL" --label "$CI_LABEL_RUN" \
|
||||
docker build -t "$CI_IMAGE_APP" --label "$CI_LABEL" \
|
||||
-f testing/docker/Dockerfile.app testing/docker --quiet \
|
||||
|| { record "docker-build-app" 1; return; }
|
||||
docker tag "$CI_IMAGE_TEST" fips-test:latest
|
||||
|
||||
@@ -187,7 +187,7 @@ _run_deb_install_scenario() {
|
||||
local distro_label="$1"
|
||||
local base_image="$2"
|
||||
|
||||
local name="fips-deb-test-${distro_label}${FIPS_CI_NAME_SUFFIX:-}"
|
||||
local name="fips-deb-test-${distro_label}"
|
||||
local image="fips-deb-test:${distro_label}"
|
||||
log ".deb install: ${base_image}"
|
||||
|
||||
|
||||
@@ -284,7 +284,7 @@ DOCKERFILE
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test_debian12_resolved() {
|
||||
local name="fips-dns-test-deb12-resolved${FIPS_CI_NAME_SUFFIX:-}"
|
||||
local name="fips-dns-test-deb12-resolved"
|
||||
local image="fips-dns-test:debian12-resolved"
|
||||
log "Debian 12 + systemd-resolved (expects global-drop-in)"
|
||||
|
||||
@@ -312,7 +312,7 @@ DOCKERFILE
|
||||
}
|
||||
|
||||
test_debian13_resolved() {
|
||||
local name="fips-dns-test-deb13-resolved${FIPS_CI_NAME_SUFFIX:-}"
|
||||
local name="fips-dns-test-deb13-resolved"
|
||||
local image="fips-dns-test:debian13-resolved"
|
||||
log "Debian 13 (trixie) + systemd-resolved (expects global-drop-in)"
|
||||
|
||||
@@ -340,7 +340,7 @@ DOCKERFILE
|
||||
}
|
||||
|
||||
test_ubuntu22_resolved() {
|
||||
local name="fips-dns-test-u22-resolved${FIPS_CI_NAME_SUFFIX:-}"
|
||||
local name="fips-dns-test-u22-resolved"
|
||||
local image="fips-dns-test:ubuntu22-resolved"
|
||||
log "Ubuntu 22.04 + systemd-resolved (expects global-drop-in)"
|
||||
|
||||
@@ -370,7 +370,7 @@ DOCKERFILE
|
||||
}
|
||||
|
||||
test_ubuntu24_resolved() {
|
||||
local name="fips-dns-test-u24-resolved${FIPS_CI_NAME_SUFFIX:-}"
|
||||
local name="fips-dns-test-u24-resolved"
|
||||
local image="fips-dns-test:ubuntu24-resolved"
|
||||
log "Ubuntu 24.04 + systemd-resolved (expects global-drop-in)"
|
||||
|
||||
@@ -398,7 +398,7 @@ DOCKERFILE
|
||||
}
|
||||
|
||||
test_ubuntu26_resolved() {
|
||||
local name="fips-dns-test-u26-resolved${FIPS_CI_NAME_SUFFIX:-}"
|
||||
local name="fips-dns-test-u26-resolved"
|
||||
local image="fips-dns-test:ubuntu26-resolved"
|
||||
log "Ubuntu 26.04 + systemd-resolved (expects global-drop-in)"
|
||||
|
||||
@@ -426,7 +426,7 @@ DOCKERFILE
|
||||
}
|
||||
|
||||
test_dnsmasq() {
|
||||
local name="fips-dns-test-dnsmasq${FIPS_CI_NAME_SUFFIX:-}"
|
||||
local name="fips-dns-test-dnsmasq"
|
||||
local image="fips-dns-test:dnsmasq"
|
||||
log "Debian 12 + dnsmasq standalone"
|
||||
|
||||
@@ -492,7 +492,7 @@ DOCKERFILE
|
||||
}
|
||||
|
||||
test_nm_dnsmasq() {
|
||||
local name="fips-dns-test-nm-dnsmasq${FIPS_CI_NAME_SUFFIX:-}"
|
||||
local name="fips-dns-test-nm-dnsmasq"
|
||||
local image="fips-dns-test:nm-dnsmasq"
|
||||
log "Fedora + NetworkManager + dnsmasq plugin"
|
||||
|
||||
@@ -555,7 +555,7 @@ DOCKERFILE
|
||||
}
|
||||
|
||||
test_no_resolver() {
|
||||
local name="fips-dns-test-none${FIPS_CI_NAME_SUFFIX:-}"
|
||||
local name="fips-dns-test-none"
|
||||
local image="fips-dns-test:none"
|
||||
log "Debian 12 bare (no resolver)"
|
||||
|
||||
@@ -625,7 +625,7 @@ _run_e2e_scenario() {
|
||||
local base_image="$2"
|
||||
local apt_packages="$3"
|
||||
|
||||
local name="fips-dns-test-e2e-${distro_label}${FIPS_CI_NAME_SUFFIX:-}"
|
||||
local name="fips-dns-test-e2e-${distro_label}"
|
||||
local image="fips-dns-test:e2e-${distro_label}"
|
||||
log "End-to-end: ${base_image} + systemd-resolved + real fips + fips-gateway + dig"
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ x-fips-common: &fips-common
|
||||
services:
|
||||
service-a:
|
||||
<<: *fips-common
|
||||
container_name: fips-fw-container-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-fw-container-a
|
||||
hostname: host-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -40,7 +40,7 @@ services:
|
||||
|
||||
service-b:
|
||||
<<: *fips-common
|
||||
container_name: fips-fw-container-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-fw-container-b
|
||||
hostname: host-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
|
||||
@@ -22,8 +22,8 @@ TESTING_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml"
|
||||
GENERATE_CONFIGS="$SCRIPT_DIR/generate-configs.sh"
|
||||
|
||||
CONTAINER_A="fips-fw-container-a${FIPS_CI_NAME_SUFFIX:-}"
|
||||
CONTAINER_B="fips-fw-container-b${FIPS_CI_NAME_SUFFIX:-}"
|
||||
CONTAINER_A="fips-fw-container-a"
|
||||
CONTAINER_B="fips-fw-container-b"
|
||||
|
||||
NPUB_A="npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
||||
NPUB_B="npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le"
|
||||
|
||||
@@ -34,7 +34,7 @@ services:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: examples/sidecar-nostr-relay/Dockerfile.app
|
||||
container_name: fips-nat-relay${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-relay
|
||||
restart: "no"
|
||||
volumes:
|
||||
- relay-data:/usr/src/app/strfry-db
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
stun:
|
||||
build:
|
||||
context: ./stun
|
||||
container_name: fips-nat-stun${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-stun
|
||||
restart: "no"
|
||||
networks:
|
||||
wan:
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
build:
|
||||
context: ./router
|
||||
profiles: ["cone", "symmetric"]
|
||||
container_name: fips-nat-router-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-router-a
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
sysctls:
|
||||
@@ -84,7 +84,7 @@ services:
|
||||
build:
|
||||
context: ./router
|
||||
profiles: ["cone", "symmetric"]
|
||||
container_name: fips-nat-router-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-router-b
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
sysctls:
|
||||
@@ -106,7 +106,7 @@ services:
|
||||
cone-a:
|
||||
<<: *fips-common
|
||||
profiles: ["cone"]
|
||||
container_name: fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-cone-a
|
||||
hostname: fips-nat-cone-a
|
||||
depends_on:
|
||||
- nat-a
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
cone-b:
|
||||
<<: *fips-common
|
||||
profiles: ["cone"]
|
||||
container_name: fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-cone-b
|
||||
hostname: fips-nat-cone-b
|
||||
depends_on:
|
||||
- nat-b
|
||||
@@ -158,7 +158,7 @@ services:
|
||||
symmetric-a:
|
||||
<<: *fips-common
|
||||
profiles: ["symmetric"]
|
||||
container_name: fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-symmetric-a
|
||||
hostname: fips-nat-symmetric-a
|
||||
depends_on:
|
||||
- nat-a
|
||||
@@ -184,7 +184,7 @@ services:
|
||||
symmetric-b:
|
||||
<<: *fips-common
|
||||
profiles: ["symmetric"]
|
||||
container_name: fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-symmetric-b
|
||||
hostname: fips-nat-symmetric-b
|
||||
depends_on:
|
||||
- nat-b
|
||||
@@ -210,7 +210,7 @@ services:
|
||||
lan-a:
|
||||
<<: *fips-common
|
||||
profiles: ["lan"]
|
||||
container_name: fips-nat-lan-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-lan-a
|
||||
hostname: fips-nat-lan-a
|
||||
depends_on:
|
||||
- relay
|
||||
@@ -225,7 +225,7 @@ services:
|
||||
lan-b:
|
||||
<<: *fips-common
|
||||
profiles: ["lan"]
|
||||
container_name: fips-nat-lan-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-lan-b
|
||||
hostname: fips-nat-lan-b
|
||||
depends_on:
|
||||
- relay
|
||||
@@ -247,7 +247,7 @@ services:
|
||||
nostr-pub-a:
|
||||
<<: *fips-common
|
||||
profiles: ["nostr-publish-consume"]
|
||||
container_name: fips-nat-nostr-pub-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-nostr-pub-a
|
||||
hostname: fips-nat-nostr-pub-a
|
||||
depends_on:
|
||||
- relay
|
||||
@@ -262,7 +262,7 @@ services:
|
||||
nostr-pub-b:
|
||||
<<: *fips-common
|
||||
profiles: ["nostr-publish-consume"]
|
||||
container_name: fips-nat-nostr-pub-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-nostr-pub-b
|
||||
hostname: fips-nat-nostr-pub-b
|
||||
depends_on:
|
||||
- relay
|
||||
@@ -291,7 +291,7 @@ services:
|
||||
stun-fault-node:
|
||||
<<: *fips-common
|
||||
profiles: ["stun-faults"]
|
||||
container_name: fips-nat-stun-fault-node${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-stun-fault-node
|
||||
hostname: fips-nat-stun-fault-node
|
||||
depends_on:
|
||||
- relay
|
||||
@@ -312,7 +312,7 @@ services:
|
||||
stun-fault-peer:
|
||||
<<: *fips-common
|
||||
profiles: ["stun-faults"]
|
||||
container_name: fips-nat-stun-fault-peer${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-stun-fault-peer
|
||||
hostname: fips-nat-stun-fault-peer
|
||||
depends_on:
|
||||
- relay
|
||||
@@ -327,7 +327,7 @@ services:
|
||||
stun-fault-shim:
|
||||
image: fips-test:latest
|
||||
profiles: ["stun-faults"]
|
||||
container_name: fips-nat-stun-fault-shim${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-nat-stun-fault-shim
|
||||
depends_on:
|
||||
- stun-fault-node
|
||||
cap_add:
|
||||
|
||||
@@ -36,7 +36,7 @@ cleanup() {
|
||||
}
|
||||
|
||||
helper_tcpdump_image() {
|
||||
docker inspect -f '{{.Config.Image}}' fips-nat-router-a${FIPS_CI_NAME_SUFFIX:-} 2>/dev/null || echo nat-nat-a
|
||||
docker inspect -f '{{.Config.Image}}' fips-nat-router-a 2>/dev/null || echo nat-nat-a
|
||||
}
|
||||
|
||||
dump_container_state() {
|
||||
@@ -202,7 +202,7 @@ dump_stun_udp_probe() {
|
||||
|
||||
local capture_file
|
||||
capture_file="$(mktemp)"
|
||||
docker run --rm --label com.corganlabs.fips-ci=1 --label "com.corganlabs.fips-ci.run=${FIPS_CI_RUN_ID:-manual}" --net=container:fips-nat-stun${FIPS_CI_NAME_SUFFIX:-} --cap-add NET_ADMIN --cap-add NET_RAW \
|
||||
docker run --rm --label com.corganlabs.fips-ci=1 --net=container:fips-nat-stun --cap-add NET_ADMIN --cap-add NET_RAW \
|
||||
--entrypoint sh "$helper_image" \
|
||||
-lc "timeout 8 tcpdump -ni any 'udp and not port 53' -c 80" \
|
||||
>"$capture_file" 2>&1 &
|
||||
@@ -225,38 +225,38 @@ dump_stun_udp_probe() {
|
||||
dump_cone_diagnostics() {
|
||||
echo ""
|
||||
echo "=== cone diagnostics ==="
|
||||
dump_fips_state fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-} 172.31.254.30 7777 172.31.254.40 3478
|
||||
dump_node_udp_probe fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_fips_state fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-} 172.31.254.30 7777 172.31.254.40 3478
|
||||
dump_node_udp_probe fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_container_state fips-nat-router-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_router_udp_probe fips-nat-router-a${FIPS_CI_NAME_SUFFIX:-} fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_container_state fips-nat-router-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_router_udp_probe fips-nat-router-b${FIPS_CI_NAME_SUFFIX:-} fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_container_state fips-nat-relay${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_stun_udp_probe fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_stun_udp_probe fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_container_state fips-nat-stun${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_fips_state fips-nat-cone-a 172.31.254.30 7777 172.31.254.40 3478
|
||||
dump_node_udp_probe fips-nat-cone-a
|
||||
dump_fips_state fips-nat-cone-b 172.31.254.30 7777 172.31.254.40 3478
|
||||
dump_node_udp_probe fips-nat-cone-b
|
||||
dump_container_state fips-nat-router-a
|
||||
dump_router_udp_probe fips-nat-router-a fips-nat-cone-a
|
||||
dump_container_state fips-nat-router-b
|
||||
dump_router_udp_probe fips-nat-router-b fips-nat-cone-b
|
||||
dump_container_state fips-nat-relay
|
||||
dump_stun_udp_probe fips-nat-cone-a
|
||||
dump_stun_udp_probe fips-nat-cone-b
|
||||
dump_container_state fips-nat-stun
|
||||
}
|
||||
|
||||
dump_symmetric_diagnostics() {
|
||||
echo ""
|
||||
echo "=== symmetric diagnostics ==="
|
||||
dump_fips_state fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-} 172.31.254.30 7777 172.31.254.40 3478
|
||||
dump_fips_state fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-} 172.31.254.30 7777 172.31.254.40 3478
|
||||
dump_container_state fips-nat-router-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_container_state fips-nat-router-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_container_state fips-nat-relay${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_container_state fips-nat-stun${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_fips_state fips-nat-symmetric-a 172.31.254.30 7777 172.31.254.40 3478
|
||||
dump_fips_state fips-nat-symmetric-b 172.31.254.30 7777 172.31.254.40 3478
|
||||
dump_container_state fips-nat-router-a
|
||||
dump_container_state fips-nat-router-b
|
||||
dump_container_state fips-nat-relay
|
||||
dump_container_state fips-nat-stun
|
||||
}
|
||||
|
||||
dump_lan_diagnostics() {
|
||||
echo ""
|
||||
echo "=== lan diagnostics ==="
|
||||
dump_fips_state fips-nat-lan-a${FIPS_CI_NAME_SUFFIX:-} 172.31.10.30 7777 172.31.10.40 3478
|
||||
dump_fips_state fips-nat-lan-b${FIPS_CI_NAME_SUFFIX:-} 172.31.10.30 7777 172.31.10.40 3478
|
||||
dump_container_state fips-nat-relay${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_container_state fips-nat-stun${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_fips_state fips-nat-lan-a 172.31.10.30 7777 172.31.10.40 3478
|
||||
dump_fips_state fips-nat-lan-b 172.31.10.30 7777 172.31.10.40 3478
|
||||
dump_container_state fips-nat-relay
|
||||
dump_container_state fips-nat-stun
|
||||
}
|
||||
|
||||
trap 'echo ""; echo "NAT test interrupted"; cleanup; exit 130' INT TERM
|
||||
@@ -334,22 +334,22 @@ run_cone() {
|
||||
"$GENERATE_SCRIPT" cone
|
||||
"${COMPOSE[@]}" --profile cone up -d --build --force-recreate
|
||||
"$TOPOLOGY_SCRIPT" cone
|
||||
wait_for_peers fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-} 1 45 || {
|
||||
wait_for_peers fips-nat-cone-a 1 45 || {
|
||||
dump_cone_diagnostics
|
||||
return 1
|
||||
}
|
||||
wait_for_peers fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-} 1 45 || {
|
||||
wait_for_peers fips-nat-cone-b 1 45 || {
|
||||
dump_cone_diagnostics
|
||||
return 1
|
||||
}
|
||||
assert_peer_path fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-} udp 172.31.254.
|
||||
assert_peer_path fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-} udp 172.31.254.
|
||||
assert_link_path fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-} 172.31.254.
|
||||
assert_link_path fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-} 172.31.254.
|
||||
assert_peer_path fips-nat-cone-a udp 172.31.254.
|
||||
assert_peer_path fips-nat-cone-b udp 172.31.254.
|
||||
assert_link_path fips-nat-cone-a 172.31.254.
|
||||
assert_link_path fips-nat-cone-b 172.31.254.
|
||||
# shellcheck disable=SC1090
|
||||
source "$NAT_DIR/generated-configs/cone/npubs.env"
|
||||
ping_peer fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-} "$NPUB_B"
|
||||
ping_peer fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-} "$NPUB_A"
|
||||
ping_peer fips-nat-cone-a "$NPUB_B"
|
||||
ping_peer fips-nat-cone-b "$NPUB_A"
|
||||
cleanup
|
||||
}
|
||||
|
||||
@@ -359,24 +359,24 @@ run_symmetric() {
|
||||
NAT_MODE_A=symmetric NAT_MODE_B=symmetric "$GENERATE_SCRIPT" symmetric
|
||||
NAT_MODE_A=symmetric NAT_MODE_B=symmetric "${COMPOSE[@]}" --profile symmetric up -d --build --force-recreate
|
||||
"$TOPOLOGY_SCRIPT" symmetric
|
||||
wait_for_peers fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-} 1 60 || {
|
||||
wait_for_peers fips-nat-symmetric-a 1 60 || {
|
||||
dump_symmetric_diagnostics
|
||||
return 1
|
||||
}
|
||||
wait_for_peers fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-} 1 60 || {
|
||||
wait_for_peers fips-nat-symmetric-b 1 60 || {
|
||||
dump_symmetric_diagnostics
|
||||
return 1
|
||||
}
|
||||
assert_peer_path fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-} tcp 172.31.254.11:
|
||||
assert_peer_path fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-} tcp 172.31.254.10:
|
||||
assert_link_path fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-} 172.31.254.11:
|
||||
assert_link_path fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-} 172.31.254.10:
|
||||
require_bootstrap_activity fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
require_bootstrap_activity fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
assert_peer_path fips-nat-symmetric-a tcp 172.31.254.11:
|
||||
assert_peer_path fips-nat-symmetric-b tcp 172.31.254.10:
|
||||
assert_link_path fips-nat-symmetric-a 172.31.254.11:
|
||||
assert_link_path fips-nat-symmetric-b 172.31.254.10:
|
||||
require_bootstrap_activity fips-nat-symmetric-a
|
||||
require_bootstrap_activity fips-nat-symmetric-b
|
||||
# shellcheck disable=SC1090
|
||||
source "$NAT_DIR/generated-configs/symmetric/npubs.env"
|
||||
ping_peer fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-} "$NPUB_B"
|
||||
ping_peer fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-} "$NPUB_A"
|
||||
ping_peer fips-nat-symmetric-a "$NPUB_B"
|
||||
ping_peer fips-nat-symmetric-b "$NPUB_A"
|
||||
cleanup
|
||||
}
|
||||
|
||||
@@ -385,22 +385,22 @@ run_lan() {
|
||||
cleanup
|
||||
"$GENERATE_SCRIPT" lan
|
||||
"${COMPOSE[@]}" --profile lan up -d --build --force-recreate
|
||||
wait_for_peers fips-nat-lan-a${FIPS_CI_NAME_SUFFIX:-} 1 45 || {
|
||||
wait_for_peers fips-nat-lan-a 1 45 || {
|
||||
dump_lan_diagnostics
|
||||
return 1
|
||||
}
|
||||
wait_for_peers fips-nat-lan-b${FIPS_CI_NAME_SUFFIX:-} 1 45 || {
|
||||
wait_for_peers fips-nat-lan-b 1 45 || {
|
||||
dump_lan_diagnostics
|
||||
return 1
|
||||
}
|
||||
assert_peer_path fips-nat-lan-a${FIPS_CI_NAME_SUFFIX:-} udp 172.31.10.
|
||||
assert_peer_path fips-nat-lan-b${FIPS_CI_NAME_SUFFIX:-} udp 172.31.10.
|
||||
assert_link_path fips-nat-lan-a${FIPS_CI_NAME_SUFFIX:-} 172.31.10.
|
||||
assert_link_path fips-nat-lan-b${FIPS_CI_NAME_SUFFIX:-} 172.31.10.
|
||||
assert_peer_path fips-nat-lan-a udp 172.31.10.
|
||||
assert_peer_path fips-nat-lan-b udp 172.31.10.
|
||||
assert_link_path fips-nat-lan-a 172.31.10.
|
||||
assert_link_path fips-nat-lan-b 172.31.10.
|
||||
# shellcheck disable=SC1090
|
||||
source "$NAT_DIR/generated-configs/lan/npubs.env"
|
||||
ping_peer fips-nat-lan-a${FIPS_CI_NAME_SUFFIX:-} "$NPUB_B"
|
||||
ping_peer fips-nat-lan-b${FIPS_CI_NAME_SUFFIX:-} "$NPUB_A"
|
||||
ping_peer fips-nat-lan-a "$NPUB_B"
|
||||
ping_peer fips-nat-lan-b "$NPUB_A"
|
||||
# Skip the final teardown when the mesh-lab harness wraps this
|
||||
# script: it needs to docker-logs the containers before teardown,
|
||||
# and will run its own cleanup after capture. Failure paths above
|
||||
|
||||
@@ -25,11 +25,11 @@ WAIT_LIB="$ROOT_DIR/testing/lib/wait-converge.sh"
|
||||
PROFILE="nostr-publish-consume"
|
||||
SCENARIO="$PROFILE"
|
||||
COMPOSE=(docker compose -f "$NAT_DIR/docker-compose.yml")
|
||||
NODE_A="fips-nat-nostr-pub-a${FIPS_CI_NAME_SUFFIX:-}"
|
||||
NODE_B="fips-nat-nostr-pub-b${FIPS_CI_NAME_SUFFIX:-}"
|
||||
NODE_A="fips-nat-nostr-pub-a"
|
||||
NODE_B="fips-nat-nostr-pub-b"
|
||||
RELAY_HOST="172.31.10.30"
|
||||
RELAY_PORT=7777
|
||||
RELAY_CONTAINER="fips-nat-relay${FIPS_CI_NAME_SUFFIX:-}"
|
||||
RELAY_CONTAINER="fips-nat-relay"
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$WAIT_LIB"
|
||||
|
||||
@@ -9,12 +9,12 @@ SCENARIO="${1:?usage: setup-topology.sh <cone|symmetric>}"
|
||||
|
||||
case "$SCENARIO" in
|
||||
cone)
|
||||
node_a="fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-}"
|
||||
node_b="fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-}"
|
||||
node_a="fips-nat-cone-a"
|
||||
node_b="fips-nat-cone-b"
|
||||
;;
|
||||
symmetric)
|
||||
node_a="fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-}"
|
||||
node_b="fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-}"
|
||||
node_a="fips-nat-symmetric-a"
|
||||
node_b="fips-nat-symmetric-b"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported topology scenario: $SCENARIO" >&2
|
||||
@@ -22,8 +22,8 @@ case "$SCENARIO" in
|
||||
;;
|
||||
esac
|
||||
|
||||
router_a="fips-nat-router-a${FIPS_CI_NAME_SUFFIX:-}"
|
||||
router_b="fips-nat-router-b${FIPS_CI_NAME_SUFFIX:-}"
|
||||
router_a="fips-nat-router-a"
|
||||
router_b="fips-nat-router-b"
|
||||
|
||||
helper_image() {
|
||||
if [ -n "${IP_HELPER_IMAGE:-}" ]; then
|
||||
|
||||
@@ -33,10 +33,10 @@ GENERATE_SCRIPT="$SCRIPT_DIR/generate-configs.sh"
|
||||
PROFILE="stun-faults"
|
||||
SCENARIO="$PROFILE"
|
||||
COMPOSE=(docker compose -f "$NAT_DIR/docker-compose.yml")
|
||||
NODE="fips-nat-stun-fault-node${FIPS_CI_NAME_SUFFIX:-}"
|
||||
PEER="fips-nat-stun-fault-peer${FIPS_CI_NAME_SUFFIX:-}"
|
||||
SHIM="fips-nat-stun-fault-shim${FIPS_CI_NAME_SUFFIX:-}"
|
||||
STUN_CONTAINER="fips-nat-stun${FIPS_CI_NAME_SUFFIX:-}"
|
||||
NODE="fips-nat-stun-fault-node"
|
||||
PEER="fips-nat-stun-fault-peer"
|
||||
SHIM="fips-nat-stun-fault-shim"
|
||||
STUN_CONTAINER="fips-nat-stun"
|
||||
STUN_HOST="172.31.10.40"
|
||||
STUN_PORT=3478
|
||||
DEV="eth0"
|
||||
|
||||
@@ -25,28 +25,11 @@ NODE_B_NPUB="npub15h7z0ljzudqe9pgwx99cjsz2c0ennuyvkcc8zvtk3lg97xwzex9ska6g4y"
|
||||
NODE_C_NSEC="15148ed0131f7da43fd13e369dfedede14fb64698f3756636b569c3a3e87438f"
|
||||
NODE_C_NPUB="npub1zhezcykd0e34z4fxtranl45jaasgnlxv0kjqwlq2v56ggssn0w4qelcrvr"
|
||||
|
||||
NETWORK_NAME="fips-sidecar-test${FIPS_CI_NAME_SUFFIX:-}"
|
||||
|
||||
# Compose project names. These are explicit (they override COMPOSE_PROJECT_NAME),
|
||||
# so they carry the run suffix themselves — otherwise concurrent runs would share
|
||||
# one project and tear down each other's containers.
|
||||
PROJ_A="sidecar-a${FIPS_CI_NAME_SUFFIX:-}"
|
||||
PROJ_B="sidecar-b${FIPS_CI_NAME_SUFFIX:-}"
|
||||
PROJ_C="sidecar-c${FIPS_CI_NAME_SUFFIX:-}"
|
||||
# Network address range — claimed at startup by alloc_network below rather than
|
||||
# hardcoded, so two concurrent runs cannot request the same range.
|
||||
#
|
||||
# 10.40.0.0/16 is unclaimed in-tree: the suites use 172.20-172.32 and 172.99,
|
||||
# the chaos children 10.30.x, boringtun's inner network 10.99.x, and docker's
|
||||
# own default pool is 172.17-31 plus 192.168.
|
||||
NET_BASE="10.40"
|
||||
NET_CANDIDATES=64
|
||||
|
||||
SUBNET=""
|
||||
GATEWAY_IP=""
|
||||
NODE_A_IP=""
|
||||
NODE_B_IP=""
|
||||
NODE_C_IP=""
|
||||
NETWORK_NAME="fips-sidecar-test"
|
||||
SUBNET="172.20.2.0/24"
|
||||
NODE_A_IP="172.20.2.10"
|
||||
NODE_B_IP="172.20.2.11"
|
||||
NODE_C_IP="172.20.2.12"
|
||||
|
||||
CONVERGE_TIMEOUT=30
|
||||
PASSED=0
|
||||
@@ -62,50 +45,13 @@ log() { echo "=== $*"; }
|
||||
pass() { echo " PASS: $*"; PASSED=$((PASSED + 1)); }
|
||||
fail() { echo " FAIL: $*"; FAILED=$((FAILED + 1)); }
|
||||
|
||||
# Claim a free /24 for this run's network.
|
||||
#
|
||||
# Claim-and-advance rather than deriving an offset from the run id: we attempt
|
||||
# creation on a candidate range and, if docker reports the range is taken, move
|
||||
# to the next. Docker's own address pool is then the arbiter, which makes an
|
||||
# overlap between two concurrent runs *impossible*. A hashed offset would only
|
||||
# make it unlikely, and an overlap is precisely the failure being avoided here.
|
||||
#
|
||||
# Deliberately does NOT discard stderr: only an address-pool conflict is worth
|
||||
# advancing on. Any other failure (name already taken, daemon error) is real,
|
||||
# and retrying it 64 times would bury the reason.
|
||||
alloc_network() {
|
||||
local i err
|
||||
for (( i = 0; i < NET_CANDIDATES; i++ )); do
|
||||
SUBNET="${NET_BASE}.${i}.0/24"
|
||||
if err=$(docker network create \
|
||||
--subnet "$SUBNET" \
|
||||
--label com.corganlabs.fips-ci=1 \
|
||||
--label "com.corganlabs.fips-ci.run=${FIPS_CI_RUN_ID:-manual}" \
|
||||
"$NETWORK_NAME" 2>&1); then
|
||||
GATEWAY_IP="${NET_BASE}.${i}.1"
|
||||
NODE_A_IP="${NET_BASE}.${i}.10"
|
||||
NODE_B_IP="${NET_BASE}.${i}.11"
|
||||
NODE_C_IP="${NET_BASE}.${i}.12"
|
||||
log "Claimed network $NETWORK_NAME on $SUBNET"
|
||||
return 0
|
||||
fi
|
||||
case "$err" in
|
||||
*"Pool overlaps"*|*"pool overlaps"*) continue ;;
|
||||
*) echo " FAIL: docker network create: $err" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
echo " FAIL: no free /24 in ${NET_BASE}.0.0/16 after ${NET_CANDIDATES} attempts" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
log "Cleaning up..."
|
||||
# Tear down C and B first, then A. All three attach to the network as
|
||||
# external, so none of them removes it — we do that last, by name.
|
||||
docker compose $COMPOSE_EXT -p "$PROJ_C" down --volumes --remove-orphans 2>/dev/null || true
|
||||
docker compose $COMPOSE_EXT -p "$PROJ_B" down --volumes --remove-orphans 2>/dev/null || true
|
||||
docker compose $COMPOSE_EXT -p "$PROJ_A" down --volumes --remove-orphans 2>/dev/null || true
|
||||
docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true
|
||||
# Tear down B and C first (they reference A's network as external)
|
||||
docker compose $COMPOSE_EXT -p sidecar-c down --volumes --remove-orphans 2>/dev/null || true
|
||||
docker compose $COMPOSE_EXT -p sidecar-b down --volumes --remove-orphans 2>/dev/null || true
|
||||
# Tear down A last (it owns the network)
|
||||
docker compose $COMPOSE_BASE -p sidecar-a down --volumes --remove-orphans 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Always clean up on exit
|
||||
@@ -127,9 +73,7 @@ fi
|
||||
# node-b: peers with A (middle node, transit router)
|
||||
# node-c: peers with B (end node)
|
||||
|
||||
alloc_network || exit 1
|
||||
|
||||
log "Starting node-a (no peers, joins the claimed network)..."
|
||||
log "Starting node-a (no peers, creates network)..."
|
||||
# node-a is the root: explicitly clear FIPS_PEER_* so it does not inherit the
|
||||
# external peer default from .env (which points at a real public mesh node).
|
||||
# Without this, node-a auto-connects to the live mesh and the chain attaches
|
||||
@@ -140,7 +84,7 @@ FIPS_PEER_ADDR="" \
|
||||
FIPS_NETWORK="$NETWORK_NAME" \
|
||||
FIPS_SUBNET="$SUBNET" \
|
||||
FIPS_IPV4="$NODE_A_IP" \
|
||||
docker compose $COMPOSE_EXT -p "$PROJ_A" up -d
|
||||
docker compose $COMPOSE_BASE -p sidecar-a up -d
|
||||
|
||||
log "Starting node-b (peers with node-a, joins external network)..."
|
||||
FIPS_NSEC="$NODE_B_NSEC" \
|
||||
@@ -150,7 +94,7 @@ FIPS_PEER_ALIAS="node-a" \
|
||||
FIPS_NETWORK="$NETWORK_NAME" \
|
||||
FIPS_SUBNET="$SUBNET" \
|
||||
FIPS_IPV4="$NODE_B_IP" \
|
||||
docker compose $COMPOSE_EXT -p "$PROJ_B" up -d
|
||||
docker compose $COMPOSE_EXT -p sidecar-b up -d
|
||||
|
||||
log "Starting node-c (peers with node-b, joins external network)..."
|
||||
FIPS_NSEC="$NODE_C_NSEC" \
|
||||
@@ -160,7 +104,7 @@ FIPS_PEER_ALIAS="node-b" \
|
||||
FIPS_NETWORK="$NETWORK_NAME" \
|
||||
FIPS_SUBNET="$SUBNET" \
|
||||
FIPS_IPV4="$NODE_C_IP" \
|
||||
docker compose $COMPOSE_EXT -p "$PROJ_C" up -d
|
||||
docker compose $COMPOSE_EXT -p sidecar-c up -d
|
||||
|
||||
# ── Wait for convergence ──────────────────────────────────────────────────
|
||||
|
||||
@@ -169,7 +113,7 @@ log "Waiting for link establishment (up to ${CONVERGE_TIMEOUT}s)..."
|
||||
converged=false
|
||||
for i in $(seq 1 "$CONVERGE_TIMEOUT"); do
|
||||
# node-b should have 2 links (A and C)
|
||||
link_count=$(docker exec "${PROJ_B}-fips-1" fipsctl show links 2>/dev/null \
|
||||
link_count=$(docker exec sidecar-b-fips-1 fipsctl show links 2>/dev/null \
|
||||
| grep -c '"state": "connected"' || true)
|
||||
if [ "$link_count" -ge 2 ]; then
|
||||
converged=true
|
||||
@@ -183,11 +127,11 @@ if [ "$converged" = true ]; then
|
||||
else
|
||||
log "TIMEOUT: links did not converge in ${CONVERGE_TIMEOUT}s"
|
||||
log "node-a links:"
|
||||
docker exec "${PROJ_A}-fips-1" fipsctl show links 2>&1 || true
|
||||
docker exec sidecar-a-fips-1 fipsctl show links 2>&1 || true
|
||||
log "node-b links:"
|
||||
docker exec "${PROJ_B}-fips-1" fipsctl show links 2>&1 || true
|
||||
docker exec sidecar-b-fips-1 fipsctl show links 2>&1 || true
|
||||
log "node-c links:"
|
||||
docker exec "${PROJ_C}-fips-1" fipsctl show links 2>&1 || true
|
||||
docker exec sidecar-c-fips-1 fipsctl show links 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -197,9 +141,9 @@ fi
|
||||
# chain. The directed pings below remain the actual assertions.
|
||||
_sidecar_converged() {
|
||||
PASSED=0; FAILED=0
|
||||
if docker exec "${PROJ_B}-app-1" ping6 -c1 -W2 "${NODE_A_NPUB}.fips" >/dev/null 2>&1; then PASSED=$((PASSED+1)); else FAILED=$((FAILED+1)); fi
|
||||
if docker exec "${PROJ_C}-app-1" ping6 -c1 -W2 "${NODE_A_NPUB}.fips" >/dev/null 2>&1; then PASSED=$((PASSED+1)); else FAILED=$((FAILED+1)); fi
|
||||
if docker exec "${PROJ_A}-app-1" ping6 -c1 -W2 "${NODE_C_NPUB}.fips" >/dev/null 2>&1; then PASSED=$((PASSED+1)); else FAILED=$((FAILED+1)); fi
|
||||
if docker exec sidecar-b-app-1 ping6 -c1 -W2 "${NODE_A_NPUB}.fips" >/dev/null 2>&1; then PASSED=$((PASSED+1)); else FAILED=$((FAILED+1)); fi
|
||||
if docker exec sidecar-c-app-1 ping6 -c1 -W2 "${NODE_A_NPUB}.fips" >/dev/null 2>&1; then PASSED=$((PASSED+1)); else FAILED=$((FAILED+1)); fi
|
||||
if docker exec sidecar-a-app-1 ping6 -c1 -W2 "${NODE_C_NPUB}.fips" >/dev/null 2>&1; then PASSED=$((PASSED+1)); else FAILED=$((FAILED+1)); fi
|
||||
}
|
||||
wait_until_connected _sidecar_converged "$CONVERGE_TIMEOUT" 10 || true
|
||||
|
||||
@@ -207,11 +151,11 @@ wait_until_connected _sidecar_converged "$CONVERGE_TIMEOUT" 10 || true
|
||||
|
||||
log "Verifying link counts..."
|
||||
|
||||
a_links=$(docker exec "${PROJ_A}-fips-1" fipsctl show links 2>/dev/null \
|
||||
a_links=$(docker exec sidecar-a-fips-1 fipsctl show links 2>/dev/null \
|
||||
| grep -c '"state": "connected"' || true)
|
||||
b_links=$(docker exec "${PROJ_B}-fips-1" fipsctl show links 2>/dev/null \
|
||||
b_links=$(docker exec sidecar-b-fips-1 fipsctl show links 2>/dev/null \
|
||||
| grep -c '"state": "connected"' || true)
|
||||
c_links=$(docker exec "${PROJ_C}-fips-1" fipsctl show links 2>/dev/null \
|
||||
c_links=$(docker exec sidecar-c-fips-1 fipsctl show links 2>/dev/null \
|
||||
| grep -c '"state": "connected"' || true)
|
||||
|
||||
[ "$a_links" -ge 1 ] && pass "node-a has $a_links link(s)" || fail "node-a has $a_links links (expected >= 1)"
|
||||
@@ -222,7 +166,7 @@ c_links=$(docker exec "${PROJ_C}-fips-1" fipsctl show links 2>/dev/null \
|
||||
|
||||
log "Testing direct connectivity (B app → A via fips0)..."
|
||||
|
||||
if docker exec "${PROJ_B}-app-1" ping6 -c2 -W5 "${NODE_A_NPUB}.fips" >/dev/null 2>&1; then
|
||||
if docker exec sidecar-b-app-1 ping6 -c2 -W5 "${NODE_A_NPUB}.fips" >/dev/null 2>&1; then
|
||||
pass "node-b app can ping node-a via fips0"
|
||||
else
|
||||
fail "node-b app cannot ping node-a via fips0"
|
||||
@@ -232,7 +176,7 @@ fi
|
||||
|
||||
log "Testing multi-hop connectivity (C app → A via fips0, through B)..."
|
||||
|
||||
if docker exec "${PROJ_C}-app-1" ping6 -c2 -W10 "${NODE_A_NPUB}.fips" >/dev/null 2>&1; then
|
||||
if docker exec sidecar-c-app-1 ping6 -c2 -W10 "${NODE_A_NPUB}.fips" >/dev/null 2>&1; then
|
||||
pass "node-c app can ping node-a via fips0 (multi-hop through B)"
|
||||
else
|
||||
fail "node-c app cannot ping node-a via fips0 (multi-hop through B)"
|
||||
@@ -242,7 +186,7 @@ fi
|
||||
|
||||
log "Testing reverse multi-hop (A app → C via fips0, through B)..."
|
||||
|
||||
if docker exec "${PROJ_A}-app-1" ping6 -c2 -W10 "${NODE_C_NPUB}.fips" >/dev/null 2>&1; then
|
||||
if docker exec sidecar-a-app-1 ping6 -c2 -W10 "${NODE_C_NPUB}.fips" >/dev/null 2>&1; then
|
||||
pass "node-a app can ping node-c via fips0 (multi-hop through B)"
|
||||
else
|
||||
fail "node-a app cannot ping node-c via fips0 (multi-hop through B)"
|
||||
@@ -256,14 +200,7 @@ fi
|
||||
log "Verifying network isolation on app containers..."
|
||||
|
||||
for node in a b c; do
|
||||
container="sidecar-${node}${FIPS_CI_NAME_SUFFIX:-}-app-1"
|
||||
# Fail loudly if the container is missing. Without this the two isolation
|
||||
# assertions below pass vacuously: they assert a ping FAILS, and a ping into
|
||||
# a non-existent container fails for the wrong reason. Only the loopback
|
||||
# check, which expects success, would notice — so a naming slip here would
|
||||
# silently turn the security assertions into no-ops.
|
||||
docker inspect "$container" >/dev/null 2>&1 \
|
||||
|| { fail "$container does not exist — isolation assertions cannot be trusted"; continue; }
|
||||
container="sidecar-${node}-app-1"
|
||||
# Pick a peer IP that isn't this node's own address
|
||||
case $node in
|
||||
a) peer_ip="$NODE_B_IP" ;;
|
||||
@@ -273,7 +210,7 @@ for node in a b c; do
|
||||
log " Checking $container..."
|
||||
|
||||
# IPv4 gateway should be unreachable (iptables DROP on eth0)
|
||||
if docker exec "$container" ping -c1 -W2 "$GATEWAY_IP" >/dev/null 2>&1; then
|
||||
if docker exec "$container" ping -c1 -W2 172.20.2.1 >/dev/null 2>&1; then
|
||||
fail "$container can reach IPv4 gateway (isolation broken!)"
|
||||
else
|
||||
pass "$container cannot reach IPv4 gateway (IPv4 blocked)"
|
||||
@@ -303,7 +240,7 @@ if [ "$FAILED" -gt 0 ]; then
|
||||
log "Dumping logs for failed run..."
|
||||
for node in a b c; do
|
||||
echo "--- sidecar-${node} logs ---"
|
||||
docker logs "sidecar-${node}${FIPS_CI_NAME_SUFFIX:-}-fips-1" 2>&1 | tail -30
|
||||
docker logs "sidecar-${node}-fips-1" 2>&1 | tail -30
|
||||
echo ""
|
||||
done
|
||||
exit 1
|
||||
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
node-a:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh"]
|
||||
container_name: fips-node-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-a
|
||||
hostname: node-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
node-b:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh"]
|
||||
container_name: fips-node-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-b
|
||||
hostname: node-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -65,7 +65,7 @@ services:
|
||||
node-c:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh"]
|
||||
container_name: fips-node-c${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-c
|
||||
hostname: node-c
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -77,7 +77,7 @@ services:
|
||||
node-d:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh"]
|
||||
container_name: fips-node-d${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-d
|
||||
hostname: node-d
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -89,7 +89,7 @@ services:
|
||||
node-e:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh"]
|
||||
container_name: fips-node-e${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-e
|
||||
hostname: node-e
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -102,7 +102,7 @@ services:
|
||||
pub-a:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-a
|
||||
hostname: node-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -114,7 +114,7 @@ services:
|
||||
pub-b:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-b
|
||||
hostname: node-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -126,7 +126,7 @@ services:
|
||||
pub-c:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-c${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-c
|
||||
hostname: node-c
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -138,7 +138,7 @@ services:
|
||||
pub-d:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-d${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-d
|
||||
hostname: node-d
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -150,7 +150,7 @@ services:
|
||||
pub-e:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-e${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-e
|
||||
hostname: node-e
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -163,7 +163,7 @@ services:
|
||||
chain-a:
|
||||
<<: *fips-common
|
||||
profiles: ["chain"]
|
||||
container_name: fips-node-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-a
|
||||
hostname: node-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -175,7 +175,7 @@ services:
|
||||
chain-b:
|
||||
<<: *fips-common
|
||||
profiles: ["chain"]
|
||||
container_name: fips-node-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-b
|
||||
hostname: node-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -187,7 +187,7 @@ services:
|
||||
chain-c:
|
||||
<<: *fips-common
|
||||
profiles: ["chain"]
|
||||
container_name: fips-node-c${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-c
|
||||
hostname: node-c
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -199,7 +199,7 @@ services:
|
||||
chain-d:
|
||||
<<: *fips-common
|
||||
profiles: ["chain"]
|
||||
container_name: fips-node-d${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-d
|
||||
hostname: node-d
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -211,7 +211,7 @@ services:
|
||||
chain-e:
|
||||
<<: *fips-common
|
||||
profiles: ["chain"]
|
||||
container_name: fips-node-e${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-e
|
||||
hostname: node-e
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -224,7 +224,7 @@ services:
|
||||
rekey-a:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey"]
|
||||
container_name: fips-node-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-a
|
||||
hostname: node-a
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug
|
||||
@@ -238,7 +238,7 @@ services:
|
||||
rekey-b:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey"]
|
||||
container_name: fips-node-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-b
|
||||
hostname: node-b
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug
|
||||
@@ -252,7 +252,7 @@ services:
|
||||
rekey-c:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey"]
|
||||
container_name: fips-node-c${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-c
|
||||
hostname: node-c
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug
|
||||
@@ -266,7 +266,7 @@ services:
|
||||
rekey-d:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey"]
|
||||
container_name: fips-node-d${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-d
|
||||
hostname: node-d
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug
|
||||
@@ -280,7 +280,7 @@ services:
|
||||
rekey-e:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey"]
|
||||
container_name: fips-node-e${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-e
|
||||
hostname: node-e
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug
|
||||
@@ -299,7 +299,7 @@ services:
|
||||
rekey-accept-off-a:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey-accept-off"]
|
||||
container_name: fips-node-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-a
|
||||
hostname: node-a
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
@@ -313,7 +313,7 @@ services:
|
||||
rekey-accept-off-b:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey-accept-off"]
|
||||
container_name: fips-node-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-b
|
||||
hostname: node-b
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
@@ -327,7 +327,7 @@ services:
|
||||
rekey-accept-off-c:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey-accept-off"]
|
||||
container_name: fips-node-c${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-c
|
||||
hostname: node-c
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
@@ -341,7 +341,7 @@ services:
|
||||
rekey-accept-off-d:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey-accept-off"]
|
||||
container_name: fips-node-d${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-d
|
||||
hostname: node-d
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
@@ -355,7 +355,7 @@ services:
|
||||
rekey-accept-off-e:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey-accept-off"]
|
||||
container_name: fips-node-e${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-e
|
||||
hostname: node-e
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
@@ -379,7 +379,7 @@ services:
|
||||
rekey-outbound-only-a:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey-outbound-only"]
|
||||
container_name: fips-node-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-a
|
||||
hostname: node-a
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
@@ -393,7 +393,7 @@ services:
|
||||
rekey-outbound-only-b:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey-outbound-only"]
|
||||
container_name: fips-node-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-b
|
||||
hostname: node-b
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
@@ -407,7 +407,7 @@ services:
|
||||
rekey-outbound-only-c:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey-outbound-only"]
|
||||
container_name: fips-node-c${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-c
|
||||
hostname: node-c
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
@@ -421,7 +421,7 @@ services:
|
||||
rekey-outbound-only-d:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey-outbound-only"]
|
||||
container_name: fips-node-d${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-d
|
||||
hostname: node-d
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
@@ -435,7 +435,7 @@ services:
|
||||
rekey-outbound-only-e:
|
||||
<<: *fips-common
|
||||
profiles: ["rekey-outbound-only"]
|
||||
container_name: fips-node-e${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-e
|
||||
hostname: node-e
|
||||
environment:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
@@ -450,7 +450,7 @@ services:
|
||||
tcp-a:
|
||||
<<: *fips-common
|
||||
profiles: ["tcp-chain"]
|
||||
container_name: fips-node-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-a
|
||||
hostname: node-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -462,7 +462,7 @@ services:
|
||||
tcp-b:
|
||||
<<: *fips-common
|
||||
profiles: ["tcp-chain"]
|
||||
container_name: fips-node-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-b
|
||||
hostname: node-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -474,7 +474,7 @@ services:
|
||||
tcp-c:
|
||||
<<: *fips-common
|
||||
profiles: ["tcp-chain"]
|
||||
container_name: fips-node-c${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-node-c
|
||||
hostname: node-c
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -487,7 +487,7 @@ services:
|
||||
gw-gateway:
|
||||
<<: *fips-common
|
||||
profiles: ["gateway"]
|
||||
container_name: fips-gw-gateway${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-gw-gateway
|
||||
hostname: gw-gateway
|
||||
# Privileged required: gateway must enable IPv6 on eth1 (second network,
|
||||
# attached after container start) and manage nftables NAT rules.
|
||||
@@ -513,7 +513,7 @@ services:
|
||||
gw-server:
|
||||
<<: *fips-common
|
||||
profiles: ["gateway"]
|
||||
container_name: fips-gw-server${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-gw-server
|
||||
hostname: gw-server
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -528,7 +528,7 @@ services:
|
||||
gw-server-2:
|
||||
<<: *fips-common
|
||||
profiles: ["gateway"]
|
||||
container_name: fips-gw-server-2${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-gw-server-2
|
||||
hostname: gw-server-2
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
@@ -540,7 +540,7 @@ services:
|
||||
gw-client:
|
||||
image: ${FIPS_TEST_APP_IMAGE:-fips-test-app:latest}
|
||||
profiles: ["gateway"]
|
||||
container_name: fips-gw-client${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-gw-client
|
||||
hostname: gw-client
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
@@ -562,7 +562,7 @@ services:
|
||||
gw-client-2:
|
||||
image: ${FIPS_TEST_APP_IMAGE:-fips-test-app:latest}
|
||||
profiles: ["gateway"]
|
||||
container_name: fips-gw-client-2${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-gw-client-2
|
||||
hostname: gw-client-2
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
|
||||
@@ -87,23 +87,13 @@ CAP_IP=$(node_ip "$CAP_NODE")
|
||||
[ -n "$CAP_IP" ] || fail "could not resolve docker_ip for node-$CAP_NODE in $TOPO_FILE"
|
||||
info "cap'd node: node-$CAP_NODE (ip $CAP_IP, max_peers=$MAX_PEERS)"
|
||||
|
||||
# Read the cap'd node's peer_count, or the empty string if it did not answer.
|
||||
#
|
||||
# Empty is deliberately distinct from a real 0. An `|| echo 0` fallback cannot
|
||||
# do that job here: `||` binds to the last stage of the pipeline, which succeeds
|
||||
# on empty input, so the fallback never fires and an unreachable container would
|
||||
# be reported as a legitimate zero.
|
||||
read_peer_count() {
|
||||
docker exec "fips-node-${CAP_NODE}${FIPS_CI_NAME_SUFFIX:-}" fipsctl show status 2>/dev/null \
|
||||
| grep -m1 peer_count | sed 's/.*: *//' | tr -d ','
|
||||
}
|
||||
|
||||
# ── Phase 1: wait for convergence ────────────────────────────────────
|
||||
info "phase 1: wait for node-$CAP_NODE peer_count to reach $MAX_PEERS (90s timeout)"
|
||||
deadline=$(($(date +%s) + 90))
|
||||
pc=0
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
pc=$(read_peer_count)
|
||||
pc=$(docker exec fips-node-$CAP_NODE fipsctl show status 2>/dev/null \
|
||||
| grep -m1 peer_count | sed 's/.*: *//' | tr -d ',' || echo 0)
|
||||
[ "$pc" = "$MAX_PEERS" ] && break
|
||||
sleep 2
|
||||
done
|
||||
@@ -112,7 +102,7 @@ done
|
||||
info "node-$CAP_NODE converged: peer_count=$pc"
|
||||
|
||||
# Identify admitted vs denied peers among configured peers
|
||||
ADMITTED_NPUBS=$(docker exec "fips-node-${CAP_NODE}${FIPS_CI_NAME_SUFFIX:-}" fipsctl show peers 2>/dev/null \
|
||||
ADMITTED_NPUBS=$(docker exec fips-node-$CAP_NODE fipsctl show peers 2>/dev/null \
|
||||
| grep -oE 'npub1[a-z0-9]+' | sort -u || true)
|
||||
DENIED=""
|
||||
ADMITTED=""
|
||||
@@ -140,8 +130,8 @@ info "denied (sustained-retry): ${DENIED:-<none>}"
|
||||
# with restarts every 15s we get ~30-50 firings across both denied peers.
|
||||
info "phase 2: capture UDP/2121 on node-$CAP_NODE for ${CAPTURE_SECS}s, with denied-peer restart loop"
|
||||
CAP_FILE=$(mktemp /tmp/admission-cap-pcap.XXXXXX.txt)
|
||||
HELPER_IMAGE=$(docker inspect -f '{{.Config.Image}}' "fips-node-${CAP_NODE}${FIPS_CI_NAME_SUFFIX:-}" 2>/dev/null)
|
||||
[ -n "$HELPER_IMAGE" ] || fail "could not resolve helper image from fips-node-${CAP_NODE}${FIPS_CI_NAME_SUFFIX:-}"
|
||||
HELPER_IMAGE=$(docker inspect -f '{{.Config.Image}}' fips-node-$CAP_NODE 2>/dev/null)
|
||||
[ -n "$HELPER_IMAGE" ] || fail "could not resolve helper image from fips-node-$CAP_NODE"
|
||||
|
||||
# Background: cycle denied peers to reset their backoff and drive load.
|
||||
(
|
||||
@@ -150,7 +140,7 @@ HELPER_IMAGE=$(docker inspect -f '{{.Config.Image}}' "fips-node-${CAP_NODE}${FIP
|
||||
sleep 15
|
||||
elapsed=$((elapsed + 15))
|
||||
for n in $DENIED; do
|
||||
docker restart "fips-node-${n}${FIPS_CI_NAME_SUFFIX:-}" >/dev/null 2>&1 &
|
||||
docker restart "fips-node-$n" >/dev/null 2>&1 &
|
||||
done
|
||||
wait
|
||||
info " [load-driver] restarted denied peers ($DENIED) at t+${elapsed}s"
|
||||
@@ -159,7 +149,7 @@ HELPER_IMAGE=$(docker inspect -f '{{.Config.Image}}' "fips-node-${CAP_NODE}${FIP
|
||||
LOAD_PID=$!
|
||||
|
||||
# Foreground: tcpdump capture for CAPTURE_SECS
|
||||
docker run --rm --label com.corganlabs.fips-ci=1 --label "com.corganlabs.fips-ci.run=${FIPS_CI_RUN_ID:-manual}" --net=container:"fips-node-${CAP_NODE}${FIPS_CI_NAME_SUFFIX:-}" \
|
||||
docker run --rm --label com.corganlabs.fips-ci=1 --net=container:fips-node-$CAP_NODE \
|
||||
--cap-add NET_ADMIN --cap-add NET_RAW \
|
||||
--entrypoint sh "$HELPER_IMAGE" \
|
||||
-c "timeout $CAPTURE_SECS tcpdump -nn -i any 'udp port 2121' -l 2>&1 || true" \
|
||||
@@ -196,21 +186,9 @@ for n in $DENIED; do
|
||||
done
|
||||
|
||||
# ── Phase 4: cap'd node still at exactly max_peers ───────────────────
|
||||
#
|
||||
# Polled rather than sampled once. The load driver above restarts the denied
|
||||
# peers every 15s and its final restart lands in the same second this runs, so a
|
||||
# single read can hit a daemon busy with those restarts and come back empty --
|
||||
# which is a harness race, not a cap regression. Retry briefly before believing
|
||||
# the answer. A genuine regression still fails, just after the retry window,
|
||||
# because the loop exits early only on the expected value.
|
||||
pc_final=""
|
||||
deadline=$(($(date +%s) + 30))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
pc_final=$(read_peer_count)
|
||||
[ "$pc_final" = "$MAX_PEERS" ] && break
|
||||
sleep 2
|
||||
done
|
||||
info "node-$CAP_NODE final peer_count=${pc_final:-<no answer>} (expected $MAX_PEERS)"
|
||||
pc_final=$(docker exec fips-node-$CAP_NODE fipsctl show status 2>/dev/null \
|
||||
| grep -m1 peer_count | sed 's/.*: *//' | tr -d ',' || echo 0)
|
||||
info "node-$CAP_NODE final peer_count=$pc_final (expected $MAX_PEERS)"
|
||||
[ "$pc_final" = "$MAX_PEERS" ] || OVERALL=1
|
||||
|
||||
if [ "$OVERALL" -eq 0 ]; then
|
||||
|
||||
@@ -47,7 +47,7 @@ source "$ENV_FILE"
|
||||
iperf_once() {
|
||||
local client="$1" dest_npub="$2"
|
||||
local out
|
||||
if ! out=$(docker exec "fips-${client}${FIPS_CI_NAME_SUFFIX:-}" iperf3 -c "${dest_npub}.fips" \
|
||||
if ! out=$(docker exec "fips-$client" iperf3 -c "${dest_npub}.fips" \
|
||||
-t "$DURATION" -P "$PARALLEL" -J 2>&1); then
|
||||
echo FAIL
|
||||
return
|
||||
@@ -105,7 +105,7 @@ PYEOF
|
||||
ping_path() {
|
||||
local client="$1" dest_npub="$2"
|
||||
local out
|
||||
if ! out=$(docker exec "fips-${client}${FIPS_CI_NAME_SUFFIX:-}" \
|
||||
if ! out=$(docker exec "fips-$client" \
|
||||
ping -c "$PING_COUNT" -i 0.2 -w "$((PING_COUNT * 2))" \
|
||||
-q "${dest_npub}.fips" 2>&1); then
|
||||
echo FAIL
|
||||
@@ -203,7 +203,7 @@ CONVERGE_SECS="${FIPS_BENCH_CONVERGE_SECS:-15}"
|
||||
|
||||
peer_is_direct() {
|
||||
local client="$1" dest_npub="$2"
|
||||
docker exec "fips-${client}${FIPS_CI_NAME_SUFFIX:-}" fipsctl show peers 2>/dev/null \
|
||||
docker exec "fips-$client" fipsctl show peers 2>/dev/null \
|
||||
| python3 -c '
|
||||
import json, sys
|
||||
target = sys.argv[1]
|
||||
@@ -225,7 +225,7 @@ except Exception:
|
||||
# multihop alternative).
|
||||
peer_bytes_sent_snapshot() {
|
||||
local client="$1"
|
||||
docker exec "fips-${client}${FIPS_CI_NAME_SUFFIX:-}" fipsctl show peers 2>/dev/null \
|
||||
docker exec "fips-$client" fipsctl show peers 2>/dev/null \
|
||||
| python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
|
||||
@@ -20,11 +20,11 @@ source "$SCRIPT_DIR/../../lib/wait-converge.sh"
|
||||
GENERATED_DIR="$SCRIPT_DIR/../generated-configs"
|
||||
ENV_FILE="$GENERATED_DIR/npubs.env"
|
||||
|
||||
GATEWAY="fips-gw-gateway${FIPS_CI_NAME_SUFFIX:-}"
|
||||
SERVER="fips-gw-server${FIPS_CI_NAME_SUFFIX:-}"
|
||||
SERVER2="fips-gw-server-2${FIPS_CI_NAME_SUFFIX:-}"
|
||||
CLIENT="fips-gw-client${FIPS_CI_NAME_SUFFIX:-}"
|
||||
CLIENT2="fips-gw-client-2${FIPS_CI_NAME_SUFFIX:-}"
|
||||
GATEWAY="fips-gw-gateway"
|
||||
SERVER="fips-gw-server"
|
||||
SERVER2="fips-gw-server-2"
|
||||
CLIENT="fips-gw-client"
|
||||
CLIENT2="fips-gw-client-2"
|
||||
|
||||
# ── inject-config subcommand ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ iperf_test() {
|
||||
if [ "$LIVE_OUTPUT" = true ]; then
|
||||
# Show live output
|
||||
echo "Running iperf3 test (live output):"
|
||||
if docker exec "fips-${client_node}${FIPS_CI_NAME_SUFFIX:-}" timeout "$IPERF_TIMEOUT" iperf3 -c "${dest_npub}.fips" -t "$DURATION" -P "$PARALLEL"; then
|
||||
if docker exec "fips-$client_node" timeout "$IPERF_TIMEOUT" iperf3 -c "${dest_npub}.fips" -t "$DURATION" -P "$PARALLEL"; then
|
||||
PASSED=$((PASSED + 1))
|
||||
else
|
||||
echo "FAIL"
|
||||
@@ -59,7 +59,7 @@ iperf_test() {
|
||||
# Capture and summarize output
|
||||
echo -n "Running iperf3 test... "
|
||||
local output
|
||||
if output=$(docker exec "fips-${client_node}${FIPS_CI_NAME_SUFFIX:-}" timeout "$IPERF_TIMEOUT" iperf3 -c "${dest_npub}.fips" -t "$DURATION" -P "$PARALLEL" 2>&1); then
|
||||
if output=$(docker exec "fips-$client_node" timeout "$IPERF_TIMEOUT" iperf3 -c "${dest_npub}.fips" -t "$DURATION" -P "$PARALLEL" 2>&1); then
|
||||
# Check if we got valid results
|
||||
if echo "$output" | grep -q "sender"; then
|
||||
# Extract and display results (get SUM line for aggregate bandwidth)
|
||||
|
||||
@@ -180,7 +180,7 @@ do_apply() {
|
||||
echo ""
|
||||
|
||||
for node in $NODES; do
|
||||
local container="fips-node-${node}${FIPS_CI_NAME_SUFFIX:-}"
|
||||
local container="fips-node-$node"
|
||||
echo -n " $container ... "
|
||||
if ! container_running "$container"; then
|
||||
echo "SKIP (not running)"
|
||||
@@ -199,7 +199,7 @@ do_remove() {
|
||||
echo ""
|
||||
|
||||
for node in $NODES; do
|
||||
local container="fips-node-${node}${FIPS_CI_NAME_SUFFIX:-}"
|
||||
local container="fips-node-$node"
|
||||
echo -n " $container ... "
|
||||
if ! container_running "$container"; then
|
||||
echo "SKIP (not running)"
|
||||
@@ -219,7 +219,7 @@ do_status() {
|
||||
echo ""
|
||||
|
||||
for node in $NODES; do
|
||||
local container="fips-node-${node}${FIPS_CI_NAME_SUFFIX:-}"
|
||||
local container="fips-node-$node"
|
||||
echo " $container:"
|
||||
if ! container_running "$container"; then
|
||||
echo " (not running)"
|
||||
|
||||
@@ -37,7 +37,7 @@ ping_test() {
|
||||
|
||||
echo -n " $label ... "
|
||||
local output
|
||||
if output=$(docker exec "fips-${from}${FIPS_CI_NAME_SUFFIX:-}" ping6 -c "$COUNT" -W "$TIMEOUT" "${to_npub}.fips" 2>&1); then
|
||||
if output=$(docker exec "fips-$from" ping6 -c "$COUNT" -W "$TIMEOUT" "${to_npub}.fips" 2>&1); then
|
||||
# Extract round-trip time from ping output
|
||||
local rtt
|
||||
rtt=$(echo "$output" | grep -oE 'time=[0-9.]+' | cut -d= -f2)
|
||||
@@ -61,7 +61,7 @@ ping_all_quiet() {
|
||||
for ((i=0; i<n; i++)); do
|
||||
for ((j=0; j<n; j++)); do
|
||||
[ "$i" -eq "$j" ] && continue
|
||||
if docker exec "fips-node-${LABELS[$i],,}${FIPS_CI_NAME_SUFFIX:-}" \
|
||||
if docker exec "fips-node-${LABELS[$i],,}" \
|
||||
ping6 -c 1 -W 1 "${NPUBS[$j]}.fips" >/dev/null 2>&1; then
|
||||
PASSED=$((PASSED + 1))
|
||||
else
|
||||
@@ -78,18 +78,18 @@ echo ""
|
||||
echo "Waiting for mesh convergence..."
|
||||
if [ "$PROFILE" = "chain" ]; then
|
||||
# Chain: A-B-C-D-E, each interior node has 2 peers, endpoints have 1
|
||||
wait_for_peers fips-node-a${FIPS_CI_NAME_SUFFIX:-} 1 20 || true
|
||||
wait_for_peers fips-node-b${FIPS_CI_NAME_SUFFIX:-} 2 20 || true
|
||||
wait_for_peers fips-node-c${FIPS_CI_NAME_SUFFIX:-} 2 20 || true
|
||||
wait_for_peers fips-node-d${FIPS_CI_NAME_SUFFIX:-} 2 20 || true
|
||||
wait_for_peers fips-node-e${FIPS_CI_NAME_SUFFIX:-} 1 20 || true
|
||||
wait_for_peers fips-node-a 1 20 || true
|
||||
wait_for_peers fips-node-b 2 20 || true
|
||||
wait_for_peers fips-node-c 2 20 || true
|
||||
wait_for_peers fips-node-d 2 20 || true
|
||||
wait_for_peers fips-node-e 1 20 || true
|
||||
elif [ "$PROFILE" = "mesh" ] || [ "$PROFILE" = "mesh-public" ]; then
|
||||
# Mesh: check all nodes reach their configured peer counts
|
||||
wait_for_peers fips-node-a${FIPS_CI_NAME_SUFFIX:-} 2 20 || true
|
||||
wait_for_peers fips-node-b${FIPS_CI_NAME_SUFFIX:-} 1 20 || true
|
||||
wait_for_peers fips-node-c${FIPS_CI_NAME_SUFFIX:-} 3 20 || true
|
||||
wait_for_peers fips-node-d${FIPS_CI_NAME_SUFFIX:-} 3 20 || true
|
||||
wait_for_peers fips-node-e${FIPS_CI_NAME_SUFFIX:-} 3 20 || true
|
||||
wait_for_peers fips-node-a 2 20 || true
|
||||
wait_for_peers fips-node-b 1 20 || true
|
||||
wait_for_peers fips-node-c 3 20 || true
|
||||
wait_for_peers fips-node-d 3 20 || true
|
||||
wait_for_peers fips-node-e 3 20 || true
|
||||
fi
|
||||
# Wait for full pairwise connectivity, progress-aware: the actual pings
|
||||
# are the convergence signal, the deadline extends while more pairs come
|
||||
|
||||
@@ -223,7 +223,7 @@ ping_one() {
|
||||
if (( attempt > 1 )); then
|
||||
sleep "$PING_RETRY_DELAY"
|
||||
fi
|
||||
if output=$(docker exec "fips-${from}${FIPS_CI_NAME_SUFFIX:-}" ping6 -c 1 -W "$ping_timeout" "${to_npub}.fips" 2>&1); then
|
||||
if output=$(docker exec "fips-$from" ping6 -c 1 -W "$ping_timeout" "${to_npub}.fips" 2>&1); then
|
||||
rtt=$(echo "$output" | grep -oE 'time=[0-9.]+' | cut -d= -f2)
|
||||
if [ -z "$quiet" ]; then
|
||||
if (( attempt == 1 )); then
|
||||
@@ -289,7 +289,7 @@ count_log_pattern() {
|
||||
local pattern="$1"
|
||||
local total=0
|
||||
for node in $NODES; do
|
||||
local count=$(docker logs "fips-node-${node}${FIPS_CI_NAME_SUFFIX:-}" 2>&1 | grep -c "$pattern" || true)
|
||||
local count=$(docker logs "fips-node-$node" 2>&1 | grep -c "$pattern" || true)
|
||||
total=$((total + count))
|
||||
done
|
||||
echo "$total"
|
||||
@@ -352,7 +352,7 @@ dump_peer_connectivity() {
|
||||
echo "=== Peer connectivity snapshot ==="
|
||||
for node in $NODES; do
|
||||
echo "--- node-$node ---"
|
||||
docker exec "fips-node-${node}${FIPS_CI_NAME_SUFFIX:-}" fipsctl show peers 2>/dev/null || true
|
||||
docker exec "fips-node-$node" fipsctl show peers 2>/dev/null || true
|
||||
echo ""
|
||||
done
|
||||
}
|
||||
@@ -415,38 +415,17 @@ ping_all "" "$TIMEOUT" "$MAX_PING_ATTEMPTS"
|
||||
phase_result "Post-first-rekey (all 20 pairs)"
|
||||
echo ""
|
||||
|
||||
# ── Phase 4: Wait for further rekey cutovers ──────────────────────────
|
||||
# Poll for the FMP rekey cutovers instead of blind-sleeping: capture the
|
||||
# cutover count reached so far, then wait until two more land AND the
|
||||
# Phase 6 absolute threshold (>= 4) is covered. The relative "+2" alone
|
||||
# is not enough: it inherits whatever count Phase 4 starts from, and on
|
||||
# an unloaded host the ping phases can outrun the jittered first rekey
|
||||
# cycle, entering here with a single cutover on the books — then "+2"
|
||||
# releases at three, the remaining first-cycle cutovers land moments
|
||||
# after the Phase 6 snapshot, and the >= 4 assertion fails even though
|
||||
# every cutover completed on schedule. Flooring the wait target at the
|
||||
# Phase 6 threshold makes this wait's guarantee cover that assertion:
|
||||
# the log count is monotone over a container's lifetime, so a released
|
||||
# wait means the later count cannot lose the timing race.
|
||||
#
|
||||
# What the count measures: the counted line is emitted by the cadence
|
||||
# path on whichever side(s) flip first, so a completed link rekey yields
|
||||
# one or two lines (a side promoted by receiving a flipped-K frame logs
|
||||
# only a DEBUG on a target the test's log filter suppresses). With rekey
|
||||
# timers resetting at each cutover, the events landing inside this
|
||||
# test's window are first-cycle cutovers spread across the topology's
|
||||
# links, not a second cycle on one link.
|
||||
# ── Phase 4: Wait for second rekey cycle ──────────────────────────────
|
||||
# Poll for the next FMP rekey cutover instead of blind-sleeping: capture
|
||||
# the cutover count reached so far, then wait until at least one more
|
||||
# cutover lands — that increment is the second rekey cycle firing.
|
||||
# Bounded by SECOND_REKEY_WAIT so a stalled rekey still falls through to
|
||||
# the strict Phase 5/6 assertions rather than hanging.
|
||||
echo "Phase 4: Further rekey cutovers (waiting up to ${SECOND_REKEY_WAIT}s)"
|
||||
echo "Phase 4: Second rekey cycle (waiting up to ${SECOND_REKEY_WAIT}s for the next cutover)"
|
||||
fmp_cutovers_before=$(count_log_pattern "Rekey cutover complete (initiator), K-bit flipped")
|
||||
fmp_cutover_target=$((fmp_cutovers_before + 2))
|
||||
if [ "$fmp_cutover_target" -lt 4 ]; then
|
||||
fmp_cutover_target=4
|
||||
fi
|
||||
wait_for_log_pattern_count \
|
||||
"Rekey cutover complete (initiator), K-bit flipped" \
|
||||
"$fmp_cutover_target" "$SECOND_REKEY_WAIT" || true
|
||||
"$((fmp_cutovers_before + 1))" "$SECOND_REKEY_WAIT" || true
|
||||
|
||||
# Verify connectivity after second rekey (back-to-back). This is the
|
||||
# site of the recurring post-second-rekey straggler-pair flake: wait for
|
||||
@@ -478,7 +457,7 @@ wait_for_log_pattern_count "Peer FSP new-epoch frame authenticated" 1 "$REKEY_SE
|
||||
|
||||
# Positive checks: rekey machinery worked
|
||||
assert_min_count "Rekey cutover complete (initiator), K-bit flipped" 4 \
|
||||
"FMP rekey initiator cutovers"
|
||||
"FMP rekey initiator cutovers (>= 2 cycles)"
|
||||
|
||||
# FSP rekey checks (sessions between non-adjacent nodes)
|
||||
assert_min_count "FSP rekey cutover complete (initiator)" 1 \
|
||||
@@ -504,7 +483,7 @@ assert_zero_count "Session AEAD decryption failed" \
|
||||
if [ -n "$REKEY_ACCEPT_OFF_NODES" ]; then
|
||||
DUAL_INIT_THRESHOLD=10
|
||||
for off_node in ${REKEY_ACCEPT_OFF_NODES//,/ }; do
|
||||
count=$(docker logs "fips-node-${off_node}${FIPS_CI_NAME_SUFFIX:-}" 2>&1 \
|
||||
count=$(docker logs "fips-node-$off_node" 2>&1 \
|
||||
| grep -cE "Dual rekey initiation: we win" || true)
|
||||
if [ "${count:-0}" -le "$DUAL_INIT_THRESHOLD" ]; then
|
||||
echo " PASS: node-$off_node dual-init drops below threshold ($count <= $DUAL_INIT_THRESHOLD)"
|
||||
@@ -527,7 +506,7 @@ fi
|
||||
if [ -n "$REKEY_OUTBOUND_ONLY_NODES" ]; then
|
||||
DUAL_INIT_THRESHOLD=10
|
||||
for n in $NODES; do
|
||||
count=$(docker logs "fips-node-${n}${FIPS_CI_NAME_SUFFIX:-}" 2>&1 \
|
||||
count=$(docker logs "fips-node-$n" 2>&1 \
|
||||
| grep -cE "Dual rekey initiation: we win" || true)
|
||||
if [ "${count:-0}" -le "$DUAL_INIT_THRESHOLD" ]; then
|
||||
echo " PASS: node-$n dual-init drops below threshold ($count <= $DUAL_INIT_THRESHOLD)"
|
||||
@@ -562,7 +541,7 @@ else
|
||||
echo "=== Node logs (rekey-related, head -200) ==="
|
||||
for node in $NODES; do
|
||||
echo "--- node-$node ---"
|
||||
docker logs "fips-node-${node}${FIPS_CI_NAME_SUFFIX:-}" 2>&1 | \
|
||||
docker logs "fips-node-$node" 2>&1 | \
|
||||
grep -E "(rekey|Rekey|cross|Cross|teardown|ERROR|PANIC|K-bit|no route|next hop|TTL exhausted|MTU exceeded|Congestion|decrypt|Decrypt|AEAD|Notify|drain|Drain|promot)" | \
|
||||
head -200
|
||||
echo ""
|
||||
@@ -571,7 +550,7 @@ else
|
||||
echo "=== Node logs (last 80 lines, unfiltered) ==="
|
||||
for node in $NODES; do
|
||||
echo "--- node-$node ---"
|
||||
docker logs "fips-node-${node}${FIPS_CI_NAME_SUFFIX:-}" 2>&1 | tail -80
|
||||
docker logs "fips-node-$node" 2>&1 | tail -80
|
||||
echo ""
|
||||
done
|
||||
exit 1
|
||||
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
sysctls:
|
||||
- net.ipv6.conf.all.disable_ipv6=0
|
||||
restart: "no"
|
||||
container_name: fips-dir-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-dir-a
|
||||
hostname: fips-dir-a
|
||||
volumes:
|
||||
- ./configs/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
sysctls:
|
||||
- net.ipv6.conf.all.disable_ipv6=0
|
||||
restart: "no"
|
||||
container_name: fips-dir-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-dir-b
|
||||
hostname: fips-dir-b
|
||||
volumes:
|
||||
- ./configs/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
|
||||
@@ -95,7 +95,7 @@ elapsed=0
|
||||
while [ "$elapsed" -lt "$MAX_WAIT_ONION" ]; do
|
||||
# Extract .onion address from structured log: onion_address=<addr>.onion
|
||||
# Strip ANSI color codes before matching (tracing emits them by default)
|
||||
ONION_ADDR=$(docker logs fips-dir-a${FIPS_CI_NAME_SUFFIX:-} 2>&1 \
|
||||
ONION_ADDR=$(docker logs fips-dir-a 2>&1 \
|
||||
| sed 's/\x1b\[[0-9;]*m//g' \
|
||||
| grep -oE 'onion_address=[a-z2-7]{56}\.onion' \
|
||||
| head -1 \
|
||||
@@ -115,7 +115,7 @@ if [ -z "$ONION_ADDR" ]; then
|
||||
echo " FAIL: Onion service not created within ${MAX_WAIT_ONION}s"
|
||||
echo ""
|
||||
echo "Node A logs (last 30 lines):"
|
||||
docker logs fips-dir-a${FIPS_CI_NAME_SUFFIX:-} 2>&1 | tail -30
|
||||
docker logs fips-dir-a 2>&1 | tail -30
|
||||
docker compose down
|
||||
exit 1
|
||||
fi
|
||||
@@ -143,8 +143,8 @@ peers_a=0
|
||||
peers_b=0
|
||||
elapsed=0
|
||||
while [ "$elapsed" -lt "$MAX_WAIT_PEER" ]; do
|
||||
peers_a=$(count_connected_peers fips-dir-a${FIPS_CI_NAME_SUFFIX:-})
|
||||
peers_b=$(count_connected_peers fips-dir-b${FIPS_CI_NAME_SUFFIX:-})
|
||||
peers_a=$(count_connected_peers fips-dir-a)
|
||||
peers_b=$(count_connected_peers fips-dir-b)
|
||||
|
||||
if [ "$peers_a" -ge 1 ] && [ "$peers_b" -ge 1 ]; then
|
||||
echo " Both nodes connected after ${elapsed}s (A: ${peers_a}, B: ${peers_b})"
|
||||
@@ -159,10 +159,10 @@ if [ "$peers_a" -lt 1 ] || [ "$peers_b" -lt 1 ]; then
|
||||
echo " FAIL: Peers not established within ${MAX_WAIT_PEER}s"
|
||||
echo ""
|
||||
echo "Node A logs (last 30 lines):"
|
||||
docker logs fips-dir-a${FIPS_CI_NAME_SUFFIX:-} 2>&1 | tail -30
|
||||
docker logs fips-dir-a 2>&1 | tail -30
|
||||
echo ""
|
||||
echo "Node B logs (last 30 lines):"
|
||||
docker logs fips-dir-b${FIPS_CI_NAME_SUFFIX:-} 2>&1 | tail -30
|
||||
docker logs fips-dir-b 2>&1 | tail -30
|
||||
docker compose down
|
||||
exit 1
|
||||
fi
|
||||
@@ -229,15 +229,15 @@ print(f'{sum(trimmed)/len(trimmed):.1f}')
|
||||
|
||||
echo ""
|
||||
echo " Ping via Tor onion service (directory mode, Sandbox 1):"
|
||||
ping_series fips-dir-a${FIPS_CI_NAME_SUFFIX:-} "$NPUB_B" "A → B"
|
||||
ping_series fips-dir-b${FIPS_CI_NAME_SUFFIX:-} "$NPUB_A" "B → A"
|
||||
ping_series fips-dir-a "$NPUB_B" "A → B"
|
||||
ping_series fips-dir-b "$NPUB_A" "B → A"
|
||||
|
||||
echo ""
|
||||
|
||||
# ── Phase 6: Log analysis ────────────────────────────────────────
|
||||
echo "Phase 6: Log analysis"
|
||||
|
||||
for node in fips-dir-a${FIPS_CI_NAME_SUFFIX:-} fips-dir-b${FIPS_CI_NAME_SUFFIX:-}; do
|
||||
for node in fips-dir-a fips-dir-b; do
|
||||
panics=$(docker logs "$node" 2>&1 | grep -ci "panic" || true)
|
||||
errors=$(docker logs "$node" 2>&1 | grep -ci "error" || true)
|
||||
onion=$(docker logs "$node" 2>&1 | grep -ci "onion" || true)
|
||||
|
||||
@@ -16,7 +16,7 @@ networks:
|
||||
services:
|
||||
tor-daemon:
|
||||
image: osminogin/tor-simple:latest
|
||||
container_name: tor-daemon${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: tor-daemon
|
||||
restart: "no"
|
||||
volumes:
|
||||
- ../common/torrc:/etc/tor/torrc:ro
|
||||
@@ -32,7 +32,7 @@ services:
|
||||
sysctls:
|
||||
- net.ipv6.conf.all.disable_ipv6=0
|
||||
restart: "no"
|
||||
container_name: fips-tor-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-tor-a
|
||||
hostname: fips-tor-a
|
||||
depends_on:
|
||||
- tor-daemon
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
sysctls:
|
||||
- net.ipv6.conf.all.disable_ipv6=0
|
||||
restart: "no"
|
||||
container_name: fips-tor-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
container_name: fips-tor-b
|
||||
hostname: fips-tor-b
|
||||
depends_on:
|
||||
- tor-daemon
|
||||
|
||||
@@ -87,7 +87,7 @@ echo ""
|
||||
echo "Phase 2: Waiting for Tor daemon to bootstrap (up to ${MAX_WAIT_TOR}s)..."
|
||||
elapsed=0
|
||||
while [ "$elapsed" -lt "$MAX_WAIT_TOR" ]; do
|
||||
if docker logs tor-daemon${FIPS_CI_NAME_SUFFIX:-} 2>&1 | grep -q "Bootstrapped 100%"; then
|
||||
if docker logs tor-daemon 2>&1 | grep -q "Bootstrapped 100%"; then
|
||||
echo " Tor bootstrapped after ${elapsed}s"
|
||||
break
|
||||
fi
|
||||
@@ -100,7 +100,7 @@ if [ "$elapsed" -ge "$MAX_WAIT_TOR" ]; then
|
||||
echo " FAIL: Tor daemon did not bootstrap within ${MAX_WAIT_TOR}s"
|
||||
echo ""
|
||||
echo "Tor daemon logs:"
|
||||
docker logs tor-daemon${FIPS_CI_NAME_SUFFIX:-} 2>&1 | tail -20
|
||||
docker logs tor-daemon 2>&1 | tail -20
|
||||
docker compose down
|
||||
exit 1
|
||||
fi
|
||||
@@ -114,8 +114,8 @@ peers_a=0
|
||||
peers_b=0
|
||||
elapsed=0
|
||||
while [ "$elapsed" -lt "$MAX_WAIT_PEER" ]; do
|
||||
peers_a=$(count_connected_peers fips-tor-a${FIPS_CI_NAME_SUFFIX:-})
|
||||
peers_b=$(count_connected_peers fips-tor-b${FIPS_CI_NAME_SUFFIX:-})
|
||||
peers_a=$(count_connected_peers fips-tor-a)
|
||||
peers_b=$(count_connected_peers fips-tor-b)
|
||||
|
||||
if [ "$peers_a" -ge 1 ] && [ "$peers_b" -ge 1 ]; then
|
||||
echo " Both nodes have connected peers after ${elapsed}s (A: ${peers_a}, B: ${peers_b})"
|
||||
@@ -130,10 +130,10 @@ if [ "$peers_a" -lt 1 ] || [ "$peers_b" -lt 1 ]; then
|
||||
echo " FAIL: Peers not established within ${MAX_WAIT_PEER}s"
|
||||
echo ""
|
||||
echo "Node A logs (last 30 lines):"
|
||||
docker logs fips-tor-a${FIPS_CI_NAME_SUFFIX:-} 2>&1 | tail -30
|
||||
docker logs fips-tor-a 2>&1 | tail -30
|
||||
echo ""
|
||||
echo "Node B logs (last 30 lines):"
|
||||
docker logs fips-tor-b${FIPS_CI_NAME_SUFFIX:-} 2>&1 | tail -30
|
||||
docker logs fips-tor-b 2>&1 | tail -30
|
||||
docker compose down
|
||||
exit 1
|
||||
fi
|
||||
@@ -200,15 +200,15 @@ print(f'{sum(trimmed)/len(trimmed):.1f}')
|
||||
|
||||
echo ""
|
||||
echo " Ping via Tor (routed through test-us01):"
|
||||
ping_series fips-tor-a${FIPS_CI_NAME_SUFFIX:-} "$NPUB_B" "A → B"
|
||||
ping_series fips-tor-b${FIPS_CI_NAME_SUFFIX:-} "$NPUB_A" "B → A"
|
||||
ping_series fips-tor-a "$NPUB_B" "A → B"
|
||||
ping_series fips-tor-b "$NPUB_A" "B → A"
|
||||
|
||||
echo ""
|
||||
|
||||
# ── Phase 5: Log analysis ────────────────────────────────────────
|
||||
echo "Phase 5: Log analysis"
|
||||
|
||||
for node in fips-tor-a${FIPS_CI_NAME_SUFFIX:-} fips-tor-b${FIPS_CI_NAME_SUFFIX:-}; do
|
||||
for node in fips-tor-a fips-tor-b; do
|
||||
panics=$(docker logs "$node" 2>&1 | grep -ci "panic" || true)
|
||||
errors=$(docker logs "$node" 2>&1 | grep -ci "error" || true)
|
||||
socks5=$(docker logs "$node" 2>&1 | grep -ci "socks5\|socks" || true)
|
||||
|
||||
Reference in New Issue
Block a user