feat: M1 — Noise transport, Cap'n Proto framing, Ping/Pong
Establishes the foundational transport layer for noiseml: - Noise_XX_25519_ChaChaPoly_BLAKE2s handshake (initiator + responder) via `snow`; mutual authentication of static X25519 keys guaranteed before any application data flows. - Length-prefixed frame codec (4-byte LE u32, max 65 535 B per Noise spec) implemented as a Tokio Encoder/Decoder pair. - Cap'n Proto Envelope schema with MsgType enum (Ping, Pong, and future MLS message types defined but not yet dispatched). - Server: TCP listener, one Tokio task per connection, Ping→Pong handler, fresh X25519 keypair logged at startup. - Client: `ping` subcommand — handshake, send Ping, receive Pong, print RTT, exit 0. - Integration tests: bidirectional Ping/Pong with mutual-auth verification; server keypair reuse across sequential connections. - Docker multi-stage build (rust:bookworm → debian:bookworm-slim, non-root) and docker-compose with TCP healthcheck. No MLS group state, no AS/DS, no persistence — out of scope for M1.
This commit is contained in:
32
crates/noiseml-server/Cargo.toml
Normal file
32
crates/noiseml-server/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "noiseml-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Delivery Service and Authentication Service for noiseml."
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "noiseml-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
noiseml-core = { path = "../noiseml-core" }
|
||||
noiseml-proto = { path = "../noiseml-proto" }
|
||||
|
||||
# Serialisation + RPC
|
||||
capnp = { workspace = true }
|
||||
capnp-rpc = { workspace = true }
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
# Server utilities
|
||||
dashmap = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
# Error handling
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
180
crates/noiseml-server/src/main.rs
Normal file
180
crates/noiseml-server/src/main.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
//! noiseml-server — Delivery Service + Authentication Service binary.
|
||||
//!
|
||||
//! # M1 scope
|
||||
//!
|
||||
//! Accepts Noise_XX connections over TCP and replies to `Ping` frames with
|
||||
//! `Pong`. The AS and DS RPC interfaces (Cap'n Proto RPC) are added in M2+.
|
||||
//!
|
||||
//! # Configuration
|
||||
//!
|
||||
//! | Env var | CLI flag | Default |
|
||||
//! |------------------|-------------|-----------------|
|
||||
//! | `NOISEML_LISTEN` | `--listen` | `0.0.0.0:7000` |
|
||||
//! | `RUST_LOG` | — | `info` |
|
||||
//!
|
||||
//! # Keypair lifecycle
|
||||
//!
|
||||
//! A fresh static X25519 keypair is generated at startup. The public key is
|
||||
//! logged so clients can optionally pin it. M6 replaces this with persistent
|
||||
//! key loading from SQLite.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tracing::Instrument;
|
||||
|
||||
use noiseml_core::{CodecError, CoreError, NoiseKeypair, handshake_responder};
|
||||
use noiseml_proto::{MsgType, ParsedEnvelope};
|
||||
|
||||
// ── CLI ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "noiseml-server",
|
||||
about = "noiseml Delivery Service + Authentication Service",
|
||||
version
|
||||
)]
|
||||
struct Args {
|
||||
/// TCP address to listen on.
|
||||
#[arg(long, default_value = "0.0.0.0:7000", env = "NOISEML_LISTEN")]
|
||||
listen: String,
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
// Generate a fresh static keypair for this server instance.
|
||||
// M6 will replace this with persistent key loading from SQLite.
|
||||
let keypair = Arc::new(NoiseKeypair::generate());
|
||||
|
||||
{
|
||||
let pub_bytes = keypair.public_bytes();
|
||||
tracing::info!(
|
||||
listen = %args.listen,
|
||||
public_key = %fmt_key(&pub_bytes),
|
||||
"noiseml-server starting — key is ephemeral in M1 (not persisted)"
|
||||
);
|
||||
}
|
||||
|
||||
let listener = TcpListener::bind(&args.listen)
|
||||
.await
|
||||
.with_context(|| format!("failed to bind to {}", args.listen))?;
|
||||
|
||||
tracing::info!(listen = %args.listen, "accepting connections");
|
||||
|
||||
loop {
|
||||
let (stream, peer_addr) = listener.accept().await.context("accept failed")?;
|
||||
let keypair = Arc::clone(&keypair);
|
||||
|
||||
tokio::spawn(
|
||||
async move {
|
||||
match handle_connection(stream, keypair).await {
|
||||
Ok(()) => tracing::debug!("connection closed cleanly"),
|
||||
Err(e) => tracing::warn!(error = %e, "connection error"),
|
||||
}
|
||||
}
|
||||
.instrument(tracing::info_span!("conn", peer = %peer_addr)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-connection handler ────────────────────────────────────────────────────
|
||||
|
||||
/// Drive a single client connection through handshake and M1 message loop.
|
||||
///
|
||||
/// Returns `Ok(())` on any clean or expected disconnection.
|
||||
/// Returns `Err` only for unexpected Noise or decryption failures.
|
||||
async fn handle_connection(
|
||||
stream: TcpStream,
|
||||
keypair: Arc<NoiseKeypair>,
|
||||
) -> Result<(), CoreError> {
|
||||
let mut transport = handshake_responder(stream, &keypair).await?;
|
||||
|
||||
{
|
||||
let remote = transport
|
||||
.remote_static_public_key()
|
||||
.map(fmt_key)
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
tracing::info!(remote_key = %remote, "Noise_XX handshake complete");
|
||||
}
|
||||
|
||||
loop {
|
||||
let env = match transport.recv_envelope().await {
|
||||
Ok(env) => env,
|
||||
|
||||
// Clean EOF: the peer closed the connection gracefully.
|
||||
Err(CoreError::ConnectionClosed) => {
|
||||
tracing::debug!("peer disconnected");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Unclean TCP close (RST / unexpected EOF): treat as normal disconnect.
|
||||
Err(CoreError::Codec(CodecError::Io(ref e)))
|
||||
if matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::UnexpectedEof
|
||||
| std::io::ErrorKind::BrokenPipe
|
||||
) =>
|
||||
{
|
||||
tracing::debug!(io_kind = %e.kind(), "peer disconnected (unclean)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
match env.msg_type {
|
||||
MsgType::Ping => {
|
||||
tracing::debug!("ping → pong");
|
||||
transport
|
||||
.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Pong,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: current_timestamp_ms(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
// All other message types are silently ignored in M1.
|
||||
// M2 adds AS/DS RPC dispatch here.
|
||||
_ => {
|
||||
tracing::warn!("unexpected message type in M1 — ignoring");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Format the first 4 bytes of a key as hex with a trailing ellipsis.
|
||||
fn fmt_key(key: &[u8]) -> String {
|
||||
if key.len() < 4 {
|
||||
return format!("{key:02x?}");
|
||||
}
|
||||
format!("{:02x}{:02x}{:02x}{:02x}…", key[0], key[1], key[2], key[3])
|
||||
}
|
||||
|
||||
/// Return the current Unix timestamp in milliseconds.
|
||||
///
|
||||
/// Falls back to 0 if the system clock predates the Unix epoch (pathological).
|
||||
fn current_timestamp_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
Reference in New Issue
Block a user