turn: add TLS listener (tls.rs), expose module; docs: LXC/Proxmox TLS deployment
This commit is contained in:
parent
92d4cdbde5
commit
59d24d2c28
18
Cargo.lock
generated
18
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"] }
|
||||
|
||||
86
docs/deploy_tls_lxc.md
Normal file
86
docs/deploy_tls_lxc.md
Normal 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.
|
||||
@ -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::*;
|
||||
|
||||
12
src/main.rs
12
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;
|
||||
|
||||
180
src/tls.rs
Normal file
180
src/tls.rs
Normal 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),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user