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:
Fredrik Johansson
2026-06-21 16:32:36 +02:00
commit d2c06680f7
21 changed files with 2797 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/target
**/*.rs.bk
.env
*.identity.json
.local-dev/

9
.vscode/extensions.json vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

23
Cargo.toml Normal file
View 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
View 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.
- ~400600 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 (1050 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(&reg)? + "\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
View 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
View 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
View 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
View 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(())
}