From db2e586da2cbaea6a56189e2fe67cf95eae19d7c Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 19 Mar 2026 12:41:26 +0000 Subject: [PATCH] feat(proxy-protocol): add PROXY protocol v2 support to the Rust passthrough listener and streamline TypeScript proxy protocol exports --- changelog.md | 7 + .../src/proxy_protocol.rs | 363 +++++++++++++++++- .../rustproxy-passthrough/src/tcp_listener.rs | 34 +- test/test.proxy-protocol.ts | 133 ------- ts/00_commitinfo_data.ts | 2 +- ts/core/utils/index.ts | 1 - ts/core/utils/proxy-protocol.ts | 129 ------- ts/protocols/proxy/index.ts | 5 +- ts/protocols/proxy/parser.ts | 183 --------- ts/protocols/proxy/types.ts | 2 +- 10 files changed, 391 insertions(+), 468 deletions(-) delete mode 100644 test/test.proxy-protocol.ts delete mode 100644 ts/core/utils/proxy-protocol.ts delete mode 100644 ts/protocols/proxy/parser.ts diff --git a/changelog.md b/changelog.md index c36ae2f..ce04295 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-03-19 - 25.12.0 - feat(proxy-protocol) +add PROXY protocol v2 support to the Rust passthrough listener and streamline TypeScript proxy protocol exports + +- detect and parse PROXY protocol v2 headers in the Rust TCP listener, including TCP and UDP address families +- add Rust v2 header generation, incomplete-header handling, and broader parser test coverage +- remove deprecated TypeScript proxy protocol parser exports and tests, leaving shared type definitions only + ## 2026-03-17 - 25.11.24 - fix(rustproxy-http) improve async static file serving, websocket handshake buffering, and shared metric metadata handling diff --git a/rust/crates/rustproxy-passthrough/src/proxy_protocol.rs b/rust/crates/rustproxy-passthrough/src/proxy_protocol.rs index d9bcd73..2b313f2 100644 --- a/rust/crates/rustproxy-passthrough/src/proxy_protocol.rs +++ b/rust/crates/rustproxy-passthrough/src/proxy_protocol.rs @@ -1,4 +1,4 @@ -use std::net::SocketAddr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use thiserror::Error; #[derive(Debug, Error)] @@ -9,9 +9,11 @@ pub enum ProxyProtocolError { UnsupportedVersion, #[error("Parse error: {0}")] Parse(String), + #[error("Incomplete header: need {0} bytes, got {1}")] + Incomplete(usize, usize), } -/// Parsed PROXY protocol v1 header. +/// Parsed PROXY protocol header (v1 or v2). #[derive(Debug, Clone)] pub struct ProxyProtocolHeader { pub source_addr: SocketAddr, @@ -24,14 +26,29 @@ pub struct ProxyProtocolHeader { pub enum ProxyProtocol { Tcp4, Tcp6, + Udp4, + Udp6, Unknown, } +/// Transport type for PROXY v2 header generation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProxyV2Transport { + Stream, // TCP + Datagram, // UDP +} + +/// PROXY protocol v2 signature (12 bytes). +const PROXY_V2_SIGNATURE: [u8; 12] = [ + 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A, +]; + +// ===== v1 (text format) ===== + /// Parse a PROXY protocol v1 header from data. /// /// Format: `PROXY TCP4 \r\n` pub fn parse_v1(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtocolError> { - // Find the end of the header line let line_end = data .windows(2) .position(|w| w == b"\r\n") @@ -56,10 +73,10 @@ pub fn parse_v1(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtoc _ => return Err(ProxyProtocolError::UnsupportedVersion), }; - let src_ip: std::net::IpAddr = parts[2] + let src_ip: IpAddr = parts[2] .parse() .map_err(|_| ProxyProtocolError::Parse("Invalid source IP".to_string()))?; - let dst_ip: std::net::IpAddr = parts[3] + let dst_ip: IpAddr = parts[3] .parse() .map_err(|_| ProxyProtocolError::Parse("Invalid destination IP".to_string()))?; let src_port: u16 = parts[4] @@ -75,7 +92,6 @@ pub fn parse_v1(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtoc protocol, }; - // Consumed bytes = line + \r\n Ok((header, line_end + 2)) } @@ -97,10 +113,219 @@ pub fn is_proxy_protocol_v1(data: &[u8]) -> bool { data.starts_with(b"PROXY ") } +// ===== v2 (binary format) ===== + +/// Check if data starts with a PROXY protocol v2 header. +pub fn is_proxy_protocol_v2(data: &[u8]) -> bool { + data.len() >= 12 && data[..12] == PROXY_V2_SIGNATURE +} + +/// Parse a PROXY protocol v2 binary header. +/// +/// Binary format: +/// - [0..12] signature (12 bytes) +/// - [12] version (high nibble) + command (low nibble) +/// - [13] address family (high nibble) + transport (low nibble) +/// - [14..16] address block length (big-endian u16) +/// - [16..] address block (variable, depends on family) +pub fn parse_v2(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtocolError> { + if data.len() < 16 { + return Err(ProxyProtocolError::Incomplete(16, data.len())); + } + + // Validate signature + if data[..12] != PROXY_V2_SIGNATURE { + return Err(ProxyProtocolError::InvalidHeader); + } + + // Version (high nibble of byte 12) must be 0x2 + let version = (data[12] >> 4) & 0x0F; + if version != 2 { + return Err(ProxyProtocolError::UnsupportedVersion); + } + + // Command (low nibble of byte 12) + let command = data[12] & 0x0F; + // 0x0 = LOCAL, 0x1 = PROXY + if command > 1 { + return Err(ProxyProtocolError::Parse(format!("Unknown command: {}", command))); + } + + // Address family (high nibble) + transport (low nibble) of byte 13 + let family = (data[13] >> 4) & 0x0F; + let transport = data[13] & 0x0F; + + // Address block length + let addr_len = u16::from_be_bytes([data[14], data[15]]) as usize; + let total_len = 16 + addr_len; + + if data.len() < total_len { + return Err(ProxyProtocolError::Incomplete(total_len, data.len())); + } + + // LOCAL command: no real addresses, return unspecified + if command == 0 { + return Ok(( + ProxyProtocolHeader { + source_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + dest_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + protocol: ProxyProtocol::Unknown, + }, + total_len, + )); + } + + // PROXY command: parse addresses based on family + transport + let addr_block = &data[16..16 + addr_len]; + + match (family, transport) { + // AF_INET (0x1) + STREAM (0x1) = TCP4 + (0x1, 0x1) => { + if addr_len < 12 { + return Err(ProxyProtocolError::Parse("IPv4 address block too short".to_string())); + } + let src_ip = Ipv4Addr::new(addr_block[0], addr_block[1], addr_block[2], addr_block[3]); + let dst_ip = Ipv4Addr::new(addr_block[4], addr_block[5], addr_block[6], addr_block[7]); + let src_port = u16::from_be_bytes([addr_block[8], addr_block[9]]); + let dst_port = u16::from_be_bytes([addr_block[10], addr_block[11]]); + Ok(( + ProxyProtocolHeader { + source_addr: SocketAddr::new(IpAddr::V4(src_ip), src_port), + dest_addr: SocketAddr::new(IpAddr::V4(dst_ip), dst_port), + protocol: ProxyProtocol::Tcp4, + }, + total_len, + )) + } + // AF_INET (0x1) + DGRAM (0x2) = UDP4 + (0x1, 0x2) => { + if addr_len < 12 { + return Err(ProxyProtocolError::Parse("IPv4 address block too short".to_string())); + } + let src_ip = Ipv4Addr::new(addr_block[0], addr_block[1], addr_block[2], addr_block[3]); + let dst_ip = Ipv4Addr::new(addr_block[4], addr_block[5], addr_block[6], addr_block[7]); + let src_port = u16::from_be_bytes([addr_block[8], addr_block[9]]); + let dst_port = u16::from_be_bytes([addr_block[10], addr_block[11]]); + Ok(( + ProxyProtocolHeader { + source_addr: SocketAddr::new(IpAddr::V4(src_ip), src_port), + dest_addr: SocketAddr::new(IpAddr::V4(dst_ip), dst_port), + protocol: ProxyProtocol::Udp4, + }, + total_len, + )) + } + // AF_INET6 (0x2) + STREAM (0x1) = TCP6 + (0x2, 0x1) => { + if addr_len < 36 { + return Err(ProxyProtocolError::Parse("IPv6 address block too short".to_string())); + } + let src_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_block[0..16]).unwrap()); + let dst_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_block[16..32]).unwrap()); + let src_port = u16::from_be_bytes([addr_block[32], addr_block[33]]); + let dst_port = u16::from_be_bytes([addr_block[34], addr_block[35]]); + Ok(( + ProxyProtocolHeader { + source_addr: SocketAddr::new(IpAddr::V6(src_ip), src_port), + dest_addr: SocketAddr::new(IpAddr::V6(dst_ip), dst_port), + protocol: ProxyProtocol::Tcp6, + }, + total_len, + )) + } + // AF_INET6 (0x2) + DGRAM (0x2) = UDP6 + (0x2, 0x2) => { + if addr_len < 36 { + return Err(ProxyProtocolError::Parse("IPv6 address block too short".to_string())); + } + let src_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_block[0..16]).unwrap()); + let dst_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_block[16..32]).unwrap()); + let src_port = u16::from_be_bytes([addr_block[32], addr_block[33]]); + let dst_port = u16::from_be_bytes([addr_block[34], addr_block[35]]); + Ok(( + ProxyProtocolHeader { + source_addr: SocketAddr::new(IpAddr::V6(src_ip), src_port), + dest_addr: SocketAddr::new(IpAddr::V6(dst_ip), dst_port), + protocol: ProxyProtocol::Udp6, + }, + total_len, + )) + } + // AF_UNSPEC or unknown + (0x0, _) => Ok(( + ProxyProtocolHeader { + source_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + dest_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + protocol: ProxyProtocol::Unknown, + }, + total_len, + )), + _ => Err(ProxyProtocolError::Parse(format!( + "Unsupported family/transport: 0x{:X}{:X}", + family, transport + ))), + } +} + +/// Generate a PROXY protocol v2 binary header. +pub fn generate_v2( + source: &SocketAddr, + dest: &SocketAddr, + transport: ProxyV2Transport, +) -> Vec { + let transport_nibble: u8 = match transport { + ProxyV2Transport::Stream => 0x1, + ProxyV2Transport::Datagram => 0x2, + }; + + match (source.ip(), dest.ip()) { + (IpAddr::V4(src_ip), IpAddr::V4(dst_ip)) => { + let mut buf = Vec::with_capacity(28); + buf.extend_from_slice(&PROXY_V2_SIGNATURE); + buf.push(0x21); // version 2, PROXY command + buf.push(0x10 | transport_nibble); // AF_INET + transport + buf.extend_from_slice(&12u16.to_be_bytes()); // addr block length + buf.extend_from_slice(&src_ip.octets()); + buf.extend_from_slice(&dst_ip.octets()); + buf.extend_from_slice(&source.port().to_be_bytes()); + buf.extend_from_slice(&dest.port().to_be_bytes()); + buf + } + (IpAddr::V6(src_ip), IpAddr::V6(dst_ip)) => { + let mut buf = Vec::with_capacity(52); + buf.extend_from_slice(&PROXY_V2_SIGNATURE); + buf.push(0x21); // version 2, PROXY command + buf.push(0x20 | transport_nibble); // AF_INET6 + transport + buf.extend_from_slice(&36u16.to_be_bytes()); // addr block length + buf.extend_from_slice(&src_ip.octets()); + buf.extend_from_slice(&dst_ip.octets()); + buf.extend_from_slice(&source.port().to_be_bytes()); + buf.extend_from_slice(&dest.port().to_be_bytes()); + buf + } + // Mixed IPv4/IPv6: map IPv4 to IPv6-mapped address + _ => { + let src_v6 = match source.ip() { + IpAddr::V4(v4) => v4.to_ipv6_mapped(), + IpAddr::V6(v6) => v6, + }; + let dst_v6 = match dest.ip() { + IpAddr::V4(v4) => v4.to_ipv6_mapped(), + IpAddr::V6(v6) => v6, + }; + let src6 = SocketAddr::new(IpAddr::V6(src_v6), source.port()); + let dst6 = SocketAddr::new(IpAddr::V6(dst_v6), dest.port()); + generate_v2(&src6, &dst6, transport) + } + } +} + #[cfg(test)] mod tests { use super::*; + // ===== v1 tests ===== + #[test] fn test_parse_v1_tcp4() { let header = b"PROXY TCP4 192.168.1.100 10.0.0.1 12345 443\r\n"; @@ -126,4 +351,130 @@ mod tests { assert!(is_proxy_protocol_v1(b"PROXY TCP4 ...")); assert!(!is_proxy_protocol_v1(b"GET / HTTP/1.1")); } + + // ===== v2 tests ===== + + #[test] + fn test_is_proxy_protocol_v2() { + assert!(is_proxy_protocol_v2(&PROXY_V2_SIGNATURE)); + assert!(!is_proxy_protocol_v2(b"PROXY TCP4 ...")); + assert!(!is_proxy_protocol_v2(b"short")); + } + + #[test] + fn test_parse_v2_tcp4() { + let source: SocketAddr = "198.51.100.10:54321".parse().unwrap(); + let dest: SocketAddr = "203.0.113.25:8443".parse().unwrap(); + let header = generate_v2(&source, &dest, ProxyV2Transport::Stream); + + assert_eq!(header.len(), 28); + let (parsed, consumed) = parse_v2(&header).unwrap(); + assert_eq!(consumed, 28); + assert_eq!(parsed.protocol, ProxyProtocol::Tcp4); + assert_eq!(parsed.source_addr, source); + assert_eq!(parsed.dest_addr, dest); + } + + #[test] + fn test_parse_v2_udp4() { + let source: SocketAddr = "10.0.0.1:12345".parse().unwrap(); + let dest: SocketAddr = "10.0.0.2:53".parse().unwrap(); + let header = generate_v2(&source, &dest, ProxyV2Transport::Datagram); + + assert_eq!(header.len(), 28); + assert_eq!(header[13], 0x12); // AF_INET + DGRAM + + let (parsed, consumed) = parse_v2(&header).unwrap(); + assert_eq!(consumed, 28); + assert_eq!(parsed.protocol, ProxyProtocol::Udp4); + assert_eq!(parsed.source_addr, source); + assert_eq!(parsed.dest_addr, dest); + } + + #[test] + fn test_parse_v2_tcp6() { + let source: SocketAddr = "[2001:db8::1]:54321".parse().unwrap(); + let dest: SocketAddr = "[2001:db8::2]:443".parse().unwrap(); + let header = generate_v2(&source, &dest, ProxyV2Transport::Stream); + + assert_eq!(header.len(), 52); + assert_eq!(header[13], 0x21); // AF_INET6 + STREAM + + let (parsed, consumed) = parse_v2(&header).unwrap(); + assert_eq!(consumed, 52); + assert_eq!(parsed.protocol, ProxyProtocol::Tcp6); + assert_eq!(parsed.source_addr, source); + assert_eq!(parsed.dest_addr, dest); + } + + #[test] + fn test_generate_v2_tcp4_byte_layout() { + let source: SocketAddr = "1.2.3.4:1000".parse().unwrap(); + let dest: SocketAddr = "5.6.7.8:443".parse().unwrap(); + let header = generate_v2(&source, &dest, ProxyV2Transport::Stream); + + assert_eq!(&header[0..12], &PROXY_V2_SIGNATURE); + assert_eq!(header[12], 0x21); // v2, PROXY + assert_eq!(header[13], 0x11); // AF_INET, STREAM + assert_eq!(u16::from_be_bytes([header[14], header[15]]), 12); // addr len + assert_eq!(&header[16..20], &[1, 2, 3, 4]); // src ip + assert_eq!(&header[20..24], &[5, 6, 7, 8]); // dst ip + assert_eq!(u16::from_be_bytes([header[24], header[25]]), 1000); // src port + assert_eq!(u16::from_be_bytes([header[26], header[27]]), 443); // dst port + } + + #[test] + fn test_generate_v2_udp4_byte_layout() { + let source: SocketAddr = "10.0.0.1:5000".parse().unwrap(); + let dest: SocketAddr = "10.0.0.2:53".parse().unwrap(); + let header = generate_v2(&source, &dest, ProxyV2Transport::Datagram); + + assert_eq!(header[12], 0x21); // v2, PROXY + assert_eq!(header[13], 0x12); // AF_INET, DGRAM (UDP) + } + + #[test] + fn test_parse_v2_local_command() { + // Build a LOCAL command header (no addresses) + let mut header = Vec::new(); + header.extend_from_slice(&PROXY_V2_SIGNATURE); + header.push(0x20); // v2, LOCAL + header.push(0x00); // AF_UNSPEC + header.extend_from_slice(&0u16.to_be_bytes()); // 0-length address block + + let (parsed, consumed) = parse_v2(&header).unwrap(); + assert_eq!(consumed, 16); + assert_eq!(parsed.protocol, ProxyProtocol::Unknown); + assert_eq!(parsed.source_addr.port(), 0); + } + + #[test] + fn test_parse_v2_incomplete() { + let data = &PROXY_V2_SIGNATURE[..8]; // only 8 bytes + assert!(parse_v2(data).is_err()); + } + + #[test] + fn test_parse_v2_wrong_version() { + let mut header = Vec::new(); + header.extend_from_slice(&PROXY_V2_SIGNATURE); + header.push(0x11); // version 1, not 2 + header.push(0x11); + header.extend_from_slice(&12u16.to_be_bytes()); + header.extend_from_slice(&[0u8; 12]); + assert!(matches!(parse_v2(&header), Err(ProxyProtocolError::UnsupportedVersion))); + } + + #[test] + fn test_v2_roundtrip_with_trailing_data() { + let source: SocketAddr = "192.168.1.1:8080".parse().unwrap(); + let dest: SocketAddr = "10.0.0.1:443".parse().unwrap(); + let mut data = generate_v2(&source, &dest, ProxyV2Transport::Stream); + data.extend_from_slice(b"GET / HTTP/1.1\r\n"); // trailing app data + + let (parsed, consumed) = parse_v2(&data).unwrap(); + assert_eq!(consumed, 28); + assert_eq!(parsed.source_addr, source); + assert_eq!(&data[consumed..], b"GET / HTTP/1.1\r\n"); + } } diff --git a/rust/crates/rustproxy-passthrough/src/tcp_listener.rs b/rust/crates/rustproxy-passthrough/src/tcp_listener.rs index 6dacb89..898a1cb 100644 --- a/rust/crates/rustproxy-passthrough/src/tcp_listener.rs +++ b/rust/crates/rustproxy-passthrough/src/tcp_listener.rs @@ -573,18 +573,30 @@ impl TcpListenerManager { Err(_) => return Err("Initial data timeout (proxy protocol peek)".into()), }; - if pn > 0 && crate::proxy_protocol::is_proxy_protocol_v1(&proxy_peek[..pn]) { - match crate::proxy_protocol::parse_v1(&proxy_peek[..pn]) { - Ok((header, consumed)) => { - debug!("PROXY protocol: real client {} -> {}", header.source_addr, header.dest_addr); - effective_peer_addr = header.source_addr; - // Consume the proxy protocol header bytes (stack buffer, max 108 bytes) - let mut discard = [0u8; 128]; - stream.read_exact(&mut discard[..consumed]).await?; + if pn > 0 { + if crate::proxy_protocol::is_proxy_protocol_v1(&proxy_peek[..pn]) { + match crate::proxy_protocol::parse_v1(&proxy_peek[..pn]) { + Ok((header, consumed)) => { + debug!("PROXY v1: real client {} -> {}", header.source_addr, header.dest_addr); + effective_peer_addr = header.source_addr; + let mut discard = [0u8; 128]; + stream.read_exact(&mut discard[..consumed]).await?; + } + Err(e) => { + debug!("Failed to parse PROXY v1 header: {}", e); + } } - Err(e) => { - debug!("Failed to parse PROXY protocol header: {}", e); - // Not a PROXY protocol header, continue normally + } else if crate::proxy_protocol::is_proxy_protocol_v2(&proxy_peek[..pn]) { + match crate::proxy_protocol::parse_v2(&proxy_peek[..pn]) { + Ok((header, consumed)) => { + debug!("PROXY v2: real client {} -> {} ({:?})", header.source_addr, header.dest_addr, header.protocol); + effective_peer_addr = header.source_addr; + let mut discard = [0u8; 256]; + stream.read_exact(&mut discard[..consumed]).await?; + } + Err(e) => { + debug!("Failed to parse PROXY v2 header: {}", e); + } } } } diff --git a/test/test.proxy-protocol.ts b/test/test.proxy-protocol.ts deleted file mode 100644 index 2ae3608..0000000 --- a/test/test.proxy-protocol.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as smartproxy from '../ts/index.js'; -import { ProxyProtocolParser } from '../ts/core/utils/proxy-protocol.js'; - -tap.test('PROXY protocol v1 parser - valid headers', async () => { - // Test TCP4 format - const tcp4Header = Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n', 'ascii'); - const tcp4Result = ProxyProtocolParser.parse(tcp4Header); - - expect(tcp4Result.proxyInfo).property('protocol').toEqual('TCP4'); - expect(tcp4Result.proxyInfo).property('sourceIP').toEqual('192.168.1.1'); - expect(tcp4Result.proxyInfo).property('sourcePort').toEqual(56324); - expect(tcp4Result.proxyInfo).property('destinationIP').toEqual('10.0.0.1'); - expect(tcp4Result.proxyInfo).property('destinationPort').toEqual(443); - expect(tcp4Result.remainingData.length).toEqual(0); - - // Test TCP6 format - const tcp6Header = Buffer.from('PROXY TCP6 2001:db8::1 2001:db8::2 56324 443\r\n', 'ascii'); - const tcp6Result = ProxyProtocolParser.parse(tcp6Header); - - expect(tcp6Result.proxyInfo).property('protocol').toEqual('TCP6'); - expect(tcp6Result.proxyInfo).property('sourceIP').toEqual('2001:db8::1'); - expect(tcp6Result.proxyInfo).property('sourcePort').toEqual(56324); - expect(tcp6Result.proxyInfo).property('destinationIP').toEqual('2001:db8::2'); - expect(tcp6Result.proxyInfo).property('destinationPort').toEqual(443); - - // Test UNKNOWN protocol - const unknownHeader = Buffer.from('PROXY UNKNOWN\r\n', 'ascii'); - const unknownResult = ProxyProtocolParser.parse(unknownHeader); - - expect(unknownResult.proxyInfo).property('protocol').toEqual('UNKNOWN'); - expect(unknownResult.proxyInfo).property('sourceIP').toEqual(''); - expect(unknownResult.proxyInfo).property('sourcePort').toEqual(0); -}); - -tap.test('PROXY protocol v1 parser - with remaining data', async () => { - const headerWithData = Buffer.concat([ - Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n', 'ascii'), - Buffer.from('GET / HTTP/1.1\r\n', 'ascii') - ]); - - const result = ProxyProtocolParser.parse(headerWithData); - - expect(result.proxyInfo).property('protocol').toEqual('TCP4'); - expect(result.proxyInfo).property('sourceIP').toEqual('192.168.1.1'); - expect(result.remainingData.toString()).toEqual('GET / HTTP/1.1\r\n'); -}); - -tap.test('PROXY protocol v1 parser - invalid headers', async () => { - // Not a PROXY protocol header - const notProxy = Buffer.from('GET / HTTP/1.1\r\n', 'ascii'); - const notProxyResult = ProxyProtocolParser.parse(notProxy); - expect(notProxyResult.proxyInfo).toBeNull(); - expect(notProxyResult.remainingData).toEqual(notProxy); - - // Invalid protocol - expect(() => { - ProxyProtocolParser.parse(Buffer.from('PROXY INVALID 1.1.1.1 2.2.2.2 80 443\r\n', 'ascii')); - }).toThrow(); - - // Wrong number of fields - expect(() => { - ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324\r\n', 'ascii')); - }).toThrow(); - - // Invalid port - expect(() => { - ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 99999 443\r\n', 'ascii')); - }).toThrow(); - - // Invalid IP for protocol - expect(() => { - ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 2001:db8::1 10.0.0.1 56324 443\r\n', 'ascii')); - }).toThrow(); -}); - -tap.test('PROXY protocol v1 parser - incomplete headers', async () => { - // Header without terminator - const incomplete = Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443', 'ascii'); - const result = ProxyProtocolParser.parse(incomplete); - - expect(result.proxyInfo).toBeNull(); - expect(result.remainingData).toEqual(incomplete); - - // Header exceeding max length - create a buffer that actually starts with PROXY - const longHeader = Buffer.from('PROXY TCP4 ' + '1'.repeat(100), 'ascii'); - expect(() => { - ProxyProtocolParser.parse(longHeader); - }).toThrow(); -}); - -tap.test('PROXY protocol v1 generator', async () => { - // Generate TCP4 header - const tcp4Info = { - protocol: 'TCP4' as const, - sourceIP: '192.168.1.1', - sourcePort: 56324, - destinationIP: '10.0.0.1', - destinationPort: 443 - }; - - const tcp4Header = ProxyProtocolParser.generate(tcp4Info); - expect(tcp4Header.toString('ascii')).toEqual('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n'); - - // Generate TCP6 header - const tcp6Info = { - protocol: 'TCP6' as const, - sourceIP: '2001:db8::1', - sourcePort: 56324, - destinationIP: '2001:db8::2', - destinationPort: 443 - }; - - const tcp6Header = ProxyProtocolParser.generate(tcp6Info); - expect(tcp6Header.toString('ascii')).toEqual('PROXY TCP6 2001:db8::1 2001:db8::2 56324 443\r\n'); - - // Generate UNKNOWN header - const unknownInfo = { - protocol: 'UNKNOWN' as const, - sourceIP: '', - sourcePort: 0, - destinationIP: '', - destinationPort: 0 - }; - - const unknownHeader = ProxyProtocolParser.generate(unknownInfo); - expect(unknownHeader.toString('ascii')).toEqual('PROXY UNKNOWN\r\n'); -}); - -// Skipping integration tests for now - focus on unit tests -// Integration tests would require more complex setup and teardown - -export default tap.start(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 333e702..0830144 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '25.11.24', + version: '25.12.0', description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' } diff --git a/ts/core/utils/index.ts b/ts/core/utils/index.ts index 8305e32..08f7360 100644 --- a/ts/core/utils/index.ts +++ b/ts/core/utils/index.ts @@ -15,4 +15,3 @@ export * from './lifecycle-component.js'; export * from './binary-heap.js'; export * from './enhanced-connection-pool.js'; export * from './socket-utils.js'; -export * from './proxy-protocol.js'; diff --git a/ts/core/utils/proxy-protocol.ts b/ts/core/utils/proxy-protocol.ts deleted file mode 100644 index 7652895..0000000 --- a/ts/core/utils/proxy-protocol.ts +++ /dev/null @@ -1,129 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { logger } from './logger.js'; -import { ProxyProtocolParser as ProtocolParser, type IProxyInfo, type IProxyParseResult } from '../../protocols/proxy/index.js'; - -// Re-export types from protocols for backward compatibility -export type { IProxyInfo, IProxyParseResult } from '../../protocols/proxy/index.js'; - -/** - * Parser for PROXY protocol v1 (text format) - * Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt - * - * This class now delegates to the protocol parser but adds - * smartproxy-specific features like socket reading and logging - */ -export class ProxyProtocolParser { - static readonly PROXY_V1_SIGNATURE = ProtocolParser.PROXY_V1_SIGNATURE; - static readonly MAX_HEADER_LENGTH = ProtocolParser.MAX_HEADER_LENGTH; - static readonly HEADER_TERMINATOR = ProtocolParser.HEADER_TERMINATOR; - - /** - * Parse PROXY protocol v1 header from buffer - * Returns proxy info and remaining data after header - */ - static parse(data: Buffer): IProxyParseResult { - // Delegate to protocol parser - return ProtocolParser.parse(data); - } - - /** - * Generate PROXY protocol v1 header - */ - static generate(info: IProxyInfo): Buffer { - // Delegate to protocol parser - return ProtocolParser.generate(info); - } - - /** - * Validate IP address format - */ - private static isValidIP(ip: string, protocol: 'TCP4' | 'TCP6' | 'UNKNOWN'): boolean { - return ProtocolParser.isValidIP(ip, protocol); - } - - /** - * Attempt to read a complete PROXY protocol header from a socket - * Returns null if no PROXY protocol detected or incomplete - */ - static async readFromSocket(socket: plugins.net.Socket, timeout: number = 5000): Promise { - return new Promise((resolve) => { - let buffer = Buffer.alloc(0); - let resolved = false; - - const cleanup = () => { - socket.removeListener('data', onData); - socket.removeListener('error', onError); - clearTimeout(timer); - }; - - const timer = setTimeout(() => { - if (!resolved) { - resolved = true; - cleanup(); - resolve({ - proxyInfo: null, - remainingData: buffer - }); - } - }, timeout); - - const onData = (chunk: Buffer) => { - buffer = Buffer.concat([buffer, chunk]); - - // Check if we have enough data - if (!buffer.toString('ascii', 0, Math.min(6, buffer.length)).startsWith(this.PROXY_V1_SIGNATURE)) { - // Not PROXY protocol - resolved = true; - cleanup(); - resolve({ - proxyInfo: null, - remainingData: buffer - }); - return; - } - - // Try to parse - try { - const result = this.parse(buffer); - if (result.proxyInfo) { - // Successfully parsed - resolved = true; - cleanup(); - resolve(result); - } else if (buffer.length > this.MAX_HEADER_LENGTH) { - // Header too long - resolved = true; - cleanup(); - resolve({ - proxyInfo: null, - remainingData: buffer - }); - } - // Otherwise continue reading - } catch (error) { - // Parse error - logger.log('error', `PROXY protocol parse error: ${error.message}`); - resolved = true; - cleanup(); - resolve({ - proxyInfo: null, - remainingData: buffer - }); - } - }; - - const onError = (error: Error) => { - logger.log('error', `Socket error while reading PROXY protocol: ${error.message}`); - resolved = true; - cleanup(); - resolve({ - proxyInfo: null, - remainingData: buffer - }); - }; - - socket.on('data', onData); - socket.on('error', onError); - }); - } -} \ No newline at end of file diff --git a/ts/protocols/proxy/index.ts b/ts/protocols/proxy/index.ts index cfd20cd..c4938a0 100644 --- a/ts/protocols/proxy/index.ts +++ b/ts/protocols/proxy/index.ts @@ -1,7 +1,6 @@ /** * PROXY Protocol Module - * HAProxy PROXY protocol implementation + * Type definitions for HAProxy PROXY protocol v1/v2 */ -export * from './types.js'; -export * from './parser.js'; \ No newline at end of file +export * from './types.js'; \ No newline at end of file diff --git a/ts/protocols/proxy/parser.ts b/ts/protocols/proxy/parser.ts deleted file mode 100644 index 61bd36d..0000000 --- a/ts/protocols/proxy/parser.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * PROXY Protocol Parser - * Implementation of HAProxy PROXY protocol v1 (text format) - * Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt - */ - -import type { IProxyInfo, IProxyParseResult, TProxyProtocol } from './types.js'; - -/** - * PROXY protocol parser - */ -export class ProxyProtocolParser { - static readonly PROXY_V1_SIGNATURE = 'PROXY '; - static readonly MAX_HEADER_LENGTH = 107; // Max length for v1 header - static readonly HEADER_TERMINATOR = '\r\n'; - - /** - * Parse PROXY protocol v1 header from buffer - * Returns proxy info and remaining data after header - */ - static parse(data: Buffer): IProxyParseResult { - // Check if buffer starts with PROXY signature - if (!data.toString('ascii', 0, 6).startsWith(this.PROXY_V1_SIGNATURE)) { - return { - proxyInfo: null, - remainingData: data - }; - } - - // Find header terminator - const headerEndIndex = data.indexOf(this.HEADER_TERMINATOR); - if (headerEndIndex === -1) { - // Header incomplete, need more data - if (data.length > this.MAX_HEADER_LENGTH) { - // Header too long, invalid - throw new Error('PROXY protocol header exceeds maximum length'); - } - return { - proxyInfo: null, - remainingData: data - }; - } - - // Extract header line - const headerLine = data.toString('ascii', 0, headerEndIndex); - const remainingData = data.slice(headerEndIndex + 2); // Skip \r\n - - // Parse header - const parts = headerLine.split(' '); - - if (parts.length < 2) { - throw new Error(`Invalid PROXY protocol header format: ${headerLine}`); - } - - const [signature, protocol] = parts; - - // Validate protocol - if (!['TCP4', 'TCP6', 'UNKNOWN'].includes(protocol)) { - throw new Error(`Invalid PROXY protocol: ${protocol}`); - } - - // For UNKNOWN protocol, ignore addresses - if (protocol === 'UNKNOWN') { - return { - proxyInfo: { - protocol: 'UNKNOWN', - sourceIP: '', - sourcePort: 0, - destinationIP: '', - destinationPort: 0 - }, - remainingData - }; - } - - // For TCP4/TCP6, we need all 6 parts - if (parts.length !== 6) { - throw new Error(`Invalid PROXY protocol header format: ${headerLine}`); - } - - const [, , srcIP, dstIP, srcPort, dstPort] = parts; - - // Validate and parse ports - const sourcePort = parseInt(srcPort, 10); - const destinationPort = parseInt(dstPort, 10); - - if (isNaN(sourcePort) || sourcePort < 0 || sourcePort > 65535) { - throw new Error(`Invalid source port: ${srcPort}`); - } - - if (isNaN(destinationPort) || destinationPort < 0 || destinationPort > 65535) { - throw new Error(`Invalid destination port: ${dstPort}`); - } - - // Validate IP addresses - const protocolType = protocol as TProxyProtocol; - if (!this.isValidIP(srcIP, protocolType)) { - throw new Error(`Invalid source IP for ${protocol}: ${srcIP}`); - } - - if (!this.isValidIP(dstIP, protocolType)) { - throw new Error(`Invalid destination IP for ${protocol}: ${dstIP}`); - } - - return { - proxyInfo: { - protocol: protocolType, - sourceIP: srcIP, - sourcePort, - destinationIP: dstIP, - destinationPort - }, - remainingData - }; - } - - /** - * Generate PROXY protocol v1 header - */ - static generate(info: IProxyInfo): Buffer { - if (info.protocol === 'UNKNOWN') { - return Buffer.from(`PROXY UNKNOWN\r\n`, 'ascii'); - } - - const header = `PROXY ${info.protocol} ${info.sourceIP} ${info.destinationIP} ${info.sourcePort} ${info.destinationPort}\r\n`; - - if (header.length > this.MAX_HEADER_LENGTH) { - throw new Error('Generated PROXY protocol header exceeds maximum length'); - } - - return Buffer.from(header, 'ascii'); - } - - /** - * Validate IP address format - */ - static isValidIP(ip: string, protocol: TProxyProtocol): boolean { - if (protocol === 'TCP4') { - return this.isIPv4(ip); - } else if (protocol === 'TCP6') { - return this.isIPv6(ip); - } - return false; - } - - /** - * Check if string is valid IPv4 - */ - static isIPv4(ip: string): boolean { - const parts = ip.split('.'); - if (parts.length !== 4) return false; - - for (const part of parts) { - const num = parseInt(part, 10); - if (isNaN(num) || num < 0 || num > 255 || part !== num.toString()) { - return false; - } - } - return true; - } - - /** - * Check if string is valid IPv6 - */ - static isIPv6(ip: string): boolean { - // Basic IPv6 validation - const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; - return ipv6Regex.test(ip); - } - - /** - * Create a connection ID string for tracking - */ - static createConnectionId(connectionInfo: { - sourceIp?: string; - sourcePort?: number; - destIp?: string; - destPort?: number; - }): string { - const { sourceIp, sourcePort, destIp, destPort } = connectionInfo; - return `${sourceIp}:${sourcePort}-${destIp}:${destPort}`; - } -} \ No newline at end of file diff --git a/ts/protocols/proxy/types.ts b/ts/protocols/proxy/types.ts index 5d726a1..94315a9 100644 --- a/ts/protocols/proxy/types.ts +++ b/ts/protocols/proxy/types.ts @@ -11,7 +11,7 @@ export type TProxyProtocolVersion = 'v1' | 'v2'; /** * Connection protocol type */ -export type TProxyProtocol = 'TCP4' | 'TCP6' | 'UNKNOWN'; +export type TProxyProtocol = 'TCP4' | 'TCP6' | 'UDP4' | 'UDP6' | 'UNKNOWN'; /** * Interface representing parsed PROXY protocol information