// Go reference: golang/nats-server/server/client_proxyproto_test.go
// Ports the PROXY protocol v1 and v2 parsing tests from the Go implementation.
// The Go implementation uses a mock net.Conn; here we work directly with byte
// buffers via the pure-parser surface ProxyProtocolParser.
using System.Buffers.Binary;
using System.Net;
using System.Text;
using NATS.Server.Protocol;
namespace NATS.Server.Core.Tests;
///
/// PROXY protocol v1/v2 parser tests.
/// Ported from golang/nats-server/server/client_proxyproto_test.go.
///
public class ProxyProtocolTests
{
// -------------------------------------------------------------------------
// Build helpers (mirror the Go buildProxy* helpers)
// -------------------------------------------------------------------------
/// Wraps the static builder for convenience inside tests.
private static byte[] BuildV2Header(
string srcIp, string dstIp, ushort srcPort, ushort dstPort, bool ipv6 = false)
=> ProxyProtocolParser.BuildV2Header(srcIp, dstIp, srcPort, dstPort, ipv6);
private static byte[] BuildV2LocalHeader()
=> ProxyProtocolParser.BuildV2LocalHeader();
private static byte[] BuildV1Header(
string protocol, string srcIp = "", string dstIp = "", ushort srcPort = 0, ushort dstPort = 0)
=> ProxyProtocolParser.BuildV1Header(protocol, srcIp, dstIp, srcPort, dstPort);
// =========================================================================
// PROXY protocol v2 tests
// =========================================================================
///
/// Parses a well-formed v2 PROXY header carrying an IPv4 source address and
/// verifies that the extracted src/dst IP, port, and network string are correct.
/// Ref: TestClientProxyProtoV2ParseIPv4 (client_proxyproto_test.go:155)
///
[Fact]
public void V2_parses_IPv4_address()
{
var header = BuildV2Header("192.168.1.50", "10.0.0.1", 12345, 4222);
var result = ProxyProtocolParser.Parse(header);
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
result.Address.ShouldNotBeNull();
result.Address.SrcIp.ToString().ShouldBe("192.168.1.50");
result.Address.SrcPort.ShouldBe((ushort)12345);
result.Address.DstIp.ToString().ShouldBe("10.0.0.1");
result.Address.DstPort.ShouldBe((ushort)4222);
result.Address.ToString().ShouldBe("192.168.1.50:12345");
result.Address.Network.ShouldBe("tcp4");
}
///
/// Parses a well-formed v2 PROXY header carrying an IPv6 source address and
/// verifies that the extracted src/dst IP, port, and network string are correct.
/// Ref: TestClientProxyProtoV2ParseIPv6 (client_proxyproto_test.go:174)
///
[Fact]
public void V2_parses_IPv6_address()
{
var header = BuildV2Header("2001:db8::1", "2001:db8::2", 54321, 4222, ipv6: true);
var result = ProxyProtocolParser.Parse(header);
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
result.Address.ShouldNotBeNull();
result.Address.SrcIp.ToString().ShouldBe("2001:db8::1");
result.Address.SrcPort.ShouldBe((ushort)54321);
result.Address.DstIp.ToString().ShouldBe("2001:db8::2");
result.Address.DstPort.ShouldBe((ushort)4222);
result.Address.ToString().ShouldBe("[2001:db8::1]:54321");
result.Address.Network.ShouldBe("tcp6");
}
///
/// A LOCAL command header (health check) must parse successfully and return
/// a Local result with no address.
/// Ref: TestClientProxyProtoV2ParseLocalCommand (client_proxyproto_test.go:193)
///
[Fact]
public void V2_LOCAL_command_returns_local_result()
{
var header = BuildV2LocalHeader();
var result = ProxyProtocolParser.Parse(header);
result.Kind.ShouldBe(ProxyParseResultKind.Local);
result.Address.ShouldBeNull();
}
///
/// A v2 header with an invalid 12-byte signature must throw
/// . The test calls
/// directly so the full-signature check is exercised (auto-detection would classify the
/// buffer as "unrecognized" before reaching the signature comparison).
/// Ref: TestClientProxyProtoV2InvalidSignature (client_proxyproto_test.go:202)
///
[Fact]
public void V2_invalid_signature_throws()
{
// Build a 16-byte buffer whose first 12 bytes are garbage — ParseV2 must
// reject it because the full signature comparison fails.
var header = new byte[16];
Encoding.ASCII.GetBytes("INVALID_SIG_").CopyTo(header, 0);
header[12] = 0x20; // ver/cmd
header[13] = 0x11; // fam/proto
header[14] = 0x00;
header[15] = 0x0C;
// Use ParseV2 directly — this validates the complete 12-byte signature.
Should.Throw(() => ProxyProtocolParser.ParseV2(header));
}
///
/// A v2 header where the version nibble is not 2 must be rejected.
/// Ref: TestClientProxyProtoV2InvalidVersion (client_proxyproto_test.go:212)
///
[Fact]
public void V2_invalid_version_nibble_throws()
{
var ms = new MemoryStream();
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A")); // valid sig
ms.WriteByte(0x10 | 0x01); // version = 1 (wrong), command = PROXY
ms.WriteByte(0x10 | 0x01); // family = IPv4, proto = STREAM
ms.WriteByte(0x00);
ms.WriteByte(0x00);
Should.Throw(() => ProxyProtocolParser.ParseV2(ms.ToArray()));
}
///
/// A v2 PROXY command with the Unix socket address family must be rejected
/// with an unsupported-feature exception.
/// Ref: TestClientProxyProtoV2UnsupportedFamily (client_proxyproto_test.go:226)
///
[Fact]
public void V2_unix_socket_family_is_unsupported()
{
var ms = new MemoryStream();
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"));
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
ms.WriteByte(0x30 | 0x01); // family=Unix, proto=STREAM
ms.WriteByte(0x00);
ms.WriteByte(0x00);
Should.Throw(() => ProxyProtocolParser.ParseV2(ms.ToArray()));
}
///
/// A v2 PROXY command with the UDP (Datagram) protocol must be rejected
/// with an unsupported-feature exception.
/// Ref: TestClientProxyProtoV2UnsupportedProtocol (client_proxyproto_test.go:240)
///
[Fact]
public void V2_datagram_protocol_is_unsupported()
{
var ms = new MemoryStream();
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"));
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
ms.WriteByte(0x10 | 0x02); // family=IPv4, proto=DATAGRAM (UDP)
ms.WriteByte(0x00);
ms.WriteByte(0x0C); // addr-len = 12
Should.Throw(() => ProxyProtocolParser.ParseV2(ms.ToArray()));
}
///
/// A truncated v2 header (only 10 of the required 16 bytes) must throw.
/// Ref: TestClientProxyProtoV2TruncatedHeader (client_proxyproto_test.go:254)
///
[Fact]
public void V2_truncated_header_throws()
{
var full = BuildV2Header("192.168.1.50", "10.0.0.1", 12345, 4222);
Should.Throw(() => ProxyProtocolParser.Parse(full[..10]));
}
///
/// A v2 header whose address-length field says 12 bytes but the buffer
/// supplies only 5 bytes must throw.
/// Ref: TestClientProxyProtoV2ShortAddressData (client_proxyproto_test.go:263)
///
[Fact]
public void V2_short_address_data_throws()
{
var ms = new MemoryStream();
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"));
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
ms.WriteByte(0x10 | 0x01); // family=IPv4, proto=STREAM
ms.WriteByte(0x00);
ms.WriteByte(0x0C); // addr-len = 12
// Write only 5 bytes of address data instead of 12
ms.Write(new byte[] { 1, 2, 3, 4, 5 });
Should.Throw(() => ProxyProtocolParser.ParseV2(ms.ToArray()));
}
///
/// ProxyAddress.ToString() returns "ip:port" for IPv4 and "[ip]:port" for IPv6;
/// ProxyAddress.Network() returns "tcp4" or "tcp6" accordingly.
/// Ref: TestProxyConnRemoteAddr (client_proxyproto_test.go:280)
///
[Fact]
public void ProxyAddress_string_and_network_are_correct()
{
var ipv4Addr = new ProxyAddress
{
SrcIp = IPAddress.Parse("10.0.0.50"),
SrcPort = 12345,
DstIp = IPAddress.Parse("10.0.0.1"),
DstPort = 4222,
};
ipv4Addr.ToString().ShouldBe("10.0.0.50:12345");
ipv4Addr.Network.ShouldBe("tcp4");
var ipv6Addr = new ProxyAddress
{
SrcIp = IPAddress.Parse("2001:db8::1"),
SrcPort = 54321,
DstIp = IPAddress.Parse("2001:db8::2"),
DstPort = 4222,
};
ipv6Addr.ToString().ShouldBe("[2001:db8::1]:54321");
ipv6Addr.Network.ShouldBe("tcp6");
}
// =========================================================================
// PROXY protocol v1 tests
// =========================================================================
///
/// A well-formed TCP4 v1 header is parsed and the source address is returned.
/// Ref: TestClientProxyProtoV1ParseTCP4 (client_proxyproto_test.go:416)
///
[Fact]
public void V1_parses_TCP4_address()
{
var header = BuildV1Header("TCP4", "192.168.1.50", "10.0.0.1", 12345, 4222);
var result = ProxyProtocolParser.Parse(header);
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
result.Address.ShouldNotBeNull();
result.Address.SrcIp.ToString().ShouldBe("192.168.1.50");
result.Address.SrcPort.ShouldBe((ushort)12345);
result.Address.DstIp.ToString().ShouldBe("10.0.0.1");
result.Address.DstPort.ShouldBe((ushort)4222);
}
///
/// A well-formed TCP6 v1 header is parsed and the source IPv6 address is returned.
/// Ref: TestClientProxyProtoV1ParseTCP6 (client_proxyproto_test.go:431)
///
[Fact]
public void V1_parses_TCP6_address()
{
var header = BuildV1Header("TCP6", "2001:db8::1", "2001:db8::2", 54321, 4222);
var result = ProxyProtocolParser.Parse(header);
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
result.Address.ShouldNotBeNull();
result.Address.SrcIp.ToString().ShouldBe("2001:db8::1");
result.Address.SrcPort.ShouldBe((ushort)54321);
result.Address.DstIp.ToString().ShouldBe("2001:db8::2");
result.Address.DstPort.ShouldBe((ushort)4222);
}
///
/// An UNKNOWN v1 header (health check) must return a Local result with no address.
/// Ref: TestClientProxyProtoV1ParseUnknown (client_proxyproto_test.go:446)
///
[Fact]
public void V1_UNKNOWN_returns_local_result()
{
var header = BuildV1Header("UNKNOWN");
var result = ProxyProtocolParser.Parse(header);
result.Kind.ShouldBe(ProxyParseResultKind.Local);
result.Address.ShouldBeNull();
}
///
/// A v1 header with too few fields (e.g. missing port tokens) must throw.
/// Ref: TestClientProxyProtoV1InvalidFormat (client_proxyproto_test.go:455)
///
[Fact]
public void V1_missing_fields_throws()
{
// "PROXY TCP4 192.168.1.1\r\n" — only 1 token after PROXY
var header = Encoding.ASCII.GetBytes("PROXY TCP4 192.168.1.1\r\n");
Should.Throw(() => ProxyProtocolParser.Parse(header));
}
///
/// A v1 line longer than 107 bytes without a CRLF must throw.
/// Ref: TestClientProxyProtoV1LineTooLong (client_proxyproto_test.go:464)
///
[Fact]
public void V1_line_too_long_throws()
{
var longIp = new string('1', 120);
var header = Encoding.ASCII.GetBytes($"PROXY TCP4 {longIp} 10.0.0.1 12345 443\r\n");
Should.Throw(() => ProxyProtocolParser.Parse(header));
}
///
/// A v1 header whose IP token is not a parseable IP address must throw.
/// Ref: TestClientProxyProtoV1InvalidIP (client_proxyproto_test.go:474)
///
[Fact]
public void V1_invalid_IP_address_throws()
{
var header = Encoding.ASCII.GetBytes("PROXY TCP4 not.an.ip.addr 10.0.0.1 12345 443\r\n");
Should.Throw(() => ProxyProtocolParser.Parse(header));
}
///
/// TCP4 protocol with an IPv6 source address, and TCP6 protocol with an IPv4
/// source address, must both throw a protocol-mismatch exception.
/// Ref: TestClientProxyProtoV1MismatchedProtocol (client_proxyproto_test.go:482)
///
[Fact]
public void V1_TCP4_with_IPv6_address_throws()
{
var header = BuildV1Header("TCP4", "2001:db8::1", "2001:db8::2", 12345, 443);
Should.Throw(() => ProxyProtocolParser.Parse(header));
}
[Fact]
public void V1_TCP6_with_IPv4_address_throws()
{
var header = BuildV1Header("TCP6", "192.168.1.1", "10.0.0.1", 12345, 443);
Should.Throw(() => ProxyProtocolParser.Parse(header));
}
///
/// A port value that exceeds 65535 cannot be parsed as ushort and must throw.
/// Ref: TestClientProxyProtoV1InvalidPort (client_proxyproto_test.go:498)
///
[Fact]
public void V1_port_out_of_range_throws()
{
var header = Encoding.ASCII.GetBytes("PROXY TCP4 192.168.1.1 10.0.0.1 99999 443\r\n");
Should.Throw(() => ProxyProtocolParser.Parse(header));
}
// =========================================================================
// Mixed version detection tests
// =========================================================================
///
/// The auto-detection logic correctly routes a "PROXY " prefix to the v1 parser
/// and a binary v2 signature to the v2 parser, extracting the correct source address.
/// Ref: TestClientProxyProtoVersionDetection (client_proxyproto_test.go:567)
///
[Fact]
public void Auto_detection_routes_v1_and_v2_correctly()
{
var v1Header = BuildV1Header("TCP4", "192.168.1.1", "10.0.0.1", 12345, 443);
var r1 = ProxyProtocolParser.Parse(v1Header);
r1.Kind.ShouldBe(ProxyParseResultKind.Proxy);
r1.Address!.SrcIp.ToString().ShouldBe("192.168.1.1");
var v2Header = BuildV2Header("192.168.1.2", "10.0.0.1", 54321, 443);
var r2 = ProxyProtocolParser.Parse(v2Header);
r2.Kind.ShouldBe(ProxyParseResultKind.Proxy);
r2.Address!.SrcIp.ToString().ShouldBe("192.168.1.2");
}
///
/// A header that starts with neither "PROXY " nor the v2 binary signature must
/// throw a indicating the format is unrecognized.
/// Ref: TestClientProxyProtoUnrecognizedVersion (client_proxyproto_test.go:587)
///
[Fact]
public void Unrecognized_header_throws()
{
var header = Encoding.ASCII.GetBytes("HELLO WORLD\r\n");
Should.Throw(() => ProxyProtocolParser.Parse(header));
}
///
/// A data buffer shorter than 6 bytes cannot carry any valid PROXY header prefix
/// and must throw.
///
[Fact]
public void Too_short_input_throws()
{
Should.Throw(() => ProxyProtocolParser.Parse(new byte[] { 0x50, 0x52 }));
}
// =========================================================================
// Additional edge cases (not directly from Go tests but needed for full coverage)
// =========================================================================
///
/// ParseV1 operating directly on the bytes after the "PROXY " prefix correctly
/// extracts a TCP4 address without going through the auto-detector.
///
[Fact]
public void ParseV1_direct_entry_point_works()
{
var afterPrefix = Encoding.ASCII.GetBytes("TCP4 1.2.3.4 5.6.7.8 1234 4222\r\n");
var result = ProxyProtocolParser.ParseV1(afterPrefix);
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
result.Address!.SrcIp.ToString().ShouldBe("1.2.3.4");
result.Address.SrcPort.ShouldBe((ushort)1234);
}
///
/// ParseV2AfterSig operating on the 4-byte post-signature header correctly parses
/// a PROXY command with the full IPv4 address block appended.
///
[Fact]
public void ParseV2AfterSig_direct_entry_point_works()
{
// Build just the 4 header bytes + 12 address bytes (no sig)
var ms = new MemoryStream();
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
ms.WriteByte(0x10 | 0x01); // family=IPv4, proto=STREAM
ms.WriteByte(0x00);
ms.WriteByte(0x0C); // addr-len = 12
// src IP 192.168.0.1, dst IP 10.0.0.1, src port 9999, dst port 4222
ms.Write(IPAddress.Parse("192.168.0.1").GetAddressBytes());
ms.Write(IPAddress.Parse("10.0.0.1").GetAddressBytes());
var ports = new byte[4];
BinaryPrimitives.WriteUInt16BigEndian(ports.AsSpan(0), 9999);
BinaryPrimitives.WriteUInt16BigEndian(ports.AsSpan(2), 4222);
ms.Write(ports);
var result = ProxyProtocolParser.ParseV2AfterSig(ms.ToArray());
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
result.Address!.SrcIp.ToString().ShouldBe("192.168.0.1");
result.Address.SrcPort.ShouldBe((ushort)9999);
result.Address.DstPort.ShouldBe((ushort)4222);
}
///
/// A v2 UNSPEC family with PROXY command returns a Local result (no address override).
/// The Go implementation discards unspec address data and returns nil addr.
///
[Fact]
public void V2_UNSPEC_family_returns_local()
{
var ms = new MemoryStream();
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"));
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
ms.WriteByte(0x00 | 0x01); // family=UNSPEC, proto=STREAM
ms.WriteByte(0x00);
ms.WriteByte(0x00); // addr-len = 0
var result = ProxyProtocolParser.ParseV2(ms.ToArray());
result.Kind.ShouldBe(ProxyParseResultKind.Local);
result.Address.ShouldBeNull();
}
///
/// BuildV2Header round-trips — parsing the output of the builder yields the same
/// addresses that were passed in, for both IPv4 and IPv6.
///
[Fact]
public void BuildV2Header_round_trips_IPv4()
{
var bytes = BuildV2Header("203.0.113.50", "127.0.0.1", 54321, 4222);
var result = ProxyProtocolParser.Parse(bytes);
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
result.Address!.SrcIp.ToString().ShouldBe("203.0.113.50");
result.Address.SrcPort.ShouldBe((ushort)54321);
result.Address.DstIp.ToString().ShouldBe("127.0.0.1");
result.Address.DstPort.ShouldBe((ushort)4222);
}
[Fact]
public void BuildV2Header_round_trips_IPv6()
{
var bytes = BuildV2Header("fe80::1", "fe80::2", 1234, 4222, ipv6: true);
var result = ProxyProtocolParser.Parse(bytes);
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
result.Address!.Network.ShouldBe("tcp6");
result.Address.SrcPort.ShouldBe((ushort)1234);
}
///
/// BuildV1Header round-trips for both TCP4 and TCP6 lines.
///
[Fact]
public void BuildV1Header_round_trips_TCP4()
{
var bytes = BuildV1Header("TCP4", "203.0.113.50", "127.0.0.1", 54321, 4222);
var result = ProxyProtocolParser.Parse(bytes);
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
result.Address!.SrcIp.ToString().ShouldBe("203.0.113.50");
result.Address.SrcPort.ShouldBe((ushort)54321);
}
[Fact]
public void BuildV1Header_round_trips_TCP6()
{
var bytes = BuildV1Header("TCP6", "2001:db8::cafe", "2001:db8::1", 11111, 4222);
var result = ProxyProtocolParser.Parse(bytes);
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
result.Address!.SrcIp.ToString().ShouldBe("2001:db8::cafe");
result.Address.SrcPort.ShouldBe((ushort)11111);
}
}