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:
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user