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

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