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<I> 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.
This commit is contained in:
Johnathan Corgan
2026-03-24 21:30:19 +00:00
parent d3385b902a
commit 89352d3218
27 changed files with 5098 additions and 54 deletions

View File

@@ -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

View File

@@ -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

230
Cargo.lock generated
View File

@@ -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"

View File

@@ -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 <jcorgan@corganlabs.com>"
@@ -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"

View File

@@ -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

View File

@@ -82,48 +82,44 @@
<!-- === Transport layer === -->
<!-- Overlay transports -->
<rect x="80" y="336" width="150" height="60" class="cat"/>
<text x="155" y="349" text-anchor="middle" class="cat">Overlay</text>
<rect x="80" y="336" width="216" height="60" class="cat"/>
<text x="188" y="349" text-anchor="middle" class="cat">Overlay</text>
<rect x="92" y="356" width="60" height="30" class="layer xport"/>
<text x="122" y="371" text-anchor="middle" font-size="10" font-weight="bold" fill="#e0e0e0">UDP</text>
<text x="122" y="381" text-anchor="middle" class="sub">IP</text>
<rect x="158" y="356" width="60" height="30" class="layer xport"/>
<text x="188" y="371" text-anchor="middle" font-size="10" font-weight="bold" fill="#e0e0e0">Tor</text>
<text x="188" y="381" text-anchor="middle" class="sub">.onion</text>
<text x="188" y="371" text-anchor="middle" font-size="10" font-weight="bold" fill="#e0e0e0">TCP</text>
<text x="188" y="381" text-anchor="middle" class="sub">IP</text>
<rect x="224" y="356" width="60" height="30" class="layer xport"/>
<text x="254" y="371" text-anchor="middle" font-size="10" font-weight="bold" fill="#e0e0e0">Tor</text>
<text x="254" y="381" text-anchor="middle" class="sub">.onion</text>
<!-- Shared medium transports -->
<rect x="240" y="336" width="300" height="60" class="cat"/>
<text x="390" y="349" text-anchor="middle" class="cat">Shared Medium</text>
<rect x="306" y="336" width="234" height="60" class="cat"/>
<text x="423" y="349" text-anchor="middle" class="cat">Shared Medium</text>
<rect x="254" y="356" width="60" height="30" class="layer xport"/>
<text x="284" y="371" text-anchor="middle" font-size="10" font-weight="bold" fill="#e0e0e0">Ether</text>
<text x="284" y="381" text-anchor="middle" class="sub">802.3</text>
<rect x="318" y="356" width="60" height="30" class="layer xport"/>
<text x="348" y="371" text-anchor="middle" font-size="10" font-weight="bold" fill="#e0e0e0">Ether</text>
<text x="348" y="381" text-anchor="middle" class="sub">802.3</text>
<rect x="320" y="356" width="60" height="30" class="layer xport"/>
<text x="350" y="371" text-anchor="middle" font-size="10" font-weight="bold" fill="#e0e0e0">WiFi</text>
<text x="350" y="381" text-anchor="middle" class="sub">802.11</text>
<rect x="384" y="356" width="60" height="30" class="layer xport"/>
<text x="414" y="371" text-anchor="middle" font-size="10" font-weight="bold" fill="#e0e0e0">BLE</text>
<text x="414" y="381" text-anchor="middle" class="sub">L2CAP</text>
<rect x="386" y="356" width="60" height="30" class="layer xport"/>
<text x="416" y="371" text-anchor="middle" font-size="10" font-weight="bold" fill="#e0e0e0">BT</text>
<text x="416" y="381" text-anchor="middle" class="sub">RFCOMM</text>
<rect x="452" y="356" width="60" height="30" class="layer xport"/>
<text x="482" y="371" text-anchor="middle" font-size="10" font-weight="bold" fill="#e0e0e0">Radio</text>
<text x="482" y="381" text-anchor="middle" class="sub">Sat, ...</text>
<rect x="450" y="356" width="80" height="30" class="layer xport"/>
<text x="490" y="371" text-anchor="middle" font-size="10" font-weight="bold" fill="#e0e0e0">Radio ...</text>
<text x="490" y="381" text-anchor="middle" class="sub">future</text>
<!-- Point-to-point transports -->
<rect x="550" y="336" width="150" height="60" class="cat"/>
<text x="625" y="349" text-anchor="middle" class="cat">Point-to-Point</text>
<rect x="562" y="356" width="60" height="30" class="layer xport"/>
<text x="592" y="371" text-anchor="middle" font-size="10" font-weight="bold" fill="#e0e0e0">Serial</text>
<text x="592" y="381" text-anchor="middle" class="sub">UART</text>
<rect x="628" y="356" width="60" height="30" class="layer xport"/>
<text x="658" y="371" text-anchor="middle" font-size="10" font-weight="bold" fill="#e0e0e0">...</text>
<text x="658" y="381" text-anchor="middle" class="sub"></text>
<rect x="562" y="356" width="126" height="30" class="layer xport"/>
<text x="625" y="371" text-anchor="middle" font-size="10" font-weight="bold" fill="#e0e0e0">Serial ...</text>
<text x="625" y="381" text-anchor="middle" class="sub">future</text>
<!-- === Peer networks below node box === -->
<text x="155" y="436" text-anchor="middle" class="sub">Internet / Overlay Peers</text>

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -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..."

View File

@@ -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.

View File

@@ -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..."

View File

@@ -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

View File

@@ -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";

View File

@@ -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<String>,
/// L2CAP PSM for FIPS connections. Default: 0x0085 (133).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub psm: Option<u16>,
/// Default MTU for BLE connections. Default: 2048.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mtu: Option<u16>,
/// Maximum concurrent BLE connections. Default: 7.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_connections: Option<usize>,
/// Outbound connect timeout in milliseconds. Default: 10000.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connect_timeout_ms: Option<u64>,
/// Broadcast BLE advertisements. Default: true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub advertise: Option<bool>,
/// Listen for BLE advertisements. Default: true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scan: Option<bool>,
/// Auto-connect to discovered BLE peers. Default: false.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_connect: Option<bool>,
/// Accept incoming BLE connections. Default: true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accept_connections: Option<bool>,
/// Scan interval in seconds. Default: 10.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scan_interval_secs: Option<u64>,
/// Beacon interval in seconds between advertising bursts. Default: 10.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub beacon_interval_secs: Option<u64>,
/// Beacon duration in seconds per advertising burst. Default: 3.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub beacon_duration_secs: Option<u64>,
}
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<TorConfig>,
/// BLE transport instances.
#[serde(default, skip_serializing_if = "is_transport_empty")]
pub ble: TransportInstances<BleConfig>,
}
/// Helper for skip_serializing_if on TransportInstances.
@@ -533,7 +684,11 @@ fn is_transport_empty<T>(instances: &TransportInstances<T>) -> 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;
}
}
}

View File

@@ -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();

View File

@@ -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<TransportHandle> {
async fn create_transports(&mut self, packet_tx: &PacketTx) -> Vec<TransportHandle> {
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.

277
src/node/tests/ble.rs Normal file
View File

@@ -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<StdMutex<HashMap<String, MockBleStream>>>;
/// 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;
}

View File

@@ -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")]

185
src/transport/ble/addr.rs Normal file
View File

@@ -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<Self, TransportError> {
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);
}
}

View File

@@ -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<Vec<DiscoveredPeer>>,
}
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<DiscoveredPeer> {
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")
);
}
}

774
src/transport/ble/io.rs Normal file
View File

@@ -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<Output = Result<(), TransportError>> + 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<Output = Result<usize, TransportError>> + 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<Output = Result<Self::Stream, TransportError>> + 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<Output = Option<BleAddr>> + 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<Stream = Self::Stream> + '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<Output = Result<Self::Acceptor, TransportError>> + Send;
/// Connect to a remote BLE device on the given PSM.
fn connect(
&self,
addr: &BleAddr,
psm: u16,
) -> impl std::future::Future<Output = Result<Self::Stream, TransportError>> + Send;
/// Start advertising the FIPS service UUID.
fn start_advertising(
&self,
) -> impl std::future::Future<Output = Result<(), TransportError>> + Send;
/// Stop advertising.
fn stop_advertising(
&self,
) -> impl std::future::Future<Output = Result<(), TransportError>> + Send;
/// Start passive scanning for FIPS service UUID advertisements.
fn start_scanning(
&self,
) -> impl std::future::Future<Output = Result<Self::Scanner, TransportError>> + Send;
/// Get the adapter's BLE address.
fn local_addr(&self) -> Result<BleAddr, TransportError>;
/// 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<Self, TransportError> {
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<usize, TransportError> {
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<BluerStream, TransportError> {
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<Box<dyn futures::Stream<Item = AdapterEvent> + Send>>,
adapter: bluer::Adapter,
adapter_name: String,
}
impl BleScanner for BluerScanner {
async fn next(&mut self) -> Option<BleAddr> {
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<Option<bluer::adv::AdvertisementHandle>>,
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<Self, TransportError> {
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<Self::Acceptor, TransportError> {
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<Self::Stream, TransportError> {
let target_sa = addr.to_socket_addr(psm);
let socket = Socket::<SeqPacket>::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<Self::Scanner, TransportError> {
// 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<BleAddr, TransportError> {
// 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<T: Send + Sync>() {}
require::<BluerIo>();
}
}
#[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<Vec<u8>>,
rx: tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Vec<u8>>>,
}
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<usize, TransportError> {
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<MockBleStream>,
}
impl BleAcceptor for MockBleAcceptor {
type Stream = MockBleStream;
async fn accept(&mut self) -> Result<MockBleStream, TransportError> {
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<BleAddr>,
}
impl BleScanner for MockBleScanner {
async fn next(&mut self) -> Option<BleAddr> {
self.rx.recv().await
}
}
/// Handler type for outbound mock connections.
type ConnectHandler =
Box<dyn Fn(&BleAddr, u16) -> Result<MockBleStream, TransportError> + 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<MockBleStream>,
accept_rx: std::sync::Mutex<Option<tokio::sync::mpsc::Receiver<MockBleStream>>>,
scan_tx: tokio::sync::mpsc::Sender<BleAddr>,
scan_rx: std::sync::Mutex<Option<tokio::sync::mpsc::Receiver<BleAddr>>>,
connect_handler: std::sync::Mutex<Option<ConnectHandler>>,
}
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<F>(&self, handler: F)
where
F: Fn(&BleAddr, u16) -> Result<MockBleStream, TransportError> + 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<Self::Acceptor, TransportError> {
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<Self::Stream, TransportError> {
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<Self::Scanner, TransportError> {
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<BleAddr, TransportError> {
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());
}
}

1183
src/transport/ble/mod.rs Normal file

File diff suppressed because it is too large Load Diff

294
src/transport/ble/pool.rs Normal file
View File

@@ -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<S> {
/// The L2CAP stream for this connection.
pub stream: S,
/// Background receive task handle.
pub recv_task: Option<JoinHandle<()>>,
/// 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<S> BleConnection<S> {
/// Effective MTU for this connection: min(send, recv).
pub fn effective_mtu(&self) -> u16 {
self.send_mtu.min(self.recv_mtu)
}
}
impl<S> Drop for BleConnection<S> {
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<S> {
connections: HashMap<TransportAddr, BleConnection<S>>,
max_connections: usize,
}
impl<S> ConnectionPool<S> {
/// 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<S>> {
self.connections.get(addr)
}
/// Look up a mutable connection by transport address.
pub fn get_mut(&mut self, addr: &TransportAddr) -> Option<&mut BleConnection<S>> {
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<S>,
) -> Result<Option<TransportAddr>, 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<BleConnection<S>> {
self.connections.remove(addr)
}
/// Get all connection addresses.
pub fn addrs(&self) -> Vec<TransportAddr> {
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<TransportAddr, TransportError> {
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);
}
}

155
src/transport/ble/stats.rs Normal file
View File

@@ -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,
}

View File

@@ -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()
}
}
}
}

1
testing/ble/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target/

996
testing/ble/Cargo.lock generated Normal file
View File

@@ -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"

14
testing/ble/Cargo.toml Normal file
View File

@@ -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"

363
testing/ble/ble_spike.rs Normal file
View File

@@ -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 <MAC> # 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 <host-a-MAC>
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<dyn std::error::Error>> {
let args: Vec<String> = 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 <MAC>' for testing.");
}
"listen" => listen(&adapter).await?,
"connect" => {
let mac = args.get(2).ok_or("usage: ble_spike connect <MAC>")?;
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 <MAC>")?;
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] <MAC>");
std::process::exit(1);
}
}
Ok(())
}
async fn listen(adapter: &bluer::Adapter) -> Result<(), Box<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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::<SeqPacket>::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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
// 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::<SeqPacket>::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);
}
}