Introduced AuthManager with signed nonce handling and long-term credential validation.
This commit is contained in:
parent
c77e95afdd
commit
7169ed0d1e
38
README.md
38
README.md
@ -8,20 +8,22 @@ Goals
|
|||||||
- Start with a minimal, well-tested parsing/utility layer and an in-memory credential store interface that can be replaced later.
|
- Start with a minimal, well-tested parsing/utility layer and an in-memory credential store interface that can be replaced later.
|
||||||
|
|
||||||
Current status
|
Current status
|
||||||
- UDP listener on 0.0.0.0:3478 (STUN/TURN) implemented.
|
- UDP listener on 0.0.0.0:3478 (STUN/TURN) with Allocate, CreatePermission, ChannelBind, and Send flows forwarding traffic via relay sockets.
|
||||||
|
- Long-term authentication with REALM/NONCE challenges and MESSAGE-INTEGRITY validation driven by `AuthManager`.
|
||||||
- STUN message parser + builder in `src/stun.rs`.
|
- STUN message parser + builder in `src/stun.rs`.
|
||||||
- CredentialStore trait + in-memory implementation in `src/auth.rs`.
|
- Optional TLS listener (0.0.0.0:5349) mirrors the UDP path for `turns:` clients.
|
||||||
- Minimal logic: on any STUN request, server replies with a 401 challenge (REALM + NONCE).
|
|
||||||
|
|
||||||
Design
|
Design
|
||||||
- Modules
|
- Modules
|
||||||
- `stun.rs` - STUN/TURN message parsing and builders.
|
- `stun.rs` – STUN/TURN message parsing, MESSAGE-INTEGRITY helpers, and response builders.
|
||||||
- `auth.rs` - CredentialStore trait and an `InMemoryStore` impl. Use the trait to swap for DB-backed stores later.
|
- `auth.rs` – `AuthManager` orchestrates nonce minting, realm checking, and key derivation using the pluggable `CredentialStore` (default: `InMemoryStore`).
|
||||||
- `main.rs` - Bootstraps UDP listener, parses requests, and emits challenges for auth.
|
- `alloc.rs` – Relay allocation management with permission and channel tracking.
|
||||||
|
- `main.rs` / `tls.rs` – Runtime wiring for UDP and TLS listeners using the shared authentication + allocation stack.
|
||||||
|
|
||||||
CredentialStore interface
|
Authentication & credential store
|
||||||
- `CredentialStore` is an async trait with `get_password(username) -> Option<String>`.
|
- `CredentialStore` is an async trait with `get_password(username) -> Option<String>` used by `AuthManager`.
|
||||||
- The default `InMemoryStore` is provided for tests and local dev. Swap in a production store by implementing the trait.
|
- `AuthManager` derives the RFC long-term key (`MD5(username:realm:password)`) and validates MESSAGE-INTEGRITY while issuing signed, timestamped nonces.
|
||||||
|
- The default `InMemoryStore` is provided for tests and local dev. Swap in a production store by implementing the trait and passing it to `AuthManager`.
|
||||||
|
|
||||||
How to build
|
How to build
|
||||||
|
|
||||||
@ -51,11 +53,10 @@ Security / Deployment
|
|||||||
- Ensure UDP and TCP/TLS ports (3478/5349) are reachable from the internet when used as a public TURN server.
|
- Ensure UDP and TCP/TLS ports (3478/5349) are reachable from the internet when used as a public TURN server.
|
||||||
|
|
||||||
Auth caveat
|
Auth caveat
|
||||||
- The current in-repo long-term auth implementation is intentionally minimal for the MVP and
|
- The current implementation intentionally keeps things simple: credentials live in-memory, A1 keys
|
||||||
uses legacy constructs (A1/MD5 derivation + HMAC-SHA1 MESSAGE-INTEGRITY). MD5 is not recommended
|
are derived via MD5 for RFC compatibility, and nonces are signed with HMAC-SHA1. Replace these
|
||||||
for new secure systems — this is present for RFC compatibility and testing only. We will replace
|
pieces (Argon2-backed store, modern KDFs, nonce rotation) before production rollout. See
|
||||||
this with a secure credential workflow (ephemeral/REST credentials, PBKDF/KDF storage, or mTLS)
|
`src/auth.rs` for the pluggable surface.
|
||||||
before any production deployment. See `src/auth.rs` for the current simple store and helpers.
|
|
||||||
|
|
||||||
Milestone 1 — Protocol Backlog
|
Milestone 1 — Protocol Backlog
|
||||||
------------------------------
|
------------------------------
|
||||||
@ -154,11 +155,16 @@ Das Projekt kann eine JSON-Konfigdatei `appsettings.json` im Arbeitsverzeichnis
|
|||||||
"username": "testuser",
|
"username": "testuser",
|
||||||
"password": "secretpassword"
|
"password": "secretpassword"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"auth": {
|
||||||
|
"realm": "niom-turn.local",
|
||||||
|
"nonce_secret": null,
|
||||||
|
"nonce_ttl_seconds": 300
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Wenn `appsettings.json` vorhanden ist, verwendet der Server die `server.bind` Adresse und befüllt den anfänglichen Credential-Store aus dem `credentials`-Array. Falls die Datei fehlt, verwendet der Server die internen Defaults (Bind `0.0.0.0:3478` und Demo-Cred `testuser`).
|
Wenn `appsettings.json` vorhanden ist, verwendet der Server die `server.bind` Adresse, befüllt den Credential-Store aus dem `credentials`-Array und übernimmt zusätzlich Realm/Nonce-Einstellungen aus `auth`. Falls die Datei fehlt, verwendet der Server die internen Defaults (Bind `0.0.0.0:3478`, Demo-Cred `testuser`, Realm `niom-turn.local`).
|
||||||
|
|
||||||
Deployment & TLS / Long-term Auth roadmap
|
Deployment & TLS / Long-term Auth roadmap
|
||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
|
|||||||
@ -9,5 +9,10 @@
|
|||||||
"username": "testuser",
|
"username": "testuser",
|
||||||
"password": "secretpassword"
|
"password": "secretpassword"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"auth": {
|
||||||
|
"realm": "niom-turn.local",
|
||||||
|
"nonce_secret": null,
|
||||||
|
"nonce_ttl_seconds": 300
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
215
src/auth.rs
215
src/auth.rs
@ -1,8 +1,16 @@
|
|||||||
//! Authentication helpers and the in-memory credential store used for the MVP server.
|
//! Authentication helpers and the in-memory credential store used for the MVP server.
|
||||||
//! Backlog: Argon2-backed storage, nonce lifecycle, and integration with persistent secrets.
|
//! Backlog: Argon2-backed storage, nonce lifecycle, and integration with persistent secrets.
|
||||||
|
use crate::config::AuthOptions;
|
||||||
|
use crate::constants::{ATTR_NONCE, ATTR_REALM, ATTR_USERNAME};
|
||||||
|
use crate::models::stun::StunMessage;
|
||||||
|
use crate::stun::{find_message_integrity, validate_message_integrity};
|
||||||
use crate::traits::CredentialStore;
|
use crate::traits::CredentialStore;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha1::Sha1;
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// Simple in-memory credential store for MVP
|
/// Simple in-memory credential store for MVP
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
@ -32,6 +40,213 @@ impl CredentialStore for InMemoryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authentication settings resolved from configuration for runtime usage.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AuthSettings {
|
||||||
|
pub realm: String,
|
||||||
|
pub nonce_secret: Vec<u8>,
|
||||||
|
pub nonce_ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthSettings {
|
||||||
|
pub fn from_options(opts: &AuthOptions) -> Self {
|
||||||
|
let secret = opts
|
||||||
|
.nonce_secret
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||||
|
// Ensure TTL does not collapse to zero so challenges stay valid briefly.
|
||||||
|
let ttl = Duration::from_secs(opts.nonce_ttl_seconds.max(60));
|
||||||
|
Self {
|
||||||
|
realm: opts.realm.clone(),
|
||||||
|
nonce_secret: secret.into_bytes(),
|
||||||
|
nonce_ttl: ttl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of validating authentication attributes on an incoming STUN/TURN request.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum AuthStatus {
|
||||||
|
Granted { username: String },
|
||||||
|
Challenge { nonce: String },
|
||||||
|
StaleNonce { nonce: String },
|
||||||
|
Reject { code: u16, reason: &'static str },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orchestrates STUN/TURN long-term credential validation for the server.
|
||||||
|
pub struct AuthManager<S: CredentialStore + Clone> {
|
||||||
|
store: S,
|
||||||
|
settings: AuthSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: CredentialStore + Clone> Clone for AuthManager<S> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
store: self.store.clone(),
|
||||||
|
settings: self.settings.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: CredentialStore + Clone> AuthManager<S> {
|
||||||
|
pub fn new(store: S, opts: &AuthOptions) -> Self {
|
||||||
|
Self {
|
||||||
|
store,
|
||||||
|
settings: AuthSettings::from_options(opts),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn realm(&self) -> &str {
|
||||||
|
&self.settings.realm
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspect a parsed STUN/TURN message and determine whether credentials are acceptable.
|
||||||
|
pub async fn authenticate(&self, msg: &StunMessage, peer: &SocketAddr) -> AuthStatus {
|
||||||
|
if find_message_integrity(msg).is_none() {
|
||||||
|
// Client has not yet computed MESSAGE-INTEGRITY; ask it to retry with credentials.
|
||||||
|
return AuthStatus::Challenge {
|
||||||
|
nonce: self.mint_nonce(peer),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = match self.attribute_utf8(msg, ATTR_USERNAME) {
|
||||||
|
Some(u) => u,
|
||||||
|
None => {
|
||||||
|
return AuthStatus::Challenge {
|
||||||
|
nonce: self.mint_nonce(peer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let realm = match self.attribute_utf8(msg, ATTR_REALM) {
|
||||||
|
Some(r) => r,
|
||||||
|
None => {
|
||||||
|
return AuthStatus::Challenge {
|
||||||
|
nonce: self.mint_nonce(peer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if realm != self.settings.realm {
|
||||||
|
return AuthStatus::Reject {
|
||||||
|
code: 400,
|
||||||
|
reason: "Realm Mismatch",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let nonce = match self.attribute_utf8(msg, ATTR_NONCE) {
|
||||||
|
Some(n) => n,
|
||||||
|
None => {
|
||||||
|
return AuthStatus::Challenge {
|
||||||
|
nonce: self.mint_nonce(peer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.check_nonce(&nonce, peer) {
|
||||||
|
NonceValidation::Valid => {}
|
||||||
|
NonceValidation::Expired => {
|
||||||
|
return AuthStatus::StaleNonce {
|
||||||
|
nonce: self.mint_nonce(peer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NonceValidation::Invalid => {
|
||||||
|
return AuthStatus::Challenge {
|
||||||
|
nonce: self.mint_nonce(peer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = match self.store.get_password(&username).await {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
return AuthStatus::Reject {
|
||||||
|
code: 401,
|
||||||
|
reason: "Unknown User",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = self.derive_long_term_key(&username, &password);
|
||||||
|
if !validate_message_integrity(msg, &key) {
|
||||||
|
return AuthStatus::Reject {
|
||||||
|
code: 401,
|
||||||
|
reason: "Bad Credentials",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthStatus::Granted { username }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attribute_utf8(&self, msg: &StunMessage, attr_type: u16) -> Option<String> {
|
||||||
|
msg.attributes
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.typ == attr_type)
|
||||||
|
.and_then(|attr| std::str::from_utf8(&attr.value).ok())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_long_term_key(&self, username: &str, password: &str) -> Vec<u8> {
|
||||||
|
compute_a1_md5(username, &self.settings.realm, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mint_nonce(&self, peer: &SocketAddr) -> String {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_else(|_| Duration::from_secs(0))
|
||||||
|
.as_secs();
|
||||||
|
let payload = format!("{}|{}", now, peer.ip());
|
||||||
|
let sig = self.sign_payload(payload.as_bytes());
|
||||||
|
format!("{}:{}", now, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_nonce(&self, nonce: &str, peer: &SocketAddr) -> NonceValidation {
|
||||||
|
let mut parts = nonce.splitn(2, ':');
|
||||||
|
let ts_str = parts.next();
|
||||||
|
let sig_str = parts.next();
|
||||||
|
let (ts_str, sig_str) = match (ts_str, sig_str) {
|
||||||
|
(Some(ts), Some(sig)) => (ts, sig),
|
||||||
|
_ => return NonceValidation::Invalid,
|
||||||
|
};
|
||||||
|
|
||||||
|
let timestamp = match ts_str.parse::<u64>() {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(_) => return NonceValidation::Invalid,
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_else(|_| Duration::from_secs(0))
|
||||||
|
.as_secs();
|
||||||
|
if now.saturating_sub(timestamp) > self.settings.nonce_ttl.as_secs() {
|
||||||
|
return NonceValidation::Expired;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = format!("{}|{}", timestamp, peer.ip());
|
||||||
|
let expected = self.sign_payload(payload.as_bytes());
|
||||||
|
if expected == sig_str {
|
||||||
|
NonceValidation::Valid
|
||||||
|
} else {
|
||||||
|
NonceValidation::Invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_payload(&self, payload: &[u8]) -> String {
|
||||||
|
type HmacSha1 = Hmac<Sha1>;
|
||||||
|
let mut mac = HmacSha1::new_from_slice(&self.settings.nonce_secret)
|
||||||
|
.expect("nonce secret to build hmac");
|
||||||
|
mac.update(payload);
|
||||||
|
let bytes = mac.finalize().into_bytes();
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NonceValidation {
|
||||||
|
Valid,
|
||||||
|
Expired,
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper: compute MESSAGE-INTEGRITY (HMAC-SHA1 as bytes)
|
/// Helper: compute MESSAGE-INTEGRITY (HMAC-SHA1 as bytes)
|
||||||
pub fn compute_hmac_sha1_bytes(key: &str, data: &[u8]) -> Vec<u8> {
|
pub fn compute_hmac_sha1_bytes(key: &str, data: &[u8]) -> Vec<u8> {
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
|
|||||||
@ -3,12 +3,43 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn default_realm() -> String {
|
||||||
|
"niom-turn.local".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_nonce_ttl_seconds() -> u64 {
|
||||||
|
300
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct CredentialEntry {
|
pub struct CredentialEntry {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct AuthOptions {
|
||||||
|
/// STUN/TURN realm advertised to clients when issuing challenges.
|
||||||
|
#[serde(default = "default_realm")]
|
||||||
|
pub realm: String,
|
||||||
|
/// Optional shared secret used to sign nonces; if omitted a random value is generated at runtime.
|
||||||
|
#[serde(default)]
|
||||||
|
pub nonce_secret: Option<String>,
|
||||||
|
/// Validity period for generated nonces in seconds.
|
||||||
|
#[serde(default = "default_nonce_ttl_seconds")]
|
||||||
|
pub nonce_ttl_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuthOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
realm: default_realm(),
|
||||||
|
nonce_secret: None,
|
||||||
|
nonce_ttl_seconds: default_nonce_ttl_seconds(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct ServerOptions {
|
pub struct ServerOptions {
|
||||||
/// Listen address, e.g. "0.0.0.0:3478"
|
/// Listen address, e.g. "0.0.0.0:3478"
|
||||||
@ -25,6 +56,9 @@ pub struct Config {
|
|||||||
/// Initial credentials to populate the credential store
|
/// Initial credentials to populate the credential store
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub credentials: Vec<CredentialEntry>,
|
pub credentials: Vec<CredentialEntry>,
|
||||||
|
/// Authentication behaviour advertised to clients.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth: AuthOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
|||||||
709
src/main.rs
709
src/main.rs
@ -7,14 +7,13 @@ use tracing::{error, info};
|
|||||||
|
|
||||||
// Use the library crate's public modules instead of local `mod` declarations.
|
// Use the library crate's public modules instead of local `mod` declarations.
|
||||||
use niom_turn::alloc::AllocationManager;
|
use niom_turn::alloc::AllocationManager;
|
||||||
use niom_turn::auth::InMemoryStore;
|
use niom_turn::auth::{AuthManager, AuthStatus, InMemoryStore};
|
||||||
use niom_turn::config::Config;
|
use niom_turn::config::{AuthOptions, Config};
|
||||||
use niom_turn::constants::*;
|
use niom_turn::constants::*;
|
||||||
use niom_turn::stun::{
|
use niom_turn::stun::{
|
||||||
build_401_response, build_error_response, build_success_response, decode_xor_peer_address,
|
build_401_response, build_error_response, build_success_response, decode_xor_peer_address,
|
||||||
encode_xor_relayed_address, find_message_integrity, parse_message, validate_message_integrity,
|
encode_xor_relayed_address, parse_message,
|
||||||
};
|
};
|
||||||
use niom_turn::traits::CredentialStore;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
@ -44,6 +43,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
username: "testuser".into(),
|
username: "testuser".into(),
|
||||||
password: "secretpassword".into(),
|
password: "secretpassword".into(),
|
||||||
}],
|
}],
|
||||||
|
auth: AuthOptions::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -56,6 +56,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
creds.insert(&c.username, &c.password);
|
creds.insert(&c.username, &c.password);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let auth = AuthManager::new(creds.clone(), &cfg.auth);
|
||||||
|
|
||||||
// Bind the UDP socket that receives STUN/TURN traffic from WebRTC clients.
|
// Bind the UDP socket that receives STUN/TURN traffic from WebRTC clients.
|
||||||
let udp = UdpSocket::bind(bind_addr).await?;
|
let udp = UdpSocket::bind(bind_addr).await?;
|
||||||
let udp = Arc::new(udp);
|
let udp = Arc::new(udp);
|
||||||
@ -65,10 +67,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Spawn the asynchronous packet loop that handles all UDP requests.
|
// Spawn the asynchronous packet loop that handles all UDP requests.
|
||||||
let udp_clone = udp.clone();
|
let udp_clone = udp.clone();
|
||||||
let creds_clone = creds.clone();
|
let auth_clone = auth.clone();
|
||||||
let alloc_clone = alloc_mgr.clone();
|
let alloc_clone = alloc_mgr.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = udp_reader_loop(udp_clone, creds_clone, alloc_clone).await {
|
if let Err(e) = udp_reader_loop(udp_clone, auth_clone, alloc_clone).await {
|
||||||
error!("udp loop error: {:?}", e);
|
error!("udp loop error: {:?}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -76,7 +78,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Optionally start the TLS listener so `turns:` clients can connect via TCP/TLS.
|
// Optionally start the TLS listener so `turns:` clients can connect via TCP/TLS.
|
||||||
if let (Some(cert), Some(key)) = (cfg.server.tls_cert.clone(), cfg.server.tls_key.clone()) {
|
if let (Some(cert), Some(key)) = (cfg.server.tls_cert.clone(), cfg.server.tls_key.clone()) {
|
||||||
let udp_for_tls = udp.clone();
|
let udp_for_tls = udp.clone();
|
||||||
let creds_for_tls = creds.clone();
|
let auth_for_tls = auth.clone();
|
||||||
let alloc_for_tls = alloc_mgr.clone();
|
let alloc_for_tls = alloc_mgr.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = niom_turn::tls::serve_tls(
|
if let Err(e) = niom_turn::tls::serve_tls(
|
||||||
@ -84,7 +86,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
&cert,
|
&cert,
|
||||||
&key,
|
&key,
|
||||||
udp_for_tls,
|
udp_for_tls,
|
||||||
creds_for_tls,
|
auth_for_tls,
|
||||||
alloc_for_tls,
|
alloc_for_tls,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -102,7 +104,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
async fn udp_reader_loop(
|
async fn udp_reader_loop(
|
||||||
udp: Arc<UdpSocket>,
|
udp: Arc<UdpSocket>,
|
||||||
creds: InMemoryStore,
|
auth: AuthManager<InMemoryStore>,
|
||||||
allocs: AllocationManager,
|
allocs: AllocationManager,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let mut buf = vec![0u8; 1500];
|
let mut buf = vec![0u8; 1500];
|
||||||
@ -119,377 +121,346 @@ async fn udp_reader_loop(
|
|||||||
msg.header.msg_type,
|
msg.header.msg_type,
|
||||||
len
|
len
|
||||||
);
|
);
|
||||||
// Fast-path authenticated requests when MESSAGE-INTEGRITY can be validated.
|
let requires_auth = matches!(
|
||||||
if let Some(_mi_attr) = find_message_integrity(&msg) {
|
msg.header.msg_type,
|
||||||
// For MVP we expect username attribute (USERNAME) to be present
|
METHOD_ALLOCATE
|
||||||
let username_attr = msg.attributes.iter().find(|a| a.typ == ATTR_USERNAME);
|
| METHOD_CREATE_PERMISSION
|
||||||
if let Some(u) = username_attr {
|
| METHOD_CHANNEL_BIND
|
||||||
if let Ok(username) = std::str::from_utf8(&u.value) {
|
| METHOD_SEND
|
||||||
// lookup password
|
| METHOD_REFRESH
|
||||||
let store = creds.clone();
|
);
|
||||||
let pw = store.get_password(username).await;
|
|
||||||
if let Some(password) = pw {
|
|
||||||
let valid = validate_message_integrity(&msg, &password);
|
|
||||||
if valid {
|
|
||||||
tracing::info!("MI valid for user {}", username);
|
|
||||||
// Handle authenticated Allocate to mint a relay binding for the client.
|
|
||||||
if msg.header.msg_type == METHOD_ALLOCATE {
|
|
||||||
match allocs.allocate_for(peer, udp.clone()).await {
|
|
||||||
Ok(relay_addr) => {
|
|
||||||
use bytes::BytesMut;
|
|
||||||
let mut out = BytesMut::new();
|
|
||||||
let success_type = msg.header.msg_type | CLASS_SUCCESS;
|
|
||||||
out.extend_from_slice(&success_type.to_be_bytes());
|
|
||||||
out.extend_from_slice(&0u16.to_be_bytes());
|
|
||||||
out.extend_from_slice(&MAGIC_COOKIE_U32.to_be_bytes());
|
|
||||||
out.extend_from_slice(&msg.header.transaction_id);
|
|
||||||
// RFC: XOR-RELAYED-ADDRESS (0x0016)
|
|
||||||
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];
|
|
||||||
let vec_out = out.to_vec();
|
|
||||||
tracing::info!("sending allocate success (mi-valid) -> {} bytes hex={} ", vec_out.len(), hex::encode(&vec_out));
|
|
||||||
let _ = udp.send_to(&vec_out, &peer).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(e) => tracing::error!(
|
|
||||||
"allocate failed after MI valid: {:?}",
|
|
||||||
e
|
|
||||||
),
|
|
||||||
}
|
|
||||||
} else if msg.header.msg_type == METHOD_CREATE_PERMISSION {
|
|
||||||
// Permission updates extend the list of peer addresses an allocation may forward to.
|
|
||||||
if allocs.get_allocation(&peer).is_none() {
|
|
||||||
tracing::warn!(
|
|
||||||
"create-permission without allocation from {}",
|
|
||||||
peer
|
|
||||||
);
|
|
||||||
let resp = build_error_response(
|
|
||||||
&msg.header,
|
|
||||||
437,
|
|
||||||
"Allocation Mismatch",
|
|
||||||
);
|
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut added = 0usize;
|
if requires_auth {
|
||||||
for attr in msg
|
match auth.authenticate(&msg, &peer).await {
|
||||||
.attributes
|
AuthStatus::Granted { username } => {
|
||||||
.iter()
|
tracing::debug!(
|
||||||
.filter(|a| a.typ == ATTR_XOR_PEER_ADDRESS)
|
"TURN auth ok for {} as {} (0x{:04x})",
|
||||||
{
|
peer,
|
||||||
if let Some(peer_addr) = decode_xor_peer_address(
|
username,
|
||||||
&attr.value,
|
msg.header.msg_type
|
||||||
&msg.header.transaction_id,
|
);
|
||||||
) {
|
}
|
||||||
match allocs.add_permission(peer, peer_addr) {
|
AuthStatus::Challenge { nonce } => {
|
||||||
Ok(()) => {
|
let resp = build_401_response(
|
||||||
tracing::info!(
|
&msg.header,
|
||||||
"added permission for {} -> {}",
|
auth.realm(),
|
||||||
peer,
|
&nonce,
|
||||||
peer_addr
|
401,
|
||||||
);
|
"Unauthorized",
|
||||||
added += 1;
|
);
|
||||||
}
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
Err(e) => {
|
continue;
|
||||||
tracing::error!("failed to persist permission {} -> {}: {:?}", peer, peer_addr, e);
|
}
|
||||||
}
|
AuthStatus::StaleNonce { nonce } => {
|
||||||
}
|
let resp = build_401_response(
|
||||||
} else {
|
&msg.header,
|
||||||
tracing::warn!(
|
auth.realm(),
|
||||||
"invalid XOR-PEER-ADDRESS in request from {}",
|
&nonce,
|
||||||
peer
|
438,
|
||||||
);
|
"Stale Nonce",
|
||||||
}
|
);
|
||||||
}
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
AuthStatus::Reject { code, reason } => {
|
||||||
|
let resp = build_error_response(&msg.header, code, reason);
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if added == 0 {
|
match msg.header.msg_type {
|
||||||
let resp = build_error_response(
|
METHOD_ALLOCATE => {
|
||||||
&msg.header,
|
use bytes::BytesMut;
|
||||||
400,
|
match allocs.allocate_for(peer, udp.clone()).await {
|
||||||
"No valid XOR-PEER-ADDRESS",
|
Ok(relay_addr) => {
|
||||||
);
|
let mut out = BytesMut::new();
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
let success_type = msg.header.msg_type | CLASS_SUCCESS;
|
||||||
} else {
|
out.extend_from_slice(&success_type.to_be_bytes());
|
||||||
let resp = build_success_response(&msg.header);
|
out.extend_from_slice(&0u16.to_be_bytes());
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
out.extend_from_slice(&MAGIC_COOKIE_U32.to_be_bytes());
|
||||||
}
|
out.extend_from_slice(&msg.header.transaction_id);
|
||||||
continue;
|
let attr_val = encode_xor_relayed_address(
|
||||||
} else if msg.header.msg_type == METHOD_CHANNEL_BIND {
|
&relay_addr,
|
||||||
let allocation = match allocs.get_allocation(&peer) {
|
&msg.header.transaction_id,
|
||||||
Some(a) => a,
|
);
|
||||||
None => {
|
out.extend_from_slice(&ATTR_XOR_RELAYED_ADDRESS.to_be_bytes());
|
||||||
tracing::warn!(
|
out.extend_from_slice(&((attr_val.len() as u16).to_be_bytes()));
|
||||||
"channel-bind without allocation from {}",
|
out.extend_from_slice(&attr_val);
|
||||||
peer
|
while (out.len() % 4) != 0 {
|
||||||
);
|
out.extend_from_slice(&[0]);
|
||||||
let resp = build_error_response(
|
|
||||||
&msg.header,
|
|
||||||
437,
|
|
||||||
"Allocation Mismatch",
|
|
||||||
);
|
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let channel_attr = msg
|
|
||||||
.attributes
|
|
||||||
.iter()
|
|
||||||
.find(|a| a.typ == ATTR_CHANNEL_NUMBER);
|
|
||||||
let peer_attr = msg
|
|
||||||
.attributes
|
|
||||||
.iter()
|
|
||||||
.find(|a| a.typ == ATTR_XOR_PEER_ADDRESS);
|
|
||||||
|
|
||||||
let channel = match channel_attr.and_then(|attr| {
|
|
||||||
if attr.value.len() >= 4 {
|
|
||||||
Some(u16::from_be_bytes([attr.value[0], attr.value[1]]))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Some(c) => c,
|
|
||||||
None => {
|
|
||||||
let resp = build_error_response(
|
|
||||||
&msg.header,
|
|
||||||
400,
|
|
||||||
"Missing CHANNEL-NUMBER",
|
|
||||||
);
|
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if channel < 0x4000 || channel > 0x7FFF {
|
|
||||||
let resp = build_error_response(
|
|
||||||
&msg.header,
|
|
||||||
400,
|
|
||||||
"Channel Out Of Range",
|
|
||||||
);
|
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let peer_addr = match peer_attr.and_then(|attr| {
|
|
||||||
decode_xor_peer_address(
|
|
||||||
&attr.value,
|
|
||||||
&msg.header.transaction_id,
|
|
||||||
)
|
|
||||||
}) {
|
|
||||||
Some(addr) => addr,
|
|
||||||
None => {
|
|
||||||
let resp = build_error_response(
|
|
||||||
&msg.header,
|
|
||||||
400,
|
|
||||||
"Missing XOR-PEER-ADDRESS",
|
|
||||||
);
|
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !allocation.is_peer_allowed(&peer_addr) {
|
|
||||||
let resp = build_error_response(
|
|
||||||
&msg.header,
|
|
||||||
403,
|
|
||||||
"Peer Not Permitted",
|
|
||||||
);
|
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match allocs.add_channel_binding(peer, channel, peer_addr) {
|
|
||||||
Ok(()) => {
|
|
||||||
tracing::info!(
|
|
||||||
"bound channel 0x{:04x} for {} -> {}",
|
|
||||||
channel,
|
|
||||||
peer,
|
|
||||||
peer_addr
|
|
||||||
);
|
|
||||||
let resp = build_success_response(&msg.header);
|
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(
|
|
||||||
"failed to add channel binding {} -> {} (channel 0x{:04x}): {:?}",
|
|
||||||
peer, peer_addr, channel, e
|
|
||||||
);
|
|
||||||
let resp = build_error_response(
|
|
||||||
&msg.header,
|
|
||||||
500,
|
|
||||||
"Channel Binding Failed",
|
|
||||||
);
|
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
} else if msg.header.msg_type == METHOD_SEND {
|
|
||||||
let allocation = match allocs.get_allocation(&peer) {
|
|
||||||
Some(a) => a,
|
|
||||||
None => {
|
|
||||||
tracing::warn!("send without allocation from {}", peer);
|
|
||||||
let resp = build_error_response(
|
|
||||||
&msg.header,
|
|
||||||
437,
|
|
||||||
"Allocation Mismatch",
|
|
||||||
);
|
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let peer_attr = msg
|
|
||||||
.attributes
|
|
||||||
.iter()
|
|
||||||
.find(|a| a.typ == ATTR_XOR_PEER_ADDRESS);
|
|
||||||
let data_attr =
|
|
||||||
msg.attributes.iter().find(|a| a.typ == ATTR_DATA);
|
|
||||||
|
|
||||||
let peer_addr = match peer_attr.and_then(|attr| {
|
|
||||||
decode_xor_peer_address(
|
|
||||||
&attr.value,
|
|
||||||
&msg.header.transaction_id,
|
|
||||||
)
|
|
||||||
}) {
|
|
||||||
Some(addr) => addr,
|
|
||||||
None => {
|
|
||||||
let resp = build_error_response(
|
|
||||||
&msg.header,
|
|
||||||
400,
|
|
||||||
"Missing XOR-PEER-ADDRESS",
|
|
||||||
);
|
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let data_attr = match data_attr {
|
|
||||||
Some(attr) => attr,
|
|
||||||
None => {
|
|
||||||
let resp = build_error_response(
|
|
||||||
&msg.header,
|
|
||||||
400,
|
|
||||||
"Missing DATA Attribute",
|
|
||||||
);
|
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !allocation.is_peer_allowed(&peer_addr) {
|
|
||||||
let resp = build_error_response(
|
|
||||||
&msg.header,
|
|
||||||
403,
|
|
||||||
"Peer Not Permitted",
|
|
||||||
);
|
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match allocation.send_to_peer(peer_addr, &data_attr.value).await
|
|
||||||
{
|
|
||||||
Ok(sent) => {
|
|
||||||
tracing::info!(
|
|
||||||
"forwarded {} bytes from {} to peer {}",
|
|
||||||
sent,
|
|
||||||
peer,
|
|
||||||
peer_addr
|
|
||||||
);
|
|
||||||
let resp = build_success_response(&msg.header);
|
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(
|
|
||||||
"failed to send payload from {} to {}: {:?}",
|
|
||||||
peer,
|
|
||||||
peer_addr,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
let resp = build_error_response(
|
|
||||||
&msg.header,
|
|
||||||
500,
|
|
||||||
"Peer Send Failed",
|
|
||||||
);
|
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
// Non-specific success path: echo a success response so the client continues handshake.
|
let total_len = (out.len() - 20) as u16;
|
||||||
let resp = build_success_response(&msg.header);
|
let len_bytes = total_len.to_be_bytes();
|
||||||
|
out[2] = len_bytes[0];
|
||||||
|
out[3] = len_bytes[1];
|
||||||
|
let vec_out = out.to_vec();
|
||||||
|
tracing::info!(
|
||||||
|
"sending allocate success -> {} bytes hex={} ",
|
||||||
|
vec_out.len(),
|
||||||
|
hex::encode(&vec_out)
|
||||||
|
);
|
||||||
|
let _ = udp.send_to(&vec_out, &peer).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("allocate failed: {:?}", e);
|
||||||
|
let resp =
|
||||||
|
build_error_response(&msg.header, 500, "Allocate Failed");
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
METHOD_CREATE_PERMISSION => {
|
||||||
|
if allocs.get_allocation(&peer).is_none() {
|
||||||
|
tracing::warn!("create-permission without allocation from {}", peer);
|
||||||
|
let resp =
|
||||||
|
build_error_response(&msg.header, 437, "Allocation Mismatch");
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut added = 0usize;
|
||||||
|
for attr in msg
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.typ == ATTR_XOR_PEER_ADDRESS)
|
||||||
|
{
|
||||||
|
if let Some(peer_addr) =
|
||||||
|
decode_xor_peer_address(&attr.value, &msg.header.transaction_id)
|
||||||
|
{
|
||||||
|
match allocs.add_permission(peer, peer_addr) {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(
|
||||||
|
"added permission for {} -> {}",
|
||||||
|
peer,
|
||||||
|
peer_addr
|
||||||
|
);
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
"failed to persist permission {} -> {}: {:?}",
|
||||||
|
peer,
|
||||||
|
peer_addr,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("invalid XOR-PEER-ADDRESS in request from {}", peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if added == 0 {
|
||||||
|
let resp =
|
||||||
|
build_error_response(&msg.header, 400, "No valid XOR-PEER-ADDRESS");
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
} else {
|
||||||
|
let resp = build_success_response(&msg.header);
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
METHOD_CHANNEL_BIND => {
|
||||||
|
let allocation = match allocs.get_allocation(&peer) {
|
||||||
|
Some(a) => a,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("channel-bind without allocation from {}", peer);
|
||||||
|
let resp =
|
||||||
|
build_error_response(&msg.header, 437, "Allocation Mismatch");
|
||||||
let _ = udp.send_to(&resp, &peer).await;
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
continue;
|
continue;
|
||||||
} else {
|
|
||||||
tracing::info!("MI invalid for user {}", username);
|
|
||||||
}
|
}
|
||||||
} else {
|
};
|
||||||
tracing::info!("unknown user {}", username);
|
|
||||||
|
let channel_attr =
|
||||||
|
msg.attributes.iter().find(|a| a.typ == ATTR_CHANNEL_NUMBER);
|
||||||
|
let peer_attr = msg
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.typ == ATTR_XOR_PEER_ADDRESS);
|
||||||
|
|
||||||
|
let channel = match channel_attr.and_then(|attr| {
|
||||||
|
if attr.value.len() >= 4 {
|
||||||
|
Some(u16::from_be_bytes([attr.value[0], attr.value[1]]))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
let resp = build_error_response(
|
||||||
|
&msg.header,
|
||||||
|
400,
|
||||||
|
"Missing CHANNEL-NUMBER",
|
||||||
|
);
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if channel < 0x4000 || channel > 0x7FFF {
|
||||||
|
let resp =
|
||||||
|
build_error_response(&msg.header, 400, "Channel Out Of Range");
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let peer_addr = match peer_attr.and_then(|attr| {
|
||||||
|
decode_xor_peer_address(&attr.value, &msg.header.transaction_id)
|
||||||
|
}) {
|
||||||
|
Some(addr) => addr,
|
||||||
|
None => {
|
||||||
|
let resp = build_error_response(
|
||||||
|
&msg.header,
|
||||||
|
400,
|
||||||
|
"Missing XOR-PEER-ADDRESS",
|
||||||
|
);
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !allocation.is_peer_allowed(&peer_addr) {
|
||||||
|
let resp = build_error_response(&msg.header, 403, "Peer Not Permitted");
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match allocs.add_channel_binding(peer, channel, peer_addr) {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(
|
||||||
|
"bound channel 0x{:04x} for {} -> {}",
|
||||||
|
channel,
|
||||||
|
peer,
|
||||||
|
peer_addr
|
||||||
|
);
|
||||||
|
let resp = build_success_response(&msg.header);
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
"failed to add channel binding {} -> {} (channel 0x{:04x}): {:?}",
|
||||||
|
peer,
|
||||||
|
peer_addr,
|
||||||
|
channel,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
let resp = build_error_response(
|
||||||
|
&msg.header,
|
||||||
|
500,
|
||||||
|
"Channel Binding Failed",
|
||||||
|
);
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
METHOD_SEND => {
|
||||||
|
let allocation = match allocs.get_allocation(&peer) {
|
||||||
|
Some(a) => a,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("send without allocation from {}", peer);
|
||||||
|
let resp =
|
||||||
|
build_error_response(&msg.header, 437, "Allocation Mismatch");
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let peer_attr = msg
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.typ == ATTR_XOR_PEER_ADDRESS);
|
||||||
|
let data_attr = msg.attributes.iter().find(|a| a.typ == ATTR_DATA);
|
||||||
|
|
||||||
|
let peer_addr = match peer_attr.and_then(|attr| {
|
||||||
|
decode_xor_peer_address(&attr.value, &msg.header.transaction_id)
|
||||||
|
}) {
|
||||||
|
Some(addr) => addr,
|
||||||
|
None => {
|
||||||
|
let resp = build_error_response(
|
||||||
|
&msg.header,
|
||||||
|
400,
|
||||||
|
"Missing XOR-PEER-ADDRESS",
|
||||||
|
);
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let data_attr = match data_attr {
|
||||||
|
Some(attr) => attr,
|
||||||
|
None => {
|
||||||
|
let resp = build_error_response(
|
||||||
|
&msg.header,
|
||||||
|
400,
|
||||||
|
"Missing DATA Attribute",
|
||||||
|
);
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !allocation.is_peer_allowed(&peer_addr) {
|
||||||
|
let resp = build_error_response(&msg.header, 403, "Peer Not Permitted");
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match allocation.send_to_peer(peer_addr, &data_attr.value).await {
|
||||||
|
Ok(sent) => {
|
||||||
|
tracing::info!(
|
||||||
|
"forwarded {} bytes from {} to peer {}",
|
||||||
|
sent,
|
||||||
|
peer,
|
||||||
|
peer_addr
|
||||||
|
);
|
||||||
|
let resp = build_success_response(&msg.header);
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
"failed to send payload from {} to {}: {:?}",
|
||||||
|
peer,
|
||||||
|
peer_addr,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
let resp =
|
||||||
|
build_error_response(&msg.header, 500, "Peer Send Failed");
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
METHOD_REFRESH => {
|
||||||
|
// Refresh support is still MVP-level; acknowledge so clients extend allocations.
|
||||||
|
let resp = build_success_response(&msg.header);
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let resp = build_error_response(&msg.header, 420, "Unknown TURN Method");
|
||||||
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Allow unauthenticated Allocate to fall back to challenge/early success for now (MVP compatibility).
|
|
||||||
if msg.header.msg_type == METHOD_ALLOCATE {
|
|
||||||
// If we reach here without MI, still attempt allocation but we will send a 401 earlier
|
|
||||||
let relay = allocs.allocate_for(peer, udp.clone()).await;
|
|
||||||
match relay {
|
|
||||||
Ok(relay_addr) => {
|
|
||||||
use bytes::BytesMut;
|
|
||||||
let mut out = BytesMut::new();
|
|
||||||
let success_type = msg.header.msg_type | CLASS_SUCCESS;
|
|
||||||
out.extend_from_slice(&success_type.to_be_bytes());
|
|
||||||
out.extend_from_slice(&0u16.to_be_bytes());
|
|
||||||
out.extend_from_slice(&MAGIC_COOKIE_U32.to_be_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];
|
|
||||||
let vec_out = out.to_vec();
|
|
||||||
tracing::info!(
|
|
||||||
"sending allocate success (no-mi) -> {} bytes hex={} ",
|
|
||||||
vec_out.len(),
|
|
||||||
hex::encode(&vec_out)
|
|
||||||
);
|
|
||||||
let _ = udp.send_to(&vec_out, &peer).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("allocate failed: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything else receives a 401 challenge so the client can retry with credentials.
|
match msg.header.msg_type {
|
||||||
let nonce = format!("nonce-{}", uuid::Uuid::new_v4());
|
METHOD_BINDING => {
|
||||||
let resp = build_401_response(&msg.header, "niom-turn.local", &nonce, 401);
|
let resp = build_success_response(&msg.header);
|
||||||
if let Err(e) = udp.send_to(&resp, &peer).await {
|
let _ = udp.send_to(&resp, &peer).await;
|
||||||
error!("failed to send 401: {:?}", e);
|
}
|
||||||
|
_ => {
|
||||||
|
let nonce = auth.mint_nonce(&peer);
|
||||||
|
let resp =
|
||||||
|
build_401_response(&msg.header, auth.realm(), &nonce, 401, "Unauthorized");
|
||||||
|
if let Err(e) = udp.send_to(&resp, &peer).await {
|
||||||
|
error!("failed to send 401: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("Non-STUN or parse error from {} len={}", peer, len);
|
tracing::debug!("Non-STUN or parse error from {} len={}", peer, len);
|
||||||
|
|||||||
38
src/stun.rs
38
src/stun.rs
@ -66,8 +66,14 @@ pub fn parse_message(buf: &[u8]) -> Result<StunMessage, ParseError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a minimal 401 error response (REALM + NONCE). Returns the bytes to send.
|
/// Build a challenge error response with REALM + NONCE and the provided error code.
|
||||||
pub fn build_401_response(req: &StunHeader, realm: &str, nonce: &str, _err_code: u16) -> Vec<u8> {
|
pub fn build_401_response(
|
||||||
|
req: &StunHeader,
|
||||||
|
realm: &str,
|
||||||
|
nonce: &str,
|
||||||
|
err_code: u16,
|
||||||
|
reason: &str,
|
||||||
|
) -> Vec<u8> {
|
||||||
use bytes::BytesMut;
|
use bytes::BytesMut;
|
||||||
let mut buf = BytesMut::new();
|
let mut buf = BytesMut::new();
|
||||||
// Error response type for TURN: reuse the request method with error class bits set
|
// Error response type for TURN: reuse the request method with error class bits set
|
||||||
@ -77,6 +83,20 @@ pub fn build_401_response(req: &StunHeader, realm: &str, nonce: &str, _err_code:
|
|||||||
buf.extend_from_slice(&MAGIC_COOKIE_BYTES);
|
buf.extend_from_slice(&MAGIC_COOKIE_BYTES);
|
||||||
buf.extend_from_slice(&req.transaction_id);
|
buf.extend_from_slice(&req.transaction_id);
|
||||||
|
|
||||||
|
// ERROR-CODE attribute (RFC 5389 section 15.6)
|
||||||
|
let mut err_value = Vec::new();
|
||||||
|
let class = (err_code / 100) as u8;
|
||||||
|
let number = (err_code % 100) as u8;
|
||||||
|
err_value.extend_from_slice(&[0, 0, class, number]);
|
||||||
|
err_value.extend_from_slice(reason.as_bytes());
|
||||||
|
|
||||||
|
buf.extend_from_slice(&ATTR_ERROR_CODE.to_be_bytes());
|
||||||
|
buf.extend_from_slice(&(err_value.len() as u16).to_be_bytes());
|
||||||
|
buf.extend_from_slice(&err_value);
|
||||||
|
while (buf.len() % 4) != 0 {
|
||||||
|
buf.extend_from_slice(&[0]);
|
||||||
|
}
|
||||||
|
|
||||||
// REALM (RFC attr)
|
// REALM (RFC attr)
|
||||||
let realm_bytes = realm.as_bytes();
|
let realm_bytes = realm.as_bytes();
|
||||||
buf.extend_from_slice(&ATTR_REALM.to_be_bytes());
|
buf.extend_from_slice(&ATTR_REALM.to_be_bytes());
|
||||||
@ -147,7 +167,7 @@ pub fn find_message_integrity(msg: &StunMessage) -> Option<&StunAttribute> {
|
|||||||
/// Validate MESSAGE-INTEGRITY using provided key (password). Returns true if valid.
|
/// Validate MESSAGE-INTEGRITY using provided key (password). Returns true if valid.
|
||||||
/// Note: This is a simplified validator that assumes the MESSAGE-INTEGRITY attribute exists and
|
/// Note: This is a simplified validator that assumes the MESSAGE-INTEGRITY attribute exists and
|
||||||
/// that the message bytes passed are the full STUN message (including attributes).
|
/// that the message bytes passed are the full STUN message (including attributes).
|
||||||
pub fn validate_message_integrity(msg: &StunMessage, key: &str) -> bool {
|
pub fn validate_message_integrity(msg: &StunMessage, key: &[u8]) -> bool {
|
||||||
if let Some(mi) = find_message_integrity(msg) {
|
if let Some(mi) = find_message_integrity(msg) {
|
||||||
// MESSAGE-INTEGRITY attribute value is 20 bytes (HMAC-SHA1)
|
// MESSAGE-INTEGRITY attribute value is 20 bytes (HMAC-SHA1)
|
||||||
if mi.value.len() != 20 {
|
if mi.value.len() != 20 {
|
||||||
@ -189,11 +209,11 @@ pub fn compute_fingerprint(msg: &[u8]) -> u32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Compute MESSAGE-INTEGRITY (HMAC-SHA1) over the message
|
/// Compute MESSAGE-INTEGRITY (HMAC-SHA1) over the message
|
||||||
pub fn compute_message_integrity(key: &str, msg: &[u8]) -> Vec<u8> {
|
pub fn compute_message_integrity(key: &[u8], msg: &[u8]) -> Vec<u8> {
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use sha1::Sha1;
|
use sha1::Sha1;
|
||||||
type HmacSha1 = Hmac<Sha1>;
|
type HmacSha1 = Hmac<Sha1>;
|
||||||
let mut mac = HmacSha1::new_from_slice(key.as_bytes()).expect("HMAC key");
|
let mut mac = HmacSha1::new_from_slice(key).expect("HMAC key");
|
||||||
mac.update(msg);
|
mac.update(msg);
|
||||||
mac.finalize().into_bytes().to_vec()
|
mac.finalize().into_bytes().to_vec()
|
||||||
}
|
}
|
||||||
@ -284,7 +304,7 @@ mod tests {
|
|||||||
cookie: MAGIC_COOKIE_U32,
|
cookie: MAGIC_COOKIE_U32,
|
||||||
transaction_id: [2u8; 12],
|
transaction_id: [2u8; 12],
|
||||||
};
|
};
|
||||||
let out = build_401_response(&req, "realm", "nonce", 401);
|
let out = build_401_response(&req, "realm", "nonce", 401, "Unauthorized");
|
||||||
// parse back should succeed
|
// parse back should succeed
|
||||||
let parsed = parse_message(&out).expect("parse resp");
|
let parsed = parse_message(&out).expect("parse resp");
|
||||||
assert!(!parsed.attributes.is_empty());
|
assert!(!parsed.attributes.is_empty());
|
||||||
@ -331,7 +351,7 @@ mod tests {
|
|||||||
buf[3] = len_bytes[1];
|
buf[3] = len_bytes[1];
|
||||||
|
|
||||||
// Compute HMAC over message up to MI attribute header (mi_attr_offset)
|
// Compute HMAC over message up to MI attribute header (mi_attr_offset)
|
||||||
let hmac = compute_message_integrity(password, &buf[..mi_attr_offset]);
|
let hmac = compute_message_integrity(password.as_bytes(), &buf[..mi_attr_offset]);
|
||||||
// place first 20 bytes into mi value
|
// place first 20 bytes into mi value
|
||||||
for i in 0..20 {
|
for i in 0..20 {
|
||||||
buf[mi_val_pos + i] = hmac[i];
|
buf[mi_val_pos + i] = hmac[i];
|
||||||
@ -339,12 +359,12 @@ mod tests {
|
|||||||
|
|
||||||
// Parse and validate
|
// Parse and validate
|
||||||
let parsed = parse_message(&buf).expect("parsed");
|
let parsed = parse_message(&buf).expect("parsed");
|
||||||
assert!(validate_message_integrity(&parsed, password));
|
assert!(validate_message_integrity(&parsed, password.as_bytes()));
|
||||||
|
|
||||||
// tamper: change one byte -> invalid
|
// tamper: change one byte -> invalid
|
||||||
let mut tampered = buf.to_vec();
|
let mut tampered = buf.to_vec();
|
||||||
tampered[10] ^= 0xFF;
|
tampered[10] ^= 0xFF;
|
||||||
let parsed2 = parse_message(&tampered).expect("parsed2");
|
let parsed2 = parse_message(&tampered).expect("parsed2");
|
||||||
assert!(!validate_message_integrity(&parsed2, password));
|
assert!(!validate_message_integrity(&parsed2, password.as_bytes()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1126
src/tls.rs
1126
src/tls.rs
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user