From 6ffd6a57bc83dde9f5a04eb2a6ab23f5fe82d46c Mon Sep 17 00:00:00 2001 From: reverse Date: Tue, 11 Aug 2026 17:13:32 +0000 Subject: [PATCH] proxy: forward v2 TLS TLVs (PP2_TYPE_SSL/CERTFP) so plaintext clients behind a TLS-terminating proxy show secure+certfp; restore ws_trust_proxy to the config example --- echoircd.conf.example | 4 +- src/proxy.rs | 111 +++++++++++++++++++++++++++++++++++++----- src/socketengine.rs | 36 +++++++++----- 3 files changed, 128 insertions(+), 23 deletions(-) diff --git a/echoircd.conf.example b/echoircd.conf.example index 8b77fb1..f0d4d70 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -149,7 +149,9 @@ amu_target = both # ws_origin = https://x.example # (repeatable) allowed Origin globs; empty = any # ws_defaultmode = text # frame mode with no subprotocol: text|binary|reject # ws_proxyranges = 127.0.0.1 # (repeatable) glob/CIDR of proxies whose -# # X-Real-IP / X-Forwarded-For we trust +# # X-Real-IP / X-Forwarded-For we trust (scoped) +# ws_trust_proxy = no # trust those headers from ANY peer (simpler but +# # allows IP spoofing; prefer ws_proxyranges) # ws_allowmissingorigin = yes # allow clients that send no Origin header # ws_nativeping = yes # liveness via WebSocket pings (no = IRC PING) # ws_handshake_timeout = 10 # seconds to complete the HTTP Upgrade diff --git a/src/proxy.rs b/src/proxy.rs index e5ffffb..4a47159 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -19,8 +19,14 @@ const V1_MAX: usize = 107; /// The result of trying to parse a PROXY header from a byte prefix. pub enum Parsed { - /// A full header giving the real client (source) address. - Proxy(SocketAddr), + /// A full header giving the real client (source) address, plus any TLS metadata + /// a v2 header forwarded (a TLS-terminating proxy sets `secure` and, if it + /// forwards one, the client cert `certfp`). + Proxy { + addr: SocketAddr, + secure: bool, + certfp: Option, + }, /// A full header with no address to apply (LOCAL / unsupported family). Local, /// Not enough bytes yet — read more and retry. @@ -91,7 +97,14 @@ fn parse_v1(buf: &[u8]) -> (Parsed, usize) { return (Parsed::Invalid, consumed); } match (p[2].parse::(), p[4].parse::()) { - (Ok(ip), Ok(port)) => (Parsed::Proxy(SocketAddr::new(ip, port)), consumed), + (Ok(ip), Ok(port)) => ( + Parsed::Proxy { + addr: SocketAddr::new(ip, port), + secure: false, + certfp: None, + }, + consumed, + ), _ => (Parsed::Invalid, consumed), } } @@ -122,23 +135,68 @@ fn parse_v2(buf: &[u8]) -> (Parsed, usize) { return (Parsed::Invalid, total); } let a = &buf[16..total]; - match family { + let (addr, fixed) = match family { 1 if len >= 12 => { let src = Ipv4Addr::new(a[0], a[1], a[2], a[3]); let sport = u16::from_be_bytes([a[8], a[9]]); - (Parsed::Proxy(SocketAddr::new(IpAddr::V4(src), sport)), total) + (SocketAddr::new(IpAddr::V4(src), sport), 12) } 2 if len >= 36 => { let mut o = [0u8; 16]; o.copy_from_slice(&a[0..16]); let sport = u16::from_be_bytes([a[32], a[33]]); - ( - Parsed::Proxy(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(o)), sport)), - total, - ) + (SocketAddr::new(IpAddr::V6(Ipv6Addr::from(o)), sport), 36) } - _ => (Parsed::Local, total), // AF_UNIX / unspecified: keep peer addr + _ => return (Parsed::Local, total), // AF_UNIX / unspecified: keep peer addr + }; + // any bytes after the fixed address are TLVs: a TLS-terminating proxy may + // forward the client's TLS status (PP2_TYPE_SSL) and cert fingerprint (CERTFP) + let (secure, certfp) = parse_v2_tlvs(&a[fixed..]); + ( + Parsed::Proxy { + addr, + secure, + certfp, + }, + total, + ) +} + +// PROXY v2 TLV types we care about. +const PP2_TYPE_SSL: u8 = 0x20; +const PP2_TYPE_CERTFP: u8 = 0xE0; +const PP2_CLIENT_SSL: u8 = 0x01; + +/// Walk the v2 TLV block: `type(1) len(2, big-endian) value(len)`. Returns whether +/// the client was on TLS and its forwarded cert fingerprint, if any. +fn parse_v2_tlvs(mut tlv: &[u8]) -> (bool, Option) { + let mut secure = false; + let mut certfp = None; + while tlv.len() >= 3 { + let ttype = tlv[0]; + let tlen = u16::from_be_bytes([tlv[1], tlv[2]]) as usize; + if tlv.len() < 3 + tlen { + break; // truncated TLV + } + let val = &tlv[3..3 + tlen]; + match ttype { + PP2_TYPE_SSL => { + if !val.is_empty() && val[0] & PP2_CLIENT_SSL != 0 { + secure = true; + } + } + PP2_TYPE_CERTFP => { + if let Ok(s) = std::str::from_utf8(val) { + if !s.is_empty() && s.len() <= 128 && s.bytes().all(|c| c.is_ascii_hexdigit()) { + certfp = Some(s.to_string()); + } + } + } + _ => {} + } + tlv = &tlv[3 + tlen..]; } + (secure, certfp) } #[cfg(test)] @@ -147,7 +205,7 @@ mod tests { fn src(p: &Parsed) -> Option { match p { - Parsed::Proxy(a) => Some(*a), + Parsed::Proxy { addr, .. } => Some(*addr), _ => None, } } @@ -192,6 +250,37 @@ mod tests { assert_eq!(n, 28); } + #[test] + fn v2_tls_tlvs() { + // a TLS-terminating proxy forwards PP2_TYPE_SSL (client-on-TLS) + CERTFP + let mut h = V2_SIG.to_vec(); + h.push(0x21); // v2, PROXY + h.push(0x11); // AF_INET, STREAM + h.extend_from_slice(&31u16.to_be_bytes()); // 12 addr + 8 SSL TLV + 11 CERTFP TLV + h.extend_from_slice(&[198, 51, 100, 10]); // src + h.extend_from_slice(&[10, 0, 0, 1]); // dst + h.extend_from_slice(&5000u16.to_be_bytes()); + h.extend_from_slice(&443u16.to_be_bytes()); + h.push(0x20); // PP2_TYPE_SSL + h.extend_from_slice(&5u16.to_be_bytes()); + h.extend_from_slice(&[0x01, 0, 0, 0, 0]); // client=PP2_CLIENT_SSL, verify=0 + h.push(0xE0); // PP2_TYPE_CERTFP + h.extend_from_slice(&8u16.to_be_bytes()); + h.extend_from_slice(b"abcd1234"); + match parse(&h).0 { + Parsed::Proxy { + addr, + secure, + certfp, + } => { + assert_eq!(addr.to_string(), "198.51.100.10:5000"); + assert!(secure); + assert_eq!(certfp.as_deref(), Some("abcd1234")); + } + _ => panic!("expected Proxy"), + } + } + #[test] fn v2_partial_and_local() { assert!(matches!(parse(&V2_SIG[..8]), (Parsed::Need, _))); diff --git a/src/socketengine.rs b/src/socketengine.rs index fa18599..20b80c8 100644 --- a/src/socketengine.rs +++ b/src/socketengine.rs @@ -369,7 +369,7 @@ fn read_conn(poll: &mut Poll, conns: &mut HashMap, t: usize, core: let mut chunk = [0u8; 8192]; let mut lines: Vec<(Uid, String)> = Vec::new(); // a deferred Connect (PROXY conn) to emit, before any lines from the same read - let mut connect: Option<(Uid, SocketAddr, u16, OutSink)> = None; + let mut connect: Option<(Uid, SocketAddr, u16, bool, Option, OutSink)> = None; let mut close = false; if let Some(c) = conns.get_mut(&t) { loop { @@ -381,6 +381,10 @@ fn read_conn(poll: &mut Poll, conns: &mut HashMap, t: usize, core: Ok(n) => { c.rbuf.extend_from_slice(&chunk[..n]); if c.proxy_pending { + // a v2 header from a TLS-terminating proxy can forward the + // client's TLS status + cert fingerprint (see modules::proxy) + let mut psecure = false; + let mut pcertfp: Option = None; match crate::proxy::parse(&c.rbuf) { (crate::proxy::Parsed::Need, _) => { if c.rbuf.len() > PROXY_MAX { @@ -393,10 +397,19 @@ fn read_conn(poll: &mut Poll, conns: &mut HashMap, t: usize, core: close = true; break; } - (crate::proxy::Parsed::Proxy(real), used) => { - c.addr = real; // rewrite to the real client address + ( + crate::proxy::Parsed::Proxy { + addr, + secure, + certfp, + }, + used, + ) => { + c.addr = addr; // rewrite to the real client address c.rbuf.drain(..used); c.proxy_pending = false; + psecure = secure; + pcertfp = certfp; } (crate::proxy::Parsed::Local, used) => { c.rbuf.drain(..used); // keep the peer addr @@ -404,10 +417,9 @@ fn read_conn(poll: &mut Poll, conns: &mut HashMap, t: usize, core: } } if !c.proxy_pending { - connect = c - .pending_out - .take() - .map(|out| (c.uid, c.addr, c.local_port, out)); + connect = c.pending_out.take().map(|out| { + (c.uid, c.addr, c.local_port, psecure, pcertfp, out) + }); } } if !c.proxy_pending { @@ -433,15 +445,15 @@ fn read_conn(poll: &mut Poll, conns: &mut HashMap, t: usize, core: } } } - if let Some((uid, addr, local_port, out)) = connect { + if let Some((uid, addr, local_port, secure, certfp, out)) = connect { if core .send(Event::Connect { uid, addr, out, sock: None, - secure: false, - certfp: None, + secure, + certfp, local_port, link: false, outbound: false, @@ -690,8 +702,10 @@ fn tls_conn( .any(|g| crate::modules::connclass::ip_matches(g, &addr.ip().to_string())) { let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); + // echo terminates TLS on this listener, so only the client address is taken + // from the header (its TLS TLVs would be redundant here). let real = match crate::proxy::read_header(&mut stream) { - crate::proxy::Parsed::Proxy(a) => a, + crate::proxy::Parsed::Proxy { addr, .. } => addr, crate::proxy::Parsed::Local => addr, _ => { let _ = shutdown.shutdown(Shutdown::Both);