turn: add TLS listener (tls.rs), expose module; docs: LXC/Proxmox TLS deployment

This commit is contained in:
ghost 2025-09-30 15:20:25 +02:00
parent 92d4cdbde5
commit 59d24d2c28
6 changed files with 297 additions and 1 deletions

18
Cargo.lock generated
View File

@ -61,6 +61,12 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.9.4" version = "2.9.4"
@ -312,6 +318,7 @@ dependencies = [
"md5", "md5",
"rcgen", "rcgen",
"rustls 0.21.12", "rustls 0.21.12",
"rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
"sha1", "sha1",
@ -382,7 +389,7 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8"
dependencies = [ dependencies = [
"base64", "base64 0.13.1",
] ]
[[package]] [[package]]
@ -501,6 +508,15 @@ dependencies = [
"sct", "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]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.101.7" version = "0.101.7"

View File

@ -23,6 +23,7 @@ tracing-subscriber = { version = "0.3", features = ["fmt"] }
tokio-rustls = "0.23" tokio-rustls = "0.23"
rustls = "0.21" rustls = "0.21"
rcgen = "0.9" rcgen = "0.9"
rustls-pemfile = "1.0"
# small STUN helper # small STUN helper
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }

86
docs/deploy_tls_lxc.md Normal file
View File

@ -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 <your-lxc-ip>:5349 -servername <your-hostname>` 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.

View File

@ -6,6 +6,7 @@ pub mod traits;
pub mod models; pub mod models;
pub mod alloc; pub mod alloc;
pub mod config; pub mod config;
pub mod tls;
pub use crate::auth::*; pub use crate::auth::*;
pub use crate::stun::*; pub use crate::stun::*;

View File

@ -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 // keep running
loop { loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await; tokio::time::sleep(std::time::Duration::from_secs(60)).await;

180
src/tls.rs Normal file
View File

@ -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<Vec<Certificate>> {
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<PrivateKey> {
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<tokio::net::UdpSocket>, 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<u8> = 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::<Vec<u8>>();
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),
}
});
}
}