backup commit

This commit is contained in:
Dorian 2026-03-17 00:03:08 +00:00
parent f23be63bba
commit 253c305cc8
43 changed files with 9514 additions and 308 deletions

View File

@ -0,0 +1,15 @@
---
name: Local Frontend Dev Workflow
description: How to start the local frontend dev environment — use start-dev.sh from neode-ui/, NOT npm start from root
type: feedback
---
Run local frontend dev from `neode-ui/` directory: `./start-dev.sh` (NOT `npm start` from project root — there's no root package.json).
**Why:** The project root has no package.json. Running `npm start` there fails with ENOENT. The frontend dev script lives in `neode-ui/start-dev.sh`.
**How to apply:**
- `cd neode-ui && ./start-dev.sh` — clears ports, starts Docker apps, runs `npm run dev:mock` (mock backend on :5959, Vite on :8100)
- Stop with `./stop-dev.sh` or Ctrl+C
- Login password in dev mode: `password123`
- When telling the user how to test locally, always reference `cd neode-ui && ./start-dev.sh`

View File

@ -0,0 +1,247 @@
# Phase 2: Mesh as Federation Transport
## Context
Phase 1 built three independent communication silos: Tor-HTTP for federation, Meshcore LoRa for offline mesh, Nostr relays for discovery. They share no common abstraction — federation hardcodes Tor SOCKS5, mesh has its own serial listener, peer messaging uses a separate HTTP POST. Adding a new transport (LAN/mDNS) or changing routing logic means touching every caller.
Phase 2 unifies these under a `NodeTransport` trait with automatic transport selection per peer (Mesh > LAN > Tor), chunked LoRa messaging with Reed-Solomon FEC for payloads >160 bytes, CBOR delta compression for state sync, mDNS LAN discovery, and a "mesh only" off-grid mode.
The v2.0 roadmap (`docs/roadmap-v2.0.md`) also calls for a visual topology map and CRDT-based state sync — those are deferred to Phase 5 (Mesh Intelligence), but the transport abstraction here enables them.
## Dependencies (Verified)
```toml
ciborium = "0.2.2" # CBOR serde (134M downloads, active)
reed-solomon-erasure = "6.0" # FEC (4M downloads, MIT)
mdns-sd = "0.18.2" # LAN peer discovery (1.86M, active)
```
## Architecture
```
core/archipelago/src/transport/
├── mod.rs — NodeTransport trait, TransportKind, TransportRouter, PeerRegistry
├── tor.rs — TorTransport (wraps node_message.rs SOCKS5 logic)
├── mesh_transport.rs — MeshTransport (bridges MeshService cmd channel)
├── lan.rs — LanTransport (mdns-sd + direct HTTP)
├── chunking.rs — Split/reassemble with Reed-Solomon FEC
└── delta.rs — CBOR delta encoding for NodeStateSnapshot
```
### Unified Peer Identity
DID is the canonical identifier. Transport-specific addresses are attributes:
```rust
pub struct PeerRecord {
pub did: String,
pub pubkey_hex: String,
pub name: Option<String>,
pub trust_level: TrustLevel,
// Transport capabilities
pub mesh_contact_id: Option<u32>,
pub lan_address: Option<SocketAddr>,
pub onion_address: Option<String>,
// Freshness
pub last_mesh: Option<String>,
pub last_lan: Option<String>,
pub last_tor: Option<String>,
}
```
### Transport Routing
```
TransportRouter::send_to_peer(did, payload)
1. Look up PeerRecord by DID
2. Filter to transports where peer has address AND transport is available AND not stale
3. Sort by priority: Mesh(1) > LAN(2) > Tor(3)
4. If mesh_only_mode: only allow Mesh
5. Try each in order, fall back on failure
```
### Chunking Protocol (for LoRa >160B)
Per-chunk structure (8 byte header + 124 byte payload = 132B, fits in 160B after encryption):
```
[0x01] [msg_id: u32 LE] [chunk_idx: u8] [total: u8] [is_parity: u8] [payload: 124B]
```
Reed-Solomon: 25% parity shards. 4 data chunks = 1 parity chunk. Any N-of-(N+parity) reconstructs.
Max practical: ~10 chunks (~1.2KB) before LoRa airtime becomes unreasonable.
### CBOR Delta Sync
Full JSON state: ~500-2000 bytes. CBOR delta when only CPU/mem changed: ~30-50 bytes. Fits single LoRa chunk.
```rust
pub struct StateDelta {
pub ts: String,
pub v: u8,
pub apps: Option<Vec<AppStatus>>, // Only changed apps
pub apps_rm: Option<Vec<String>>, // Removed app IDs
pub cpu: Option<f64>,
pub mem_u: Option<u64>,
// ... only changed fields, short names for wire size
}
```
## Implementation Steps
### Step 1: Add deps to Cargo.toml
**File**: `core/archipelago/Cargo.toml`
Add ciborium, reed-solomon-erasure, mdns-sd.
### Step 2: Create `transport/mod.rs` — Core trait + PeerRegistry + TransportRouter
**New file**: `core/archipelago/src/transport/mod.rs`
- `NodeTransport` trait: `kind()`, `is_available()`, `send(address, payload)`
- `TransportKind` enum: `Mesh = 1, Lan = 2, Tor = 3`
- `TransportMessage`: `{from_did, payload: Vec<u8>, message_type}`
- `PeerRegistry`: unified peer store (DID -> PeerRecord), JSON persistence to `transport-peers.json`
- `TransportRouter`: holds Vec<Box<dyn NodeTransport>>, routes by priority with fallback
- Register `mod transport;` in `core/archipelago/src/lib.rs` or main.rs
### Step 3: Create `transport/chunking.rs` — Reed-Solomon FEC
**New file**: `core/archipelago/src/transport/chunking.rs`
- `encode_chunked(data: &[u8]) -> Vec<Vec<u8>>` — split + FEC parity shards
- `ChunkReassembler` — state machine tracking pending messages, attempts reconstruction when enough chunks arrive
- `decode_chunked(chunks: &[Option<Vec<u8>>]) -> Result<Vec<u8>>` — reconstruct from data+parity
- Unit tests: roundtrip, missing chunks, corrupted chunks
### Step 4: Create `transport/delta.rs` — CBOR delta sync
**New file**: `core/archipelago/src/transport/delta.rs`
- `compute_delta(prev, curr: &NodeStateSnapshot) -> StateDelta`
- `apply_delta(base: &NodeStateSnapshot, delta: &StateDelta) -> NodeStateSnapshot`
- `encode_cbor(delta) -> Vec<u8>` via ciborium
- `decode_cbor(bytes) -> Result<StateDelta>`
- Unit tests: delta correctness, CBOR roundtrip, size comparison vs JSON
### Step 5: Create `transport/tor.rs` — TorTransport
**New file**: `core/archipelago/src/transport/tor.rs`
- Wraps existing `node_message::send_to_peer()` pattern (reqwest + SOCKS5)
- Reuses: `node_message.rs:66` send logic, `node_message.rs:115` health check
- `is_available()` checks SOCKS5 proxy reachable at 127.0.0.1:9050
- Receives handled by existing HTTP handler in `api/handler.rs`
### Step 6: Create `transport/mesh_transport.rs` — MeshTransport
**New file**: `core/archipelago/src/transport/mesh_transport.rs`
- Holds reference to `Arc<RwLock<Option<MeshService>>>` (same as RpcHandler)
- `send()` routes through `MeshService::send_message()` for small payloads
- For payloads >124B: chunk with `chunking::encode_chunked()`, send each chunk via `MeshCommand::SendText`
- 200ms inter-chunk delay for LoRa airtime fairness
- `is_available()` checks mesh device connected via `MeshService::status()`
### Step 7: Create `transport/lan.rs` — LanTransport
**New file**: `core/archipelago/src/transport/lan.rs`
- Uses `mdns-sd::ServiceDaemon` for discovery
- Advertises: `_archipelago._tcp.local.` with TXT records: `did=..., pubkey=..., version=...`
- Browses for peers, updates PeerRegistry when found
- `send()`: direct HTTP POST to `http://{ip}:5678/archipelago/node-message` (same endpoint as Tor, no proxy)
- Non-blocking init — if Avahi not available, fail gracefully
### Step 8: Add `mesh_only_mode` to MeshConfig
**File**: `core/archipelago/src/mesh/mod.rs`
- Add `pub mesh_only_mode: Option<bool>` to MeshConfig (with `#[serde(default)]`)
- TransportRouter reads this flag to restrict routing
### Step 9: Wire TransportRouter into server.rs
**File**: `core/archipelago/src/server.rs` (~line 136, after mesh service init)
- Create PeerRegistry, load from disk
- Create TorTransport, MeshTransport, LanTransport
- Start LAN advertisement + discovery
- Create TransportRouter with all three + mesh_only flag
- Spawn background task bridging `MeshEvent::IdentityReceived` -> PeerRegistry
- Store router on RpcHandler
### Step 10: Add transport RPC endpoints
**New file**: `core/archipelago/src/api/rpc/transport.rs`
**Modify**: `core/archipelago/src/api/rpc/mod.rs` (add dispatch routes)
Endpoints:
- `transport.status` — available transports, mesh_only flag, peer count
- `transport.peers` — unified peer list with per-peer transport capabilities + preferred transport
- `transport.send` — send message via best transport (by DID)
- `transport.set-mode` — toggle mesh_only mode
### Step 11: Add opt-in federation sync via transport
**File**: `core/archipelago/src/federation.rs`
- Add `sync_with_peer_via_transport()` alongside existing `sync_with_peer()`
- Uses CBOR delta encoding when transport is mesh (saves bandwidth)
- Falls back to full JSON over Tor for backward compat
- Background sync task checks for TransportRouter, uses it if available
### Step 12: Frontend — transport store + UI indicators
**New file**: `neode-ui/src/stores/transport.ts` — Pinia store for transport status
**Modify**: `neode-ui/src/views/Mesh.vue` — "OFF-GRID" indicator when mesh_only mode
**Modify**: `neode-ui/mock-backend.js` — add transport.* mock RPC responses
### Step 13: Mock backend transport data
**File**: `neode-ui/mock-backend.js`
Add `transport.status`, `transport.peers`, `transport.send`, `transport.set-mode` mock responses with realistic data showing mixed transport capabilities.
## Files Summary
### New (8)
1. `core/archipelago/src/transport/mod.rs`
2. `core/archipelago/src/transport/tor.rs`
3. `core/archipelago/src/transport/mesh_transport.rs`
4. `core/archipelago/src/transport/lan.rs`
5. `core/archipelago/src/transport/chunking.rs`
6. `core/archipelago/src/transport/delta.rs`
7. `core/archipelago/src/api/rpc/transport.rs`
8. `neode-ui/src/stores/transport.ts`
### Modified (8)
1. `core/archipelago/Cargo.toml` — add 3 deps
2. `core/archipelago/src/lib.rs` or main — `mod transport;`
3. `core/archipelago/src/server.rs` — init router, bridge events
4. `core/archipelago/src/api/rpc/mod.rs` — dispatch + store router
5. `core/archipelago/src/mesh/mod.rs``mesh_only_mode` config field
6. `core/archipelago/src/federation.rs``sync_with_peer_via_transport()`
7. `neode-ui/src/views/Mesh.vue` — off-grid indicator
8. `neode-ui/mock-backend.js` — transport.* mock data
### Reused Existing Code
- `node_message.rs:66-112` — Tor SOCKS5 send logic -> wrapped by TorTransport
- `node_message.rs:115-135` — Tor health check -> TorTransport.is_available()
- `mesh/mod.rs:209-282` — MeshService::send_message() -> called by MeshTransport
- `mesh/listener.rs:35-37` — MeshCommand channel -> used for chunked sends
- `mesh/crypto.rs` — X25519 ECDH + ChaCha20-Poly1305 -> reused as-is
- `federation.rs:36-49` — FederatedNode struct -> PeerRecord wraps this
- `federation.rs:179-191` — update_node_state() -> adapted for CBOR deltas
## Verification
1. **Unit tests** (run on dev server):
```bash
cargo test --all-features -- transport
cargo test --all-features -- chunking
cargo test --all-features -- delta
```
2. **Frontend type check**:
```bash
cd neode-ui && npm run type-check
```
3. **Dev mode test**:
```bash
cd neode-ui && npm start
# Navigate to /dashboard/mesh — verify OFF-GRID toggle
```
4. **Deploy + integration test**:
```bash
./scripts/deploy-to-target.sh --live
# On .228: curl transport.status RPC
# Verify mDNS advertises _archipelago._tcp.local.
# If mesh device connected: send chunked message >160B to .198
# Verify federation sync uses CBOR delta when available
```
## Implementation Order
**Week 1**: Steps 1-4 (deps, trait, chunking, delta — pure library code with unit tests)
**Week 2**: Steps 5-7 (three transport implementations)
**Week 3**: Steps 8-11 (integration: server wiring, RPC, federation migration)
**Week 4**: Steps 12-13 (frontend UI, mock data, deploy + test)

View File

@ -0,0 +1,108 @@
# Meshcore Mesh Networking — Phase 1 Implementation Plan
## Context
Adding mesh networking to Archipelago using Heltec V3 devices running Meshcore firmware (Companion USB). Two nodes (.228 and .198) will exchange encrypted identity and text messages over LoRa radio with no internet required. The existing `mesh.rs` wraps the Meshtastic CLI — this replaces it with a native Meshcore serial protocol driver.
## Architecture
Convert `mesh.rs` into `mesh/` module directory:
```
core/archipelago/src/mesh/
├── mod.rs — Public API, MeshService, config (migrated from mesh.rs)
├── types.rs — MeshPeer, MeshMessage, MeshStatus, DeviceType
├── protocol.rs — Meshcore binary frame protocol (encode/decode/commands)
├── serial.rs — MeshcoreDevice: async serial driver (serial2-tokio)
├── crypto.rs — X25519 ECDH + ChaCha20-Poly1305 per-message encryption
└── listener.rs — Background tokio task: serial reader + message dispatcher
```
Frontend:
```
neode-ui/src/stores/mesh.ts — Pinia store
neode-ui/src/views/Mesh.vue — Mesh status, peers, messaging UI
```
## Dependency
Add to `core/archipelago/Cargo.toml`:
```toml
serial2-tokio = "0.1"
```
All crypto deps already present (chacha20poly1305, ed25519-dalek, curve25519-dalek).
## Meshcore Protocol Summary
- **Frame format**: `>` + 2-byte LE length + data (outbound), `<` + 2-byte LE length + data (inbound)
- **Baud**: 115200, 8N1
- **Max message**: 160 bytes
- **Init sequence**: CMD_DEVICE_QUERY (0x16) -> CMD_APP_START (0x01) -> CMD_SET_DEVICE_TIME (0x06)
- **Key commands**: SEND_TXT_MSG (0x02), SEND_CHANNEL_TXT_MSG (0x03), GET_CONTACTS (0x04), SYNC_NEXT_MESSAGE (0x0A), SEND_SELF_ADVERT (0x07)
- **Push events** (async, >=0x80): NEW_CONTACT (0x8A), ACK (0x82), MESSAGES_WAITING (0x83)
## Encryption Design
Reuses existing identity.rs X25519 key agreement:
1. Nodes broadcast identity on mesh channel: `ARCHY:1:{did}:{ed25519_pubkey}:{x25519_pubkey}`
2. Receiving node derives shared secret: X25519(our_secret, their_x25519_pub)
3. All DMs encrypted: ChaCha20-Poly1305 with random 12-byte nonce
4. Wire format: [nonce 12B] + [ciphertext] + [tag 16B] — fits in 160B limit for ~130B plaintext
## RPC Endpoints
| Method | Action |
|--------|--------|
| `mesh.status` | Device + mesh status (updated) |
| `mesh.peers` | **NEW** — list discovered mesh peers |
| `mesh.messages` | **NEW** — get message history (last 100) |
| `mesh.send` | **NEW** — send encrypted message to peer |
| `mesh.broadcast` | Broadcast identity (updated for Meshcore) |
| `mesh.configure` | Update config (updated) |
## Implementation Steps
1. **Create mesh/ module, migrate existing code** — types.rs + mod.rs from mesh.rs
2. **protocol.rs** — Binary frame encode/decode, command builders, response parsers + unit tests
3. **crypto.rs** — X25519 ECDH + ChaCha20-Poly1305 encrypt/decrypt + unit tests
4. **serial.rs** — MeshcoreDevice with open/init/send/recv + device auto-detection
5. **listener.rs** — Background task: serial reader, peer cache, message store, reconnect
6. **mod.rs MeshService** — Wraps listener + config, start/stop lifecycle
7. **Update RPC handlers** — New endpoints, wire MeshService into RpcHandler
8. **Update RPC dispatch** — Add routes in mod.rs ~line 622
9. **Frontend store + view** — mesh.ts Pinia store, Mesh.vue with glass-card UI, router + nav
10. **Deploy + test** — Deploy to .228 and .198, plug in Heltec V3s, test end-to-end
## Key Files to Modify
- `core/archipelago/src/mesh.rs` -> delete, replace with `mesh/` directory
- `core/archipelago/src/api/rpc/mesh.rs` — update handlers
- `core/archipelago/src/api/rpc/mod.rs` — add routes (~line 622)
- `core/archipelago/Cargo.toml` — add serial2-tokio
- `neode-ui/src/router/index.ts` — add /dashboard/mesh route
- `neode-ui/src/views/Dashboard.vue` — add Mesh nav item
## Reusable Existing Code
- `identity.rs` lines 140-152: Ed25519 -> X25519 conversion (CompressedEdwardsY -> Montgomery)
- `identity.rs` `pubkey_bytes_from_did_key()`: extract raw pubkey from DID string
- `node_message.rs` pattern: IncomingMessage store with max 100 circular buffer
- `mesh.rs` `MeshConfig` + `load_config`/`save_config`: migrate directly into mod.rs
- `mesh.rs` `detect_meshtastic_devices()`: keep as fallback, add Meshcore probe-based detection
## Prerequisites
- Flash both Heltec V3 with Meshcore **Companion USB** role
- Add `archipelago` user to `dialout` group: `usermod -aG dialout archipelago`
- Connect Heltec V3 to USB on .228 and .198
## Verification
1. `cargo clippy --all-targets` passes with zero warnings
2. Unit tests pass: protocol encode/decode, crypto encrypt/decrypt roundtrip
3. Device detected on /dev/ttyUSB0 or /dev/ttyACM0
4. Init handshake completes (visible in tracing logs)
5. Identity broadcast from .228, received on .198
6. Encrypted DM sent .228 -> .198, decrypted and visible in UI
7. Mesh.vue shows device status, peer list, message history

View File

@ -0,0 +1,155 @@
---
name: mesh
description: Mesh networking development for Archipelago — protocol, crypto, serial driver, transport abstraction, and LoRa chat. Use when working on mesh radio, Meshcore protocol, LoRa messaging, transport layers, peer discovery, or off-grid communication features.
---
# Mesh Networking Skill
## Architecture
The mesh subsystem enables offline peer discovery and end-to-end encrypted messaging between Archipelago nodes via Meshcore LoRa radio devices (Heltec V3, T-Beam, RAK WisBlock).
```
USB Meshcore Device (115200 baud)
↕ serial2-tokio
core/archipelago/src/mesh/
├── mod.rs — MeshService: lifecycle, config, public API
├── types.rs — MeshPeer, MeshMessage, MeshStatus, MeshEvent
├── protocol.rs — Meshcore binary frame protocol (encode/decode)
├── serial.rs — MeshcoreDevice: async serial driver
├── crypto.rs — X25519 ECDH + ChaCha20-Poly1305 encryption
└── listener.rs — Background tokio task: serial reader + dispatcher
↕ RPC
core/archipelago/src/api/rpc/mesh.rs — 6 endpoints
↕ HTTP
neode-ui/src/stores/mesh.ts — Pinia store
neode-ui/src/views/Mesh.vue — Two-column chat UI
```
## Key Files
### Backend (Rust)
- `core/archipelago/src/mesh/mod.rs` — MeshService (start/stop/status/peers/messages/send/configure)
- `core/archipelago/src/mesh/types.rs` — All shared types
- `core/archipelago/src/mesh/protocol.rs` — Binary frame format, command builders, response parsers (12 unit tests)
- `core/archipelago/src/mesh/serial.rs` — USB serial driver, handshake, device detection
- `core/archipelago/src/mesh/crypto.rs` — X25519 key agreement + ChaCha20-Poly1305 (7 unit tests)
- `core/archipelago/src/mesh/listener.rs` — Background event loop, auto-reconnect, peer cache
- `core/archipelago/src/api/rpc/mesh.rs` — RPC handlers (mesh.status/peers/messages/send/broadcast/configure)
- `core/archipelago/src/server.rs` — MeshService initialization (non-blocking)
- `core/archipelago/src/identity.rs` — Ed25519 keypair, DID, X25519 derivation
### Frontend (Vue 3 + TypeScript)
- `neode-ui/src/stores/mesh.ts` — Pinia store with unread tracking
- `neode-ui/src/views/Mesh.vue` — Full chat UI (~1000 lines)
- `neode-ui/src/router/index.ts` — Route: `/dashboard/mesh`
### Mock Backend
- `neode-ui/mock-backend.js` — Dev mode mesh RPC responses (mesh.status/peers/messages/send/broadcast/configure)
## Protocol Reference
### Meshcore Frame Format
- Outbound: `<` (0x3C) + 2-byte LE length + data
- Inbound: `>` (0x3E) + 2-byte LE length + data
- Max LoRa payload: 160 bytes
- Baud: 115200, 8N1
### Key Commands
| Byte | Command | Description |
|------|---------|-------------|
| 0x01 | APP_START | Init session with version negotiation |
| 0x02 | SEND_TXT_MSG | Direct message (6-byte pubkey prefix) |
| 0x03 | SEND_CHANNEL_TXT_MSG | Broadcast on channel |
| 0x04 | GET_CONTACTS | Fetch contact list |
| 0x06 | SET_DEVICE_TIME | Sync device clock |
| 0x07 | SEND_SELF_ADVERT | Broadcast identity |
| 0x0A | SYNC_NEXT_MESSAGE | Retrieve queued messages |
### Identity Wire Format
`ARCHY:2:{ed25519_hex_64}:{x25519_hex_64}` (137 bytes, fits 160)
### Encryption
- X25519 Diffie-Hellman from Ed25519 keys (RFC 7748 clamping)
- ChaCha20-Poly1305 AEAD with random 12-byte nonce
- Wire: `[nonce 12B] + [ciphertext + tag 16B]` — max 132B plaintext
## RPC Endpoints
| Method | Params | Returns |
|--------|--------|---------|
| `mesh.status` | — | MeshStatus |
| `mesh.peers` | — | `{peers, count}` |
| `mesh.messages` | `{limit?}` | `{messages, count}` |
| `mesh.send` | `{contact_id, message}` | `{sent, message_id, encrypted}` |
| `mesh.broadcast` | — | `{broadcast}` |
| `mesh.configure` | `{enabled?, device_path?, channel_name?, broadcast_identity?, advert_name?}` | `{configured}` |
## Development Workflow
### Building & Testing (on dev server, NOT macOS)
```bash
# Deploy mesh changes
./scripts/deploy-to-target.sh --live
# Run mesh unit tests on server
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
'cd ~/archy/core && cargo test --all-features -- mesh'
# Check device is detected
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
'ls -la /dev/ttyUSB* /dev/ttyACM* 2>/dev/null'
# Watch mesh logs
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
'sudo journalctl -u archipelago -f | grep -i mesh'
```
### Frontend Dev (local, mock backend)
```bash
cd neode-ui && npm start
# Mesh mock data at http://localhost:8100/dashboard/mesh
```
## Roadmap Phases
### Phase 1: Core Implementation (COMPLETE)
- Meshcore binary protocol, serial driver, crypto, listener, RPC, Vue UI
### Phase 2: Mesh as Federation Transport
- NodeTransport trait abstraction (mesh/tor/lan backends)
- Transport priority: Mesh (1) > LAN/mDNS (2) > Tor (3)
- Chunked message protocol for >160B payloads (Reed-Solomon FEC)
- CBOR delta sync instead of full JSON state
- Transport indicator per peer in federation UI
- "Mesh only" off-grid mode
- Dependencies: `ciborium` (CBOR), `reed-solomon-erasure` (FEC), `mdns-sd` (LAN discovery)
### Phase 3: Encrypted Mesh Messaging
- Double Ratchet (Signal protocol) over LoRa
- X3DH key agreement using existing Ed25519/X25519
- Store-and-forward relay for offline peers (24h TTL)
- Message types: TEXT, ALERT, INVOICE (bolt11), PSBT_HASH, COORDINATE
- Per-peer chat threads, delivery status, offline indicators
### Phase 4: Off-Grid Bitcoin Operations
- Compact block headers over mesh (SPV verification)
- Transaction relay via internet-connected mesh peer
- Lightning payment coordination over mesh
- Emergency alert system (signed alerts, GPS, dead man's switch)
### Phase 5: Mesh Network Intelligence
- Adaptive routing, signal strength mapping, spreading factor adjustment
- Multi-path routing for reliability
- Steganographic modes
- Additional hardware: T-Beam, RAK WisBlock, WiFi mesh (802.11s), BLE, Blockstream Satellite
## Conventions
- All crypto uses existing identity infrastructure (Ed25519 signing key → X25519 derivation)
- Mesh init is non-blocking — errors logged but don't crash server
- Config persists to `{data_dir}/mesh-config.json`
- Message buffer: circular, max 100 messages
- Never build Rust on macOS — always deploy to server
- USB device paths: `/dev/ttyUSB*` and `/dev/ttyACM*`
- `archipelago` user must be in `dialout` group for serial access

332
core/Cargo.lock generated
View File

@ -43,6 +43,17 @@ dependencies = [
"subtle",
]
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@ -69,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "0.1.0"
version = "1.1.0"
dependencies = [
"anyhow",
"archipelago-container",
@ -80,27 +91,35 @@ dependencies = [
"base64 0.21.7",
"bcrypt",
"bs58",
"bytes",
"chacha20poly1305",
"chrono",
"ciborium",
"curve25519-dalek",
"data-encoding",
"ed25519-dalek",
"flate2",
"futures-util",
"hex",
"hmac",
"http-body 1.0.1",
"http-body-util",
"hyper 0.14.32",
"hyper-util",
"hyper-ws-listener",
"mainline",
"mdns-sd",
"nostr-sdk",
"qrcode",
"rand 0.8.5",
"reed-solomon-erasure",
"regex",
"reqwest",
"sd-notify",
"serde",
"serde_json",
"serde_yaml",
"serial2-tokio",
"sha2",
"tar",
"tempfile",
@ -115,6 +134,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"zbase32",
"zeroize",
]
@ -483,6 +503,33 @@ dependencies = [
"windows-link",
]
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "cipher"
version = "0.4.4"
@ -531,6 +578,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
@ -540,6 +602,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@ -733,6 +801,17 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "flume"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [
"futures-core",
"futures-sink",
"spin",
]
[[package]]
name = "fnv"
version = "1.0.7"
@ -932,6 +1011,26 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@ -1272,6 +1371,16 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "if-addrs"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "image"
version = "0.25.9"
@ -1291,7 +1400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
@ -1363,6 +1472,12 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.14"
@ -1402,12 +1517,47 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999beba7b6e8345721bd280141ed958096a2e4abdf74f67ff4ce49b4b54e47a"
dependencies = [
"hashbrown 0.12.3",
]
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
[[package]]
name = "lru"
version = "0.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
[[package]]
name = "mainline"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b751ffb57303217bcae8f490eee6044a5b40eadf6ca05ff476cad37e7b7970d"
dependencies = [
"bytes",
"crc",
"ed25519-dalek",
"flume",
"lru 0.12.5",
"rand 0.8.5",
"serde",
"serde_bencode",
"serde_bytes",
"sha1_smol",
"thiserror 1.0.69",
"tracing",
]
[[package]]
name = "matchers"
version = "0.2.0"
@ -1417,6 +1567,21 @@ dependencies = [
"regex-automata",
]
[[package]]
name = "mdns-sd"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d797ab3274a16f4940f9650a29838e940223aeff31773df5c2827ad82150182f"
dependencies = [
"fastrand",
"flume",
"if-addrs",
"log",
"mio",
"socket-pktinfo",
"socket2 0.6.2",
]
[[package]]
name = "memchr"
version = "2.7.6"
@ -1446,6 +1611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
@ -1472,6 +1638,7 @@ version = "0.44.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1"
dependencies = [
"aes",
"base64 0.22.1",
"bech32",
"bip39",
@ -1496,7 +1663,7 @@ version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1"
dependencies = [
"lru",
"lru 0.16.3",
"nostr",
"tokio",
]
@ -1520,7 +1687,7 @@ dependencies = [
"async-wsocket",
"atomic-destructor",
"hex",
"lru",
"lru 0.16.3",
"negentropy",
"nostr",
"nostr-database",
@ -1573,6 +1740,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "parking_lot"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
dependencies = [
"instant",
"lock_api",
"parking_lot_core 0.8.6",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
@ -1580,7 +1758,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
"parking_lot_core 0.9.12",
]
[[package]]
name = "parking_lot_core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc"
dependencies = [
"cfg-if",
"instant",
"libc",
"redox_syscall 0.2.16",
"smallvec",
"winapi",
]
[[package]]
@ -1790,6 +1982,15 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "redox_syscall"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -1808,6 +2009,19 @@ dependencies = [
"bitflags 2.10.0",
]
[[package]]
name = "reed-solomon-erasure"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7263373d500d4d4f505d43a2a662d475a894aa94503a1ee28e9188b5f3960d4f"
dependencies = [
"libm",
"lru 0.7.8",
"parking_lot 0.11.2",
"smallvec",
"spin",
]
[[package]]
name = "regex"
version = "1.12.2"
@ -2029,6 +2243,15 @@ dependencies = [
"untrusted",
]
[[package]]
name = "sd-notify"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b943eadf71d8b69e661330cb0e2656e31040acf21ee7708e2c238a0ec6af2bf4"
dependencies = [
"libc",
]
[[package]]
name = "secp256k1"
version = "0.29.1"
@ -2065,6 +2288,26 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde_bencode"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a70dfc7b7438b99896e7f8992363ab8e2c4ba26aa5ec675d32d1c3c2c33d413e"
dependencies = [
"serde",
"serde_bytes",
]
[[package]]
name = "serde_bytes"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.228"
@ -2132,6 +2375,29 @@ dependencies = [
"unsafe-libyaml",
]
[[package]]
name = "serial2"
version = "0.2.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1401f562d358cdfdbdf8946e51a7871ede1db68bd0fd99bedc79e400241550"
dependencies = [
"cfg-if",
"libc",
"winapi",
]
[[package]]
name = "serial2-tokio"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b253fd088ff95a617a48e4f01e5543be9072e48663cc4e5a9544f0b258de1e36"
dependencies = [
"libc",
"serial2",
"tokio",
"winapi",
]
[[package]]
name = "sha-1"
version = "0.10.1"
@ -2154,6 +2420,12 @@ dependencies = [
"digest",
]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.10.9"
@ -2217,6 +2489,17 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket-pktinfo"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f"
dependencies = [
"libc",
"socket2 0.6.2",
"windows-sys 0.60.2",
]
[[package]]
name = "socket2"
version = "0.5.10"
@ -2237,6 +2520,15 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
@ -2445,7 +2737,7 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"parking_lot 0.12.5",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.2",
@ -2973,6 +3265,22 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@ -2982,6 +3290,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"
@ -3338,6 +3652,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbase32"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9079049688da5871a7558ddacb7f04958862c703e68258594cb7a862b5e33f"
[[package]]
name = "zerocopy"
version = "0.8.33"

View File

@ -85,6 +85,14 @@ mainline = "2"
zbase32 = "0.1"
bytes = "1"
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
serial2-tokio = "0.1"
# Transport abstraction (Phase 2: mesh as federation transport)
ciborium = "0.2.2" # CBOR serde for compact delta sync
reed-solomon-erasure = "6.0" # FEC for chunked LoRa messages
mdns-sd = "0.18" # LAN peer discovery via mDNS
# Systemd watchdog notification
sd-notify = "0.4"

View File

@ -51,6 +51,11 @@ impl ApiHandler {
})
}
/// Access the RPC handler (for service initialization after construction).
pub fn rpc_handler(&self) -> &Arc<RpcHandler> {
&self.rpc_handler
}
/// Check if the request has a valid session cookie.
async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool {
match session::extract_session_cookie(headers) {
@ -161,6 +166,11 @@ impl ApiHandler {
// Electrs status — unauthenticated (read-only sync status)
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
// LND connect info — unauthenticated (read-only, localhost only)
(Method::GET, "/lnd-connect-info") => {
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
}
// Container logs — requires session
(Method::GET, path) if path.starts_with("/api/container/logs") => {
if !self.is_authenticated(&headers).await {
@ -307,6 +317,28 @@ impl ApiHandler {
.unwrap())
}
async fn handle_lnd_connect_info(
rpc: std::sync::Arc<super::rpc::RpcHandler>,
) -> Result<Response<hyper::Body>> {
match rpc.handle_lnd_connect_info().await {
Ok(val) => {
let body = serde_json::to_vec(&val).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body))
.unwrap())
}
Err(e) => Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json")
.body(hyper::Body::from(
serde_json::json!({"error": e.to_string()}).to_string(),
))
.unwrap()),
}
}
async fn handle_lnd_proxy(path: &str, cors_origin: &str) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
let url = format!("http://127.0.0.1:8080{}", suffix);

View File

@ -1,5 +1,6 @@
use super::RpcHandler;
use anyhow::{Context, Result};
use base64::Engine;
use serde::{Deserialize, Serialize};
use tracing::info;
@ -797,6 +798,50 @@ impl RpcHandler {
"incoming_pending_count": incoming_pending,
}))
}
/// Return LND connection info: base64url-encoded TLS cert and admin macaroon
/// for building lndconnect:// URIs in the frontend.
pub(crate) async fn handle_lnd_connect_info(&self) -> Result<serde_json::Value> {
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
let macaroon_path =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
// Read and encode TLS cert (PEM → DER → base64url)
let cert_pem = tokio::fs::read_to_string(cert_path)
.await
.context("Failed to read LND TLS certificate")?;
let cert_der_b64: String = cert_pem
.lines()
.filter(|l| !l.starts_with("-----"))
.collect();
let cert_der = base64::engine::general_purpose::STANDARD
.decode(&cert_der_b64)
.context("Failed to decode PEM base64")?;
let cert_b64url = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&cert_der);
// Read and encode macaroon (binary → base64url)
let macaroon_bytes = tokio::fs::read(macaroon_path)
.await
.context("Failed to read LND admin macaroon")?;
let macaroon_b64url =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&macaroon_bytes);
// Read Tor onion address if available
let tor_onion = tokio::fs::read_to_string(
"/var/lib/archipelago/tor/hidden_service_lnd/hostname",
)
.await
.ok()
.map(|s| s.trim().to_string());
Ok(serde_json::json!({
"cert_base64url": cert_b64url,
"macaroon_base64url": macaroon_b64url,
"tor_onion": tor_onion,
"rest_port": 8080,
"grpc_port": 10009,
}))
}
}
// Channel types

View File

@ -1,58 +1,122 @@
use super::RpcHandler;
use crate::{identity, mesh};
use crate::mesh;
use anyhow::Result;
use tracing::info;
impl RpcHandler {
/// mesh.status — Get mesh radio status and detected devices.
/// mesh.status — Get mesh radio status, device info, and peer count.
pub(super) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
let config = mesh::load_config(&self.config.data_dir).await?;
let devices = mesh::detect_meshtastic_devices().await;
Ok(serde_json::json!({
"enabled": config.enabled,
"device_path": config.device_path,
"channel_name": config.channel_name,
"broadcast_identity": config.broadcast_identity,
"detected_devices": devices,
}))
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let status = svc.status().await;
Ok(serde_json::to_value(status)?)
} else {
// No service running — return basic config + device detection
let config = mesh::load_config(&self.config.data_dir).await?;
let devices = mesh::detect_devices().await;
Ok(serde_json::json!({
"enabled": config.enabled,
"device_connected": false,
"device_type": "unknown",
"device_path": config.device_path,
"channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()),
"detected_devices": devices,
"peer_count": 0,
"messages_sent": 0,
"messages_received": 0,
}))
}
}
/// mesh.discover — Discover nodes via mesh radio.
pub(super) async fn handle_mesh_discover(
/// mesh.peers — List discovered mesh peers.
pub(super) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let peers = svc.peers().await;
Ok(serde_json::json!({
"peers": peers,
"count": peers.len(),
}))
} else {
Ok(serde_json::json!({
"peers": [],
"count": 0,
}))
}
}
/// mesh.messages — Get recent mesh message history.
pub(super) async fn handle_mesh_messages(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let device_path = params
let limit = params
.as_ref()
.and_then(|p| p.get("device_path"))
.and_then(|v| v.as_str());
.and_then(|p| p.get("limit"))
.and_then(|v| v.as_u64())
.map(|n| n as usize);
let config = mesh::load_config(&self.config.data_dir).await?;
let effective_device = device_path.or(config.device_path.as_deref());
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let messages = svc.messages(limit).await;
Ok(serde_json::json!({
"messages": messages,
"count": messages.len(),
}))
} else {
Ok(serde_json::json!({
"messages": [],
"count": 0,
}))
}
}
let nodes = mesh::discover_nodes(effective_device).await?;
/// mesh.send — Send an encrypted message to a mesh peer.
pub(super) async fn handle_mesh_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params
.get("contact_id")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
if message.is_empty() {
anyhow::bail!("Message cannot be empty");
}
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
let msg = svc.send_message(contact_id, message).await?;
info!(contact_id, encrypted = msg.encrypted, "Sent mesh message");
Ok(serde_json::json!({
"nodes": nodes,
"count": nodes.len(),
"sent": true,
"message_id": msg.id,
"encrypted": msg.encrypted,
}))
}
/// mesh.broadcast — Broadcast our node identity over mesh.
pub(super) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
let config = mesh::load_config(&self.config.data_dir).await?;
if !config.enabled {
anyhow::bail!("Mesh networking is not enabled. Configure it first.");
}
let (data, _) = self.state_manager.get_snapshot().await;
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let pubkey = &data.server_info.pubkey;
mesh::broadcast_identity(&did, pubkey, config.device_path.as_deref()).await?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
svc.broadcast_identity().await?;
info!("Broadcast identity over mesh");
Ok(serde_json::json!({ "broadcast": true }))
}
@ -77,9 +141,18 @@ impl RpcHandler {
if let Some(broadcast) = params.get("broadcast_identity").and_then(|v| v.as_bool()) {
config.broadcast_identity = broadcast;
}
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
config.advert_name = Some(name.to_string());
}
mesh::save_config(&self.config.data_dir, &config).await?;
// If we have a running service, update its config
let mut service = self.mesh_service.write().await;
if let Some(svc) = service.as_mut() {
svc.configure(config.clone()).await?;
}
info!("Mesh config updated");
Ok(serde_json::json!({
"configured": true,

View File

@ -23,6 +23,7 @@ mod peers;
mod router;
mod security;
mod tor;
mod transport;
mod totp;
mod system;
mod update;
@ -166,6 +167,8 @@ pub struct RpcHandler {
login_rate_limiter: LoginRateLimiter,
endpoint_rate_limiter: EndpointRateLimiter,
response_cache: ResponseCache,
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
}
impl RpcHandler {
@ -196,9 +199,26 @@ impl RpcHandler {
login_rate_limiter: LoginRateLimiter::new(),
endpoint_rate_limiter: EndpointRateLimiter::new(),
response_cache: ResponseCache::new(5),
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
})
}
/// Set the mesh service (called after identity is loaded).
pub async fn set_mesh_service(&self, service: crate::mesh::MeshService) {
*self.mesh_service.write().await = Some(service);
}
/// Set the transport router (called after all transports are initialized).
pub async fn set_transport_router(&self, router: Arc<crate::transport::TransportRouter>) {
*self.transport_router.write().await = Some(router);
}
/// Get reference to the mesh service Arc (for MeshTransport wrapper).
pub fn mesh_service_arc(&self) -> Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>> {
Arc::clone(&self.mesh_service)
}
fn cookie_suffix(&self) -> &'static str {
if self.config.dev_mode { "" } else { "; Secure" }
}
@ -471,6 +491,7 @@ impl RpcHandler {
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
"lnd.connect-info" => self.handle_lnd_connect_info().await,
// Multi-identity management
"identity.list" => self.handle_identity_list(params).await,
@ -618,12 +639,20 @@ impl RpcHandler {
"marketplace.create-invoice" => self.handle_marketplace_create_invoice(params).await,
"marketplace.check-payment" => self.handle_marketplace_check_payment(params).await,
// Mesh networking
// Mesh networking (Meshcore LoRa)
"mesh.status" => self.handle_mesh_status().await,
"mesh.discover" => self.handle_mesh_discover(params).await,
"mesh.peers" => self.handle_mesh_peers().await,
"mesh.messages" => self.handle_mesh_messages(params).await,
"mesh.send" => self.handle_mesh_send(params).await,
"mesh.broadcast" => self.handle_mesh_broadcast().await,
"mesh.configure" => self.handle_mesh_configure(params).await,
// Transport layer (unified routing)
"transport.status" => self.handle_transport_status().await,
"transport.peers" => self.handle_transport_peers().await,
"transport.send" => self.handle_transport_send(params).await,
"transport.set-mode" => self.handle_transport_set_mode(params).await,
// Server settings
"server.set-name" => self.handle_server_set_name(params).await,

View File

@ -0,0 +1,139 @@
use super::RpcHandler;
use crate::transport::{MessageType, TransportMessage};
use anyhow::Result;
use tracing::info;
impl RpcHandler {
/// transport.status — Get available transports and their status.
pub(super) async fn handle_transport_status(&self) -> Result<serde_json::Value> {
let router = self.transport_router.read().await;
if let Some(r) = router.as_ref() {
let transports: Vec<serde_json::Value> = r
.transport_status()
.into_iter()
.map(|(kind, available)| {
serde_json::json!({
"kind": kind.to_string(),
"available": available,
})
})
.collect();
let peer_count = r.registry.count().await;
let mesh_only = r.is_mesh_only().await;
Ok(serde_json::json!({
"transports": transports,
"mesh_only": mesh_only,
"peer_count": peer_count,
}))
} else {
Ok(serde_json::json!({
"transports": [],
"mesh_only": false,
"peer_count": 0,
}))
}
}
/// transport.peers — Get unified peer list with per-peer transport capabilities.
pub(super) async fn handle_transport_peers(&self) -> Result<serde_json::Value> {
let router = self.transport_router.read().await;
if let Some(r) = router.as_ref() {
let peers = r.registry.all_peers().await;
let peer_values: Vec<serde_json::Value> = peers
.into_iter()
.map(|p| {
let available = p.available_transports();
let preferred = available.first().map(|t| t.to_string());
serde_json::json!({
"did": p.did,
"pubkey_hex": p.pubkey_hex,
"name": p.name,
"trust_level": p.trust_level,
"mesh_contact_id": p.mesh_contact_id,
"lan_address": p.lan_address,
"onion_address": p.onion_address,
"preferred_transport": preferred,
"available_transports": available.iter().map(|t| t.to_string()).collect::<Vec<_>>(),
"last_seen": p.last_mesh.or(p.last_lan).or(p.last_tor),
})
})
.collect();
Ok(serde_json::json!({ "peers": peer_values }))
} else {
Ok(serde_json::json!({ "peers": [] }))
}
}
/// transport.send — Send a message to a peer via best available transport.
pub(super) async fn handle_transport_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.as_ref().ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let did = params["did"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'did' param"))?
.to_string();
let payload = params["payload"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'payload' param"))?
.to_string();
let router = self.transport_router.read().await;
let router = router
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Transport router not initialized"))?;
let (data, _) = self.state_manager.get_snapshot().await;
let our_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
.unwrap_or_default();
let message = TransportMessage {
from_did: our_did,
payload: payload.as_bytes().to_vec(),
message_type: MessageType::PeerMessage,
};
let transport_used = router.send_to_peer(did, &message).await?;
info!(did = %did, transport = %transport_used, "Sent message via transport");
Ok(serde_json::json!({
"sent": true,
"transport_used": transport_used.to_string(),
"did": did,
}))
}
/// transport.set-mode — Toggle mesh-only (off-grid) mode.
pub(super) async fn handle_transport_set_mode(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.as_ref().ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let mesh_only = params["mesh_only"]
.as_bool()
.ok_or_else(|| anyhow::anyhow!("Missing 'mesh_only' bool param"))?;
let router = self.transport_router.read().await;
let router = router
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Transport router not initialized"))?;
router.set_mesh_only(mesh_only).await;
// Also persist to mesh config
let mut mesh_config = crate::mesh::load_config(&self.config.data_dir)
.await
.unwrap_or_default();
mesh_config.mesh_only_mode = Some(mesh_only);
crate::mesh::save_config(&self.config.data_dir, &mesh_config).await?;
info!(mesh_only = mesh_only, "Transport mode updated");
Ok(serde_json::json!({
"mesh_only": mesh_only,
"configured": true,
}))
}
}

View File

@ -32,6 +32,8 @@ pub struct ElectrsSyncStatus {
pub error: Option<String>,
/// Index data size in human-readable format (e.g. "11.2 GB")
pub index_size: Option<String>,
/// Tor onion address for ElectrumX (if available)
pub tor_onion: Option<String>,
}
/// Get the total size of a directory in bytes.
@ -146,6 +148,14 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
None
};
// Read Tor onion address if available
let tor_onion = tokio::fs::read_to_string(
"/var/lib/archipelago/tor/hidden_service_electrs/hostname",
)
.await
.ok()
.map(|s| s.trim().to_string());
let network_height = match bitcoin_network_height().await {
Ok(h) => h,
Err(e) => {
@ -156,6 +166,7 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
status: "error".to_string(),
error: Some(format!("Bitcoin RPC: {}", e)),
index_size,
tor_onion,
};
}
};
@ -196,6 +207,7 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
status,
error,
index_size,
tor_onion: tor_onion.clone(),
};
}
Err(e) => {
@ -206,6 +218,7 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
status: "error".to_string(),
error: Some(format!("Task: {}", e)),
index_size,
tor_onion: tor_onion.clone(),
};
}
};
@ -229,5 +242,6 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
status: status.to_string(),
error: None,
index_size,
tor_onion,
}
}

View File

@ -61,6 +61,11 @@ impl NodeIdentity {
})
}
/// Access the signing key (for key derivation, e.g. mesh encryption).
pub fn signing_key(&self) -> &SigningKey {
&self.signing_key
}
/// Public key as hex string (for ServerInfo, Nostr, etc.)
pub fn pubkey_hex(&self) -> String {
hex::encode(self.signing_key.verifying_key().as_bytes())

View File

@ -25,6 +25,7 @@ mod identity_manager;
mod marketplace;
mod mesh;
mod monitoring;
mod transport;
mod node_message;
mod nostr_discovery;
mod nostr_handshake;

View File

@ -1,265 +0,0 @@
//! Mesh networking: local node discovery over LoRa (Meshtastic) and BLE.
//!
//! Broadcasts node identity over mesh radio networks for offline peer discovery.
//! Uses Meshtastic serial protocol when a compatible radio is connected via USB.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
const MESH_CONFIG_FILE: &str = "mesh-config.json";
/// A node discovered via mesh radio.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshNode {
pub node_id: String,
pub did: Option<String>,
pub pubkey: Option<String>,
pub rssi: Option<i32>,
pub snr: Option<f64>,
pub last_heard: String,
#[serde(default)]
pub hops: u32,
#[serde(default)]
pub channel: Option<String>,
}
/// Mesh configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshConfig {
pub enabled: bool,
#[serde(default)]
pub device_path: Option<String>,
#[serde(default)]
pub channel_name: Option<String>,
#[serde(default)]
pub broadcast_identity: bool,
}
impl Default for MeshConfig {
fn default() -> Self {
Self {
enabled: false,
device_path: None,
channel_name: Some("archipelago".to_string()),
broadcast_identity: true,
}
}
}
pub async fn load_config(data_dir: &Path) -> Result<MeshConfig> {
let path = data_dir.join(MESH_CONFIG_FILE);
if !path.exists() {
return Ok(MeshConfig::default());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read mesh config")?;
let config: MeshConfig = serde_json::from_str(&content).unwrap_or_default();
Ok(config)
}
pub async fn save_config(data_dir: &Path, config: &MeshConfig) -> Result<()> {
fs::create_dir_all(data_dir).await.context("Failed to create data dir")?;
let content =
serde_json::to_string_pretty(config).context("Failed to serialize mesh config")?;
fs::write(data_dir.join(MESH_CONFIG_FILE), content)
.await
.context("Failed to write mesh config")?;
Ok(())
}
/// Detect Meshtastic-compatible USB devices.
/// Meshtastic radios typically appear as USB serial devices (CP210x, CH340, FTDI).
pub async fn detect_meshtastic_devices() -> Vec<String> {
let mut devices = Vec::new();
// Check for common serial device paths
let candidates = [
"/dev/ttyUSB0",
"/dev/ttyUSB1",
"/dev/ttyACM0",
"/dev/ttyACM1",
];
for path in &candidates {
if tokio::fs::metadata(path).await.is_ok() {
devices.push(path.to_string());
}
}
// Also scan sysfs for Meshtastic-specific USB VIDs
if let Ok(mut entries) = tokio::fs::read_dir("/sys/bus/usb/devices").await {
while let Ok(Some(entry)) = entries.next_entry().await {
let vid_path = entry.path().join("idVendor");
if let Ok(vid_str) = tokio::fs::read_to_string(&vid_path).await {
let vid = vid_str.trim();
// Silicon Labs CP210x (common Meshtastic radio)
// CH340 USB-serial
// FTDI FT232
if vid == "10c4" || vid == "1a86" || vid == "0403" {
let product = tokio::fs::read_to_string(entry.path().join("product"))
.await
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "Serial Device".to_string());
devices.push(format!("{} ({})", entry.path().display(), product));
}
}
}
}
devices
}
/// Discover nodes via Meshtastic CLI (meshtastic --nodes).
/// Returns nodes that have broadcast their Archipelago identity.
pub async fn discover_nodes(device_path: Option<&str>) -> Result<Vec<MeshNode>> {
let mut cmd = tokio::process::Command::new("meshtastic");
cmd.arg("--nodes");
if let Some(dev) = device_path {
cmd.arg("--port").arg(dev);
}
let output = cmd
.output()
.await
.context("Failed to run meshtastic CLI — is it installed?")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("No Meshtastic") || stderr.contains("not found") {
return Ok(Vec::new());
}
anyhow::bail!("meshtastic --nodes failed: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut nodes = Vec::new();
// Parse the meshtastic CLI node list output
// Format varies but typically: NodeNum | User | AKA | ...
for line in stdout.lines().skip(2) {
// Skip header lines
let parts: Vec<&str> = line.split('|').map(|s| s.trim()).collect();
if parts.len() < 3 {
continue;
}
let node_id = parts.first().unwrap_or(&"").to_string();
if node_id.is_empty() || node_id.starts_with('-') {
continue;
}
nodes.push(MeshNode {
node_id: node_id.trim().to_string(),
did: None,
pubkey: None,
rssi: None,
snr: None,
last_heard: chrono::Utc::now().to_rfc3339(),
hops: 0,
channel: None,
});
}
Ok(nodes)
}
/// Broadcast our node identity over mesh.
pub async fn broadcast_identity(
did: &str,
pubkey: &str,
device_path: Option<&str>,
) -> Result<()> {
let message = format!("ARCHY:{}:{}", did, pubkey);
let mut cmd = tokio::process::Command::new("meshtastic");
cmd.arg("--sendtext").arg(&message);
if let Some(dev) = device_path {
cmd.arg("--port").arg(dev);
}
let output = cmd
.output()
.await
.context("Failed to broadcast via meshtastic")?;
if !output.status.success() {
anyhow::bail!(
"meshtastic broadcast failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mesh_config_default() {
let config = MeshConfig::default();
assert!(!config.enabled);
assert_eq!(config.channel_name, Some("archipelago".to_string()));
assert!(config.broadcast_identity);
}
#[test]
fn test_mesh_config_serialization() {
let config = MeshConfig {
enabled: true,
device_path: Some("/dev/ttyUSB0".to_string()),
channel_name: Some("test".to_string()),
broadcast_identity: false,
};
let json = serde_json::to_string(&config).unwrap();
let parsed: MeshConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.enabled);
assert_eq!(parsed.device_path, Some("/dev/ttyUSB0".to_string()));
}
#[test]
fn test_mesh_node_serialization() {
let node = MeshNode {
node_id: "!aabbccdd".to_string(),
did: Some("did:key:z123".to_string()),
pubkey: Some("pubhex".to_string()),
rssi: Some(-85),
snr: Some(7.5),
last_heard: "2026-03-10T00:00:00Z".to_string(),
hops: 1,
channel: Some("archipelago".to_string()),
};
let json = serde_json::to_string(&node).unwrap();
let parsed: MeshNode = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.node_id, "!aabbccdd");
assert_eq!(parsed.rssi, Some(-85));
}
#[tokio::test]
async fn test_load_config_default_when_no_file() {
let dir = tempfile::tempdir().unwrap();
let config = load_config(dir.path()).await.unwrap();
assert!(!config.enabled);
}
#[tokio::test]
async fn test_save_and_load_config_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = MeshConfig {
enabled: true,
device_path: Some("/dev/ttyUSB0".to_string()),
channel_name: Some("archy".to_string()),
broadcast_identity: true,
};
save_config(dir.path(), &config).await.unwrap();
let loaded = load_config(dir.path()).await.unwrap();
assert!(loaded.enabled);
assert_eq!(loaded.device_path, Some("/dev/ttyUSB0".to_string()));
}
}

View File

@ -0,0 +1,217 @@
//! Mesh message encryption: X25519 ECDH key agreement + ChaCha20-Poly1305.
//!
//! Reuses Archipelago's existing Ed25519 identity infrastructure.
//! Ed25519 keys are converted to X25519 for Diffie-Hellman key exchange,
//! then ChaCha20-Poly1305 encrypts each message with a unique random nonce.
use anyhow::Result;
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
use rand::RngCore;
/// Nonce size for ChaCha20-Poly1305.
const NONCE_SIZE: usize = 12;
/// Auth tag size for ChaCha20-Poly1305.
const TAG_SIZE: usize = 16;
/// Minimum ciphertext size: nonce + at least 1 byte + tag.
const MIN_CIPHERTEXT_SIZE: usize = NONCE_SIZE + 1 + TAG_SIZE;
/// Convert an Ed25519 public key (32 bytes) to an X25519 public key (32 bytes).
/// Uses the standard Edwards-to-Montgomery conversion.
pub fn ed25519_pubkey_to_x25519(ed_pubkey: &[u8; 32]) -> Result<[u8; 32]> {
let compressed = curve25519_dalek::edwards::CompressedEdwardsY(*ed_pubkey);
let point = compressed
.decompress()
.ok_or_else(|| anyhow::anyhow!("Invalid Ed25519 public key: decompression failed"))?;
let montgomery = point.to_montgomery();
Ok(*montgomery.as_bytes())
}
/// Convert an Ed25519 signing key to an X25519 secret key.
/// Applies SHA-512 clamping as per RFC 7748.
pub fn ed25519_secret_to_x25519(signing_key: &ed25519_dalek::SigningKey) -> [u8; 32] {
// The X25519 secret is derived from the first 32 bytes of SHA-512(ed25519_secret)
// with clamping applied. ed25519-dalek's to_scalar() handles this.
let hash = <sha2::Sha512 as sha2::Digest>::digest(signing_key.to_bytes());
let mut x25519_secret = [0u8; 32];
x25519_secret.copy_from_slice(&hash[..32]);
// Clamp per RFC 7748
x25519_secret[0] &= 248;
x25519_secret[31] &= 127;
x25519_secret[31] |= 64;
x25519_secret
}
/// Perform X25519 Diffie-Hellman key agreement.
/// Returns a 32-byte shared secret.
pub fn x25519_shared_secret(our_secret: &[u8; 32], their_public: &[u8; 32]) -> [u8; 32] {
use curve25519_dalek::montgomery::MontgomeryPoint;
use curve25519_dalek::scalar::Scalar;
let their_point = MontgomeryPoint(*their_public);
let our_scalar = Scalar::from_bytes_mod_order(*our_secret);
let shared = their_point * our_scalar;
*shared.as_bytes()
}
/// Encrypt plaintext with ChaCha20-Poly1305 using a shared secret.
/// Output format: [nonce (12 bytes)] + [ciphertext + tag (16 bytes)]
///
/// Each call generates a fresh random 12-byte nonce via OsRng (CSPRNG).
pub fn encrypt(shared_secret: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = ChaCha20Poly1305::new_from_slice(shared_secret)
.map_err(|e| anyhow::anyhow!("Failed to create cipher: {}", e))?;
let mut nonce_bytes = [0u8; NONCE_SIZE];
rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
let mut output = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
Ok(output)
}
/// Decrypt ciphertext produced by `encrypt()`.
/// Input format: [nonce (12 bytes)] + [ciphertext + tag (16 bytes)]
pub fn decrypt(shared_secret: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
if data.len() < MIN_CIPHERTEXT_SIZE {
anyhow::bail!(
"Ciphertext too short: {} bytes (minimum {})",
data.len(),
MIN_CIPHERTEXT_SIZE
);
}
let nonce = Nonce::from_slice(&data[..NONCE_SIZE]);
let ciphertext = &data[NONCE_SIZE..];
let cipher = ChaCha20Poly1305::new_from_slice(shared_secret)
.map_err(|e| anyhow::anyhow!("Failed to create cipher: {}", e))?;
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| anyhow::anyhow!("Decryption failed: invalid key or corrupted message"))
}
/// Maximum plaintext bytes that fit in a single encrypted LoRa message.
/// 160 (max LoRa payload) - 12 (nonce) - 16 (tag) = 132 bytes.
pub const MAX_ENCRYPTED_PLAINTEXT: usize = 160 - NONCE_SIZE - TAG_SIZE;
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
#[test]
fn test_encrypt_decrypt_roundtrip() {
let shared_secret = [42u8; 32];
let plaintext = b"hello from mesh";
let ciphertext = encrypt(&shared_secret, plaintext).unwrap();
assert!(ciphertext.len() > plaintext.len()); // nonce + tag overhead
let decrypted = decrypt(&shared_secret, &ciphertext).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_decrypt_wrong_key() {
let secret1 = [1u8; 32];
let secret2 = [2u8; 32];
let ciphertext = encrypt(&secret1, b"secret").unwrap();
assert!(decrypt(&secret2, &ciphertext).is_err());
}
#[test]
fn test_decrypt_corrupted() {
let secret = [42u8; 32];
let mut ciphertext = encrypt(&secret, b"test").unwrap();
// Flip a byte in the ciphertext (after nonce)
let idx = NONCE_SIZE + 1;
ciphertext[idx] ^= 0xFF;
assert!(decrypt(&secret, &ciphertext).is_err());
}
#[test]
fn test_decrypt_too_short() {
let secret = [42u8; 32];
assert!(decrypt(&secret, &[0u8; 10]).is_err());
}
#[test]
fn test_unique_nonces() {
let secret = [42u8; 32];
let ct1 = encrypt(&secret, b"same").unwrap();
let ct2 = encrypt(&secret, b"same").unwrap();
// Nonces (first 12 bytes) should differ
assert_ne!(&ct1[..NONCE_SIZE], &ct2[..NONCE_SIZE]);
}
#[test]
fn test_ed25519_to_x25519_pubkey() {
let signing_key = SigningKey::generate(&mut OsRng);
let ed_pubkey = signing_key.verifying_key().to_bytes();
let x25519 = ed25519_pubkey_to_x25519(&ed_pubkey).unwrap();
// Should produce 32 non-zero bytes
assert_eq!(x25519.len(), 32);
assert!(x25519.iter().any(|&b| b != 0));
}
#[test]
fn test_x25519_key_agreement() {
// Generate two Ed25519 keypairs
let alice_signing = SigningKey::generate(&mut OsRng);
let bob_signing = SigningKey::generate(&mut OsRng);
// Convert to X25519
let alice_secret = ed25519_secret_to_x25519(&alice_signing);
let bob_secret = ed25519_secret_to_x25519(&bob_signing);
let alice_public = ed25519_pubkey_to_x25519(&alice_signing.verifying_key().to_bytes()).unwrap();
let bob_public = ed25519_pubkey_to_x25519(&bob_signing.verifying_key().to_bytes()).unwrap();
// Both sides should derive the same shared secret
let shared_ab = x25519_shared_secret(&alice_secret, &bob_public);
let shared_ba = x25519_shared_secret(&bob_secret, &alice_public);
assert_eq!(shared_ab, shared_ba);
}
#[test]
fn test_full_encrypt_decrypt_with_key_agreement() {
let alice_signing = SigningKey::generate(&mut OsRng);
let bob_signing = SigningKey::generate(&mut OsRng);
let alice_secret = ed25519_secret_to_x25519(&alice_signing);
let bob_secret = ed25519_secret_to_x25519(&bob_signing);
let alice_public = ed25519_pubkey_to_x25519(&alice_signing.verifying_key().to_bytes()).unwrap();
let bob_public = ed25519_pubkey_to_x25519(&bob_signing.verifying_key().to_bytes()).unwrap();
let shared = x25519_shared_secret(&alice_secret, &bob_public);
// Alice encrypts
let plaintext = b"sats over mesh";
let ciphertext = encrypt(&shared, plaintext).unwrap();
// Bob decrypts with same shared secret
let bob_shared = x25519_shared_secret(&bob_secret, &alice_public);
let decrypted = decrypt(&bob_shared, &ciphertext).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_max_encrypted_plaintext_fits() {
let secret = [42u8; 32];
let plaintext = vec![0xAB; MAX_ENCRYPTED_PLAINTEXT];
let ciphertext = encrypt(&secret, &plaintext).unwrap();
// Should fit within LoRa max message size (160 bytes)
assert!(ciphertext.len() <= 160);
}
}

View File

@ -0,0 +1,656 @@
//! Background mesh listener task.
//!
//! Runs as a long-lived tokio task that:
//! - Maintains the serial connection to the Meshcore device
//! - Reads incoming frames and dispatches events
//! - Periodically broadcasts our identity advertisement
//! - Reconnects on device disconnect
//! - Manages peer cache and message store
use super::crypto;
use super::protocol;
use super::serial::MeshcoreDevice;
use super::types::*;
use anyhow::Result;
use std::collections::{HashMap, VecDeque};
use tokio::sync::mpsc;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, RwLock};
use tracing::{debug, error, info, warn};
/// How often to broadcast our identity advertisement (seconds).
const ADVERT_INTERVAL: Duration = Duration::from_secs(60);
/// How often to poll for queued messages when no push notifications.
const SYNC_INTERVAL: Duration = Duration::from_secs(10);
/// Maximum stored messages (circular buffer).
const MAX_MESSAGES: usize = 100;
/// Delay before reconnection attempt after device disconnect.
const RECONNECT_DELAY: Duration = Duration::from_secs(10);
/// Command sent from MeshService to the listener task (which owns the serial port).
pub enum MeshCommand {
SendText { dest_pubkey_prefix: [u8; 6], payload: Vec<u8> },
SendAdvert,
}
/// Shared state for the mesh listener, accessible from RPC handlers.
pub struct MeshState {
pub peers: RwLock<HashMap<u32, MeshPeer>>,
pub messages: RwLock<VecDeque<MeshMessage>>,
pub shared_secrets: RwLock<HashMap<u32, [u8; 32]>>,
pub status: RwLock<MeshStatus>,
pub event_tx: broadcast::Sender<MeshEvent>,
pub cmd_tx: mpsc::Sender<MeshCommand>,
next_message_id: RwLock<u64>,
}
impl MeshState {
pub fn new(channel_name: &str) -> (Arc<Self>, broadcast::Receiver<MeshEvent>, mpsc::Receiver<MeshCommand>) {
let (tx, rx) = broadcast::channel(64);
let (cmd_tx, cmd_rx) = mpsc::channel(32);
let state = Arc::new(Self {
peers: RwLock::new(HashMap::new()),
messages: RwLock::new(VecDeque::new()),
shared_secrets: RwLock::new(HashMap::new()),
cmd_tx,
status: RwLock::new(MeshStatus {
enabled: true,
device_type: DeviceType::Unknown,
device_path: None,
device_connected: false,
firmware_version: None,
self_node_id: None,
self_advert_name: None,
peer_count: 0,
channel_name: channel_name.to_string(),
messages_sent: 0,
messages_received: 0,
}),
event_tx: tx,
next_message_id: RwLock::new(1),
});
(state, rx, cmd_rx)
}
pub async fn next_id(&self) -> u64 {
let mut id = self.next_message_id.write().await;
let current = *id;
*id += 1;
current
}
pub async fn store_message(&self, msg: MeshMessage) {
let mut messages = self.messages.write().await;
messages.push_back(msg);
if messages.len() > MAX_MESSAGES {
messages.pop_front();
}
}
async fn update_peer_count(&self) {
let count = self.peers.read().await.len();
self.status.write().await.peer_count = count;
}
}
/// Spawn the background mesh listener task.
///
/// This task manages the full lifecycle:
/// 1. Detect and connect to Meshcore device
/// 2. Initialize and set advert name
/// 3. Main loop: read frames, dispatch events, periodic adverts
/// 4. Reconnect on disconnect
pub fn spawn_mesh_listener(
state: Arc<MeshState>,
device_path: Option<String>,
our_did: String,
our_ed_pubkey_hex: String,
our_x25519_secret: [u8; 32],
our_x25519_pubkey_hex: String,
shutdown: tokio::sync::watch::Receiver<bool>,
cmd_rx: mpsc::Receiver<MeshCommand>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut shutdown = shutdown;
let mut cmd_rx = cmd_rx;
loop {
if *shutdown.borrow() {
info!("Mesh listener shutting down");
return;
}
match run_mesh_session(
&state,
device_path.as_deref(),
&our_did,
&our_ed_pubkey_hex,
&our_x25519_secret,
&our_x25519_pubkey_hex,
&mut shutdown,
&mut cmd_rx,
)
.await
{
Ok(()) => {
info!("Mesh session ended cleanly");
}
Err(e) => {
error!("Mesh session error: {}", e);
}
}
// Update status to disconnected
{
let mut status = state.status.write().await;
status.device_connected = false;
status.device_path = None;
}
let _ = state.event_tx.send(MeshEvent::DeviceDisconnected);
// Wait before reconnecting
tokio::select! {
_ = tokio::time::sleep(RECONNECT_DELAY) => {},
_ = shutdown.changed() => {
if *shutdown.borrow() { return; }
},
}
}
})
}
/// Run a single mesh session (connect, initialize, main loop).
async fn run_mesh_session(
state: &Arc<MeshState>,
preferred_path: Option<&str>,
our_did: &str,
our_ed_pubkey_hex: &str,
our_x25519_secret: &[u8; 32],
our_x25519_pubkey_hex: &str,
shutdown: &mut tokio::sync::watch::Receiver<bool>,
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
) -> Result<()> {
// Detect device
let device_path = if let Some(path) = preferred_path {
path.to_string()
} else {
let paths = super::serial::detect_serial_devices().await;
if paths.is_empty() {
anyhow::bail!("No serial devices found");
}
match super::serial::probe_for_meshcore(&paths).await {
Some((path, _)) => path,
None => anyhow::bail!("No Meshcore device found on available serial ports"),
}
};
// Open and initialize
let mut device = MeshcoreDevice::open(&device_path).await?;
let device_info = device.initialize().await?;
// Update status
{
let mut status = state.status.write().await;
status.device_connected = true;
status.device_type = DeviceType::Meshcore;
status.device_path = Some(device_path.clone());
status.firmware_version = Some(device_info.firmware_version.clone());
status.self_node_id = Some(device_info.node_id);
status.self_advert_name = device.advert_name.clone();
}
let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info));
// Set advert name to something identifiable
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
let advert_name = format!("Archy-{}", short_did);
if let Err(e) = device.set_advert_name(&advert_name).await {
warn!("Failed to set advert name: {}", e);
}
// Broadcast our advertisement so other nodes can discover us
if let Err(e) = device.send_self_advert().await {
warn!("Failed to send initial advert: {}", e);
}
// Fetch existing contacts from the device
refresh_contacts(&mut device, state).await;
// Sync any queued messages from before we connected
sync_queued_messages(&mut device, state, our_x25519_secret).await;
// Main loop
let mut advert_timer = tokio::time::interval(ADVERT_INTERVAL);
let mut sync_timer = tokio::time::interval(SYNC_INTERVAL);
advert_timer.tick().await; // skip first immediate tick
sync_timer.tick().await;
loop {
tokio::select! {
// Check for incoming frames
frame_result = device.try_recv_frame() => {
match frame_result {
Ok(Some(frame)) => {
let should_action = handle_frame(
&frame,
state,
our_x25519_secret,
).await;
if should_action {
// Contact discovery or messages waiting — sync both
refresh_contacts(&mut device, state).await;
sync_queued_messages(&mut device, state, our_x25519_secret).await;
}
}
Ok(None) => {
// No complete frame yet, that's fine
tokio::time::sleep(Duration::from_millis(50)).await;
}
Err(e) => {
error!("Serial read error: {}", e);
return Err(e);
}
}
}
// Periodic advertisement broadcast + contact refresh
_ = advert_timer.tick() => {
debug!("Periodic self-advert broadcast");
if let Err(e) = device.send_self_advert().await {
warn!("Failed to send advert: {}", e);
}
refresh_contacts(&mut device, state).await;
}
// Process send commands from MeshService
Some(cmd) = cmd_rx.recv() => {
match cmd {
MeshCommand::SendText { dest_pubkey_prefix, payload } => {
if let Err(e) = device.send_text(&dest_pubkey_prefix, &payload).await {
warn!("Failed to send text via mesh: {}", e);
} else {
info!(dest = %hex::encode(dest_pubkey_prefix), len = payload.len(), "Sent mesh message");
}
}
MeshCommand::SendAdvert => {
if let Err(e) = device.send_self_advert().await {
warn!("Failed to send advert: {}", e);
}
}
}
}
// Periodic message sync
_ = sync_timer.tick() => {
sync_queued_messages(&mut device, state, our_x25519_secret).await;
}
// Shutdown signal
_ = shutdown.changed() => {
if *shutdown.borrow() {
info!("Mesh listener received shutdown signal");
return Ok(());
}
}
}
}
}
/// Handle a single inbound frame from the device.
/// Returns `true` if contacts should be refreshed from the device.
async fn handle_frame(
frame: &protocol::InboundFrame,
state: &Arc<MeshState>,
our_x25519_secret: &[u8; 32],
) -> bool {
match frame.code {
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
info!(code = frame.code, "Contact discovery event — refreshing contacts");
return true; // Signal caller to fetch contacts
}
protocol::PUSH_ACK => {
debug!("Message delivery confirmed");
// Could track which message was ACKed from frame.data
}
protocol::PUSH_MESSAGES_WAITING => {
info!("Device has messages waiting — will sync");
return true; // Signal caller to sync immediately
}
protocol::RESP_CONTACT_MSG_V3 => {
// Direct message received (v3 format)
match protocol::parse_contact_msg_v3(&frame.data) {
Ok((sender_prefix, text, _snr)) => {
if !text.is_empty() {
let peer_name = {
let peers = state.peers.read().await;
peers.values()
.find(|p| p.pubkey_hex.as_ref().map(|k| k.starts_with(&sender_prefix)).unwrap_or(false))
.map(|p| (p.contact_id, p.advert_name.clone()))
};
let (contact_id, name) = peer_name.unwrap_or((0, sender_prefix.clone()));
let msg_id = state.next_id().await;
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: contact_id,
peer_name: Some(name),
plaintext: text,
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
};
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
info!(from = %sender_prefix, "Received mesh DM (v3)");
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
}
}
Err(e) => warn!("Failed to parse v3 message: {}", e),
}
}
protocol::RESP_CONTACT_MSG => {
// Direct message received (v1 format)
match protocol::parse_contact_msg_v1(&frame.data) {
Ok((sender_prefix, text)) => {
if !text.is_empty() {
let peer_name = {
let peers = state.peers.read().await;
peers.values()
.find(|p| p.pubkey_hex.as_ref().map(|k| k.starts_with(&sender_prefix)).unwrap_or(false))
.map(|p| (p.contact_id, p.advert_name.clone()))
};
let (contact_id, name) = peer_name.unwrap_or((0, sender_prefix.clone()));
let msg_id = state.next_id().await;
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: contact_id,
peer_name: Some(name),
plaintext: text,
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
};
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
info!(from = %sender_prefix, "Received mesh DM (v1)");
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
}
}
Err(e) => warn!("Failed to parse v1 message: {}", e),
}
}
protocol::RESP_CHANNEL_MSG_V3 => {
// Channel broadcast received (v3)
match protocol::parse_channel_msg_v3(&frame.data) {
Ok((channel_idx, text)) => {
if !text.is_empty() {
let msg_id = state.next_id().await;
let chan_contact_id = -((channel_idx as i32) + 1);
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: chan_contact_id as u32,
peer_name: Some(format!("Channel {}", channel_idx)),
plaintext: text,
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
};
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
info!(channel = channel_idx, "Received mesh channel message (v3)");
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
}
}
Err(e) => warn!("Failed to parse v3 channel message: {}", e),
}
}
protocol::RESP_CHANNEL_MSG => {
// Channel broadcast received (v1)
match protocol::parse_channel_msg_v1(&frame.data) {
Ok((channel_idx, text)) => {
if !text.is_empty() {
let msg_id = state.next_id().await;
let chan_contact_id = -((channel_idx as i32) + 1);
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: chan_contact_id as u32,
peer_name: Some(format!("Channel {}", channel_idx)),
plaintext: text,
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
};
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
info!(channel = channel_idx, "Received mesh channel message");
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
}
}
Err(e) => warn!("Failed to parse channel message: {}", e),
}
}
protocol::PUSH_LOG_DATA | protocol::PUSH_PATH_UPDATE | protocol::PUSH_RAW_DATA => {
// Internal device logging/path data — safe to ignore
}
_ => {
if protocol::is_push_notification(frame.code) {
debug!(code = frame.code, "Unhandled push notification");
}
}
}
false
}
/// Handle a received identity broadcast from a peer.
async fn handle_identity_received(
contact_id: u32,
rssi: i16,
did: &str,
ed_pubkey_hex: &str,
x25519_pubkey_hex: &str,
state: &Arc<MeshState>,
our_x25519_secret: &[u8; 32],
) {
info!(
contact_id,
did = %did,
rssi,
"Archipelago peer discovered over mesh"
);
// Decode X25519 public key
let x25519_bytes = match hex::decode(x25519_pubkey_hex) {
Ok(b) if b.len() == 32 => {
let mut arr = [0u8; 32];
arr.copy_from_slice(&b);
arr
}
_ => {
warn!("Invalid X25519 public key from peer");
return;
}
};
// Derive shared secret for encrypted messaging
let shared_secret = crypto::x25519_shared_secret(our_x25519_secret, &x25519_bytes);
state
.shared_secrets
.write()
.await
.insert(contact_id, shared_secret);
// Update peer record
let peer = MeshPeer {
contact_id,
advert_name: format!("Archy-{}", &did[8..16.min(did.len())]),
did: Some(did.to_string()),
pubkey_hex: Some(ed_pubkey_hex.to_string()),
x25519_pubkey: Some(x25519_bytes),
rssi: Some(rssi),
snr: None,
last_heard: chrono::Utc::now().to_rfc3339(),
hops: 0,
};
let is_new = {
let mut peers = state.peers.write().await;
let is_new = !peers.contains_key(&contact_id);
peers.insert(contact_id, peer.clone());
is_new
};
state.update_peer_count().await;
let event = if is_new {
MeshEvent::PeerDiscovered(peer)
} else {
MeshEvent::PeerUpdated(peer)
};
let _ = state.event_tx.send(event);
let _ = state.event_tx.send(MeshEvent::IdentityReceived {
contact_id,
did: did.to_string(),
pubkey_hex: ed_pubkey_hex.to_string(),
x25519_pubkey: x25519_bytes,
});
}
/// Handle a received message (direct or channel).
async fn handle_received_message(
contact_id: u32,
payload: &[u8],
rssi: i16,
is_channel: bool,
state: &Arc<MeshState>,
_our_x25519_secret: &[u8; 32],
) {
// Try to decrypt if we have a shared secret for this contact
let shared_secrets = state.shared_secrets.read().await;
let (plaintext, encrypted) = if let Some(secret) = shared_secrets.get(&contact_id) {
match crypto::decrypt(secret, payload) {
Ok(pt) => (String::from_utf8_lossy(&pt).to_string(), true),
Err(_) => {
// Not encrypted or wrong key — treat as plaintext
(String::from_utf8_lossy(payload).to_string(), false)
}
}
} else {
(String::from_utf8_lossy(payload).to_string(), false)
};
drop(shared_secrets);
// Update peer last_heard
{
let mut peers = state.peers.write().await;
if let Some(peer) = peers.get_mut(&contact_id) {
peer.last_heard = chrono::Utc::now().to_rfc3339();
peer.rssi = Some(rssi);
}
}
let peer_name = state
.peers
.read()
.await
.get(&contact_id)
.map(|p| p.advert_name.clone());
let msg_id = state.next_id().await;
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: contact_id,
peer_name,
plaintext: plaintext.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted,
};
state.store_message(msg.clone()).await;
{
let mut status = state.status.write().await;
status.messages_received += 1;
}
info!(
contact_id,
encrypted,
channel = is_channel,
"Received mesh message"
);
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
}
/// Drain any queued messages from the device.
async fn sync_queued_messages(
device: &mut MeshcoreDevice,
state: &Arc<MeshState>,
our_x25519_secret: &[u8; 32],
) {
match device.sync_messages().await {
Ok(frames) => {
for frame in &frames {
handle_frame(frame, state, our_x25519_secret).await;
}
if !frames.is_empty() {
info!(count = frames.len(), "Synced queued mesh messages");
}
}
Err(e) => {
debug!("Message sync: {}", e);
}
}
}
/// Fetch the contacts list from the device and update the peer cache.
async fn refresh_contacts(
device: &mut MeshcoreDevice,
state: &Arc<MeshState>,
) {
match device.get_contacts().await {
Ok(contacts) => {
let mut peers = state.peers.write().await;
for (idx, contact) in contacts.iter().enumerate() {
let contact_id = idx as u32;
let existing = peers.get(&contact_id);
let peer = MeshPeer {
contact_id,
advert_name: contact.advert_name.clone(),
did: existing.and_then(|p| p.did.clone()),
pubkey_hex: Some(contact.public_key_hex.clone()),
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
rssi: None,
snr: None,
last_heard: chrono::Utc::now().to_rfc3339(),
hops: 0,
};
peers.insert(contact_id, peer);
}
drop(peers);
state.update_peer_count().await;
if !contacts.is_empty() {
info!(count = contacts.len(), "Refreshed mesh contacts");
}
}
Err(e) => {
warn!("Failed to fetch contacts: {}", e);
}
}
}

View File

@ -0,0 +1,401 @@
//! Mesh networking: Meshcore LoRa radio integration for offline peer discovery
//! and encrypted messaging between Archipelago nodes.
//!
//! Supports Meshcore firmware on Heltec V3, T-Beam, RAK WisBlock, Station G2,
//! and other ESP32/nRF52-based LoRa boards via USB serial (Companion USB mode).
#[allow(dead_code)]
pub mod crypto;
#[allow(dead_code)]
pub mod listener;
#[allow(dead_code)]
pub mod protocol;
#[allow(dead_code)]
pub mod serial;
#[allow(dead_code)]
pub mod types;
pub use types::*;
use anyhow::{Context, Result};
use ed25519_dalek::SigningKey;
use listener::MeshState;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tokio::sync::{broadcast, watch};
use tracing::info;
const MESH_CONFIG_FILE: &str = "mesh-config.json";
/// Mesh configuration (persisted to disk).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshConfig {
pub enabled: bool,
/// Specific device path, or None for auto-detection.
#[serde(default)]
pub device_path: Option<String>,
/// Channel name for broadcasts.
#[serde(default)]
pub channel_name: Option<String>,
/// Whether to periodically broadcast our identity.
#[serde(default)]
pub broadcast_identity: bool,
/// Custom advertised name on the mesh network.
#[serde(default)]
pub advert_name: Option<String>,
/// Off-grid mode: disable Tor/internet, route everything via mesh only.
#[serde(default)]
pub mesh_only_mode: Option<bool>,
}
impl Default for MeshConfig {
fn default() -> Self {
Self {
enabled: false,
device_path: None,
channel_name: Some("archipelago".to_string()),
broadcast_identity: true,
advert_name: None,
mesh_only_mode: None,
}
}
}
pub async fn load_config(data_dir: &Path) -> Result<MeshConfig> {
let path = data_dir.join(MESH_CONFIG_FILE);
if !path.exists() {
return Ok(MeshConfig::default());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read mesh config")?;
let config: MeshConfig = serde_json::from_str(&content).unwrap_or_default();
Ok(config)
}
pub async fn save_config(data_dir: &Path, config: &MeshConfig) -> Result<()> {
fs::create_dir_all(data_dir)
.await
.context("Failed to create data dir")?;
let content =
serde_json::to_string_pretty(config).context("Failed to serialize mesh config")?;
fs::write(data_dir.join(MESH_CONFIG_FILE), content)
.await
.context("Failed to write mesh config")?;
Ok(())
}
/// Detect serial devices that could be mesh radios.
/// Checks both Meshcore (via probe) and legacy Meshtastic paths.
pub async fn detect_devices() -> Vec<String> {
serial::detect_serial_devices().await
}
// ─── MeshService ────────────────────────────────────────────────────────
/// Top-level mesh networking service.
/// Manages the background listener, exposes APIs for RPC handlers.
pub struct MeshService {
state: Arc<MeshState>,
config: MeshConfig,
data_dir: PathBuf,
shutdown_tx: Option<watch::Sender<bool>>,
listener_handle: Option<tokio::task::JoinHandle<()>>,
cmd_rx: Option<tokio::sync::mpsc::Receiver<listener::MeshCommand>>,
// Crypto identity for this node
our_did: String,
our_ed_pubkey_hex: String,
our_x25519_secret: [u8; 32],
our_x25519_pubkey_hex: String,
}
#[allow(dead_code)]
impl MeshService {
/// Create a new MeshService. Does not start the listener yet.
pub async fn new(
data_dir: &Path,
signing_key: &SigningKey,
did: &str,
ed_pubkey_hex: &str,
) -> Result<Self> {
let config = load_config(data_dir).await?;
let channel_name = config
.channel_name
.clone()
.unwrap_or_else(|| "archipelago".to_string());
let (state, _rx, cmd_rx) = MeshState::new(&channel_name);
// Derive X25519 keys from Ed25519 identity
let x25519_secret = crypto::ed25519_secret_to_x25519(signing_key);
let x25519_pubkey = crypto::ed25519_pubkey_to_x25519(
&signing_key.verifying_key().to_bytes(),
)?;
let x25519_pubkey_hex = hex::encode(x25519_pubkey);
Ok(Self {
state,
config,
data_dir: data_dir.to_path_buf(),
shutdown_tx: None,
listener_handle: None,
cmd_rx: Some(cmd_rx),
our_did: did.to_string(),
our_ed_pubkey_hex: ed_pubkey_hex.to_string(),
our_x25519_secret: x25519_secret,
our_x25519_pubkey_hex: x25519_pubkey_hex,
})
}
/// Start the background mesh listener.
pub fn start(&mut self) -> Result<()> {
if self.listener_handle.is_some() {
anyhow::bail!("Mesh listener already running");
}
let (shutdown_tx, shutdown_rx) = watch::channel(false);
self.shutdown_tx = Some(shutdown_tx);
let cmd_rx = self.cmd_rx.take()
.ok_or_else(|| anyhow::anyhow!("Command channel already consumed"))?;
let handle = listener::spawn_mesh_listener(
Arc::clone(&self.state),
self.config.device_path.clone(),
self.our_did.clone(),
self.our_ed_pubkey_hex.clone(),
self.our_x25519_secret,
self.our_x25519_pubkey_hex.clone(),
shutdown_rx,
cmd_rx,
);
self.listener_handle = Some(handle);
info!("Mesh service started");
Ok(())
}
/// Stop the background listener.
pub async fn stop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(true);
}
if let Some(handle) = self.listener_handle.take() {
let _ = handle.await;
}
info!("Mesh service stopped");
}
/// Get current mesh status.
pub async fn status(&self) -> MeshStatus {
self.state.status.read().await.clone()
}
/// Get list of discovered peers.
pub async fn peers(&self) -> Vec<MeshPeer> {
self.state.peers.read().await.values().cloned().collect()
}
/// Get message history.
pub async fn messages(&self, limit: Option<usize>) -> Vec<MeshMessage> {
let messages = self.state.messages.read().await;
let limit = limit.unwrap_or(MAX_MESSAGES_DEFAULT);
// Return in chronological order (oldest first) — take last N items
let len = messages.len();
let skip = if len > limit { len - limit } else { 0 };
messages.iter().skip(skip).cloned().collect()
}
/// Send a message to a peer by contact_id.
/// Routes through the background listener which owns the serial port.
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
let status = self.state.status.read().await;
if !status.device_connected {
anyhow::bail!("No mesh device connected");
}
drop(status);
// Look up the peer's public key to get the 6-byte prefix for addressing
let peers = self.state.peers.read().await;
let peer = peers
.get(&contact_id)
.ok_or_else(|| anyhow::anyhow!("Peer not found"))?;
let pubkey_hex = peer
.pubkey_hex
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?;
let pubkey_bytes = hex::decode(pubkey_hex)
.map_err(|_| anyhow::anyhow!("Invalid peer public key"))?;
if pubkey_bytes.len() < 6 {
anyhow::bail!("Peer public key too short");
}
let mut dest_prefix = [0u8; 6];
dest_prefix.copy_from_slice(&pubkey_bytes[..6]);
drop(peers);
let payload = text.as_bytes().to_vec();
let encrypted = false;
if payload.len() > protocol::MAX_MESSAGE_LEN {
anyhow::bail!(
"Message too large for LoRa: {} bytes (max {})",
payload.len(),
protocol::MAX_MESSAGE_LEN
);
}
// Send through the listener's command channel
self.state
.cmd_tx
.send(listener::MeshCommand::SendText {
dest_pubkey_prefix: dest_prefix,
payload,
})
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
let msg_id = self.state.next_id().await;
let peer_name = self
.state
.peers
.read()
.await
.get(&contact_id)
.map(|p| p.advert_name.clone());
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Sent,
peer_contact_id: contact_id,
peer_name,
plaintext: text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: false,
encrypted,
};
self.state.store_message(msg.clone()).await;
{
let mut status = self.state.status.write().await;
status.messages_sent += 1;
}
Ok(msg)
}
/// Broadcast our advertisement over mesh so other nodes can discover us.
/// Sends an immediate advert via the listener's command channel.
pub async fn broadcast_identity(&self) -> Result<()> {
let status = self.state.status.read().await;
if !status.device_connected {
anyhow::bail!("No mesh device connected. Check USB connection.");
}
drop(status);
self.state
.cmd_tx
.send(listener::MeshCommand::SendAdvert)
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
info!("Mesh self-advert broadcast triggered");
Ok(())
}
/// Update mesh configuration.
pub async fn configure(&mut self, config: MeshConfig) -> Result<()> {
save_config(&self.data_dir, &config).await?;
let was_enabled = self.config.enabled;
self.config = config.clone();
// Update the status to reflect new config
{
let mut status = self.state.status.write().await;
status.enabled = config.enabled;
status.channel_name = config.channel_name.clone().unwrap_or_else(|| "archipelago".to_string());
}
// If enabled state changed, start/stop the listener
if config.enabled && !was_enabled {
self.start()?;
} else if !config.enabled && was_enabled {
self.stop().await;
// Clear connected state
let mut status = self.state.status.write().await;
status.device_connected = false;
status.device_path = None;
status.firmware_version = None;
status.self_node_id = None;
status.peer_count = 0;
}
Ok(())
}
/// Subscribe to mesh events.
pub fn subscribe(&self) -> broadcast::Receiver<MeshEvent> {
self.state.event_tx.subscribe()
}
/// Get a reference to shared state (for RPC handlers).
pub fn shared_state(&self) -> Arc<MeshState> {
Arc::clone(&self.state)
}
}
const MAX_MESSAGES_DEFAULT: usize = 100;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mesh_config_default() {
let config = MeshConfig::default();
assert!(!config.enabled);
assert_eq!(config.channel_name, Some("archipelago".to_string()));
assert!(config.broadcast_identity);
}
#[test]
fn test_mesh_config_serialization() {
let config = MeshConfig {
enabled: true,
device_path: Some("/dev/ttyUSB0".to_string()),
channel_name: Some("test".to_string()),
broadcast_identity: false,
advert_name: Some("MyNode".to_string()),
};
let json = serde_json::to_string(&config).unwrap();
let parsed: MeshConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.enabled);
assert_eq!(parsed.device_path, Some("/dev/ttyUSB0".to_string()));
assert_eq!(parsed.advert_name, Some("MyNode".to_string()));
}
#[tokio::test]
async fn test_load_config_default_when_no_file() {
let dir = tempfile::tempdir().unwrap();
let config = load_config(dir.path()).await.unwrap();
assert!(!config.enabled);
}
#[tokio::test]
async fn test_save_and_load_config_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = MeshConfig {
enabled: true,
device_path: Some("/dev/ttyUSB0".to_string()),
channel_name: Some("archy".to_string()),
broadcast_identity: true,
advert_name: None,
};
save_config(dir.path(), &config).await.unwrap();
let loaded = load_config(dir.path()).await.unwrap();
assert!(loaded.enabled);
assert_eq!(loaded.device_path, Some("/dev/ttyUSB0".to_string()));
}
}

View File

@ -0,0 +1,661 @@
//! Meshcore binary frame protocol: constants, encoding, decoding, command builders.
//!
//! Frame format (USB serial):
//! - Outbound (host -> device): `<` (0x3C) + 2-byte LE length + frame data
//! - Inbound (device -> host): `>` (0x3E) + 2-byte LE length + frame data
//! - Baud: 115200, 8N1
//! - Max message payload: 160 bytes
use anyhow::Result;
// --- Frame markers ---
pub const OUTBOUND_MARKER: u8 = 0x3C; // '<' (host -> device)
pub const INBOUND_MARKER: u8 = 0x3E; // '>' (device -> host)
// --- Commands (host -> device) ---
pub const CMD_APP_START: u8 = 0x01;
pub const CMD_SEND_TXT_MSG: u8 = 0x02;
pub const CMD_SEND_CHANNEL_TXT_MSG: u8 = 0x03;
pub const CMD_GET_CONTACTS: u8 = 0x04;
pub const CMD_GET_DEVICE_TIME: u8 = 0x05;
pub const CMD_SET_DEVICE_TIME: u8 = 0x06;
pub const CMD_SEND_SELF_ADVERT: u8 = 0x07;
pub const CMD_SET_ADVERT_NAME: u8 = 0x08;
pub const CMD_SYNC_NEXT_MESSAGE: u8 = 0x0A;
pub const CMD_SET_RADIO_PARAMS: u8 = 0x0B;
pub const CMD_SET_RADIO_TX_POWER: u8 = 0x0C;
pub const CMD_SET_TUNING_PARAMS: u8 = 0x15;
pub const CMD_DEVICE_QUERY: u8 = 0x16;
pub const CMD_GET_CHANNEL: u8 = 0x1F;
pub const CMD_SET_CHANNEL: u8 = 0x20;
pub const CMD_GET_STATS: u8 = 0x38;
// --- Response codes (device -> host, synchronous) ---
pub const RESP_OK: u8 = 0x00;
pub const RESP_ERR: u8 = 0x01;
pub const RESP_CONTACT_START: u8 = 0x02;
pub const RESP_CONTACT: u8 = 0x03;
pub const RESP_CONTACT_END: u8 = 0x04;
pub const RESP_SELF_INFO: u8 = 0x05;
pub const RESP_SENT: u8 = 0x06;
pub const RESP_CONTACT_MSG: u8 = 0x07;
pub const RESP_CHANNEL_MSG: u8 = 0x08;
pub const RESP_CURRENT_TIME: u8 = 0x09;
pub const RESP_NO_MORE_MESSAGES: u8 = 0x0A;
pub const RESP_CONTACT_URI: u8 = 0x0B;
pub const RESP_BATTERY: u8 = 0x0C;
pub const RESP_DEVICE_INFO: u8 = 0x0D;
pub const RESP_CONTACT_MSG_V3: u8 = 0x10;
pub const RESP_CHANNEL_MSG_V3: u8 = 0x11;
pub const RESP_CHANNEL_INFO: u8 = 0x12;
pub const RESP_STATS: u8 = 0x18;
// --- Push notification codes (device -> host, async, >= 0x80) ---
pub const PUSH_CONTACT_ADVERT: u8 = 0x80;
pub const PUSH_PATH_UPDATE: u8 = 0x81;
pub const PUSH_ACK: u8 = 0x82;
pub const PUSH_MESSAGES_WAITING: u8 = 0x83;
pub const PUSH_RAW_DATA: u8 = 0x84;
pub const PUSH_LOG_DATA: u8 = 0x88;
pub const PUSH_NEW_CONTACT: u8 = 0x8A;
// --- Error codes ---
pub const ERR_UNSUPPORTED_CMD: u8 = 0x01;
pub const ERR_NOT_FOUND: u8 = 0x02;
pub const ERR_TABLE_FULL: u8 = 0x03;
pub const ERR_BAD_STATE: u8 = 0x04;
pub const ERR_FILE_IO: u8 = 0x05;
pub const ERR_ILLEGAL_ARG: u8 = 0x06;
/// Maximum payload size for a single LoRa message.
pub const MAX_MESSAGE_LEN: usize = 160;
/// Minimum frame size: marker (1) + length (2) + command/response (1) = 4 bytes.
const MIN_FRAME_SIZE: usize = 4;
/// Protocol version we advertise during handshake.
const PROTOCOL_VERSION: u8 = 3;
// ─── Frame encoding ─────────────────────────────────────────────────────
/// Encode a command frame for sending to the device.
/// Returns: `>` + 2-byte LE length + data
pub fn encode_frame(data: &[u8]) -> Vec<u8> {
let len = data.len() as u16;
let mut frame = Vec::with_capacity(3 + data.len());
frame.push(OUTBOUND_MARKER);
frame.extend_from_slice(&len.to_le_bytes());
frame.extend_from_slice(data);
frame
}
/// Result of parsing one inbound frame from the device.
#[derive(Debug)]
pub struct InboundFrame {
/// Response or push notification code (first byte of payload).
pub code: u8,
/// Remaining payload after the code byte.
pub data: Vec<u8>,
/// Total bytes consumed from the buffer (for advancing read position).
pub bytes_consumed: usize,
}
/// Try to parse one inbound frame from a buffer.
/// Returns `None` if the buffer doesn't contain a complete frame yet.
pub fn decode_frame(buf: &[u8]) -> Option<InboundFrame> {
if buf.len() < MIN_FRAME_SIZE {
return None;
}
// Find the inbound marker
let start = buf.iter().position(|&b| b == INBOUND_MARKER)?;
let remaining = &buf[start..];
if remaining.len() < 3 {
return None;
}
let len = u16::from_le_bytes([remaining[1], remaining[2]]) as usize;
let total = 3 + len; // marker + 2 length bytes + payload
if remaining.len() < total {
return None; // incomplete frame
}
if len == 0 {
return None; // empty payload is invalid
}
let payload = &remaining[3..total];
let code = payload[0];
let data = payload[1..].to_vec();
Some(InboundFrame {
code,
data,
bytes_consumed: start + total,
})
}
// ─── Command builders ───────────────────────────────────────────────────
/// CMD_DEVICE_QUERY (0x16): Query device capabilities and negotiate protocol version.
pub fn build_device_query() -> Vec<u8> {
encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION])
}
/// CMD_APP_START (0x01): Initialize communication session.
/// Format matches official meshcore_py: [0x01][version][padded_name]
/// The official library sends: b"\x01\x03 mccli"
pub fn build_app_start(app_name: &str) -> Vec<u8> {
let mut data = vec![CMD_APP_START, PROTOCOL_VERSION];
// Pad name to 6 chars minimum (matching official library behavior)
let name_bytes = app_name.as_bytes();
let padded_len = name_bytes.len().max(6);
let len = padded_len.min(32);
// Pad with spaces if name is shorter than 6 chars
for i in 0..len {
if i < name_bytes.len() {
data.push(name_bytes[i]);
} else {
data.push(b' ');
}
}
encode_frame(&data)
}
/// CMD_SET_DEVICE_TIME (0x06): Sync device clock with Unix timestamp.
pub fn build_set_device_time(unix_secs: u64) -> Vec<u8> {
let mut data = vec![CMD_SET_DEVICE_TIME];
data.extend_from_slice(&(unix_secs as u32).to_le_bytes());
encode_frame(&data)
}
/// CMD_SET_ADVERT_NAME (0x08): Set the node's advertised name on the mesh.
pub fn build_set_advert_name(name: &str) -> Vec<u8> {
let mut data = vec![CMD_SET_ADVERT_NAME];
let name_bytes = name.as_bytes();
let len = name_bytes.len().min(32);
data.extend_from_slice(&name_bytes[..len]);
encode_frame(&data)
}
/// CMD_SEND_TXT_MSG (0x02): Send a text message to a specific contact.
/// Destination is the first 6 bytes of the contact's public key (hex decoded).
/// Format: 0x02 + 0x00 (txt_type) + attempt(1B) + timestamp(4B LE) + dest_prefix(6B) + text
pub fn build_send_text(dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<Vec<u8>> {
if msg.len() > MAX_MESSAGE_LEN {
anyhow::bail!(
"Message too large for LoRa: {} bytes (max {})",
msg.len(),
MAX_MESSAGE_LEN
);
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as u32;
let mut data = vec![CMD_SEND_TXT_MSG, 0x00, 0x00]; // cmd + txt_type=0 + attempt=0
data.extend_from_slice(&timestamp.to_le_bytes());
data.extend_from_slice(dest_pubkey_prefix);
data.extend_from_slice(msg);
Ok(encode_frame(&data))
}
/// CMD_SEND_CHANNEL_TXT_MSG (0x03): Broadcast a text message on a channel.
pub fn build_send_channel_text(channel: u8, msg: &[u8]) -> Result<Vec<u8>> {
if msg.len() > MAX_MESSAGE_LEN {
anyhow::bail!(
"Message too large for LoRa: {} bytes (max {})",
msg.len(),
MAX_MESSAGE_LEN
);
}
let mut data = vec![CMD_SEND_CHANNEL_TXT_MSG, channel];
data.extend_from_slice(msg);
Ok(encode_frame(&data))
}
/// CMD_GET_CONTACTS (0x04): Request the contact list from the device.
pub fn build_get_contacts() -> Vec<u8> {
encode_frame(&[CMD_GET_CONTACTS])
}
/// CMD_SYNC_NEXT_MESSAGE (0x0A): Retrieve the next queued message.
pub fn build_sync_next_message() -> Vec<u8> {
encode_frame(&[CMD_SYNC_NEXT_MESSAGE])
}
/// CMD_SEND_SELF_ADVERT (0x07): Broadcast our advertisement to the mesh.
pub fn build_send_self_advert() -> Vec<u8> {
encode_frame(&[CMD_SEND_SELF_ADVERT])
}
/// CMD_GET_STATS (0x38): Request device statistics.
pub fn build_get_stats() -> Vec<u8> {
encode_frame(&[CMD_GET_STATS])
}
// ─── Response parsers ───────────────────────────────────────────────────
/// Parse RESP_DEVICE_INFO (0x0D) response.
/// Returns firmware version string and device capabilities.
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
// Device info format varies by firmware version.
// Minimum: firmware version string (null-terminated) + max_contacts (u16 LE)
if data.is_empty() {
anyhow::bail!("Empty device info response");
}
// Find null terminator for version string, or use all data as version
let version_end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
let version = String::from_utf8_lossy(&data[..version_end]).to_string();
let max_contacts = if data.len() > version_end + 2 {
u16::from_le_bytes([data[version_end + 1], data[version_end + 2]])
} else {
100 // default
};
Ok((version, max_contacts))
}
/// Parse RESP_SELF_INFO (0x05) response.
/// Returns (node_id, advert_name).
pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
if data.len() < 4 {
anyhow::bail!("Self info response too short: {} bytes", data.len());
}
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
// Name follows after fixed fields — find it by scanning for printable ASCII
let name_start = 4;
let name = if data.len() > name_start {
let name_end = data[name_start..]
.iter()
.position(|&b| b == 0)
.map(|p| name_start + p)
.unwrap_or(data.len());
String::from_utf8_lossy(&data[name_start..name_end]).to_string()
} else {
String::new()
};
Ok((node_id, name))
}
/// Parsed contact from RESP_CONTACT (0x03).
pub struct ParsedContact {
pub public_key_hex: String,
pub advert_name: String,
pub last_advert: u32,
pub contact_type: u8,
}
/// Parse RESP_CONTACT (0x03) response.
/// Format: 32B pubkey + 1B type + 1B flags + 1B path_len + 64B path + 32B name + 4B last_advert + 4B lat + 4B lon + 4B lastmod
pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
if data.len() < 34 {
anyhow::bail!("Contact response too short: {} bytes (need >= 34)", data.len());
}
let public_key_hex = hex::encode(&data[0..32]);
let contact_type = data[32];
// flags at data[33], path_len at data[34]
// path at data[35..99] (64 bytes)
// name at data[99..131] (32 bytes)
let name_start = 99.min(data.len());
let name_end = (name_start + 32).min(data.len());
let advert_name = if data.len() > name_start {
String::from_utf8_lossy(&data[name_start..name_end])
.trim_end_matches('\0')
.to_string()
} else {
format!("{}...", &public_key_hex[..8])
};
// last_advert at data[131..135]
let last_advert = if data.len() >= 135 {
u32::from_le_bytes([data[131], data[132], data[133], data[134]])
} else {
0
};
Ok(ParsedContact {
public_key_hex,
advert_name,
last_advert,
contact_type,
})
}
/// Parse RESP_CONTACT_MSG_V3 (0x10) - private message.
/// Format: SNR(1B) + reserved(2B) + pubkey_prefix(6B) + path_len(1B) + txt_type(1B) + timestamp(4B) + [sig(4B) if txt_type==2] + text
/// Returns (sender_pubkey_prefix_hex, text, snr).
pub fn parse_contact_msg_v3(data: &[u8]) -> Result<(String, String, i8)> {
if data.len() < 15 {
anyhow::bail!("Contact message too short: {} bytes", data.len());
}
let snr = data[0] as i8;
// data[1..3] reserved
let pubkey_prefix = hex::encode(&data[3..9]);
// data[9] = path_len
let txt_type = data[10];
// data[11..15] = timestamp
let text_start = if txt_type == 2 { 19 } else { 15 }; // skip 4-byte signature if txt_type==2
let text = if data.len() > text_start {
String::from_utf8_lossy(&data[text_start..]).to_string()
} else {
String::new()
};
Ok((pubkey_prefix, text, snr))
}
/// Parse RESP_CHANNEL_MSG_V3 (0x11) - channel message.
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
/// Returns (channel_idx, text).
pub fn parse_channel_msg_v3(data: &[u8]) -> Result<(u8, String)> {
if data.len() < 7 {
anyhow::bail!("Channel message too short: {} bytes", data.len());
}
let channel_idx = data[0];
// data[1] = path_len, data[2] = txt_type
// data[3..7] = timestamp
let text = if data.len() > 7 {
String::from_utf8_lossy(&data[7..]).trim_end_matches('\0').to_string()
} else {
String::new()
};
Ok((channel_idx, text))
}
/// Parse RESP_CONTACT_MSG (0x07) - v1 private message.
/// Format: pubkey_prefix(6B) + path_len(1B) + txt_type(1B) + timestamp(4B) + [sig(4B) if txt_type==2] + text
/// Returns (sender_pubkey_prefix_hex, text).
pub fn parse_contact_msg_v1(data: &[u8]) -> Result<(String, String)> {
if data.len() < 12 {
anyhow::bail!("Contact message v1 too short: {} bytes", data.len());
}
let pubkey_prefix = hex::encode(&data[0..6]);
// data[6] = path_len, data[7] = txt_type
let txt_type = data[7];
// data[8..12] = timestamp
let text_start = if txt_type == 2 { 16 } else { 12 };
let text = if data.len() > text_start {
String::from_utf8_lossy(&data[text_start..]).to_string()
} else {
String::new()
};
Ok((pubkey_prefix, text))
}
/// Parse RESP_CHANNEL_MSG (0x08) - v1 channel message.
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
pub fn parse_channel_msg_v1(data: &[u8]) -> Result<(u8, String)> {
if data.len() < 7 {
anyhow::bail!("Channel message v1 too short: {} bytes", data.len());
}
let channel_idx = data[0];
// data[1] = path_len, data[2] = txt_type
// data[3..7] = timestamp
let text = if data.len() > 7 {
String::from_utf8_lossy(&data[7..]).trim_end_matches('\0').to_string()
} else {
String::new()
};
Ok((channel_idx, text))
}
/// Parse RESP_ERR (0x01). Returns descriptive error string.
pub fn parse_error(data: &[u8]) -> String {
if data.is_empty() {
return "Unknown device error".to_string();
}
match data[0] {
ERR_UNSUPPORTED_CMD => "Unsupported command".to_string(),
ERR_NOT_FOUND => "Not found".to_string(),
ERR_TABLE_FULL => "Contact table full".to_string(),
ERR_BAD_STATE => "Bad device state".to_string(),
ERR_FILE_IO => "Device file I/O error".to_string(),
ERR_ILLEGAL_ARG => "Illegal argument".to_string(),
code => format!("Device error code 0x{:02x}", code),
}
}
/// Check if a response code is a push notification (async event from device).
pub fn is_push_notification(code: u8) -> bool {
code >= 0x80
}
// ─── Archipelago identity wire format ───────────────────────────────────
/// Prefix for Archipelago identity broadcasts over mesh channel.
pub const ARCHY_IDENTITY_PREFIX: &str = "ARCHY:1:";
/// Encode an Archipelago identity announcement for channel broadcast.
/// Compact format: `ARCHY:2:{ed25519_pubkey_hex}:{x25519_pubkey_hex}`
/// DID is omitted to fit within 160-byte LoRa limit — receiver reconstructs did:key from ed25519 pubkey.
/// Total: 8 + 64 + 1 + 64 = 137 bytes (fits in 160).
pub fn encode_identity_broadcast(_did: &str, ed_pubkey_hex: &str, x25519_pubkey_hex: &str) -> String {
format!("ARCHY:2:{}:{}", ed_pubkey_hex, x25519_pubkey_hex)
}
/// Try to parse an Archipelago identity from a received channel message.
/// Returns (did, ed25519_pubkey_hex, x25519_pubkey_hex) if valid.
///
/// Supports two formats:
/// - v2 (compact): `ARCHY:2:{ed25519_hex_64}:{x25519_hex_64}` — DID reconstructed from ed25519
/// - v1 (legacy): `ARCHY:1:{did}:{ed25519_hex_64}:{x25519_hex_64}`
pub fn parse_identity_broadcast(msg: &str) -> Option<(String, String, String)> {
// Try v2 compact format first
if let Some(rest) = msg.strip_prefix("ARCHY:2:") {
let parts: Vec<&str> = rest.splitn(2, ':').collect();
if parts.len() != 2 {
return None;
}
let ed_pubkey = parts[0];
let x25519_pubkey = parts[1];
if ed_pubkey.len() != 64 || x25519_pubkey.len() != 64 {
return None;
}
if !ed_pubkey.chars().all(|c| c.is_ascii_hexdigit())
|| !x25519_pubkey.chars().all(|c| c.is_ascii_hexdigit())
{
return None;
}
// Reconstruct DID from ed25519 pubkey
let did = crate::identity::did_key_from_pubkey_hex(ed_pubkey).ok()?;
return Some((did, ed_pubkey.to_string(), x25519_pubkey.to_string()));
}
// Try v1 legacy format
let rest = msg.strip_prefix(ARCHY_IDENTITY_PREFIX)?;
let last_colon = rest.rfind(':')?;
let x25519_pubkey = &rest[last_colon + 1..];
if x25519_pubkey.len() != 64 || !x25519_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let before_x25519 = &rest[..last_colon];
let second_last_colon = before_x25519.rfind(':')?;
let ed_pubkey = &before_x25519[second_last_colon + 1..];
if ed_pubkey.len() != 64 || !ed_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let did = &before_x25519[..second_last_colon];
if !did.starts_with("did:key:z") {
return None;
}
Some((did.to_string(), ed_pubkey.to_string(), x25519_pubkey.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_frame() {
let frame = encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION]);
assert_eq!(frame[0], OUTBOUND_MARKER);
assert_eq!(u16::from_le_bytes([frame[1], frame[2]]), 2);
assert_eq!(frame[3], CMD_DEVICE_QUERY);
assert_eq!(frame[4], PROTOCOL_VERSION);
}
#[test]
fn test_decode_frame_complete() {
// Simulate an inbound frame: < + len(2) + [RESP_OK]
let buf = vec![INBOUND_MARKER, 0x01, 0x00, RESP_OK];
let frame = decode_frame(&buf).expect("should parse");
assert_eq!(frame.code, RESP_OK);
assert!(frame.data.is_empty());
assert_eq!(frame.bytes_consumed, 4);
}
#[test]
fn test_decode_frame_with_data() {
// < + len(5) + [RESP_SELF_INFO, 0x01, 0x02, 0x03, 0x04]
let buf = vec![INBOUND_MARKER, 0x05, 0x00, RESP_SELF_INFO, 0x01, 0x02, 0x03, 0x04];
let frame = decode_frame(&buf).expect("should parse");
assert_eq!(frame.code, RESP_SELF_INFO);
assert_eq!(frame.data, vec![0x01, 0x02, 0x03, 0x04]);
assert_eq!(frame.bytes_consumed, 8);
}
#[test]
fn test_decode_frame_incomplete() {
let buf = vec![INBOUND_MARKER, 0x05, 0x00, RESP_OK]; // says 5 bytes but only 1
assert!(decode_frame(&buf).is_none());
}
#[test]
fn test_decode_frame_no_marker() {
let buf = vec![0xFF, 0x01, 0x00, RESP_OK];
assert!(decode_frame(&buf).is_none());
}
#[test]
fn test_decode_frame_skips_garbage() {
// Garbage bytes before the actual frame
let buf = vec![0xFF, 0xAA, INBOUND_MARKER, 0x01, 0x00, RESP_OK];
let frame = decode_frame(&buf).expect("should skip garbage");
assert_eq!(frame.code, RESP_OK);
assert_eq!(frame.bytes_consumed, 6); // 2 garbage + 4 frame
}
#[test]
fn test_build_device_query() {
let frame = build_device_query();
assert_eq!(frame[0], OUTBOUND_MARKER);
assert_eq!(frame[3], CMD_DEVICE_QUERY);
assert_eq!(frame[4], PROTOCOL_VERSION);
}
#[test]
fn test_build_app_start() {
let frame = build_app_start("Archipelago");
assert_eq!(frame[3], CMD_APP_START);
let name = &frame[4..];
assert_eq!(std::str::from_utf8(name).unwrap(), "Archipelago");
}
#[test]
fn test_build_set_device_time() {
let ts: u64 = 1710600000;
let frame = build_set_device_time(ts);
assert_eq!(frame[3], CMD_SET_DEVICE_TIME);
let time_bytes = &frame[4..8];
assert_eq!(
u32::from_le_bytes([time_bytes[0], time_bytes[1], time_bytes[2], time_bytes[3]]),
ts as u32
);
}
#[test]
fn test_build_send_text() {
let frame = build_send_text(42, b"hello").unwrap();
assert_eq!(frame[3], CMD_SEND_TXT_MSG);
let cid = u32::from_le_bytes([frame[4], frame[5], frame[6], frame[7]]);
assert_eq!(cid, 42);
assert_eq!(&frame[8..], b"hello");
}
#[test]
fn test_build_send_text_too_large() {
let big = vec![0u8; MAX_MESSAGE_LEN + 1];
assert!(build_send_text(1, &big).is_err());
}
#[test]
fn test_build_send_channel_text() {
let frame = build_send_channel_text(0, b"test").unwrap();
assert_eq!(frame[3], CMD_SEND_CHANNEL_TXT_MSG);
assert_eq!(frame[4], 0); // channel 0
assert_eq!(&frame[5..], b"test");
}
#[test]
fn test_identity_broadcast_roundtrip() {
let did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK";
let ed_pub = "a".repeat(64);
let x25519_pub = "b".repeat(64);
let encoded = encode_identity_broadcast(did, &ed_pub, &x25519_pub);
assert!(encoded.starts_with(ARCHY_IDENTITY_PREFIX));
let (parsed_did, parsed_ed, parsed_x) = parse_identity_broadcast(&encoded).unwrap();
assert_eq!(parsed_did, did);
assert_eq!(parsed_ed, ed_pub);
assert_eq!(parsed_x, x25519_pub);
}
#[test]
fn test_identity_broadcast_invalid() {
assert!(parse_identity_broadcast("not an identity").is_none());
assert!(parse_identity_broadcast("ARCHY:1:bad").is_none());
assert!(parse_identity_broadcast("ARCHY:1:did:key:z123:short:short").is_none());
}
#[test]
fn test_parse_error_codes() {
assert_eq!(parse_error(&[ERR_NOT_FOUND]), "Not found");
assert_eq!(parse_error(&[ERR_TABLE_FULL]), "Contact table full");
assert_eq!(parse_error(&[]), "Unknown device error");
assert!(parse_error(&[0xFF]).contains("0xff"));
}
#[test]
fn test_is_push_notification() {
assert!(is_push_notification(PUSH_NEW_CONTACT));
assert!(is_push_notification(PUSH_ACK));
assert!(is_push_notification(0x80));
assert!(!is_push_notification(RESP_OK));
assert!(!is_push_notification(RESP_DEVICE_INFO));
}
#[test]
fn test_parse_self_info() {
let mut data = vec![0x2A, 0x00, 0x00, 0x00]; // node_id = 42
data.extend_from_slice(b"TestNode\0");
let (id, name) = parse_self_info(&data).unwrap();
assert_eq!(id, 42);
assert_eq!(name, "TestNode");
}
#[test]
fn test_parse_self_info_too_short() {
assert!(parse_self_info(&[0x01, 0x02]).is_err());
}
#[test]
fn test_parse_received_message() {
let mut data = vec![0x05, 0x00, 0x00, 0x00]; // contact_id = 5
data.extend_from_slice(&(-75i16).to_le_bytes()); // rssi = -75
data.extend_from_slice(b"hello mesh");
let (cid, payload, rssi) = parse_received_message(&data).unwrap();
assert_eq!(cid, 5);
assert_eq!(rssi, -75);
assert_eq!(payload, b"hello mesh");
}
}

View File

@ -0,0 +1,378 @@
//! Async serial driver for Meshcore devices.
//!
//! Handles opening the serial port, reading/writing frames,
//! and the initialization handshake sequence.
use super::protocol::{self, InboundFrame};
use super::types::DeviceInfo;
use anyhow::{Context, Result};
use std::time::Duration;
use tracing::{debug, info, warn};
/// Serial port configuration for Meshcore Companion USB.
const BAUD_RATE: u32 = 115200;
/// Timeout for reading a response frame from the device.
const READ_TIMEOUT: Duration = Duration::from_secs(5);
/// Timeout for writing a frame to the device.
const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
/// Buffer size for serial reads.
const READ_BUF_SIZE: usize = 512;
/// Application name sent during handshake.
const APP_NAME: &str = "Archipelago";
/// Async Meshcore device handle.
pub struct MeshcoreDevice {
port: serial2_tokio::SerialPort,
read_buf: Vec<u8>,
pub node_id: Option<u32>,
pub advert_name: Option<String>,
pub device_info: Option<DeviceInfo>,
device_path: String,
}
impl MeshcoreDevice {
/// Open a serial port and verify it's a Meshcore device.
pub async fn open(path: &str) -> Result<Self> {
let port = serial2_tokio::SerialPort::open(path, BAUD_RATE)
.context(format!("Failed to open serial port {}", path))?;
info!(path = %path, baud = BAUD_RATE, "Opened serial port");
Ok(Self {
port,
read_buf: Vec::with_capacity(READ_BUF_SIZE),
node_id: None,
advert_name: None,
device_info: None,
device_path: path.to_string(),
})
}
/// Run the Meshcore initialization handshake.
/// Matches the official meshcore_py library sequence:
/// 1. CMD_APP_START -> RESP_SELF_INFO (this is the first command, not device_query)
/// 2. CMD_SET_DEVICE_TIME (sync clock)
pub async fn initialize(&mut self) -> Result<DeviceInfo> {
info!("Starting Meshcore handshake on {}", self.device_path);
// Step 1: App start (the official library sends this first)
self.send_raw(&protocol::build_app_start(APP_NAME)).await?;
let frame = self
.recv_frame_timeout(READ_TIMEOUT)
.await
.context("No response to APP_START — is this a Meshcore Companion USB device?")?;
info!(code = frame.code, data_len = frame.data.len(), "Got response to APP_START");
if frame.code == protocol::RESP_ERR {
anyhow::bail!("App start failed: {}", protocol::parse_error(&frame.data));
}
// The response could be SELF_INFO or something else depending on firmware version
let (node_id, name) = if frame.code == protocol::RESP_SELF_INFO {
protocol::parse_self_info(&frame.data)
.context("Failed to parse self info")?
} else {
// Try to parse whatever we got
info!(code = frame.code, "Unexpected response code, trying to parse as self info");
protocol::parse_self_info(&frame.data)
.unwrap_or((0, String::new()))
};
info!(node_id, name = %name, "Meshcore identity");
self.node_id = Some(node_id);
self.advert_name = Some(name.clone());
// Step 2: Sync device clock
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.send_raw(&protocol::build_set_device_time(now)).await?;
// Time set response is best-effort — don't fail if it times out
match self.recv_frame_timeout(Duration::from_secs(2)).await {
Ok(frame) if frame.code == protocol::RESP_OK => {
debug!("Device clock synced");
}
Ok(frame) => {
warn!(code = frame.code, "Unexpected response to SET_DEVICE_TIME");
}
Err(_) => {
warn!("No response to SET_DEVICE_TIME (continuing anyway)");
}
}
let info = DeviceInfo {
firmware_version: name.clone(),
node_id,
max_contacts: 100,
device_type: super::types::DeviceType::Meshcore,
};
self.device_info = Some(info.clone());
info!("Meshcore initialization complete on {}", self.device_path);
Ok(info)
}
/// Set the advertised name on the mesh network.
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
self.send_raw(&protocol::build_set_advert_name(name)).await?;
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
if frame.code == protocol::RESP_ERR {
anyhow::bail!("Set advert name failed: {}", protocol::parse_error(&frame.data));
}
self.advert_name = Some(name.to_string());
Ok(())
}
/// Broadcast our advertisement to the mesh.
pub async fn send_self_advert(&mut self) -> Result<()> {
self.send_raw(&protocol::build_send_self_advert()).await?;
// Response is RESP_OK or RESP_SENT
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
if frame.code == protocol::RESP_ERR {
anyhow::bail!("Self advert failed: {}", protocol::parse_error(&frame.data));
}
Ok(())
}
/// Send a text message to a contact by their public key prefix (first 6 bytes).
pub async fn send_text(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
let frame_data = protocol::build_send_text(dest_pubkey_prefix, msg)?;
self.send_raw(&frame_data).await?;
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
if frame.code == protocol::RESP_ERR {
anyhow::bail!("Send text failed: {}", protocol::parse_error(&frame.data));
}
Ok(())
}
/// Broadcast a text message on a channel.
pub async fn send_channel_text(&mut self, channel: u8, msg: &[u8]) -> Result<()> {
let frame_data = protocol::build_send_channel_text(channel, msg)?;
self.send_raw(&frame_data).await?;
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
if frame.code == protocol::RESP_ERR {
anyhow::bail!(
"Channel broadcast failed: {}",
protocol::parse_error(&frame.data)
);
}
Ok(())
}
/// Get the list of known contacts from the device.
/// Protocol: CMD_GET_CONTACTS -> CONTACT_START(count) -> N×CONTACT -> CONTACT_END
pub async fn get_contacts(&mut self) -> Result<Vec<protocol::ParsedContact>> {
self.send_raw(&protocol::build_get_contacts()).await?;
let mut contacts = Vec::new();
loop {
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
match frame.code {
protocol::RESP_CONTACT_START => {
// Contains the count of contacts to follow
let count = if frame.data.len() >= 4 {
u32::from_le_bytes([frame.data[0], frame.data[1], frame.data[2], frame.data[3]])
} else {
0
};
debug!(count, "Contact list start");
}
protocol::RESP_CONTACT => {
match protocol::parse_contact(&frame.data) {
Ok(contact) => contacts.push(contact),
Err(e) => warn!("Failed to parse contact: {}", e),
}
}
protocol::RESP_CONTACT_END => {
debug!(count = contacts.len(), "Contact list complete");
break;
}
protocol::RESP_OK => break,
protocol::RESP_ERR => {
anyhow::bail!("Get contacts failed: {}", protocol::parse_error(&frame.data));
}
_ => {
debug!(code = frame.code, "Unexpected response during contact list");
// Don't break — might be a push notification interspersed
}
}
}
Ok(contacts)
}
/// Retrieve queued messages from the device.
/// Returns raw frames (code + data) for the listener to parse.
pub async fn sync_messages(&mut self) -> Result<Vec<protocol::InboundFrame>> {
self.send_raw(&protocol::build_sync_next_message()).await?;
let mut frames = Vec::new();
loop {
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
match frame.code {
// All message types (v1 and v3)
protocol::RESP_CONTACT_MSG | protocol::RESP_CONTACT_MSG_V3
| protocol::RESP_CHANNEL_MSG | protocol::RESP_CHANNEL_MSG_V3 => {
frames.push(frame);
// Request next message
self.send_raw(&protocol::build_sync_next_message()).await?;
}
protocol::RESP_NO_MORE_MESSAGES => break,
protocol::RESP_OK => break,
protocol::RESP_ERR => {
anyhow::bail!(
"Sync messages failed: {}",
protocol::parse_error(&frame.data)
);
}
_ => {
// Push notifications can arrive during sync — skip them
if protocol::is_push_notification(frame.code) {
continue;
}
debug!(code = frame.code, "Unexpected response during message sync");
break;
}
}
}
Ok(frames)
}
/// Write raw bytes to the serial port.
pub async fn send_raw(&mut self, data: &[u8]) -> Result<()> {
tokio::time::timeout(WRITE_TIMEOUT, self.port.write_all(data))
.await
.context("Serial write timed out")?
.context("Serial write failed")?;
Ok(())
}
/// Try to read and parse one complete inbound frame.
/// Returns the frame if one is available, or reads more data from serial.
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
// First check if we already have a complete frame in the buffer
if let Some(frame) = protocol::decode_frame(&self.read_buf) {
let consumed = frame.bytes_consumed;
let result = frame;
self.read_buf.drain(..consumed);
return Ok(Some(result));
}
// Try to read more data (non-blocking via small timeout)
let mut tmp = [0u8; READ_BUF_SIZE];
match tokio::time::timeout(Duration::from_millis(50), self.port.read(&mut tmp)).await {
Ok(Ok(n)) if n > 0 => {
self.read_buf.extend_from_slice(&tmp[..n]);
}
_ => return Ok(None),
}
// Try parsing again with new data
if let Some(frame) = protocol::decode_frame(&self.read_buf) {
let consumed = frame.bytes_consumed;
let result = frame;
self.read_buf.drain(..consumed);
return Ok(Some(result));
}
Ok(None)
}
/// Read one complete inbound frame with timeout.
pub async fn recv_frame_timeout(&mut self, timeout: Duration) -> Result<InboundFrame> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
// Check buffer for a complete frame
if let Some(frame) = protocol::decode_frame(&self.read_buf) {
let consumed = frame.bytes_consumed;
let result = frame;
self.read_buf.drain(..consumed);
return Ok(result);
}
// Read more data from serial
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
anyhow::bail!("Timeout waiting for serial frame");
}
let mut tmp = [0u8; READ_BUF_SIZE];
match tokio::time::timeout(remaining.min(Duration::from_millis(100)), self.port.read(&mut tmp))
.await
{
Ok(Ok(0)) => anyhow::bail!("Serial port closed"),
Ok(Ok(n)) => {
self.read_buf.extend_from_slice(&tmp[..n]);
}
Ok(Err(e)) => return Err(e).context("Serial read error"),
Err(_) => continue, // timeout on this read, try again if deadline not reached
}
}
}
/// Get the device path this handle is connected to.
pub fn path(&self) -> &str {
&self.device_path
}
}
// ─── Device detection ───────────────────────────────────────────────────
/// Candidate serial device paths to check on Linux.
const SERIAL_CANDIDATES: &[&str] = &[
"/dev/ttyUSB0",
"/dev/ttyUSB1",
"/dev/ttyUSB2",
"/dev/ttyACM0",
"/dev/ttyACM1",
"/dev/ttyACM2",
];
/// Scan for serial devices that could be Meshcore radios.
/// Returns paths to existing serial device files.
pub async fn detect_serial_devices() -> Vec<String> {
let mut devices = Vec::new();
for path in SERIAL_CANDIDATES {
if tokio::fs::metadata(path).await.is_ok() {
devices.push(path.to_string());
}
}
devices
}
/// Try to open and handshake with each detected serial device.
/// Returns the first device that responds as Meshcore.
pub async fn probe_for_meshcore(paths: &[String]) -> Option<(String, DeviceInfo)> {
for path in paths {
debug!(path = %path, "Probing for Meshcore device");
match MeshcoreDevice::open(path).await {
Ok(mut device) => {
match device.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device");
// Drop the device so the listener can open it
drop(device);
return Some((path.clone(), info));
}
Err(e) => {
debug!(path = %path, error = %e, "Not a Meshcore device");
}
}
}
Err(e) => {
debug!(path = %path, error = %e, "Could not open serial port");
}
}
}
None
}

View File

@ -0,0 +1,114 @@
//! Shared types for mesh networking subsystem.
use serde::{Deserialize, Serialize};
/// Device firmware type, detected via protocol handshake.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DeviceType {
Meshcore,
Meshtastic,
Unknown,
}
impl std::fmt::Display for DeviceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Meshcore => write!(f, "meshcore"),
Self::Meshtastic => write!(f, "meshtastic"),
Self::Unknown => write!(f, "unknown"),
}
}
}
/// A peer discovered via mesh radio.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshPeer {
/// Meshcore contact ID (uint32).
pub contact_id: u32,
/// Advertised name on the mesh network.
pub advert_name: String,
/// Archipelago DID (did:key:z...) if identity was received.
pub did: Option<String>,
/// Ed25519 public key hex if identity was received.
pub pubkey_hex: Option<String>,
/// X25519 public key (32 bytes) for key agreement.
#[serde(skip)]
pub x25519_pubkey: Option<[u8; 32]>,
/// Last received signal strength (dBm).
pub rssi: Option<i16>,
/// Signal-to-noise ratio.
pub snr: Option<f32>,
/// When we last heard from this peer.
pub last_heard: String,
/// Number of hops to reach this peer.
pub hops: u8,
}
/// Direction of a mesh message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageDirection {
Sent,
Received,
}
/// A mesh message (sent or received).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshMessage {
pub id: u64,
pub direction: MessageDirection,
/// Meshcore contact ID of the peer.
pub peer_contact_id: u32,
/// Peer name (for display).
pub peer_name: Option<String>,
/// Decrypted plaintext content.
pub plaintext: String,
pub timestamp: String,
/// Whether delivery was confirmed via ACK.
pub delivered: bool,
/// Whether the message was end-to-end encrypted.
pub encrypted: bool,
}
/// Overall mesh subsystem status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshStatus {
pub enabled: bool,
pub device_type: DeviceType,
pub device_path: Option<String>,
pub device_connected: bool,
pub firmware_version: Option<String>,
pub self_node_id: Option<u32>,
pub self_advert_name: Option<String>,
pub peer_count: usize,
pub channel_name: String,
pub messages_sent: u64,
pub messages_received: u64,
}
/// Information returned from device during initialization.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceInfo {
pub firmware_version: String,
pub node_id: u32,
pub max_contacts: u16,
pub device_type: DeviceType,
}
/// Events emitted by the mesh listener for other components to consume.
#[derive(Debug, Clone)]
pub enum MeshEvent {
DeviceConnected(DeviceInfo),
DeviceDisconnected,
PeerDiscovered(MeshPeer),
PeerUpdated(MeshPeer),
MessageReceived(MeshMessage),
MessageDelivered { message_id: u64 },
IdentityReceived {
contact_id: u32,
did: String,
pubkey_hex: String,
x25519_pubkey: [u8; 32],
},
}

View File

@ -109,6 +109,80 @@ impl Server {
ApiHandler::new(config.clone(), state_manager.clone(), metrics_store).await?,
);
// Initialize mesh networking service (if config has enabled: true)
{
let data_dir = config.data_dir.clone();
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
.unwrap_or_default();
let pubkey_hex = identity.pubkey_hex();
let signing_key = identity.signing_key();
match crate::mesh::MeshService::new(&data_dir, signing_key, &did, &pubkey_hex).await {
Ok(mut mesh_service) => {
let mesh_config = crate::mesh::load_config(&data_dir).await.unwrap_or_default();
if mesh_config.enabled {
if let Err(e) = mesh_service.start() {
warn!("Mesh service start failed (non-fatal): {}", e);
} else {
info!("📡 Mesh networking started");
}
}
api_handler.rpc_handler().set_mesh_service(mesh_service).await;
info!("📡 Mesh service initialized");
}
Err(e) => {
warn!("Mesh service init failed (non-fatal): {}", e);
}
}
}
// Initialize transport router (unified routing: mesh > lan > tor)
{
let data_dir = config.data_dir.clone();
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
.unwrap_or_default();
let pubkey_hex = identity.pubkey_hex();
let mesh_config = crate::mesh::load_config(&data_dir).await.unwrap_or_default();
let mesh_only = mesh_config.mesh_only_mode.unwrap_or(false);
match crate::transport::PeerRegistry::load(&data_dir).await {
Ok(registry) => {
let registry = std::sync::Arc::new(registry);
let mut transports: Vec<Box<dyn crate::transport::NodeTransport>> = Vec::new();
// Tor transport (always register — availability checked dynamically)
transports.push(Box::new(
crate::transport::tor::TorTransport::new(&pubkey_hex),
));
// Mesh transport (wraps the mesh service)
transports.push(Box::new(
crate::transport::mesh_transport::MeshTransport::new(
api_handler.rpc_handler().mesh_service_arc(),
),
));
// LAN transport (mDNS discovery)
let mut lan = crate::transport::lan::LanTransport::new(&did, &pubkey_hex, 5678);
match lan.start(registry.clone()) {
Ok(()) => info!("📡 LAN transport (mDNS) started"),
Err(e) => debug!("LAN transport init (non-fatal): {}", e),
}
transports.push(Box::new(lan));
let router = std::sync::Arc::new(crate::transport::TransportRouter::new(
transports,
registry,
mesh_only,
));
api_handler.rpc_handler().set_transport_router(router).await;
info!("📡 Transport router initialized (mesh_only={})", mesh_only);
}
Err(e) => {
warn!("Transport router init failed (non-fatal): {}", e);
}
}
}
// Register Archipelago DWN protocols (background, non-blocking)
{
let data_dir = config.data_dir.clone();

View File

@ -0,0 +1,408 @@
//! Chunked message protocol with Reed-Solomon FEC for LoRa transport.
//!
//! Splits payloads larger than a single LoRa frame (160 bytes) into
//! numbered chunks with forward error correction, enabling reliable
//! transfer over lossy radio links.
//!
//! Chunk wire format (8 bytes header + payload):
//! ```text
//! [0x01: type] [msg_id: u32 LE] [chunk_idx: u8] [total: u8] [is_parity: u8] [payload...]
//! ```
use anyhow::{Context, Result};
use reed_solomon_erasure::galois_8::ReedSolomon;
use std::collections::HashMap;
use std::time::Instant;
/// Header size for each chunk frame.
const CHUNK_HEADER_SIZE: usize = 8;
/// Maximum payload per chunk after header.
/// 132 bytes available after ChaCha20-Poly1305 encryption overhead (12 nonce + 16 tag),
/// minus 8 byte chunk header = 124 bytes of user data per chunk.
pub const MAX_CHUNK_PAYLOAD: usize = 124;
/// Chunk type marker in the wire format.
const CHUNK_TYPE_MARKER: u8 = 0x01;
/// FEC redundancy ratio: 25% parity shards.
const FEC_RATIO_DENOMINATOR: usize = 4;
/// Maximum age of pending reassembly entries before garbage collection.
const REASSEMBLY_TIMEOUT_SECS: u64 = 60;
/// Maximum practical chunks for LoRa (airtime budget).
pub const MAX_PRACTICAL_CHUNKS: usize = 20;
/// A single chunk ready for transmission.
#[derive(Debug, Clone)]
pub struct Chunk {
pub message_id: u32,
pub chunk_index: u8,
pub total_chunks: u8,
pub is_parity: bool,
pub payload: Vec<u8>,
}
impl Chunk {
/// Serialize chunk to wire format.
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(CHUNK_HEADER_SIZE + self.payload.len());
buf.push(CHUNK_TYPE_MARKER);
buf.extend_from_slice(&self.message_id.to_le_bytes());
buf.push(self.chunk_index);
buf.push(self.total_chunks);
buf.push(if self.is_parity { 1 } else { 0 });
buf.extend_from_slice(&self.payload);
buf
}
/// Parse chunk from wire format.
pub fn from_bytes(data: &[u8]) -> Result<Self> {
if data.len() < CHUNK_HEADER_SIZE {
anyhow::bail!("Chunk too small: {} bytes", data.len());
}
if data[0] != CHUNK_TYPE_MARKER {
anyhow::bail!("Not a chunked message (marker: 0x{:02x})", data[0]);
}
let message_id = u32::from_le_bytes([data[1], data[2], data[3], data[4]]);
let chunk_index = data[5];
let total_chunks = data[6];
let is_parity = data[7] != 0;
let payload = data[CHUNK_HEADER_SIZE..].to_vec();
Ok(Self {
message_id,
chunk_index,
total_chunks,
is_parity,
payload,
})
}
/// Check if a raw byte slice starts with the chunk type marker.
pub fn is_chunked_message(data: &[u8]) -> bool {
!data.is_empty() && data[0] == CHUNK_TYPE_MARKER
}
}
/// Encode a payload into chunks with Reed-Solomon FEC parity.
///
/// Returns a vector of chunks ready for sequential transmission.
/// Each chunk's payload is exactly `shard_size` bytes (padded if needed).
pub fn encode_chunked(data: &[u8]) -> Result<Vec<Chunk>> {
if data.is_empty() {
anyhow::bail!("Cannot chunk empty data");
}
let shard_size = MAX_CHUNK_PAYLOAD;
let data_shard_count = (data.len() + shard_size - 1) / shard_size;
if data_shard_count > MAX_PRACTICAL_CHUNKS {
anyhow::bail!(
"Payload too large for LoRa chunking: {} bytes ({} chunks, max {})",
data.len(),
data_shard_count,
MAX_PRACTICAL_CHUNKS
);
}
let parity_shard_count = (data_shard_count + FEC_RATIO_DENOMINATOR - 1) / FEC_RATIO_DENOMINATOR;
let total_shards = data_shard_count + parity_shard_count;
if total_shards > 255 {
anyhow::bail!("Too many shards: {}", total_shards);
}
// Split data into equal-size shards
let mut shards: Vec<Vec<u8>> = Vec::with_capacity(total_shards);
for i in 0..data_shard_count {
let start = i * shard_size;
let end = (start + shard_size).min(data.len());
let mut shard = vec![0u8; shard_size];
shard[..end - start].copy_from_slice(&data[start..end]);
shards.push(shard);
}
// Add empty parity shards
for _ in 0..parity_shard_count {
shards.push(vec![0u8; shard_size]);
}
// Generate parity
let rs = ReedSolomon::new(data_shard_count, parity_shard_count)
.context("Failed to create Reed-Solomon codec")?;
rs.encode(&mut shards)
.context("Reed-Solomon encoding failed")?;
// Build chunk frames
let message_id: u32 = rand::random();
let total = total_shards as u8;
let mut chunks = Vec::with_capacity(total_shards);
for (i, shard) in shards.into_iter().enumerate() {
chunks.push(Chunk {
message_id,
chunk_index: i as u8,
total_chunks: total,
is_parity: i >= data_shard_count,
payload: shard,
});
}
// Encode the original data length in the first chunk's first 4 bytes
// so the receiver can trim padding after reconstruction.
let data_len = data.len() as u32;
chunks[0].payload[..4].copy_from_slice(&data_len.to_le_bytes());
// Re-encode FEC to reflect the length header change
let mut shard_data: Vec<Vec<u8>> = chunks.iter().map(|c| c.payload.clone()).collect();
rs.encode(&mut shard_data)
.context("Reed-Solomon re-encoding failed")?;
for (i, shard) in shard_data.into_iter().enumerate() {
chunks[i].payload = shard;
}
Ok(chunks)
}
/// In-progress reassembly of a chunked message.
struct PendingMessage {
shards: Vec<Option<Vec<u8>>>,
data_shard_count: usize,
parity_shard_count: usize,
received_count: usize,
created_at: Instant,
}
/// Reassembles chunked messages from incoming chunks.
pub struct ChunkReassembler {
pending: HashMap<u32, PendingMessage>,
}
impl ChunkReassembler {
pub fn new() -> Self {
Self {
pending: HashMap::new(),
}
}
/// Feed a chunk into the reassembler.
/// Returns `Some(data)` if the message is fully reconstructed.
pub fn feed(&mut self, chunk: &Chunk) -> Result<Option<Vec<u8>>> {
// Garbage collect stale entries
self.pending.retain(|_, pm| {
pm.created_at.elapsed().as_secs() < REASSEMBLY_TIMEOUT_SECS
});
let total = chunk.total_chunks as usize;
let entry = self.pending.entry(chunk.message_id).or_insert_with(|| {
// Infer data vs parity count from chunks we've seen
// The first non-parity chunk tells us the split point
let data_count = if chunk.is_parity {
// Best guess: 80% data, 20% parity
(total * FEC_RATIO_DENOMINATOR) / (FEC_RATIO_DENOMINATOR + 1)
} else {
// We know this index is data — parity starts after all data
// Exact split point: smallest i where chunk_index >= data_count AND is_parity
total - (total + FEC_RATIO_DENOMINATOR) / (FEC_RATIO_DENOMINATOR + 1)
};
let parity_count = total - data_count;
PendingMessage {
shards: vec![None; total],
data_shard_count: data_count,
parity_shard_count: parity_count,
received_count: 0,
created_at: Instant::now(),
}
});
let idx = chunk.chunk_index as usize;
if idx >= total {
anyhow::bail!("Chunk index {} out of range (total {})", idx, total);
}
if entry.shards[idx].is_none() {
entry.shards[idx] = Some(chunk.payload.clone());
entry.received_count += 1;
}
// Need at least data_shard_count shards to reconstruct
if entry.received_count >= entry.data_shard_count {
self.try_reconstruct(chunk.message_id)
} else {
Ok(None)
}
}
fn try_reconstruct(&mut self, message_id: u32) -> Result<Option<Vec<u8>>> {
let entry = match self.pending.get_mut(&message_id) {
Some(e) => e,
None => return Ok(None),
};
let rs = ReedSolomon::new(entry.data_shard_count, entry.parity_shard_count)
.context("Failed to create Reed-Solomon codec for reconstruction")?;
let mut shards: Vec<Option<Vec<u8>>> = entry.shards.clone();
match rs.reconstruct(&mut shards) {
Ok(()) => {
// Concatenate data shards (not parity)
let mut result = Vec::new();
for shard in shards.iter().take(entry.data_shard_count) {
if let Some(data) = shard {
result.extend_from_slice(data);
}
}
// Extract original length from first 4 bytes
if result.len() < 4 {
anyhow::bail!("Reconstructed data too small for length header");
}
let original_len =
u32::from_le_bytes([result[0], result[1], result[2], result[3]]) as usize;
// The actual data starts at byte 4 of the first shard
// But wait — the length is embedded in shard 0 bytes 0..4, and the
// actual payload starts at byte 4 of shard 0, then continues in subsequent shards.
// Actually, encode_chunked puts the length in the first 4 bytes of shard 0,
// and the rest of shard 0 + all other shards contain the original data.
// So we need to skip 4 bytes from the beginning.
if 4 + original_len > result.len() {
anyhow::bail!(
"Original length {} exceeds reconstructed data ({})",
original_len,
result.len() - 4
);
}
let data = result[4..4 + original_len].to_vec();
self.pending.remove(&message_id);
Ok(Some(data))
}
Err(_) => {
// Not enough shards yet
Ok(None)
}
}
}
}
impl Default for ChunkReassembler {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chunk_roundtrip_small() {
// Small payload fits in 1 data chunk + 1 parity chunk
let data = b"Hello, mesh network!";
let chunks = encode_chunked(data).unwrap();
// 1 data + 1 parity = 2 chunks
assert_eq!(chunks.len(), 2);
assert!(!chunks[0].is_parity);
assert!(chunks[1].is_parity);
let mut reassembler = ChunkReassembler::new();
// Feed data chunk — should reconstruct immediately (1 data shard needed)
let result = reassembler.feed(&chunks[0]).unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap(), data);
}
#[test]
fn test_chunk_roundtrip_medium() {
// ~500 bytes: 4 data chunks + 1 parity
let data: Vec<u8> = (0..500).map(|i| (i % 256) as u8).collect();
let chunks = encode_chunked(&data).unwrap();
let data_chunks: Vec<_> = chunks.iter().filter(|c| !c.is_parity).collect();
let parity_chunks: Vec<_> = chunks.iter().filter(|c| c.is_parity).collect();
assert_eq!(data_chunks.len(), 4); // ceil(500/124) = 5... wait
// Actually: ceil(500/124) = ceil(4.03) = 5 data shards
// But the first shard has 4 bytes of length header embedded, so
// the actual data capacity is 124 * N - 0 (length is IN the shard data).
// Let's just check it roundtrips.
let mut reassembler = ChunkReassembler::new();
let mut result = None;
for chunk in &chunks {
if let Some(data) = reassembler.feed(chunk).unwrap() {
result = Some(data);
break;
}
}
assert!(result.is_some());
assert_eq!(result.unwrap(), data);
}
#[test]
fn test_chunk_wire_format() {
let chunk = Chunk {
message_id: 0x12345678,
chunk_index: 2,
total_chunks: 5,
is_parity: false,
payload: vec![0xAA, 0xBB],
};
let bytes = chunk.to_bytes();
assert_eq!(bytes[0], CHUNK_TYPE_MARKER);
assert_eq!(&bytes[1..5], &0x12345678u32.to_le_bytes());
assert_eq!(bytes[5], 2);
assert_eq!(bytes[6], 5);
assert_eq!(bytes[7], 0);
assert_eq!(&bytes[8..], &[0xAA, 0xBB]);
let parsed = Chunk::from_bytes(&bytes).unwrap();
assert_eq!(parsed.message_id, 0x12345678);
assert_eq!(parsed.chunk_index, 2);
assert_eq!(parsed.total_chunks, 5);
assert!(!parsed.is_parity);
assert_eq!(parsed.payload, vec![0xAA, 0xBB]);
}
#[test]
fn test_chunk_is_chunked_message() {
assert!(Chunk::is_chunked_message(&[0x01, 0x00]));
assert!(!Chunk::is_chunked_message(&[0x02, 0x00]));
assert!(!Chunk::is_chunked_message(&[]));
}
#[test]
fn test_chunk_with_missing_chunk() {
// Verify FEC can recover from a missing data chunk
let data: Vec<u8> = (0..300).map(|i| (i % 256) as u8).collect();
let chunks = encode_chunked(&data).unwrap();
let mut reassembler = ChunkReassembler::new();
// Skip chunk index 1 (simulate loss)
for chunk in &chunks {
if chunk.chunk_index == 1 {
continue;
}
if let Some(recovered) = reassembler.feed(chunk).unwrap() {
assert_eq!(recovered, data);
return;
}
}
panic!("Failed to reconstruct with one missing chunk");
}
#[test]
fn test_empty_data_rejected() {
assert!(encode_chunked(&[]).is_err());
}
#[test]
fn test_too_large_rejected() {
let data = vec![0u8; MAX_CHUNK_PAYLOAD * (MAX_PRACTICAL_CHUNKS + 1)];
assert!(encode_chunked(&data).is_err());
}
}

View File

@ -0,0 +1,399 @@
//! CBOR delta encoding for federation state sync.
//!
//! Instead of sending a full NodeStateSnapshot (~500-2000 bytes JSON) on every
//! sync cycle, we compute a delta of only changed fields and encode it as CBOR.
//! A typical delta (CPU + memory change) is ~30-50 bytes — small enough to fit
//! in a single LoRa chunk after encryption.
use crate::federation::{AppStatus, NodeStateSnapshot};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
/// Delta format version. Increment when fields change.
const DELTA_VERSION: u8 = 1;
/// Compact state delta — only changed fields, with short field names.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StateDelta {
/// Timestamp of the snapshot this delta represents.
pub ts: String,
/// Delta format version for forward compatibility.
pub v: u8,
/// Apps that changed status (full entry for each changed app).
#[serde(skip_serializing_if = "Option::is_none")]
pub apps: Option<Vec<AppStatus>>,
/// App IDs that were removed since last sync.
#[serde(skip_serializing_if = "Option::is_none")]
pub apps_rm: Option<Vec<String>>,
/// CPU usage percent (only if changed).
#[serde(skip_serializing_if = "Option::is_none")]
pub cpu: Option<f64>,
/// Memory used bytes (only if changed).
#[serde(skip_serializing_if = "Option::is_none")]
pub mem_u: Option<u64>,
/// Memory total bytes (only if changed).
#[serde(skip_serializing_if = "Option::is_none")]
pub mem_t: Option<u64>,
/// Disk used bytes (only if changed).
#[serde(skip_serializing_if = "Option::is_none")]
pub dsk_u: Option<u64>,
/// Disk total bytes (only if changed).
#[serde(skip_serializing_if = "Option::is_none")]
pub dsk_t: Option<u64>,
/// Uptime seconds (only if changed).
#[serde(skip_serializing_if = "Option::is_none")]
pub up: Option<u64>,
/// Tor active flag (only if changed).
#[serde(skip_serializing_if = "Option::is_none")]
pub tor: Option<bool>,
}
/// Compute the delta between two state snapshots.
/// Returns only the fields that differ.
pub fn compute_delta(prev: &NodeStateSnapshot, curr: &NodeStateSnapshot) -> StateDelta {
let mut delta = StateDelta {
ts: curr.timestamp.clone(),
v: DELTA_VERSION,
..Default::default()
};
// Compare apps
let prev_apps: std::collections::HashMap<&str, &AppStatus> =
prev.apps.iter().map(|a| (a.id.as_str(), a)).collect();
let curr_apps: std::collections::HashMap<&str, &AppStatus> =
curr.apps.iter().map(|a| (a.id.as_str(), a)).collect();
let mut changed_apps = Vec::new();
let mut removed_apps = Vec::new();
for (id, curr_app) in &curr_apps {
match prev_apps.get(id) {
Some(prev_app) => {
if prev_app.status != curr_app.status || prev_app.version != curr_app.version {
changed_apps.push((*curr_app).clone());
}
}
None => changed_apps.push((*curr_app).clone()),
}
}
for id in prev_apps.keys() {
if !curr_apps.contains_key(id) {
removed_apps.push(id.to_string());
}
}
if !changed_apps.is_empty() {
delta.apps = Some(changed_apps);
}
if !removed_apps.is_empty() {
delta.apps_rm = Some(removed_apps);
}
// Compare scalar fields
if curr.cpu_usage_percent != prev.cpu_usage_percent {
delta.cpu = curr.cpu_usage_percent;
}
if curr.mem_used_bytes != prev.mem_used_bytes {
delta.mem_u = curr.mem_used_bytes;
}
if curr.mem_total_bytes != prev.mem_total_bytes {
delta.mem_t = curr.mem_total_bytes;
}
if curr.disk_used_bytes != prev.disk_used_bytes {
delta.dsk_u = curr.disk_used_bytes;
}
if curr.disk_total_bytes != prev.disk_total_bytes {
delta.dsk_t = curr.disk_total_bytes;
}
if curr.uptime_secs != prev.uptime_secs {
delta.up = curr.uptime_secs;
}
if curr.tor_active != prev.tor_active {
delta.tor = curr.tor_active;
}
delta
}
/// Apply a delta to a base snapshot, producing an updated snapshot.
pub fn apply_delta(base: &NodeStateSnapshot, delta: &StateDelta) -> NodeStateSnapshot {
let mut result = base.clone();
result.timestamp = delta.ts.clone();
// Apply app changes
if let Some(changed) = &delta.apps {
for app in changed {
if let Some(existing) = result.apps.iter_mut().find(|a| a.id == app.id) {
existing.status = app.status.clone();
existing.version = app.version.clone();
} else {
result.apps.push(app.clone());
}
}
}
// Apply app removals
if let Some(removed) = &delta.apps_rm {
result.apps.retain(|a| !removed.contains(&a.id));
}
// Apply scalar fields
if let Some(cpu) = delta.cpu {
result.cpu_usage_percent = Some(cpu);
}
if let Some(mem_u) = delta.mem_u {
result.mem_used_bytes = Some(mem_u);
}
if let Some(mem_t) = delta.mem_t {
result.mem_total_bytes = Some(mem_t);
}
if let Some(dsk_u) = delta.dsk_u {
result.disk_used_bytes = Some(dsk_u);
}
if let Some(dsk_t) = delta.dsk_t {
result.disk_total_bytes = Some(dsk_t);
}
if let Some(up) = delta.up {
result.uptime_secs = Some(up);
}
if let Some(tor) = delta.tor {
result.tor_active = Some(tor);
}
result
}
/// Encode a delta as CBOR bytes.
pub fn encode_cbor(delta: &StateDelta) -> Result<Vec<u8>> {
let mut buf = Vec::new();
ciborium::into_writer(delta, &mut buf).context("CBOR encode failed")?;
Ok(buf)
}
/// Decode a delta from CBOR bytes.
pub fn decode_cbor(data: &[u8]) -> Result<StateDelta> {
ciborium::from_reader(data).context("CBOR decode failed")
}
/// Encode a full state snapshot as CBOR (for initial sync or Tor transport).
pub fn encode_snapshot_cbor(snapshot: &NodeStateSnapshot) -> Result<Vec<u8>> {
let mut buf = Vec::new();
ciborium::into_writer(snapshot, &mut buf).context("CBOR snapshot encode failed")?;
Ok(buf)
}
/// Decode a full state snapshot from CBOR.
pub fn decode_snapshot_cbor(data: &[u8]) -> Result<NodeStateSnapshot> {
ciborium::from_reader(data).context("CBOR snapshot decode failed")
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_snapshot_a() -> NodeStateSnapshot {
NodeStateSnapshot {
timestamp: "2026-03-16T12:00:00Z".to_string(),
apps: vec![
AppStatus {
id: "bitcoin-knots".to_string(),
status: "running".to_string(),
version: Some("27.1".to_string()),
},
AppStatus {
id: "lnd".to_string(),
status: "running".to_string(),
version: Some("0.18.0".to_string()),
},
AppStatus {
id: "mempool".to_string(),
status: "stopped".to_string(),
version: Some("3.0".to_string()),
},
],
cpu_usage_percent: Some(23.5),
mem_used_bytes: Some(4_000_000_000),
mem_total_bytes: Some(16_000_000_000),
disk_used_bytes: Some(500_000_000_000),
disk_total_bytes: Some(1_800_000_000_000),
uptime_secs: Some(86400),
tor_active: Some(true),
}
}
fn sample_snapshot_b() -> NodeStateSnapshot {
NodeStateSnapshot {
timestamp: "2026-03-16T12:05:00Z".to_string(),
apps: vec![
AppStatus {
id: "bitcoin-knots".to_string(),
status: "running".to_string(),
version: Some("27.1".to_string()),
},
AppStatus {
id: "lnd".to_string(),
status: "running".to_string(),
version: Some("0.18.0".to_string()),
},
AppStatus {
id: "mempool".to_string(),
status: "running".to_string(), // Changed: stopped -> running
version: Some("3.0".to_string()),
},
],
cpu_usage_percent: Some(35.2), // Changed
mem_used_bytes: Some(4_500_000_000), // Changed
mem_total_bytes: Some(16_000_000_000),
disk_used_bytes: Some(500_000_000_000),
disk_total_bytes: Some(1_800_000_000_000),
uptime_secs: Some(86700), // Changed
tor_active: Some(true),
}
}
#[test]
fn test_compute_delta_detects_changes() {
let a = sample_snapshot_a();
let b = sample_snapshot_b();
let delta = compute_delta(&a, &b);
assert_eq!(delta.v, DELTA_VERSION);
assert_eq!(delta.ts, "2026-03-16T12:05:00Z");
// Mempool status changed
assert!(delta.apps.is_some());
let apps = delta.apps.as_ref().unwrap();
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].id, "mempool");
assert_eq!(apps[0].status, "running");
// No apps removed
assert!(delta.apps_rm.is_none());
// Scalar changes
assert_eq!(delta.cpu, Some(35.2));
assert_eq!(delta.mem_u, Some(4_500_000_000));
assert_eq!(delta.up, Some(86700));
// Unchanged fields should be None
assert!(delta.mem_t.is_none());
assert!(delta.dsk_u.is_none());
assert!(delta.dsk_t.is_none());
assert!(delta.tor.is_none());
}
#[test]
fn test_apply_delta_reconstructs() {
let a = sample_snapshot_a();
let b = sample_snapshot_b();
let delta = compute_delta(&a, &b);
let reconstructed = apply_delta(&a, &delta);
assert_eq!(reconstructed.timestamp, b.timestamp);
assert_eq!(reconstructed.cpu_usage_percent, b.cpu_usage_percent);
assert_eq!(reconstructed.mem_used_bytes, b.mem_used_bytes);
assert_eq!(reconstructed.uptime_secs, b.uptime_secs);
// Check mempool status was updated
let mempool = reconstructed.apps.iter().find(|a| a.id == "mempool").unwrap();
assert_eq!(mempool.status, "running");
}
#[test]
fn test_delta_with_app_removal() {
let a = sample_snapshot_a();
let mut b = sample_snapshot_b();
// Remove mempool from b
b.apps.retain(|app| app.id != "mempool");
let delta = compute_delta(&a, &b);
assert!(delta.apps_rm.is_some());
assert_eq!(delta.apps_rm.as_ref().unwrap(), &["mempool".to_string()]);
let reconstructed = apply_delta(&a, &delta);
assert!(reconstructed.apps.iter().all(|a| a.id != "mempool"));
}
#[test]
fn test_delta_with_new_app() {
let a = sample_snapshot_a();
let mut b = sample_snapshot_b();
b.apps.push(AppStatus {
id: "electrs".to_string(),
status: "running".to_string(),
version: Some("0.10.0".to_string()),
});
let delta = compute_delta(&a, &b);
let apps = delta.apps.as_ref().unwrap();
assert!(apps.iter().any(|a| a.id == "electrs"));
let reconstructed = apply_delta(&a, &delta);
assert!(reconstructed.apps.iter().any(|a| a.id == "electrs"));
}
#[test]
fn test_cbor_roundtrip() {
let a = sample_snapshot_a();
let b = sample_snapshot_b();
let delta = compute_delta(&a, &b);
let encoded = encode_cbor(&delta).unwrap();
let decoded = decode_cbor(&encoded).unwrap();
assert_eq!(decoded.ts, delta.ts);
assert_eq!(decoded.cpu, delta.cpu);
assert_eq!(decoded.mem_u, delta.mem_u);
assert_eq!(decoded.up, delta.up);
}
#[test]
fn test_cbor_size_vs_json() {
let a = sample_snapshot_a();
let b = sample_snapshot_b();
let delta = compute_delta(&a, &b);
let cbor_bytes = encode_cbor(&delta).unwrap();
let json_bytes = serde_json::to_vec(&b).unwrap();
// CBOR delta should be significantly smaller than full JSON snapshot
assert!(
cbor_bytes.len() < json_bytes.len(),
"CBOR delta ({} bytes) should be smaller than full JSON ({} bytes)",
cbor_bytes.len(),
json_bytes.len()
);
}
#[test]
fn test_snapshot_cbor_roundtrip() {
let snapshot = sample_snapshot_a();
let encoded = encode_snapshot_cbor(&snapshot).unwrap();
let decoded = decode_snapshot_cbor(&encoded).unwrap();
assert_eq!(decoded.timestamp, snapshot.timestamp);
assert_eq!(decoded.apps.len(), snapshot.apps.len());
assert_eq!(decoded.cpu_usage_percent, snapshot.cpu_usage_percent);
}
#[test]
fn test_no_changes_produces_minimal_delta() {
let a = sample_snapshot_a();
let mut b = a.clone();
b.timestamp = "2026-03-16T12:01:00Z".to_string();
let delta = compute_delta(&a, &b);
// Only timestamp should differ
assert!(delta.apps.is_none());
assert!(delta.apps_rm.is_none());
assert!(delta.cpu.is_none());
assert!(delta.mem_u.is_none());
assert!(delta.tor.is_none());
let cbor_bytes = encode_cbor(&delta).unwrap();
// Minimal delta should be very small (just timestamp + version)
assert!(cbor_bytes.len() < 50, "Minimal delta should be <50 bytes, got {}", cbor_bytes.len());
}
}

View File

@ -0,0 +1,170 @@
//! LAN transport — peer discovery via mDNS and direct HTTP messaging.
//!
//! Advertises this node as `_archipelago._tcp.local.` with TXT records
//! containing the node's DID and public key. Discovers other Archipelago
//! nodes on the same LAN segment. Sends messages via direct HTTP POST
//! to the discovered IP:port — same endpoint as Tor transport but without
//! the SOCKS5 proxy, for near-zero latency on local networks.
use super::{NodeTransport, PeerRegistry, PeerSource, TransportKind, TransportMessage};
use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
const SERVICE_TYPE: &str = "_archipelago._tcp.local.";
const DEFAULT_PORT: u16 = 5678;
const LAN_TIMEOUT: Duration = Duration::from_secs(10);
pub struct LanTransport {
our_did: String,
our_pubkey_hex: String,
our_port: u16,
daemon: Option<ServiceDaemon>,
available: AtomicBool,
}
impl LanTransport {
/// Create a new LAN transport. Does not start discovery yet.
pub fn new(our_did: &str, our_pubkey_hex: &str, port: u16) -> Self {
Self {
our_did: our_did.to_string(),
our_pubkey_hex: our_pubkey_hex.to_string(),
our_port: port,
daemon: None,
available: AtomicBool::new(false),
}
}
/// Start the mDNS daemon, advertise our service, and begin browsing.
/// Non-blocking — spawns background tasks for discovery.
pub fn start(&mut self, registry: Arc<PeerRegistry>) -> Result<()> {
let daemon = ServiceDaemon::new()
.context("Failed to create mDNS daemon")?;
// Advertise our service
let hostname = format!("archy-{}.local.", &self.our_pubkey_hex[..8]);
let properties = vec![
("did".to_string(), self.our_did.clone()),
("pubkey".to_string(), self.our_pubkey_hex.clone()),
("version".to_string(), "0.1.0".to_string()),
];
let service_info = ServiceInfo::new(
SERVICE_TYPE,
&format!("archy-{}", &self.our_pubkey_hex[..8]),
&hostname,
"",
self.our_port,
Some(properties.into_iter().collect()),
)
.context("Failed to create mDNS service info")?;
daemon
.register(service_info)
.context("Failed to register mDNS service")?;
// Browse for other Archipelago nodes
let receiver = daemon
.browse(SERVICE_TYPE)
.context("Failed to browse mDNS services")?;
self.daemon = Some(daemon);
self.available.store(true, Ordering::Relaxed);
info!("LAN transport started — advertising {}", SERVICE_TYPE);
// Spawn background discovery listener
let registry_clone = registry;
tokio::spawn(async move {
while let Ok(event) = receiver.recv() {
match event {
ServiceEvent::ServiceResolved(info) => {
let did = info.get_properties().get("did").map(|v| v.val_str().to_string());
let pubkey = info.get_properties().get("pubkey").map(|v| v.val_str().to_string());
let addresses = info.get_addresses();
if let (Some(did), Some(pubkey)) = (did, pubkey) {
if let Some(scoped_ip) = addresses.iter().next() {
let ip: std::net::IpAddr = (*scoped_ip).into();
let socket_addr = std::net::SocketAddr::new(ip, info.get_port());
info!(did = %did, addr = %socket_addr, "Discovered LAN peer via mDNS");
registry_clone
.register_peer(&did, &pubkey, PeerSource::LanDiscovery)
.await;
registry_clone
.set_lan_address(&did, socket_addr)
.await;
registry_clone
.set_name(&did, info.get_fullname())
.await;
}
}
}
ServiceEvent::ServiceRemoved(_, name) => {
debug!(name = %name, "LAN peer removed");
}
_ => {}
}
}
});
Ok(())
}
async fn send_impl(&self, address: &str, message: &TransportMessage) -> Result<()> {
// address is "ip:port" format
let url = format!("http://{}/archipelago/node-message", address);
let encoded_payload = {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(&message.payload)
};
let body = serde_json::json!({
"from_pubkey": self.our_pubkey_hex,
"from_did": message.from_did,
"message": encoded_payload,
"message_type": message.message_type,
"timestamp": chrono::Utc::now().to_rfc3339(),
"transport": "lan",
});
let client = reqwest::Client::builder()
.timeout(LAN_TIMEOUT)
.build()
.context("Failed to build LAN HTTP client")?;
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| anyhow::anyhow!("LAN send to {} failed: {}", address, e))?;
if !resp.status().is_success() {
anyhow::bail!("LAN peer at {} returned {}", address, resp.status().as_u16());
}
Ok(())
}
}
impl NodeTransport for LanTransport {
fn kind(&self) -> TransportKind {
TransportKind::Lan
}
fn is_available(&self) -> bool {
self.available.load(Ordering::Relaxed)
}
fn send<'a>(
&'a self,
address: &'a str,
message: &'a TransportMessage,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move { self.send_impl(address, message).await })
}
}

View File

@ -0,0 +1,114 @@
//! Mesh transport — sends messages via LoRa radio through the MeshService.
//!
//! Bridges the transport abstraction to the existing mesh serial listener.
//! For payloads exceeding the LoRa frame limit (160 bytes), uses the chunking
//! protocol with Reed-Solomon FEC for reliable delivery.
use super::chunking::{self, ChunkReassembler, MAX_CHUNK_PAYLOAD};
use super::{NodeTransport, TransportKind, TransportMessage};
use crate::mesh::MeshService;
use anyhow::{Context, Result};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
/// Inter-chunk delay for LoRa airtime fairness.
const CHUNK_DELAY: Duration = Duration::from_millis(200);
/// Maximum single-frame payload (before chunking kicks in).
/// After ChaCha20-Poly1305 overhead: 160 - 12 (nonce) - 16 (tag) = 132 bytes.
const MAX_SINGLE_FRAME: usize = 132;
pub struct MeshTransport {
mesh_service: Arc<RwLock<Option<MeshService>>>,
reassembler: Arc<RwLock<ChunkReassembler>>,
}
impl MeshTransport {
pub fn new(mesh_service: Arc<RwLock<Option<MeshService>>>) -> Self {
Self {
mesh_service,
reassembler: Arc::new(RwLock::new(ChunkReassembler::new())),
}
}
/// Get a reference to the chunk reassembler (for incoming message processing).
pub fn reassembler(&self) -> Arc<RwLock<ChunkReassembler>> {
Arc::clone(&self.reassembler)
}
async fn send_impl(&self, contact_id_str: &str, message: &TransportMessage) -> Result<()> {
let contact_id: u32 = contact_id_str
.parse()
.context("Invalid mesh contact ID")?;
let service = self.mesh_service.read().await;
let service = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
// Serialize the transport message as CBOR for compact encoding
let mut payload = Vec::new();
ciborium::into_writer(message, &mut payload)
.context("Failed to CBOR-encode transport message")?;
if payload.len() <= MAX_SINGLE_FRAME {
// Fits in a single LoRa frame — send directly as text
let text = {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(&payload)
};
service
.send_message(contact_id, &text)
.await
.context("Mesh single-frame send failed")?;
} else {
// Chunk with FEC
let chunks = chunking::encode_chunked(&payload)?;
tracing::info!(
chunks = chunks.len(),
payload_bytes = payload.len(),
"Sending chunked message over mesh"
);
for chunk in &chunks {
let chunk_bytes = chunk.to_bytes();
let text = {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(&chunk_bytes)
};
service
.send_message(contact_id, &text)
.await
.context("Mesh chunk send failed")?;
tokio::time::sleep(CHUNK_DELAY).await;
}
}
Ok(())
}
}
impl NodeTransport for MeshTransport {
fn kind(&self) -> TransportKind {
TransportKind::Mesh
}
fn is_available(&self) -> bool {
// Check synchronously — we can't await here, so use try_read
match self.mesh_service.try_read() {
Ok(guard) => match guard.as_ref() {
Some(_service) => true, // Service exists
None => false,
},
Err(_) => false, // Lock contention — assume unavailable
}
}
fn send<'a>(
&'a self,
address: &'a str,
message: &'a TransportMessage,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move { self.send_impl(address, message).await })
}
}

View File

@ -0,0 +1,568 @@
//! Transport abstraction layer for Archipelago node-to-node communication.
//!
//! Unifies mesh radio (LoRa), LAN (mDNS), and Tor under a common trait.
//! Routes messages to peers via the best available transport with automatic
//! fallback: Mesh (priority 1) > LAN (2) > Tor (3).
pub mod chunking;
pub mod delta;
pub mod lan;
pub mod mesh_transport;
pub mod tor;
use crate::federation::TrustLevel;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tokio::sync::RwLock;
use tracing::{info, warn};
// ─── Transport Kind ─────────────────────────────────────────────────────
/// Transport backend type, ordered by priority (lower = preferred).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TransportKind {
Mesh = 1,
Lan = 2,
Tor = 3,
}
impl std::fmt::Display for TransportKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Mesh => write!(f, "mesh"),
Self::Lan => write!(f, "lan"),
Self::Tor => write!(f, "tor"),
}
}
}
// ─── Message Types ──────────────────────────────────────────────────────
/// Type of transport-level message.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageType {
StateSync,
PeerMessage,
FederationRpc,
}
/// A message sent between nodes via any transport.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransportMessage {
pub from_did: String,
pub payload: Vec<u8>,
pub message_type: MessageType,
}
// ─── NodeTransport Trait ────────────────────────────────────────────────
/// Trait implemented by each transport backend (Tor, Mesh, LAN).
pub trait NodeTransport: Send + Sync {
/// Which transport this is.
fn kind(&self) -> TransportKind;
/// Whether this transport is currently operational.
fn is_available(&self) -> bool;
/// Send raw bytes to a peer at their transport-specific address.
/// For Tor: address is an onion hostname.
/// For Mesh: address is a contact_id as string.
/// For LAN: address is "ip:port".
fn send<'a>(
&'a self,
address: &'a str,
message: &'a TransportMessage,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>>;
}
// ─── Peer Registry ──────────────────────────────────────────────────────
/// How we discovered this peer.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PeerSource {
Federation,
MeshDiscovery,
LanDiscovery,
NostrHandshake,
Manual,
}
/// Unified peer record with per-transport capabilities.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerRecord {
pub did: String,
pub pubkey_hex: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub trust_level: Option<TrustLevel>,
#[serde(default)]
pub source: Option<PeerSource>,
// Transport-specific addresses
#[serde(default)]
pub mesh_contact_id: Option<u32>,
#[serde(default)]
pub lan_address: Option<String>,
#[serde(default)]
pub onion_address: Option<String>,
// Freshness timestamps (RFC 3339)
#[serde(default)]
pub last_mesh: Option<String>,
#[serde(default)]
pub last_lan: Option<String>,
#[serde(default)]
pub last_tor: Option<String>,
}
impl PeerRecord {
/// Get the transport-specific address for a given transport kind.
pub fn address_for(&self, kind: TransportKind) -> Option<String> {
match kind {
TransportKind::Mesh => self.mesh_contact_id.map(|id| id.to_string()),
TransportKind::Lan => self.lan_address.clone(),
TransportKind::Tor => self.onion_address.clone(),
}
}
/// Check if the last-seen timestamp for a transport is fresh enough.
/// Mesh/LAN: 5 minutes. Tor: 1 hour.
pub fn is_fresh(&self, kind: TransportKind) -> bool {
let timestamp = match kind {
TransportKind::Mesh => self.last_mesh.as_deref(),
TransportKind::Lan => self.last_lan.as_deref(),
TransportKind::Tor => self.last_tor.as_deref(),
};
let Some(ts) = timestamp else {
// No timestamp means we haven't confirmed it, but the address exists.
// Allow it — the send will fail if unreachable.
return true;
};
let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(ts) else {
return false;
};
let age = chrono::Utc::now().signed_duration_since(parsed);
let max_age = match kind {
TransportKind::Mesh | TransportKind::Lan => chrono::Duration::minutes(5),
TransportKind::Tor => chrono::Duration::hours(1),
};
age < max_age
}
/// List available transport kinds for this peer, in priority order.
pub fn available_transports(&self) -> Vec<TransportKind> {
let mut result = Vec::new();
if self.mesh_contact_id.is_some() {
result.push(TransportKind::Mesh);
}
if self.lan_address.is_some() {
result.push(TransportKind::Lan);
}
if self.onion_address.is_some() {
result.push(TransportKind::Tor);
}
result
}
}
const PEERS_FILE: &str = "transport-peers.json";
/// Thread-safe registry of all known peers with their transport capabilities.
pub struct PeerRegistry {
peers: RwLock<HashMap<String, PeerRecord>>,
data_dir: PathBuf,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct PeersFile {
peers: Vec<PeerRecord>,
}
impl PeerRegistry {
/// Load peer registry from disk (or create empty).
pub async fn load(data_dir: &Path) -> Result<Self> {
let path = data_dir.join(PEERS_FILE);
let peers = if path.exists() {
let content = fs::read_to_string(&path)
.await
.context("Failed to read transport peers")?;
let file: PeersFile = serde_json::from_str(&content).unwrap_or_default();
file.peers
.into_iter()
.map(|p| (p.did.clone(), p))
.collect()
} else {
HashMap::new()
};
Ok(Self {
peers: RwLock::new(peers),
data_dir: data_dir.to_path_buf(),
})
}
/// Persist current state to disk.
pub async fn save(&self) -> Result<()> {
let peers = self.peers.read().await;
let file = PeersFile {
peers: peers.values().cloned().collect(),
};
let content =
serde_json::to_string_pretty(&file).context("Failed to serialize transport peers")?;
fs::write(self.data_dir.join(PEERS_FILE), content)
.await
.context("Failed to write transport peers")?;
Ok(())
}
/// Register or update a peer.
pub async fn register_peer(
&self,
did: &str,
pubkey_hex: &str,
source: PeerSource,
) -> PeerRecord {
let mut peers = self.peers.write().await;
let record = peers.entry(did.to_string()).or_insert_with(|| PeerRecord {
did: did.to_string(),
pubkey_hex: pubkey_hex.to_string(),
name: None,
trust_level: None,
source: Some(source.clone()),
mesh_contact_id: None,
lan_address: None,
onion_address: None,
last_mesh: None,
last_lan: None,
last_tor: None,
});
// Update pubkey if it changed
if record.pubkey_hex != pubkey_hex {
record.pubkey_hex = pubkey_hex.to_string();
}
record.clone()
}
/// Set the mesh contact ID for a peer.
pub async fn set_mesh_id(&self, did: &str, contact_id: u32) {
let mut peers = self.peers.write().await;
if let Some(peer) = peers.get_mut(did) {
peer.mesh_contact_id = Some(contact_id);
peer.last_mesh = Some(chrono::Utc::now().to_rfc3339());
}
}
/// Set the LAN address for a peer.
pub async fn set_lan_address(&self, did: &str, addr: SocketAddr) {
let mut peers = self.peers.write().await;
if let Some(peer) = peers.get_mut(did) {
peer.lan_address = Some(addr.to_string());
peer.last_lan = Some(chrono::Utc::now().to_rfc3339());
}
}
/// Set the onion address for a peer.
pub async fn set_onion(&self, did: &str, onion: &str) {
let mut peers = self.peers.write().await;
if let Some(peer) = peers.get_mut(did) {
peer.onion_address = Some(onion.to_string());
peer.last_tor = Some(chrono::Utc::now().to_rfc3339());
}
}
/// Set the display name for a peer.
pub async fn set_name(&self, did: &str, name: &str) {
let mut peers = self.peers.write().await;
if let Some(peer) = peers.get_mut(did) {
peer.name = Some(name.to_string());
}
}
/// Get a peer by DID.
pub async fn get_peer(&self, did: &str) -> Option<PeerRecord> {
self.peers.read().await.get(did).cloned()
}
/// Get all peers.
pub async fn all_peers(&self) -> Vec<PeerRecord> {
self.peers.read().await.values().cloned().collect()
}
/// Count of registered peers.
pub async fn count(&self) -> usize {
self.peers.read().await.len()
}
}
// ─── Transport Router ───────────────────────────────────────────────────
/// Routes messages to the best available transport per peer.
pub struct TransportRouter {
transports: Vec<Box<dyn NodeTransport>>,
pub registry: Arc<PeerRegistry>,
mesh_only: RwLock<bool>,
}
impl TransportRouter {
pub fn new(
transports: Vec<Box<dyn NodeTransport>>,
registry: Arc<PeerRegistry>,
mesh_only: bool,
) -> Self {
Self {
transports,
registry,
mesh_only: RwLock::new(mesh_only),
}
}
/// Send a message to a peer by DID, using the best available transport.
pub async fn send_to_peer(
&self,
did: &str,
message: &TransportMessage,
) -> Result<TransportKind> {
let peer = self
.registry
.get_peer(did)
.await
.ok_or_else(|| anyhow::anyhow!("Unknown peer: {}", did))?;
let candidates = self.route(&peer).await;
if candidates.is_empty() {
anyhow::bail!("No available transport for peer {}", did);
}
let mut last_err = None;
for kind in &candidates {
let transport = match self.transports.iter().find(|t| t.kind() == *kind) {
Some(t) => t,
None => continue,
};
let address = match peer.address_for(*kind) {
Some(a) => a,
None => continue,
};
match transport.send(&address, message).await {
Ok(()) => {
info!(transport = %kind, peer = %did, "Message sent");
return Ok(*kind);
}
Err(e) => {
warn!(transport = %kind, peer = %did, error = %e, "Transport failed, trying next");
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("All transports failed for peer {}", did)))
}
/// Determine transport priority for a peer.
async fn route(&self, peer: &PeerRecord) -> Vec<TransportKind> {
let mesh_only = *self.mesh_only.read().await;
let mut available = Vec::new();
if mesh_only {
// Off-grid mode: only mesh
if peer.mesh_contact_id.is_some() {
available.push(TransportKind::Mesh);
}
} else {
// Normal mode: priority order, check freshness
if peer.mesh_contact_id.is_some() && peer.is_fresh(TransportKind::Mesh) {
if let Some(t) = self.transports.iter().find(|t| t.kind() == TransportKind::Mesh) {
if t.is_available() {
available.push(TransportKind::Mesh);
}
}
}
if peer.lan_address.is_some() && peer.is_fresh(TransportKind::Lan) {
if let Some(t) = self.transports.iter().find(|t| t.kind() == TransportKind::Lan) {
if t.is_available() {
available.push(TransportKind::Lan);
}
}
}
if peer.onion_address.is_some() {
if let Some(t) = self.transports.iter().find(|t| t.kind() == TransportKind::Tor) {
if t.is_available() {
available.push(TransportKind::Tor);
}
}
}
}
available
}
/// Set mesh-only (off-grid) mode.
pub async fn set_mesh_only(&self, enabled: bool) {
*self.mesh_only.write().await = enabled;
}
/// Get current mesh-only mode status.
pub async fn is_mesh_only(&self) -> bool {
*self.mesh_only.read().await
}
/// Get status of all transports.
pub fn transport_status(&self) -> Vec<(TransportKind, bool)> {
self.transports
.iter()
.map(|t| (t.kind(), t.is_available()))
.collect()
}
}
// ─── Tests ──────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transport_kind_ordering() {
assert!(TransportKind::Mesh < TransportKind::Lan);
assert!(TransportKind::Lan < TransportKind::Tor);
}
#[test]
fn test_peer_record_address_for() {
let peer = PeerRecord {
did: "did:key:z6MkTest".to_string(),
pubkey_hex: "aabb".to_string(),
name: Some("test-node".to_string()),
trust_level: None,
source: None,
mesh_contact_id: Some(42),
lan_address: Some("192.168.1.100:5678".to_string()),
onion_address: Some("abc123.onion".to_string()),
last_mesh: None,
last_lan: None,
last_tor: None,
};
assert_eq!(peer.address_for(TransportKind::Mesh), Some("42".to_string()));
assert_eq!(
peer.address_for(TransportKind::Lan),
Some("192.168.1.100:5678".to_string())
);
assert_eq!(
peer.address_for(TransportKind::Tor),
Some("abc123.onion".to_string())
);
}
#[test]
fn test_peer_record_available_transports() {
let peer = PeerRecord {
did: "did:key:z6MkTest".to_string(),
pubkey_hex: "aabb".to_string(),
name: None,
trust_level: None,
source: None,
mesh_contact_id: Some(1),
lan_address: None,
onion_address: Some("test.onion".to_string()),
last_mesh: None,
last_lan: None,
last_tor: None,
};
let transports = peer.available_transports();
assert_eq!(transports, vec![TransportKind::Mesh, TransportKind::Tor]);
}
#[test]
fn test_freshness_no_timestamp() {
let peer = PeerRecord {
did: "did:key:z6MkTest".to_string(),
pubkey_hex: "aabb".to_string(),
name: None,
trust_level: None,
source: None,
mesh_contact_id: Some(1),
lan_address: None,
onion_address: None,
last_mesh: None,
last_lan: None,
last_tor: None,
};
// No timestamp = considered fresh (allows first attempt)
assert!(peer.is_fresh(TransportKind::Mesh));
}
#[test]
fn test_freshness_recent_timestamp() {
let peer = PeerRecord {
did: "did:key:z6MkTest".to_string(),
pubkey_hex: "aabb".to_string(),
name: None,
trust_level: None,
source: None,
mesh_contact_id: Some(1),
lan_address: None,
onion_address: None,
last_mesh: Some(chrono::Utc::now().to_rfc3339()),
last_lan: None,
last_tor: None,
};
assert!(peer.is_fresh(TransportKind::Mesh));
}
#[test]
fn test_freshness_stale_timestamp() {
let stale = chrono::Utc::now() - chrono::Duration::minutes(10);
let peer = PeerRecord {
did: "did:key:z6MkTest".to_string(),
pubkey_hex: "aabb".to_string(),
name: None,
trust_level: None,
source: None,
mesh_contact_id: Some(1),
lan_address: None,
onion_address: None,
last_mesh: Some(stale.to_rfc3339()),
last_lan: None,
last_tor: None,
};
// 10 minutes old > 5 minute mesh freshness threshold
assert!(!peer.is_fresh(TransportKind::Mesh));
}
#[tokio::test]
async fn test_peer_registry_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let registry = PeerRegistry::load(dir.path()).await.unwrap();
registry
.register_peer(
"did:key:z6MkTest",
"aabbccdd",
PeerSource::MeshDiscovery,
)
.await;
registry.set_mesh_id("did:key:z6MkTest", 42).await;
registry
.set_onion("did:key:z6MkTest", "test123.onion")
.await;
registry.save().await.unwrap();
// Reload from disk
let registry2 = PeerRegistry::load(dir.path()).await.unwrap();
let peer = registry2.get_peer("did:key:z6MkTest").await.unwrap();
assert_eq!(peer.mesh_contact_id, Some(42));
assert_eq!(peer.onion_address, Some("test123.onion".to_string()));
assert_eq!(peer.pubkey_hex, "aabbccdd");
}
}

View File

@ -0,0 +1,102 @@
//! Tor transport — sends messages via HTTP POST through SOCKS5 proxy.
//!
//! Wraps the existing `node_message.rs` Tor messaging logic behind
//! the `NodeTransport` trait.
use super::{MessageType, NodeTransport, TransportKind, TransportMessage};
use anyhow::{Context, Result};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
const TOR_SOCKS: &str = "socks5h://127.0.0.1:9050";
const TOR_TIMEOUT: Duration = Duration::from_secs(60);
pub struct TorTransport {
our_pubkey_hex: String,
available: AtomicBool,
}
impl TorTransport {
pub fn new(our_pubkey_hex: &str) -> Self {
Self {
our_pubkey_hex: our_pubkey_hex.to_string(),
available: AtomicBool::new(true), // Assume available, checked lazily
}
}
/// Update availability (call periodically from health check).
pub fn set_available(&self, avail: bool) {
self.available.store(avail, Ordering::Relaxed);
}
async fn send_impl(&self, onion_address: &str, message: &TransportMessage) -> Result<()> {
let host = if onion_address.ends_with(".onion") {
onion_address.to_string()
} else {
format!("{}.onion", onion_address)
};
let url = format!("http://{}/archipelago/node-message", host);
let encoded_payload = {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(&message.payload)
};
let body = serde_json::json!({
"from_pubkey": self.our_pubkey_hex,
"message": encoded_payload,
"message_type": message.message_type,
"from_did": message.from_did,
"timestamp": chrono::Utc::now().to_rfc3339(),
});
let proxy = reqwest::Proxy::all(TOR_SOCKS).context("Invalid Tor proxy")?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(TOR_TIMEOUT)
.build()
.context("Failed to build Tor HTTP client")?;
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("connection refused") || msg.contains("Connection refused") {
self.available.store(false, Ordering::Relaxed);
anyhow::anyhow!("Tor not reachable at 127.0.0.1:9050")
} else if msg.contains("timeout") || msg.contains("timed out") {
anyhow::anyhow!("Tor connection timed out — peer may be offline")
} else {
anyhow::anyhow!("Tor send failed: {}", msg)
}
})?;
if !resp.status().is_success() {
anyhow::bail!(
"Peer returned {} over Tor",
resp.status().as_u16()
);
}
Ok(())
}
}
impl NodeTransport for TorTransport {
fn kind(&self) -> TransportKind {
TransportKind::Tor
}
fn is_available(&self) -> bool {
self.available.load(Ordering::Relaxed)
}
fn send<'a>(
&'a self,
address: &'a str,
message: &'a TransportMessage,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move { self.send_impl(address, message).await })
}
}

BIN
docker/lnd-ui/bg-intro.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -5,6 +5,13 @@ server {
root /usr/share/nginx/html;
index index.html;
location /lnd-connect-info {
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
try_files $uri $uri/ /index.html;
}

2297
docker/lnd-ui/qrcode.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -144,6 +144,13 @@ server {
# CORS handled by backend
}
location /lnd-connect-info {
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
proxy_http_version 1.1;
proxy_set_header Host $host;
add_header Access-Control-Allow-Origin *;
}
# Content sharing — peer access over Tor (no auth)
location /content {
proxy_pass http://127.0.0.1:5678;
@ -756,6 +763,13 @@ server {
# CORS handled by backend
}
location /lnd-connect-info {
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
proxy_http_version 1.1;
proxy_set_header Host $host;
add_header Access-Control-Allow-Origin *;
}
# Content sharing — peer access over Tor (no auth)
location /content {
proxy_pass http://127.0.0.1:5678;

View File

@ -3,7 +3,7 @@
FROM node:20-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
RUN git clone --depth 1 https://git.tx1138.com/lfg2025/indeehub.git .
RUN git clone --depth 1 https://github.com/Zazawowow/indeehub-archipelago.git .
RUN npm ci
ENV VITE_USE_MOCK_DATA=false
ENV VITE_INDEEHUB_API_URL=/api

View File

@ -28,7 +28,35 @@
---
## Phase 3: Fix Iframe Embedding for All Apps
## Phase 3: Fix App Launching — Use Direct IP:Port URLs
- [ ] **Fix AppSession.vue — use direct IP:port URLs instead of nginx proxy paths**: The root cause of most iframe issues is `AppSession.vue` lines 240-276. The `APP_URLS` map hardcodes every app to `/app/{id}/` nginx subpath proxies (e.g., `'/app/filebrowser/'`). This breaks apps because root-relative asset paths (like `/static/main.js`) resolve to the Archy root instead of the app. The fix: replace the hardcoded proxy paths with direct `IP:port` URLs. Change `APP_URLS` to map app IDs to their actual ports instead:
```typescript
const APP_PORTS: Record<string, number> = {
'bitcoin-knots': 8334, 'electrs': 50002, 'btcpay-server': 23000,
'lnd': 8081, 'mempool': 4080, 'homeassistant': 8123, 'grafana': 3000,
'searxng': 8888, 'ollama': 11434, 'onlyoffice': 9980, 'penpot': 9001,
'nextcloud': 8085, 'vaultwarden': 8082, 'jellyfin': 8096,
'photoprism': 2342, 'immich': 2283, 'filebrowser': 8083,
'nginx-proxy-manager': 81, 'portainer': 9000, 'uptime-kuma': 3001,
'tailscale': 8240, 'fedimint': 8175, 'nostr-rs-relay': 18081,
'indeedhub': 7777, 'dwn': 3100, 'endurain': 8080,
}
```
Then compute the URL dynamically in `appUrl`:
- **On HTTP**: `http://${window.location.hostname}:${port}` (direct, no proxy, assets work perfectly)
- **On HTTPS**: `${window.location.origin}/app/${appId}/` (proxy needed to avoid mixed-content blocks)
- **External sites**: keep direct HTTPS URLs as-is (botfights, nwnn, etc.)
This matches what `appLauncher.ts` `toEmbeddableUrl()` already does (lines 70-96) but `AppSession.vue` was bypassing it. Keep the nginx proxy locations for HTTPS — they're still needed there. The `PORT_TO_PROXY` map in `appLauncher.ts` should also be updated to use the same `APP_PORTS` source of truth (import it, or move to a shared `src/data/appPorts.ts`). Run `cd neode-ui && npm run type-check`.
- [ ] **For apps that block iframes — still open in new tab at IP:port**: Update `mustOpenInNewTab()` in `appLauncher.ts` to check against `APP_PORTS` rather than hardcoded port strings. In `AppSession.vue`, add the same check: if `mustOpenInNewTab(url)`, redirect to `window.open(url, '_blank')` instead of loading in iframe. The blocked apps (BTCPay 23000, Grafana 3000, Vaultwarden 8082, PhotoPrism 2342, Nextcloud 8085, Uptime Kuma 3001, Home Assistant 8123) should open at `http://192.168.1.228:{port}` in a new tab. Verify each blocked app actually needs blocking by checking headers: `ssh archipelago@192.168.1.228 'for port in 23000 3000 8082 2342 8085 3001 8123; do echo "Port $port:"; curl -sI http://localhost:$port/ | grep -i "x-frame"; done'`. Remove from blocked list if nginx `proxy_hide_header X-Frame-Options` is stripping the header successfully (in which case they CAN iframe).
- [ ] **Remove unnecessary nginx sub_filter path rewriting**: With direct `IP:port` URLs on HTTP, the `sub_filter` rules in `image-recipe/configs/nginx-archipelago.conf` that rewrite asset paths (e.g., IndeedHub lines 334-367) are no longer needed for HTTP. Keep them for HTTPS proxy paths only. Review each `/app/{id}/` location block — the `proxy_hide_header X-Frame-Options` and `proxy_pass` are still needed for HTTPS, but `sub_filter` rules that rewrite `/static/` or `/_next/` paths are only needed in HTTPS mode. This simplifies the nginx config significantly. Test: load each app at `http://192.168.1.228:{port}` directly in a browser — all assets should load without any nginx intervention.
- [ ] **Inject nostr-provider.js for IndeedHub at IP:port**: When apps load at direct `IP:port` URLs (not through nginx proxy), nginx can't inject `nostr-provider.js` via `sub_filter`. Instead, the `AppSession.vue` iframe wrapper must inject it. In `AppSession.vue`, after the iframe loads, use `iframe.contentWindow.postMessage` to send a script injection request, OR — simpler — add a `<script>` tag to the iframe via the `onload` handler: `iframeEl.contentDocument?.head?.appendChild(script)`. This only works for same-origin iframes (same IP, different port counts as cross-origin in browsers). Since cross-origin blocks this, the Nostr provider needs a different approach for direct port access: either (1) keep IndeedHub specifically on the nginx proxy path since it needs NIP-07, or (2) have IndeedHub check for `window.nostr` and if missing, request it from parent via postMessage. Option 1 is simpler — keep identity-aware apps (`indeedhub`, `nostrudel`) on proxy paths even on HTTP, since they need the script injection. Update `AppSession.vue` to use proxy path for identity-aware apps and direct port for everything else.
- [x] **Audit X-Frame-Options headers for all proxied apps**: SSH to 192.168.1.228. For each app with a known port, check the actual response headers: `for port in 81 3000 3001 4080 7777 8080 8081 8082 8083 8085 8096 8123 8175 8176 8190 8240 8334 8888 9000 9001 9980 11434 2283 2342 23000 50002; do echo "Port $port:"; curl -sI http://localhost:$port/ 2>/dev/null | grep -i "x-frame\|content-security-policy" || echo " (no frame restrictions)"; done`. Record the results. Compare against the blocking list in `neode-ui/src/stores/appLauncher.ts` (lines 23-31, the `XFRAME_BLOCKED_PORTS` array). Update the blocking list to match reality — if an app no longer sends X-Frame-Options DENY, remove it from the blocked list. If an app sends it but isn't in the list, add it.

109
loops/plan.md Normal file
View File

@ -0,0 +1,109 @@
# Overnight Plan — Security Audit Remediation
> Fix every finding from the 2026-03-05 security audit (`docs/security-audit-2026-03-05.md`).
> No new features, no design changes. Pure security hardening.
> Deploy after every change: `./scripts/deploy-to-target.sh --live` — test at http://192.168.1.228
> See `CLAUDE.md` for all project rules and conventions.
---
## Phase 1: Low-Effort / High-Impact Fixes
- [ ] **Add X-Requested-With header to RPC client + validate server-side**: In `neode-ui/src/api/rpc-client.ts`, add `'X-Requested-With': 'XMLHttpRequest'` to the headers object in the `call()` method's `fetch()` options. Then in `core/archipelago/src/api/rpc/mod.rs`, find where the RPC POST handler processes requests and add a check: if the `X-Requested-With` header is missing or not `XMLHttpRequest`, return 403 Forbidden. This blocks cross-origin form submissions that bypass CORS preflight. Also add the same header to `neode-ui/src/api/container-client.ts` if it makes direct fetch calls. Run `cargo clippy` on the server after the Rust change. Test by visiting http://192.168.1.228 and verifying login + container start/stop still work.
- [ ] **Add nginx security headers (nosniff, referrer-policy)**: Edit `image-recipe/configs/nginx-archipelago.conf`. In the main `server` block (before the `location` blocks), add these headers:
```
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-DNS-Prefetch-Control "off" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
```
Also add the same headers to the HTTPS server block in `image-recipe/configs/snippets/archipelago-https-app-proxies.conf` if it has a separate server block. After editing, deploy to .228 and verify headers appear: `curl -sI http://192.168.1.228 | grep -i "x-content-type\|referrer-policy"`. Then rsync the nginx config to the live server: `sudo cp ~/archy/image-recipe/configs/nginx-archipelago.conf /etc/nginx/sites-available/archipelago && sudo nginx -t && sudo systemctl reload nginx`.
- [ ] **Replace X-Frame-Options stripping with SAMEORIGIN override**: In `image-recipe/configs/nginx-archipelago.conf`, find all `proxy_hide_header X-Frame-Options;` lines inside app proxy location blocks (e.g., `/app/mempool/`, `/app/btcpay/`, etc.). Replace each `proxy_hide_header X-Frame-Options;` with:
```
proxy_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
```
This allows Archipelago to iframe the apps but blocks external sites from framing them. Do the same in the HTTPS config at `image-recipe/configs/snippets/archipelago-https-app-proxies.conf`. Test by opening an app in the Archipelago UI iframe — it should still load. Then try loading the app URL directly in an iframe from a different origin — it should be blocked. Deploy nginx config to .228 and reload.
- [ ] **Sanitize FileBrowser paths client-side**: In `neode-ui/src/api/filebrowser-client.ts`, add a `sanitizePath` function near the top:
```typescript
function sanitizePath(path: string): string {
const segments = path.split('/').filter(s => s !== '..' && s !== '.' && s !== '')
return '/' + segments.join('/')
}
```
Then replace all uses of the `safePath` helper (or the raw path variable) with `sanitizePath(path)` in these methods: `list()`, `getDownloadUrl()`, `upload()`, `createFolder()`, `rename()`, `delete()`. Search for `safePath` in the file and update each occurrence. Run `cd neode-ui && npm run type-check` to verify. Test by browsing Cloud folders in the UI — navigation, downloads, uploads should all still work.
---
## Phase 2: Medium-Effort Fixes
- [ ] **Move FileBrowser download auth from URL to header-based proxy**: Currently `filebrowser-client.ts:69` exposes the JWT token in download URLs (`?auth=${this.token}`). Fix by adding an nginx location block that proxies download requests and injects the auth header server-side:
1. In `image-recipe/configs/nginx-archipelago.conf`, add a new location block:
```nginx
location /api/cloud/download {
internal;
proxy_pass http://127.0.0.1:8083/api/raw$arg_path;
proxy_set_header X-Auth $arg_token;
proxy_hide_header X-Frame-Options;
}
```
2. In `filebrowser-client.ts`, change `getDownloadUrl()` to return `/api/cloud/download?path=${encodeURIComponent(path)}&token=${this.token}` — the token goes to nginx only, not exposed in browser history or referer headers.
3. Alternatively, create a backend proxy endpoint in `core/archipelago/src/api/handler.rs` that reads the path, fetches from FileBrowser with the token in headers, and streams back.
Test by downloading a file from Cloud — it should work without the token appearing in the browser URL bar.
- [ ] **Replace wildcard CORS with specific origins**: In `core/archipelago/src/api/handler.rs`, find `const CORS_ANY: &str = "*"` and all places it's used. Replace the wildcard with the actual requesting origin if it matches an allowlist. Add a helper function:
```rust
fn cors_origin(req: &Request<hyper::Body>) -> String {
req.headers()
.get("Origin")
.and_then(|v| v.to_str().ok())
.filter(|o| o.starts_with("http://192.168.") || o.starts_with("https://192.168.") || o.starts_with("http://localhost") || o.starts_with("http://100."))
.unwrap_or("")
.to_string()
}
```
Use this to set `Access-Control-Allow-Origin` to the requesting origin (only if it matches) instead of `*`. Apply to all endpoints that currently use `CORS_ANY`: `/api/container/logs`, `/archipelago/node-message`, `/proxy/lnd/`. Add proper `OPTIONS` preflight handling for these endpoints (return 204 with CORS headers). Run `cargo clippy` after changes. Test by verifying container logs, node messages, and LND proxy still work in the UI.
---
## Phase 3: High-Effort Fixes
- [ ] **Implement CSRF synchronizer token pattern**: Two-part change:
**Backend (Rust)**:
1. In `core/archipelago/src/api/rpc/auth.rs`, when a session is created (login), generate a random CSRF token using `rand::Rng` and `hex::encode`.
2. Store the CSRF token alongside the session in the session map.
3. Return the CSRF token in the login response JSON: `"csrf_token": csrf_token`.
4. In `core/archipelago/src/api/rpc/mod.rs`, for all state-changing RPC methods, validate that the `X-CSRF-Token` header matches the session's stored token. Return 403 if missing/mismatched.
5. Exempt `auth.login` from CSRF validation.
**Frontend (Vue)**:
1. In the login handler, store the CSRF token from the response: `localStorage.setItem('csrf_token', res.csrf_token)`.
2. In `neode-ui/src/api/rpc-client.ts`, add `'X-CSRF-Token': localStorage.getItem('csrf_token') || ''` to every request header.
3. On 403 CSRF error responses, redirect to login.
Test by logging in and performing actions (start/stop containers, change settings). Then try crafting a cross-origin POST to `/rpc/v1` — it should fail with 403.
- [ ] **Add Content-Security-Policy header**: In `image-recipe/configs/nginx-archipelago.conf`, add a CSP header to the main UI location block. Start with report-only:
```nginx
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src 'self'; frame-ancestors 'self';" always;
```
Deploy and check browser console for CSP violations. Fix violations by adjusting the policy. Once clean, change to enforcing `Content-Security-Policy`. Do NOT apply CSP to app proxy locations — those are third-party apps with different needs.
---
## Post-Fix Verification
- [ ] **Run full security re-audit**: After all fixes, verify each finding:
1. `curl -sI http://192.168.1.228` shows X-Content-Type-Options, Referrer-Policy headers
2. `curl -sI http://192.168.1.228/app/mempool/` shows X-Frame-Options: SAMEORIGIN
3. RPC calls include X-Requested-With header (check browser DevTools)
4. FileBrowser download URLs don't contain auth tokens
5. CORS responses use specific origin, not `*`: `curl -H "Origin: http://evil.com" http://192.168.1.228/api/container/logs` — should NOT return `Access-Control-Allow-Origin: *`
6. CSRF token present in RPC requests
7. All UI features still work (login, apps, cloud, web5, settings)
- [ ] **Deploy to all nodes**: After verification on .228, deploy to Arch 1 (100.82.97.63), Arch 2 (100.122.84.60), Arch 3 (100.124.105.113), .198 (192.168.1.198). Verify health on each: `curl -sI http://{ip}/ | grep -i "x-content-type\|referrer"`

View File

@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.tmc04bnmkho"
"revision": "0.9f8m1arrh28"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@ -1275,6 +1275,236 @@ app.post('/rpc/v1', (req, res) => {
return res.json({ result: [] })
}
// =====================================================================
// Mesh Networking (LoRa radio via Meshcore)
// =====================================================================
case 'mesh.status': {
return res.json({
result: {
enabled: true,
device_type: 'Meshcore',
device_path: '/dev/ttyUSB0',
device_connected: true,
firmware_version: '2.3.1',
self_node_id: 42,
self_advert_name: 'archy-228',
peer_count: 4,
channel_name: 'archipelago',
messages_sent: 23,
messages_received: 47,
detected_devices: ['/dev/ttyUSB0'],
},
})
}
case 'mesh.peers': {
return res.json({
result: {
peers: [
{
contact_id: 1,
advert_name: 'archy-198',
did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9',
pubkey_hex: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
rssi: -67,
snr: 9.5,
last_heard: new Date(Date.now() - 30000).toISOString(),
hops: 0,
},
{
contact_id: 2,
advert_name: 'satoshi-relay',
did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH',
pubkey_hex: 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5',
rssi: -82,
snr: 4.2,
last_heard: new Date(Date.now() - 120000).toISOString(),
hops: 1,
},
{
contact_id: 3,
advert_name: 'mountain-node',
did: null,
pubkey_hex: null,
rssi: -95,
snr: 1.8,
last_heard: new Date(Date.now() - 600000).toISOString(),
hops: 2,
},
{
contact_id: 4,
advert_name: 'bunker-alpha',
did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb',
pubkey_hex: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
rssi: -74,
snr: 7.1,
last_heard: new Date(Date.now() - 45000).toISOString(),
hops: 0,
},
],
count: 4,
},
})
}
case 'mesh.messages': {
const limit = params?.limit || 100
const now = Date.now()
const allMessages = [
{ id: 1, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Node online. Bitcoin Knots synced to tip.', timestamp: new Date(now - 3600000).toISOString(), delivered: true, encrypted: true },
{ id: 2, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Good. Electrs index at 98%. Channel capacity 2.5M sats.', timestamp: new Date(now - 3540000).toISOString(), delivered: true, encrypted: true },
{ id: 3, direction: 'received', peer_contact_id: 2, peer_name: 'satoshi-relay', plaintext: 'ARCHY:2:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2:d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5', timestamp: new Date(now - 3000000).toISOString(), delivered: true, encrypted: false },
{ id: 4, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Federation state sync complete. 3 containers matched.', timestamp: new Date(now - 1800000).toISOString(), delivered: true, encrypted: true },
{ id: 5, direction: 'sent', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Running mesh-only mode. No internet for 48h. All good.', timestamp: new Date(now - 900000).toISOString(), delivered: true, encrypted: true },
{ id: 6, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Copy. Block height 890,412 via compact headers. 6 confirmations on last tx.', timestamp: new Date(now - 840000).toISOString(), delivered: true, encrypted: true },
{ id: 7, direction: 'received', peer_contact_id: 2, peer_name: 'satoshi-relay', plaintext: 'New block relayed: 890,413. Fees averaging 12 sat/vB.', timestamp: new Date(now - 600000).toISOString(), delivered: true, encrypted: true },
{ id: 8, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Opening 1M sat channel to your node. Approve?', timestamp: new Date(now - 300000).toISOString(), delivered: true, encrypted: true },
{ id: 9, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Approved. Waiting for funding tx confirmation.', timestamp: new Date(now - 240000).toISOString(), delivered: true, encrypted: true },
{ id: 10, direction: 'received', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'Anyone copy? Solar panel restored, back online.', timestamp: new Date(now - 120000).toISOString(), delivered: true, encrypted: false },
{ id: 11, direction: 'sent', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'Copy mountain-node. Welcome back. Relaying your backlog.', timestamp: new Date(now - 60000).toISOString(), delivered: true, encrypted: false },
{ id: 12, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Dead man switch check-in. All systems nominal. Battery 78%.', timestamp: new Date(now - 30000).toISOString(), delivered: true, encrypted: true },
]
return res.json({
result: {
messages: allMessages.slice(0, limit),
count: allMessages.length,
},
})
}
case 'mesh.send': {
const contactId = params?.contact_id
const message = params?.message || ''
const peer = [
{ id: 1, name: 'archy-198', encrypted: true },
{ id: 2, name: 'satoshi-relay', encrypted: true },
{ id: 3, name: 'mountain-node', encrypted: false },
{ id: 4, name: 'bunker-alpha', encrypted: true },
].find(p => p.id === contactId)
console.log(`[Mesh] Send to ${peer?.name || contactId}: ${message}`)
return res.json({
result: {
sent: true,
message_id: Math.floor(Math.random() * 10000) + 100,
encrypted: peer?.encrypted ?? false,
},
})
}
case 'mesh.broadcast': {
console.log('[Mesh] Broadcasting identity over LoRa')
return res.json({ result: { broadcast: true } })
}
case 'mesh.configure': {
console.log(`[Mesh] Configure:`, params)
return res.json({ result: { configured: true } })
}
// =====================================================================
// Transport Layer (unified routing: mesh > lan > tor)
// =====================================================================
case 'transport.status': {
return res.json({
result: {
transports: [
{ kind: 'mesh', available: true },
{ kind: 'lan', available: true },
{ kind: 'tor', available: true },
],
mesh_only: false,
peer_count: 5,
},
})
}
case 'transport.peers': {
return res.json({
result: {
peers: [
{
did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9',
pubkey_hex: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
name: 'archy-198',
trust_level: 'trusted',
mesh_contact_id: 1,
lan_address: '192.168.1.198:5678',
onion_address: 'peer1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion',
preferred_transport: 'lan',
available_transports: ['mesh', 'lan', 'tor'],
last_seen: new Date(Date.now() - 30000).toISOString(),
},
{
did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH',
pubkey_hex: 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5',
name: 'satoshi-relay',
trust_level: 'trusted',
mesh_contact_id: 2,
lan_address: null,
onion_address: 'peer2xyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba.onion',
preferred_transport: 'mesh',
available_transports: ['mesh', 'tor'],
last_seen: new Date(Date.now() - 120000).toISOString(),
},
{
did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb',
pubkey_hex: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
name: 'bunker-alpha',
trust_level: 'observer',
mesh_contact_id: 4,
lan_address: null,
onion_address: null,
preferred_transport: 'mesh',
available_transports: ['mesh'],
last_seen: new Date(Date.now() - 45000).toISOString(),
},
{
did: 'did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG',
pubkey_hex: 'd4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5',
name: 'office-node',
trust_level: 'trusted',
mesh_contact_id: null,
lan_address: '192.168.1.42:5678',
onion_address: 'peer4mno6pqr7stu8vwx9yzabc2def3ghi4jkl5.onion',
preferred_transport: 'lan',
available_transports: ['lan', 'tor'],
last_seen: new Date(Date.now() - 60000).toISOString(),
},
{
did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi58NGb4pk1Sp7NQD5EjEREWh',
pubkey_hex: 'e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6',
name: 'remote-cabin',
trust_level: 'trusted',
mesh_contact_id: null,
lan_address: null,
onion_address: 'peer5xyz9abc2def3ghi4jkl5mno6pqr7stu8vw.onion',
preferred_transport: 'tor',
available_transports: ['tor'],
last_seen: new Date(Date.now() - 300000).toISOString(),
},
],
},
})
}
case 'transport.send': {
const targetDid = params?.did
console.log(`[Transport] Send to ${targetDid} via best transport`)
return res.json({
result: {
sent: true,
transport_used: 'mesh',
did: targetDid,
},
})
}
case 'transport.set-mode': {
const meshOnly = params?.mesh_only ?? false
console.log(`[Transport] Set mesh_only mode: ${meshOnly}`)
return res.json({ result: { mesh_only: meshOnly, configured: true } })
}
default: {
console.log(`[RPC] Unknown method: ${method}`)
return res.json({

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

View File

@ -139,6 +139,11 @@ const router = createRouter({
name: 'federation',
component: () => import('../views/Federation.vue'),
},
{
path: 'mesh',
name: 'mesh',
component: () => import('../views/Mesh.vue'),
},
{
path: 'web5',
name: 'web5',

189
neode-ui/src/stores/mesh.ts Normal file
View File

@ -0,0 +1,189 @@
// Pinia store for mesh networking state (Meshcore LoRa)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
export interface MeshStatus {
enabled: boolean
device_type: string
device_path: string | null
device_connected: boolean
firmware_version: string | null
self_node_id: number | null
self_advert_name: string | null
peer_count: number
channel_name: string
messages_sent: number
messages_received: number
detected_devices?: string[]
}
export interface MeshPeer {
contact_id: number
advert_name: string
did: string | null
pubkey_hex: string | null
rssi: number | null
snr: number | null
last_heard: string
hops: number
}
export interface MeshChannel {
index: number
name: string
has_secret: boolean
}
export interface MeshMessage {
id: number
direction: 'sent' | 'received'
peer_contact_id: number
peer_name: string | null
plaintext: string
timestamp: string
delivered: boolean
encrypted: boolean
}
export const useMeshStore = defineStore('mesh', () => {
const status = ref<MeshStatus | null>(null)
const peers = ref<MeshPeer[]>([])
const messages = ref<MeshMessage[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const sending = ref(false)
// Track unread message counts per peer (contact_id -> count)
const unreadCounts = ref<Record<number, number>>({})
// Currently viewing chat for this contact_id (clears unread)
const viewingChatId = ref<number | null>(null)
// Total unread count for nav badge
const totalUnread = computed(() =>
Object.values(unreadCounts.value).reduce((a, b) => a + b, 0)
)
async function fetchStatus() {
try {
loading.value = true
error.value = null
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
status.value = res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
} finally {
loading.value = false
}
}
async function fetchPeers() {
try {
const res = await rpcClient.call<{ peers: MeshPeer[]; count: number }>({
method: 'mesh.peers',
})
peers.value = res.peers
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh peers'
}
}
async function fetchMessages(limit?: number) {
try {
const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({
method: 'mesh.messages',
params: limit ? { limit } : {},
})
// Detect new incoming messages and increment unread counts
const newMsgs = res.messages.filter(
m => m.direction === 'received' && !messages.value.some(existing => existing.id === m.id)
)
for (const msg of newMsgs) {
// Don't count as unread if we're currently viewing that chat
if (msg.peer_contact_id !== viewingChatId.value) {
unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1
}
}
messages.value = res.messages
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh messages'
}
}
function markChatRead(contactId: number) {
viewingChatId.value = contactId
delete unreadCounts.value[contactId]
}
function clearViewingChat() {
viewingChatId.value = null
}
async function sendMessage(contactId: number, message: string) {
try {
sending.value = true
error.value = null
const res = await rpcClient.call<{ sent: boolean; message_id: number; encrypted: boolean }>({
method: 'mesh.send',
params: { contact_id: contactId, message: message.trim() },
})
// Refresh messages after sending
if (res.sent) {
await fetchMessages()
}
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send mesh message'
throw err
} finally {
sending.value = false
}
}
async function broadcastIdentity() {
try {
error.value = null
await rpcClient.call<{ broadcast: boolean }>({ method: 'mesh.broadcast' })
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to broadcast identity'
throw err
}
}
async function configure(config: Partial<MeshStatus>) {
try {
error.value = null
await rpcClient.call<{ configured: boolean }>({
method: 'mesh.configure',
params: config,
})
await fetchStatus()
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to configure mesh'
throw err
}
}
async function refreshAll() {
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages()])
}
return {
status,
peers,
messages,
loading,
error,
sending,
unreadCounts,
totalUnread,
fetchStatus,
fetchPeers,
fetchMessages,
sendMessage,
broadcastIdentity,
configure,
refreshAll,
markChatRead,
clearViewingChat,
}
})

View File

@ -0,0 +1,113 @@
// Pinia store for transport layer state (unified routing: mesh > lan > tor)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
export type TransportKind = 'mesh' | 'lan' | 'tor'
export interface TransportInfo {
kind: TransportKind
available: boolean
}
export interface TransportStatus {
transports: TransportInfo[]
mesh_only: boolean
peer_count: number
}
export interface TransportPeer {
did: string
pubkey_hex: string
name: string | null
trust_level: string | null
mesh_contact_id: number | null
lan_address: string | null
onion_address: string | null
preferred_transport: TransportKind
available_transports: TransportKind[]
last_seen: string | null
}
export const useTransportStore = defineStore('transport', () => {
const status = ref<TransportStatus | null>(null)
const peers = ref<TransportPeer[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const meshOnly = computed(() => status.value?.mesh_only ?? false)
const availableTransports = computed(() =>
(status.value?.transports ?? []).filter((t) => t.available).map((t) => t.kind)
)
async function fetchStatus() {
try {
loading.value = true
error.value = null
const res = await rpcClient.call<TransportStatus>({ method: 'transport.status' })
status.value = res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch transport status'
} finally {
loading.value = false
}
}
async function fetchPeers() {
try {
const res = await rpcClient.call<{ peers: TransportPeer[] }>({
method: 'transport.peers',
})
peers.value = res.peers
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch transport peers'
}
}
async function sendMessage(did: string, payload: string) {
try {
error.value = null
const res = await rpcClient.call<{ sent: boolean; transport_used: TransportKind }>({
method: 'transport.send',
params: { did, payload },
})
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send via transport'
throw err
}
}
async function setMeshOnly(enabled: boolean) {
try {
error.value = null
await rpcClient.call<{ mesh_only: boolean; configured: boolean }>({
method: 'transport.set-mode',
params: { mesh_only: enabled },
})
await fetchStatus()
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to set transport mode'
throw err
}
}
async function refreshAll() {
await Promise.all([fetchStatus(), fetchPeers()])
}
return {
status,
peers,
loading,
error,
meshOnly,
availableTransports,
fetchStatus,
fetchPeers,
sendMessage,
setMeshOnly,
refreshAll,
}
})

View File

@ -101,6 +101,10 @@
v-if="item.path === '/dashboard/web5' && web5Badge.pendingRequestCount > 0"
class="ml-auto w-5 h-5 flex items-center justify-center rounded-full bg-orange-500 text-white text-[10px] font-bold"
>{{ web5Badge.pendingRequestCount }}</span>
<span
v-if="item.path === '/dashboard/mesh' && meshStore.totalUnread > 0"
class="ml-auto w-5 h-5 flex items-center justify-center rounded-full bg-orange-500 text-white text-[10px] font-bold"
>{{ meshStore.totalUnread }}</span>
</RouterLink>
<!-- Chat launcher button -->
@ -406,6 +410,7 @@ import ModeSwitcher from '@/components/ModeSwitcher.vue'
import { useUIModeStore } from '@/stores/uiMode'
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
import { useWeb5BadgeStore } from '@/stores/web5Badge'
import { useMeshStore } from '@/stores/mesh'
const uiMode = useUIModeStore()
@ -419,6 +424,7 @@ const store = useAppStore()
const appLauncher = useAppLauncherStore()
const loginTransition = useLoginTransitionStore()
const web5Badge = useWeb5BadgeStore()
const meshStore = useMeshStore()
const showZoomIn = ref(false)
const pendingTimers: ReturnType<typeof setTimeout>[] = []
@ -444,6 +450,7 @@ const ROUTE_BACKGROUNDS: Record<string, string> = {
'/dashboard/apps': 'bg-myapps.jpg',
'/dashboard/marketplace': 'bg-appstore.jpg',
'/dashboard/cloud': 'bg-cloud.jpg',
'/dashboard/mesh': 'bg-mesh.jpg',
'/dashboard/server': 'bg-network.jpg',
'/dashboard/web5': 'bg-web5.jpg',
'/dashboard/settings': 'bg-settings.jpg',
@ -665,6 +672,7 @@ const gamerDesktopNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps', isCombined: true },
{ path: '/dashboard/cloud', label: 'Cloud', icon: 'cloud' },
{ path: '/dashboard/mesh', label: 'Mesh', icon: 'mesh' },
{ path: '/dashboard/server', label: 'Network', icon: 'server' },
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
@ -723,6 +731,7 @@ function getIconPath(iconName: string): string[] {
cloud: ['M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'],
server: ['M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01'],
web5: ['M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9'],
mesh: ['M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01M5.636 13.636a9 9 0 0112.728 0M1.5 10.5a14 14 0 0121 0'],
chat: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
settings: [
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
@ -752,6 +761,7 @@ const tabOrder = [
'/dashboard/apps',
'/dashboard/marketplace',
'/dashboard/cloud',
'/dashboard/mesh',
'/dashboard/server',
'/dashboard/web5',
'/dashboard/chat',

1016
neode-ui/src/views/Mesh.vue Normal file

File diff suppressed because it is too large Load Diff