Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions docs/clustering.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,12 @@ ingress eligibility rapidly.
- Control/media use shared-secret challenge-response auth; enable
`CLUSTER_TLS_ENABLED` with cert/key/CA for mTLS in production.
- When mTLS is enabled, each node client certificate must embed its
`CLUSTER_NODE_ID` as the printable string `lrtmp2-node-{id}` in the
subject CN or SAN (the server scans the DER for this marker). The
authenticated control/media `node_id` must match the certificate.
`CLUSTER_NODE_ID` as the exact printable string `lrtmp2-node-{id}` in the
leaf certificate's subject CN or SAN. The authenticated control/media
`node_id` must match that leaf-certificate identity; issuer certificates
and arbitrary certificate data are not considered. Existing certificates
whose CN/SAN merely contains the marker (for example,
`node-lrtmp2-node-42`) must be reissued before upgrading.
- HA relay export carries live frames only. Peers that join after export
starts must also fetch `stream_init_snapshot` (or receive init-cache via
the media mesh `InitCache` subscribe path) before playing.
Expand Down
85 changes: 24 additions & 61 deletions src/cluster/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ use rand::rngs::SysRng;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::{ClientConfig, RootCertStore, ServerConfig};

const NODE_ID_CERT_PREFIX: &[u8] = b"lrtmp2-node-";
const CLUSTER_AUTH_MAX_FAILURES: usize = 10;
const CLUSTER_AUTH_FAILURE_WINDOW: Duration = Duration::from_secs(60);
const MAX_TRACKED_CLUSTER_AUTH_IPS: usize = 10_000;
Expand Down Expand Up @@ -165,24 +164,11 @@ pub fn secrets_equal(a: &str, b: &str) -> bool {

fn parse_node_id_from_identity_str(value: &str) -> Option<u64> {
let marker = "lrtmp2-node-";
let pos = value.find(marker)?;
let digits = &value[pos + marker.len()..];
let mut id = 0u64;
let mut digits_seen = 0usize;
for ch in digits.chars() {
if ch.is_ascii_digit() {
id = id
.saturating_mul(10)
.saturating_add((ch as u8 - b'0') as u64);
digits_seen += 1;
if digits_seen > 20 {
return None;
}
} else {
break;
}
let digits = value.strip_prefix(marker)?;
if digits.is_empty() || digits.len() > 20 || !digits.bytes().all(|b| b.is_ascii_digit()) {
Comment on lines +167 to +168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the documented mTLS certificate format

This now accepts only an exact lrtmp2-node-{id} CN/DNS SAN/URI SAN value, but docs/clustering.md still tells operators that the server scans certificate DER for the marker. Consequently, an existing certificate whose CN or SAN merely contains the marker (for example, node-lrtmp2-node-42) was accepted before this change but will make every control and media authentication fail after an upgrade. Update the deployment documentation and migration guidance to require the exact identity value.

Useful? React with 👍 / 👎.

return None;
}
(digits_seen > 0 && id > 0).then_some(id)
digits.parse::<u64>().ok().filter(|id| *id > 0)
}

fn node_id_from_cert_der(bytes: &[u8]) -> Option<u64> {
Expand Down Expand Up @@ -213,37 +199,6 @@ fn node_id_from_cert_der(bytes: &[u8]) -> Option<u64> {
None
}

fn node_id_from_cert_bytes_scan(bytes: &[u8]) -> Option<u64> {
let mut search_from = 0usize;
while let Some(rel) = bytes[search_from..]
.windows(NODE_ID_CERT_PREFIX.len())
.position(|w| w == NODE_ID_CERT_PREFIX)
{
let pos = search_from + rel + NODE_ID_CERT_PREFIX.len();
let mut id = 0u64;
let mut digits = 0usize;
for &b in &bytes[pos..] {
if b.is_ascii_digit() {
id = id.saturating_mul(10).saturating_add((b - b'0') as u64);
digits += 1;
if digits > 20 {
break;
}
} else {
break;
}
}
if digits > 0 && id > 0 {
return Some(id);
}
search_from = pos.saturating_add(1);
if search_from >= bytes.len() {
break;
}
}
None
}

/// CSPRNG nonce for cluster auth handshakes.
pub fn auth_nonce() -> Vec<u8> {
let mut nonce = vec![0u8; 16];
Expand All @@ -256,16 +211,7 @@ pub fn auth_nonce() -> Vec<u8> {
/// Extract `node_id` embedded in a peer client certificate (SAN/CN string
/// `lrtmp2-node-{id}`). Returns `None` when TLS is off or the pattern is absent.
pub fn node_id_from_peer_certs(certs: &[CertificateDer<'_>]) -> Option<u64> {
for cert in certs {
let bytes = cert.as_ref();
if let Some(id) = node_id_from_cert_der(bytes) {
return Some(id);
}
if let Some(id) = node_id_from_cert_bytes_scan(bytes) {
return Some(id);
}
}
None
node_id_from_cert_der(certs.first()?.as_ref())
}

/// When mTLS is active, the authenticated `node_id` must match the client cert.
Expand Down Expand Up @@ -428,11 +374,28 @@ mod tests {
}

#[test]
fn node_id_from_cert_prefix_scan() {
fn node_id_rejects_unparsed_certificate_bytes() {
let mut der = vec![0u8; 64];
der.extend_from_slice(b"prefix-lrtmp2-node-42-suffix");
let id = node_id_from_peer_certs(&[CertificateDer::from(der)]);
assert_eq!(id, Some(42));
assert_eq!(id, None);
}

#[test]
fn node_id_ignores_identity_in_issuer_certificate_bytes() {
let leaf = CertificateDer::from(b"identity-free-leaf".to_vec());
let issuer = CertificateDer::from(b"issuer-lrtmp2-node-42".to_vec());
assert_eq!(node_id_from_peer_certs(&[leaf, issuer]), None);
}

#[test]
fn node_id_identity_requires_an_exact_value() {
assert_eq!(parse_node_id_from_identity_str("lrtmp2-node-42"), Some(42));
assert_eq!(parse_node_id_from_identity_str("node-lrtmp2-node-42"), None);
assert_eq!(
parse_node_id_from_identity_str("lrtmp2-node-42.example"),
None
);
}

#[test]
Expand Down
7 changes: 3 additions & 4 deletions tests/cluster_security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,11 @@ fn admin_proof_is_deterministic_so_replay_cache_is_required() {
}

#[test]
fn tls_identity_requires_cert_marker_when_tls_on() {
fn tls_identity_rejects_unparsed_certificate_bytes() {
let mut der = Vec::new();
der.extend_from_slice(b"noise-lrtmp2-node-99-trailer");
let cert_id = node_id_from_peer_certs(&[rustls::pki_types::CertificateDer::from(der)]);
assert_eq!(cert_id, Some(99));
assert!(verify_tls_node_identity(true, cert_id, 99).is_ok());
assert!(verify_tls_node_identity(true, cert_id, 1).is_err());
assert_eq!(cert_id, None);
assert!(verify_tls_node_identity(true, cert_id, 99).is_err());
assert!(verify_tls_node_identity(true, None, 1).is_err());
}
Loading