diff --git a/Cargo.lock b/Cargo.lock index 4ab571e..7feaa80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,12 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "2.9.4" @@ -312,6 +318,7 @@ dependencies = [ "md5", "rcgen", "rustls 0.21.12", + "rustls-pemfile", "serde", "serde_json", "sha1", @@ -382,7 +389,7 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" dependencies = [ - "base64", + "base64 0.13.1", ] [[package]] @@ -501,6 +508,15 @@ dependencies = [ "sct", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-webpki" version = "0.101.7" diff --git a/Cargo.toml b/Cargo.toml index f02f8ef..924d831 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ tracing-subscriber = { version = "0.3", features = ["fmt"] } tokio-rustls = "0.23" rustls = "0.21" rcgen = "0.9" +rustls-pemfile = "1.0" # small STUN helper uuid = { version = "1", features = ["v4"] } diff --git a/docs/deploy_tls_lxc.md b/docs/deploy_tls_lxc.md new file mode 100644 index 0000000..6ffa0a5 --- /dev/null +++ b/docs/deploy_tls_lxc.md @@ -0,0 +1,86 @@ +# Deployment: TLS (turns) in Proxmox LXC + +This document describes a minimal, practical approach to deploy `niom-turn` as a TURN/TLS +(`turns:`) server inside a Proxmox LXC container for an initial Internet-facing MVP. + +Goals +- Run `niom-turn` with a TLS listener (TCP/TLS 5349) and UDP listener (3478). +- Ensure TLS certs are provisioned securely (Let's Encrypt or mounted files). +- Harden minimal attack surface (firewall, user, systemd supervision). + +Prerequisites +- Proxmox VE host with public IPv4 address and a reserved port mapping for the LXC. +- LXC template based on Debian/Ubuntu (minimal), with rust runtime or built binary copied in. + +Ports +- UDP 3478 — STUN/TURN (required for many clients). +- TCP 5349 — TLS/TURN (turns). + +Networking notes for LXC +- Use a bridged interface so the LXC has a routable IP (recommended). +- Alternatively, NAT the required ports from the Proxmox host to the LXC. + +Certificates +- Recommended: use Let's Encrypt with a reverse proxy or certbot on the host and mount the + certs into the container at `/etc/ssl/niom-turn/` (e.g. `fullchain.pem` and `privkey.pem`). +- Alternatively, copy PEM certs into the LXC (owner root only). For testing, you can generate + a self-signed cert with `rcgen` or `openssl`. + +Systemd service (example) + +Create `/etc/systemd/system/niom-turn.service` inside the container: + +``` +[Unit] +Description=niom-turn TURN server +After=network.target + +[Service] +User=niom +Group=niom +WorkingDirectory=/opt/niom-turn +ExecStart=/opt/niom-turn/niom-turn --config /opt/niom-turn/appsettings.json +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +Sample `appsettings.json` (mount next to binary) + +```json +{ + "server": { + "bind": "0.0.0.0:3478", + "tls_cert": "/etc/ssl/niom-turn/fullchain.pem", + "tls_key": "/etc/ssl/niom-turn/privkey.pem" + }, + "credentials": [ + { "username": "testuser", "password": "secretpassword" } + ] +} +``` + +Firewall +- Allow UDP 3478 and TCP 5349. If using UFW on the container host: + +```bash +ufw allow proto udp from any to any port 3478 +ufw allow proto tcp from any to any port 5349 +``` + +Security recommendations (MVP) +- Run the server as an unprivileged user (e.g. `niom`). +- Mount TLS certs read-only and restrict permissions to the service user. +- Monitor logs and open ports; add basic rate-limiting on host if available. +- For production: use ephemeral credential minting and avoid long-lived plain passwords. + +Validation steps +1. Start server via systemd: `systemctl start niom-turn` and check `journalctl -u niom-turn`. +2. From a client (or the host), run the included `smoke_client` to test the UDP path. +3. For TLS: use `openssl s_client -connect :5349 -servername ` to verify + the cert chain and that TCP connections are accepted. + +If you want, I can prepare a `systemd` unit, a small packaging script, and CI steps that build +and copy the binary into a release tarball suitable for deploying into the LXC. Let me know +which level of automation you prefer. diff --git a/src/lib.rs b/src/lib.rs index 7c0bf5f..e2e655e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod traits; pub mod models; pub mod alloc; pub mod config; +pub mod tls; pub use crate::auth::*; pub use crate::stun::*; diff --git a/src/main.rs b/src/main.rs index bf45d21..af9b798 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,6 +62,18 @@ async fn main() -> anyhow::Result<()> { } }); + // If TLS cert/key are present in config, start a TLS-backed listener (turns) + if let (Some(cert), Some(key)) = (cfg.server.tls_cert.clone(), cfg.server.tls_key.clone()) { + let udp_for_tls = udp.clone(); + let creds_for_tls = creds.clone(); + let alloc_for_tls = alloc_mgr.clone(); + tokio::spawn(async move { + if let Err(e) = niom_turn::tls::serve_tls("0.0.0.0:5349", &cert, &key, udp_for_tls, creds_for_tls, alloc_for_tls).await { + error!("tls serve failed: {:?}", e); + } + }); + } + // keep running loop { tokio::time::sleep(std::time::Duration::from_secs(60)).await; diff --git a/src/tls.rs b/src/tls.rs new file mode 100644 index 0000000..52a272e --- /dev/null +++ b/src/tls.rs @@ -0,0 +1,180 @@ +use std::sync::Arc; +use anyhow::Context; +use tokio::net::TcpListener; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_rustls::TlsAcceptor; +use tokio_rustls::rustls::{Certificate, PrivateKey, ServerConfig}; +use std::fs::File; +use std::io::BufReader; + +use crate::auth::InMemoryStore; +use crate::traits::CredentialStore; +use crate::alloc::AllocationManager; +use crate::stun::{parse_message, build_401_response, find_message_integrity, validate_message_integrity, build_success_response, encode_xor_relayed_address}; +use crate::constants::*; + +fn load_certs(path: &str) -> anyhow::Result> { + let f = File::open(path).context("opening cert file")?; + let mut reader = BufReader::new(f); + let certs = rustls_pemfile::certs(&mut reader).context("reading certs")?; + Ok(certs.into_iter().map(Certificate).collect()) +} + +fn load_private_key(path: &str) -> anyhow::Result { + let f = File::open(path).context("opening key file")?; + let mut reader = BufReader::new(f); + let keys = rustls_pemfile::pkcs8_private_keys(&mut reader).context("reading pkcs8 keys")?; + if !keys.is_empty() { + return Ok(PrivateKey(keys[0].clone())); + } + // try RSA keys + let mut reader = BufReader::new(File::open(path)?); + let rsa_keys = rustls_pemfile::rsa_private_keys(&mut reader).context("reading rsa keys")?; + if !rsa_keys.is_empty() { + return Ok(PrivateKey(rsa_keys[0].clone())); + } + Err(anyhow::anyhow!("no private key found in {}", path)) +} + +/// Start a TLS-backed listener (turns) on the given bind address. +/// This reuses the existing STUN/TURN message handling logic, but sends replies +/// back over the TLS stream rather than UDP. +pub async fn serve_tls(bind: &str, cert_path: &str, key_path: &str, udp_sock: std::sync::Arc, creds: InMemoryStore, allocs: AllocationManager) -> anyhow::Result<()> { + let certs = load_certs(cert_path)?; + let key = load_private_key(key_path)?; + + let cfg = ServerConfig::builder() + .with_safe_defaults() + .with_no_client_auth() + .with_single_cert(certs, key)?; + + // set protocols etc if needed + let acceptor = TlsAcceptor::from(Arc::new(cfg)); + + let listener = TcpListener::bind(bind).await?; + tracing::info!("TLS listener bound to {}", bind); + + loop { + let (stream, peer) = listener.accept().await?; + let acceptor = acceptor.clone(); + let udp_clone = udp_sock.clone(); + let creds_clone = creds.clone(); + let alloc_clone = allocs.clone(); + + tokio::spawn(async move { + match acceptor.accept(stream).await { + Ok(mut tls_stream) => { + tracing::info!("accepted TLS connection from {}", peer); + let mut read_buf = vec![0u8; 4096]; + let mut buffer: Vec = Vec::new(); + loop { + match tls_stream.read(&mut read_buf).await { + Ok(0) => { + tracing::info!("TLS client {} closed connection", peer); + break; + } + Ok(n) => { + buffer.extend_from_slice(&read_buf[..n]); + // Try to extract STUN messages framed by header length + while buffer.len() >= 20 { + let len = u16::from_be_bytes([buffer[2], buffer[3]]) as usize; + let total = len + 20; + if buffer.len() < total { break; } + let chunk = buffer.drain(..total).collect::>(); + if let Ok(msg) = parse_message(&chunk) { + // process message similarly to UDP path + if let Some(_mi_attr) = find_message_integrity(&msg) { + let username_attr = msg.attributes.iter().find(|a| a.typ == ATTR_USERNAME); + if let Some(u) = username_attr { + if let Ok(username) = std::str::from_utf8(&u.value) { + let pw = creds_clone.get_password(username).await; + if let Some(password) = pw { + let valid = validate_message_integrity(&msg, &password); + if valid { + tracing::info!("MI valid for user {} on TLS", username); + if msg.header.msg_type == METHOD_ALLOCATE { + match alloc_clone.allocate_for(peer, udp_clone.clone()).await { + Ok(relay_addr) => { + let mut out = Vec::new(); + out.extend_from_slice(&RESP_BINDING_SUCCESS.to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); + out.extend_from_slice(&MAGIC_COOKIE_BYTES); + out.extend_from_slice(&msg.header.transaction_id); + let attr_val = encode_xor_relayed_address(&relay_addr, &msg.header.transaction_id); + out.extend_from_slice(&ATTR_XOR_RELAYED_ADDRESS.to_be_bytes()); + out.extend_from_slice(&((attr_val.len() as u16).to_be_bytes())); + out.extend_from_slice(&attr_val); + while (out.len() % 4) != 0 { out.extend_from_slice(&[0]); } + let total_len = (out.len() - 20) as u16; + let len_bytes = total_len.to_be_bytes(); + out[2] = len_bytes[0]; out[3] = len_bytes[1]; + if let Err(e) = tls_stream.write_all(&out).await { + tracing::error!("failed to write tls response: {:?}", e); + } + continue; + } + Err(e) => tracing::error!("allocate failed after MI valid (tls): {:?}", e), + } + } + let resp = build_success_response(&msg.header); + if let Err(e) = tls_stream.write_all(&resp).await { + tracing::error!("failed to write tls response: {:?}", e); + } + continue; + } else { + tracing::info!("MI invalid for user {} (tls)", username); + } + } else { + tracing::info!("unknown user {} (tls)", username); + } + } + } + } + if msg.header.msg_type == METHOD_ALLOCATE { + match alloc_clone.allocate_for(peer, udp_clone.clone()).await { + Ok(relay_addr) => { + let mut out = Vec::new(); + out.extend_from_slice(&RESP_BINDING_SUCCESS.to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); + out.extend_from_slice(&MAGIC_COOKIE_BYTES); + out.extend_from_slice(&msg.header.transaction_id); + let attr_val = encode_xor_relayed_address(&relay_addr, &msg.header.transaction_id); + out.extend_from_slice(&ATTR_XOR_RELAYED_ADDRESS.to_be_bytes()); + out.extend_from_slice(&((attr_val.len() as u16).to_be_bytes())); + out.extend_from_slice(&attr_val); + while (out.len() % 4) != 0 { out.extend_from_slice(&[0]); } + let total_len = (out.len() - 20) as u16; + let len_bytes = total_len.to_be_bytes(); + out[2] = len_bytes[0]; out[3] = len_bytes[1]; + if let Err(e) = tls_stream.write_all(&out).await { + tracing::error!("failed to write tls response: {:?}", e); + } + } + Err(e) => tracing::error!("allocate failed (tls): {:?}", e), + } + continue; + } + + // default: send 401 challenge + let nonce = format!("nonce-{}", uuid::Uuid::new_v4()); + let resp = build_401_response(&msg.header, "niom-turn.local", &nonce, 401); + if let Err(e) = tls_stream.write_all(&resp).await { + tracing::error!("failed to write tls 401: {:?}", e); + } + } else { + tracing::debug!("failed to parse stun message on tls from {}", peer); + } + } + } + Err(e) => { + tracing::error!("tls read error from {}: {:?}", peer, e); + break; + } + } + } + } + Err(e) => tracing::error!("TLS accept error: {:?}", e), + } + }); + } +}