From 89352d32184774b022500e50e1e4e7256dd30ec1 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Tue, 24 Mar 2026 21:30:19 +0000 Subject: [PATCH] Add BLE L2CAP transport with scan-based auto-connect BLE transport implementation using L2CAP Connection-Oriented Channels (SeqPacket mode) via the bluer crate, behind cfg(feature = "ble"). Core transport: - BleTransport generic over BleIo trait (BluerIo prod, MockBleIo test) - Connection pool with priority eviction (static > discovered, max 7) - Connect-on-send via connect_inline() matching TCP behavior - Per-connection receive loops with pool cleanup on disconnect Discovery and probing: - Combined scan_probe_loop using select! over scanner events and a BinaryHeap delay queue with per-entry random jitter (0-5s) to prevent herd effects when multiple nodes see the same beacon simultaneously - Pre-handshake pubkey exchange ([0x00][pubkey:32]) for IK identity - Cross-probe tie-breaker: smaller NodeAddr's outbound wins (same convention as FMP/FSP rekey dual-initiation) - Probed peers reported to DiscoveryBuffer; pool fills through normal node-layer auto-connect -> send_async -> connect_inline path Beacon management: - Periodic advertising: 1s burst every 30s (configurable via beacon_interval_secs / beacon_duration_secs) - FIPS service UUID for scan filtering Configuration (all fields optional with defaults): - adapter, psm, mtu, max_connections, connect_timeout_ms - advertise, scan, auto_connect, accept_connections - beacon_interval_secs (30), beacon_duration_secs (1) Hardware validated with two BLE nodes: - 2048-byte MTU, ~60-160ms RTT, zero-config auto-connect - BLE spike tool at testing/ble/ for standalone adapter validation 42 unit tests + 4 node-level integration tests, all CI-compatible via MockBleIo (no hardware required). tokio test-util added for time-dependent scan/probe tests. --- .github/workflows/ci.yml | 6 + CHANGELOG.md | 11 + Cargo.lock | 230 +++- Cargo.toml | 15 +- README.md | 13 +- .../diagrams/fips-node-architecture.svg | 48 +- docs/design/fips-configuration.md | 89 +- docs/design/fips-intro.md | 7 +- packaging/common/fips.yaml | 9 + packaging/systemd/README.install.md | 29 +- src/config/mod.rs | 2 +- src/config/transport.rs | 160 ++- src/node/lifecycle.rs | 26 +- src/node/mod.rs | 83 +- src/node/tests/ble.rs | 277 ++++ src/node/tests/mod.rs | 2 + src/transport/ble/addr.rs | 185 +++ src/transport/ble/discovery.rs | 126 ++ src/transport/ble/io.rs | 774 +++++++++++ src/transport/ble/mod.rs | 1183 +++++++++++++++++ src/transport/ble/pool.rs | 294 ++++ src/transport/ble/stats.rs | 155 +++ src/transport/mod.rs | 54 + testing/ble/.gitignore | 1 + testing/ble/Cargo.lock | 996 ++++++++++++++ testing/ble/Cargo.toml | 14 + testing/ble/ble_spike.rs | 363 +++++ 27 files changed, 5098 insertions(+), 54 deletions(-) create mode 100644 src/node/tests/ble.rs create mode 100644 src/transport/ble/addr.rs create mode 100644 src/transport/ble/discovery.rs create mode 100644 src/transport/ble/io.rs create mode 100644 src/transport/ble/mod.rs create mode 100644 src/transport/ble/pool.rs create mode 100644 src/transport/ble/stats.rs create mode 100644 testing/ble/.gitignore create mode 100644 testing/ble/Cargo.lock create mode 100644 testing/ble/Cargo.toml create mode 100644 testing/ble/ble_spike.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 986bf1f..51c707a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,9 @@ jobs: - name: Set SOURCE_DATE_EPOCH from git run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV" + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -92,6 +95,9 @@ jobs: - name: Set SOURCE_DATE_EPOCH from git run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV" + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/CHANGELOG.md b/CHANGELOG.md index fe541b7..1d27033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#21](https://github.com/jmcorgan/fips/pull/21), [@dskvr](https://github.com/dskvr)) +#### Bluetooth Transport + +- Bluetooth Low Energy (BLE) L2CAP Connection-Oriented Channel transport + with per-link MTU negotiation, behind the `ble` Cargo feature flag + (default-on, Linux only, requires BlueZ) +- BLE peer discovery via scan/probe with per-entry random jitter to + prevent herd effects when multiple nodes see the same beacon +- Periodic BLE beacon advertising with configurable interval and duration +- Cross-probe tie-breaker using deterministic NodeAddr comparison +- Connection pool with configurable capacity and eviction + ### Fixed - Control socket path detection in fipsctl and fipstop now checks for diff --git a/Cargo.lock b/Cargo.lock index 029fcef..1a8a61e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,6 +220,35 @@ dependencies = [ "piper", ] +[[package]] +name = "bluer" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af68112f5c60196495c8b0eea68349817855f565df5b04b2477916d09fb1a901" +dependencies = [ + "custom_debug", + "dbus", + "dbus-crossroads", + "dbus-tokio", + "displaydoc", + "futures", + "hex", + "lazy_static", + "libc", + "log", + "macaddr", + "nix 0.29.0", + "num-derive", + "num-traits", + "pin-project", + "serde", + "serde_json", + "strum 0.26.3", + "tokio", + "tokio-stream", + "uuid", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -578,14 +607,60 @@ dependencies = [ "phf", ] +[[package]] +name = "custom_debug" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" +dependencies = [ + "custom_debug_derive", +] + +[[package]] +name = "custom_debug_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", ] [[package]] @@ -601,17 +676,61 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.114", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.114", ] +[[package]] +name = "dbus" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190b6255e8ab55a7b568df5a883e9497edc3e4821c06396612048b430e5ad1e9" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + [[package]] name = "deltae" version = "0.3.2" @@ -681,6 +800,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "document-features" version = "0.2.12" @@ -786,6 +916,7 @@ name = "fips" version = "0.3.0-dev" dependencies = [ "bech32", + "bluer", "chacha20poly1305", "clap", "criterion", @@ -1101,7 +1232,7 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", @@ -1189,6 +1320,15 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libdbus-sys" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cbe856efeb50e4681f010e9aaa2bf0a644e10139e54cde10fc83a307c23bd9f" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.9.0" @@ -1264,6 +1404,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + [[package]] name = "matchers" version = "0.2.0" @@ -1618,6 +1764,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1641,6 +1807,12 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "plotters" version = "0.3.7" @@ -1809,7 +1981,7 @@ dependencies = [ "itertools 0.14.0", "kasuari", "lru", - "strum", + "strum 0.27.2", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -1861,7 +2033,7 @@ dependencies = [ "itertools 0.14.0", "line-clipping", "ratatui-core", - "strum", + "strum 0.27.2", "time", "unicode-segmentation", "unicode-width", @@ -2191,13 +2363,35 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.114", ] [[package]] @@ -2240,6 +2434,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "tempfile" version = "3.24.0" @@ -2435,6 +2640,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" diff --git a/Cargo.toml b/Cargo.toml index ac987da..ee0f3ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,9 @@ repository = "https://github.com/jmcorgan/fips" readme = "README.md" [features] -default = ["tui"] +default = ["tui", "ble"] tui = ["dep:ratatui"] +ble = ["dep:bluer"] [dependencies] ratatui = { version = "0.30", optional = true } @@ -37,6 +38,7 @@ futures = "0.3" simple-dns = "0.11.2" socket2 = { version = "0.6.2", features = ["all"] } tokio-socks = "0.5" +bluer = { version = "0.17", features = ["bluetoothd", "l2cap"], optional = true } [package.metadata.deb] maintainer = "Johnathan Corgan " @@ -44,12 +46,14 @@ copyright = "2026 Johnathan Corgan" license-file = ["LICENSE", "0"] section = "net" priority = "optional" -depends = "libc6, systemd" +depends = "libc6, systemd, libdbus-1-3" +recommends = "bluez" extended-description = """\ FIPS is a distributed, decentralized network routing protocol for mesh \ -nodes connecting over arbitrary transports including UDP, TCP, and Ethernet. \ -It provides encrypted peer-to-peer connectivity with automatic key management, \ -TUN-based virtual networking, and .fips DNS resolution.""" +nodes connecting over arbitrary transports including UDP, TCP, Ethernet, \ +Tor, and Bluetooth (BLE). It provides encrypted peer-to-peer connectivity \ +with automatic key management, TUN-based virtual networking, and .fips DNS \ +resolution.""" maintainer-scripts = "packaging/debian/" assets = [ ["target/release/fips", "/usr/bin/", "755"], @@ -66,6 +70,7 @@ conf-files = ["/etc/fips/fips.yaml", "/etc/fips/hosts"] [dev-dependencies] tempfile = "3.15" criterion = { version = "0.8.2", features = ["html_reports"] } +tokio = { version = "1", features = ["test-util"] } [[bin]] name = "fipsctl" diff --git a/README.md b/README.md index c93c135..52db6fa 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ endpoints. - **Self-organizing mesh routing** — spanning tree coordinates with bloom filter guided discovery, no global routing tables -- **Multi-transport** — UDP, TCP, Ethernet, and Tor today; designed for - Bluetooth, serial, and radio +- **Multi-transport** — UDP, TCP, Ethernet, Tor, and Bluetooth (BLE L2CAP) + today; designed for serial and radio - **Noise encryption** — hop-by-hop link encryption (IK) plus independent end-to-end session encryption (XK), with periodic rekey for forward secrecy - **Nostr-native identity** — secp256k1 keypairs as node addresses, no @@ -65,6 +65,10 @@ cargo build --release Requires Rust 1.85+ (edition 2024) and Linux with TUN support. +The BLE transport (enabled by default) requires BlueZ and libdbus. On +Debian/Ubuntu: `sudo apt install bluez libdbus-1-dev`. To build without BLE: +`cargo build --release --no-default-features --features tui`. + ## Installation After building, choose one of the following methods to install. @@ -240,7 +244,7 @@ testing/ Docker-based integration test harnesses ## Status & Roadmap FIPS is at **v0.2.0**. The core protocol works end-to-end over UDP, TCP, -Ethernet, and Tor with a small live mesh of deployed nodes. +Ethernet, Tor, and Bluetooth (BLE) with a small live mesh of deployed nodes. ### What works today @@ -253,7 +257,7 @@ Ethernet, and Tor with a small live mesh of deployed nodes. - Static hostname mapping (`/etc/fips/hosts`) with auto-reload - Per-link metrics (RTT, loss, jitter, goodput) and mesh size estimation - ECN congestion signaling (hop-by-hop CE relay, IPv6 CE marking, kernel drop detection) -- UDP, TCP, Ethernet, and Tor transports (SOCKS5 outbound + directory-mode onion service inbound) +- UDP, TCP, Ethernet, Tor, and BLE transports (BLE via L2CAP CoC with per-link MTU negotiation) - Runtime inspection and peer management via `fipsctl` and `fipstop` - Reproducible builds with toolchain pinning and SOURCE_DATE_EPOCH - Debian and systemd tarball packaging @@ -263,7 +267,6 @@ Ethernet, and Tor with a small live mesh of deployed nodes. - Peer discovery via Nostr relays (bootstrap without static peer lists) - Native API for FIPS-aware applications (npub:port addressing) -- Additional transports (Bluetooth) - Security audit of cryptographic protocols ### Longer-term diff --git a/docs/design/diagrams/fips-node-architecture.svg b/docs/design/diagrams/fips-node-architecture.svg index 61c72be..e6a6e0f 100644 --- a/docs/design/diagrams/fips-node-architecture.svg +++ b/docs/design/diagrams/fips-node-architecture.svg @@ -82,48 +82,44 @@ - - Overlay + + Overlay UDP IP - Tor - .onion + TCP + IP + + + Tor + .onion - - Shared Medium + + Shared Medium - - Ether - 802.3 + + Ether + 802.3 - - WiFi - 802.11 + + BLE + L2CAP - - BT - RFCOMM - - - Radio - Sat, ... + + Radio ... + future Point-to-Point - - Serial - UART - - - ... - + + Serial ... + future Internet / Overlay Peers diff --git a/docs/design/fips-configuration.md b/docs/design/fips-configuration.md index 3d8f614..32f43ad 100644 --- a/docs/design/fips-configuration.md +++ b/docs/design/fips-configuration.md @@ -485,6 +485,78 @@ HiddenServiceDir /var/lib/tor/fips HiddenServicePort 8443 127.0.0.1:8444 ``` +### BLE (`transports.ble.*`) + +Bluetooth Low Energy transport using L2CAP Connection-Oriented Channels. +Requires BlueZ and the `ble` Cargo feature flag (default-on). Linux only; +guarded by `#[cfg(target_os = "linux")]`. Communicates with BlueZ via D-Bus +using the `bluer` crate. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `transports.ble.adapter` | string | `"hci0"` | HCI adapter name | +| `transports.ble.psm` | u16 | `0x0085` (133) | L2CAP Protocol/Service Multiplexer | +| `transports.ble.mtu` | u16 | `2048` | Default MTU. Actual MTU is negotiated per-link during L2CAP connection setup. | +| `transports.ble.max_connections` | usize | `7` | Maximum concurrent BLE connections | +| `transports.ble.connect_timeout_ms` | u64 | `10000` | Outbound connect timeout in milliseconds | +| `transports.ble.advertise` | bool | `true` | Broadcast BLE beacon advertisements for peer discovery | +| `transports.ble.scan` | bool | `true` | Listen for BLE beacon advertisements from other nodes | +| `transports.ble.auto_connect` | bool | `false` | Automatically connect to discovered peers | +| `transports.ble.accept_connections` | bool | `true` | Accept incoming L2CAP connections | +| `transports.ble.scan_interval_secs` | u64 | `10` | Interval between BLE scan cycles | +| `transports.ble.beacon_interval_secs` | u64 | `10` | Interval between beacon advertising bursts | +| `transports.ble.beacon_duration_secs` | u64 | `3` | Duration of each beacon advertising burst | + +**Address format.** BLE peer addresses use the form +`"adapter/device_address"` — for example, `"hci0/AA:BB:CC:DD:EE:FF"`. + +**Scan/probe and tie-breaking.** When `scan` is enabled, the transport +periodically scans for BLE beacons from other FIPS nodes. Discovered +peers are probed with per-entry random jitter to prevent herd effects +when multiple nodes see the same beacon simultaneously. If two nodes +probe each other at the same time (cross-probe), a deterministic +tie-breaker based on NodeAddr comparison ensures only one connection +is established. + +**Connection pool.** The `max_connections` parameter limits the number of +concurrent BLE connections. When the pool is full, the least-recently-used +connection is evicted to make room for new connections. + +### BLE Example + +A node using BLE for local mesh discovery alongside UDP for internet peers: + +```yaml +node: + identity: + persistent: true + +tun: + enabled: true + +transports: + udp: + bind_addr: "0.0.0.0:2121" + ble: + adapter: "hci0" + advertise: true + scan: true + auto_connect: true + accept_connections: true + +peers: + - npub: "npub1abc..." + alias: "internet-peer" + addresses: + - transport: udp + addr: "203.0.113.5:2121" + connect_policy: auto_connect +``` + +BLE peers on the local radio range are discovered automatically via +beacons — no static peer entries needed. Internet peers still require +explicit configuration. + ## Peers (`peers[]`) Static peer list. Each entry defines a peer to connect to. @@ -493,8 +565,8 @@ Static peer list. Each entry defines a peer to connect to. |-----------|------|---------|-------------| | `peers[].npub` | string | *(required)* | Peer's Nostr public key (npub-encoded) | | `peers[].alias` | string | *(none)* | Human-readable name for logging | -| `peers[].addresses[].transport` | string | *(required)* | Transport type: `udp`, `tcp`, `ethernet`, or `tor` | -| `peers[].addresses[].addr` | string | *(required)* | Transport address. UDP/TCP: `"host:port"` (IP or DNS hostname). Ethernet: `"interface/mac"` (e.g., `"eth0/aa:bb:cc:dd:ee:ff"`). Tor: `".onion:port"` or `"host:port"` | +| `peers[].addresses[].transport` | string | *(required)* | Transport type: `udp`, `tcp`, `ethernet`, `tor`, or `ble` | +| `peers[].addresses[].addr` | string | *(required)* | Transport address. UDP/TCP: `"host:port"` (IP or DNS hostname). Ethernet: `"interface/mac"` (e.g., `"eth0/aa:bb:cc:dd:ee:ff"`). BLE: `"adapter/device_address"` (e.g., `"hci0/AA:BB:CC:DD:EE:FF"`). Tor: `".onion:port"` or `"host:port"` | | `peers[].addresses[].priority` | u8 | `100` | Address priority (lower = preferred) | | `peers[].connect_policy` | string | `"auto_connect"` | Connection policy: `auto_connect`, `on_demand`, or `manual` | | `peers[].auto_reconnect` | bool | `true` | Automatically reconnect after MMP link-dead removal (exponential backoff, unlimited retries) | @@ -720,6 +792,19 @@ transports: # # hostname_file: "/var/lib/tor/fips/hostname" # # bind_addr: "127.0.0.1:8444" # # max_inbound_connections: 64 + # ble: # uncomment to enable BLE transport (Linux only, requires BlueZ) + # adapter: "hci0" # HCI adapter name + # psm: 0x0085 # L2CAP PSM (133) + # mtu: 2048 # default MTU (negotiated per-link) + # max_connections: 7 # max concurrent BLE connections + # connect_timeout_ms: 10000 # outbound connect timeout + # advertise: true # broadcast BLE beacons + # scan: true # listen for BLE beacons + # auto_connect: false # connect to discovered peers + # accept_connections: true # accept incoming L2CAP connections + # scan_interval_secs: 10 # interval between scan cycles + # beacon_interval_secs: 10 # interval between beacon bursts + # beacon_duration_secs: 3 # duration of each beacon burst peers: # static peer list # - npub: "npub1..." diff --git a/docs/design/fips-intro.md b/docs/design/fips-intro.md index 4ddaba1..046e59a 100644 --- a/docs/design/fips-intro.md +++ b/docs/design/fips-intro.md @@ -524,9 +524,10 @@ forwarding, a publicly addressed peer, or relay through other mesh nodes. UDP hole punching and relay-assisted NAT traversal are potential future mechanisms but are not part of the current design. -> **Implementation status**: UDP/IP, TCP/IP, Ethernet, and Tor -> (SOCKS5 outbound + directory-mode inbound via onion service) -> transports are implemented. All others are future directions. +> **Implementation status**: UDP/IP, TCP/IP, Ethernet, Tor +> (SOCKS5 outbound + directory-mode inbound via onion service), +> and Bluetooth (BLE L2CAP CoC) transports are implemented. +> All others are future directions. See [fips-transport-layer.md](fips-transport-layer.md) for the full transport layer specification. diff --git a/packaging/common/fips.yaml b/packaging/common/fips.yaml index c226001..cabe916 100644 --- a/packaging/common/fips.yaml +++ b/packaging/common/fips.yaml @@ -37,6 +37,15 @@ transports: # auto_connect: true # accept_connections: true + # Bluetooth Low Energy transport — requires BlueZ and the 'ble' feature. + # ble: + # adapter: "hci0" + # mtu: 2048 + # advertise: true + # scan: true + # auto_connect: true + # accept_connections: true + peers: [] # Static peers for bootstrapping (UDP or TCP): # - npub: "npub1..." diff --git a/packaging/systemd/README.install.md b/packaging/systemd/README.install.md index 9770dcc..85ba088 100644 --- a/packaging/systemd/README.install.md +++ b/packaging/systemd/README.install.md @@ -62,7 +62,32 @@ transports: accept_connections: true ``` -### 3. Static Peers +### 3. Bluetooth Transport + +If using BLE for local mesh discovery, the FIPS binary must be built with +the `ble` feature (enabled by default). BlueZ must be installed and running: + +```bash +sudo apt install bluez +sudo systemctl enable --now bluetooth +``` + +Add your service user to the `bluetooth` group, or run with +`CAP_NET_ADMIN` + `CAP_NET_RAW` capabilities. + +Configure BLE in the transports section: + +```yaml +transports: + ble: + adapter: "hci0" + advertise: true + scan: true + auto_connect: true + accept_connections: true +``` + +### 4. Static Peers For bootstrapping over UDP or TCP, add known peers: @@ -76,7 +101,7 @@ peers: connect_policy: auto_connect ``` -### 4. DNS Resolver (optional, requires systemd-resolved) +### 5. DNS Resolver (optional, requires systemd-resolved) FIPS includes a DNS responder for `.fips` domain names (port 5354). On systems running `systemd-resolved`, the installer automatically enables diff --git a/src/config/mod.rs b/src/config/mod.rs index 700123c..65848c3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -34,7 +34,7 @@ pub use node::{ TreeConfig, }; pub use peer::{ConnectPolicy, PeerAddress, PeerConfig}; -pub use transport::{DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances, TransportsConfig, UdpConfig}; +pub use transport::{BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances, TransportsConfig, UdpConfig}; /// Default config filename. const CONFIG_FILENAME: &str = "fips.yaml"; diff --git a/src/config/transport.rs b/src/config/transport.rs index c7ad5b1..9e21edf 100644 --- a/src/config/transport.rs +++ b/src/config/transport.rs @@ -498,6 +498,153 @@ impl TorConfig { } } +// ============================================================================ +// BLE Transport Configuration +// ============================================================================ + +/// Default BLE L2CAP PSM (dynamic range). +const DEFAULT_BLE_PSM: u16 = 0x0085; + +/// Default BLE MTU for L2CAP CoC connections. +const DEFAULT_BLE_MTU: u16 = 2048; + +/// Default maximum concurrent BLE connections. +const DEFAULT_BLE_MAX_CONNECTIONS: usize = 7; + +/// Default BLE connect timeout in milliseconds. +const DEFAULT_BLE_CONNECT_TIMEOUT_MS: u64 = 10_000; + +/// Default BLE scan interval in seconds. +const DEFAULT_BLE_SCAN_INTERVAL_SECS: u64 = 10; + +/// Default BLE beacon interval in seconds. +const DEFAULT_BLE_BEACON_INTERVAL_SECS: u64 = 30; + +/// Default BLE beacon duration in seconds (how long each burst lasts). +const DEFAULT_BLE_BEACON_DURATION_SECS: u64 = 1; + +/// BLE transport instance configuration. +/// +/// BleConfig is always compiled (for config parsing on any platform), +/// but the transport runtime requires Linux and the `ble` feature. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BleConfig { + /// HCI adapter name (e.g., "hci0"). Required. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, + + /// L2CAP PSM for FIPS connections. Default: 0x0085 (133). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub psm: Option, + + /// Default MTU for BLE connections. Default: 2048. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mtu: Option, + + /// Maximum concurrent BLE connections. Default: 7. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_connections: Option, + + /// Outbound connect timeout in milliseconds. Default: 10000. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connect_timeout_ms: Option, + + /// Broadcast BLE advertisements. Default: true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub advertise: Option, + + /// Listen for BLE advertisements. Default: true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scan: Option, + + /// Auto-connect to discovered BLE peers. Default: false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_connect: Option, + + /// Accept incoming BLE connections. Default: true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accept_connections: Option, + + /// Scan interval in seconds. Default: 10. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scan_interval_secs: Option, + + /// Beacon interval in seconds between advertising bursts. Default: 10. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub beacon_interval_secs: Option, + + /// Beacon duration in seconds per advertising burst. Default: 3. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub beacon_duration_secs: Option, +} + +impl BleConfig { + /// Get the adapter name. Default: "hci0". + pub fn adapter(&self) -> &str { + self.adapter.as_deref().unwrap_or("hci0") + } + + /// Get the L2CAP PSM. Default: 0x0085. + pub fn psm(&self) -> u16 { + self.psm.unwrap_or(DEFAULT_BLE_PSM) + } + + /// Get the default MTU. Default: 2048. + pub fn mtu(&self) -> u16 { + self.mtu.unwrap_or(DEFAULT_BLE_MTU) + } + + /// Get the maximum concurrent connections. Default: 7. + pub fn max_connections(&self) -> usize { + self.max_connections.unwrap_or(DEFAULT_BLE_MAX_CONNECTIONS) + } + + /// Get the connect timeout in milliseconds. Default: 10000. + pub fn connect_timeout_ms(&self) -> u64 { + self.connect_timeout_ms + .unwrap_or(DEFAULT_BLE_CONNECT_TIMEOUT_MS) + } + + /// Whether to broadcast advertisements. Default: true. + pub fn advertise(&self) -> bool { + self.advertise.unwrap_or(true) + } + + /// Whether to scan for advertisements. Default: true. + pub fn scan(&self) -> bool { + self.scan.unwrap_or(true) + } + + /// Whether to auto-connect to discovered peers. Default: false. + pub fn auto_connect(&self) -> bool { + self.auto_connect.unwrap_or(false) + } + + /// Whether to accept incoming connections. Default: true. + pub fn accept_connections(&self) -> bool { + self.accept_connections.unwrap_or(true) + } + + /// Get the scan interval in seconds. Default: 10. + pub fn scan_interval_secs(&self) -> u64 { + self.scan_interval_secs + .unwrap_or(DEFAULT_BLE_SCAN_INTERVAL_SECS) + } + + /// Get the beacon interval in seconds. Default: 10. + pub fn beacon_interval_secs(&self) -> u64 { + self.beacon_interval_secs + .unwrap_or(DEFAULT_BLE_BEACON_INTERVAL_SECS) + } + + /// Get the beacon duration in seconds. Default: 3. + pub fn beacon_duration_secs(&self) -> u64 { + self.beacon_duration_secs + .unwrap_or(DEFAULT_BLE_BEACON_DURATION_SECS) + } +} + // ============================================================================ // TransportsConfig // ============================================================================ @@ -523,6 +670,10 @@ pub struct TransportsConfig { /// Tor transport instances. #[serde(default, skip_serializing_if = "is_transport_empty")] pub tor: TransportInstances, + + /// BLE transport instances. + #[serde(default, skip_serializing_if = "is_transport_empty")] + pub ble: TransportInstances, } /// Helper for skip_serializing_if on TransportInstances. @@ -533,7 +684,11 @@ fn is_transport_empty(instances: &TransportInstances) -> bool { impl TransportsConfig { /// Check if any transports are configured. pub fn is_empty(&self) -> bool { - self.udp.is_empty() && self.ethernet.is_empty() && self.tcp.is_empty() && self.tor.is_empty() + self.udp.is_empty() + && self.ethernet.is_empty() + && self.tcp.is_empty() + && self.tor.is_empty() + && self.ble.is_empty() } /// Merge another TransportsConfig into this one. @@ -552,5 +707,8 @@ impl TransportsConfig { if !other.tor.is_empty() { self.tor = other.tor; } + if !other.ble.is_empty() { + self.ble = other.ble; + } } } diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index 3a1a3fb..c405940 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -118,6 +118,30 @@ impl Node { continue; } } + } else if addr.transport == "ble" { + #[cfg(target_os = "linux")] + { + match self.resolve_ble_addr(&addr.addr) { + Ok(result) => result, + Err(e) => { + debug!( + transport = %addr.transport, + addr = %addr.addr, + error = %e, + "Failed to resolve BLE address" + ); + continue; + } + } + } + #[cfg(not(target_os = "linux"))] + { + debug!( + transport = %addr.transport, + "BLE transport not available on this platform" + ); + continue; + } } else { // Find a transport matching this address type let tid = match self.find_transport_for_type(&addr.transport) { @@ -513,7 +537,7 @@ impl Node { self.packet_rx = Some(packet_rx); // Initialize transports first (before TUN) - let transport_handles = self.create_transports(&packet_tx); + let transport_handles = self.create_transports(&packet_tx).await; for mut handle in transport_handles { let transport_id = handle.transport_id(); diff --git a/src/node/mod.rs b/src/node/mod.rs index f835cc1..dca9f42 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -675,7 +675,7 @@ impl Node { /// Create transport instances from configuration. /// /// Returns a vector of TransportHandles for all configured transports. - fn create_transports(&mut self, packet_tx: &PacketTx) -> Vec { + async fn create_transports(&mut self, packet_tx: &PacketTx) -> Vec { let mut transports = Vec::new(); // Collect UDP configs with optional names to avoid borrow conflicts @@ -744,6 +744,47 @@ impl Node { transports.push(TransportHandle::Tor(tor)); } + // Create BLE transport instances + #[cfg(target_os = "linux")] + { + let ble_instances: Vec<_> = self + .config + .transports + .ble + .iter() + .map(|(name, config)| (name.map(|s| s.to_string()), config.clone())) + .collect(); + + #[cfg(all(feature = "ble", not(test)))] + for (name, ble_config) in ble_instances { + let transport_id = self.allocate_transport_id(); + let adapter = ble_config.adapter().to_string(); + let mtu = ble_config.mtu(); + match crate::transport::ble::io::BluerIo::new(&adapter, mtu).await { + Ok(io) => { + 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)); + } + Err(e) => { + tracing::warn!(adapter = %adapter, error = %e, "failed to initialize BLE adapter"); + } + } + } + + #[cfg(any(not(feature = "ble"), test))] + if !ble_instances.is_empty() { + #[cfg(not(test))] + tracing::warn!("BLE transport configured but 'ble' feature not enabled at compile time"); + } + } + transports } @@ -806,6 +847,46 @@ impl Node { Ok((transport_id, TransportAddr::from_bytes(&mac))) } + /// Resolve a BLE address string (`"adapter/AA:BB:CC:DD:EE:FF"`) to a + /// (TransportId, TransportAddr) pair by finding the BLE transport + /// instance matching the adapter name. + #[cfg(target_os = "linux")] + fn resolve_ble_addr( + &self, + addr_str: &str, + ) -> Result<(TransportId, TransportAddr), NodeError> { + let ta = TransportAddr::from_string(addr_str); + let adapter = crate::transport::ble::addr::adapter_from_addr(&ta) + .ok_or_else(|| { + NodeError::NoTransportForType(format!( + "invalid BLE address format '{}': expected 'adapter/mac'", + addr_str + )) + })?; + + // Find the BLE transport for this adapter + let transport_id = self + .transports + .iter() + .find(|(_, handle)| { + handle.transport_type().name == "ble" && handle.is_operational() + }) + .map(|(id, _)| *id) + .ok_or_else(|| { + NodeError::NoTransportForType(format!( + "no operational BLE transport for adapter '{}'", + adapter + )) + })?; + + // Validate the address format + crate::transport::ble::addr::BleAddr::parse(addr_str).map_err(|e| { + NodeError::NoTransportForType(format!("invalid BLE address '{}': {}", addr_str, e)) + })?; + + Ok((transport_id, TransportAddr::from_string(addr_str))) + } + // === Identity Accessors === /// Get this node's identity. diff --git a/src/node/tests/ble.rs b/src/node/tests/ble.rs new file mode 100644 index 0000000..c3c974a --- /dev/null +++ b/src/node/tests/ble.rs @@ -0,0 +1,277 @@ +//! BLE transport integration tests. +//! +//! Tests that the BLE transport works end-to-end at the node level: +//! handshake, spanning tree convergence, mixed-transport routing. +//! All tests use MockBleIo (in-memory channels, no hardware needed). + +use super::*; +use crate::config::BleConfig; +use crate::transport::ble::addr::BleAddr; +use crate::transport::ble::io::{MockBleIo, MockBleStream}; +use crate::transport::ble::BleTransport; +use crate::transport::{packet_channel, Transport, TransportHandle, TransportId}; +use spanning_tree::{ + cleanup_nodes, drain_all_packets, initiate_handshake, verify_tree_convergence, TestNode, +}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex as StdMutex}; + +/// Generate a deterministic BLE address for test node `n`. +fn ble_addr(n: u8) -> BleAddr { + BleAddr { + adapter: "hci0".to_string(), + device: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, n], + } +} + +/// A pre-connected stream bank for MockBleIo connect handlers. +/// +/// When a connect handler fires, it looks up the target address in this +/// bank and returns the pre-created stream. The peer end should be +/// injected into the target node's acceptor separately. +type StreamBank = Arc>>; + +/// Create a test node with a BLE transport backed by MockBleIo. +/// +/// Returns the TestNode and its MockBleIo (via Arc inside the transport) +/// for test injection of connections and scan results. +async fn make_test_node_ble(node_num: u8) -> TestNode { + let mut node = make_node(); + let transport_id = TransportId::new(1); + let addr = ble_addr(node_num); + + let config = BleConfig { + adapter: Some("hci0".to_string()), + mtu: Some(2048), + accept_connections: Some(true), + scan: Some(false), // no auto-scan in tests + advertise: Some(false), // no advertising in tests + auto_connect: Some(false), + ..Default::default() + }; + + let io = MockBleIo::new("hci0", addr.clone()); + let (packet_tx, packet_rx) = packet_channel(256); + let mut transport = BleTransport::new(transport_id, None, config, io, packet_tx); + transport.start_async().await.unwrap(); + + let ta = addr.to_transport_addr(); + + node.transports + .insert(transport_id, TransportHandle::Ble(transport)); + + TestNode { + node, + transport_id, + packet_rx, + addr: ta, + } +} + +/// Extract the BleAddr from a TestNode's TransportAddr. +fn node_ble_addr(node: &TestNode) -> BleAddr { + BleAddr::parse(node.addr.as_str().unwrap()).unwrap() +} + +/// Wire a unidirectional BLE connection from node `i` to node `j`. +/// +/// Creates a MockBleStream pair, deposits one end in a stream bank for +/// node i's connect handler, and injects the other end into node j's +/// accept loop. Must be called after `make_test_node_ble()` and before +/// `initiate_handshake()`. +async fn wire_ble_connection(nodes: &[TestNode], i: usize, j: usize, bank: &StreamBank) { + let addr_i = node_ble_addr(&nodes[i]); + let addr_j = node_ble_addr(&nodes[j]); + + let (stream_i, stream_j) = MockBleStream::pair(addr_j.clone(), addr_i.clone(), 2048); + + // Store stream_i in the bank keyed by node j's address string. + // When node i connects to node j, the handler returns this stream. + let key = nodes[j].addr.to_string(); + bank.lock().unwrap().insert(key, stream_i); + + // Inject stream_j into node j's accept loop so it sees the inbound. + let transport_j = nodes[j].node.transports.get(&nodes[j].transport_id).unwrap(); + match transport_j { + TransportHandle::Ble(t) => { + t.io().inject_inbound(stream_j).await; + } + _ => panic!("expected BLE transport"), + } +} + +/// Install a connect handler on node `i` that draws from the stream bank. +fn install_connect_handler(nodes: &[TestNode], i: usize, bank: &StreamBank) { + let bank = Arc::clone(bank); + let transport_i = nodes[i].node.transports.get(&nodes[i].transport_id).unwrap(); + match transport_i { + TransportHandle::Ble(t) => { + t.io().set_connect_handler(move |addr, _psm| { + let key = addr.to_transport_addr().to_string(); + let mut map = bank.lock().unwrap(); + match map.remove(&key) { + Some(stream) => Ok(stream), + None => Err(crate::transport::TransportError::ConnectionRefused), + } + }); + } + _ => panic!("expected BLE transport"), + } +} + +/// Two BLE nodes complete a Noise handshake and establish bidirectional peering. +#[tokio::test] +async fn test_ble_two_node_handshake() { + let mut nodes = vec![make_test_node_ble(1).await, make_test_node_ble(2).await]; + + // Wire connection: node 0 → node 1 + let bank: StreamBank = Arc::new(StdMutex::new(HashMap::new())); + wire_ble_connection(&nodes, 0, 1, &bank).await; + install_connect_handler(&nodes, 0, &bank); + + // Initiate handshake (connect-on-send creates the BLE connection) + initiate_handshake(&mut nodes, 0, 1).await; + + // Drain all packets (handshake + TreeAnnounce exchange) + let total = drain_all_packets(&mut nodes, false).await; + assert!(total > 0, "should have processed packets"); + + // Verify bidirectional peering + let addr_0 = *nodes[0].node.node_addr(); + let addr_1 = *nodes[1].node.node_addr(); + assert!( + nodes[0].node.get_peer(&addr_1).is_some(), + "node 0 should have node 1 as peer" + ); + assert!( + nodes[1].node.get_peer(&addr_0).is_some(), + "node 1 should have node 0 as peer" + ); + + cleanup_nodes(&mut nodes).await; +} + +/// Three BLE nodes in a chain converge to a consistent spanning tree. +#[tokio::test] +async fn test_ble_three_node_chain() { + let mut nodes = vec![ + make_test_node_ble(1).await, + make_test_node_ble(2).await, + make_test_node_ble(3).await, + ]; + + let bank: StreamBank = Arc::new(StdMutex::new(HashMap::new())); + + // Wire: 0 -- 1 -- 2 + wire_ble_connection(&nodes, 0, 1, &bank).await; + wire_ble_connection(&nodes, 1, 2, &bank).await; + install_connect_handler(&nodes, 0, &bank); + install_connect_handler(&nodes, 1, &bank); + + initiate_handshake(&mut nodes, 0, 1).await; + initiate_handshake(&mut nodes, 1, 2).await; + + let total = drain_all_packets(&mut nodes, false).await; + assert!(total > 0, "should have processed packets"); + + // Verify spanning tree convergence + verify_tree_convergence(&nodes); + + // Verify correct root + let expected_root = nodes.iter().map(|tn| *tn.node.node_addr()).min().unwrap(); + for tn in &nodes { + assert_eq!(*tn.node.tree_state().root(), expected_root); + } + + // Verify peer counts + assert_eq!(nodes[0].node.peer_count(), 1); + assert_eq!(nodes[1].node.peer_count(), 2); + assert_eq!(nodes[2].node.peer_count(), 1); + + // Verify bloom filter reachability: node 0 → node 2 + let addr_2 = *nodes[2].node.node_addr(); + let reaches = nodes[0].node.peers().any(|p| p.may_reach(&addr_2)); + assert!(reaches, "node 0 should see node 2 as reachable"); + + cleanup_nodes(&mut nodes).await; +} + +/// Mixed transport: UDP and BLE nodes coexist in independent components. +#[tokio::test] +async fn test_ble_mixed_transport() { + use spanning_tree::{make_test_node, verify_tree_convergence_components}; + + let udp_0 = make_test_node().await; + let udp_1 = make_test_node().await; + let ble_0 = make_test_node_ble(1).await; + let ble_1 = make_test_node_ble(2).await; + + let mut nodes = vec![udp_0, udp_1, ble_0, ble_1]; + + // Wire BLE pair + let bank: StreamBank = Arc::new(StdMutex::new(HashMap::new())); + wire_ble_connection(&nodes, 2, 3, &bank).await; + install_connect_handler(&nodes, 2, &bank); + + // Handshake within each component + initiate_handshake(&mut nodes, 0, 1).await; // UDP pair + initiate_handshake(&mut nodes, 2, 3).await; // BLE pair + + let total = drain_all_packets(&mut nodes, false).await; + assert!(total > 0); + + // Verify each component converges independently + verify_tree_convergence_components(&nodes, &[vec![0, 1], vec![2, 3]]); + + // BLE component has its own root + let ble_root = std::cmp::min(*nodes[2].node.node_addr(), *nodes[3].node.node_addr()); + assert_eq!(*nodes[2].node.tree_state().root(), ble_root); + assert_eq!(*nodes[3].node.tree_state().root(), ble_root); + + cleanup_nodes(&mut nodes).await; +} + +/// BLE scan+probe loop discovers peers via adapter scan events. +#[tokio::test(start_paused = true)] +async fn test_ble_discovery() { + let mut node = make_node(); + let transport_id = TransportId::new(1); + let addr = ble_addr(1); + + // Enable scanning so the scan+probe loop runs + let config = BleConfig { + adapter: Some("hci0".to_string()), + mtu: Some(2048), + accept_connections: Some(true), + scan: Some(true), + advertise: Some(false), + auto_connect: Some(false), + ..Default::default() + }; + + let io = MockBleIo::new("hci0", addr.clone()); + let (packet_tx, packet_rx) = packet_channel(256); + let mut transport = BleTransport::new(transport_id, None, config, io, packet_tx); + transport.start_async().await.unwrap(); + + // Inject scan results via the I/O mock + transport.io().inject_scan_result(ble_addr(2)).await; + transport.io().inject_scan_result(ble_addr(3)).await; + + // Let scan_probe_loop pick up results and schedule jitter + tokio::task::yield_now().await; + // Advance past max jitter so probes fire + tokio::time::advance(std::time::Duration::from_secs(6)).await; + tokio::task::yield_now().await; + + // Without pubkey set, peers appear as bare MACs in discovery buffer + let peers = transport.discover().unwrap(); + assert_eq!(peers.len(), 2); + + let ta = addr.to_transport_addr(); + node.transports + .insert(transport_id, TransportHandle::Ble(transport)); + + let mut nodes = vec![TestNode { node, transport_id, packet_rx, addr: ta }]; + cleanup_nodes(&mut nodes).await; +} diff --git a/src/node/tests/mod.rs b/src/node/tests/mod.rs index 6c4ef09..20f8967 100644 --- a/src/node/tests/mod.rs +++ b/src/node/tests/mod.rs @@ -5,6 +5,8 @@ use crate::PeerIdentity; use std::time::Duration; mod bloom; +#[cfg(target_os = "linux")] +mod ble; mod disconnect; mod discovery; #[cfg(target_os = "linux")] diff --git a/src/transport/ble/addr.rs b/src/transport/ble/addr.rs new file mode 100644 index 0000000..d0ab952 --- /dev/null +++ b/src/transport/ble/addr.rs @@ -0,0 +1,185 @@ +//! BLE transport address parsing and formatting. +//! +//! Address format: `"hci0/AA:BB:CC:DD:EE:FF"` — adapter name / device address. + +use crate::transport::{TransportAddr, TransportError}; + +/// A parsed BLE device address. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BleAddr { + /// HCI adapter name (e.g., "hci0"). + pub adapter: String, + /// 6-byte Bluetooth device address. + pub device: [u8; 6], +} + +impl BleAddr { + /// Parse a BLE address from the `"adapter/AA:BB:CC:DD:EE:FF"` format. + pub fn parse(s: &str) -> Result { + let (adapter, mac_str) = s + .split_once('/') + .ok_or_else(|| TransportError::InvalidAddress(format!("missing '/' in BLE address: {s}")))?; + + if adapter.is_empty() { + return Err(TransportError::InvalidAddress("empty adapter name".into())); + } + + let device = parse_mac(mac_str).ok_or_else(|| { + TransportError::InvalidAddress(format!("invalid MAC address: {mac_str}")) + })?; + + Ok(Self { + adapter: adapter.to_string(), + device, + }) + } + + /// Format as `"adapter/AA:BB:CC:DD:EE:FF"`. + pub fn to_string_repr(&self) -> String { + format!( + "{}/{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + self.adapter, + self.device[0], + self.device[1], + self.device[2], + self.device[3], + self.device[4], + self.device[5], + ) + } + + /// Convert to a `TransportAddr` (string representation). + pub fn to_transport_addr(&self) -> TransportAddr { + TransportAddr::from_string(&self.to_string_repr()) + } +} + +// ============================================================================ +// bluer type conversions (behind ble feature) +// ============================================================================ + +#[cfg(feature = "ble")] +impl BleAddr { + /// Construct from a bluer `Address` and adapter name. + pub fn from_bluer(addr: bluer::Address, adapter: &str) -> Self { + Self { + adapter: adapter.to_string(), + device: addr.0, + } + } + + /// Convert to a bluer `Address`. + pub fn to_bluer_address(&self) -> bluer::Address { + bluer::Address(self.device) + } + + /// Convert to a bluer L2CAP `SocketAddr` with the given PSM. + pub fn to_socket_addr(&self, psm: u16) -> bluer::l2cap::SocketAddr { + bluer::l2cap::SocketAddr::new( + self.to_bluer_address(), + bluer::AddressType::LePublic, + psm, + ) + } +} + +impl std::fmt::Display for BleAddr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_string_repr()) + } +} + +/// Parse a colon-delimited MAC address string into 6 bytes. +fn parse_mac(s: &str) -> Option<[u8; 6]> { + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 6 { + return None; + } + let mut mac = [0u8; 6]; + for (i, part) in parts.iter().enumerate() { + mac[i] = u8::from_str_radix(part, 16).ok()?; + } + Some(mac) +} + +/// Extract the adapter name from a transport address string. +/// +/// Returns `None` if the address is not valid UTF-8 or doesn't contain '/'. +pub fn adapter_from_addr(addr: &TransportAddr) -> Option<&str> { + addr.as_str()?.split_once('/').map(|(adapter, _)| adapter) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_valid() { + let addr = BleAddr::parse("hci0/AA:BB:CC:DD:EE:FF").unwrap(); + assert_eq!(addr.adapter, "hci0"); + assert_eq!(addr.device, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + } + + #[test] + fn test_parse_lowercase() { + let addr = BleAddr::parse("hci1/aa:bb:cc:dd:ee:ff").unwrap(); + assert_eq!(addr.adapter, "hci1"); + assert_eq!(addr.device, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + } + + #[test] + fn test_roundtrip() { + let original = "hci0/AA:BB:CC:DD:EE:FF"; + let addr = BleAddr::parse(original).unwrap(); + assert_eq!(addr.to_string_repr(), original); + } + + #[test] + fn test_display() { + let addr = BleAddr::parse("hci0/01:02:03:04:05:06").unwrap(); + assert_eq!(format!("{addr}"), "hci0/01:02:03:04:05:06"); + } + + #[test] + fn test_to_transport_addr() { + let addr = BleAddr::parse("hci0/AA:BB:CC:DD:EE:FF").unwrap(); + let ta = addr.to_transport_addr(); + assert_eq!(ta.as_str(), Some("hci0/AA:BB:CC:DD:EE:FF")); + } + + #[test] + fn test_parse_missing_slash() { + assert!(BleAddr::parse("hci0-AA:BB:CC:DD:EE:FF").is_err()); + } + + #[test] + fn test_parse_empty_adapter() { + assert!(BleAddr::parse("/AA:BB:CC:DD:EE:FF").is_err()); + } + + #[test] + fn test_parse_invalid_mac_short() { + assert!(BleAddr::parse("hci0/AA:BB:CC").is_err()); + } + + #[test] + fn test_parse_invalid_mac_hex() { + assert!(BleAddr::parse("hci0/GG:HH:II:JJ:KK:LL").is_err()); + } + + #[test] + fn test_adapter_from_addr() { + let ta = TransportAddr::from_string("hci0/AA:BB:CC:DD:EE:FF"); + assert_eq!(adapter_from_addr(&ta), Some("hci0")); + } + + #[test] + fn test_adapter_from_addr_no_slash() { + let ta = TransportAddr::from_string("invalid"); + assert_eq!(adapter_from_addr(&ta), None); + } +} diff --git a/src/transport/ble/discovery.rs b/src/transport/ble/discovery.rs new file mode 100644 index 0000000..364b677 --- /dev/null +++ b/src/transport/ble/discovery.rs @@ -0,0 +1,126 @@ +//! BLE discovery via advertising and scanning. +//! +//! BLE advertisements carry a 128-bit FIPS service UUID for identification. +//! Post-forklift, advertisements are UUID-only (no identity material); +//! identity is exchanged during the Noise handshake. + +use crate::transport::{DiscoveredPeer, TransportId}; +use secp256k1::XOnlyPublicKey; +use std::sync::Mutex; + +use super::addr::BleAddr; + +/// Buffer for discovered BLE peers, drained by `discover()`. +/// +/// Follows the same pattern as Ethernet's `DiscoveryBuffer`: peers are +/// added from the scan loop and drained by the node's discovery polling. +pub struct DiscoveryBuffer { + transport_id: TransportId, + peers: Mutex>, +} + +impl DiscoveryBuffer { + /// Create a new empty discovery buffer. + pub fn new(transport_id: TransportId) -> Self { + Self { + transport_id, + peers: Mutex::new(Vec::new()), + } + } + + /// Add a discovered BLE peer. + /// + /// Deduplicates by device address — keeps the latest entry. + pub fn add_peer(&self, addr: &BleAddr) { + let ta = addr.to_transport_addr(); + let peer = DiscoveredPeer::new(self.transport_id, ta.clone()); + let mut peers = self.peers.lock().unwrap(); + // Deduplicate by address string + let addr_str = addr.to_string_repr(); + peers.retain(|p| p.addr.as_str() != Some(addr_str.as_str())); + peers.push(peer); + } + + /// Add a discovered BLE peer with a known public key. + /// + /// Used after the pre-handshake pubkey exchange confirms the peer's + /// identity. The pubkey_hint enables the node's auto-connect path + /// to initiate the IK handshake. + pub fn add_peer_with_pubkey(&self, addr: &BleAddr, pubkey: XOnlyPublicKey) { + let ta = addr.to_transport_addr(); + let peer = DiscoveredPeer::with_hint(self.transport_id, ta.clone(), pubkey); + let mut peers = self.peers.lock().unwrap(); + let addr_str = addr.to_string_repr(); + peers.retain(|p| p.addr.as_str() != Some(addr_str.as_str())); + peers.push(peer); + } + + /// Drain all discovered peers since the last call. + pub fn take(&self) -> Vec { + let mut peers = self.peers.lock().unwrap(); + std::mem::take(&mut *peers) + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::TransportAddr; + + fn test_addr(n: u8) -> BleAddr { + BleAddr { + adapter: "hci0".to_string(), + device: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, n], + } + } + + #[test] + fn test_discovery_buffer_add_take() { + let buffer = DiscoveryBuffer::new(TransportId::new(1)); + buffer.add_peer(&test_addr(1)); + + let peers = buffer.take(); + assert_eq!(peers.len(), 1); + + // Second take should be empty + let peers = buffer.take(); + assert!(peers.is_empty()); + } + + #[test] + fn test_discovery_buffer_dedup() { + let buffer = DiscoveryBuffer::new(TransportId::new(1)); + buffer.add_peer(&test_addr(1)); + buffer.add_peer(&test_addr(1)); // same address again + + let peers = buffer.take(); + assert_eq!(peers.len(), 1); + } + + #[test] + fn test_discovery_buffer_multiple_peers() { + let buffer = DiscoveryBuffer::new(TransportId::new(1)); + buffer.add_peer(&test_addr(1)); + buffer.add_peer(&test_addr(2)); + buffer.add_peer(&test_addr(3)); + + let peers = buffer.take(); + assert_eq!(peers.len(), 3); + } + + #[test] + fn test_discovery_buffer_transport_addr_format() { + let buffer = DiscoveryBuffer::new(TransportId::new(1)); + buffer.add_peer(&test_addr(0x42)); + + let peers = buffer.take(); + assert_eq!( + peers[0].addr, + TransportAddr::from_string("hci0/AA:BB:CC:DD:EE:42") + ); + } +} diff --git a/src/transport/ble/io.rs b/src/transport/ble/io.rs new file mode 100644 index 0000000..86df395 --- /dev/null +++ b/src/transport/ble/io.rs @@ -0,0 +1,774 @@ +//! BLE I/O abstraction layer. +//! +//! Defines the `BleIo` trait that separates transport logic from the +//! BlueZ/bluer stack. `BluerIo` (behind `cfg(feature = "ble")`) provides +//! the real implementation; `MockBleIo` provides an in-memory test double. + +use crate::transport::TransportError; + +use super::addr::BleAddr; + +// ============================================================================ +// BLE I/O Traits +// ============================================================================ + +/// A connected L2CAP stream for sending and receiving data. +pub trait BleStream: Send + Sync { + /// Send data over the L2CAP connection. + fn send( + &self, + data: &[u8], + ) -> impl std::future::Future> + Send; + + /// Receive data from the L2CAP connection. + /// + /// Returns the number of bytes read into `buf`. + fn recv( + &self, + buf: &mut [u8], + ) -> impl std::future::Future> + Send; + + /// Get the L2CAP send MTU for this connection. + fn send_mtu(&self) -> u16; + + /// Get the L2CAP receive MTU for this connection. + fn recv_mtu(&self) -> u16; + + /// Get the remote device address. + fn remote_addr(&self) -> &BleAddr; +} + +/// An acceptor that yields inbound L2CAP connections. +pub trait BleAcceptor: Send { + /// The concrete stream type yielded by this acceptor. + type Stream: BleStream + 'static; + + /// Accept the next inbound connection. + fn accept( + &mut self, + ) -> impl std::future::Future> + Send; +} + +/// A scanner that yields discovered BLE devices advertising the FIPS UUID. +pub trait BleScanner: Send { + /// Wait for the next discovered device. + /// + /// Returns `None` when scanning is stopped. + fn next( + &mut self, + ) -> impl std::future::Future> + Send; +} + +/// Core BLE I/O operations. +/// +/// This trait abstracts the BlueZ/bluer stack so that `BleTransport` +/// can be tested with `MockBleIo` (in-memory channels) in CI without +/// requiring Bluetooth hardware, D-Bus, or bluetoothd. +pub trait BleIo: Send + Sync + 'static { + /// The concrete stream type returned by this I/O implementation. + type Stream: BleStream + 'static; + /// The concrete acceptor type. + type Acceptor: BleAcceptor + 'static; + /// The concrete scanner type. + type Scanner: BleScanner + 'static; + + /// Start listening for inbound L2CAP connections on the given PSM. + fn listen( + &self, + psm: u16, + ) -> impl std::future::Future> + Send; + + /// Connect to a remote BLE device on the given PSM. + fn connect( + &self, + addr: &BleAddr, + psm: u16, + ) -> impl std::future::Future> + Send; + + /// Start advertising the FIPS service UUID. + fn start_advertising( + &self, + ) -> impl std::future::Future> + Send; + + /// Stop advertising. + fn stop_advertising( + &self, + ) -> impl std::future::Future> + Send; + + /// Start passive scanning for FIPS service UUID advertisements. + fn start_scanning( + &self, + ) -> impl std::future::Future> + Send; + + /// Get the adapter's BLE address. + fn local_addr(&self) -> Result; + + /// Get the adapter name (e.g., "hci0"). + fn adapter_name(&self) -> &str; +} + +// ============================================================================ +// BluerIo — Production BLE I/O via BlueZ D-Bus +// ============================================================================ + +#[cfg(feature = "ble")] +mod bluer_impl { + use super::*; + use crate::transport::TransportError; + + use bluer::l2cap::{SeqPacket, SeqPacketListener, Socket, SocketAddr}; + use bluer::{adv::Advertisement, AdapterEvent, AddressType, DiscoveryFilter, DiscoveryTransport}; + use futures::StreamExt; + use std::collections::{BTreeSet, HashSet}; + use std::pin::Pin; + use tokio::sync::Mutex; + use tracing::{debug, trace}; + + /// FIPS BLE service UUID. + /// + /// Derived from SHA-256("FIPS: welcome to cryptoanarchy") with UUID v4 + /// version/variant bits applied. + pub const FIPS_SERVICE_UUID: bluer::Uuid = + bluer::Uuid::from_u128(0x9c90_b790_2cc5_42c0_9f87_c9cc_4064_8f4c); + + /// Map a bluer error to a TransportError. + fn map_err(context: &str, e: bluer::Error) -> TransportError { + TransportError::Io(std::io::Error::other(format!("{}: {}", context, e))) + } + + /// Map a std::io::Error to a TransportError. + fn map_io_err(context: &str, e: std::io::Error) -> TransportError { + TransportError::Io(std::io::Error::new( + e.kind(), + format!("{}: {}", context, e), + )) + } + + // ---------------------------------------------------------------- + // BluerStream + // ---------------------------------------------------------------- + + /// BLE stream wrapping a bluer L2CAP SeqPacket connection. + pub struct BluerStream { + conn: SeqPacket, + remote: BleAddr, + send_mtu: u16, + recv_mtu: u16, + } + + impl BluerStream { + /// Construct from a connected SeqPacket, querying MTU values. + pub fn new(conn: SeqPacket, remote: BleAddr) -> Result { + let send_mtu = conn + .send_mtu() + .map_err(|e| map_io_err("send_mtu", e))? as u16; + let recv_mtu = conn + .recv_mtu() + .map_err(|e| map_io_err("recv_mtu", e))? as u16; + Ok(Self { conn, remote, send_mtu, recv_mtu }) + } + } + + impl BleStream for BluerStream { + async fn send(&self, data: &[u8]) -> Result<(), TransportError> { + self.conn + .send(data) + .await + .map(|_| ()) + .map_err(|e| TransportError::SendFailed(format!("{}", e))) + } + + async fn recv(&self, buf: &mut [u8]) -> Result { + self.conn + .recv(buf) + .await + .map_err(|e| TransportError::RecvFailed(format!("{}", e))) + } + + fn send_mtu(&self) -> u16 { + self.send_mtu + } + + fn recv_mtu(&self) -> u16 { + self.recv_mtu + } + + fn remote_addr(&self) -> &BleAddr { + &self.remote + } + } + + // ---------------------------------------------------------------- + // BluerAcceptor + // ---------------------------------------------------------------- + + /// Acceptor wrapping a bluer L2CAP SeqPacketListener. + pub struct BluerAcceptor { + listener: SeqPacketListener, + adapter_name: String, + } + + impl BleAcceptor for BluerAcceptor { + type Stream = BluerStream; + + async fn accept(&mut self) -> Result { + let (conn, peer_sa) = self + .listener + .accept() + .await + .map_err(|e| map_io_err("accept", e))?; + + let remote = BleAddr::from_bluer(peer_sa.addr, &self.adapter_name); + BluerStream::new(conn, remote) + } + } + + // ---------------------------------------------------------------- + // BluerScanner + // ---------------------------------------------------------------- + + /// Scanner wrapping a bluer discovery event stream. + pub struct BluerScanner { + events: Pin + Send>>, + adapter: bluer::Adapter, + adapter_name: String, + } + + impl BleScanner for BluerScanner { + async fn next(&mut self) -> Option { + loop { + match self.events.next().await { + Some(AdapterEvent::DeviceAdded(addr)) => { + // Check if device advertises FIPS UUID + if let Ok(device) = self.adapter.device(addr) { + match device.uuids().await { + Ok(Some(uuids)) if uuids.contains(&FIPS_SERVICE_UUID) => { + let ble_addr = + BleAddr::from_bluer(addr, &self.adapter_name); + debug!(addr = %ble_addr, "BLE scanner: FIPS peer found"); + return Some(ble_addr); + } + Ok(_) => { + trace!(addr = %addr, "BLE scanner: device without FIPS UUID"); + } + Err(e) => { + trace!(addr = %addr, error = %e, "BLE scanner: failed to read UUIDs"); + } + } + } + } + Some(_) => continue, + None => return None, + } + } + } + } + + // ---------------------------------------------------------------- + // BluerIo + // ---------------------------------------------------------------- + + /// Production BLE I/O implementation via BlueZ D-Bus (bluer crate). + pub struct BluerIo { + #[allow(dead_code)] // Session must be kept alive for the adapter. + session: bluer::Session, + adapter: bluer::Adapter, + adapter_name: String, + adv_handle: Mutex>, + mtu: u16, + } + + impl BluerIo { + /// Create a new BluerIo for the given adapter. + /// + /// Connects to BlueZ via D-Bus and powers on the adapter. + pub async fn new(adapter_name: &str, mtu: u16) -> Result { + let session = bluer::Session::new() + .await + .map_err(|e| map_err("Session::new", e))?; + + let adapter = if adapter_name == "default" { + session + .default_adapter() + .await + .map_err(|e| map_err("default_adapter", e))? + } else { + session + .adapter(adapter_name) + .map_err(|e| map_err("adapter", e))? + }; + + adapter + .set_powered(true) + .await + .map_err(|e| map_err("set_powered", e))?; + + let name = adapter.name().to_string(); + debug!(adapter = %name, "BluerIo initialized"); + + Ok(Self { + session, + adapter, + adapter_name: name, + adv_handle: Mutex::new(None), + mtu, + }) + } + } + + impl BleIo for BluerIo { + type Stream = BluerStream; + type Acceptor = BluerAcceptor; + type Scanner = BluerScanner; + + async fn listen(&self, psm: u16) -> Result { + let local_addr = self + .adapter + .address() + .await + .map_err(|e| map_err("address", e))?; + + let sa = SocketAddr::new(local_addr, AddressType::LePublic, psm); + let listener = SeqPacketListener::bind(sa) + .await + .map_err(|e| map_io_err("bind", e))?; + + // Request high MTU for accepted connections + listener + .as_ref() + .set_recv_mtu(self.mtu) + .map_err(|e| map_io_err("set_recv_mtu", e))?; + + debug!(psm, mtu = self.mtu, "BLE listener bound"); + + Ok(BluerAcceptor { + listener, + adapter_name: self.adapter_name.clone(), + }) + } + + async fn connect( + &self, + addr: &BleAddr, + psm: u16, + ) -> Result { + let target_sa = addr.to_socket_addr(psm); + + let socket = Socket::::new_seq_packet() + .map_err(|e| map_io_err("new_seq_packet", e))?; + socket + .bind(SocketAddr::any_le()) + .map_err(|e| map_io_err("bind", e))?; + socket + .set_recv_mtu(self.mtu) + .map_err(|e| map_io_err("set_recv_mtu", e))?; + + let conn = socket + .connect(target_sa) + .await + .map_err(|e| map_io_err("connect", e))?; + + let remote = addr.clone(); + BluerStream::new(conn, remote) + } + + async fn start_advertising(&self) -> Result<(), TransportError> { + let adv = Advertisement { + advertisement_type: bluer::adv::Type::Peripheral, + service_uuids: { + let mut s = BTreeSet::new(); + s.insert(FIPS_SERVICE_UUID); + s + }, + local_name: Some("fips".to_string()), + ..Default::default() + }; + + let handle = self + .adapter + .advertise(adv) + .await + .map_err(|e| map_err("advertise", e))?; + + *self.adv_handle.lock().await = Some(handle); + debug!("BLE advertising started"); + Ok(()) + } + + async fn stop_advertising(&self) -> Result<(), TransportError> { + let _ = self.adv_handle.lock().await.take(); + debug!("BLE advertising stopped"); + Ok(()) + } + + async fn start_scanning(&self) -> Result { + // Set discovery filter for LE transport with FIPS UUID + let filter = DiscoveryFilter { + transport: DiscoveryTransport::Le, + uuids: { + let mut s = HashSet::new(); + s.insert(FIPS_SERVICE_UUID); + s + }, + ..Default::default() + }; + + self.adapter + .set_discovery_filter(filter) + .await + .map_err(|e| map_err("set_discovery_filter", e))?; + + let events = self + .adapter + .discover_devices() + .await + .map_err(|e| map_err("discover_devices", e))?; + + debug!("BLE scanning started"); + + Ok(BluerScanner { + events: Box::pin(events), + adapter: self.adapter.clone(), + adapter_name: self.adapter_name.clone(), + }) + } + + fn local_addr(&self) -> Result { + // Use futures::executor::block_on since this is a sync method + // but needs an async call. The adapter address is cached so + // the D-Bus call is fast. + let addr = futures::executor::block_on(self.adapter.address()) + .map_err(|e| map_err("address", e))?; + Ok(BleAddr::from_bluer(addr, &self.adapter_name)) + } + + fn adapter_name(&self) -> &str { + &self.adapter_name + } + } + + // Compile-time assertion that BluerIo satisfies Send + Sync. + #[allow(dead_code)] + fn _assert_bluer_io_send_sync() { + fn require() {} + require::(); + } +} + +#[cfg(feature = "ble")] +pub use bluer_impl::{BluerAcceptor, BluerIo, BluerScanner, BluerStream, FIPS_SERVICE_UUID}; + +// ============================================================================ +// Mock BLE I/O (for testing without hardware) +// ============================================================================ + +/// Mock BLE stream backed by tokio channels. +pub struct MockBleStream { + addr: BleAddr, + send_mtu: u16, + recv_mtu: u16, + tx: tokio::sync::mpsc::Sender>, + rx: tokio::sync::Mutex>>, +} + +impl MockBleStream { + /// Create a linked pair of mock streams simulating an L2CAP connection. + pub fn pair( + addr_a: BleAddr, + addr_b: BleAddr, + mtu: u16, + ) -> (Self, Self) { + let (tx_a, rx_a) = tokio::sync::mpsc::channel(64); + let (tx_b, rx_b) = tokio::sync::mpsc::channel(64); + let stream_a = Self { + addr: addr_b.clone(), + send_mtu: mtu, + recv_mtu: mtu, + tx: tx_a, + rx: tokio::sync::Mutex::new(rx_b), + }; + let stream_b = Self { + addr: addr_a, + send_mtu: mtu, + recv_mtu: mtu, + tx: tx_b, + rx: tokio::sync::Mutex::new(rx_a), + }; + (stream_a, stream_b) + } +} + +impl BleStream for MockBleStream { + async fn send(&self, data: &[u8]) -> Result<(), TransportError> { + self.tx + .send(data.to_vec()) + .await + .map_err(|_| TransportError::SendFailed("channel closed".into())) + } + + async fn recv(&self, buf: &mut [u8]) -> Result { + let mut rx = self.rx.lock().await; + match rx.recv().await { + Some(data) => { + let len = data.len().min(buf.len()); + buf[..len].copy_from_slice(&data[..len]); + Ok(len) + } + None => Ok(0), // channel closed = connection closed = zero-length read + } + } + + fn send_mtu(&self) -> u16 { + self.send_mtu + } + + fn recv_mtu(&self) -> u16 { + self.recv_mtu + } + + fn remote_addr(&self) -> &BleAddr { + &self.addr + } +} + +/// Mock BLE acceptor backed by a channel of pre-connected streams. +pub struct MockBleAcceptor { + rx: tokio::sync::mpsc::Receiver, +} + +impl BleAcceptor for MockBleAcceptor { + type Stream = MockBleStream; + + async fn accept(&mut self) -> Result { + self.rx + .recv() + .await + .ok_or(TransportError::RecvFailed("acceptor channel closed".into())) + } +} + +/// Mock BLE scanner backed by a channel of discovered addresses. +pub struct MockBleScanner { + rx: tokio::sync::mpsc::Receiver, +} + +impl BleScanner for MockBleScanner { + async fn next(&mut self) -> Option { + self.rx.recv().await + } +} + +/// Handler type for outbound mock connections. +type ConnectHandler = + Box Result + Send + Sync>; + +/// Mock BLE I/O for testing without hardware. +/// +/// Create with `MockBleIo::new()`, then use `inject_*` methods to +/// feed connections and scan results into the transport under test. +pub struct MockBleIo { + adapter: String, + local_addr: BleAddr, + accept_tx: tokio::sync::mpsc::Sender, + accept_rx: std::sync::Mutex>>, + scan_tx: tokio::sync::mpsc::Sender, + scan_rx: std::sync::Mutex>>, + connect_handler: std::sync::Mutex>, +} + +impl MockBleIo { + /// Create a new mock BLE I/O with the given adapter name and address. + pub fn new(adapter: &str, local_addr: BleAddr) -> Self { + let (accept_tx, accept_rx) = tokio::sync::mpsc::channel(16); + let (scan_tx, scan_rx) = tokio::sync::mpsc::channel(64); + Self { + adapter: adapter.to_string(), + local_addr, + accept_tx, + accept_rx: std::sync::Mutex::new(Some(accept_rx)), + scan_tx, + scan_rx: std::sync::Mutex::new(Some(scan_rx)), + connect_handler: std::sync::Mutex::new(None), + } + } + + /// Inject an inbound connection (simulates a remote device connecting). + pub async fn inject_inbound(&self, stream: MockBleStream) { + let _ = self.accept_tx.send(stream).await; + } + + /// Inject a scan result (simulates discovering a remote device). + pub async fn inject_scan_result(&self, addr: BleAddr) { + let _ = self.scan_tx.send(addr).await; + } + + /// Set a handler for outbound connect calls. + pub fn set_connect_handler(&self, handler: F) + where + F: Fn(&BleAddr, u16) -> Result + Send + Sync + 'static, + { + *self.connect_handler.lock().unwrap() = Some(Box::new(handler)); + } +} + +impl BleIo for MockBleIo { + type Stream = MockBleStream; + type Acceptor = MockBleAcceptor; + type Scanner = MockBleScanner; + + async fn listen(&self, _psm: u16) -> Result { + let rx = self + .accept_rx + .lock() + .unwrap() + .take() + .ok_or_else(|| TransportError::NotSupported("acceptor already taken".into()))?; + Ok(MockBleAcceptor { rx }) + } + + async fn connect(&self, addr: &BleAddr, psm: u16) -> Result { + let handler = self.connect_handler.lock().unwrap(); + match handler.as_ref() { + Some(f) => f(addr, psm), + None => Err(TransportError::ConnectionRefused), + } + } + + async fn start_advertising(&self) -> Result<(), TransportError> { + Ok(()) + } + + async fn stop_advertising(&self) -> Result<(), TransportError> { + Ok(()) + } + + async fn start_scanning(&self) -> Result { + let rx = self + .scan_rx + .lock() + .unwrap() + .take() + .ok_or_else(|| TransportError::NotSupported("scanner already taken".into()))?; + Ok(MockBleScanner { rx }) + } + + fn local_addr(&self) -> Result { + Ok(self.local_addr.clone()) + } + + fn adapter_name(&self) -> &str { + &self.adapter + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn test_addr(n: u8) -> BleAddr { + BleAddr { + adapter: "hci0".to_string(), + device: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, n], + } + } + + #[tokio::test] + async fn test_mock_stream_pair_send_recv() { + let (a, b) = MockBleStream::pair(test_addr(1), test_addr(2), 2048); + + a.send(b"hello").await.unwrap(); + let mut buf = [0u8; 64]; + let n = b.recv(&mut buf).await.unwrap(); + assert_eq!(&buf[..n], b"hello"); + + b.send(b"world").await.unwrap(); + let n = a.recv(&mut buf).await.unwrap(); + assert_eq!(&buf[..n], b"world"); + } + + #[tokio::test] + async fn test_mock_stream_mtu() { + let (a, b) = MockBleStream::pair(test_addr(1), test_addr(2), 512); + assert_eq!(a.send_mtu(), 512); + assert_eq!(a.recv_mtu(), 512); + assert_eq!(b.send_mtu(), 512); + assert_eq!(b.recv_mtu(), 512); + } + + #[tokio::test] + async fn test_mock_stream_remote_addr() { + let (a, b) = MockBleStream::pair(test_addr(1), test_addr(2), 2048); + assert_eq!(a.remote_addr(), &test_addr(2)); + assert_eq!(b.remote_addr(), &test_addr(1)); + } + + #[tokio::test] + async fn test_mock_io_listen_accept() { + let io = MockBleIo::new("hci0", test_addr(1)); + let mut acceptor = io.listen(0x0085).await.unwrap(); + + let (stream_a, _stream_b) = MockBleStream::pair(test_addr(1), test_addr(2), 2048); + io.inject_inbound(stream_a).await; + + let accepted = acceptor.accept().await.unwrap(); + // stream_a's remote_addr is addr_b (test_addr(2)) + assert_eq!(accepted.remote_addr(), &test_addr(2)); + } + + #[tokio::test] + async fn test_mock_io_connect() { + let io = MockBleIo::new("hci0", test_addr(1)); + let local = test_addr(1); + io.set_connect_handler(move |addr, _psm| { + let (stream, _peer) = MockBleStream::pair(local.clone(), addr.clone(), 2048); + Ok(stream) + }); + + let stream = io.connect(&test_addr(2), 0x0085).await.unwrap(); + assert_eq!(stream.remote_addr(), &test_addr(2)); + } + + #[tokio::test] + async fn test_mock_io_connect_no_handler() { + let io = MockBleIo::new("hci0", test_addr(1)); + let result = io.connect(&test_addr(2), 0x0085).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_mock_io_scan() { + let io = MockBleIo::new("hci0", test_addr(1)); + let mut scanner = io.start_scanning().await.unwrap(); + + io.inject_scan_result(test_addr(2)).await; + io.inject_scan_result(test_addr(3)).await; + + assert_eq!(scanner.next().await, Some(test_addr(2))); + assert_eq!(scanner.next().await, Some(test_addr(3))); + } + + #[tokio::test] + async fn test_mock_io_local_addr() { + let io = MockBleIo::new("hci0", test_addr(1)); + assert_eq!(io.local_addr().unwrap(), test_addr(1)); + assert_eq!(io.adapter_name(), "hci0"); + } + + #[tokio::test] + async fn test_mock_io_advertising_noop() { + let io = MockBleIo::new("hci0", test_addr(1)); + io.start_advertising().await.unwrap(); + io.stop_advertising().await.unwrap(); + } + + #[tokio::test] + async fn test_mock_io_listen_twice_fails() { + let io = MockBleIo::new("hci0", test_addr(1)); + let _acceptor = io.listen(0x0085).await.unwrap(); + assert!(io.listen(0x0085).await.is_err()); + } +} diff --git a/src/transport/ble/mod.rs b/src/transport/ble/mod.rs new file mode 100644 index 0000000..3c2586e --- /dev/null +++ b/src/transport/ble/mod.rs @@ -0,0 +1,1183 @@ +//! 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. +//! +//! ## Architecture +//! +//! Transport logic (pool, discovery, lifecycle) is separated from the +//! BlueZ/bluer stack via the `BleIo` trait. `BluerIo` provides the real +//! implementation (behind `cfg(feature = "ble")`); `MockBleIo` provides +//! an in-memory test double for CI without hardware. +//! +//! ## Connection Pool +//! +//! BLE hardware limits concurrent connections (typically 4-10). The pool +//! enforces a configurable maximum (default 7) with priority eviction: +//! static (configured) peers get priority over discovered peers. + +pub mod addr; +pub mod discovery; +pub mod io; +pub mod pool; +pub mod stats; + +use super::{ + ConnectionState, DiscoveredPeer, PacketTx, ReceivedPacket, Transport, TransportAddr, + TransportError, TransportId, TransportState, TransportType, +}; +use crate::config::BleConfig; +use crate::identity::NodeAddr; +use addr::BleAddr; +use discovery::DiscoveryBuffer; +use io::{BleIo, BleScanner, BleStream}; +use pool::{BleConnection, ConnectionPool}; +use stats::BleStats; + +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use secp256k1::XOnlyPublicKey; +use tracing::{debug, info, trace, warn}; + +/// Default FIPS L2CAP PSM (Protocol Service Multiplexer). +/// +/// 0x0085 (133) is in the dynamic range (0x0080-0x00FF). +pub const DEFAULT_PSM: u16 = 0x0085; + +/// Concrete BLE transport type for use in TransportHandle. +/// +/// Production builds with the `ble` feature use `BluerIo` (real BlueZ stack). +/// Test builds and builds without `ble` use `MockBleIo`. +#[cfg(all(feature = "ble", not(test)))] +pub type DefaultBleTransport = BleTransport; + +#[cfg(any(not(feature = "ble"), test))] +pub type DefaultBleTransport = BleTransport; + + +// ============================================================================ +// BLE Transport +// ============================================================================ + +/// BLE transport for FIPS. +/// +/// Provides connection-oriented, reliable delivery over BLE L2CAP CoC. +/// Each peer has its own L2CAP connection; the pool enforces hardware +/// connection limits with priority eviction. +pub struct BleTransport { + /// Unique transport identifier. + transport_id: TransportId, + /// Optional instance name. + name: Option, + /// Configuration. + config: BleConfig, + /// Current state. + state: TransportState, + /// BLE I/O implementation (BluerIo or MockBleIo). + io: Arc, + /// Established connection pool. + pool: Arc>>>, + /// Pending connection attempts. + connecting: Arc>>, + /// Channel for delivering received packets to Node. + packet_tx: PacketTx, + /// Accept loop task handle. + accept_task: Option>, + /// Combined scan + probe loop task handle. + scan_probe_task: Option>, + /// Advertising task handle. + advertise_task: Option>, + /// Discovery buffer for discovered peers. + discovery_buffer: Arc, + /// Transport statistics. + stats: Arc, + /// Our public key for pre-handshake identity exchange. + /// + /// BLE advertisements carry only the FIPS UUID, not the pubkey. + /// After L2CAP connection, both sides exchange `[0x00][pubkey:32]` + /// so the node layer can initiate the IK handshake. + /// Temporary — removed when FMP switches to XX. + local_pubkey: Option<[u8; 32]>, +} + +/// A pending background connection attempt. +struct ConnectingEntry { + task: JoinHandle<()>, +} + +impl BleTransport { + /// Create a new BLE transport. + pub fn new( + transport_id: TransportId, + name: Option, + config: BleConfig, + io: I, + packet_tx: PacketTx, + ) -> Self { + let max_conns = config.max_connections(); + Self { + transport_id, + name, + config, + state: TransportState::Configured, + io: Arc::new(io), + pool: Arc::new(Mutex::new(ConnectionPool::new(max_conns))), + connecting: Arc::new(Mutex::new(HashMap::new())), + packet_tx, + accept_task: None, + scan_probe_task: None, + advertise_task: None, + discovery_buffer: Arc::new(DiscoveryBuffer::new(transport_id)), + stats: Arc::new(BleStats::new()), + local_pubkey: None, + } + } + + /// Get the instance name. + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// Get the transport statistics. + pub fn stats(&self) -> &Arc { + &self.stats + } + + /// Get the I/O implementation (for test injection). + pub fn io(&self) -> &Arc { + &self.io + } + + /// Set the local public key for pre-handshake identity exchange. + /// + /// Must be called before `start_async()`. Without this, BLE + /// connections skip the pubkey exchange and discovered peers + /// won't have identity information for auto-connect. + pub fn set_local_pubkey(&mut self, pubkey: [u8; 32]) { + self.local_pubkey = Some(pubkey); + } + + /// Start the transport asynchronously. + pub async fn start_async(&mut self) -> Result<(), TransportError> { + if !self.state.can_start() { + return Err(TransportError::AlreadyStarted); + } + self.state = TransportState::Starting; + + let psm = self.config.psm(); + let adapter = self.io.adapter_name().to_string(); + + // Pre-compute local NodeAddr for cross-probe tie-breaking + let local_node_addr = self.local_pubkey.and_then(|pk| { + XOnlyPublicKey::from_slice(&pk) + .ok() + .map(|xonly| NodeAddr::from_pubkey(&xonly)) + }); + + // Start L2CAP listener for inbound connections + if self.config.accept_connections() { + match self.io.listen(psm).await { + Ok(acceptor) => { + let pool = Arc::clone(&self.pool); + let packet_tx = self.packet_tx.clone(); + let transport_id = self.transport_id; + let stats = Arc::clone(&self.stats); + let max_conns = self.config.max_connections(); + + self.accept_task = Some(tokio::spawn(accept_loop( + acceptor, + pool, + packet_tx, + transport_id, + stats, + max_conns, + self.local_pubkey, + Arc::clone(&self.discovery_buffer), + local_node_addr, + ))); + debug!(adapter = %adapter, psm = psm, "BLE accept loop started"); + } + Err(e) => { + warn!(adapter = %adapter, error = %e, "failed to start BLE listener"); + self.state = TransportState::Failed; + return Err(e); + } + } + } + + // Start periodic beacon (advertise in bursts) + if self.config.advertise() { + let io = Arc::clone(&self.io); + let beacon_interval = self.config.beacon_interval_secs(); + let beacon_duration = self.config.beacon_duration_secs(); + let stats = Arc::clone(&self.stats); + self.advertise_task = Some(tokio::spawn(beacon_loop( + io, + beacon_interval, + beacon_duration, + stats, + ))); + debug!( + adapter = %adapter, + interval_secs = beacon_interval, + duration_secs = beacon_duration, + "BLE beacon loop started" + ); + } + + // Start combined scan + probe loop + if self.config.scan() { + match self.io.start_scanning().await { + Ok(scanner) => { + self.scan_probe_task = Some(tokio::spawn(scan_probe_loop::( + scanner, + Arc::clone(&self.io), + Arc::clone(&self.pool), + Arc::clone(&self.discovery_buffer), + Arc::clone(&self.stats), + self.local_pubkey, + self.config.psm(), + self.config.connect_timeout_ms(), + local_node_addr, + ))); + debug!(adapter = %adapter, "BLE scan+probe loop started"); + } + Err(e) => { + warn!(adapter = %adapter, error = %e, "failed to start BLE scanning"); + } + } + } + + self.state = TransportState::Up; + info!(adapter = %adapter, psm = psm, "BLE transport started"); + Ok(()) + } + + /// Stop the transport asynchronously. + pub async fn stop_async(&mut self) -> Result<(), TransportError> { + // Stop advertising + let _ = self.io.stop_advertising().await; + + // Abort accept loop + if let Some(task) = self.accept_task.take() { + task.abort(); + } + + // Abort scan+probe loop + if let Some(task) = self.scan_probe_task.take() { + task.abort(); + } + + // Abort advertising task + if let Some(task) = self.advertise_task.take() { + task.abort(); + } + + // Drain connecting pool + { + let mut connecting = self.connecting.lock().await; + for (_, entry) in connecting.drain() { + entry.task.abort(); + } + } + + // Drain established connections (recv tasks aborted via Drop) + { + let mut pool = self.pool.lock().await; + for addr in pool.addrs() { + pool.remove(&addr); + } + } + + self.state = TransportState::Down; + info!("BLE transport stopped"); + Ok(()) + } + + /// Send data to a remote BLE address. + /// + /// If no connection exists to the target, attempts connect-on-send + /// (inline connection with timeout), matching TCP transport behavior. + pub async fn send_async( + &self, + addr: &TransportAddr, + data: &[u8], + ) -> Result { + // Get existing connection or connect inline + let has_conn = { + let pool = self.pool.lock().await; + pool.contains(addr) + }; + + if !has_conn { + self.connect_inline(addr).await?; + } + + let pool = self.pool.lock().await; + let conn = pool + .get(addr) + .ok_or_else(|| TransportError::SendFailed("not connected".into()))?; + + // MTU check + let mtu = conn.effective_mtu() as usize; + if data.len() > mtu { + self.stats.record_mtu_exceeded(); + return Err(TransportError::MtuExceeded { + packet_size: data.len(), + mtu: mtu as u16, + }); + } + + match conn.stream.send(data).await { + Ok(()) => { + self.stats.record_send(data.len()); + Ok(data.len()) + } + Err(e) => { + self.stats.record_send_error(); + // Drop pool lock before removing to avoid deadlock + drop(pool); + let mut pool = self.pool.lock().await; + pool.remove(addr); + warn!(addr = %addr, error = %e, "BLE send failed, connection removed"); + Err(e) + } + } + } + + /// Connect to a remote BLE device inline (blocking the caller). + /// + /// Used by connect-on-send. Connects with timeout, promotes to pool, + /// and spawns the receive loop. + async fn connect_inline(&self, addr: &TransportAddr) -> Result<(), TransportError> { + let ble_addr = BleAddr::parse( + addr.as_str() + .ok_or_else(|| TransportError::InvalidAddress("not valid UTF-8".into()))?, + )?; + + let psm = self.config.psm(); + let timeout_ms = self.config.connect_timeout_ms(); + + let stream = match tokio::time::timeout( + std::time::Duration::from_millis(timeout_ms), + self.io.connect(&ble_addr, psm), + ) + .await + { + Ok(Ok(stream)) => stream, + Ok(Err(e)) => { + debug!(addr = %addr, error = %e, "BLE connect-on-send failed"); + return Err(TransportError::ConnectionRefused); + } + Err(_) => { + self.stats.record_connect_timeout(); + debug!(addr = %addr, "BLE connect-on-send timeout"); + return Err(TransportError::Timeout); + } + }; + + // Pre-handshake pubkey exchange (temporary, pre-XX) + if let Some(ref our_pubkey) = self.local_pubkey { + match pubkey_exchange(&stream, our_pubkey).await { + Ok(peer_pubkey) => { + debug!(addr = %addr, "BLE outbound pubkey exchange complete"); + self.discovery_buffer + .add_peer_with_pubkey(&ble_addr, peer_pubkey); + } + Err(e) => { + warn!(addr = %addr, error = %e, "BLE outbound pubkey exchange failed"); + return Err(e); + } + } + } + + self.promote_connection(addr, &ble_addr, stream).await + } + + /// Promote a newly established stream into the connection pool. + /// + /// Spawns the receive loop and inserts into the pool with eviction. + async fn promote_connection( + &self, + addr: &TransportAddr, + ble_addr: &BleAddr, + stream: 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), + addr.clone(), + Arc::clone(&self.pool), + self.packet_tx.clone(), + self.transport_id, + Arc::clone(&self.stats), + )); + + let conn = BleConnection { + stream, + recv_task: Some(recv_task), + send_mtu, + recv_mtu, + established_at: tokio::time::Instant::now(), + is_static: false, + addr: ble_addr.clone(), + }; + + let mut pool = self.pool.lock().await; + match pool.insert(addr.clone(), conn) { + Ok(Some(evicted)) => { + self.stats.record_pool_eviction(); + debug!(addr = %addr, evicted = %evicted, "BLE connection established (evicted peer)"); + } + Ok(None) => { + debug!(addr = %addr, "BLE connection established"); + } + Err(e) => { + warn!(addr = %addr, error = %e, "BLE pool full, connection dropped"); + self.stats.record_connection_rejected(); + return Err(TransportError::SendFailed("pool full".into())); + } + } + self.stats.record_connection_established(); + Ok(()) + } + + /// Initiate a non-blocking connection to a remote BLE device. + /// + /// Spawns a background task that connects with timeout and promotes + /// to the pool on success. Poll `connection_state_sync()` to check. + pub async fn connect_async(&self, addr: &TransportAddr) -> Result<(), TransportError> { + // Already connected? + { + let pool = self.pool.lock().await; + if pool.contains(addr) { + return Ok(()); + } + } + + // Already connecting? + { + let connecting = self.connecting.lock().await; + if connecting.contains_key(addr) { + return Ok(()); + } + } + + let ble_addr = BleAddr::parse( + addr.as_str() + .ok_or_else(|| TransportError::InvalidAddress("not valid UTF-8".into()))?, + )?; + + let io = Arc::clone(&self.io); + let pool = Arc::clone(&self.pool); + let connecting = Arc::clone(&self.connecting); + let packet_tx = self.packet_tx.clone(); + let transport_id = self.transport_id; + let stats = Arc::clone(&self.stats); + let psm = self.config.psm(); + let timeout_ms = self.config.connect_timeout_ms(); + let addr_clone = addr.clone(); + + let task = tokio::spawn(async move { + let result = tokio::time::timeout( + std::time::Duration::from_millis(timeout_ms), + io.connect(&ble_addr, psm), + ) + .await; + + // Remove from connecting pool + connecting.lock().await.remove(&addr_clone); + + match result { + Ok(Ok(stream)) => { + 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), + addr_clone.clone(), + Arc::clone(&pool), + packet_tx, + transport_id, + Arc::clone(&stats), + )); + + let conn = BleConnection { + stream, + recv_task: Some(recv_task), + send_mtu, + recv_mtu, + established_at: tokio::time::Instant::now(), + is_static: false, + addr: ble_addr, + }; + + let mut pool = pool.lock().await; + match pool.insert(addr_clone.clone(), conn) { + Ok(Some(evicted)) => { + stats.record_pool_eviction(); + debug!(addr = %addr_clone, evicted = %evicted, "BLE connection established (evicted peer)"); + } + Ok(None) => { + debug!(addr = %addr_clone, "BLE connection established"); + } + Err(e) => { + warn!(addr = %addr_clone, error = %e, "BLE pool full, connection dropped"); + stats.record_connection_rejected(); + return; + } + } + stats.record_connection_established(); + } + Ok(Err(e)) => { + debug!(addr = %addr_clone, error = %e, "BLE connect failed"); + } + Err(_) => { + stats.record_connect_timeout(); + debug!(addr = %addr_clone, "BLE connect timeout"); + } + } + }); + + self.connecting + .lock() + .await + .insert(addr.clone(), ConnectingEntry { task }); + + Ok(()) + } + + /// Query the state of a connection attempt. + pub fn connection_state_sync(&self, addr: &TransportAddr) -> ConnectionState { + // Check established pool (try_lock to avoid blocking) + if let Ok(pool) = self.pool.try_lock() + && pool.contains(addr) + { + return ConnectionState::Connected; + } + + // Check connecting pool + if let Ok(connecting) = self.connecting.try_lock() + && connecting.contains_key(addr) + { + return ConnectionState::Connecting; + } + + ConnectionState::None + } + + /// Close a specific connection. + pub async fn close_connection_async(&self, addr: &TransportAddr) { + let mut pool = self.pool.lock().await; + if let Some(conn) = pool.remove(addr) { + debug!(addr = %addr, "BLE connection closed"); + drop(conn); // recv_task aborted via Drop + } + } + + /// Get the link MTU for a specific address. + pub fn link_mtu(&self, addr: &TransportAddr) -> u16 { + if let Ok(pool) = self.pool.try_lock() + && let Some(conn) = pool.get(addr) + { + return conn.effective_mtu(); + } + self.config.mtu() + } +} + +impl Transport for BleTransport { + fn transport_id(&self) -> TransportId { + self.transport_id + } + + fn transport_type(&self) -> &TransportType { + &TransportType::BLE + } + + fn state(&self) -> TransportState { + self.state + } + + fn mtu(&self) -> u16 { + self.config.mtu() + } + + fn link_mtu(&self, addr: &TransportAddr) -> u16 { + self.link_mtu(addr) + } + + fn start(&mut self) -> Result<(), TransportError> { + Err(TransportError::NotSupported( + "use start_async() for BLE transport".into(), + )) + } + + fn stop(&mut self) -> Result<(), TransportError> { + Err(TransportError::NotSupported( + "use stop_async() for BLE transport".into(), + )) + } + + fn send(&self, _addr: &TransportAddr, _data: &[u8]) -> Result<(), TransportError> { + Err(TransportError::NotSupported( + "use send_async() for BLE transport".into(), + )) + } + + fn discover(&self) -> Result, TransportError> { + Ok(self.discovery_buffer.take()) + } + + fn auto_connect(&self) -> bool { + self.config.auto_connect() + } + + fn accept_connections(&self) -> bool { + self.config.accept_connections() + } + + fn close_connection(&self, _addr: &TransportAddr) { + // use close_connection_async() + } +} + +// ============================================================================ +// Background Tasks +// ============================================================================ + +/// Pre-handshake pubkey exchange prefix byte. +/// +/// Distinguishes the identity exchange from FMP packets (version ≥ 0x01). +/// Temporary — removed when FMP switches from IK to XX handshake. +const PUBKEY_EXCHANGE_PREFIX: u8 = 0x00; + +/// Pre-handshake pubkey exchange message size: `[0x00][pubkey:32]`. +const PUBKEY_EXCHANGE_SIZE: usize = 33; + +/// Exchange public keys over a newly established L2CAP connection. +/// +/// Both sides send `[0x00][our_pubkey:32]` and receive the peer's. +/// Returns the peer's XOnlyPublicKey on success. +async fn pubkey_exchange( + stream: &S, + local_pubkey: &[u8; 32], +) -> Result { + // 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?; + + // Receive peer's pubkey + let mut buf = [0u8; PUBKEY_EXCHANGE_SIZE]; + let n = stream.recv(&mut buf).await?; + 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}", + buf[0] + ))); + } + + XOnlyPublicKey::from_slice(&buf[1..]) + .map_err(|e| TransportError::RecvFailed(format!("pubkey exchange: invalid key: {}", e))) +} + +/// Beacon loop: periodically advertises the FIPS service UUID. +/// +/// Advertises immediately on startup, then repeats in bursts: +/// advertise for `duration_secs`, stop for `interval_secs`, repeat. +/// This reduces BLE radio usage compared to continuous advertising. +async fn beacon_loop( + io: Arc, + interval_secs: u64, + duration_secs: u64, + stats: Arc, +) { + let interval = std::time::Duration::from_secs(interval_secs); + let duration = std::time::Duration::from_secs(duration_secs); + + loop { + // Start advertising burst + if let Err(e) = io.start_advertising().await { + warn!(error = %e, "BLE beacon: failed to start advertising"); + tokio::time::sleep(interval).await; + continue; + } + stats.record_advertisement(); + trace!(duration_secs, "BLE beacon: advertising"); + + // Hold advertisement for the burst duration + tokio::time::sleep(duration).await; + + // Stop advertising until next burst + if let Err(e) = io.stop_advertising().await { + warn!(error = %e, "BLE beacon: failed to stop advertising"); + } + + // Wait for next beacon interval + tokio::time::sleep(interval).await; + } +} + +/// Accept loop: accepts inbound L2CAP connections, exchanges pubkeys, +/// and adds to pool. +#[allow(clippy::too_many_arguments)] +async fn accept_loop( + mut acceptor: A, + pool: Arc>>>, + packet_tx: PacketTx, + transport_id: TransportId, + stats: Arc, + _max_conns: usize, + local_pubkey: Option<[u8; 32]>, + discovery_buffer: Arc, + local_node_addr: Option, +) where + A: io::BleAcceptor, + A::Stream: 'static, +{ + loop { + match acceptor.accept().await { + Ok(stream) => { + let addr = stream.remote_addr().clone(); + let ta = addr.to_transport_addr(); + + // Skip if already connected (outbound won the race) + { + let pool_guard = pool.lock().await; + if pool_guard.contains(&ta) { + debug!(addr = %ta, "BLE inbound: already connected, skipping"); + continue; + } + } + + let send_mtu = stream.send_mtu(); + let recv_mtu = 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 { + Ok(peer_pubkey) => { + debug!(addr = %ta, "BLE inbound pubkey exchange complete"); + discovery_buffer.add_peer_with_pubkey(&addr, peer_pubkey); + + // Cross-probe tie-breaker: smaller NodeAddr's + // outbound wins. If we're smaller, our outbound + // should win — drop this inbound. + if let Some(ref our_addr) = local_node_addr { + let peer_addr = NodeAddr::from_pubkey(&peer_pubkey); + if our_addr < &peer_addr { + debug!( + addr = %ta, + "BLE inbound tie-breaker: dropping (our addr < peer, outbound wins)" + ); + continue; + } + } + } + Err(e) => { + debug!(addr = %ta, error = %e, "BLE inbound pubkey exchange failed"); + continue; + } + } + } + + let stream = Arc::new(stream); + + // Spawn receive loop + let recv_task = tokio::spawn(receive_loop( + Arc::clone(&stream), + ta.clone(), + Arc::clone(&pool), + packet_tx.clone(), + transport_id, + Arc::clone(&stats), + )); + + let conn = BleConnection { + stream, + recv_task: Some(recv_task), + send_mtu, + recv_mtu, + established_at: tokio::time::Instant::now(), + is_static: false, + addr, + }; + + let mut pool_guard = pool.lock().await; + match pool_guard.insert(ta.clone(), conn) { + Ok(Some(evicted)) => { + stats.record_pool_eviction(); + info!(addr = %ta, evicted = %evicted, "BLE inbound accepted (evicted peer)"); + } + Ok(None) => { + info!(addr = %ta, send_mtu, recv_mtu, "BLE inbound connection accepted"); + } + Err(e) => { + warn!(addr = %ta, error = %e, "BLE pool full, inbound connection rejected"); + stats.record_connection_rejected(); + continue; + } + } + stats.record_connection_accepted(); + } + Err(e) => { + warn!(error = %e, "BLE accept error"); + break; + } + } + } +} + +/// Receive loop: reads packets from a BLE stream and delivers to node. +async fn receive_loop( + stream: Arc, + addr: TransportAddr, + pool: Arc>>>, + packet_tx: PacketTx, + transport_id: TransportId, + stats: Arc, +) { + let mut buf = vec![0u8; 4096]; + 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() { + trace!("BLE packet_tx closed, stopping receive loop"); + break; + } + } + Err(e) => { + debug!(addr = %addr, error = %e, "BLE receive error"); + stats.record_recv_error(); + break; + } + } + } + + // Remove from pool + let mut pool = pool.lock().await; + pool.remove(&addr); +} + +/// Combined scan + probe loop. +/// +/// Scanner events arrive and get inserted into a delay queue with random +/// jitter. When a delayed entry expires, it's probed (L2CAP connect + +/// pubkey exchange) and the result goes into the DiscoveryBuffer. The +/// node layer picks up discovered peers via `discover()` and connects +/// through the normal auto-connect → send_async → connect_inline path. +/// +/// The delay queue ensures each beacon response gets an independent +/// random delay, preventing herd effects when multiple nodes see the +/// same advertisement simultaneously. +#[allow(clippy::too_many_arguments)] +async fn scan_probe_loop( + mut scanner: I::Scanner, + io: Arc, + pool: Arc>>>, + buffer: Arc, + stats: Arc, + local_pubkey: Option<[u8; 32]>, + psm: u16, + connect_timeout_ms: u64, + local_node_addr: Option, +) { + use rand::RngExt; + + // Time-sorted delay queue: (fire_time, addr). Min-heap on fire_time. + let mut delay_queue: BinaryHeap> = BinaryHeap::new(); + // Track queued/probed addresses to deduplicate + let mut seen: HashSet = HashSet::new(); + + let max_jitter_ms: u64 = 5000; + + loop { + // Compute sleep target: next delay queue entry, or far future + let next_fire = delay_queue + .peek() + .map(|Reverse((t, _))| *t) + .unwrap_or_else(|| tokio::time::Instant::now() + std::time::Duration::from_secs(3600)); + + tokio::select! { + // New scan result from adapter + result = scanner.next() => { + let addr = match result { + Some(a) => a, + None => { + debug!("BLE scanner ended"); + break; + } + }; + + trace!(addr = %addr, "BLE scan result"); + stats.record_scan_result(); + + // Dedup: skip if already queued, probed, or connected + if seen.contains(&addr) { + continue; + } + { + let pool_guard = pool.lock().await; + if pool_guard.contains(&addr.to_transport_addr()) { + continue; + } + } + + // Schedule with random jitter + let jitter = std::time::Duration::from_millis( + rand::rng().random_range(0..max_jitter_ms), + ); + let fire_at = tokio::time::Instant::now() + jitter; + delay_queue.push(Reverse((fire_at, addr.clone()))); + seen.insert(addr); + } + + // Next delayed probe is ready + _ = tokio::time::sleep_until(next_fire) => { + let Reverse((_, addr)) = match delay_queue.pop() { + Some(entry) => entry, + None => continue, + }; + + // Skip if connected while waiting + { + let pool_guard = pool.lock().await; + if pool_guard.contains(&addr.to_transport_addr()) { + continue; + } + } + + // Need pubkey for probe + let our_pubkey = match local_pubkey { + Some(pk) => pk, + None => { + // No pubkey — just report MAC without identity + buffer.add_peer(&addr); + continue; + } + }; + + // L2CAP connect + let stream = match tokio::time::timeout( + std::time::Duration::from_millis(connect_timeout_ms), + io.connect(&addr, psm), + ) + .await + { + Ok(Ok(s)) => s, + Ok(Err(e)) => { + debug!(addr = %addr, error = %e, "BLE probe connect failed"); + continue; + } + Err(_) => { + debug!(addr = %addr, "BLE probe connect timeout"); + stats.record_connect_timeout(); + continue; + } + }; + + // Pubkey exchange, then close the L2CAP connection + match pubkey_exchange(&stream, &our_pubkey).await { + Ok(peer_pubkey) => { + debug!(addr = %addr, "BLE probe complete"); + + // Cross-probe tie-breaker: smaller NodeAddr's outbound wins + if let Some(ref our_addr) = local_node_addr { + let peer_addr = NodeAddr::from_pubkey(&peer_pubkey); + if our_addr >= &peer_addr { + debug!( + addr = %addr, + "BLE probe tie-breaker: yielding to peer's outbound" + ); + } + } + + // Report to node layer — auto-connect will establish + // a persistent connection via send_async/connect_inline + buffer.add_peer_with_pubkey(&addr, peer_pubkey); + } + Err(e) => { + debug!(addr = %addr, error = %e, "BLE probe pubkey exchange failed"); + } + } + // L2CAP connection dropped here (stream goes out of scope) + } + } + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use io::MockBleIo; + + fn test_addr(n: u8) -> BleAddr { + BleAddr { + adapter: "hci0".to_string(), + device: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, n], + } + } + + fn make_transport( + io: MockBleIo, + ) -> (BleTransport, tokio::sync::mpsc::Receiver) { + let (tx, rx) = tokio::sync::mpsc::channel(64); + let config = BleConfig::default(); + let transport = BleTransport::new( + TransportId::new(1), + None, + config, + io, + tx, + ); + (transport, rx) + } + + #[test] + fn test_transport_type() { + let io = MockBleIo::new("hci0", test_addr(1)); + let (transport, _rx) = make_transport(io); + assert_eq!(transport.transport_type().name, "ble"); + assert!(transport.transport_type().connection_oriented); + assert!(transport.transport_type().reliable); + } + + #[test] + fn test_transport_initial_state() { + let io = MockBleIo::new("hci0", test_addr(1)); + let (transport, _rx) = make_transport(io); + assert_eq!(transport.state(), TransportState::Configured); + } + + #[test] + fn test_transport_default_mtu() { + let io = MockBleIo::new("hci0", test_addr(1)); + let (transport, _rx) = make_transport(io); + assert_eq!(transport.mtu(), 2048); + } + + #[tokio::test] + async fn test_transport_start_stop() { + let io = MockBleIo::new("hci0", test_addr(1)); + let (mut transport, _rx) = make_transport(io); + transport.start_async().await.unwrap(); + assert_eq!(transport.state(), TransportState::Up); + + transport.stop_async().await.unwrap(); + assert_eq!(transport.state(), TransportState::Down); + } + + #[tokio::test(start_paused = true)] + async fn test_scan_discovers_peers() { + let io = MockBleIo::new("hci0", test_addr(1)); + let (mut transport, _rx) = make_transport(io); + transport.start_async().await.unwrap(); + + // Inject scan results via the I/O mock + transport.io.inject_scan_result(test_addr(2)).await; + transport.io.inject_scan_result(test_addr(3)).await; + + // Let scan_probe_loop pick up results and schedule jitter + tokio::task::yield_now().await; + // Advance past max jitter (5s) so probes fire + tokio::time::advance(std::time::Duration::from_secs(6)).await; + // Let the expired entries get processed + tokio::task::yield_now().await; + + // Without pubkey set, scan results go to discovery buffer as bare MACs + let peers = transport.discovery_buffer.take(); + assert_eq!(peers.len(), 2); + } + + #[tokio::test(start_paused = true)] + async fn test_scan_deduplicates() { + let io = MockBleIo::new("hci0", test_addr(1)); + let (mut transport, _rx) = make_transport(io); + transport.start_async().await.unwrap(); + + // Same address twice + transport.io.inject_scan_result(test_addr(2)).await; + transport.io.inject_scan_result(test_addr(2)).await; + + // Let scan_probe_loop pick up results + tokio::task::yield_now().await; + tokio::time::advance(std::time::Duration::from_secs(6)).await; + tokio::task::yield_now().await; + + let peers = transport.discovery_buffer.take(); + assert_eq!(peers.len(), 1); + } + + #[test] + fn test_transport_auto_connect_default() { + let io = MockBleIo::new("hci0", test_addr(1)); + let (transport, _rx) = make_transport(io); + assert!(!transport.auto_connect()); + } + + #[test] + fn test_connection_state_none() { + let io = MockBleIo::new("hci0", test_addr(1)); + let (transport, _rx) = make_transport(io); + let addr = test_addr(2).to_transport_addr(); + assert_eq!(transport.connection_state_sync(&addr), ConnectionState::None); + } + + /// Verify that the cross-probe tie-breaker follows the same convention + /// as `cross_connection_winner`: smaller NodeAddr's outbound wins. + #[test] + fn test_tiebreaker_convention() { + use secp256k1::{Secp256k1, SecretKey}; + + let secp = Secp256k1::new(); + let sk_a = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let sk_b = SecretKey::from_slice(&[2u8; 32]).unwrap(); + let (pk_a, _) = sk_a.public_key(&secp).x_only_public_key(); + let (pk_b, _) = sk_b.public_key(&secp).x_only_public_key(); + + let addr_a = NodeAddr::from_pubkey(&pk_a); + let addr_b = NodeAddr::from_pubkey(&pk_b); + + // Determine which is smaller + let (smaller, larger) = if addr_a < addr_b { + (addr_a, addr_b) + } else { + (addr_b, addr_a) + }; + + // scan_loop (outbound): promotes when our_addr < peer_addr + // Smaller node scanning larger → our_addr < peer_addr → promote (win) + assert!(smaller < larger, "test setup: smaller < larger"); + + // accept_loop (inbound): drops when our_addr < peer_addr + // Smaller node accepting from larger → drops inbound (outbound wins) + // This means: smaller always uses outbound, larger always uses inbound + } +} diff --git a/src/transport/ble/pool.rs b/src/transport/ble/pool.rs new file mode 100644 index 0000000..1d63dc9 --- /dev/null +++ b/src/transport/ble/pool.rs @@ -0,0 +1,294 @@ +//! BLE connection pool with priority eviction. +//! +//! BLE hardware limits concurrent connections (typically 4-10). The pool +//! enforces a configurable maximum and prioritizes static (configured) +//! peers over dynamically discovered ones. + +use std::collections::HashMap; + +use tokio::task::JoinHandle; + +use crate::transport::{TransportAddr, TransportError}; + +use super::addr::BleAddr; + +/// A single BLE connection in the pool. +pub struct BleConnection { + /// The L2CAP stream for this connection. + pub stream: S, + /// Background receive task handle. + pub recv_task: Option>, + /// Negotiated L2CAP send MTU. + pub send_mtu: u16, + /// Negotiated L2CAP receive MTU. + pub recv_mtu: u16, + /// When the connection was established. + pub established_at: tokio::time::Instant, + /// Whether this is a static (configured) peer. + pub is_static: bool, + /// Parsed remote address. + pub addr: BleAddr, +} + +impl BleConnection { + /// Effective MTU for this connection: min(send, recv). + pub fn effective_mtu(&self) -> u16 { + self.send_mtu.min(self.recv_mtu) + } +} + +impl Drop for BleConnection { + fn drop(&mut self) { + if let Some(task) = self.recv_task.take() { + task.abort(); + } + } +} + +/// Connection pool managing BLE connections with priority eviction. +pub struct ConnectionPool { + connections: HashMap>, + max_connections: usize, +} + +impl ConnectionPool { + /// Create a new pool with the given maximum capacity. + pub fn new(max_connections: usize) -> Self { + Self { + connections: HashMap::new(), + max_connections, + } + } + + /// Get the number of active connections. + pub fn len(&self) -> usize { + self.connections.len() + } + + /// Check if the pool is empty. + pub fn is_empty(&self) -> bool { + self.connections.is_empty() + } + + /// Check if the pool is at capacity. + pub fn is_full(&self) -> bool { + self.connections.len() >= self.max_connections + } + + /// Get the maximum pool capacity. + pub fn max_connections(&self) -> usize { + self.max_connections + } + + /// Look up a connection by transport address. + pub fn get(&self, addr: &TransportAddr) -> Option<&BleConnection> { + self.connections.get(addr) + } + + /// Look up a mutable connection by transport address. + pub fn get_mut(&mut self, addr: &TransportAddr) -> Option<&mut BleConnection> { + self.connections.get_mut(addr) + } + + /// Check if a connection exists for the given address. + pub fn contains(&self, addr: &TransportAddr) -> bool { + self.connections.contains_key(addr) + } + + /// Try to insert a connection, evicting if necessary. + /// + /// Returns `Ok(evicted_addr)` on success (with optional evicted peer), + /// or `Err` if the pool is full and the new connection cannot evict anyone. + pub fn insert( + &mut self, + addr: TransportAddr, + conn: BleConnection, + ) -> Result, TransportError> { + use std::collections::hash_map::Entry; + + // Already connected — replace + if let Entry::Occupied(mut e) = self.connections.entry(addr.clone()) { + e.insert(conn); + return Ok(None); + } + + // Room available + if !self.is_full() { + self.connections.insert(addr, conn); + return Ok(None); + } + + // Pool full — try eviction + let evicted = self.find_eviction_candidate(conn.is_static)?; + self.connections.remove(&evicted); + self.connections.insert(addr, conn); + Ok(Some(evicted)) + } + + /// Remove a connection by address. + pub fn remove(&mut self, addr: &TransportAddr) -> Option> { + self.connections.remove(addr) + } + + /// Get all connection addresses. + pub fn addrs(&self) -> Vec { + self.connections.keys().cloned().collect() + } + + /// Find the best eviction candidate. + /// + /// Static peers requesting a slot can evict the oldest non-static peer. + /// Non-static peers cannot evict anyone if all slots are static. + fn find_eviction_candidate( + &self, + new_is_static: bool, + ) -> Result { + if new_is_static { + // Static peer can evict oldest non-static + self.connections + .iter() + .filter(|(_, c)| !c.is_static) + .min_by_key(|(_, c)| c.established_at) + .map(|(addr, _)| addr.clone()) + .ok_or_else(|| { + TransportError::NotSupported( + "BLE pool full: all connections are static".into(), + ) + }) + } else { + // Non-static peer evicts oldest non-static + self.connections + .iter() + .filter(|(_, c)| !c.is_static) + .min_by_key(|(_, c)| c.established_at) + .map(|(addr, _)| addr.clone()) + .ok_or_else(|| { + TransportError::NotSupported( + "BLE pool full: all connections are static".into(), + ) + }) + } + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn test_addr(n: u8) -> TransportAddr { + TransportAddr::from_string(&format!("hci0/AA:BB:CC:DD:EE:{n:02X}")) + } + + fn test_ble_addr(n: u8) -> BleAddr { + BleAddr { + adapter: "hci0".to_string(), + device: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, n], + } + } + + fn test_conn(n: u8, is_static: bool) -> BleConnection<()> { + BleConnection { + stream: (), + recv_task: None, + send_mtu: 2048, + recv_mtu: 2048, + established_at: tokio::time::Instant::now(), + is_static, + addr: test_ble_addr(n), + } + } + + #[test] + fn test_pool_basic_insert() { + let mut pool: ConnectionPool<()> = ConnectionPool::new(7); + assert!(pool.is_empty()); + + pool.insert(test_addr(1), test_conn(1, false)).unwrap(); + assert_eq!(pool.len(), 1); + assert!(!pool.is_empty()); + assert!(pool.contains(&test_addr(1))); + } + + #[test] + fn test_pool_remove() { + let mut pool: ConnectionPool<()> = ConnectionPool::new(7); + pool.insert(test_addr(1), test_conn(1, false)).unwrap(); + assert!(pool.remove(&test_addr(1)).is_some()); + assert!(pool.is_empty()); + } + + #[test] + fn test_pool_full_eviction() { + let mut pool: ConnectionPool<()> = ConnectionPool::new(3); + pool.insert(test_addr(1), test_conn(1, false)).unwrap(); + pool.insert(test_addr(2), test_conn(2, false)).unwrap(); + pool.insert(test_addr(3), test_conn(3, false)).unwrap(); + assert!(pool.is_full()); + + // Inserting a 4th should evict the oldest non-static + let result = pool.insert(test_addr(4), test_conn(4, false)); + assert!(result.is_ok()); + assert!(result.unwrap().is_some()); // something was evicted + assert_eq!(pool.len(), 3); + assert!(pool.contains(&test_addr(4))); + } + + #[test] + fn test_pool_static_evicts_nonstatic() { + let mut pool: ConnectionPool<()> = ConnectionPool::new(2); + pool.insert(test_addr(1), test_conn(1, false)).unwrap(); + pool.insert(test_addr(2), test_conn(2, false)).unwrap(); + + // Static peer should evict a non-static + let result = pool.insert(test_addr(3), test_conn(3, true)); + assert!(result.is_ok()); + assert_eq!(pool.len(), 2); + assert!(pool.contains(&test_addr(3))); + } + + #[test] + fn test_pool_all_static_rejects() { + let mut pool: ConnectionPool<()> = ConnectionPool::new(2); + pool.insert(test_addr(1), test_conn(1, true)).unwrap(); + pool.insert(test_addr(2), test_conn(2, true)).unwrap(); + + // Non-static peer cannot evict static peers + let result = pool.insert(test_addr(3), test_conn(3, false)); + assert!(result.is_err()); + } + + #[test] + fn test_pool_replace_existing() { + let mut pool: ConnectionPool<()> = ConnectionPool::new(2); + pool.insert(test_addr(1), test_conn(1, false)).unwrap(); + + // Re-inserting same address should replace, not grow + let result = pool.insert(test_addr(1), test_conn(1, true)); + assert!(result.is_ok()); + assert_eq!(pool.len(), 1); + assert!(pool.get(&test_addr(1)).unwrap().is_static); + } + + #[test] + fn test_pool_effective_mtu() { + let mut conn = test_conn(1, false); + conn.send_mtu = 1024; + conn.recv_mtu = 2048; + assert_eq!(conn.effective_mtu(), 1024); + } + + #[test] + fn test_pool_addrs() { + let mut pool: ConnectionPool<()> = ConnectionPool::new(7); + pool.insert(test_addr(1), test_conn(1, false)).unwrap(); + pool.insert(test_addr(2), test_conn(2, false)).unwrap(); + + let mut addrs = pool.addrs(); + addrs.sort_by(|a, b| a.as_str().cmp(&b.as_str())); + assert_eq!(addrs.len(), 2); + } +} diff --git a/src/transport/ble/stats.rs b/src/transport/ble/stats.rs new file mode 100644 index 0000000..bae7461 --- /dev/null +++ b/src/transport/ble/stats.rs @@ -0,0 +1,155 @@ +//! BLE transport statistics. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::Serialize; + +/// Statistics for a BLE transport instance. +/// +/// Uses atomic counters for lock-free updates from per-connection +/// receive loops and the send path concurrently. +pub struct BleStats { + pub packets_sent: AtomicU64, + pub bytes_sent: AtomicU64, + pub packets_recv: AtomicU64, + pub bytes_recv: AtomicU64, + pub send_errors: AtomicU64, + pub recv_errors: AtomicU64, + pub mtu_exceeded: AtomicU64, + pub connections_established: AtomicU64, + pub connections_accepted: AtomicU64, + pub connections_rejected: AtomicU64, + pub connect_timeouts: AtomicU64, + pub pool_evictions: AtomicU64, + pub advertisements_sent: AtomicU64, + pub scan_results: AtomicU64, +} + +impl BleStats { + /// Create a new stats instance with all counters at zero. + pub fn new() -> Self { + Self { + packets_sent: AtomicU64::new(0), + bytes_sent: AtomicU64::new(0), + packets_recv: AtomicU64::new(0), + bytes_recv: AtomicU64::new(0), + send_errors: AtomicU64::new(0), + recv_errors: AtomicU64::new(0), + mtu_exceeded: AtomicU64::new(0), + connections_established: AtomicU64::new(0), + connections_accepted: AtomicU64::new(0), + connections_rejected: AtomicU64::new(0), + connect_timeouts: AtomicU64::new(0), + pool_evictions: AtomicU64::new(0), + advertisements_sent: AtomicU64::new(0), + scan_results: AtomicU64::new(0), + } + } + + /// Record a successful send. + pub fn record_send(&self, bytes: usize) { + self.packets_sent.fetch_add(1, Ordering::Relaxed); + self.bytes_sent.fetch_add(bytes as u64, Ordering::Relaxed); + } + + /// Record a successful receive. + pub fn record_recv(&self, bytes: usize) { + self.packets_recv.fetch_add(1, Ordering::Relaxed); + self.bytes_recv.fetch_add(bytes as u64, Ordering::Relaxed); + } + + /// Record a send error. + pub fn record_send_error(&self) { + self.send_errors.fetch_add(1, Ordering::Relaxed); + } + + /// Record a receive error. + pub fn record_recv_error(&self) { + self.recv_errors.fetch_add(1, Ordering::Relaxed); + } + + /// Record an MTU exceeded rejection. + pub fn record_mtu_exceeded(&self) { + self.mtu_exceeded.fetch_add(1, Ordering::Relaxed); + } + + /// Record a successful outbound connection. + pub fn record_connection_established(&self) { + self.connections_established.fetch_add(1, Ordering::Relaxed); + } + + /// Record a successful inbound connection. + pub fn record_connection_accepted(&self) { + self.connections_accepted.fetch_add(1, Ordering::Relaxed); + } + + /// Record a rejected inbound connection (pool full). + pub fn record_connection_rejected(&self) { + self.connections_rejected.fetch_add(1, Ordering::Relaxed); + } + + /// Record a connect timeout. + pub fn record_connect_timeout(&self) { + self.connect_timeouts.fetch_add(1, Ordering::Relaxed); + } + + /// Record a pool eviction (non-static peer displaced). + pub fn record_pool_eviction(&self) { + self.pool_evictions.fetch_add(1, Ordering::Relaxed); + } + + /// Record an advertisement broadcast. + pub fn record_advertisement(&self) { + self.advertisements_sent.fetch_add(1, Ordering::Relaxed); + } + + /// Record a scan result received. + pub fn record_scan_result(&self) { + self.scan_results.fetch_add(1, Ordering::Relaxed); + } + + /// Take a snapshot of all counters. + pub fn snapshot(&self) -> BleStatsSnapshot { + BleStatsSnapshot { + packets_sent: self.packets_sent.load(Ordering::Relaxed), + bytes_sent: self.bytes_sent.load(Ordering::Relaxed), + packets_recv: self.packets_recv.load(Ordering::Relaxed), + bytes_recv: self.bytes_recv.load(Ordering::Relaxed), + send_errors: self.send_errors.load(Ordering::Relaxed), + recv_errors: self.recv_errors.load(Ordering::Relaxed), + mtu_exceeded: self.mtu_exceeded.load(Ordering::Relaxed), + connections_established: self.connections_established.load(Ordering::Relaxed), + connections_accepted: self.connections_accepted.load(Ordering::Relaxed), + connections_rejected: self.connections_rejected.load(Ordering::Relaxed), + connect_timeouts: self.connect_timeouts.load(Ordering::Relaxed), + pool_evictions: self.pool_evictions.load(Ordering::Relaxed), + advertisements_sent: self.advertisements_sent.load(Ordering::Relaxed), + scan_results: self.scan_results.load(Ordering::Relaxed), + } + } +} + +impl Default for BleStats { + fn default() -> Self { + Self::new() + } +} + +/// Point-in-time snapshot of BLE stats (non-atomic, copyable). +#[derive(Clone, Debug, Default, Serialize)] +pub struct BleStatsSnapshot { + pub packets_sent: u64, + pub bytes_sent: u64, + pub packets_recv: u64, + pub bytes_recv: u64, + pub send_errors: u64, + pub recv_errors: u64, + pub mtu_exceeded: u64, + pub connections_established: u64, + pub connections_accepted: u64, + pub connections_rejected: u64, + pub connect_timeouts: u64, + pub pool_evictions: u64, + pub advertisements_sent: u64, + pub scan_results: u64, +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs index ccbfb77..63d1f09 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -11,6 +11,8 @@ pub mod tor; #[cfg(target_os = "linux")] pub mod ethernet; +pub mod ble; + use secp256k1::XOnlyPublicKey; use udp::UdpTransport; use tcp::TcpTransport; @@ -18,6 +20,8 @@ use tor::control::TorMonitoringInfo; use tor::TorTransport; #[cfg(target_os = "linux")] use ethernet::EthernetTransport; +#[cfg(target_os = "linux")] +use ble::DefaultBleTransport; use std::fmt; use std::net::SocketAddr; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -235,6 +239,13 @@ impl TransportType { reliable: true, // typically uses framing with checksums }; + /// BLE L2CAP CoC transport. + pub const BLE: TransportType = TransportType { + name: "ble", + connection_oriented: true, + reliable: true, // L2CAP SeqPacket guarantees delivery + }; + /// Check if the transport is connectionless. pub fn is_connectionless(&self) -> bool { !self.connection_oriented @@ -847,6 +858,9 @@ pub enum TransportHandle { Tcp(TcpTransport), /// Tor transport (via SOCKS5). Tor(TorTransport), + /// BLE L2CAP transport. + #[cfg(target_os = "linux")] + Ble(DefaultBleTransport), } impl TransportHandle { @@ -858,6 +872,8 @@ impl TransportHandle { TransportHandle::Ethernet(t) => t.start_async().await, TransportHandle::Tcp(t) => t.start_async().await, TransportHandle::Tor(t) => t.start_async().await, + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.start_async().await, } } @@ -869,6 +885,8 @@ impl TransportHandle { TransportHandle::Ethernet(t) => t.stop_async().await, TransportHandle::Tcp(t) => t.stop_async().await, TransportHandle::Tor(t) => t.stop_async().await, + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.stop_async().await, } } @@ -880,6 +898,8 @@ impl TransportHandle { 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, + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.send_async(addr, data).await, } } @@ -891,6 +911,8 @@ impl TransportHandle { TransportHandle::Ethernet(t) => t.transport_id(), TransportHandle::Tcp(t) => t.transport_id(), TransportHandle::Tor(t) => t.transport_id(), + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.transport_id(), } } @@ -902,6 +924,8 @@ impl TransportHandle { TransportHandle::Ethernet(t) => t.name(), TransportHandle::Tcp(t) => t.name(), TransportHandle::Tor(t) => t.name(), + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.name(), } } @@ -913,6 +937,8 @@ impl TransportHandle { TransportHandle::Ethernet(t) => t.transport_type(), TransportHandle::Tcp(t) => t.transport_type(), TransportHandle::Tor(t) => t.transport_type(), + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.transport_type(), } } @@ -924,6 +950,8 @@ impl TransportHandle { TransportHandle::Ethernet(t) => t.state(), TransportHandle::Tcp(t) => t.state(), TransportHandle::Tor(t) => t.state(), + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.state(), } } @@ -935,6 +963,8 @@ impl TransportHandle { TransportHandle::Ethernet(t) => t.mtu(), TransportHandle::Tcp(t) => t.mtu(), TransportHandle::Tor(t) => t.mtu(), + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.mtu(), } } @@ -949,6 +979,8 @@ impl TransportHandle { TransportHandle::Ethernet(t) => t.link_mtu(addr), TransportHandle::Tcp(t) => t.link_mtu(addr), TransportHandle::Tor(t) => t.link_mtu(addr), + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.link_mtu(addr), } } @@ -960,6 +992,8 @@ impl TransportHandle { TransportHandle::Ethernet(_) => None, TransportHandle::Tcp(t) => t.local_addr(), TransportHandle::Tor(_) => None, + #[cfg(target_os = "linux")] + TransportHandle::Ble(_) => None, } } @@ -971,6 +1005,8 @@ impl TransportHandle { TransportHandle::Ethernet(t) => Some(t.interface_name()), TransportHandle::Tcp(_) => None, TransportHandle::Tor(_) => None, + #[cfg(target_os = "linux")] + TransportHandle::Ble(_) => None, } } @@ -1006,6 +1042,8 @@ impl TransportHandle { TransportHandle::Ethernet(t) => t.discover(), TransportHandle::Tcp(t) => t.discover(), TransportHandle::Tor(t) => t.discover(), + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.discover(), } } @@ -1017,6 +1055,8 @@ impl TransportHandle { TransportHandle::Ethernet(t) => t.auto_connect(), TransportHandle::Tcp(t) => t.auto_connect(), TransportHandle::Tor(t) => t.auto_connect(), + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.auto_connect(), } } @@ -1028,6 +1068,8 @@ impl TransportHandle { TransportHandle::Ethernet(t) => t.accept_connections(), TransportHandle::Tcp(t) => t.accept_connections(), TransportHandle::Tor(t) => t.accept_connections(), + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.accept_connections(), } } @@ -1045,6 +1087,8 @@ impl TransportHandle { TransportHandle::Ethernet(_) => Ok(()), // connectionless TransportHandle::Tcp(t) => t.connect_async(addr).await, TransportHandle::Tor(t) => t.connect_async(addr).await, + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.connect_async(addr).await, } } @@ -1060,6 +1104,8 @@ impl TransportHandle { TransportHandle::Ethernet(_) => ConnectionState::Connected, TransportHandle::Tcp(t) => t.connection_state_sync(addr), TransportHandle::Tor(t) => t.connection_state_sync(addr), + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.connection_state_sync(addr), } } @@ -1074,6 +1120,8 @@ impl TransportHandle { 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, + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => t.close_connection_async(addr).await, } } @@ -1094,6 +1142,8 @@ impl TransportHandle { TransportHandle::Ethernet(_) => TransportCongestion::default(), TransportHandle::Tcp(_) => TransportCongestion::default(), TransportHandle::Tor(_) => TransportCongestion::default(), + #[cfg(target_os = "linux")] + TransportHandle::Ble(_) => TransportCongestion::default(), } } @@ -1127,6 +1177,10 @@ impl TransportHandle { TransportHandle::Tor(t) => { serde_json::to_value(t.stats().snapshot()).unwrap_or_default() } + #[cfg(target_os = "linux")] + TransportHandle::Ble(t) => { + serde_json::to_value(t.stats().snapshot()).unwrap_or_default() + } } } } diff --git a/testing/ble/.gitignore b/testing/ble/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/testing/ble/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/testing/ble/Cargo.lock b/testing/ble/Cargo.lock new file mode 100644 index 0000000..79e73fc --- /dev/null +++ b/testing/ble/Cargo.lock @@ -0,0 +1,996 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "ble-spike" +version = "0.1.0" +dependencies = [ + "bluer", + "futures", + "tokio", +] + +[[package]] +name = "bluer" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af68112f5c60196495c8b0eea68349817855f565df5b04b2477916d09fb1a901" +dependencies = [ + "custom_debug", + "dbus", + "dbus-crossroads", + "dbus-tokio", + "displaydoc", + "futures", + "hex", + "lazy_static", + "libc", + "log", + "macaddr", + "nix", + "num-derive", + "num-traits", + "pin-project", + "serde", + "serde_json", + "strum", + "tokio", + "tokio-stream", + "uuid", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "custom_debug" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" +dependencies = [ + "custom_debug_derive", +] + +[[package]] +name = "custom_debug_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/testing/ble/Cargo.toml b/testing/ble/Cargo.toml new file mode 100644 index 0000000..d651974 --- /dev/null +++ b/testing/ble/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ble-spike" +version = "0.1.0" +edition = "2024" +publish = false + +[[bin]] +name = "ble_spike" +path = "ble_spike.rs" + +[dependencies] +bluer = { version = "0.17", features = ["bluetoothd", "l2cap"] } +tokio = { version = "1", features = ["rt", "macros", "sync", "time"] } +futures = "0.3" diff --git a/testing/ble/ble_spike.rs b/testing/ble/ble_spike.rs new file mode 100644 index 0000000..7f229c6 --- /dev/null +++ b/testing/ble/ble_spike.rs @@ -0,0 +1,363 @@ +//! BLE L2CAP spike — validates bluer API assumptions for the BleIo trait. +//! +//! Usage: +//! ble_spike listen # Advertise + listen for L2CAP connections +//! ble_spike connect # Scan + connect to a listening peer +//! ble_spike info # Print adapter info only +//! +//! Build: cd testing/ble && cargo build +//! +//! Test flow (two machines with BLE adapters): +//! host-a$ ./ble_spike listen +//! host-b$ ./ble_spike connect + +mod spike { + use bluer::l2cap::{SeqPacket, SeqPacketListener, Socket, SocketAddr}; + use bluer::{adv::Advertisement, AdapterEvent, Address, AddressType, DiscoveryFilter, DiscoveryTransport}; + use futures::StreamExt; + use std::collections::BTreeSet; + use std::pin::pin; + use tokio::time::{timeout, Duration}; + + /// FIPS BLE service UUID. + /// Derived from SHA-256("FIPS: welcome to cryptoanarchy") with UUID v4 + /// version/variant bits applied. + const FIPS_SERVICE_UUID: bluer::Uuid = bluer::Uuid::from_u128( + 0x9c90_b790_2cc5_42c0_9f87_c9cc_4064_8f4c, + ); + + /// L2CAP PSM for FIPS connections (dynamic range). + const FIPS_PSM: u16 = 0x0085; + + /// Desired L2CAP CoC MTU (requested during negotiation). + /// Override with FIPS_BLE_MTU env var for testing asymmetric MTU. + fn desired_mtu() -> u16 { + std::env::var("FIPS_BLE_MTU") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(2048) + } + + /// Test message sent over L2CAP. + const TEST_MSG: &[u8] = b"FIPS BLE spike test"; + + pub async fn run() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + let cmd = args.get(1).map(|s| s.as_str()).unwrap_or("info"); + + let session = bluer::Session::new().await?; + let adapter = session.default_adapter().await?; + adapter.set_powered(true).await?; + + let addr = adapter.address().await?; + let name = adapter.name().to_string(); + println!("Adapter: {} ({})", name, addr); + println!("Address type: {:?}", adapter.address_type().await?); + + match cmd { + "info" => { + println!("Adapter info only. Use 'listen' or 'connect ' for testing."); + } + "listen" => listen(&adapter).await?, + "connect" => { + let mac = args.get(2).ok_or("usage: ble_spike connect ")?; + let target: Address = mac.parse().map_err(|_| format!("invalid MAC: {mac}"))?; + connect(&adapter, target).await?; + } + "sink" => sink(&adapter).await?, + "throughput" => { + let mac = args.get(2).ok_or("usage: ble_spike throughput ")?; + let target: Address = mac.parse().map_err(|_| format!("invalid MAC: {mac}"))?; + throughput(&adapter, target).await?; + } + other => { + eprintln!("unknown command: {other}"); + eprintln!("usage: ble_spike [info|listen|connect|sink|throughput] "); + std::process::exit(1); + } + } + + Ok(()) + } + + async fn listen(adapter: &bluer::Adapter) -> Result<(), Box> { + // Start advertising the FIPS UUID + let adv = Advertisement { + advertisement_type: bluer::adv::Type::Peripheral, + service_uuids: { + let mut s = BTreeSet::new(); + s.insert(FIPS_SERVICE_UUID); + s + }, + local_name: Some("fips-spike".to_string()), + ..Default::default() + }; + let adv_handle = adapter.advertise(adv).await?; + println!("Advertising FIPS UUID: {FIPS_SERVICE_UUID}"); + + // Bind L2CAP SeqPacket listener with requested MTU + let local_sa = SocketAddr::new( + adapter.address().await?, + AddressType::LePublic, + FIPS_PSM, + ); + let listener = SeqPacketListener::bind(local_sa).await?; + listener.as_ref().set_recv_mtu(desired_mtu())?; + let mtu = desired_mtu(); + println!("Listening on PSM 0x{FIPS_PSM:04X} (requested MTU: {mtu})"); + println!("Waiting for connection... (Ctrl-C to stop)"); + + // Accept one connection + let (conn, peer_sa) = listener.accept().await?; + println!("Accepted connection from: {}", peer_sa.addr); + println!(" Send MTU: {}", conn.send_mtu()?); + println!(" Recv MTU: {}", conn.recv_mtu()?); + + // Receive data + let mut buf = vec![0u8; 4096]; + let n = conn.recv(&mut buf).await?; + println!(" Received {} bytes: {:?}", n, String::from_utf8_lossy(&buf[..n])); + + // Send response + let response = b"FIPS BLE spike response"; + let sent = conn.send(response).await?; + println!(" Sent {} bytes", sent); + + // Receive and echo large payload + let n = timeout(Duration::from_secs(5), conn.recv(&mut buf)).await??; + println!(" Received large payload: {} bytes", n); + let echoed = conn.send(&buf[..n]).await?; + println!(" Echoed {} bytes", echoed); + + // Wait a moment before cleanup + tokio::time::sleep(Duration::from_secs(2)).await; + drop(adv_handle); + println!("Done."); + Ok(()) + } + + async fn connect( + adapter: &bluer::Adapter, + target: Address, + ) -> Result<(), Box> { + // Optional: scan for the target first to verify it's advertising + println!("Scanning for target {target}..."); + let filter = DiscoveryFilter { + transport: DiscoveryTransport::Le, + ..Default::default() + }; + adapter.set_discovery_filter(filter).await?; + + let events = adapter.discover_devices().await?; + let mut events = pin!(events); + + let found = timeout(Duration::from_secs(10), async { + while let Some(event) = events.next().await { + if let AdapterEvent::DeviceAdded(addr) = event { + println!(" Discovered: {addr}"); + if addr == target { + // Check if it has our UUID + if let Ok(device) = adapter.device(addr) { + if let Ok(Some(uuids)) = device.uuids().await { + if uuids.contains(&FIPS_SERVICE_UUID) { + println!(" -> Found FIPS service UUID!"); + return true; + } + } + } + println!(" -> Target found (no UUID filter match, connecting anyway)"); + return true; + } + } + } + false + }) + .await; + + match found { + Ok(true) => println!("Target found."), + Ok(false) => println!("Scan ended without finding target, trying connect anyway..."), + Err(_) => println!("Scan timeout, trying connect anyway..."), + } + + // Stop scanning before connecting + drop(events); + + // Connect via L2CAP SeqPacket with requested MTU + let target_sa = SocketAddr::new(target, AddressType::LePublic, FIPS_PSM); + let mtu = desired_mtu(); + println!("Connecting to {target} on PSM 0x{FIPS_PSM:04X} (requested MTU: {mtu})..."); + + let socket = Socket::::new_seq_packet()?; + socket.bind(SocketAddr::any_le())?; + socket.set_recv_mtu(desired_mtu())?; + let conn = timeout(Duration::from_secs(15), socket.connect(target_sa)).await??; + println!("Connected!"); + println!(" Send MTU: {}", conn.send_mtu()?); + println!(" Recv MTU: {}", conn.recv_mtu()?); + println!(" Peer: {}", conn.peer_addr()?.addr); + + // Send small test message + let sent = conn.send(TEST_MSG).await?; + println!(" Sent {} bytes: {:?}", sent, String::from_utf8_lossy(TEST_MSG)); + + // Receive response + let mut buf = vec![0u8; 4096]; + let n = timeout(Duration::from_secs(5), conn.recv(&mut buf)).await??; + println!(" Received {} bytes: {:?}", n, String::from_utf8_lossy(&buf[..n])); + + // Send large payload to test MTU + let send_mtu = conn.send_mtu()?; + let large = vec![0xAB_u8; send_mtu]; + let sent = conn.send(&large).await?; + println!(" Sent large payload: {} bytes (send_mtu={})", sent, send_mtu); + + let n = timeout(Duration::from_secs(5), conn.recv(&mut buf)).await??; + println!(" Received large echo: {} bytes", n); + + println!("Done."); + Ok(()) + } + + /// Sink mode: advertise, accept connection, receive and count bytes for 10s. + async fn sink(adapter: &bluer::Adapter) -> Result<(), Box> { + let adv = Advertisement { + advertisement_type: bluer::adv::Type::Peripheral, + service_uuids: { + let mut s = BTreeSet::new(); + s.insert(FIPS_SERVICE_UUID); + s + }, + local_name: Some("fips-sink".to_string()), + ..Default::default() + }; + let _adv_handle = adapter.advertise(adv).await?; + + let local_sa = SocketAddr::new( + adapter.address().await?, + AddressType::LePublic, + FIPS_PSM, + ); + let listener = SeqPacketListener::bind(local_sa).await?; + listener.as_ref().set_recv_mtu(desired_mtu())?; + println!("Sink waiting for connection..."); + + let (conn, peer_sa) = listener.accept().await?; + println!( + "Connected from {} (send_mtu={}, recv_mtu={})", + peer_sa.addr, + conn.send_mtu()?, + conn.recv_mtu()?, + ); + + let mut buf = vec![0u8; 8192]; + let mut total_bytes: u64 = 0; + let mut total_msgs: u64 = 0; + let start = std::time::Instant::now(); + + loop { + match conn.recv(&mut buf).await { + Ok(0) => break, + Ok(n) => { + total_bytes += n as u64; + total_msgs += 1; + } + Err(_) => break, + } + } + + let elapsed = start.elapsed(); + let secs = elapsed.as_secs_f64(); + let kbps = (total_bytes as f64 * 8.0) / (secs * 1000.0); + println!( + "Received {} bytes in {} messages over {:.2}s ({:.1} kbps)", + total_bytes, total_msgs, secs, kbps, + ); + Ok(()) + } + + /// Throughput mode: connect and blast data for 10 seconds. + async fn throughput( + adapter: &bluer::Adapter, + target: Address, + ) -> Result<(), Box> { + // Scan for target + println!("Scanning for {target}..."); + let filter = DiscoveryFilter { + transport: DiscoveryTransport::Le, + ..Default::default() + }; + adapter.set_discovery_filter(filter).await?; + let events = adapter.discover_devices().await?; + let mut events = pin!(events); + + let _ = timeout(Duration::from_secs(10), async { + while let Some(event) = events.next().await { + if let AdapterEvent::DeviceAdded(addr) = event { + if addr == target { + return; + } + } + } + }) + .await; + drop(events); + + // Connect with MTU + let target_sa = SocketAddr::new(target, AddressType::LePublic, FIPS_PSM); + let socket = Socket::::new_seq_packet()?; + socket.bind(SocketAddr::any_le())?; + socket.set_recv_mtu(desired_mtu())?; + let conn = timeout(Duration::from_secs(15), socket.connect(target_sa)).await??; + + let send_mtu = conn.send_mtu()?; + println!( + "Connected (send_mtu={}, recv_mtu={}). Sending for 10s...", + send_mtu, + conn.recv_mtu()?, + ); + + let payload = vec![0xAB_u8; send_mtu]; + let mut total_bytes: u64 = 0; + let mut total_msgs: u64 = 0; + let mut errors: u64 = 0; + let start = std::time::Instant::now(); + let duration = Duration::from_secs(10); + + while start.elapsed() < duration { + match conn.send(&payload).await { + Ok(n) => { + total_bytes += n as u64; + total_msgs += 1; + } + Err(_) => { + errors += 1; + if errors > 10 { + println!("Too many errors, stopping."); + break; + } + } + } + } + + let elapsed = start.elapsed(); + let secs = elapsed.as_secs_f64(); + let kbps = (total_bytes as f64 * 8.0) / (secs * 1000.0); + println!( + "Sent {} bytes in {} messages over {:.2}s ({:.1} kbps, {} errors)", + total_bytes, total_msgs, secs, kbps, errors, + ); + + // Close cleanly + let _ = conn.shutdown(std::net::Shutdown::Both); + Ok(()) + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + if let Err(e) = spike::run().await { + eprintln!("Error: {e}"); + std::process::exit(1); + } +}