Initial commit: waste-rs mesh daemon, relay, and proto crates
Full Rust implementation with Ed25519 identity, X25519 ECDH handshake, ChaCha20-Poly1305 encrypted peer connections, IPC server, and relay server. Builds on Windows (MSVC), Linux, and macOS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
.env
|
||||||
|
*.identity.json
|
||||||
|
.local-dev/
|
||||||
9
.vscode/extensions.json
vendored
Normal file
9
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"rust-lang.rust-analyzer",
|
||||||
|
"vadimcn.vscode-lldb",
|
||||||
|
"serayuzgur.crates",
|
||||||
|
"tamasfe.even-better-toml",
|
||||||
|
"usernamehw.errorlens"
|
||||||
|
]
|
||||||
|
}
|
||||||
50
.vscode/launch.json
vendored
Normal file
50
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "lldb",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug daemon",
|
||||||
|
"cargo": {
|
||||||
|
"args": ["build", "--bin=waste-daemon", "--package=waste-daemon"],
|
||||||
|
"filter": { "name": "waste-daemon", "kind": "bin" }
|
||||||
|
},
|
||||||
|
"args": [
|
||||||
|
"--alias", "local-dev",
|
||||||
|
"--peer-port", "17338",
|
||||||
|
"--ipc-port", "17337"
|
||||||
|
],
|
||||||
|
"env": { "RUST_LOG": "debug" },
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lldb",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug daemon (with relay)",
|
||||||
|
"cargo": {
|
||||||
|
"args": ["build", "--bin=waste-daemon", "--package=waste-daemon"],
|
||||||
|
"filter": { "name": "waste-daemon", "kind": "bin" }
|
||||||
|
},
|
||||||
|
"args": [
|
||||||
|
"--alias", "local-dev",
|
||||||
|
"--peer-port", "17338",
|
||||||
|
"--ipc-port", "17337",
|
||||||
|
"--relay", "127.0.0.1:17339"
|
||||||
|
],
|
||||||
|
"env": { "RUST_LOG": "debug" },
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lldb",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug relay",
|
||||||
|
"cargo": {
|
||||||
|
"args": ["build", "--bin=waste-relay", "--package=waste-relay"],
|
||||||
|
"filter": { "name": "waste-relay", "kind": "bin" }
|
||||||
|
},
|
||||||
|
"args": ["--bind", "127.0.0.1:17339"],
|
||||||
|
"env": { "RUST_LOG": "debug" },
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
15
.vscode/settings.json
vendored
Normal file
15
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"rust-analyzer.linkedProjects": ["./Cargo.toml"],
|
||||||
|
"rust-analyzer.checkOnSave.command": "clippy",
|
||||||
|
"rust-analyzer.cargo.features": "all",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"[rust]": {
|
||||||
|
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
||||||
|
},
|
||||||
|
"files.watcherExclude": {
|
||||||
|
"**/target/**": true
|
||||||
|
},
|
||||||
|
"search.exclude": {
|
||||||
|
"**/target": true
|
||||||
|
}
|
||||||
|
}
|
||||||
38
.vscode/tasks.json
vendored
Normal file
38
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "cargo build (all)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cargo build --workspace",
|
||||||
|
"group": { "kind": "build", "isDefault": true },
|
||||||
|
"problemMatcher": ["$rustc"],
|
||||||
|
"presentation": { "reveal": "always", "panel": "shared" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "cargo clippy",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cargo clippy --workspace -- -D warnings",
|
||||||
|
"group": "test",
|
||||||
|
"problemMatcher": ["$rustc"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "run relay (local)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "RUST_LOG=debug cargo run --bin waste-relay -- --bind 127.0.0.1:17339",
|
||||||
|
"presentation": { "reveal": "always", "panel": "dedicated", "group": "runtime" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "run daemon (peer A)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "RUST_LOG=debug cargo run --bin waste-daemon -- --alias peer-a --peer-port 17338 --ipc-port 17337 --data-dir /tmp/waste-a",
|
||||||
|
"presentation": { "reveal": "always", "panel": "dedicated", "group": "runtime" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "run daemon (peer B)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "RUST_LOG=debug cargo run --bin waste-daemon -- --alias peer-b --peer-port 17340 --ipc-port 17341 --data-dir /tmp/waste-b",
|
||||||
|
"presentation": { "reveal": "always", "panel": "dedicated", "group": "runtime" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1055
Cargo.lock
generated
Normal file
1055
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"daemon",
|
||||||
|
"relay",
|
||||||
|
"proto",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["codec"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
anyhow = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
ring = "0.17"
|
||||||
|
base64 = "0.22"
|
||||||
|
rand = "0.8"
|
||||||
|
bytes = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
123
FUTURE.md
Normal file
123
FUTURE.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# waste-rs — Project Roadmap
|
||||||
|
|
||||||
|
A modern reimplementation of [WASTE](https://en.wikipedia.org/wiki/WASTE):
|
||||||
|
decentralized, friend-to-friend encrypted mesh networking with chat and file sharing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Philosophy
|
||||||
|
|
||||||
|
WASTE's core idea is still sound: small trusted groups, no central server, encrypted
|
||||||
|
everything, equal nodes. This project preserves that philosophy while replacing the
|
||||||
|
aging crypto and adding proper NAT traversal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Daemon — Rust
|
||||||
|
|
||||||
|
The networking core (mesh routing, encryption, peer management, file transfer) is
|
||||||
|
implemented in Rust because:
|
||||||
|
|
||||||
|
- **Memory safety without GC** — critical for a long-running daemon handling untrusted peer data
|
||||||
|
- **tokio** — excellent async I/O for managing many concurrent peer connections
|
||||||
|
- **ring** — modern, audited cryptography (ChaCha20-Poly1305, Ed25519, X25519)
|
||||||
|
- **Native cross-compilation** — Windows, Linux, macOS, and eventually Android/iOS
|
||||||
|
|
||||||
|
### UI Layer — TUI first, Tauri later
|
||||||
|
|
||||||
|
**Step 1: Terminal UI (`ratatui` + `crossterm`)**
|
||||||
|
|
||||||
|
The daemon exposes everything over the IPC socket, so a TUI is purely a client —
|
||||||
|
it connects to port 17337, renders `IpcEvent` lines, and sends `IpcCommand` lines
|
||||||
|
back. No mesh or crypto knowledge required.
|
||||||
|
|
||||||
|
- ~400–600 lines of Rust, two crate dependencies
|
||||||
|
- Works on Windows, Linux, macOS, and over SSH
|
||||||
|
- Validates the IPC API design before it gets frozen
|
||||||
|
- Fast to iterate: restart the TUI, daemon keeps running
|
||||||
|
|
||||||
|
This is the right first UI milestone. It gives you a real working chat interface
|
||||||
|
without any build toolchain beyond `cargo`.
|
||||||
|
|
||||||
|
**Step 2: Tauri GUI**
|
||||||
|
|
||||||
|
Once the IPC contract is stable, wrap it in a proper desktop app:
|
||||||
|
|
||||||
|
- Rust backend shares code directly with the daemon
|
||||||
|
- Web frontend (React/TypeScript) for a modern chat UI
|
||||||
|
- Ships as a native binary using the OS webview — not Electron
|
||||||
|
- Works on Windows, Linux, macOS; Tauri 2.x adds Android/iOS
|
||||||
|
|
||||||
|
This avoids the wxWidgets ugliness of the original WASTE and the Qt licensing
|
||||||
|
headaches of the VIA fork.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Crypto Modernization
|
||||||
|
|
||||||
|
Replaces WASTE's original Blowfish/PCBC (broken cipher mode) + RSA with:
|
||||||
|
|
||||||
|
| Purpose | Algorithm | Replaces |
|
||||||
|
|---|---|---|
|
||||||
|
| Identity | Ed25519 | Unregistered nicknames |
|
||||||
|
| Key exchange | X25519 ECDH | RSA |
|
||||||
|
| Symmetric encryption | ChaCha20-Poly1305 | Blowfish/PCBC |
|
||||||
|
| File integrity | SHA-256 | CRC |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Protocol Modernization
|
||||||
|
|
||||||
|
### NAT Traversal
|
||||||
|
WASTE's longstanding problem: one party needs an open port. The fix:
|
||||||
|
|
||||||
|
1. **ICE-style UDP hole punching** (STUN) — try direct connection first
|
||||||
|
2. **Relay fallback** — a lightweight relay server (already implemented) forwards
|
||||||
|
encrypted blobs when hole punching fails; the relay never sees plaintext
|
||||||
|
|
||||||
|
### Transport (future consideration)
|
||||||
|
Consider migrating from raw TCP to **QUIC** (via `quinn`) for:
|
||||||
|
- Connection multiplexing — multiple streams per peer without head-of-line blocking
|
||||||
|
- Better NAT behavior out of the box
|
||||||
|
- Built-in TLS 1.3
|
||||||
|
|
||||||
|
### Identity & Bootstrapping
|
||||||
|
- Each peer generates an Ed25519 keypair on first run — the public key *is* their identity
|
||||||
|
- Invite via a signed `.waste-invite` file shared out-of-band (email, Signal, etc.)
|
||||||
|
- The invite carries: current IP:port hint + public key + short-lived signature
|
||||||
|
- Once two peers connect, they gossip each other's addresses to mutual friends
|
||||||
|
- At small group sizes (10–50 nodes) a local known-peers list is sufficient — no DHT needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### Near-term
|
||||||
|
- [ ] Invite file format (`.waste-invite`) for easy peer onboarding
|
||||||
|
- [ ] Relay-assisted message delivery (daemon → relay → peer when no direct path)
|
||||||
|
- [ ] Gossip-driven auto-connect (friends-of-friends)
|
||||||
|
- [ ] Ping/pong keepalive with peer timeout and reconnect
|
||||||
|
|
||||||
|
### Medium-term
|
||||||
|
- [ ] **TUI client** (`ratatui` + `crossterm`) — chat pane, peer list, text input
|
||||||
|
- [ ] UDP hole-punching (STUN) in `nat.rs`
|
||||||
|
- [ ] File transfer implementation (FileOffer/FileChunk protocol already defined in proto)
|
||||||
|
- [ ] Message persistence (SQLite via `rusqlite`)
|
||||||
|
- [ ] Tauri GUI wrapping the daemon (sidebar: peers, rooms, chat history)
|
||||||
|
|
||||||
|
### Long-term
|
||||||
|
- [ ] QUIC transport via `quinn`
|
||||||
|
- [ ] Mobile daemon (Android/iOS via Rust cross-compilation)
|
||||||
|
- [ ] Kademlia DHT for larger-scale peer discovery (if the group outgrows static bootstrap)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What We Keep from WASTE
|
||||||
|
|
||||||
|
- Small, trusted friend groups — not a public network
|
||||||
|
- No central authority — the relay is dumb and optional
|
||||||
|
- Encrypted everything — the relay forwards opaque blobs
|
||||||
|
- Equal nodes — no privileged "servers" among peers
|
||||||
|
- Simple onboarding — one file or one URL to join a group
|
||||||
215
README.md
Normal file
215
README.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# waste-rs
|
||||||
|
|
||||||
|
A modern reimagining of [WASTE](https://en.wikipedia.org/wiki/WASTE) - decentralized,
|
||||||
|
friend-to-friend encrypted mesh networking with chat and file sharing.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
waste-rs/
|
||||||
|
|-- proto/ Shared types (wire protocol, IPC messages)
|
||||||
|
|-- daemon/ Main peer process - runs locally on each friend's machine
|
||||||
|
| |-- crypto.rs Ed25519 identity, X25519 ECDH, ChaCha20-Poly1305 AEAD
|
||||||
|
| |-- mesh/ Connected peer state, gossip, broadcast
|
||||||
|
| |-- nat.rs Relay client + future hole-punching
|
||||||
|
| `-- ipc.rs Local JSON API for UIs (port 17337)
|
||||||
|
`-- relay/ Bootstrap/relay server - runs on your VPS
|
||||||
|
```
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. Each peer generates an Ed25519 keypair on first run -> their identity.
|
||||||
|
2. Two peers exchange invite info out-of-band (public key + IP:port).
|
||||||
|
3. On connect: Ed25519-authenticated X25519 handshake -> per-session ChaCha20 key.
|
||||||
|
4. All traffic is encrypted; the relay only forwards opaque blobs.
|
||||||
|
5. Connected peers gossip each other's addresses to strengthen the mesh.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Rust 1.78+ (via rustup)
|
||||||
|
- VS Code with the extensions in `.vscode/extensions.json`
|
||||||
|
- `cargo-lldb` for debugging (installed by the LLDB extension)
|
||||||
|
|
||||||
|
### Install toolchain (Windows, Linux, macOS)
|
||||||
|
|
||||||
|
Use rustup on all platforms.
|
||||||
|
|
||||||
|
Windows (PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
winget install Rustlang.Rustup
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux/macOS (bash/zsh):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then install required components:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rustup toolchain install stable
|
||||||
|
rustup default stable
|
||||||
|
rustup component add rustfmt clippy
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify your setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rustup --version
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
cargo clippy --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
### 1) Build workspace
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --workspace
|
||||||
|
cargo clippy --workspace -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2) Run local relay
|
||||||
|
|
||||||
|
Linux/macOS (bash/zsh):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug cargo run --bin waste-relay -- --bind 127.0.0.1:17339
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:RUST_LOG = "debug"
|
||||||
|
cargo run --bin waste-relay -- --bind 127.0.0.1:17339
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows cmd.exe:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
set RUST_LOG=debug && cargo run --bin waste-relay -- --bind 127.0.0.1:17339
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) Run two peers
|
||||||
|
|
||||||
|
Use a local folder for data so paths are portable across platforms.
|
||||||
|
|
||||||
|
Peer A (terminal 2)
|
||||||
|
|
||||||
|
Linux/macOS (bash/zsh):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug cargo run --bin waste-daemon -- \
|
||||||
|
--alias alice --data-dir ./.local-dev/waste-alice \
|
||||||
|
--peer-port 17338 --ipc-port 17337
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:RUST_LOG = "debug"
|
||||||
|
cargo run --bin waste-daemon -- --alias alice --data-dir ./.local-dev/waste-alice --peer-port 17338 --ipc-port 17337
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows cmd.exe:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
set RUST_LOG=debug && cargo run --bin waste-daemon -- --alias alice --data-dir ./.local-dev/waste-alice --peer-port 17338 --ipc-port 17337
|
||||||
|
```
|
||||||
|
|
||||||
|
Peer B (terminal 3)
|
||||||
|
|
||||||
|
Linux/macOS (bash/zsh):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug cargo run --bin waste-daemon -- \
|
||||||
|
--alias bob --data-dir ./.local-dev/waste-bob \
|
||||||
|
--peer-port 17340 --ipc-port 17341
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:RUST_LOG = "debug"
|
||||||
|
cargo run --bin waste-daemon -- --alias bob --data-dir ./.local-dev/waste-bob --peer-port 17340 --ipc-port 17341
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows cmd.exe:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
set RUST_LOG=debug && cargo run --bin waste-daemon -- --alias bob --data-dir ./.local-dev/waste-bob --peer-port 17340 --ipc-port 17341
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4) Connect B -> A via IPC
|
||||||
|
|
||||||
|
If nc/netcat is available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo '{"type":"connect","addr":"127.0.0.1:17338"}' | nc 127.0.0.1 17341
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell equivalent:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$json = '{"type":"connect","addr":"127.0.0.1:17338"}' + "`n"
|
||||||
|
$client = [System.Net.Sockets.TcpClient]::new("127.0.0.1", 17341)
|
||||||
|
$stream = $client.GetStream()
|
||||||
|
$writer = New-Object System.IO.StreamWriter($stream)
|
||||||
|
$writer.AutoFlush = $true
|
||||||
|
$writer.Write($json)
|
||||||
|
$writer.Dispose(); $stream.Dispose(); $client.Dispose()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relay on Hetzner VPS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release --bin waste-relay
|
||||||
|
scp target/release/waste-relay user@your-vps:~/
|
||||||
|
ssh user@your-vps './waste-relay --bind 0.0.0.0:17339'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then start the daemon with `--relay your-vps-ip:17339`.
|
||||||
|
|
||||||
|
## IPC protocol
|
||||||
|
|
||||||
|
The daemon exposes a newline-delimited JSON API on `127.0.0.1:17337`.
|
||||||
|
|
||||||
|
Commands (send to daemon):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"send_message","room":"general","body":"hello"}
|
||||||
|
{"type":"connect","addr":"1.2.3.4:17338"}
|
||||||
|
{"type":"get_state"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Events (daemon pushes to you):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"message_received","id":"...","from":"...","room":"general","body":"hello"}
|
||||||
|
{"type":"peer_connected","peer":{...}}
|
||||||
|
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [ ] UDP hole-punching (STUN) in `nat.rs`
|
||||||
|
- [ ] Invite file format (`.waste-invite`) for easy onboarding
|
||||||
|
- [ ] File transfer implementation
|
||||||
|
- [ ] Tauri UI wrapping the daemon
|
||||||
|
- [ ] Peer gossip -> auto-connect to friends-of-friends
|
||||||
|
- [ ] Message persistence (SQLite)
|
||||||
|
- [ ] Mobile daemon (Android/iOS via Rust cross-compilation)
|
||||||
|
|
||||||
|
## Crypto choices
|
||||||
|
|
||||||
|
| Purpose | Algorithm | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Identity | Ed25519 | Fast, small keys, strong |
|
||||||
|
| Key exchange | X25519 ECDH | Modern, fast, safe |
|
||||||
|
| Symmetric | ChaCha20-Poly1305 | No timing side-channels, fast without AES-NI |
|
||||||
|
| Hashing | SHA-256 (ring) | File integrity |
|
||||||
|
|
||||||
|
Replaces WASTE's original Blowfish/PCBC (broken mode) + RSA.
|
||||||
25
daemon/Cargo.toml
Normal file
25
daemon/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "waste-daemon"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "waste-daemon"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
proto = { path = "../proto" }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tokio-util = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
ring = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
rand = { workspace = true }
|
||||||
|
bytes = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
41
daemon/src/config.rs
Normal file
41
daemon/src/config.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
pub struct Config {
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub async fn init(raw_path: &str) -> Result<Self> {
|
||||||
|
let data_dir = expand_tilde(raw_path);
|
||||||
|
fs::create_dir_all(&data_dir)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("creating data dir {:?}", data_dir))?;
|
||||||
|
Ok(Self { data_dir })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn identity_path(&self) -> PathBuf {
|
||||||
|
self.data_dir.join("identity.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn peers_path(&self) -> PathBuf {
|
||||||
|
self.data_dir.join("known_peers.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_tilde(path: &str) -> PathBuf {
|
||||||
|
if let Some(rest) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) {
|
||||||
|
if let Some(home) = home_dir() {
|
||||||
|
return home.join(rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathBuf::from(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home_dir() -> Option<PathBuf> {
|
||||||
|
// $HOME on Unix, $USERPROFILE on Windows
|
||||||
|
std::env::var_os("HOME")
|
||||||
|
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||||
|
.map(PathBuf::from)
|
||||||
|
}
|
||||||
186
daemon/src/crypto.rs
Normal file
186
daemon/src/crypto.rs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/// Cryptography primitives for WASTE-rs.
|
||||||
|
///
|
||||||
|
/// Identity: Ed25519 keypair (sign/verify peer identity)
|
||||||
|
/// Session: X25519 ECDH → shared secret → ChaCha20-Poly1305 session key
|
||||||
|
/// Storage: Keys persisted as JSON in the data directory
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
use chrono::Utc;
|
||||||
|
use proto::{PeerId, PeerInfo};
|
||||||
|
use ring::{
|
||||||
|
aead::{self, BoundKey, Nonce, NonceSequence, CHACHA20_POLY1305},
|
||||||
|
agreement::{self, EphemeralPrivateKey, UnparsedPublicKey, X25519},
|
||||||
|
rand::{SecureRandom, SystemRandom},
|
||||||
|
signature::{self, Ed25519KeyPair, KeyPair},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
// ── Identity keypair ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Identity {
|
||||||
|
/// PKCS#8 Ed25519 keypair bytes (secret!)
|
||||||
|
pkcs8: Vec<u8>,
|
||||||
|
alias: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct IdentityFile {
|
||||||
|
pkcs8_base64: String,
|
||||||
|
alias: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Identity {
|
||||||
|
/// Load from disk, or generate a new keypair if none exists.
|
||||||
|
pub async fn load_or_create(data_dir: &str, alias: &str) -> Result<Self> {
|
||||||
|
let path = Path::new(data_dir).join("identity.json");
|
||||||
|
if path.exists() {
|
||||||
|
let raw = fs::read_to_string(&path)
|
||||||
|
.await
|
||||||
|
.context("reading identity file")?;
|
||||||
|
let file: IdentityFile = serde_json::from_str(&raw)?;
|
||||||
|
let pkcs8 = URL_SAFE_NO_PAD.decode(&file.pkcs8_base64)?;
|
||||||
|
tracing::info!("Loaded existing identity from {:?}", path);
|
||||||
|
Ok(Self { pkcs8, alias: file.alias })
|
||||||
|
} else {
|
||||||
|
tracing::info!("Generating new Ed25519 identity keypair");
|
||||||
|
let rng = SystemRandom::new();
|
||||||
|
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng)
|
||||||
|
.map_err(|e| anyhow::anyhow!("keygen failed: {:?}", e))?;
|
||||||
|
let identity = Self {
|
||||||
|
pkcs8: pkcs8.as_ref().to_vec(),
|
||||||
|
alias: alias.to_string(),
|
||||||
|
};
|
||||||
|
let file = IdentityFile {
|
||||||
|
pkcs8_base64: URL_SAFE_NO_PAD.encode(&identity.pkcs8),
|
||||||
|
alias: alias.to_string(),
|
||||||
|
};
|
||||||
|
fs::create_dir_all(data_dir).await?;
|
||||||
|
fs::write(&path, serde_json::to_string_pretty(&file)?).await?;
|
||||||
|
tracing::info!("Saved new identity to {:?}", path);
|
||||||
|
Ok(identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keypair(&self) -> Result<Ed25519KeyPair> {
|
||||||
|
Ed25519KeyPair::from_pkcs8(&self.pkcs8)
|
||||||
|
.map_err(|e| anyhow::anyhow!("loading keypair: {:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Our stable peer ID (base64url of the public key).
|
||||||
|
pub fn peer_id(&self) -> PeerId {
|
||||||
|
let kp = self.keypair().expect("identity keypair valid");
|
||||||
|
PeerId(URL_SAFE_NO_PAD.encode(kp.public_key().as_ref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full peer info for the Hello handshake.
|
||||||
|
pub fn peer_info(&self) -> PeerInfo {
|
||||||
|
PeerInfo {
|
||||||
|
id: self.peer_id(),
|
||||||
|
alias: self.alias.clone(),
|
||||||
|
public_key: self.peer_id().0.clone(), // public key == peer id encoding
|
||||||
|
created_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign arbitrary bytes. Returns base64url signature.
|
||||||
|
pub fn sign(&self, data: &[u8]) -> Result<String> {
|
||||||
|
let kp = self.keypair()?;
|
||||||
|
let sig = kp.sign(data);
|
||||||
|
Ok(URL_SAFE_NO_PAD.encode(sig.as_ref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a signature from a peer (given their base64url public key).
|
||||||
|
pub fn verify(public_key_b64: &str, data: &[u8], sig_b64: &str) -> Result<()> {
|
||||||
|
let pk_bytes = URL_SAFE_NO_PAD.decode(public_key_b64)?;
|
||||||
|
let sig_bytes = URL_SAFE_NO_PAD.decode(sig_b64)?;
|
||||||
|
let peer_pk = signature::UnparsedPublicKey::new(&signature::ED25519, &pk_bytes);
|
||||||
|
peer_pk
|
||||||
|
.verify(data, &sig_bytes)
|
||||||
|
.map_err(|_| anyhow::anyhow!("signature verification failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── X25519 ECDH session key derivation ───────────────────────────────────────
|
||||||
|
|
||||||
|
/// Generate an ephemeral X25519 keypair. Returns (private_key, public_key_b64).
|
||||||
|
pub fn generate_ephemeral() -> Result<(EphemeralPrivateKey, String)> {
|
||||||
|
let rng = SystemRandom::new();
|
||||||
|
let private = EphemeralPrivateKey::generate(&X25519, &rng)
|
||||||
|
.map_err(|e| anyhow::anyhow!("X25519 keygen: {:?}", e))?;
|
||||||
|
let public_b64 = URL_SAFE_NO_PAD.encode(
|
||||||
|
private
|
||||||
|
.compute_public_key()
|
||||||
|
.map_err(|e| anyhow::anyhow!("pubkey: {:?}", e))?
|
||||||
|
.as_ref(),
|
||||||
|
);
|
||||||
|
Ok((private, public_b64))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete ECDH: our ephemeral private key + their ephemeral public key → 32-byte shared secret.
|
||||||
|
pub fn ecdh(our_private: EphemeralPrivateKey, their_public_b64: &str) -> Result<Vec<u8>> {
|
||||||
|
let their_bytes = URL_SAFE_NO_PAD.decode(their_public_b64)?;
|
||||||
|
let their_pub = UnparsedPublicKey::new(&X25519, &their_bytes);
|
||||||
|
agreement::agree_ephemeral(our_private, &their_pub, |key_material| {
|
||||||
|
// In production: run through HKDF here
|
||||||
|
key_material.to_vec()
|
||||||
|
})
|
||||||
|
.map_err(|e| anyhow::anyhow!("ECDH failed: {:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ChaCha20-Poly1305 AEAD ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Encrypt plaintext with a 32-byte key. Returns (nonce_b64, ciphertext_b64).
|
||||||
|
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<(String, String)> {
|
||||||
|
let rng = SystemRandom::new();
|
||||||
|
let mut nonce_bytes = [0u8; 12];
|
||||||
|
rng.fill(&mut nonce_bytes)
|
||||||
|
.map_err(|_| anyhow::anyhow!("RNG fill failed"))?;
|
||||||
|
|
||||||
|
let unbound = aead::UnboundKey::new(&CHACHA20_POLY1305, key)
|
||||||
|
.map_err(|e| anyhow::anyhow!("key: {:?}", e))?;
|
||||||
|
let mut sealing = aead::SealingKey::new(unbound, OneNonce(Some(nonce_bytes)));
|
||||||
|
|
||||||
|
let mut in_out = plaintext.to_vec();
|
||||||
|
sealing
|
||||||
|
.seal_in_place_append_tag(aead::Aad::empty(), &mut in_out)
|
||||||
|
.map_err(|e| anyhow::anyhow!("encrypt: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
URL_SAFE_NO_PAD.encode(&nonce_bytes),
|
||||||
|
URL_SAFE_NO_PAD.encode(&in_out),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt ciphertext with a 32-byte key.
|
||||||
|
pub fn decrypt(key: &[u8; 32], nonce_b64: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
|
||||||
|
let nonce_bytes: [u8; 12] = URL_SAFE_NO_PAD
|
||||||
|
.decode(nonce_b64)?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("nonce length"))?;
|
||||||
|
|
||||||
|
let unbound = aead::UnboundKey::new(&CHACHA20_POLY1305, key)
|
||||||
|
.map_err(|e| anyhow::anyhow!("key: {:?}", e))?;
|
||||||
|
let mut opening = aead::OpeningKey::new(unbound, OneNonce(Some(nonce_bytes)));
|
||||||
|
|
||||||
|
let mut in_out = URL_SAFE_NO_PAD.decode(ciphertext_b64)?;
|
||||||
|
let plaintext = opening
|
||||||
|
.open_in_place(aead::Aad::empty(), &mut in_out)
|
||||||
|
.map_err(|_| anyhow::anyhow!("decryption failed (bad key or tampered)"))?;
|
||||||
|
|
||||||
|
Ok(plaintext.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single-use nonce holder for ring's NonceSequence trait.
|
||||||
|
struct OneNonce(Option<[u8; 12]>);
|
||||||
|
|
||||||
|
impl NonceSequence for OneNonce {
|
||||||
|
fn advance(&mut self) -> std::result::Result<Nonce, ring::error::Unspecified> {
|
||||||
|
self.0
|
||||||
|
.take()
|
||||||
|
.map(Nonce::assume_unique_for_key)
|
||||||
|
.ok_or(ring::error::Unspecified)
|
||||||
|
}
|
||||||
|
}
|
||||||
155
daemon/src/ipc.rs
Normal file
155
daemon/src/ipc.rs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/// IPC server: listens on 127.0.0.1:{port}, speaks newline-delimited JSON.
|
||||||
|
/// The UI (or any local client) sends IpcCommand and receives IpcEvent.
|
||||||
|
use crate::mesh::Mesh;
|
||||||
|
use anyhow::Result;
|
||||||
|
use chrono::Utc;
|
||||||
|
use proto::{IpcCommand, IpcEvent};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||||
|
net::{TcpListener, TcpStream},
|
||||||
|
};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub async fn run(mesh: Mesh, port: u16) -> Result<()> {
|
||||||
|
let addr = format!("127.0.0.1:{port}");
|
||||||
|
let listener = TcpListener::bind(&addr).await?;
|
||||||
|
info!("IPC server listening on {addr}");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (stream, addr) = listener.accept().await?;
|
||||||
|
info!("UI client connected from {addr}");
|
||||||
|
let mesh = mesh.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = handle_client(stream, mesh).await {
|
||||||
|
warn!("IPC client error: {e:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_client(stream: TcpStream, mesh: Mesh) -> Result<()> {
|
||||||
|
let (reader, mut writer) = stream.into_split();
|
||||||
|
let mut reader = BufReader::new(reader);
|
||||||
|
|
||||||
|
// Subscribe to mesh events before we start reading commands,
|
||||||
|
// so we don't miss anything.
|
||||||
|
let mut events = mesh.subscribe_events();
|
||||||
|
|
||||||
|
// Push events to the UI in a background task
|
||||||
|
let writer_clone = {
|
||||||
|
// We need to split writing duties: event pusher vs command replies.
|
||||||
|
// Use a channel to serialize writes.
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(128);
|
||||||
|
|
||||||
|
let tx_clone = tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok(event) = events.recv().await {
|
||||||
|
if let Ok(line) = serde_json::to_string(&event) {
|
||||||
|
if tx_clone.send(line + "\n").await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(line) = rx.recv().await {
|
||||||
|
if writer.write_all(line.as_bytes()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tx
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send an initial state snapshot
|
||||||
|
let snapshot = IpcEvent::StateSnapshot {
|
||||||
|
local_peer: mesh.identity().peer_info(),
|
||||||
|
connected_peers: mesh.connected_peers().await,
|
||||||
|
rooms: vec!["general".to_string()],
|
||||||
|
};
|
||||||
|
if let Ok(line) = serde_json::to_string(&snapshot) {
|
||||||
|
let _ = writer_clone.send(line + "\n").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read commands from the UI
|
||||||
|
let mut line = String::new();
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
let n = reader.read_line(&mut line).await?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let cmd: IpcCommand = match serde_json::from_str(line.trim()) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Bad IPC command: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handle_command(cmd, &mesh, &writer_clone).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("UI client disconnected");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_command(
|
||||||
|
cmd: IpcCommand,
|
||||||
|
mesh: &Mesh,
|
||||||
|
reply_tx: &tokio::sync::mpsc::Sender<String>,
|
||||||
|
) {
|
||||||
|
match cmd {
|
||||||
|
IpcCommand::SendMessage { room, to, body } => {
|
||||||
|
let msg = proto::ChatMessage {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
from: mesh.identity().peer_id(),
|
||||||
|
to,
|
||||||
|
room,
|
||||||
|
body,
|
||||||
|
sent_at: Utc::now(),
|
||||||
|
};
|
||||||
|
mesh.broadcast_chat(msg).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcCommand::Connect { addr } => {
|
||||||
|
info!("Connecting to peer at {addr}");
|
||||||
|
let mesh = mesh.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match tokio::net::TcpStream::connect(&addr).await {
|
||||||
|
Ok(stream) => {
|
||||||
|
if let Err(e) =
|
||||||
|
crate::mesh::listener::handle_outbound(stream, mesh).await
|
||||||
|
{
|
||||||
|
warn!("Outbound connection to {addr} failed: {e:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!("TCP connect to {addr} failed: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcCommand::GetState => {
|
||||||
|
let snapshot = IpcEvent::StateSnapshot {
|
||||||
|
local_peer: mesh.identity().peer_info(),
|
||||||
|
connected_peers: mesh.connected_peers().await,
|
||||||
|
rooms: vec!["general".to_string()],
|
||||||
|
};
|
||||||
|
if let Ok(line) = serde_json::to_string(&snapshot) {
|
||||||
|
let _ = reply_tx.send(line + "\n").await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcCommand::SendFile { to, path } => {
|
||||||
|
info!("File send to {to} from {path} — not yet implemented");
|
||||||
|
let err = IpcEvent::Error {
|
||||||
|
message: "File transfer not yet implemented".into(),
|
||||||
|
};
|
||||||
|
if let Ok(line) = serde_json::to_string(&err) {
|
||||||
|
let _ = reply_tx.send(line + "\n").await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
daemon/src/main.rs
Normal file
74
daemon/src/main.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
mod config;
|
||||||
|
mod crypto;
|
||||||
|
mod ipc;
|
||||||
|
mod mesh;
|
||||||
|
mod nat;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "waste-daemon", about = "WASTE-rs mesh daemon")]
|
||||||
|
struct Args {
|
||||||
|
/// Path to config/identity directory
|
||||||
|
#[arg(long, default_value = "~/.waste")]
|
||||||
|
data_dir: String,
|
||||||
|
|
||||||
|
/// Local alias shown to peers (advisory only)
|
||||||
|
#[arg(long, default_value = "anon")]
|
||||||
|
alias: String,
|
||||||
|
|
||||||
|
/// Port to listen for incoming peer connections
|
||||||
|
#[arg(long, default_value_t = 17338)]
|
||||||
|
peer_port: u16,
|
||||||
|
|
||||||
|
/// Port for local IPC (UI talks to daemon here)
|
||||||
|
#[arg(long, default_value_t = 17337)]
|
||||||
|
ipc_port: u16,
|
||||||
|
|
||||||
|
/// Optional relay server address (host:port)
|
||||||
|
#[arg(long)]
|
||||||
|
relay: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::from_default_env()
|
||||||
|
.add_directive("waste_daemon=debug".parse()?)
|
||||||
|
.add_directive("info".parse()?),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let args = Args::parse();
|
||||||
|
info!("Starting waste-daemon on peer_port={} ipc_port={}", args.peer_port, args.ipc_port);
|
||||||
|
|
||||||
|
let cfg = config::Config::init(&args.data_dir).await?;
|
||||||
|
let data_dir = cfg.data_dir.to_string_lossy();
|
||||||
|
|
||||||
|
// Load or generate identity keypair
|
||||||
|
let identity = crypto::Identity::load_or_create(&data_dir, &args.alias).await?;
|
||||||
|
info!("Local peer id: {}", identity.peer_info().id);
|
||||||
|
|
||||||
|
// Shared mesh state
|
||||||
|
let mesh = mesh::Mesh::new(identity.clone());
|
||||||
|
|
||||||
|
// Spawn peer listener (incoming connections from other WASTE nodes)
|
||||||
|
let peer_listener = mesh::listener::run(mesh.clone(), args.peer_port);
|
||||||
|
|
||||||
|
// Spawn NAT traversal / hole-punch coordinator
|
||||||
|
let nat_task = nat::run(mesh.clone(), args.relay.clone());
|
||||||
|
|
||||||
|
// Spawn IPC server (local UI connects here)
|
||||||
|
let ipc_task = ipc::run(mesh.clone(), args.ipc_port);
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
r = peer_listener => { r?; }
|
||||||
|
r = nat_task => { r?; }
|
||||||
|
r = ipc_task => { r?; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
174
daemon/src/mesh/listener.rs
Normal file
174
daemon/src/mesh/listener.rs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
use crate::{crypto, mesh::Mesh};
|
||||||
|
use anyhow::Result;
|
||||||
|
use proto::{Hello, HelloAck, PeerInfo, PeerMessage};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||||
|
net::{TcpListener, TcpStream},
|
||||||
|
sync::mpsc,
|
||||||
|
};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
/// Accept incoming peer connections on `port`.
|
||||||
|
pub async fn run(mesh: Mesh, port: u16) -> Result<()> {
|
||||||
|
let addr = format!("0.0.0.0:{port}");
|
||||||
|
let listener = TcpListener::bind(&addr).await?;
|
||||||
|
info!("Listening for peers on {addr}");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match listener.accept().await {
|
||||||
|
Ok((stream, peer_addr)) => {
|
||||||
|
info!("Incoming connection from {peer_addr}");
|
||||||
|
let mesh = mesh.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = handle_peer(stream, mesh).await {
|
||||||
|
warn!("Peer connection error: {e:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => error!("Accept error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inbound: we received a TCP connection. Send Hello, read HelloAck.
|
||||||
|
async fn handle_peer(stream: TcpStream, mesh: Mesh) -> Result<()> {
|
||||||
|
let peer_addr = stream.peer_addr()?;
|
||||||
|
let (reader, mut writer) = stream.into_split();
|
||||||
|
let mut reader = BufReader::new(reader);
|
||||||
|
|
||||||
|
let (our_ephemeral_priv, our_ephemeral_pub) = crypto::generate_ephemeral()?;
|
||||||
|
let identity = mesh.identity();
|
||||||
|
let hello = Hello {
|
||||||
|
version: 1,
|
||||||
|
peer: identity.peer_info(),
|
||||||
|
ephemeral_key: our_ephemeral_pub.clone(),
|
||||||
|
signature: identity.sign(our_ephemeral_pub.as_bytes())?,
|
||||||
|
};
|
||||||
|
writer.write_all((serde_json::to_string(&hello)? + "\n").as_bytes()).await?;
|
||||||
|
|
||||||
|
let mut line = String::new();
|
||||||
|
reader.read_line(&mut line).await?;
|
||||||
|
let ack: HelloAck = serde_json::from_str(line.trim())?;
|
||||||
|
|
||||||
|
crypto::Identity::verify(&ack.peer.public_key, ack.ephemeral_key.as_bytes(), &ack.signature)?;
|
||||||
|
info!("Handshake complete with peer {}", ack.peer.id);
|
||||||
|
|
||||||
|
let shared_secret = crypto::ecdh(our_ephemeral_priv, &ack.ephemeral_key)?;
|
||||||
|
let session_key: [u8; 32] = shared_secret[..32].try_into()?;
|
||||||
|
|
||||||
|
run_peer_loop(reader, writer, peer_addr.to_string(), ack.peer, session_key, mesh).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outbound: we initiated the TCP connection. Read their Hello, send HelloAck.
|
||||||
|
pub async fn handle_outbound(stream: TcpStream, mesh: Mesh) -> Result<()> {
|
||||||
|
let peer_addr = stream.peer_addr()?;
|
||||||
|
let (reader, mut writer) = stream.into_split();
|
||||||
|
let mut reader = BufReader::new(reader);
|
||||||
|
|
||||||
|
let mut line = String::new();
|
||||||
|
reader.read_line(&mut line).await?;
|
||||||
|
let hello: Hello = serde_json::from_str(line.trim())?;
|
||||||
|
|
||||||
|
crypto::Identity::verify(&hello.peer.public_key, hello.ephemeral_key.as_bytes(), &hello.signature)?;
|
||||||
|
info!("Got Hello from peer {} ({})", hello.peer.id, hello.peer.alias);
|
||||||
|
|
||||||
|
let (our_ephemeral_priv, our_ephemeral_pub) = crypto::generate_ephemeral()?;
|
||||||
|
let identity = mesh.identity();
|
||||||
|
let ack = HelloAck {
|
||||||
|
peer: identity.peer_info(),
|
||||||
|
ephemeral_key: our_ephemeral_pub.clone(),
|
||||||
|
signature: identity.sign(our_ephemeral_pub.as_bytes())?,
|
||||||
|
relay_capable: false,
|
||||||
|
};
|
||||||
|
writer.write_all((serde_json::to_string(&ack)? + "\n").as_bytes()).await?;
|
||||||
|
|
||||||
|
let shared_secret = crypto::ecdh(our_ephemeral_priv, &hello.ephemeral_key)?;
|
||||||
|
let session_key: [u8; 32] = shared_secret[..32].try_into()?;
|
||||||
|
|
||||||
|
run_peer_loop(reader, writer, peer_addr.to_string(), hello.peer, session_key, mesh).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared post-handshake loop: register in mesh, encrypt/decrypt messages.
|
||||||
|
async fn run_peer_loop(
|
||||||
|
mut reader: BufReader<tokio::net::tcp::OwnedReadHalf>,
|
||||||
|
mut writer: tokio::net::tcp::OwnedWriteHalf,
|
||||||
|
peer_addr: String,
|
||||||
|
peer_info: PeerInfo,
|
||||||
|
session_key: [u8; 32],
|
||||||
|
mesh: Mesh,
|
||||||
|
) -> Result<()> {
|
||||||
|
let (tx, mut rx) = mpsc::channel::<String>(64);
|
||||||
|
let peer_id = peer_info.id.clone();
|
||||||
|
mesh.add_peer(crate::mesh::PeerConn { info: peer_info, tx }).await;
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(msg) = rx.recv().await {
|
||||||
|
match crypto::encrypt(&session_key, msg.as_bytes()) {
|
||||||
|
Ok((nonce, ct)) => {
|
||||||
|
let env = proto::Envelope { nonce, payload: ct };
|
||||||
|
if let Ok(line) = serde_json::to_string(&env) {
|
||||||
|
let _ = writer.write_all((line + "\n").as_bytes()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!("Encrypt error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = async {
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
let n = reader.read_line(&mut line).await?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let env: proto::Envelope = match serde_json::from_str(line.trim()) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Bad envelope from {peer_addr}: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match crypto::decrypt(&session_key, &env.nonce, &env.payload) {
|
||||||
|
Ok(plaintext) => {
|
||||||
|
if let Ok(msg) = serde_json::from_slice::<PeerMessage>(&plaintext) {
|
||||||
|
handle_message(msg, &mesh).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!("Decrypt error from {peer_addr}: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
mesh.remove_peer(&peer_id).await;
|
||||||
|
info!("Peer {peer_addr} disconnected");
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_message(msg: PeerMessage, mesh: &Mesh) {
|
||||||
|
match msg {
|
||||||
|
PeerMessage::Chat(chat) => {
|
||||||
|
mesh.emit(proto::IpcEvent::MessageReceived(chat));
|
||||||
|
}
|
||||||
|
PeerMessage::PeerGossip(gossip) => {
|
||||||
|
// TODO: attempt connections to new peers from gossip
|
||||||
|
info!("Received gossip with {} peer hints", gossip.peers.len());
|
||||||
|
}
|
||||||
|
PeerMessage::Ping { seq } => {
|
||||||
|
// Pong is sent directly — for now just log
|
||||||
|
info!("Ping seq={seq}");
|
||||||
|
}
|
||||||
|
PeerMessage::Pong { seq } => {
|
||||||
|
info!("Pong seq={seq}");
|
||||||
|
}
|
||||||
|
PeerMessage::FileOffer(offer) => {
|
||||||
|
mesh.emit(proto::IpcEvent::IncomingFileOffer {
|
||||||
|
from: proto::PeerId("unknown".into()), // TODO: track per-conn sender
|
||||||
|
offer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
daemon/src/mesh/mod.rs
Normal file
112
daemon/src/mesh/mod.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
pub mod listener;
|
||||||
|
|
||||||
|
use crate::crypto::Identity;
|
||||||
|
use proto::{ChatMessage, PeerId, PeerInfo};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{broadcast, RwLock};
|
||||||
|
|
||||||
|
// ── Peer connection handle ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A live connection to a peer. Cloneable handle backed by a channel sender.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PeerConn {
|
||||||
|
pub info: PeerInfo,
|
||||||
|
/// Send raw JSON lines to this peer.
|
||||||
|
pub tx: tokio::sync::mpsc::Sender<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mesh ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Mesh(Arc<Inner>);
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
pub identity: Identity,
|
||||||
|
peers: RwLock<HashMap<PeerId, PeerConn>>,
|
||||||
|
/// Broadcast channel for events that the IPC server fans out to UI clients.
|
||||||
|
pub event_tx: broadcast::Sender<proto::IpcEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mesh {
|
||||||
|
pub fn new(identity: Identity) -> Self {
|
||||||
|
let (event_tx, _) = broadcast::channel(256);
|
||||||
|
Mesh(Arc::new(Inner {
|
||||||
|
identity,
|
||||||
|
peers: RwLock::new(HashMap::new()),
|
||||||
|
event_tx,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn identity(&self) -> &Identity {
|
||||||
|
&self.0.identity
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe_events(&self) -> broadcast::Receiver<proto::IpcEvent> {
|
||||||
|
self.0.event_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_peer(&self, conn: PeerConn) {
|
||||||
|
let peer_info = conn.info.clone();
|
||||||
|
{
|
||||||
|
let mut peers = self.0.peers.write().await;
|
||||||
|
peers.insert(conn.info.id.clone(), conn);
|
||||||
|
}
|
||||||
|
let _ = self
|
||||||
|
.0
|
||||||
|
.event_tx
|
||||||
|
.send(proto::IpcEvent::PeerConnected { peer: peer_info });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_peer(&self, peer_id: &PeerId) {
|
||||||
|
{
|
||||||
|
let mut peers = self.0.peers.write().await;
|
||||||
|
peers.remove(peer_id);
|
||||||
|
}
|
||||||
|
let _ = self
|
||||||
|
.0
|
||||||
|
.event_tx
|
||||||
|
.send(proto::IpcEvent::PeerDisconnected {
|
||||||
|
peer_id: peer_id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connected_peers(&self) -> Vec<PeerInfo> {
|
||||||
|
self.0
|
||||||
|
.peers
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.map(|c| c.info.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast a chat message to all connected peers.
|
||||||
|
pub async fn broadcast_chat(&self, msg: ChatMessage) {
|
||||||
|
let peers = self.0.peers.read().await;
|
||||||
|
let payload = serde_json::to_string(&proto::PeerMessage::Chat(msg.clone()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
for conn in peers.values() {
|
||||||
|
let _ = conn.tx.try_send(payload.clone());
|
||||||
|
}
|
||||||
|
// Also emit locally so the UI sees our own messages
|
||||||
|
let _ = self
|
||||||
|
.0
|
||||||
|
.event_tx
|
||||||
|
.send(proto::IpcEvent::MessageReceived(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message to a specific peer only.
|
||||||
|
pub async fn send_to(&self, peer_id: &PeerId, payload: String) -> bool {
|
||||||
|
let peers = self.0.peers.read().await;
|
||||||
|
if let Some(conn) = peers.get(peer_id) {
|
||||||
|
conn.tx.try_send(payload).is_ok()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit(&self, event: proto::IpcEvent) {
|
||||||
|
let _ = self.0.event_tx.send(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
daemon/src/nat.rs
Normal file
88
daemon/src/nat.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/// NAT traversal: connects to the relay server (if configured) and registers
|
||||||
|
/// this peer so friends can find us and relay-forward messages until a direct
|
||||||
|
/// connection is established.
|
||||||
|
///
|
||||||
|
/// Future: add UDP hole-punching via STUN / ICE.
|
||||||
|
use crate::mesh::Mesh;
|
||||||
|
use anyhow::Result;
|
||||||
|
use proto::{RelayClientMessage, RelayServerMessage};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||||
|
net::TcpStream,
|
||||||
|
time::{sleep, Duration},
|
||||||
|
};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
pub async fn run(mesh: Mesh, relay_addr: Option<String>) -> Result<()> {
|
||||||
|
let Some(addr) = relay_addr else {
|
||||||
|
info!("No relay configured — skipping NAT traversal");
|
||||||
|
// Sleep forever; the task just needs to stay alive.
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
info!("Connecting to relay at {addr}");
|
||||||
|
match connect_relay(&addr, &mesh).await {
|
||||||
|
Ok(()) => info!("Relay connection closed cleanly"),
|
||||||
|
Err(e) => warn!("Relay error: {e:#}"),
|
||||||
|
}
|
||||||
|
info!("Reconnecting to relay in 10s...");
|
||||||
|
sleep(Duration::from_secs(10)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_relay(addr: &str, mesh: &Mesh) -> Result<()> {
|
||||||
|
let stream = TcpStream::connect(addr).await?;
|
||||||
|
let (reader, mut writer) = stream.into_split();
|
||||||
|
let mut reader = BufReader::new(reader);
|
||||||
|
|
||||||
|
// Register with the relay
|
||||||
|
let identity = mesh.identity();
|
||||||
|
let reg = RelayClientMessage::Register {
|
||||||
|
peer: identity.peer_info(),
|
||||||
|
signature: identity.sign(identity.peer_id().0.as_bytes())?,
|
||||||
|
};
|
||||||
|
writer
|
||||||
|
.write_all((serde_json::to_string(®)? + "\n").as_bytes())
|
||||||
|
.await?;
|
||||||
|
info!("Registered with relay as {}", identity.peer_id());
|
||||||
|
|
||||||
|
// Ask for peer list (bootstrap)
|
||||||
|
let list_req = RelayClientMessage::ListPeers;
|
||||||
|
writer
|
||||||
|
.write_all((serde_json::to_string(&list_req)? + "\n").as_bytes())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Read relay messages
|
||||||
|
let mut line = String::new();
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
let n = reader.read_line(&mut line).await?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let msg: RelayServerMessage = match serde_json::from_str(line.trim()) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Bad relay message: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match msg {
|
||||||
|
RelayServerMessage::PeerList { peers } => {
|
||||||
|
info!("Relay gave us {} peer hints", peers.len());
|
||||||
|
// TODO: attempt direct connections to each peer
|
||||||
|
}
|
||||||
|
RelayServerMessage::Forwarded { from, envelope: _ } => {
|
||||||
|
info!("Relayed message from {from} — direct hole-punch not yet implemented");
|
||||||
|
// TODO: decrypt envelope, process as PeerMessage
|
||||||
|
}
|
||||||
|
RelayServerMessage::Error { message } => {
|
||||||
|
warn!("Relay error: {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
10
proto/Cargo.toml
Normal file
10
proto/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "proto"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
227
proto/src/lib.rs
Normal file
227
proto/src/lib.rs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
/// Shared protocol types between daemon and relay.
|
||||||
|
///
|
||||||
|
/// Everything on the wire is JSON-framed over TCP (length-prefixed u32 BE).
|
||||||
|
/// All binary blobs (keys, signatures, ciphertext) are base64url encoded.
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ── Identity ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A peer's stable identity: their Ed25519 public key, base64url encoded.
|
||||||
|
/// This IS the peer — display names are advisory only.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct PeerId(pub String);
|
||||||
|
|
||||||
|
impl std::fmt::Display for PeerId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
// Show first 8 chars as a short fingerprint in UIs
|
||||||
|
write!(f, "{}", &self.0[..8.min(self.0.len())])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A peer's self-description, signed with their Ed25519 key.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PeerInfo {
|
||||||
|
pub id: PeerId,
|
||||||
|
/// Human-readable alias, NOT authenticated — treat as a hint only.
|
||||||
|
pub alias: String,
|
||||||
|
/// Ed25519 public key bytes, base64url.
|
||||||
|
pub public_key: String,
|
||||||
|
/// When this record was created (used to prefer newer gossip entries).
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handshake ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// First message sent on a new TCP connection (either direction).
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Hello {
|
||||||
|
pub version: u8,
|
||||||
|
pub peer: PeerInfo,
|
||||||
|
/// X25519 ephemeral public key for this session, base64url.
|
||||||
|
pub ephemeral_key: String,
|
||||||
|
/// Ed25519 signature over (ephemeral_key || peer.id), base64url.
|
||||||
|
pub signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response to Hello — completes the handshake.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HelloAck {
|
||||||
|
pub peer: PeerInfo,
|
||||||
|
pub ephemeral_key: String,
|
||||||
|
pub signature: String,
|
||||||
|
/// Whether this peer is willing to relay traffic for others.
|
||||||
|
pub relay_capable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encrypted envelope ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// All post-handshake messages are wrapped in this envelope.
|
||||||
|
/// The inner `payload` is ChaCha20-Poly1305 encrypted.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Envelope {
|
||||||
|
/// Random nonce for this message, base64url (12 bytes).
|
||||||
|
pub nonce: String,
|
||||||
|
/// Ciphertext, base64url.
|
||||||
|
pub payload: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Peer-to-peer messages (inside Envelope) ───────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum PeerMessage {
|
||||||
|
/// Plain chat message to a public room or DM.
|
||||||
|
Chat(ChatMessage),
|
||||||
|
/// Gossip: share known peer addresses with a connected peer.
|
||||||
|
PeerGossip(PeerGossip),
|
||||||
|
/// File transfer initiation.
|
||||||
|
FileOffer(FileOffer),
|
||||||
|
/// Accept or decline a file offer.
|
||||||
|
FileResponse(FileResponse),
|
||||||
|
/// A chunk of file data.
|
||||||
|
FileChunk(FileChunk),
|
||||||
|
/// Keepalive ping.
|
||||||
|
Ping { seq: u64 },
|
||||||
|
/// Keepalive pong.
|
||||||
|
Pong { seq: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub from: PeerId,
|
||||||
|
/// None = public room broadcast, Some = DM to that peer.
|
||||||
|
pub to: Option<PeerId>,
|
||||||
|
pub room: String,
|
||||||
|
pub body: String,
|
||||||
|
pub sent_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PeerGossip {
|
||||||
|
/// Peers this node knows about and their last-seen addresses.
|
||||||
|
pub peers: Vec<GossipEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GossipEntry {
|
||||||
|
pub peer: PeerInfo,
|
||||||
|
/// IP:port hint (may be behind NAT — use as a hole-punch target).
|
||||||
|
pub addr_hint: String,
|
||||||
|
pub last_seen: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FileOffer {
|
||||||
|
pub transfer_id: Uuid,
|
||||||
|
pub filename: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FileResponse {
|
||||||
|
pub transfer_id: Uuid,
|
||||||
|
pub accepted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FileChunk {
|
||||||
|
pub transfer_id: Uuid,
|
||||||
|
pub seq: u32,
|
||||||
|
pub data: String, // base64url
|
||||||
|
pub is_last: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Relay protocol (relay server speaks this) ─────────────────────────────────
|
||||||
|
|
||||||
|
/// Messages sent TO the relay server.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum RelayClientMessage {
|
||||||
|
/// Register with the relay so other peers can find you.
|
||||||
|
Register {
|
||||||
|
peer: PeerInfo,
|
||||||
|
signature: String,
|
||||||
|
},
|
||||||
|
/// Ask the relay to forward an envelope to another peer.
|
||||||
|
Forward {
|
||||||
|
to: PeerId,
|
||||||
|
envelope: Envelope,
|
||||||
|
},
|
||||||
|
/// Ask for the relay's current peer list (for bootstrapping).
|
||||||
|
ListPeers,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Messages sent FROM the relay server.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum RelayServerMessage {
|
||||||
|
/// Forwarded envelope from another peer.
|
||||||
|
Forwarded {
|
||||||
|
from: PeerId,
|
||||||
|
envelope: Envelope,
|
||||||
|
},
|
||||||
|
/// Response to ListPeers.
|
||||||
|
PeerList {
|
||||||
|
peers: Vec<GossipEntry>,
|
||||||
|
},
|
||||||
|
/// General error.
|
||||||
|
Error { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Local IPC (daemon ↔ UI) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Commands sent from the UI to the daemon over localhost TCP (port 17337).
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum IpcCommand {
|
||||||
|
/// Send a chat message.
|
||||||
|
SendMessage {
|
||||||
|
room: String,
|
||||||
|
to: Option<PeerId>,
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
/// Offer a file to a peer.
|
||||||
|
SendFile {
|
||||||
|
to: PeerId,
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
/// Connect to a peer by address hint.
|
||||||
|
Connect { addr: String },
|
||||||
|
/// Request current state snapshot.
|
||||||
|
GetState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events pushed from the daemon to the UI.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum IpcEvent {
|
||||||
|
/// A chat message arrived.
|
||||||
|
MessageReceived(ChatMessage),
|
||||||
|
/// A peer connected.
|
||||||
|
PeerConnected { peer: PeerInfo },
|
||||||
|
/// A peer disconnected.
|
||||||
|
PeerDisconnected { peer_id: PeerId },
|
||||||
|
/// File offer incoming.
|
||||||
|
IncomingFileOffer {
|
||||||
|
from: PeerId,
|
||||||
|
offer: FileOffer,
|
||||||
|
},
|
||||||
|
/// File transfer progress update.
|
||||||
|
FileProgress {
|
||||||
|
transfer_id: Uuid,
|
||||||
|
bytes_received: u64,
|
||||||
|
total_bytes: u64,
|
||||||
|
},
|
||||||
|
/// Full state snapshot (response to GetState).
|
||||||
|
StateSnapshot {
|
||||||
|
local_peer: PeerInfo,
|
||||||
|
connected_peers: Vec<PeerInfo>,
|
||||||
|
rooms: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Generic error event.
|
||||||
|
Error { message: String },
|
||||||
|
}
|
||||||
19
relay/Cargo.toml
Normal file
19
relay/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "waste-relay"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "waste-relay"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
proto = { path = "../proto" }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
153
relay/src/main.rs
Normal file
153
relay/src/main.rs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/// waste-relay: minimal relay server.
|
||||||
|
///
|
||||||
|
/// Run this on your Hetzner VPS. Peers register with their public key + address,
|
||||||
|
/// and can ask the relay to forward encrypted envelopes to other peers.
|
||||||
|
/// The relay NEVER sees plaintext — it only forwards opaque blobs.
|
||||||
|
use anyhow::Result;
|
||||||
|
use chrono::Utc;
|
||||||
|
use clap::Parser;
|
||||||
|
use proto::{GossipEntry, PeerId, RelayClientMessage, RelayServerMessage};
|
||||||
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||||
|
net::{TcpListener, TcpStream},
|
||||||
|
sync::{mpsc, RwLock},
|
||||||
|
};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "waste-relay", about = "WASTE-rs relay/bootstrap server")]
|
||||||
|
struct Args {
|
||||||
|
#[arg(long, default_value = "0.0.0.0:17339")]
|
||||||
|
bind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Peer registry ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct RegisteredPeer {
|
||||||
|
gossip: GossipEntry,
|
||||||
|
/// Channel to forward envelopes to this peer's connection handler.
|
||||||
|
tx: mpsc::Sender<RelayServerMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Registry = Arc<RwLock<HashMap<PeerId, RegisteredPeer>>>;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter("waste_relay=debug,info")
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let args = Args::parse();
|
||||||
|
let registry: Registry = Arc::new(RwLock::new(HashMap::new()));
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(&args.bind).await?;
|
||||||
|
info!("waste-relay listening on {}", args.bind);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (stream, addr) = listener.accept().await?;
|
||||||
|
info!("Client connected: {addr}");
|
||||||
|
let registry = registry.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = handle_client(stream, registry).await {
|
||||||
|
warn!("Client error: {e:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_client(stream: TcpStream, registry: Registry) -> Result<()> {
|
||||||
|
let peer_addr = stream.peer_addr()?.to_string();
|
||||||
|
let (reader, mut writer) = stream.into_split();
|
||||||
|
let mut reader = BufReader::new(reader);
|
||||||
|
|
||||||
|
let (tx, mut rx) = mpsc::channel::<RelayServerMessage>(64);
|
||||||
|
|
||||||
|
// Writer task
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(msg) = rx.recv().await {
|
||||||
|
if let Ok(line) = serde_json::to_string(&msg) {
|
||||||
|
if writer.write_all((line + "\n").as_bytes()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut my_peer_id: Option<PeerId> = None;
|
||||||
|
let mut line = String::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
let n = reader.read_line(&mut line).await?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cmd: RelayClientMessage = match serde_json::from_str(line.trim()) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Bad relay message from {peer_addr}: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match cmd {
|
||||||
|
RelayClientMessage::Register { peer, signature: _ } => {
|
||||||
|
// TODO: verify signature against peer.public_key
|
||||||
|
info!("Registered peer {} ({}) at {peer_addr}", peer.id, peer.alias);
|
||||||
|
my_peer_id = Some(peer.id.clone());
|
||||||
|
|
||||||
|
let entry = GossipEntry {
|
||||||
|
peer: peer.clone(),
|
||||||
|
addr_hint: peer_addr.clone(),
|
||||||
|
last_seen: Utc::now(),
|
||||||
|
};
|
||||||
|
registry.write().await.insert(
|
||||||
|
peer.id,
|
||||||
|
RegisteredPeer {
|
||||||
|
gossip: entry,
|
||||||
|
tx: tx.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RelayClientMessage::ListPeers => {
|
||||||
|
let peers: Vec<GossipEntry> = registry
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.map(|p| p.gossip.clone())
|
||||||
|
.collect();
|
||||||
|
let _ = tx.send(RelayServerMessage::PeerList { peers }).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
RelayClientMessage::Forward { to, envelope } => {
|
||||||
|
let reg = registry.read().await;
|
||||||
|
if let Some(dest) = reg.get(&to) {
|
||||||
|
let from = my_peer_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| PeerId("unknown".into()));
|
||||||
|
let _ = dest
|
||||||
|
.tx
|
||||||
|
.send(RelayServerMessage::Forwarded { from, envelope })
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
let _ = tx
|
||||||
|
.send(RelayServerMessage::Error {
|
||||||
|
message: format!("Peer {to} not registered"),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up on disconnect
|
||||||
|
if let Some(pid) = my_peer_id {
|
||||||
|
registry.write().await.remove(&pid);
|
||||||
|
info!("Peer {pid} ({peer_addr}) unregistered");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user