From 90cf559cc2f5c942577ceba4fd6f5890594a5404 Mon Sep 17 00:00:00 2001 From: Benoit Plessis Date: Fri, 31 Oct 2025 14:35:29 +0100 Subject: [PATCH 01/25] RAV-2958 - Add UDP Socket support --- pom.xml | 17 ++ proxy-socket-udp/pom.xml | 61 +++++ .../proxysocket/udp/ProxyDatagramSocket.java | 102 +++++++ .../ProxyDatagramSocketIntegrationTest.java | 259 ++++++++++++++++++ 4 files changed, 439 insertions(+) create mode 100644 proxy-socket-udp/pom.xml create mode 100644 proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java create mode 100644 proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIntegrationTest.java diff --git a/pom.xml b/pom.xml index c4ef13f..dfe14d6 100755 --- a/pom.xml +++ b/pom.xml @@ -16,13 +16,30 @@ 5.10.3 + 33.3.1-jre proxy-socket-core + proxy-socket-udp + + + + + org.testcontainers + testcontainers-bom + 2.0.1 + pom + import + + + + diff --git a/proxy-socket-udp/pom.xml b/proxy-socket-udp/pom.xml new file mode 100644 index 0000000..bf4015d --- /dev/null +++ b/proxy-socket-udp/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + net.airvantage + proxysocket-java + 1.0.0-SNAPSHOT + + proxy-socket-udp + Proxy Protocol - UDP + jar + + + + net.airvantage + proxy-socket-core + ${project.version} + + + + org.slf4j + slf4j-api + 2.0.16 + true + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + testcontainers-nginx + test + + + + org.slf4j + slf4j-simple + 2.0.16 + test + + + + + + diff --git a/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java b/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java new file mode 100644 index 0000000..385688d --- /dev/null +++ b/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java @@ -0,0 +1,102 @@ +/* + * MIT License + * Copyright (c) 2025 Semtech + */ +package net.airvantage.proxysocket.udp; + +import net.airvantage.proxysocket.core.ProxyAddressCache; +import net.airvantage.proxysocket.core.ProxyProtocolMetricsListener; +import net.airvantage.proxysocket.core.cache.ConcurrentMapProxyAddressCache; +import net.airvantage.proxysocket.core.v2.ProxyHeader; +import net.airvantage.proxysocket.core.v2.ProxyProtocolV2Decoder; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.net.SocketException; +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.function.Predicate; + +/** + * DatagramSocket that strips Proxy Protocol v2 headers and exposes real client address. + * + * Thread-safety: This class is thread-safe to the extent that {@link DatagramSocket} + * is documented as thread-safe for concurrent send/receive by the JDK. The internal + * cache and metrics listener are expected to be thread-safe. The implementation does + * not mutate shared state beyond those collaborators. + */ +public class ProxyDatagramSocket extends DatagramSocket { + private static final Logger LOG = Logger.getLogger(ProxyDatagramSocket.class.getName()); + + private ProxyAddressCache addressCache; + private ProxyProtocolMetricsListener metrics; + private Predicate trustedProxyPredicate; + + public ProxyDatagramSocket() throws SocketException { + super(); + } + + public ProxyDatagramSocket(SocketAddress bindaddr) throws SocketException { + super(bindaddr); + } + + public ProxyDatagramSocket(int port) throws SocketException { + super(port); + } + + public ProxyDatagramSocket(int port, java.net.InetAddress laddr) throws SocketException { + super(port, laddr); + } + + public ProxyDatagramSocket setCache(ProxyAddressCache cache) { this.addressCache = cache; return this; } + public ProxyDatagramSocket setMetrics(ProxyProtocolMetricsListener metrics) { this.metrics = metrics; return this; } + public ProxyDatagramSocket setTrustedProxy(Predicate predicate) { this.trustedProxyPredicate = predicate; return this; } + + @Override + public void receive(DatagramPacket packet) throws IOException { + super.receive(packet); + try { + InetSocketAddress lbAddress = (InetSocketAddress) packet.getSocketAddress(); + if (trustedProxyPredicate != null && !trustedProxyPredicate.test(lbAddress)) { + // Untrusted source: do not parse, deliver original packet + return; + } + + ProxyHeader header = ProxyProtocolV2Decoder.parse(packet.getData(), packet.getOffset(), packet.getLength()); + if (metrics != null) metrics.onHeaderParsed(header); + if (header.isLocal()) { + // LOCAL: not proxied + } else if (header.isProxy() && header.getProtocol() == ProxyHeader.TransportProtocol.DGRAM) { + InetSocketAddress realClient = header.getSourceAddress(); + if (realClient != null && lbAddress != null) { + if (addressCache != null) addressCache.put(realClient, lbAddress); + packet.setSocketAddress(realClient); + } + } + int headerLen = header.getHeaderLength(); + packet.setData(packet.getData(), packet.getOffset() + headerLen, packet.getLength() - headerLen); + } catch (Exception e) { + LOG.log(Level.WARNING, "Proxy socket parse error; delivering original packet.", e); + if (metrics != null) metrics.onParseError(e); + } + } + + @Override + public void send(DatagramPacket packet) throws IOException { + InetSocketAddress client = (InetSocketAddress) packet.getSocketAddress(); + InetSocketAddress lb = addressCache != null ? addressCache.get(client) : null; + if (lb != null) { + packet.setSocketAddress(lb); + if (metrics != null) metrics.onCacheHit(client); + } else { + if (metrics != null) metrics.onCacheMiss(client); + } + super.send(packet); + } +} + + diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIntegrationTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIntegrationTest.java new file mode 100644 index 0000000..ffa96fd --- /dev/null +++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIntegrationTest.java @@ -0,0 +1,259 @@ +/* + * MIT License + * Copyright (c) 2025 Semtech + */ +package net.airvantage.proxysocket.udp; + +import net.airvantage.proxysocket.core.ProxyProtocolMetricsListener; +import net.airvantage.proxysocket.core.cache.ConcurrentMapProxyAddressCache; +import net.airvantage.proxysocket.core.v2.ProxyHeader; +import net.airvantage.proxysocket.core.v2.ProxyProtocolV2Encoder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.Testcontainers; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.nginx.NginxContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.output.OutputFrame; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.images.builder.Transferable; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.InternetProtocol; +import com.github.dockerjava.api.model.Ports; +import com.github.dockerjava.api.model.Ports.Binding; + +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.junit.jupiter.api.Assertions.*; + + +/** + * End-to-end UDP integration using an in-process Proxy Protocol v2 injector. + * The injector emulates a UDP-aware LB (e.g., NGINX/Envoy) that prepends PPv2 + * and forwards datagrams to the backend echo server built on ProxyDatagramSocket. + */ +public class ProxyDatagramSocketIntegrationTest { + + private static final byte[] PAYLOAD = "hello".getBytes(StandardCharsets.UTF_8); + private static final Logger LOG = LoggerFactory.getLogger(ProxyDatagramSocketIntegrationTest.class); + + private DatagramSocket client; + private ProxyDatagramSocket backend; + private DatagramSocket injector; + private int backendPort; + private int injectorPort; + + private java.util.concurrent.ExecutorService executor; + private Future backendLoop; + + @BeforeEach + void setUp() throws Exception { + client = new DatagramSocket(); + client.setSoTimeout(3000); + + ConcurrentMapProxyAddressCache cache = new ConcurrentMapProxyAddressCache(); + backend = new ProxyDatagramSocket((new InetSocketAddress(InetAddress.getLoopbackAddress(), 0))) + .setCache(cache) + .setMetrics(new NoopMetrics()); + backendPort = backend.getLocalPort(); + LOG.info("Backend listening on 127.0.0.1:{}", backendPort); + +// injector = new DatagramSocket(new InetSocketAddress("127.0.0.1", 0)); +// injectorPort = injector.getLocalPort(); + + // Start backend echo loop + executor = Executors.newSingleThreadExecutor(); + backendLoop = executor.submit(() -> { + try { + byte[] buf = new byte[2048]; + DatagramPacket p = new DatagramPacket(buf, buf.length); + while (!Thread.currentThread().isInterrupted()) { + backend.receive(p); + + LOG.info("Received {} bytes request from {} original address {}", p.getLength(), p.getSocketAddress(), cache.get((InetSocketAddress)p.getSocketAddress())); + + // Echo back exactly what was after proxy header + byte[] echo = new byte[p.getLength()]; + System.arraycopy(p.getData(), p.getOffset(), echo, 0, p.getLength()); + p.setData(echo); + backend.send(p); + p.setData(buf); + } + } catch (SocketException ignore) { + // socket closed during shutdown + } catch (Exception e) { + // Allow exceptions to fail the test + throw new RuntimeException(e); + } + }); + } + + @AfterEach + void tearDown() throws Exception { + if (backendLoop != null) backendLoop.cancel(true); + if (executor != null) executor.shutdownNow(); + if (backend != null) backend.close(); + // if (injector != null) injector.close(); + if (client != null) client.close(); + } + + /* + @Test + void udpEndToEnd_withProxyProtocolV2Header() throws Exception { + // Source perceived by the LB (injector) is client.getLocalAddress():client.getLocalPort() + InetSocketAddress src = new InetSocketAddress(InetAddress.getByName("127.0.0.1"), client.getLocalPort()); + InetSocketAddress dst = new InetSocketAddress(InetAddress.getByName("127.0.0.1"), backendPort); + + byte[] header = new ProxyProtocolV2Encoder() + .family(ProxyHeader.AddressFamily.INET4) + .socket(ProxyHeader.TransportProtocol.DGRAM) + .source(src) + .destination(dst) + .build(); + + byte[] out = new byte[header.length + PAYLOAD.length]; + System.arraycopy(header, 0, out, 0, header.length); + System.arraycopy(PAYLOAD, 0, out, header.length, PAYLOAD.length); + + // Send to injector; injector forwards to backend and back to client + DatagramPacket toInjector = new DatagramPacket(out, out.length, new InetSocketAddress("127.0.0.1", injectorPort)); + // Set up injector forwarder (bi-directional for the test) + BlockingQueue forwardQueue = new ArrayBlockingQueue<>(1); + Executors.newSingleThreadExecutor().execute(() -> { + try { + byte[] buf = new byte[4096]; + DatagramPacket p = new DatagramPacket(buf, buf.length); + // Receive from client + injector.receive(p); + InetSocketAddress clientAddr = (InetSocketAddress) p.getSocketAddress(); + byte[] recv = new byte[p.getLength()]; + System.arraycopy(p.getData(), p.getOffset(), recv, 0, p.getLength()); + forwardQueue.add(recv); + + // Forward to backend + DatagramPacket toBackend = new DatagramPacket(recv, recv.length, new InetSocketAddress("127.0.0.1", backendPort)); + injector.send(toBackend); + + // Await response from backend + DatagramPacket fromBackend = new DatagramPacket(new byte[4096], 4096); + injector.receive(fromBackend); + byte[] backendResp = new byte[fromBackend.getLength()]; + System.arraycopy(fromBackend.getData(), fromBackend.getOffset(), backendResp, 0, fromBackend.getLength()); + + // Forward back to original client + DatagramPacket backToClient = new DatagramPacket(backendResp, backendResp.length, clientAddr); + injector.send(backToClient); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + client.send(toInjector); + + // Receive echo back through backend -> injector -> client + DatagramPacket resp = new DatagramPacket(new byte[4096], 4096); + resp.setLength(4096); + client.receive(resp); + + String respStr = new String(resp.getData(), resp.getOffset(), resp.getLength(), StandardCharsets.UTF_8); + assertEquals("hello", respStr); + + // Sanity: ensure injector forwarded the PROXY header + payload to backend + byte[] forwarded = forwardQueue.poll(2, java.util.concurrent.TimeUnit.SECONDS); + assertNotNull(forwarded); + assertTrue(forwarded.length >= header.length + PAYLOAD.length); + } */ + + @Test + void udpEndToEnd_viaNginxContainer_proxyProtocolV2() throws Exception { + // Expose the host backend port to containers using Testcontainers' gateway helper + Testcontainers.exposeHostPorts(backendPort); + + int nginxInternalPort = 5684; // container internal UDP listen port + + String nginxConf = "worker_processes 1;\n" + + "events { worker_connections 1024; }\n" + + "stream {\n" + + " upstream backend { server host.docker.internal:" + backendPort + "; }\n" + + " log_format proxy '$remote_addr [$time_local] '\n" + + " '$protocol $status $bytes_sent $bytes_received '\n" + + " '$session_time \"$upstream_addr\" '\n" + + " '\"$upstream_bytes_sent\" \"$upstream_bytes_received\" \"$upstream_connect_time\"';" + + " server {\n" + + " listen 0.0.0.0:" + nginxInternalPort + " udp;\n" + + " proxy_pass backend;\n" + + " proxy_responses 1;\n" + + " proxy_timeout 5s;\n" + + " proxy_protocol on;\n" + + " access_log stdout proxy;\n" + + " }\n" + + "}\n"; + + ExposedPort udp = new ExposedPort(nginxInternalPort, InternetProtocol.UDP); + + GenericContainer nginx = new GenericContainer<>(DockerImageName.parse("nginx:1.29-alpine")) + .withCopyToContainer(Transferable.of(nginxConf.getBytes(StandardCharsets.UTF_8)), "/etc/nginx/nginx.conf") + //.withCommand("nginx", "-g", "daemon off;") + .withCreateContainerCmdModifier(cmd -> { + List exposedPorts = new ArrayList<>(); + for (ExposedPort p : cmd.getExposedPorts()) { + exposedPorts.add(p); + } + exposedPorts.add(udp); + cmd.withExposedPorts(exposedPorts); + + //Add previous port bindings and UDP port binding + Ports ports = cmd.getPortBindings(); + ports.bind(udp, Ports.Binding.bindIp("0.0.0.0")); + cmd.withPortBindings(ports); + }) + .withLogConsumer(new Slf4jLogConsumer(LOG).withSeparateOutputStreams()); + nginx.start(); + + String containerIpAddress = nginx.getHost(); + Ports.Binding[] bindings = nginx.getContainerInfo().getNetworkSettings().getPorts().getBindings().get(udp); + int containerPort = Integer.parseInt(bindings[0].getHostPortSpec()); + LOG.info("NGINX container host: {}, mapped UDP port: {} -> {}:{}", containerIpAddress, nginxInternalPort, containerIpAddress, containerPort); + + try { + // Java client sends to mapped host UDP port and expects echo + DatagramSocket sock = new DatagramSocket(); + sock.setSoTimeout(300000); + byte[] data = PAYLOAD; + DatagramPacket toNginx = new DatagramPacket(data, data.length, new InetSocketAddress(containerIpAddress, containerPort)); + LOG.info("Sending {} bytes to {}:{}", data.length, containerIpAddress, containerPort); + sock.send(toNginx); + + DatagramPacket resp = new DatagramPacket(new byte[4096], 4096); + sock.receive(resp); + String respStr = new String(resp.getData(), resp.getOffset(), resp.getLength(), StandardCharsets.UTF_8); + LOG.info("Received {} bytes response: '{}'", resp.getLength(), respStr); + assertEquals("hello", respStr); + sock.close(); + } finally { + try { nginx.stop(); } catch (Throwable ignore) {} + } + } + + static class NoopMetrics implements ProxyProtocolMetricsListener { + @Override public void onHeaderParsed(ProxyHeader header) { } + @Override public void onParseError(Exception e) { } + @Override public void onCacheHit(InetSocketAddress client) { } + @Override public void onCacheMiss(InetSocketAddress client) { } + } +} + + From e250c97e4061b81b556fee3c6c75e503dce75e08 Mon Sep 17 00:00:00 2001 From: Benoit Plessis Date: Tue, 4 Nov 2025 11:05:18 +0100 Subject: [PATCH 02/25] disable integration test with nginx doesn't work --- .../udp/ProxyDatagramSocketIPMappingTest.java | 279 ++++++++++++++++++ .../ProxyDatagramSocketIntegrationTest.java | 86 +++--- .../udp/ProxyDatagramSocketMetricsTest.java | 209 +++++++++++++ 3 files changed, 541 insertions(+), 33 deletions(-) create mode 100644 proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIPMappingTest.java create mode 100644 proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketMetricsTest.java diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIPMappingTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIPMappingTest.java new file mode 100644 index 0000000..90ac7d2 --- /dev/null +++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIPMappingTest.java @@ -0,0 +1,279 @@ +/* + * MIT License + * Copyright (c) 2025 Semtech + */ +package net.airvantage.proxysocket.udp; + +import net.airvantage.proxysocket.core.ProxyAddressCache; +import net.airvantage.proxysocket.core.ProxyProtocolMetricsListener; +import net.airvantage.proxysocket.core.v2.ProxyHeader; +import net.airvantage.proxysocket.core.v2.ProxyProtocolV2Encoder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.net.DatagramPacket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for ProxyDatagramSocket IP address mapping and cache behavior. + */ +class ProxyDatagramSocketIPMappingTest { + + private ProxyDatagramSocket socket; + private ProxyAddressCache mockCache; + private ProxyProtocolMetricsListener mockMetrics; + private int localPort; + + @BeforeEach + void setUp() throws Exception { + mockCache = mock(ProxyAddressCache.class); + mockMetrics = mock(ProxyProtocolMetricsListener.class); + + socket = new ProxyDatagramSocket(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)) + .setCache(mockCache) + .setMetrics(mockMetrics) + .setTrustedProxy(addr -> true); // Trust all for these tests + + localPort = socket.getLocalPort(); + } + + @AfterEach + void tearDown() { + if (socket != null && !socket.isClosed()) { + socket.close(); + } + } + + @Test + void receive_withValidProxyHeader_populatesCache() throws Exception { + // Arrange + InetSocketAddress realClient = new InetSocketAddress("10.1.2.3", 12345); + InetSocketAddress lbAddress = new InetSocketAddress("127.0.0.1", 54321); + byte[] payload = "test-data".getBytes(StandardCharsets.UTF_8); + + byte[] proxyHeader = new ProxyProtocolV2Encoder() + .family(ProxyHeader.AddressFamily.INET4) + .socket(ProxyHeader.TransportProtocol.DGRAM) + .source(realClient) + .destination(new InetSocketAddress("127.0.0.1", localPort)) + .build(); + + byte[] packet = new byte[proxyHeader.length + payload.length]; + System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length); + System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length); + + // Create a loopback socket to send from + try (java.net.DatagramSocket sender = new java.net.DatagramSocket(lbAddress)) { + sender.send(new DatagramPacket(packet, packet.length, + new InetSocketAddress("127.0.0.1", localPort))); + } + + // Act + byte[] receiveBuf = new byte[2048]; + DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length); + socket.receive(receivePacket); + + // Assert - cache should be populated with realClient -> lbAddress mapping + ArgumentCaptor clientCaptor = ArgumentCaptor.forClass(InetSocketAddress.class); + ArgumentCaptor lbCaptor = ArgumentCaptor.forClass(InetSocketAddress.class); + verify(mockCache).put(clientCaptor.capture(), lbCaptor.capture()); + + assertEquals(realClient, clientCaptor.getValue()); + assertEquals(lbAddress.getAddress(), lbCaptor.getValue().getAddress()); + assertEquals(lbAddress.getPort(), lbCaptor.getValue().getPort()); + + // Verify packet was modified to show real client address + assertEquals(realClient, receivePacket.getSocketAddress()); + + // Verify payload was stripped of proxy header + assertEquals(payload.length, receivePacket.getLength()); + assertArrayEquals(payload, + java.util.Arrays.copyOfRange(receivePacket.getData(), + receivePacket.getOffset(), + receivePacket.getOffset() + receivePacket.getLength())); + } + + @Test + void send_withCacheHit_usesLoadBalancerAddress() throws Exception { + // Arrange + InetSocketAddress realClient = new InetSocketAddress("10.1.2.3", 12345); + InetSocketAddress lbAddress = new InetSocketAddress("127.0.0.1", 54321); + byte[] payload = "response".getBytes(StandardCharsets.UTF_8); + + // Mock cache to return lb address + when(mockCache.get(realClient)).thenReturn(lbAddress); + + // Create a receiver to verify the packet destination + java.net.DatagramSocket receiver = new java.net.DatagramSocket(lbAddress); + receiver.setSoTimeout(1000); + + try { + // Act - send to real client, should be redirected to LB + DatagramPacket sendPacket = new DatagramPacket(payload, payload.length, realClient); + socket.send(sendPacket); + + // Verify packet was sent to LB address + byte[] receiveBuf = new byte[2048]; + DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length); + receiver.receive(receivePacket); + + // Assert + assertArrayEquals(payload, + java.util.Arrays.copyOfRange(receivePacket.getData(), 0, receivePacket.getLength())); + + // Verify cache was queried + verify(mockCache).get(realClient); + + // Verify metrics - cache hit + verify(mockMetrics).onCacheHit(realClient); + verify(mockMetrics, never()).onCacheMiss(any()); + } finally { + receiver.close(); + } + } + + @Test + void send_withCacheMiss_usesOriginalAddress() throws Exception { + // Arrange + InetSocketAddress clientAddress = new InetSocketAddress("127.0.0.1", 55555); + byte[] payload = "response".getBytes(StandardCharsets.UTF_8); + + // Mock cache to return null (cache miss) + when(mockCache.get(clientAddress)).thenReturn(null); + + // Create a receiver at the client address + java.net.DatagramSocket receiver = new java.net.DatagramSocket(clientAddress); + receiver.setSoTimeout(1000); + + try { + // Act - send to client address + DatagramPacket sendPacket = new DatagramPacket(payload, payload.length, clientAddress); + socket.send(sendPacket); + + // Verify packet was sent to original address + byte[] receiveBuf = new byte[2048]; + DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length); + receiver.receive(receivePacket); + + // Assert + assertArrayEquals(payload, + java.util.Arrays.copyOfRange(receivePacket.getData(), 0, receivePacket.getLength())); + + // Verify cache was queried + verify(mockCache).get(clientAddress); + + // Verify metrics - cache miss + verify(mockMetrics).onCacheMiss(clientAddress); + verify(mockMetrics, never()).onCacheHit(any()); + } finally { + receiver.close(); + } + } + + @Test + void receive_withUntrustedProxy_skipsProcessing() throws Exception { + // Arrange - configure to reject all sources + socket.setTrustedProxy(addr -> false); + + byte[] payload = "test".getBytes(StandardCharsets.UTF_8); + byte[] proxyHeader = new ProxyProtocolV2Encoder() + .family(ProxyHeader.AddressFamily.INET4) + .socket(ProxyHeader.TransportProtocol.DGRAM) + .source(new InetSocketAddress("10.1.2.3", 12345)) + .destination(new InetSocketAddress("127.0.0.1", localPort)) + .build(); + + byte[] packet = new byte[proxyHeader.length + payload.length]; + System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length); + System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length); + + try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) { + sender.send(new DatagramPacket(packet, packet.length, + new InetSocketAddress("127.0.0.1", localPort))); + } + + // Act + byte[] receiveBuf = new byte[2048]; + DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length); + socket.receive(receivePacket); + + // Assert - packet should be delivered unchanged, no parsing + verify(mockMetrics, never()).onHeaderParsed(any()); + verify(mockCache, never()).put(any(), any()); + + // Packet length should include proxy header (not stripped) + assertEquals(packet.length, receivePacket.getLength()); + } + + @Test + void receive_withLocalCommand_doesNotPopulateCache() throws Exception { + // Arrange - create LOCAL command (not proxied) + byte[] payload = "local".getBytes(StandardCharsets.UTF_8); + byte[] proxyHeader = new ProxyProtocolV2Encoder() + .command(ProxyHeader.Command.LOCAL) + .build(); + + byte[] packet = new byte[proxyHeader.length + payload.length]; + System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length); + System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length); + + try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) { + sender.send(new DatagramPacket(packet, packet.length, + new InetSocketAddress("127.0.0.1", localPort))); + } + + // Act + byte[] receiveBuf = new byte[2048]; + DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length); + socket.receive(receivePacket); + + // Assert - cache should NOT be populated for LOCAL commands + verify(mockCache, never()).put(any(), any()); + + // But metrics should still be called + verify(mockMetrics).onHeaderParsed(any()); + + // Payload should be stripped of header + assertEquals(payload.length, receivePacket.getLength()); + } + + @Test + void receive_withTcpProtocol_doesNotPopulateCache() throws Exception { + // Arrange - create header with TCP (not DGRAM) protocol + byte[] payload = "tcp".getBytes(StandardCharsets.UTF_8); + byte[] proxyHeader = new ProxyProtocolV2Encoder() + .family(ProxyHeader.AddressFamily.INET4) + .socket(ProxyHeader.TransportProtocol.STREAM) // TCP, not UDP + .source(new InetSocketAddress("10.1.2.3", 12345)) + .destination(new InetSocketAddress("127.0.0.1", localPort)) + .build(); + + byte[] packet = new byte[proxyHeader.length + payload.length]; + System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length); + System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length); + + try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) { + sender.send(new DatagramPacket(packet, packet.length, + new InetSocketAddress("127.0.0.1", localPort))); + } + + // Act + byte[] receiveBuf = new byte[2048]; + DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length); + socket.receive(receivePacket); + + // Assert - cache should NOT be populated for non-DGRAM protocols + verify(mockCache, never()).put(any(), any()); + + // Metrics should still be called + verify(mockMetrics).onHeaderParsed(any()); + } +} + diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIntegrationTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIntegrationTest.java index ffa96fd..8fce996 100644 --- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIntegrationTest.java +++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIntegrationTest.java @@ -177,36 +177,55 @@ void udpEndToEnd_withProxyProtocolV2Header() throws Exception { assertTrue(forwarded.length >= header.length + PAYLOAD.length); } */ + /** + * Attempt to test udp end-to-end with a third party proxy container. + * Doesn't work: + * * haproxy has no generic UDP support https://github.com/haproxy/haproxy/issues/62 + * * nginx has UDP+proxy protocol support but v1 only + * * envoy has UDP but no proxy protocol support + * @Test - void udpEndToEnd_viaNginxContainer_proxyProtocolV2() throws Exception { + void udpEndToEnd_viaContainer_proxyProtocolV2() throws Exception { // Expose the host backend port to containers using Testcontainers' gateway helper Testcontainers.exposeHostPorts(backendPort); - int nginxInternalPort = 5684; // container internal UDP listen port - - String nginxConf = "worker_processes 1;\n" + - "events { worker_connections 1024; }\n" + - "stream {\n" + - " upstream backend { server host.docker.internal:" + backendPort + "; }\n" + - " log_format proxy '$remote_addr [$time_local] '\n" + - " '$protocol $status $bytes_sent $bytes_received '\n" + - " '$session_time \"$upstream_addr\" '\n" + - " '\"$upstream_bytes_sent\" \"$upstream_bytes_received\" \"$upstream_connect_time\"';" + - " server {\n" + - " listen 0.0.0.0:" + nginxInternalPort + " udp;\n" + - " proxy_pass backend;\n" + - " proxy_responses 1;\n" + - " proxy_timeout 5s;\n" + - " proxy_protocol on;\n" + - " access_log stdout proxy;\n" + - " }\n" + - "}\n"; - - ExposedPort udp = new ExposedPort(nginxInternalPort, InternetProtocol.UDP); - - GenericContainer nginx = new GenericContainer<>(DockerImageName.parse("nginx:1.29-alpine")) - .withCopyToContainer(Transferable.of(nginxConf.getBytes(StandardCharsets.UTF_8)), "/etc/nginx/nginx.conf") - //.withCommand("nginx", "-g", "daemon off;") + int envoyInternalPort = 5684; // container internal UDP listen port + String envoyConfig = "static_resources:\n" + + " listeners:\n" + + " - name: udp_listener\n" + + " address:\n" + + " socket_address:\n" + + " address: 0.0.0.0\n" + + " port_value: " + envoyInternalPort + "\n" + + " protocol: UDP\n" + + " listener_filters:\n" + + " - name: envoy.filters.udp_listener.udp_proxy\n" + + " typed_config:\n" + + " \"@type\": type.googleapis.com/envoy.extensions.filters.udp.udp_proxy.v3.UdpProxyConfig\n" + + " stat_prefix: udp_proxy\n" + + " cluster: backend_cluster\n" + + " upstream_socket_config:\n" + + " proxy_protocol_options:\n" + + " version: V2\n" + + " clusters:\n" + + " - name: backend_cluster\n" + + " connect_timeout: 5s\n" + + " type: STATIC\n" + + " load_assignment:\n" + + " cluster_name: backend_cluster\n" + + " endpoints:\n" + + " - lb_endpoints:\n" + + " - endpoint:\n" + + " address:\n" + + " socket_address:\n" + + " address: host.docker.internal\n" + + " port_value: " + backendPort + "\n"; + + ExposedPort udp = new ExposedPort(envoyInternalPort, InternetProtocol.UDP); + + GenericContainer envoy = new GenericContainer<>(DockerImageName.parse("envoyproxy/envoy:v1.28-latest")) + .withCopyToContainer(Transferable.of(envoyConfig.getBytes(StandardCharsets.UTF_8)), "/etc/envoy/envoy.yaml") + .withCommand("envoy", "-c", "/etc/envoy/envoy.yaml") .withCreateContainerCmdModifier(cmd -> { List exposedPorts = new ArrayList<>(); for (ExposedPort p : cmd.getExposedPorts()) { @@ -221,21 +240,21 @@ void udpEndToEnd_viaNginxContainer_proxyProtocolV2() throws Exception { cmd.withPortBindings(ports); }) .withLogConsumer(new Slf4jLogConsumer(LOG).withSeparateOutputStreams()); - nginx.start(); + envoy.start(); - String containerIpAddress = nginx.getHost(); - Ports.Binding[] bindings = nginx.getContainerInfo().getNetworkSettings().getPorts().getBindings().get(udp); + String containerIpAddress = envoy.getHost(); + Ports.Binding[] bindings = envoy.getContainerInfo().getNetworkSettings().getPorts().getBindings().get(udp); int containerPort = Integer.parseInt(bindings[0].getHostPortSpec()); - LOG.info("NGINX container host: {}, mapped UDP port: {} -> {}:{}", containerIpAddress, nginxInternalPort, containerIpAddress, containerPort); + LOG.info("NGINX container host: {}, mapped UDP port: {} -> {}:{}", containerIpAddress, envoyInternalPort, containerIpAddress, containerPort); try { // Java client sends to mapped host UDP port and expects echo DatagramSocket sock = new DatagramSocket(); sock.setSoTimeout(300000); byte[] data = PAYLOAD; - DatagramPacket toNginx = new DatagramPacket(data, data.length, new InetSocketAddress(containerIpAddress, containerPort)); + DatagramPacket toEnvoy = new DatagramPacket(data, data.length, new InetSocketAddress(containerIpAddress, containerPort)); LOG.info("Sending {} bytes to {}:{}", data.length, containerIpAddress, containerPort); - sock.send(toNginx); + sock.send(toEnvoy); DatagramPacket resp = new DatagramPacket(new byte[4096], 4096); sock.receive(resp); @@ -244,9 +263,10 @@ void udpEndToEnd_viaNginxContainer_proxyProtocolV2() throws Exception { assertEquals("hello", respStr); sock.close(); } finally { - try { nginx.stop(); } catch (Throwable ignore) {} + try { envoy.stop(); } catch (Throwable ignore) {} } } + */ static class NoopMetrics implements ProxyProtocolMetricsListener { @Override public void onHeaderParsed(ProxyHeader header) { } diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketMetricsTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketMetricsTest.java new file mode 100644 index 0000000..4de9c03 --- /dev/null +++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketMetricsTest.java @@ -0,0 +1,209 @@ +/* + * MIT License + * Copyright (c) 2025 Semtech + */ +package net.airvantage.proxysocket.udp; + +import net.airvantage.proxysocket.core.ProxyAddressCache; +import net.airvantage.proxysocket.core.ProxyProtocolMetricsListener; +import net.airvantage.proxysocket.core.v2.ProxyHeader; +import net.airvantage.proxysocket.core.v2.ProxyProtocolV2Encoder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.net.DatagramPacket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for ProxyDatagramSocket metrics tracking behavior. + */ +class ProxyDatagramSocketMetricsTest { + + private ProxyDatagramSocket socket; + private ProxyAddressCache mockCache; + private ProxyProtocolMetricsListener mockMetrics; + private int localPort; + + @BeforeEach + void setUp() throws Exception { + mockCache = mock(ProxyAddressCache.class); + mockMetrics = mock(ProxyProtocolMetricsListener.class); + + socket = new ProxyDatagramSocket(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)) + .setCache(mockCache) + .setMetrics(mockMetrics) + .setTrustedProxy(addr -> true); // Trust all for these tests + + localPort = socket.getLocalPort(); + } + + @AfterEach + void tearDown() { + if (socket != null && !socket.isClosed()) { + socket.close(); + } + } + + @Test + void receive_withValidProxyHeader_callsMetricsOnHeaderParsed() throws Exception { + // Arrange + InetSocketAddress realClient = new InetSocketAddress("10.1.2.3", 12345); + byte[] payload = "test".getBytes(StandardCharsets.UTF_8); + + byte[] proxyHeader = new ProxyProtocolV2Encoder() + .family(ProxyHeader.AddressFamily.INET4) + .socket(ProxyHeader.TransportProtocol.DGRAM) + .source(realClient) + .destination(new InetSocketAddress("127.0.0.1", localPort)) + .build(); + + byte[] packet = new byte[proxyHeader.length + payload.length]; + System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length); + System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length); + + // Send packet + try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) { + sender.send(new DatagramPacket(packet, packet.length, + new InetSocketAddress("127.0.0.1", localPort))); + } + + // Act + byte[] receiveBuf = new byte[2048]; + DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length); + socket.receive(receivePacket); + + // Assert - onHeaderParsed should be called + ArgumentCaptor headerCaptor = ArgumentCaptor.forClass(ProxyHeader.class); + verify(mockMetrics).onHeaderParsed(headerCaptor.capture()); + + ProxyHeader capturedHeader = headerCaptor.getValue(); + assertNotNull(capturedHeader); + assertEquals(ProxyHeader.TransportProtocol.DGRAM, capturedHeader.getProtocol()); + assertEquals(realClient, capturedHeader.getSourceAddress()); + } + + @Test + void receive_withInvalidData_callsMetricsOnParseError() throws Exception { + // Arrange - send garbage data + byte[] garbage = "not-a-proxy-header".getBytes(StandardCharsets.UTF_8); + + try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) { + sender.send(new DatagramPacket(garbage, garbage.length, + new InetSocketAddress("127.0.0.1", localPort))); + } + + // Act + byte[] receiveBuf = new byte[2048]; + DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length); + socket.receive(receivePacket); + + // Assert - onParseError should be called + verify(mockMetrics).onParseError(any(Exception.class)); + + // Original packet should be delivered unchanged + assertEquals(garbage.length, receivePacket.getLength()); + } + + @Test + void send_withCacheHit_callsMetricsOnCacheHit() throws Exception { + // Arrange + InetSocketAddress realClient = new InetSocketAddress("10.1.2.3", 12345); + InetSocketAddress lbAddress = new InetSocketAddress("127.0.0.1", 54321); + byte[] payload = "response".getBytes(StandardCharsets.UTF_8); + + // Mock cache to return lb address + when(mockCache.get(realClient)).thenReturn(lbAddress); + + // Create a receiver to verify the packet destination + java.net.DatagramSocket receiver = new java.net.DatagramSocket(lbAddress); + receiver.setSoTimeout(1000); + + try { + // Act - send to real client, should be redirected to LB + DatagramPacket sendPacket = new DatagramPacket(payload, payload.length, realClient); + socket.send(sendPacket); + + // Receive the packet (to avoid timeout) + byte[] receiveBuf = new byte[2048]; + DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length); + receiver.receive(receivePacket); + + // Assert - onCacheHit should be called + verify(mockMetrics).onCacheHit(realClient); + verify(mockMetrics, never()).onCacheMiss(any()); + } finally { + receiver.close(); + } + } + + @Test + void send_withCacheMiss_callsMetricsOnCacheMiss() throws Exception { + // Arrange + InetSocketAddress clientAddress = new InetSocketAddress("127.0.0.1", 55555); + byte[] payload = "response".getBytes(StandardCharsets.UTF_8); + + // Mock cache to return null (cache miss) + when(mockCache.get(clientAddress)).thenReturn(null); + + // Create a receiver at the client address + java.net.DatagramSocket receiver = new java.net.DatagramSocket(clientAddress); + receiver.setSoTimeout(1000); + + try { + // Act - send to client address + DatagramPacket sendPacket = new DatagramPacket(payload, payload.length, clientAddress); + socket.send(sendPacket); + + // Receive the packet (to avoid timeout) + byte[] receiveBuf = new byte[2048]; + DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length); + receiver.receive(receivePacket); + + // Assert - onCacheMiss should be called + verify(mockMetrics).onCacheMiss(clientAddress); + verify(mockMetrics, never()).onCacheHit(any()); + } finally { + receiver.close(); + } + } + + @Test + void receive_withUntrustedProxy_doesNotCallMetrics() throws Exception { + // Arrange - configure to reject all sources + socket.setTrustedProxy(addr -> false); + + byte[] payload = "test".getBytes(StandardCharsets.UTF_8); + byte[] proxyHeader = new ProxyProtocolV2Encoder() + .family(ProxyHeader.AddressFamily.INET4) + .socket(ProxyHeader.TransportProtocol.DGRAM) + .source(new InetSocketAddress("10.1.2.3", 12345)) + .destination(new InetSocketAddress("127.0.0.1", localPort)) + .build(); + + byte[] packet = new byte[proxyHeader.length + payload.length]; + System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length); + System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length); + + try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) { + sender.send(new DatagramPacket(packet, packet.length, + new InetSocketAddress("127.0.0.1", localPort))); + } + + // Act + byte[] receiveBuf = new byte[2048]; + DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length); + socket.receive(receivePacket); + + // Assert - no metrics should be called for untrusted sources + verify(mockMetrics, never()).onHeaderParsed(any()); + verify(mockMetrics, never()).onParseError(any()); + } +} + From 00f40eeb338ebece159ad91516820afa4d936729 Mon Sep 17 00:00:00 2001 From: Benoit Plessis Date: Wed, 26 Nov 2025 10:55:22 +0100 Subject: [PATCH 03/25] add subnet predicate helper --- .../core/ProxyProtocolMetricsListener.java | 3 + .../proxysocket/tools/SubnetPredicate.java | 153 +++++++++ .../cache/ConcurrentMapProxyAddressCache.java | 0 .../tools/SubnetPredicateTest.java | 293 ++++++++++++++++++ .../ConcurrentMapProxyAddressCacheTest.java | 0 proxy-socket-udp/pom.xml | 15 - .../proxysocket/udp/ProxyDatagramSocket.java | 27 +- .../ProxyDatagramSocketIntegrationTest.java | 279 ----------------- 8 files changed, 468 insertions(+), 302 deletions(-) create mode 100644 proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java rename proxy-socket-core/src/main/java/net/airvantage/proxysocket/{core => tools}/cache/ConcurrentMapProxyAddressCache.java (100%) create mode 100644 proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java rename proxy-socket-core/src/test/java/net/airvantage/proxysocket/{core => tools}/cache/ConcurrentMapProxyAddressCacheTest.java (100%) delete mode 100644 proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIntegrationTest.java diff --git a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/core/ProxyProtocolMetricsListener.java b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/core/ProxyProtocolMetricsListener.java index 11ca5ab..ca83741 100644 --- a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/core/ProxyProtocolMetricsListener.java +++ b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/core/ProxyProtocolMetricsListener.java @@ -16,4 +16,7 @@ default void onHeaderParsed(ProxyHeader header) {} default void onParseError(Exception e) {} default void onCacheHit(InetSocketAddress client) {} default void onCacheMiss(InetSocketAddress client) {} + default void onUntrustedProxy(InetSocketAddress proxy) {} + default void onTrustedProxy(InetSocketAddress proxy) {} + default void onLocal(InetSocketAddress proxy) {} } diff --git a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java new file mode 100644 index 0000000..2819fd3 --- /dev/null +++ b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java @@ -0,0 +1,153 @@ +/** + * BSD-3-Clause License. + * Copyright (c) 2025 Semtech + */ +package net.airvantage.proxysocket.udp; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.function.Predicate; + +/** + * Predicate that tests whether an InetSocketAddress belongs to a given subnet (CIDR). + * Supports both IPv4 and IPv6 CIDR notation. + * + *

Example usage: + *

+ * // Single subnet
+ * socket.setTrustedProxy(new SubnetPredicate("10.0.0.0/8"));
+ *
+ * // Multiple subnets
+ * socket.setTrustedProxy(
+ *     new SubnetPredicate("10.0.0.0/8")
+ *         .or(new SubnetPredicate("192.168.0.0/16"))
+ *         .or(new SubnetPredicate("2001:db8::/32"))
+ * );
+ * 
+ * + * Thread-safety: This class is immutable and thread-safe. + */ +public class SubnetPredicate implements Predicate { + private final byte[] networkAddress; + private final int prefixLength; + private final int addressLength; // 4 for IPv4, 16 for IPv6 + + /** + * Creates a predicate for the given CIDR subnet. + * + * @param cidr CIDR notation string (e.g., "10.0.0.0/8" or "2001:db8::/32") + * @throws IllegalArgumentException if the CIDR notation is invalid + */ + public SubnetPredicate(String cidr) { + if (cidr == null || cidr.isEmpty()) { + throw new IllegalArgumentException("CIDR notation cannot be null or empty"); + } + + int slashIndex = cidr.indexOf('/'); + if (slashIndex == -1) { + throw new IllegalArgumentException("Invalid CIDR notation: missing '/' separator"); + } + + String addressPart = cidr.substring(0, slashIndex); + String prefixPart = cidr.substring(slashIndex + 1); + + try { + this.prefixLength = Integer.parseInt(prefixPart); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid prefix length: " + prefixPart, e); + } + + try { + InetAddress addr = InetAddress.getByName(addressPart); + byte[] rawAddress = addr.getAddress(); + this.addressLength = rawAddress.length; + + // Validate prefix length + int maxPrefixLength = addressLength * 8; + if (prefixLength < 0 || prefixLength > maxPrefixLength) { + throw new IllegalArgumentException( + "Invalid prefix length " + prefixLength + " for address type (must be 0-" + maxPrefixLength + ")" + ); + } + + // Apply the mask to the network address to normalize it + this.networkAddress = applyMask(rawAddress, prefixLength); + } catch (UnknownHostException e) { + throw new IllegalArgumentException("Invalid IP address: " + addressPart, e); + } + } + + /** + * Tests whether the given socket address belongs to this subnet. + * + * @param socketAddress the socket address to test + * @return true if the address is in this subnet, false otherwise + */ + @Override + public boolean test(InetSocketAddress socketAddress) { + if (socketAddress == null) { + return false; + } + + InetAddress address = socketAddress.getAddress(); + if (address == null) { + return false; + } + + byte[] testAddress = address.getAddress(); + + // Different address families don't match + if (testAddress.length != addressLength) { + return false; + } + + byte[] maskedTestAddress = applyMask(testAddress, prefixLength); + + // Compare network portions + for (int i = 0; i < networkAddress.length; i++) { + if (networkAddress[i] != maskedTestAddress[i]) { + return false; + } + } + + return true; + } + + /** + * Applies a subnet mask to an IP address. + * + * @param address the raw IP address bytes + * @param prefixLen the prefix length (number of network bits) + * @return the masked address bytes + */ + private static byte[] applyMask(byte[] address, int prefixLen) { + byte[] result = new byte[address.length]; + + int fullBytes = prefixLen / 8; + int remainingBits = prefixLen % 8; + + // Copy the full bytes + System.arraycopy(address, 0, result, 0, fullBytes); + + // Apply mask to the partial byte if any + if (remainingBits > 0 && fullBytes < address.length) { + int mask = 0xFF << (8 - remainingBits); + result[fullBytes] = (byte) (address[fullBytes] & mask); + } + + // Remaining bytes are already 0 + return result; + } + + @Override + public String toString() { + try { + InetAddress addr = InetAddress.getByAddress(networkAddress); + return addr.getHostAddress() + "/" + prefixLength; + } catch (UnknownHostException e) { + return "SubnetPredicate[invalid]"; + } + } +} + diff --git a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/core/cache/ConcurrentMapProxyAddressCache.java b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCache.java similarity index 100% rename from proxy-socket-core/src/main/java/net/airvantage/proxysocket/core/cache/ConcurrentMapProxyAddressCache.java rename to proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCache.java diff --git a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java new file mode 100644 index 0000000..f846fca --- /dev/null +++ b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java @@ -0,0 +1,293 @@ +/* + * MIT License + * Copyright (c) 2025 Semtech + */ +package net.airvantage.proxysocket.tools; + +import org.junit.jupiter.api.Test; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; + +import static org.junit.jupiter.api.Assertions.*; + +class SubnetPredicateTest { + + // ========== IPv4 Tests ========== + + @Test + void testIPv4_SingleHost_32BitMask() throws UnknownHostException { + SubnetPredicate predicate = new SubnetPredicate("192.168.1.100/32"); + + assertTrue(predicate.test(addr("192.168.1.100", 8080))); + assertFalse(predicate.test(addr("192.168.1.101", 8080))); + assertFalse(predicate.test(addr("192.168.1.99", 8080))); + } + + @Test + void testIPv4_ClassC_24BitMask() throws UnknownHostException { + SubnetPredicate predicate = new SubnetPredicate("192.168.1.0/24"); + + assertTrue(predicate.test(addr("192.168.1.0", 8080))); + assertTrue(predicate.test(addr("192.168.1.1", 8080))); + assertTrue(predicate.test(addr("192.168.1.100", 8080))); + assertTrue(predicate.test(addr("192.168.1.255", 8080))); + + assertFalse(predicate.test(addr("192.168.0.255", 8080))); + assertFalse(predicate.test(addr("192.168.2.0", 8080))); + assertFalse(predicate.test(addr("192.167.1.1", 8080))); + } + + @Test + void testIPv4_ClassB_16BitMask() throws UnknownHostException { + SubnetPredicate predicate = new SubnetPredicate("172.16.0.0/16"); + + assertTrue(predicate.test(addr("172.16.0.0", 8080))); + assertTrue(predicate.test(addr("172.16.1.1", 8080))); + assertTrue(predicate.test(addr("172.16.255.255", 8080))); + + assertFalse(predicate.test(addr("172.15.255.255", 8080))); + assertFalse(predicate.test(addr("172.17.0.0", 8080))); + } + + @Test + void testIPv4_ClassA_8BitMask() throws UnknownHostException { + SubnetPredicate predicate = new SubnetPredicate("10.0.0.0/8"); + + assertTrue(predicate.test(addr("10.0.0.0", 8080))); + assertTrue(predicate.test(addr("10.0.0.1", 8080))); + assertTrue(predicate.test(addr("10.255.255.255", 8080))); + assertTrue(predicate.test(addr("10.123.45.67", 8080))); + + assertFalse(predicate.test(addr("9.255.255.255", 8080))); + assertFalse(predicate.test(addr("11.0.0.0", 8080))); + } + + @Test + void testIPv4_NonStandardMask_25Bits() throws UnknownHostException { + SubnetPredicate predicate = new SubnetPredicate("192.168.1.0/25"); + + // First half: 192.168.1.0 - 192.168.1.127 + assertTrue(predicate.test(addr("192.168.1.0", 8080))); + assertTrue(predicate.test(addr("192.168.1.127", 8080))); + + // Second half: 192.168.1.128 - 192.168.1.255 + assertFalse(predicate.test(addr("192.168.1.128", 8080))); + assertFalse(predicate.test(addr("192.168.1.255", 8080))); + } + + @Test + void testIPv4_NonStandardMask_23Bits() throws UnknownHostException { + SubnetPredicate predicate = new SubnetPredicate("192.168.0.0/23"); + + assertTrue(predicate.test(addr("192.168.0.0", 8080))); + assertTrue(predicate.test(addr("192.168.0.255", 8080))); + assertTrue(predicate.test(addr("192.168.1.0", 8080))); + assertTrue(predicate.test(addr("192.168.1.255", 8080))); + + assertFalse(predicate.test(addr("192.168.2.0", 8080))); + assertFalse(predicate.test(addr("192.167.255.255", 8080))); + } + + @Test + void testIPv4_ZeroMask_MatchesAll() throws UnknownHostException { + SubnetPredicate predicate = new SubnetPredicate("0.0.0.0/0"); + + assertTrue(predicate.test(addr("0.0.0.0", 8080))); + assertTrue(predicate.test(addr("1.2.3.4", 8080))); + assertTrue(predicate.test(addr("192.168.1.1", 8080))); + assertTrue(predicate.test(addr("255.255.255.255", 8080))); + + // But not IPv6 + assertFalse(predicate.test(addr("::1", 8080))); + } + + // ========== IPv6 Tests ========== + + @Test + void testIPv6_SingleHost_128BitMask() throws UnknownHostException { + SubnetPredicate predicate = new SubnetPredicate("2001:db8::1/128"); + + assertTrue(predicate.test(addr("2001:db8::1", 8080))); + assertFalse(predicate.test(addr("2001:db8::2", 8080))); + } + + @Test + void testIPv6_CommonSubnet_64BitMask() throws UnknownHostException { + SubnetPredicate predicate = new SubnetPredicate("2001:db8::/64"); + + assertTrue(predicate.test(addr("2001:db8::1", 8080))); + assertTrue(predicate.test(addr("2001:db8::ffff:ffff:ffff:ffff", 8080))); + assertTrue(predicate.test(addr("2001:db8:0:0:1234:5678:9abc:def0", 8080))); + + assertFalse(predicate.test(addr("2001:db8:0:1::1", 8080))); + } + + @Test + void testIPv6_WideSubnet_32BitMask() throws UnknownHostException { + SubnetPredicate predicate = new SubnetPredicate("2001:db8::/32"); + + assertTrue(predicate.test(addr("2001:db8::", 8080))); + assertTrue(predicate.test(addr("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", 8080))); + + assertFalse(predicate.test(addr("2001:db9::", 8080))); + assertFalse(predicate.test(addr("2001:db7:ffff:ffff:ffff:ffff:ffff:ffff", 8080))); + } + + @Test + void testIPv6_Loopback() throws UnknownHostException { + SubnetPredicate predicate = new SubnetPredicate("::1/128"); + + assertTrue(predicate.test(addr("::1", 8080))); + assertFalse(predicate.test(addr("::2", 8080))); + } + + @Test + void testIPv6_ZeroMask_MatchesAll() throws UnknownHostException { + SubnetPredicate predicate = new SubnetPredicate("::/0"); + + assertTrue(predicate.test(addr("::", 8080))); + assertTrue(predicate.test(addr("::1", 8080))); + assertTrue(predicate.test(addr("2001:db8::1", 8080))); + assertTrue(predicate.test(addr("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", 8080))); + + // But not IPv4 + assertFalse(predicate.test(addr("192.168.1.1", 8080))); + } + + // ========== Edge Cases ========== + + @Test + void testNullSocketAddress() { + SubnetPredicate predicate = new SubnetPredicate("192.168.1.0/24"); + assertFalse(predicate.test(null)); + } + + @Test + void testPortIsIgnored() throws UnknownHostException { + SubnetPredicate predicate = new SubnetPredicate("192.168.1.0/24"); + + assertTrue(predicate.test(addr("192.168.1.100", 80))); + assertTrue(predicate.test(addr("192.168.1.100", 443))); + assertTrue(predicate.test(addr("192.168.1.100", 8080))); + assertTrue(predicate.test(addr("192.168.1.100", 65535))); + } + + @Test + void testIPv4vsIPv6_NoMatch() throws UnknownHostException { + SubnetPredicate ipv4Predicate = new SubnetPredicate("192.168.1.0/24"); + SubnetPredicate ipv6Predicate = new SubnetPredicate("2001:db8::/32"); + + // IPv4 predicate doesn't match IPv6 address + assertFalse(ipv4Predicate.test(addr("2001:db8::1", 8080))); + + // IPv6 predicate doesn't match IPv4 address + assertFalse(ipv6Predicate.test(addr("192.168.1.1", 8080))); + } + + @Test + void testPredicateComposition_Or() throws UnknownHostException { + SubnetPredicate predicate1 = new SubnetPredicate("192.168.1.0/24"); + SubnetPredicate predicate2 = new SubnetPredicate("10.0.0.0/8"); + SubnetPredicate combined = predicate1.or(predicate2); + + assertTrue(combined.test(addr("192.168.1.100", 8080))); + assertTrue(combined.test(addr("10.20.30.40", 8080))); + assertFalse(combined.test(addr("172.16.0.1", 8080))); + } + + @Test + void testPredicateComposition_And() throws UnknownHostException { + SubnetPredicate predicate1 = new SubnetPredicate("192.168.0.0/16"); + SubnetPredicate predicate2 = new SubnetPredicate("192.168.1.0/24"); + SubnetPredicate combined = predicate1.and(predicate2); + + assertTrue(combined.test(addr("192.168.1.100", 8080))); + assertFalse(combined.test(addr("192.168.2.100", 8080))); + } + + @Test + void testPredicateComposition_Negate() throws UnknownHostException { + SubnetPredicate predicate = new SubnetPredicate("192.168.1.0/24"); + + assertTrue(predicate.test(addr("192.168.1.100", 8080))); + assertFalse(predicate.negate().test(addr("192.168.1.100", 8080))); + + assertFalse(predicate.test(addr("192.168.2.100", 8080))); + assertTrue(predicate.negate().test(addr("192.168.2.100", 8080))); + } + + @Test + void testToString_IPv4() { + SubnetPredicate predicate = new SubnetPredicate("192.168.1.0/24"); + assertEquals("192.168.1.0/24", predicate.toString()); + } + + @Test + void testToString_IPv6() { + SubnetPredicate predicate = new SubnetPredicate("2001:db8::/32"); + assertTrue(predicate.toString().contains("2001:db8")); + assertTrue(predicate.toString().contains("/32")); + } + + // ========== Invalid Input Tests ========== + + @Test + void testInvalidCIDR_NullInput() { + assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate(null)); + } + + @Test + void testInvalidCIDR_EmptyString() { + assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate("")); + } + + @Test + void testInvalidCIDR_MissingSlash() { + assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate("192.168.1.0")); + } + + @Test + void testInvalidCIDR_InvalidPrefix() { + assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate("192.168.1.0/abc")); + } + + @Test + void testInvalidCIDR_PrefixTooLarge_IPv4() { + assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate("192.168.1.0/33")); + } + + @Test + void testInvalidCIDR_PrefixTooLarge_IPv6() { + assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate("2001:db8::/129")); + } + + @Test + void testInvalidCIDR_NegativePrefix() { + assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate("192.168.1.0/-1")); + } + + @Test + void testInvalidCIDR_InvalidIPAddress() { + assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate("256.256.256.256/24")); + } + + @Test + void testNonNormalizedCIDR_StillWorks() throws UnknownHostException { + // 192.168.1.100/24 is not normalized (should be 192.168.1.0/24) + // but the implementation should normalize it + SubnetPredicate predicate = new SubnetPredicate("192.168.1.100/24"); + + assertTrue(predicate.test(addr("192.168.1.0", 8080))); + assertTrue(predicate.test(addr("192.168.1.100", 8080))); + assertTrue(predicate.test(addr("192.168.1.255", 8080))); + assertFalse(predicate.test(addr("192.168.2.0", 8080))); + } + + // ========== Helper Methods ========== + + private InetSocketAddress addr(String host, int port) throws UnknownHostException { + return new InetSocketAddress(InetAddress.getByName(host), port); + } +} + diff --git a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/cache/ConcurrentMapProxyAddressCacheTest.java b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCacheTest.java similarity index 100% rename from proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/cache/ConcurrentMapProxyAddressCacheTest.java rename to proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCacheTest.java diff --git a/proxy-socket-udp/pom.xml b/proxy-socket-udp/pom.xml index bf4015d..d66de9f 100644 --- a/proxy-socket-udp/pom.xml +++ b/proxy-socket-udp/pom.xml @@ -32,21 +32,6 @@ ${junit.version} test - - org.testcontainers - testcontainers-junit-jupiter - test - - - org.testcontainers - testcontainers - test - - - org.testcontainers - testcontainers-nginx - test - org.slf4j diff --git a/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java b/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java index 385688d..63743f2 100644 --- a/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java +++ b/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java @@ -1,12 +1,11 @@ -/* - * MIT License +/** + * BSD-3-Clause License. * Copyright (c) 2025 Semtech */ package net.airvantage.proxysocket.udp; import net.airvantage.proxysocket.core.ProxyAddressCache; import net.airvantage.proxysocket.core.ProxyProtocolMetricsListener; -import net.airvantage.proxysocket.core.cache.ConcurrentMapProxyAddressCache; import net.airvantage.proxysocket.core.v2.ProxyHeader; import net.airvantage.proxysocket.core.v2.ProxyProtocolV2Decoder; @@ -16,7 +15,6 @@ import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.SocketException; -import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; import java.util.function.Predicate; @@ -57,29 +55,40 @@ public ProxyDatagramSocket(int port, java.net.InetAddress laddr) throws SocketEx public ProxyDatagramSocket setTrustedProxy(Predicate predicate) { this.trustedProxyPredicate = predicate; return this; } @Override - public void receive(DatagramPacket packet) throws IOException { + public void receive(DatagramPacket packet) + throws IOException, SocketTimeoutException, PortUnreachableException, IllegalBlockingModeException { + super.receive(packet); + try { InetSocketAddress lbAddress = (InetSocketAddress) packet.getSocketAddress(); if (trustedProxyPredicate != null && !trustedProxyPredicate.test(lbAddress)) { // Untrusted source: do not parse, deliver original packet + LOG.log(Level.DEBUG, "Untrusted proxy source; delivering original packet."); + if (metrics != null) metrics.onUntrustedProxy(lbAddress); return; } ProxyHeader header = ProxyProtocolV2Decoder.parse(packet.getData(), packet.getOffset(), packet.getLength()); if (metrics != null) metrics.onHeaderParsed(header); + if (header.isLocal()) { // LOCAL: not proxied - } else if (header.isProxy() && header.getProtocol() == ProxyHeader.TransportProtocol.DGRAM) { + if (metrics != null) metrics.onLocal(lbAddress); + } + if (header.isProxy() && header.getProtocol() == ProxyHeader.TransportProtocol.DGRAM) { + if (metrics != null) metrics.onTrustedProxy(lbAddress); + InetSocketAddress realClient = header.getSourceAddress(); - if (realClient != null && lbAddress != null) { + if (realClient != null) { // could be null if address family is unspecified or unix if (addressCache != null) addressCache.put(realClient, lbAddress); packet.setSocketAddress(realClient); } } + int headerLen = header.getHeaderLength(); packet.setData(packet.getData(), packet.getOffset() + headerLen, packet.getLength() - headerLen); - } catch (Exception e) { + } catch (ProxyProtocolParseException e) { LOG.log(Level.WARNING, "Proxy socket parse error; delivering original packet.", e); if (metrics != null) metrics.onParseError(e); } @@ -89,12 +98,14 @@ public void receive(DatagramPacket packet) throws IOException { public void send(DatagramPacket packet) throws IOException { InetSocketAddress client = (InetSocketAddress) packet.getSocketAddress(); InetSocketAddress lb = addressCache != null ? addressCache.get(client) : null; + if (lb != null) { packet.setSocketAddress(lb); if (metrics != null) metrics.onCacheHit(client); } else { if (metrics != null) metrics.onCacheMiss(client); } + super.send(packet); } } diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIntegrationTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIntegrationTest.java deleted file mode 100644 index 8fce996..0000000 --- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIntegrationTest.java +++ /dev/null @@ -1,279 +0,0 @@ -/* - * MIT License - * Copyright (c) 2025 Semtech - */ -package net.airvantage.proxysocket.udp; - -import net.airvantage.proxysocket.core.ProxyProtocolMetricsListener; -import net.airvantage.proxysocket.core.cache.ConcurrentMapProxyAddressCache; -import net.airvantage.proxysocket.core.v2.ProxyHeader; -import net.airvantage.proxysocket.core.v2.ProxyProtocolV2Encoder; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.testcontainers.Testcontainers; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.nginx.NginxContainer; -import org.testcontainers.containers.output.Slf4jLogConsumer; -import org.testcontainers.containers.output.OutputFrame; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.testcontainers.utility.DockerImageName; -import org.testcontainers.images.builder.Transferable; -import com.github.dockerjava.api.model.ExposedPort; -import com.github.dockerjava.api.model.InternetProtocol; -import com.github.dockerjava.api.model.Ports; -import com.github.dockerjava.api.model.Ports.Binding; - -import java.net.*; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; - -import static org.junit.jupiter.api.Assertions.*; - - -/** - * End-to-end UDP integration using an in-process Proxy Protocol v2 injector. - * The injector emulates a UDP-aware LB (e.g., NGINX/Envoy) that prepends PPv2 - * and forwards datagrams to the backend echo server built on ProxyDatagramSocket. - */ -public class ProxyDatagramSocketIntegrationTest { - - private static final byte[] PAYLOAD = "hello".getBytes(StandardCharsets.UTF_8); - private static final Logger LOG = LoggerFactory.getLogger(ProxyDatagramSocketIntegrationTest.class); - - private DatagramSocket client; - private ProxyDatagramSocket backend; - private DatagramSocket injector; - private int backendPort; - private int injectorPort; - - private java.util.concurrent.ExecutorService executor; - private Future backendLoop; - - @BeforeEach - void setUp() throws Exception { - client = new DatagramSocket(); - client.setSoTimeout(3000); - - ConcurrentMapProxyAddressCache cache = new ConcurrentMapProxyAddressCache(); - backend = new ProxyDatagramSocket((new InetSocketAddress(InetAddress.getLoopbackAddress(), 0))) - .setCache(cache) - .setMetrics(new NoopMetrics()); - backendPort = backend.getLocalPort(); - LOG.info("Backend listening on 127.0.0.1:{}", backendPort); - -// injector = new DatagramSocket(new InetSocketAddress("127.0.0.1", 0)); -// injectorPort = injector.getLocalPort(); - - // Start backend echo loop - executor = Executors.newSingleThreadExecutor(); - backendLoop = executor.submit(() -> { - try { - byte[] buf = new byte[2048]; - DatagramPacket p = new DatagramPacket(buf, buf.length); - while (!Thread.currentThread().isInterrupted()) { - backend.receive(p); - - LOG.info("Received {} bytes request from {} original address {}", p.getLength(), p.getSocketAddress(), cache.get((InetSocketAddress)p.getSocketAddress())); - - // Echo back exactly what was after proxy header - byte[] echo = new byte[p.getLength()]; - System.arraycopy(p.getData(), p.getOffset(), echo, 0, p.getLength()); - p.setData(echo); - backend.send(p); - p.setData(buf); - } - } catch (SocketException ignore) { - // socket closed during shutdown - } catch (Exception e) { - // Allow exceptions to fail the test - throw new RuntimeException(e); - } - }); - } - - @AfterEach - void tearDown() throws Exception { - if (backendLoop != null) backendLoop.cancel(true); - if (executor != null) executor.shutdownNow(); - if (backend != null) backend.close(); - // if (injector != null) injector.close(); - if (client != null) client.close(); - } - - /* - @Test - void udpEndToEnd_withProxyProtocolV2Header() throws Exception { - // Source perceived by the LB (injector) is client.getLocalAddress():client.getLocalPort() - InetSocketAddress src = new InetSocketAddress(InetAddress.getByName("127.0.0.1"), client.getLocalPort()); - InetSocketAddress dst = new InetSocketAddress(InetAddress.getByName("127.0.0.1"), backendPort); - - byte[] header = new ProxyProtocolV2Encoder() - .family(ProxyHeader.AddressFamily.INET4) - .socket(ProxyHeader.TransportProtocol.DGRAM) - .source(src) - .destination(dst) - .build(); - - byte[] out = new byte[header.length + PAYLOAD.length]; - System.arraycopy(header, 0, out, 0, header.length); - System.arraycopy(PAYLOAD, 0, out, header.length, PAYLOAD.length); - - // Send to injector; injector forwards to backend and back to client - DatagramPacket toInjector = new DatagramPacket(out, out.length, new InetSocketAddress("127.0.0.1", injectorPort)); - // Set up injector forwarder (bi-directional for the test) - BlockingQueue forwardQueue = new ArrayBlockingQueue<>(1); - Executors.newSingleThreadExecutor().execute(() -> { - try { - byte[] buf = new byte[4096]; - DatagramPacket p = new DatagramPacket(buf, buf.length); - // Receive from client - injector.receive(p); - InetSocketAddress clientAddr = (InetSocketAddress) p.getSocketAddress(); - byte[] recv = new byte[p.getLength()]; - System.arraycopy(p.getData(), p.getOffset(), recv, 0, p.getLength()); - forwardQueue.add(recv); - - // Forward to backend - DatagramPacket toBackend = new DatagramPacket(recv, recv.length, new InetSocketAddress("127.0.0.1", backendPort)); - injector.send(toBackend); - - // Await response from backend - DatagramPacket fromBackend = new DatagramPacket(new byte[4096], 4096); - injector.receive(fromBackend); - byte[] backendResp = new byte[fromBackend.getLength()]; - System.arraycopy(fromBackend.getData(), fromBackend.getOffset(), backendResp, 0, fromBackend.getLength()); - - // Forward back to original client - DatagramPacket backToClient = new DatagramPacket(backendResp, backendResp.length, clientAddr); - injector.send(backToClient); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - - client.send(toInjector); - - // Receive echo back through backend -> injector -> client - DatagramPacket resp = new DatagramPacket(new byte[4096], 4096); - resp.setLength(4096); - client.receive(resp); - - String respStr = new String(resp.getData(), resp.getOffset(), resp.getLength(), StandardCharsets.UTF_8); - assertEquals("hello", respStr); - - // Sanity: ensure injector forwarded the PROXY header + payload to backend - byte[] forwarded = forwardQueue.poll(2, java.util.concurrent.TimeUnit.SECONDS); - assertNotNull(forwarded); - assertTrue(forwarded.length >= header.length + PAYLOAD.length); - } */ - - /** - * Attempt to test udp end-to-end with a third party proxy container. - * Doesn't work: - * * haproxy has no generic UDP support https://github.com/haproxy/haproxy/issues/62 - * * nginx has UDP+proxy protocol support but v1 only - * * envoy has UDP but no proxy protocol support - * - @Test - void udpEndToEnd_viaContainer_proxyProtocolV2() throws Exception { - // Expose the host backend port to containers using Testcontainers' gateway helper - Testcontainers.exposeHostPorts(backendPort); - - int envoyInternalPort = 5684; // container internal UDP listen port - String envoyConfig = "static_resources:\n" + - " listeners:\n" + - " - name: udp_listener\n" + - " address:\n" + - " socket_address:\n" + - " address: 0.0.0.0\n" + - " port_value: " + envoyInternalPort + "\n" + - " protocol: UDP\n" + - " listener_filters:\n" + - " - name: envoy.filters.udp_listener.udp_proxy\n" + - " typed_config:\n" + - " \"@type\": type.googleapis.com/envoy.extensions.filters.udp.udp_proxy.v3.UdpProxyConfig\n" + - " stat_prefix: udp_proxy\n" + - " cluster: backend_cluster\n" + - " upstream_socket_config:\n" + - " proxy_protocol_options:\n" + - " version: V2\n" + - " clusters:\n" + - " - name: backend_cluster\n" + - " connect_timeout: 5s\n" + - " type: STATIC\n" + - " load_assignment:\n" + - " cluster_name: backend_cluster\n" + - " endpoints:\n" + - " - lb_endpoints:\n" + - " - endpoint:\n" + - " address:\n" + - " socket_address:\n" + - " address: host.docker.internal\n" + - " port_value: " + backendPort + "\n"; - - ExposedPort udp = new ExposedPort(envoyInternalPort, InternetProtocol.UDP); - - GenericContainer envoy = new GenericContainer<>(DockerImageName.parse("envoyproxy/envoy:v1.28-latest")) - .withCopyToContainer(Transferable.of(envoyConfig.getBytes(StandardCharsets.UTF_8)), "/etc/envoy/envoy.yaml") - .withCommand("envoy", "-c", "/etc/envoy/envoy.yaml") - .withCreateContainerCmdModifier(cmd -> { - List exposedPorts = new ArrayList<>(); - for (ExposedPort p : cmd.getExposedPorts()) { - exposedPorts.add(p); - } - exposedPorts.add(udp); - cmd.withExposedPorts(exposedPorts); - - //Add previous port bindings and UDP port binding - Ports ports = cmd.getPortBindings(); - ports.bind(udp, Ports.Binding.bindIp("0.0.0.0")); - cmd.withPortBindings(ports); - }) - .withLogConsumer(new Slf4jLogConsumer(LOG).withSeparateOutputStreams()); - envoy.start(); - - String containerIpAddress = envoy.getHost(); - Ports.Binding[] bindings = envoy.getContainerInfo().getNetworkSettings().getPorts().getBindings().get(udp); - int containerPort = Integer.parseInt(bindings[0].getHostPortSpec()); - LOG.info("NGINX container host: {}, mapped UDP port: {} -> {}:{}", containerIpAddress, envoyInternalPort, containerIpAddress, containerPort); - - try { - // Java client sends to mapped host UDP port and expects echo - DatagramSocket sock = new DatagramSocket(); - sock.setSoTimeout(300000); - byte[] data = PAYLOAD; - DatagramPacket toEnvoy = new DatagramPacket(data, data.length, new InetSocketAddress(containerIpAddress, containerPort)); - LOG.info("Sending {} bytes to {}:{}", data.length, containerIpAddress, containerPort); - sock.send(toEnvoy); - - DatagramPacket resp = new DatagramPacket(new byte[4096], 4096); - sock.receive(resp); - String respStr = new String(resp.getData(), resp.getOffset(), resp.getLength(), StandardCharsets.UTF_8); - LOG.info("Received {} bytes response: '{}'", resp.getLength(), respStr); - assertEquals("hello", respStr); - sock.close(); - } finally { - try { envoy.stop(); } catch (Throwable ignore) {} - } - } - */ - - static class NoopMetrics implements ProxyProtocolMetricsListener { - @Override public void onHeaderParsed(ProxyHeader header) { } - @Override public void onParseError(Exception e) { } - @Override public void onCacheHit(InetSocketAddress client) { } - @Override public void onCacheMiss(InetSocketAddress client) { } - } -} - - From fadd09dd8a9141292419ba4decb79ff2845aa445 Mon Sep 17 00:00:00 2001 From: Benoit Plessis Date: Thu, 27 Nov 2025 14:52:04 +0100 Subject: [PATCH 04/25] process review step 2 --- .../proxysocket/tools/SubnetPredicate.java | 8 +- .../tools/SubnetPredicateTest.java | 5 +- proxy-socket-udp/pom.xml | 6 +- .../proxysocket/udp/ProxyDatagramSocket.java | 44 ++-- ...gTest.java => ProxyDatagramSockeTest.java} | 166 ++++++++++---- .../ProxyDatagramSockeUnTrustedProxyTest.java | 106 +++++++++ .../udp/ProxyDatagramSocketMetricsTest.java | 209 ------------------ 7 files changed, 270 insertions(+), 274 deletions(-) rename proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/{ProxyDatagramSocketIPMappingTest.java => ProxyDatagramSockeTest.java} (70%) create mode 100644 proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSockeUnTrustedProxyTest.java delete mode 100644 proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketMetricsTest.java diff --git a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java index 2819fd3..9c8beea 100644 --- a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java +++ b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java @@ -2,7 +2,7 @@ * BSD-3-Clause License. * Copyright (c) 2025 Semtech */ -package net.airvantage.proxysocket.udp; +package net.airvantage.proxysocket.tools; import java.net.InetAddress; import java.net.InetSocketAddress; @@ -10,16 +10,16 @@ import java.util.function.Predicate; /** - * Predicate that tests whether an InetSocketAddress belongs to a given subnet (CIDR). + * Predicate compatible class that tests whether an InetSocketAddress belongs to a given subnet (CIDR). * Supports both IPv4 and IPv6 CIDR notation. * *

Example usage: *

  * // Single subnet
- * socket.setTrustedProxy(new SubnetPredicate("10.0.0.0/8"));
+ * Predicate predicate = new SubnetPredicate("10.0.0.0/8");
  *
  * // Multiple subnets
- * socket.setTrustedProxy(
+ * Predicate predicate =
  *     new SubnetPredicate("10.0.0.0/8")
  *         .or(new SubnetPredicate("192.168.0.0/16"))
  *         .or(new SubnetPredicate("2001:db8::/32"))
diff --git a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java
index f846fca..fcdd14e 100644
--- a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java
+++ b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java
@@ -8,6 +8,7 @@
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.UnknownHostException;
+import java.util.function.Predicate;
 
 import static org.junit.jupiter.api.Assertions.*;
 
@@ -189,7 +190,7 @@ void testIPv4vsIPv6_NoMatch() throws UnknownHostException {
     void testPredicateComposition_Or() throws UnknownHostException {
         SubnetPredicate predicate1 = new SubnetPredicate("192.168.1.0/24");
         SubnetPredicate predicate2 = new SubnetPredicate("10.0.0.0/8");
-        SubnetPredicate combined = predicate1.or(predicate2);
+        Predicate combined = predicate1.or(predicate2);
 
         assertTrue(combined.test(addr("192.168.1.100", 8080)));
         assertTrue(combined.test(addr("10.20.30.40", 8080)));
@@ -200,7 +201,7 @@ void testPredicateComposition_Or() throws UnknownHostException {
     void testPredicateComposition_And() throws UnknownHostException {
         SubnetPredicate predicate1 = new SubnetPredicate("192.168.0.0/16");
         SubnetPredicate predicate2 = new SubnetPredicate("192.168.1.0/24");
-        SubnetPredicate combined = predicate1.and(predicate2);
+        Predicate combined = predicate1.and(predicate2);
 
         assertTrue(combined.test(addr("192.168.1.100", 8080)));
         assertFalse(combined.test(addr("192.168.2.100", 8080)));
diff --git a/proxy-socket-udp/pom.xml b/proxy-socket-udp/pom.xml
index d66de9f..6da18f3 100644
--- a/proxy-socket-udp/pom.xml
+++ b/proxy-socket-udp/pom.xml
@@ -18,12 +18,10 @@
             proxy-socket-core
             ${project.version}
         
-        
         
             org.slf4j
             slf4j-api
-            2.0.16
-            true
+            2.0.17
         
         
         
@@ -36,7 +34,7 @@
         
             org.slf4j
             slf4j-simple
-            2.0.16
+            2.0.17
             test
         
     
diff --git a/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java b/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java
index 63743f2..13cdf94 100644
--- a/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java
+++ b/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java
@@ -6,6 +6,7 @@
 
 import net.airvantage.proxysocket.core.ProxyAddressCache;
 import net.airvantage.proxysocket.core.ProxyProtocolMetricsListener;
+import net.airvantage.proxysocket.core.ProxyProtocolParseException;
 import net.airvantage.proxysocket.core.v2.ProxyHeader;
 import net.airvantage.proxysocket.core.v2.ProxyProtocolV2Decoder;
 
@@ -13,8 +14,11 @@
 import java.net.DatagramPacket;
 import java.net.DatagramSocket;
 import java.net.InetSocketAddress;
+import java.net.PortUnreachableException;
 import java.net.SocketAddress;
 import java.net.SocketException;
+import java.net.SocketTimeoutException;
+import java.nio.channels.IllegalBlockingModeException;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 import java.util.function.Predicate;
@@ -30,30 +34,38 @@
 public class ProxyDatagramSocket extends DatagramSocket {
     private static final Logger LOG = Logger.getLogger(ProxyDatagramSocket.class.getName());
 
-    private ProxyAddressCache addressCache;
-    private ProxyProtocolMetricsListener metrics;
-    private Predicate trustedProxyPredicate;
+    private final ProxyAddressCache addressCache;
+    private final ProxyProtocolMetricsListener metrics;
+    private final Predicate trustedProxyPredicate;
 
-    public ProxyDatagramSocket() throws SocketException {
+    public ProxyDatagramSocket(ProxyAddressCache cache, ProxyProtocolMetricsListener metrics, Predicate predicate) throws SocketException {
         super();
+        this.addressCache = cache;
+        this.metrics = metrics;
+        this.trustedProxyPredicate = predicate;
     }
 
-    public ProxyDatagramSocket(SocketAddress bindaddr) throws SocketException {
+    public ProxyDatagramSocket(SocketAddress bindaddr, ProxyAddressCache cache, ProxyProtocolMetricsListener metrics, Predicate predicate) throws SocketException {
         super(bindaddr);
+        this.addressCache = cache;
+        this.metrics = metrics;
+        this.trustedProxyPredicate = predicate;
     }
 
-    public ProxyDatagramSocket(int port) throws SocketException {
+    public ProxyDatagramSocket(int port, ProxyAddressCache cache, ProxyProtocolMetricsListener metrics, Predicate predicate) throws SocketException {
         super(port);
+        this.addressCache = cache;
+        this.metrics = metrics;
+        this.trustedProxyPredicate = predicate;
     }
 
-    public ProxyDatagramSocket(int port, java.net.InetAddress laddr) throws SocketException {
+    public ProxyDatagramSocket(int port, java.net.InetAddress laddr, ProxyAddressCache cache, ProxyProtocolMetricsListener metrics, Predicate predicate) throws SocketException {
         super(port, laddr);
+        this.addressCache = cache;
+        this.metrics = metrics;
+        this.trustedProxyPredicate = predicate;
     }
 
-    public ProxyDatagramSocket setCache(ProxyAddressCache cache) { this.addressCache = cache; return this; }
-    public ProxyDatagramSocket setMetrics(ProxyProtocolMetricsListener metrics) { this.metrics = metrics; return this; }
-    public ProxyDatagramSocket setTrustedProxy(Predicate predicate) { this.trustedProxyPredicate = predicate; return this; }
-
     @Override
     public void receive(DatagramPacket packet)
         throws IOException, SocketTimeoutException, PortUnreachableException, IllegalBlockingModeException {
@@ -102,12 +114,14 @@ public void send(DatagramPacket packet) throws IOException {
         if (lb != null) {
             packet.setSocketAddress(lb);
             if (metrics != null) metrics.onCacheHit(client);
-        } else {
+        } else if (addressCache != null) {
+            // Cache miss: unable to map client to load balancer address,
+            LOG.log(Level.DEBUG, "Cache miss for client {0}; unable to map to load balancer address, dropping packet.", client);
             if (metrics != null) metrics.onCacheMiss(client);
+            return;
+        // } else {
+            // No cache: deliver original packet
         }
-
         super.send(packet);
     }
 }
-
-
diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIPMappingTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSockeTest.java
similarity index 70%
rename from proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIPMappingTest.java
rename to proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSockeTest.java
index 90ac7d2..79c2538 100644
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketIPMappingTest.java
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSockeTest.java
@@ -36,11 +36,7 @@ void setUp() throws Exception {
         mockCache = mock(ProxyAddressCache.class);
         mockMetrics = mock(ProxyProtocolMetricsListener.class);
 
-        socket = new ProxyDatagramSocket(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0))
-                .setCache(mockCache)
-                .setMetrics(mockMetrics)
-                .setTrustedProxy(addr -> true); // Trust all for these tests
-
+        socket = new ProxyDatagramSocket(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), mockCache, mockMetrics, null);
         localPort = socket.getLocalPort();
     }
 
@@ -58,8 +54,8 @@ void receive_withValidProxyHeader_populatesCache() throws Exception {
         InetSocketAddress lbAddress = new InetSocketAddress("127.0.0.1", 54321);
         byte[] payload = "test-data".getBytes(StandardCharsets.UTF_8);
 
-        byte[] proxyHeader = new ProxyProtocolV2Encoder()
-                .family(ProxyHeader.AddressFamily.INET4)
+        var proxyHeader = new AwsProxyEncoderHelper()
+                .family(ProxyHeader.AddressFamily.AF_INET)
                 .socket(ProxyHeader.TransportProtocol.DGRAM)
                 .source(realClient)
                 .destination(new InetSocketAddress("127.0.0.1", localPort))
@@ -177,17 +173,13 @@ void send_withCacheMiss_usesOriginalAddress() throws Exception {
         }
     }
 
-    @Test
-    void receive_withUntrustedProxy_skipsProcessing() throws Exception {
-        // Arrange - configure to reject all sources
-        socket.setTrustedProxy(addr -> false);
 
-        byte[] payload = "test".getBytes(StandardCharsets.UTF_8);
-        byte[] proxyHeader = new ProxyProtocolV2Encoder()
-                .family(ProxyHeader.AddressFamily.INET4)
-                .socket(ProxyHeader.TransportProtocol.DGRAM)
-                .source(new InetSocketAddress("10.1.2.3", 12345))
-                .destination(new InetSocketAddress("127.0.0.1", localPort))
+    @Test
+    void receive_withLocalCommand_doesNotPopulateCache() throws Exception {
+        // Arrange - create LOCAL command (not proxied)
+        byte[] payload = "local".getBytes(StandardCharsets.UTF_8);
+        byte[] proxyHeader = new AwsProxyEncoderHelper()
+                .command(ProxyHeader.Command.LOCAL)
                 .build();
 
         byte[] packet = new byte[proxyHeader.length + payload.length];
@@ -204,20 +196,25 @@ void receive_withUntrustedProxy_skipsProcessing() throws Exception {
         DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
         socket.receive(receivePacket);
 
-        // Assert - packet should be delivered unchanged, no parsing
-        verify(mockMetrics, never()).onHeaderParsed(any());
+        // Assert - cache should NOT be populated for LOCAL commands
         verify(mockCache, never()).put(any(), any());
 
-        // Packet length should include proxy header (not stripped)
-        assertEquals(packet.length, receivePacket.getLength());
+        // But metrics should still be called
+        verify(mockMetrics).onHeaderParsed(any());
+
+        // Payload should be stripped of header
+        assertEquals(payload.length, receivePacket.getLength());
     }
 
     @Test
-    void receive_withLocalCommand_doesNotPopulateCache() throws Exception {
-        // Arrange - create LOCAL command (not proxied)
-        byte[] payload = "local".getBytes(StandardCharsets.UTF_8);
+    void receive_withTcpProtocol_doesNotPopulateCache() throws Exception {
+        // Arrange - create header with TCP (not DGRAM) protocol
+        byte[] payload = "tcp".getBytes(StandardCharsets.UTF_8);
         byte[] proxyHeader = new ProxyProtocolV2Encoder()
-                .command(ProxyHeader.Command.LOCAL)
+                .family(ProxyHeader.AddressFamily.INET4)
+                .socket(ProxyHeader.TransportProtocol.STREAM) // TCP, not UDP
+                .source(new InetSocketAddress("10.1.2.3", 12345))
+                .destination(new InetSocketAddress("127.0.0.1", localPort))
                 .build();
 
         byte[] packet = new byte[proxyHeader.length + payload.length];
@@ -234,24 +231,24 @@ void receive_withLocalCommand_doesNotPopulateCache() throws Exception {
         DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
         socket.receive(receivePacket);
 
-        // Assert - cache should NOT be populated for LOCAL commands
+        // Assert - cache should NOT be populated for non-DGRAM protocols
         verify(mockCache, never()).put(any(), any());
 
-        // But metrics should still be called
+        // Metrics should still be called
         verify(mockMetrics).onHeaderParsed(any());
-
-        // Payload should be stripped of header
-        assertEquals(payload.length, receivePacket.getLength());
     }
 
+
     @Test
-    void receive_withTcpProtocol_doesNotPopulateCache() throws Exception {
-        // Arrange - create header with TCP (not DGRAM) protocol
-        byte[] payload = "tcp".getBytes(StandardCharsets.UTF_8);
-        byte[] proxyHeader = new ProxyProtocolV2Encoder()
+    void receive_withValidProxyHeader_callsMetricsOnHeaderParsed() throws Exception {
+        // Arrange
+        InetSocketAddress realClient = new InetSocketAddress("10.1.2.3", 12345);
+        byte[] payload = "test".getBytes(StandardCharsets.UTF_8);
+
+        byte[] proxyHeader = new AwsProxyEncoderHelper()
                 .family(ProxyHeader.AddressFamily.INET4)
-                .socket(ProxyHeader.TransportProtocol.STREAM) // TCP, not UDP
-                .source(new InetSocketAddress("10.1.2.3", 12345))
+                .socket(ProxyHeader.TransportProtocol.DGRAM)
+                .source(realClient)
                 .destination(new InetSocketAddress("127.0.0.1", localPort))
                 .build();
 
@@ -259,6 +256,7 @@ void receive_withTcpProtocol_doesNotPopulateCache() throws Exception {
         System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length);
         System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length);
 
+        // Send packet
         try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
             sender.send(new DatagramPacket(packet, packet.length,
                     new InetSocketAddress("127.0.0.1", localPort)));
@@ -269,11 +267,99 @@ void receive_withTcpProtocol_doesNotPopulateCache() throws Exception {
         DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
         socket.receive(receivePacket);
 
-        // Assert - cache should NOT be populated for non-DGRAM protocols
-        verify(mockCache, never()).put(any(), any());
+        // Assert - onHeaderParsed should be called
+        ArgumentCaptor headerCaptor = ArgumentCaptor.forClass(ProxyHeader.class);
+        verify(mockMetrics).onHeaderParsed(headerCaptor.capture());
 
-        // Metrics should still be called
-        verify(mockMetrics).onHeaderParsed(any());
+        ProxyHeader capturedHeader = headerCaptor.getValue();
+        assertNotNull(capturedHeader);
+        assertEquals(ProxyHeader.TransportProtocol.DGRAM, capturedHeader.getProtocol());
+        assertEquals(realClient, capturedHeader.getSourceAddress());
+    }
+
+    @Test
+    void receive_withInvalidData_callsMetricsOnParseError() throws Exception {
+        // Arrange - send garbage data
+        byte[] garbage = "not-a-proxy-header".getBytes(StandardCharsets.UTF_8);
+
+        try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
+            sender.send(new DatagramPacket(garbage, garbage.length,
+                    new InetSocketAddress("127.0.0.1", localPort)));
+        }
+
+        // Act
+        byte[] receiveBuf = new byte[2048];
+        DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
+        socket.receive(receivePacket);
+
+        // Assert - onParseError should be called
+        verify(mockMetrics).onParseError(any(Exception.class));
+
+        // Original packet should be delivered unchanged
+        assertEquals(garbage.length, receivePacket.getLength());
+    }
+
+    @Test
+    void send_withCacheHit_callsMetricsOnCacheHit() throws Exception {
+        // Arrange
+        InetSocketAddress realClient = new InetSocketAddress("10.1.2.3", 12345);
+        InetSocketAddress lbAddress = new InetSocketAddress("127.0.0.1", 54321);
+        byte[] payload = "response".getBytes(StandardCharsets.UTF_8);
+
+        // Mock cache to return lb address
+        when(mockCache.get(realClient)).thenReturn(lbAddress);
+
+        // Create a receiver to verify the packet destination
+        java.net.DatagramSocket receiver = new java.net.DatagramSocket(lbAddress);
+        receiver.setSoTimeout(1000);
+
+        try {
+            // Act - send to real client, should be redirected to LB
+            DatagramPacket sendPacket = new DatagramPacket(payload, payload.length, realClient);
+            socket.send(sendPacket);
+
+            // Receive the packet (to avoid timeout)
+            byte[] receiveBuf = new byte[2048];
+            DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
+            receiver.receive(receivePacket);
+
+            // Assert - onCacheHit should be called
+            verify(mockMetrics).onCacheHit(realClient);
+            verify(mockMetrics, never()).onCacheMiss(any());
+        } finally {
+            receiver.close();
+        }
+    }
+
+    @Test
+    void send_withCacheMiss_callsMetricsOnCacheMiss() throws Exception {
+        // Arrange
+        InetSocketAddress clientAddress = new InetSocketAddress("127.0.0.1", 55555);
+        byte[] payload = "response".getBytes(StandardCharsets.UTF_8);
+
+        // Mock cache to return null (cache miss)
+        when(mockCache.get(clientAddress)).thenReturn(null);
+
+        // Create a receiver at the client address
+        java.net.DatagramSocket receiver = new java.net.DatagramSocket(clientAddress);
+        receiver.setSoTimeout(1000);
+
+        try {
+            // Act - send to client address
+            DatagramPacket sendPacket = new DatagramPacket(payload, payload.length, clientAddress);
+            socket.send(sendPacket);
+
+            // Receive the packet (to avoid timeout)
+            byte[] receiveBuf = new byte[2048];
+            DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
+            receiver.receive(receivePacket);
+
+            // Assert - onCacheMiss should be called
+            verify(mockMetrics).onCacheMiss(clientAddress);
+            verify(mockMetrics, never()).onCacheHit(any());
+        } finally {
+            receiver.close();
+        }
     }
 }
 
diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSockeUnTrustedProxyTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSockeUnTrustedProxyTest.java
new file mode 100644
index 0000000..13f22c9
--- /dev/null
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSockeUnTrustedProxyTest.java
@@ -0,0 +1,106 @@
+/*
+ * MIT License
+ * Copyright (c) 2025 Semtech
+ */
+package net.airvantage.proxysocket.udp;
+
+import net.airvantage.proxysocket.core.ProxyAddressCache;
+import net.airvantage.proxysocket.core.ProxyProtocolMetricsListener;
+import net.airvantage.proxysocket.core.v2.ProxyHeader;
+import net.airvantage.proxysocket.core.v2.ProxyProtocolV2Encoder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.net.DatagramPacket;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Unit tests for ProxyDatagramSocket with untrusted proxy source.
+ */
+class ProxyDatagramSocketMetricsTest {
+
+    private ProxyDatagramSocket socket;
+    private ProxyAddressCache mockCache;
+    private ProxyProtocolMetricsListener mockMetrics;
+    private int localPort;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        mockCache = mock(ProxyAddressCache.class);
+        mockMetrics = mock(ProxyProtocolMetricsListener.class);
+
+        socket = new ProxyDatagramSocket(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), mockCache, mockMetrics, addr -> false);
+        localPort = socket.getLocalPort();
+    }
+
+    @AfterEach
+    void tearDown() {
+        if (socket != null && !socket.isClosed()) {
+            socket.close();
+        }
+    }
+
+    @Test
+    void receive_withUntrustedProxy_doesNotStripHeader() throws Exception {
+        InetSocketAddress clientAddress = new InetSocketAddress("127.0.0.1", 12345);
+
+        byte[] payload = "test".getBytes(StandardCharsets.UTF_8);
+        byte[] proxyHeader = new AwsProxyEncoderHelper()
+                .family(ProxyHeader.AddressFamily.AF_INET)
+                .socket(ProxyHeader.TransportProtocol.DGRAM)
+                .source(clientAddress)
+                .destination(new InetSocketAddress("127.0.0.1", localPort))
+                .build();
+
+        byte[] packet = new byte[proxyHeader.length + payload.length];
+        System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length);
+        System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length);
+
+        try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
+            sender.send(new DatagramPacket(packet, packet.length,
+                    new InetSocketAddress("127.0.0.1", localPort)));
+        }
+
+        // Act
+        byte[] receiveBuf = new byte[2048];
+        DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
+        socket.receive(receivePacket);
+
+        // Assert - no metrics should be called for untrusted sources
+        verify(mockMetrics, never()).onHeaderParsed(any());
+        verify(mockMetrics, never()).onParseError(any());
+        verify(mockMetrics, never()).onTrustedProxy(any());
+        verify(mockMetrics).onUntrustedProxy(clientAddress);
+
+        // Packet length should include proxy header (not stripped)
+        assertEquals(packet.length, receivePacket.getLength());
+    }
+
+    @Test
+    void receive_withUntrustedProxy_doesNotParse() throws Exception {
+        InetSocketAddress clientAddress = new InetSocketAddress("127.0.0.1", 12345);
+
+        byte[] packet = "Not a proxy header".getBytes(StandardCharsets.UTF_8);
+
+        try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
+            sender.send(new DatagramPacket(packet, packet.length,
+                    new InetSocketAddress("127.0.0.1", localPort)));
+        }
+
+        // Act
+        byte[] receiveBuf = new byte[2048];
+        DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
+        assertDoesNotThrow(() -> socket.receive(receivePacket), "No ProxyProtocolException should be thrown");
+
+        // Packet length should be the same as the original packet length
+        assertEquals(packet.length, receivePacket.getLength());
+    }
+}
+
diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketMetricsTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketMetricsTest.java
deleted file mode 100644
index 4de9c03..0000000
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketMetricsTest.java
+++ /dev/null
@@ -1,209 +0,0 @@
-/*
- * MIT License
- * Copyright (c) 2025 Semtech
- */
-package net.airvantage.proxysocket.udp;
-
-import net.airvantage.proxysocket.core.ProxyAddressCache;
-import net.airvantage.proxysocket.core.ProxyProtocolMetricsListener;
-import net.airvantage.proxysocket.core.v2.ProxyHeader;
-import net.airvantage.proxysocket.core.v2.ProxyProtocolV2Encoder;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentCaptor;
-
-import java.net.DatagramPacket;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
-import java.nio.charset.StandardCharsets;
-
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.Mockito.*;
-
-/**
- * Unit tests for ProxyDatagramSocket metrics tracking behavior.
- */
-class ProxyDatagramSocketMetricsTest {
-
-    private ProxyDatagramSocket socket;
-    private ProxyAddressCache mockCache;
-    private ProxyProtocolMetricsListener mockMetrics;
-    private int localPort;
-
-    @BeforeEach
-    void setUp() throws Exception {
-        mockCache = mock(ProxyAddressCache.class);
-        mockMetrics = mock(ProxyProtocolMetricsListener.class);
-
-        socket = new ProxyDatagramSocket(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0))
-                .setCache(mockCache)
-                .setMetrics(mockMetrics)
-                .setTrustedProxy(addr -> true); // Trust all for these tests
-
-        localPort = socket.getLocalPort();
-    }
-
-    @AfterEach
-    void tearDown() {
-        if (socket != null && !socket.isClosed()) {
-            socket.close();
-        }
-    }
-
-    @Test
-    void receive_withValidProxyHeader_callsMetricsOnHeaderParsed() throws Exception {
-        // Arrange
-        InetSocketAddress realClient = new InetSocketAddress("10.1.2.3", 12345);
-        byte[] payload = "test".getBytes(StandardCharsets.UTF_8);
-
-        byte[] proxyHeader = new ProxyProtocolV2Encoder()
-                .family(ProxyHeader.AddressFamily.INET4)
-                .socket(ProxyHeader.TransportProtocol.DGRAM)
-                .source(realClient)
-                .destination(new InetSocketAddress("127.0.0.1", localPort))
-                .build();
-
-        byte[] packet = new byte[proxyHeader.length + payload.length];
-        System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length);
-        System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length);
-
-        // Send packet
-        try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
-            sender.send(new DatagramPacket(packet, packet.length,
-                    new InetSocketAddress("127.0.0.1", localPort)));
-        }
-
-        // Act
-        byte[] receiveBuf = new byte[2048];
-        DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
-        socket.receive(receivePacket);
-
-        // Assert - onHeaderParsed should be called
-        ArgumentCaptor headerCaptor = ArgumentCaptor.forClass(ProxyHeader.class);
-        verify(mockMetrics).onHeaderParsed(headerCaptor.capture());
-
-        ProxyHeader capturedHeader = headerCaptor.getValue();
-        assertNotNull(capturedHeader);
-        assertEquals(ProxyHeader.TransportProtocol.DGRAM, capturedHeader.getProtocol());
-        assertEquals(realClient, capturedHeader.getSourceAddress());
-    }
-
-    @Test
-    void receive_withInvalidData_callsMetricsOnParseError() throws Exception {
-        // Arrange - send garbage data
-        byte[] garbage = "not-a-proxy-header".getBytes(StandardCharsets.UTF_8);
-
-        try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
-            sender.send(new DatagramPacket(garbage, garbage.length,
-                    new InetSocketAddress("127.0.0.1", localPort)));
-        }
-
-        // Act
-        byte[] receiveBuf = new byte[2048];
-        DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
-        socket.receive(receivePacket);
-
-        // Assert - onParseError should be called
-        verify(mockMetrics).onParseError(any(Exception.class));
-
-        // Original packet should be delivered unchanged
-        assertEquals(garbage.length, receivePacket.getLength());
-    }
-
-    @Test
-    void send_withCacheHit_callsMetricsOnCacheHit() throws Exception {
-        // Arrange
-        InetSocketAddress realClient = new InetSocketAddress("10.1.2.3", 12345);
-        InetSocketAddress lbAddress = new InetSocketAddress("127.0.0.1", 54321);
-        byte[] payload = "response".getBytes(StandardCharsets.UTF_8);
-
-        // Mock cache to return lb address
-        when(mockCache.get(realClient)).thenReturn(lbAddress);
-
-        // Create a receiver to verify the packet destination
-        java.net.DatagramSocket receiver = new java.net.DatagramSocket(lbAddress);
-        receiver.setSoTimeout(1000);
-
-        try {
-            // Act - send to real client, should be redirected to LB
-            DatagramPacket sendPacket = new DatagramPacket(payload, payload.length, realClient);
-            socket.send(sendPacket);
-
-            // Receive the packet (to avoid timeout)
-            byte[] receiveBuf = new byte[2048];
-            DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
-            receiver.receive(receivePacket);
-
-            // Assert - onCacheHit should be called
-            verify(mockMetrics).onCacheHit(realClient);
-            verify(mockMetrics, never()).onCacheMiss(any());
-        } finally {
-            receiver.close();
-        }
-    }
-
-    @Test
-    void send_withCacheMiss_callsMetricsOnCacheMiss() throws Exception {
-        // Arrange
-        InetSocketAddress clientAddress = new InetSocketAddress("127.0.0.1", 55555);
-        byte[] payload = "response".getBytes(StandardCharsets.UTF_8);
-
-        // Mock cache to return null (cache miss)
-        when(mockCache.get(clientAddress)).thenReturn(null);
-
-        // Create a receiver at the client address
-        java.net.DatagramSocket receiver = new java.net.DatagramSocket(clientAddress);
-        receiver.setSoTimeout(1000);
-
-        try {
-            // Act - send to client address
-            DatagramPacket sendPacket = new DatagramPacket(payload, payload.length, clientAddress);
-            socket.send(sendPacket);
-
-            // Receive the packet (to avoid timeout)
-            byte[] receiveBuf = new byte[2048];
-            DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
-            receiver.receive(receivePacket);
-
-            // Assert - onCacheMiss should be called
-            verify(mockMetrics).onCacheMiss(clientAddress);
-            verify(mockMetrics, never()).onCacheHit(any());
-        } finally {
-            receiver.close();
-        }
-    }
-
-    @Test
-    void receive_withUntrustedProxy_doesNotCallMetrics() throws Exception {
-        // Arrange - configure to reject all sources
-        socket.setTrustedProxy(addr -> false);
-
-        byte[] payload = "test".getBytes(StandardCharsets.UTF_8);
-        byte[] proxyHeader = new ProxyProtocolV2Encoder()
-                .family(ProxyHeader.AddressFamily.INET4)
-                .socket(ProxyHeader.TransportProtocol.DGRAM)
-                .source(new InetSocketAddress("10.1.2.3", 12345))
-                .destination(new InetSocketAddress("127.0.0.1", localPort))
-                .build();
-
-        byte[] packet = new byte[proxyHeader.length + payload.length];
-        System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length);
-        System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length);
-
-        try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
-            sender.send(new DatagramPacket(packet, packet.length,
-                    new InetSocketAddress("127.0.0.1", localPort)));
-        }
-
-        // Act
-        byte[] receiveBuf = new byte[2048];
-        DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
-        socket.receive(receivePacket);
-
-        // Assert - no metrics should be called for untrusted sources
-        verify(mockMetrics, never()).onHeaderParsed(any());
-        verify(mockMetrics, never()).onParseError(any());
-    }
-}
-

From 07ee29cd382bea39fadb0cc26ed130c738aec0f8 Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Thu, 27 Nov 2025 17:06:20 +0100
Subject: [PATCH 05/25] fix tests

---
 proxy-socket-core/pom.xml                     | 18 ++++++++++++++++
 .../core/ProxyProtocolMetricsListener.java    |  8 ++++---
 proxy-socket-udp/pom.xml                      | 21 +++++++++++++++++++
 .../proxysocket/udp/ProxyDatagramSocket.java  | 21 +++++++++++--------
 ...Test.java => ProxyDatagramSocketTest.java} | 15 ++++++++-----
 ...roxyDatagramSocketUnTrustedProxyTest.java} |  9 +++++---
 6 files changed, 72 insertions(+), 20 deletions(-)
 rename proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/{ProxyDatagramSockeTest.java => ProxyDatagramSocketTest.java} (96%)
 rename proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/{ProxyDatagramSockeUnTrustedProxyTest.java => ProxyDatagramSocketUnTrustedProxyTest.java} (92%)

diff --git a/proxy-socket-core/pom.xml b/proxy-socket-core/pom.xml
index c4c601f..07b3007 100644
--- a/proxy-socket-core/pom.xml
+++ b/proxy-socket-core/pom.xml
@@ -33,6 +33,24 @@
         
     
 
+    
+        
+            
+            
+                org.apache.maven.plugins
+                maven-jar-plugin
+                3.3.0
+                
+                    
+                        
+                            test-jar
+                        
+                    
+                
+            
+        
+    
+
 
 
 
diff --git a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/core/ProxyProtocolMetricsListener.java b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/core/ProxyProtocolMetricsListener.java
index ca83741..f199f52 100644
--- a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/core/ProxyProtocolMetricsListener.java
+++ b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/core/ProxyProtocolMetricsListener.java
@@ -5,6 +5,8 @@
 package net.airvantage.proxysocket.core;
 
 import net.airvantage.proxysocket.core.v2.ProxyHeader;
+
+import java.net.InetAddress;
 import java.net.InetSocketAddress;
 
 /**
@@ -16,7 +18,7 @@ default void onHeaderParsed(ProxyHeader header) {}
     default void onParseError(Exception e) {}
     default void onCacheHit(InetSocketAddress client) {}
     default void onCacheMiss(InetSocketAddress client) {}
-    default void onUntrustedProxy(InetSocketAddress proxy) {}
-    default void onTrustedProxy(InetSocketAddress proxy) {}
-    default void onLocal(InetSocketAddress proxy) {}
+    default void onUntrustedProxy(InetAddress proxy) {}
+    default void onTrustedProxy(InetAddress proxy) {}
+    default void onLocal(InetAddress proxy) {}
 }
diff --git a/proxy-socket-udp/pom.xml b/proxy-socket-udp/pom.xml
index 6da18f3..2439ec1 100644
--- a/proxy-socket-udp/pom.xml
+++ b/proxy-socket-udp/pom.xml
@@ -18,6 +18,13 @@
             proxy-socket-core
             ${project.version}
         
+        
+            net.airvantage
+            proxy-socket-core
+            ${project.version}
+            test-jar
+            test
+        
         
             org.slf4j
             slf4j-api
@@ -37,6 +44,20 @@
             2.0.17
             test
         
+        
+            org.mockito
+            mockito-core
+            5.2.0
+            test
+        
+        
+        
+            com.amazonaws.proprot
+            proprot
+            1.0
+            test
+        
+
     
 
 
diff --git a/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java b/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java
index 13cdf94..d572362 100644
--- a/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java
+++ b/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java
@@ -10,17 +10,19 @@
 import net.airvantage.proxysocket.core.v2.ProxyHeader;
 import net.airvantage.proxysocket.core.v2.ProxyProtocolV2Decoder;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.io.IOException;
 import java.net.DatagramPacket;
 import java.net.DatagramSocket;
+import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.PortUnreachableException;
 import java.net.SocketAddress;
 import java.net.SocketException;
 import java.net.SocketTimeoutException;
 import java.nio.channels.IllegalBlockingModeException;
-import java.util.logging.Level;
-import java.util.logging.Logger;
 import java.util.function.Predicate;
 
 /**
@@ -32,7 +34,7 @@
  * not mutate shared state beyond those collaborators.
  */
 public class ProxyDatagramSocket extends DatagramSocket {
-    private static final Logger LOG = Logger.getLogger(ProxyDatagramSocket.class.getName());
+    private static final Logger LOG = LoggerFactory.getLogger(ProxyDatagramSocket.class);
 
     private final ProxyAddressCache addressCache;
     private final ProxyProtocolMetricsListener metrics;
@@ -76,8 +78,8 @@ public void receive(DatagramPacket packet)
             InetSocketAddress lbAddress = (InetSocketAddress) packet.getSocketAddress();
             if (trustedProxyPredicate != null && !trustedProxyPredicate.test(lbAddress)) {
                 // Untrusted source: do not parse, deliver original packet
-                LOG.log(Level.DEBUG, "Untrusted proxy source; delivering original packet.");
-                if (metrics != null) metrics.onUntrustedProxy(lbAddress);
+                LOG.debug("Untrusted proxy source; delivering original packet.");
+                if (metrics != null) metrics.onUntrustedProxy(lbAddress.getAddress());
                 return;
             }
 
@@ -86,10 +88,10 @@ public void receive(DatagramPacket packet)
 
             if (header.isLocal()) {
                 // LOCAL: not proxied
-                if (metrics != null) metrics.onLocal(lbAddress);
+                if (metrics != null) metrics.onLocal(lbAddress.getAddress());
             }
             if (header.isProxy() && header.getProtocol() == ProxyHeader.TransportProtocol.DGRAM) {
-                if (metrics != null) metrics.onTrustedProxy(lbAddress);
+                if (metrics != null) metrics.onTrustedProxy(lbAddress.getAddress());
 
                 InetSocketAddress realClient = header.getSourceAddress();
                 if (realClient != null) { // could be null if address family is unspecified or unix
@@ -99,9 +101,10 @@ public void receive(DatagramPacket packet)
             }
 
             int headerLen = header.getHeaderLength();
+            LOG.trace("Stripping header: {} bytes, remaining length: {}", headerLen, packet.getLength() - headerLen);
             packet.setData(packet.getData(), packet.getOffset() + headerLen, packet.getLength() - headerLen);
         } catch (ProxyProtocolParseException e) {
-            LOG.log(Level.WARNING, "Proxy socket parse error; delivering original packet.", e);
+            LOG.warn("Proxy socket parse error; delivering original packet.", e);
             if (metrics != null) metrics.onParseError(e);
         }
     }
@@ -116,7 +119,7 @@ public void send(DatagramPacket packet) throws IOException {
             if (metrics != null) metrics.onCacheHit(client);
         } else if (addressCache != null) {
             // Cache miss: unable to map client to load balancer address,
-            LOG.log(Level.DEBUG, "Cache miss for client {0}; unable to map to load balancer address, dropping packet.", client);
+            LOG.debug("Cache miss for client {}; unable to map to load balancer address, dropping packet.", client);
             if (metrics != null) metrics.onCacheMiss(client);
             return;
         // } else {
diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSockeTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
similarity index 96%
rename from proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSockeTest.java
rename to proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
index 79c2538..c19d21b 100644
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSockeTest.java
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
@@ -7,7 +7,7 @@
 import net.airvantage.proxysocket.core.ProxyAddressCache;
 import net.airvantage.proxysocket.core.ProxyProtocolMetricsListener;
 import net.airvantage.proxysocket.core.v2.ProxyHeader;
-import net.airvantage.proxysocket.core.v2.ProxyProtocolV2Encoder;
+import net.airvantage.proxysocket.core.v2.AwsProxyEncoderHelper;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -94,6 +94,12 @@ void receive_withValidProxyHeader_populatesCache() throws Exception {
                 java.util.Arrays.copyOfRange(receivePacket.getData(),
                         receivePacket.getOffset(),
                         receivePacket.getOffset() + receivePacket.getLength()));
+
+        verify(mockMetrics).onHeaderParsed(any(ProxyHeader.class));
+        verify(mockMetrics).onTrustedProxy(lbAddress.getAddress());
+        verify(mockMetrics, never()).onUntrustedProxy(any());
+        verify(mockMetrics, never()).onParseError(any());
+        verify(mockMetrics, never()).onLocal(any());
     }
 
     @Test
@@ -173,7 +179,6 @@ void send_withCacheMiss_usesOriginalAddress() throws Exception {
         }
     }
 
-
     @Test
     void receive_withLocalCommand_doesNotPopulateCache() throws Exception {
         // Arrange - create LOCAL command (not proxied)
@@ -210,8 +215,8 @@ void receive_withLocalCommand_doesNotPopulateCache() throws Exception {
     void receive_withTcpProtocol_doesNotPopulateCache() throws Exception {
         // Arrange - create header with TCP (not DGRAM) protocol
         byte[] payload = "tcp".getBytes(StandardCharsets.UTF_8);
-        byte[] proxyHeader = new ProxyProtocolV2Encoder()
-                .family(ProxyHeader.AddressFamily.INET4)
+        byte[] proxyHeader = new AwsProxyEncoderHelper()
+                .family(ProxyHeader.AddressFamily.AF_INET)
                 .socket(ProxyHeader.TransportProtocol.STREAM) // TCP, not UDP
                 .source(new InetSocketAddress("10.1.2.3", 12345))
                 .destination(new InetSocketAddress("127.0.0.1", localPort))
@@ -246,7 +251,7 @@ void receive_withValidProxyHeader_callsMetricsOnHeaderParsed() throws Exception
         byte[] payload = "test".getBytes(StandardCharsets.UTF_8);
 
         byte[] proxyHeader = new AwsProxyEncoderHelper()
-                .family(ProxyHeader.AddressFamily.INET4)
+                .family(ProxyHeader.AddressFamily.AF_INET)
                 .socket(ProxyHeader.TransportProtocol.DGRAM)
                 .source(realClient)
                 .destination(new InetSocketAddress("127.0.0.1", localPort))
diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSockeUnTrustedProxyTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
similarity index 92%
rename from proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSockeUnTrustedProxyTest.java
rename to proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
index 13f22c9..b1211ae 100644
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSockeUnTrustedProxyTest.java
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
@@ -7,13 +7,14 @@
 import net.airvantage.proxysocket.core.ProxyAddressCache;
 import net.airvantage.proxysocket.core.ProxyProtocolMetricsListener;
 import net.airvantage.proxysocket.core.v2.ProxyHeader;
-import net.airvantage.proxysocket.core.v2.ProxyProtocolV2Encoder;
+import net.airvantage.proxysocket.core.v2.AwsProxyEncoderHelper;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 
 import java.net.DatagramPacket;
+import java.net.DatagramSocket;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.nio.charset.StandardCharsets;
@@ -50,6 +51,7 @@ void tearDown() {
     @Test
     void receive_withUntrustedProxy_doesNotStripHeader() throws Exception {
         InetSocketAddress clientAddress = new InetSocketAddress("127.0.0.1", 12345);
+        InetAddress lbAddress;
 
         byte[] payload = "test".getBytes(StandardCharsets.UTF_8);
         byte[] proxyHeader = new AwsProxyEncoderHelper()
@@ -63,9 +65,10 @@ void receive_withUntrustedProxy_doesNotStripHeader() throws Exception {
         System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length);
         System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length);
 
-        try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
+        try (DatagramSocket sender = new DatagramSocket(clientAddress)) {
             sender.send(new DatagramPacket(packet, packet.length,
                     new InetSocketAddress("127.0.0.1", localPort)));
+            lbAddress = sender.getLocalAddress();
         }
 
         // Act
@@ -77,7 +80,7 @@ void receive_withUntrustedProxy_doesNotStripHeader() throws Exception {
         verify(mockMetrics, never()).onHeaderParsed(any());
         verify(mockMetrics, never()).onParseError(any());
         verify(mockMetrics, never()).onTrustedProxy(any());
-        verify(mockMetrics).onUntrustedProxy(clientAddress);
+        verify(mockMetrics).onUntrustedProxy(lbAddress);
 
         // Packet length should include proxy header (not stripped)
         assertEquals(packet.length, receivePacket.getLength());

From d582239339ae6a5152826bb692bd2a0a8ed2bfdb Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Thu, 27 Nov 2025 19:28:35 +0100
Subject: [PATCH 06/25] Fix the handling on local mode in AwsProxyEncoderHelper

---
 .../core/v2/ProxyProtocolV2Decoder.java       |  16 +-
 .../core/v2/AwsProxyEncoderHelper.java        |  23 +--
 .../proxysocket/udp/ProxyDatagramSocket.java  |   2 +-
 .../udp/ProxyDatagramSocketTest.java          | 148 ++++++------------
 ...ProxyDatagramSocketUnTrustedProxyTest.java |   2 +-
 .../airvantage/proxysocket/udp/Utility.java   |  41 +++++
 6 files changed, 105 insertions(+), 127 deletions(-)
 create mode 100644 proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/Utility.java

diff --git a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/core/v2/ProxyProtocolV2Decoder.java b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/core/v2/ProxyProtocolV2Decoder.java
index 95f0edd..7e21bfe 100644
--- a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/core/v2/ProxyProtocolV2Decoder.java
+++ b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/core/v2/ProxyProtocolV2Decoder.java
@@ -65,17 +65,11 @@ public static ProxyHeader parse(byte[] data, int offset, int length, boolean par
             throw new ProxyProtocolParseException("Invalid version");
         }
 
-        Command command;
-        switch (cmd) {
-            case 0x00:
-                // Early return for LOCAL command
-                return new ProxyHeader(Command.LOCAL, AddressFamily.AF_UNSPEC, TransportProtocol.UNSPEC, null, null, null, PROTOCOL_SIGNATURE_FIXED_LENGTH);
-            case 0x01:
-                command = Command.PROXY;
-                break;
-            default:
-                throw new ProxyProtocolParseException("Invalid command");
-        }
+        Command command = switch (cmd) {
+            case 0x00 -> Command.LOCAL;
+            case 0x01 -> Command.PROXY;
+            default -> throw new ProxyProtocolParseException("Invalid command");
+        };
 
         // Byte 14: address family and protocol
         int famProto = data[pos++] & 0xFF;
diff --git a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/AwsProxyEncoderHelper.java b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/AwsProxyEncoderHelper.java
index 885e144..1bfa641 100644
--- a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/AwsProxyEncoderHelper.java
+++ b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/AwsProxyEncoderHelper.java
@@ -28,9 +28,18 @@ public final class AwsProxyEncoderHelper {
     private final Header header = new Header();
 
     public AwsProxyEncoderHelper command(ProxyHeader.Command cmd) {
-        this.command = cmd == ProxyHeader.Command.LOCAL
-                ? ProxyProtocolSpec.Command.LOCAL
-                : ProxyProtocolSpec.Command.PROXY;
+        if (cmd == ProxyHeader.Command.LOCAL) {
+            this.command = ProxyProtocolSpec.Command.LOCAL;
+
+            // Spec clearly state that for LOCAL command, we
+            // 1. must discard the protocol block including the family and
+            // 2. \x00 is expected to be used for the protocol field.
+            this.family = ProxyProtocolSpec.AddressFamily.AF_UNSPEC;
+            this.protocol = ProxyProtocolSpec.TransportProtocol.UNSPEC;
+        } else {
+            this.command = ProxyProtocolSpec.Command.PROXY;
+        }
+
         return this;
     }
 
@@ -76,13 +85,7 @@ public byte[] build() throws IOException {
         header.setAddressFamily(family);
         header.setTransportProtocol(protocol);
 
-        // AWS ProProt validates addresses even for LOCAL command, set dummy values
-        if (command == ProxyProtocolSpec.Command.LOCAL && source == null) {
-            header.setSrcAddress(new byte[]{0, 0, 0, 0});
-            header.setDstAddress(new byte[]{0, 0, 0, 0});
-            header.setSrcPort(0);
-            header.setDstPort(0);
-        } else {
+        if (command != ProxyProtocolSpec.Command.LOCAL) {
             if (source != null) {
                 header.setSrcAddress(source.getAddress().getAddress());
                 header.setSrcPort(source.getPort());
diff --git a/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java b/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java
index d572362..e4009b0 100644
--- a/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java
+++ b/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java
@@ -101,7 +101,7 @@ public void receive(DatagramPacket packet)
             }
 
             int headerLen = header.getHeaderLength();
-            LOG.trace("Stripping header: {} bytes, remaining length: {}", headerLen, packet.getLength() - headerLen);
+            LOG.trace("Stripping header: {} bytes, original length: {}", headerLen, packet.getLength());
             packet.setData(packet.getData(), packet.getOffset() + headerLen, packet.getLength() - headerLen);
         } catch (ProxyProtocolParseException e) {
             LOG.warn("Proxy socket parse error; delivering original packet.", e);
diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
index c19d21b..ab9b5dd 100644
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
@@ -21,14 +21,21 @@
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.Mockito.*;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 /**
  * Unit tests for ProxyDatagramSocket IP address mapping and cache behavior.
  */
-class ProxyDatagramSocketIPMappingTest {
+class ProxyDatagramSocketTest {
+    private static final Logger LOG = LoggerFactory.getLogger(ProxyDatagramSocket.class);
 
     private ProxyDatagramSocket socket;
     private ProxyAddressCache mockCache;
     private ProxyProtocolMetricsListener mockMetrics;
+
+    private InetSocketAddress realClient;
+    private InetSocketAddress serviceAddress;
+    private InetSocketAddress backendAddress;
     private int localPort;
 
     @BeforeEach
@@ -36,8 +43,11 @@ void setUp() throws Exception {
         mockCache = mock(ProxyAddressCache.class);
         mockMetrics = mock(ProxyProtocolMetricsListener.class);
 
+        realClient = new InetSocketAddress(InetAddress.getLoopbackAddress(), 12345);
+        serviceAddress = new InetSocketAddress(InetAddress.getLoopbackAddress(), 54321);
         socket = new ProxyDatagramSocket(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), mockCache, mockMetrics, null);
         localPort = socket.getLocalPort();
+        backendAddress = new InetSocketAddress(InetAddress.getLoopbackAddress(), localPort);
     }
 
     @AfterEach
@@ -48,17 +58,15 @@ void tearDown() {
     }
 
     @Test
-    void receive_withValidProxyHeader_populatesCache() throws Exception {
+    void receive_withValidProxyHeader() throws Exception {
         // Arrange
-        InetSocketAddress realClient = new InetSocketAddress("10.1.2.3", 12345);
-        InetSocketAddress lbAddress = new InetSocketAddress("127.0.0.1", 54321);
         byte[] payload = "test-data".getBytes(StandardCharsets.UTF_8);
 
         var proxyHeader = new AwsProxyEncoderHelper()
                 .family(ProxyHeader.AddressFamily.AF_INET)
                 .socket(ProxyHeader.TransportProtocol.DGRAM)
                 .source(realClient)
-                .destination(new InetSocketAddress("127.0.0.1", localPort))
+                .destination(serviceAddress)
                 .build();
 
         byte[] packet = new byte[proxyHeader.length + payload.length];
@@ -66,9 +74,8 @@ void receive_withValidProxyHeader_populatesCache() throws Exception {
         System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length);
 
         // Create a loopback socket to send from
-        try (java.net.DatagramSocket sender = new java.net.DatagramSocket(lbAddress)) {
-            sender.send(new DatagramPacket(packet, packet.length,
-                    new InetSocketAddress("127.0.0.1", localPort)));
+        try (java.net.DatagramSocket sender = new java.net.DatagramSocket(serviceAddress)) {
+            sender.send(new DatagramPacket(packet, packet.length, backendAddress));
         }
 
         // Act
@@ -82,8 +89,8 @@ void receive_withValidProxyHeader_populatesCache() throws Exception {
         verify(mockCache).put(clientCaptor.capture(), lbCaptor.capture());
 
         assertEquals(realClient, clientCaptor.getValue());
-        assertEquals(lbAddress.getAddress(), lbCaptor.getValue().getAddress());
-        assertEquals(lbAddress.getPort(), lbCaptor.getValue().getPort());
+        assertEquals(serviceAddress.getAddress(), lbCaptor.getValue().getAddress());
+        assertEquals(serviceAddress.getPort(), lbCaptor.getValue().getPort());
 
         // Verify packet was modified to show real client address
         assertEquals(realClient, receivePacket.getSocketAddress());
@@ -96,7 +103,7 @@ void receive_withValidProxyHeader_populatesCache() throws Exception {
                         receivePacket.getOffset() + receivePacket.getLength()));
 
         verify(mockMetrics).onHeaderParsed(any(ProxyHeader.class));
-        verify(mockMetrics).onTrustedProxy(lbAddress.getAddress());
+        verify(mockMetrics).onTrustedProxy(serviceAddress.getAddress());
         verify(mockMetrics, never()).onUntrustedProxy(any());
         verify(mockMetrics, never()).onParseError(any());
         verify(mockMetrics, never()).onLocal(any());
@@ -104,16 +111,13 @@ void receive_withValidProxyHeader_populatesCache() throws Exception {
 
     @Test
     void send_withCacheHit_usesLoadBalancerAddress() throws Exception {
-        // Arrange
-        InetSocketAddress realClient = new InetSocketAddress("10.1.2.3", 12345);
-        InetSocketAddress lbAddress = new InetSocketAddress("127.0.0.1", 54321);
         byte[] payload = "response".getBytes(StandardCharsets.UTF_8);
 
         // Mock cache to return lb address
-        when(mockCache.get(realClient)).thenReturn(lbAddress);
+        when(mockCache.get(realClient)).thenReturn(serviceAddress);
 
         // Create a receiver to verify the packet destination
-        java.net.DatagramSocket receiver = new java.net.DatagramSocket(lbAddress);
+        java.net.DatagramSocket receiver = new java.net.DatagramSocket(serviceAddress);
         receiver.setSoTimeout(1000);
 
         try {
@@ -142,37 +146,35 @@ void send_withCacheHit_usesLoadBalancerAddress() throws Exception {
     }
 
     @Test
-    void send_withCacheMiss_usesOriginalAddress() throws Exception {
+    void send_withCacheMiss_dropsPacket() throws Exception {
         // Arrange
-        InetSocketAddress clientAddress = new InetSocketAddress("127.0.0.1", 55555);
         byte[] payload = "response".getBytes(StandardCharsets.UTF_8);
 
         // Mock cache to return null (cache miss)
-        when(mockCache.get(clientAddress)).thenReturn(null);
+        when(mockCache.get(realClient)).thenReturn(null);
 
-        // Create a receiver at the client address
-        java.net.DatagramSocket receiver = new java.net.DatagramSocket(clientAddress);
-        receiver.setSoTimeout(1000);
+        // Create a receiver at the client address to verify packet is NOT sent
+        java.net.DatagramSocket receiver = new java.net.DatagramSocket(realClient);
+        receiver.setSoTimeout(500); // Short timeout since we expect no packet
 
         try {
             // Act - send to client address
-            DatagramPacket sendPacket = new DatagramPacket(payload, payload.length, clientAddress);
+            DatagramPacket sendPacket = new DatagramPacket(payload, payload.length, realClient);
             socket.send(sendPacket);
 
-            // Verify packet was sent to original address
+            // Try to receive - should timeout since packet was dropped
             byte[] receiveBuf = new byte[2048];
             DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
-            receiver.receive(receivePacket);
 
-            // Assert
-            assertArrayEquals(payload,
-                    java.util.Arrays.copyOfRange(receivePacket.getData(), 0, receivePacket.getLength()));
+            assertThrows(java.net.SocketTimeoutException.class, () -> {
+                receiver.receive(receivePacket);
+            }, "Expected packet to be dropped on cache miss");
 
             // Verify cache was queried
-            verify(mockCache).get(clientAddress);
+            verify(mockCache).get(realClient);
 
             // Verify metrics - cache miss
-            verify(mockMetrics).onCacheMiss(clientAddress);
+            verify(mockMetrics).onCacheMiss(realClient);
             verify(mockMetrics, never()).onCacheHit(any());
         } finally {
             receiver.close();
@@ -187,13 +189,15 @@ void receive_withLocalCommand_doesNotPopulateCache() throws Exception {
                 .command(ProxyHeader.Command.LOCAL)
                 .build();
 
+        LOG.trace("Payload:\n{}", Utility.hexdump(payload, 0, payload.length));
+        LOG.trace("Proxy header:\n{}", Utility.hexdump(proxyHeader, 0, proxyHeader.length));
+
         byte[] packet = new byte[proxyHeader.length + payload.length];
         System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length);
         System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length);
 
         try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
-            sender.send(new DatagramPacket(packet, packet.length,
-                    new InetSocketAddress("127.0.0.1", localPort)));
+            sender.send(new DatagramPacket(packet, packet.length, backendAddress));
         }
 
         // Act
@@ -201,6 +205,8 @@ void receive_withLocalCommand_doesNotPopulateCache() throws Exception {
         DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
         socket.receive(receivePacket);
 
+        LOG.trace("Received packet length={}, content:\n{}", receivePacket.getLength(), Utility.hexdump(receivePacket.getData(), receivePacket.getOffset(), receivePacket.getLength()));
+
         // Assert - cache should NOT be populated for LOCAL commands
         verify(mockCache, never()).put(any(), any());
 
@@ -218,8 +224,8 @@ void receive_withTcpProtocol_doesNotPopulateCache() throws Exception {
         byte[] proxyHeader = new AwsProxyEncoderHelper()
                 .family(ProxyHeader.AddressFamily.AF_INET)
                 .socket(ProxyHeader.TransportProtocol.STREAM) // TCP, not UDP
-                .source(new InetSocketAddress("10.1.2.3", 12345))
-                .destination(new InetSocketAddress("127.0.0.1", localPort))
+                .source(realClient)
+                .destination(serviceAddress)
                 .build();
 
         byte[] packet = new byte[proxyHeader.length + payload.length];
@@ -227,8 +233,7 @@ void receive_withTcpProtocol_doesNotPopulateCache() throws Exception {
         System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length);
 
         try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
-            sender.send(new DatagramPacket(packet, packet.length,
-                    new InetSocketAddress("127.0.0.1", localPort)));
+            sender.send(new DatagramPacket(packet, packet.length, backendAddress));
         }
 
         // Act
@@ -247,14 +252,13 @@ void receive_withTcpProtocol_doesNotPopulateCache() throws Exception {
     @Test
     void receive_withValidProxyHeader_callsMetricsOnHeaderParsed() throws Exception {
         // Arrange
-        InetSocketAddress realClient = new InetSocketAddress("10.1.2.3", 12345);
         byte[] payload = "test".getBytes(StandardCharsets.UTF_8);
 
         byte[] proxyHeader = new AwsProxyEncoderHelper()
                 .family(ProxyHeader.AddressFamily.AF_INET)
                 .socket(ProxyHeader.TransportProtocol.DGRAM)
                 .source(realClient)
-                .destination(new InetSocketAddress("127.0.0.1", localPort))
+                .destination(serviceAddress)
                 .build();
 
         byte[] packet = new byte[proxyHeader.length + payload.length];
@@ -263,8 +267,7 @@ void receive_withValidProxyHeader_callsMetricsOnHeaderParsed() throws Exception
 
         // Send packet
         try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
-            sender.send(new DatagramPacket(packet, packet.length,
-                    new InetSocketAddress("127.0.0.1", localPort)));
+            sender.send(new DatagramPacket(packet, packet.length, backendAddress));
         }
 
         // Act
@@ -288,8 +291,7 @@ void receive_withInvalidData_callsMetricsOnParseError() throws Exception {
         byte[] garbage = "not-a-proxy-header".getBytes(StandardCharsets.UTF_8);
 
         try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
-            sender.send(new DatagramPacket(garbage, garbage.length,
-                    new InetSocketAddress("127.0.0.1", localPort)));
+            sender.send(new DatagramPacket(garbage, garbage.length, backendAddress));
         }
 
         // Act
@@ -304,67 +306,5 @@ void receive_withInvalidData_callsMetricsOnParseError() throws Exception {
         assertEquals(garbage.length, receivePacket.getLength());
     }
 
-    @Test
-    void send_withCacheHit_callsMetricsOnCacheHit() throws Exception {
-        // Arrange
-        InetSocketAddress realClient = new InetSocketAddress("10.1.2.3", 12345);
-        InetSocketAddress lbAddress = new InetSocketAddress("127.0.0.1", 54321);
-        byte[] payload = "response".getBytes(StandardCharsets.UTF_8);
-
-        // Mock cache to return lb address
-        when(mockCache.get(realClient)).thenReturn(lbAddress);
-
-        // Create a receiver to verify the packet destination
-        java.net.DatagramSocket receiver = new java.net.DatagramSocket(lbAddress);
-        receiver.setSoTimeout(1000);
-
-        try {
-            // Act - send to real client, should be redirected to LB
-            DatagramPacket sendPacket = new DatagramPacket(payload, payload.length, realClient);
-            socket.send(sendPacket);
-
-            // Receive the packet (to avoid timeout)
-            byte[] receiveBuf = new byte[2048];
-            DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
-            receiver.receive(receivePacket);
-
-            // Assert - onCacheHit should be called
-            verify(mockMetrics).onCacheHit(realClient);
-            verify(mockMetrics, never()).onCacheMiss(any());
-        } finally {
-            receiver.close();
-        }
-    }
-
-    @Test
-    void send_withCacheMiss_callsMetricsOnCacheMiss() throws Exception {
-        // Arrange
-        InetSocketAddress clientAddress = new InetSocketAddress("127.0.0.1", 55555);
-        byte[] payload = "response".getBytes(StandardCharsets.UTF_8);
-
-        // Mock cache to return null (cache miss)
-        when(mockCache.get(clientAddress)).thenReturn(null);
-
-        // Create a receiver at the client address
-        java.net.DatagramSocket receiver = new java.net.DatagramSocket(clientAddress);
-        receiver.setSoTimeout(1000);
-
-        try {
-            // Act - send to client address
-            DatagramPacket sendPacket = new DatagramPacket(payload, payload.length, clientAddress);
-            socket.send(sendPacket);
-
-            // Receive the packet (to avoid timeout)
-            byte[] receiveBuf = new byte[2048];
-            DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
-            receiver.receive(receivePacket);
-
-            // Assert - onCacheMiss should be called
-            verify(mockMetrics).onCacheMiss(clientAddress);
-            verify(mockMetrics, never()).onCacheHit(any());
-        } finally {
-            receiver.close();
-        }
-    }
 }
 
diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
index b1211ae..2c64669 100644
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
@@ -25,7 +25,7 @@
 /**
  * Unit tests for ProxyDatagramSocket with untrusted proxy source.
  */
-class ProxyDatagramSocketMetricsTest {
+class ProxyDatagramSocketUnTrustedProxyTest {
 
     private ProxyDatagramSocket socket;
     private ProxyAddressCache mockCache;
diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/Utility.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/Utility.java
new file mode 100644
index 0000000..369196d
--- /dev/null
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/Utility.java
@@ -0,0 +1,41 @@
+/*
+ * MIT License
+ * Copyright (c) 2025 Semtech
+ */
+package net.airvantage.proxysocket.udp;
+
+public final class Utility {
+    private Utility() {}
+
+    public static String hexdump(byte[] data, int offset, int length) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = offset; i < offset + length; i += 16) {
+            // Offset
+            sb.append(String.format("%08x  ", i));
+
+            // Hex bytes (first 8)
+            for (int j = 0; j < 8 && i + j < offset + length; j++) {
+                sb.append(String.format("%02x ", data[i + j]));
+            }
+            sb.append(" ");
+
+            // Hex bytes (second 8)
+            for (int j = 8; j < 16 && i + j < offset + length; j++) {
+                sb.append(String.format("%02x ", data[i + j]));
+            }
+
+            // Padding if last line is incomplete
+            int remaining = 16 - Math.min(16, offset + length - i);
+            sb.append("   ".repeat(Math.max(0, remaining)));
+
+            // ASCII representation
+            sb.append(" |");
+            for (int j = 0; j < 16 && i + j < offset + length; j++) {
+                byte b = data[i + j];
+                sb.append((b >= 32 && b < 127) ? (char) b : '.');
+            }
+            sb.append("|\n");
+        }
+        return sb.toString();
+    }
+}
\ No newline at end of file

From 99772278e17170f85409cf0b1787f0d3fa81fbad Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Thu, 27 Nov 2025 19:35:45 +0100
Subject: [PATCH 07/25] remove testcontainers

---
 pom.xml                                              | 12 ------------
 .../cache/ConcurrentMapProxyAddressCacheTest.java    |  2 +-
 2 files changed, 1 insertion(+), 13 deletions(-)

diff --git a/pom.xml b/pom.xml
index dfe14d6..712868f 100755
--- a/pom.xml
+++ b/pom.xml
@@ -28,18 +28,6 @@
         proxy-socket-examples -->
     
 
-    
-        
-            
-                org.testcontainers
-                testcontainers-bom
-                2.0.1
-                pom
-                import
-            
-        
-    
-
     
         
             
diff --git a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCacheTest.java b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCacheTest.java
index 0875bae..78b5f17 100644
--- a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCacheTest.java
+++ b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCacheTest.java
@@ -2,7 +2,7 @@
  * MIT License
  * Copyright (c) 2025 Semtech
  */
-package net.airvantage.proxysocket.core.cache;
+package net.airvantage.proxysocket.tools.cache;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;

From 599820558f8f9924d66c30493df5e154199d7533 Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Thu, 27 Nov 2025 19:55:37 +0100
Subject: [PATCH 08/25] try to factor-in

---
 .../cache/ConcurrentMapProxyAddressCache.java |   2 +-
 .../udp/ProxyDatagramSocketTest.java          | 110 ++++++------------
 ...ProxyDatagramSocketUnTrustedProxyTest.java |  35 +++---
 .../airvantage/proxysocket/udp/Utility.java   |  40 +++++++
 4 files changed, 89 insertions(+), 98 deletions(-)

diff --git a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCache.java b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCache.java
index 4060190..9fbbf3a 100644
--- a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCache.java
+++ b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCache.java
@@ -2,7 +2,7 @@
  * BSD-3-Clause License.
  * Copyright (c) 2025 Semtech
  */
-package net.airvantage.proxysocket.core.cache;
+package net.airvantage.proxysocket.tools.cache;
 
 import net.airvantage.proxysocket.core.ProxyAddressCache;
 import java.net.InetSocketAddress;
diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
index ab9b5dd..62a600b 100644
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
@@ -38,6 +38,9 @@ class ProxyDatagramSocketTest {
     private InetSocketAddress backendAddress;
     private int localPort;
 
+    private byte[] buffer = new byte[2048];
+    private byte[] proxyHeader;
+
     @BeforeEach
     void setUp() throws Exception {
         mockCache = mock(ProxyAddressCache.class);
@@ -48,6 +51,13 @@ void setUp() throws Exception {
         socket = new ProxyDatagramSocket(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), mockCache, mockMetrics, null);
         localPort = socket.getLocalPort();
         backendAddress = new InetSocketAddress(InetAddress.getLoopbackAddress(), localPort);
+
+        proxyHeader = new AwsProxyEncoderHelper()
+            .family(ProxyHeader.AddressFamily.AF_INET)
+            .socket(ProxyHeader.TransportProtocol.DGRAM)
+            .source(realClient)
+            .destination(serviceAddress)
+            .build();
     }
 
     @AfterEach
@@ -61,26 +71,11 @@ void tearDown() {
     void receive_withValidProxyHeader() throws Exception {
         // Arrange
         byte[] payload = "test-data".getBytes(StandardCharsets.UTF_8);
-
-        var proxyHeader = new AwsProxyEncoderHelper()
-                .family(ProxyHeader.AddressFamily.AF_INET)
-                .socket(ProxyHeader.TransportProtocol.DGRAM)
-                .source(realClient)
-                .destination(serviceAddress)
-                .build();
-
-        byte[] packet = new byte[proxyHeader.length + payload.length];
-        System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length);
-        System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length);
-
-        // Create a loopback socket to send from
-        try (java.net.DatagramSocket sender = new java.net.DatagramSocket(serviceAddress)) {
-            sender.send(new DatagramPacket(packet, packet.length, backendAddress));
-        }
+        byte[] packet = Utility.createPacket(proxyHeader, payload);
+        Utility.sendPacket(packet, serviceAddress, backendAddress);
 
         // Act
-        byte[] receiveBuf = new byte[2048];
-        DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
+        DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
         socket.receive(receivePacket);
 
         // Assert - cache should be populated with realClient -> lbAddress mapping
@@ -126,8 +121,7 @@ void send_withCacheHit_usesLoadBalancerAddress() throws Exception {
             socket.send(sendPacket);
 
             // Verify packet was sent to LB address
-            byte[] receiveBuf = new byte[2048];
-            DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
+            DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
             receiver.receive(receivePacket);
 
             // Assert
@@ -163,8 +157,7 @@ void send_withCacheMiss_dropsPacket() throws Exception {
             socket.send(sendPacket);
 
             // Try to receive - should timeout since packet was dropped
-            byte[] receiveBuf = new byte[2048];
-            DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
+            DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
 
             assertThrows(java.net.SocketTimeoutException.class, () -> {
                 receiver.receive(receivePacket);
@@ -185,28 +178,16 @@ void send_withCacheMiss_dropsPacket() throws Exception {
     void receive_withLocalCommand_doesNotPopulateCache() throws Exception {
         // Arrange - create LOCAL command (not proxied)
         byte[] payload = "local".getBytes(StandardCharsets.UTF_8);
-        byte[] proxyHeader = new AwsProxyEncoderHelper()
+        byte[] localProxyHeader = new AwsProxyEncoderHelper()
                 .command(ProxyHeader.Command.LOCAL)
                 .build();
-
-        LOG.trace("Payload:\n{}", Utility.hexdump(payload, 0, payload.length));
-        LOG.trace("Proxy header:\n{}", Utility.hexdump(proxyHeader, 0, proxyHeader.length));
-
-        byte[] packet = new byte[proxyHeader.length + payload.length];
-        System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length);
-        System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length);
-
-        try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
-            sender.send(new DatagramPacket(packet, packet.length, backendAddress));
-        }
+        byte[] packet = Utility.createPacket(localProxyHeader, payload);
+        Utility.sendPacket(packet, backendAddress);
 
         // Act
-        byte[] receiveBuf = new byte[2048];
-        DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
+        DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
         socket.receive(receivePacket);
 
-        LOG.trace("Received packet length={}, content:\n{}", receivePacket.getLength(), Utility.hexdump(receivePacket.getData(), receivePacket.getOffset(), receivePacket.getLength()));
-
         // Assert - cache should NOT be populated for LOCAL commands
         verify(mockCache, never()).put(any(), any());
 
@@ -221,24 +202,18 @@ void receive_withLocalCommand_doesNotPopulateCache() throws Exception {
     void receive_withTcpProtocol_doesNotPopulateCache() throws Exception {
         // Arrange - create header with TCP (not DGRAM) protocol
         byte[] payload = "tcp".getBytes(StandardCharsets.UTF_8);
-        byte[] proxyHeader = new AwsProxyEncoderHelper()
-                .family(ProxyHeader.AddressFamily.AF_INET)
-                .socket(ProxyHeader.TransportProtocol.STREAM) // TCP, not UDP
-                .source(realClient)
-                .destination(serviceAddress)
-                .build();
-
-        byte[] packet = new byte[proxyHeader.length + payload.length];
-        System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length);
-        System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length);
+        byte[] tcpProxyHeader = new AwsProxyEncoderHelper()
+            .family(ProxyHeader.AddressFamily.AF_INET)
+            .socket(ProxyHeader.TransportProtocol.STREAM) // TCP, not UDP
+            .source(realClient)
+            .destination(serviceAddress)
+            .build();
 
-        try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
-            sender.send(new DatagramPacket(packet, packet.length, backendAddress));
-        }
+        byte[] packet = Utility.createPacket(tcpProxyHeader, payload);
+        Utility.sendPacket(packet, backendAddress);
 
         // Act
-        byte[] receiveBuf = new byte[2048];
-        DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
+        DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
         socket.receive(receivePacket);
 
         // Assert - cache should NOT be populated for non-DGRAM protocols
@@ -253,26 +228,11 @@ void receive_withTcpProtocol_doesNotPopulateCache() throws Exception {
     void receive_withValidProxyHeader_callsMetricsOnHeaderParsed() throws Exception {
         // Arrange
         byte[] payload = "test".getBytes(StandardCharsets.UTF_8);
-
-        byte[] proxyHeader = new AwsProxyEncoderHelper()
-                .family(ProxyHeader.AddressFamily.AF_INET)
-                .socket(ProxyHeader.TransportProtocol.DGRAM)
-                .source(realClient)
-                .destination(serviceAddress)
-                .build();
-
-        byte[] packet = new byte[proxyHeader.length + payload.length];
-        System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length);
-        System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length);
-
-        // Send packet
-        try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
-            sender.send(new DatagramPacket(packet, packet.length, backendAddress));
-        }
+        byte[] packet = Utility.createPacket(proxyHeader, payload);
+        Utility.sendPacket(packet, backendAddress);
 
         // Act
-        byte[] receiveBuf = new byte[2048];
-        DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
+        DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
         socket.receive(receivePacket);
 
         // Assert - onHeaderParsed should be called
@@ -289,14 +249,10 @@ void receive_withValidProxyHeader_callsMetricsOnHeaderParsed() throws Exception
     void receive_withInvalidData_callsMetricsOnParseError() throws Exception {
         // Arrange - send garbage data
         byte[] garbage = "not-a-proxy-header".getBytes(StandardCharsets.UTF_8);
-
-        try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
-            sender.send(new DatagramPacket(garbage, garbage.length, backendAddress));
-        }
+        Utility.sendPacket(garbage, backendAddress);
 
         // Act
-        byte[] receiveBuf = new byte[2048];
-        DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
+        DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
         socket.receive(receivePacket);
 
         // Assert - onParseError should be called
diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
index 2c64669..ed53c2e 100644
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
@@ -30,15 +30,22 @@ class ProxyDatagramSocketUnTrustedProxyTest {
     private ProxyDatagramSocket socket;
     private ProxyAddressCache mockCache;
     private ProxyProtocolMetricsListener mockMetrics;
+    private InetSocketAddress realClient;
+    private InetSocketAddress serviceAddress;
+    private InetSocketAddress backendAddress;
     private int localPort;
+    private byte[] buffer = new byte[2048];
 
     @BeforeEach
     void setUp() throws Exception {
         mockCache = mock(ProxyAddressCache.class);
         mockMetrics = mock(ProxyProtocolMetricsListener.class);
 
+        realClient = new InetSocketAddress(InetAddress.getLoopbackAddress(), 12345);
+        serviceAddress = new InetSocketAddress(InetAddress.getLoopbackAddress(), 54321);
         socket = new ProxyDatagramSocket(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), mockCache, mockMetrics, addr -> false);
         localPort = socket.getLocalPort();
+        backendAddress = new InetSocketAddress(InetAddress.getLoopbackAddress(), localPort);
     }
 
     @AfterEach
@@ -50,30 +57,25 @@ void tearDown() {
 
     @Test
     void receive_withUntrustedProxy_doesNotStripHeader() throws Exception {
-        InetSocketAddress clientAddress = new InetSocketAddress("127.0.0.1", 12345);
         InetAddress lbAddress;
 
         byte[] payload = "test".getBytes(StandardCharsets.UTF_8);
         byte[] proxyHeader = new AwsProxyEncoderHelper()
                 .family(ProxyHeader.AddressFamily.AF_INET)
                 .socket(ProxyHeader.TransportProtocol.DGRAM)
-                .source(clientAddress)
-                .destination(new InetSocketAddress("127.0.0.1", localPort))
+                .source(realClient)
+                .destination(serviceAddress)
                 .build();
 
-        byte[] packet = new byte[proxyHeader.length + payload.length];
-        System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length);
-        System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length);
+        byte[] packet = Utility.createPacket(proxyHeader, payload);
 
-        try (DatagramSocket sender = new DatagramSocket(clientAddress)) {
-            sender.send(new DatagramPacket(packet, packet.length,
-                    new InetSocketAddress("127.0.0.1", localPort)));
+        try (DatagramSocket sender = new DatagramSocket(realClient)) {
+            sender.send(new DatagramPacket(packet, packet.length, backendAddress));
             lbAddress = sender.getLocalAddress();
         }
 
         // Act
-        byte[] receiveBuf = new byte[2048];
-        DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
+        DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
         socket.receive(receivePacket);
 
         // Assert - no metrics should be called for untrusted sources
@@ -88,18 +90,11 @@ void receive_withUntrustedProxy_doesNotStripHeader() throws Exception {
 
     @Test
     void receive_withUntrustedProxy_doesNotParse() throws Exception {
-        InetSocketAddress clientAddress = new InetSocketAddress("127.0.0.1", 12345);
-
         byte[] packet = "Not a proxy header".getBytes(StandardCharsets.UTF_8);
-
-        try (java.net.DatagramSocket sender = new java.net.DatagramSocket()) {
-            sender.send(new DatagramPacket(packet, packet.length,
-                    new InetSocketAddress("127.0.0.1", localPort)));
-        }
+        Utility.sendPacket(packet, backendAddress);
 
         // Act
-        byte[] receiveBuf = new byte[2048];
-        DatagramPacket receivePacket = new DatagramPacket(receiveBuf, receiveBuf.length);
+        DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
         assertDoesNotThrow(() -> socket.receive(receivePacket), "No ProxyProtocolException should be thrown");
 
         // Packet length should be the same as the original packet length
diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/Utility.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/Utility.java
index 369196d..86d4ce8 100644
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/Utility.java
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/Utility.java
@@ -4,9 +4,49 @@
  */
 package net.airvantage.proxysocket.udp;
 
+import java.io.IOException;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetSocketAddress;
+
 public final class Utility {
     private Utility() {}
 
+    /**
+     * Creates a packet by combining proxy header and payload.
+     */
+    public static byte[] createPacket(byte[] proxyHeader, byte[] payload) {
+        byte[] packet = new byte[proxyHeader.length + payload.length];
+        System.arraycopy(proxyHeader, 0, packet, 0, proxyHeader.length);
+        System.arraycopy(payload, 0, packet, proxyHeader.length, payload.length);
+        return packet;
+    }
+
+    /**
+     * Sends a packet to the specified destination using an ephemeral DatagramSocket.
+     */
+    public static void sendPacket(byte[] packet, InetSocketAddress destination) throws IOException {
+        try (DatagramSocket sender = new DatagramSocket()) {
+            sender.send(new DatagramPacket(packet, packet.length, destination));
+        }
+    }
+
+    /**
+     * Sends a packet to the specified destination using a DatagramSocket bound to the specified source address.
+     */
+    public static void sendPacket(byte[] packet, InetSocketAddress source, InetSocketAddress destination) throws IOException {
+        try (DatagramSocket sender = new DatagramSocket(source)) {
+            sender.send(new DatagramPacket(packet, packet.length, destination));
+        }
+    }
+
+    /**
+     * Returns a hexdump of the specified data for debugging purposes.
+     * @param data The data to dump.
+     * @param offset The offset into the data to start dumping.
+     * @param length The length of the data to dump.
+     * @return A string containing the hexdump.
+     */
     public static String hexdump(byte[] data, int offset, int length) {
         StringBuilder sb = new StringBuilder();
         for (int i = offset; i < offset + length; i += 16) {

From ddd55984b9da17f6c3b52471d9db2bb7b240323f Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Fri, 28 Nov 2025 11:11:16 +0100
Subject: [PATCH 09/25] naming

---
 pom.xml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/pom.xml b/pom.xml
index 712868f..57a835c 100755
--- a/pom.xml
+++ b/pom.xml
@@ -6,11 +6,11 @@
     4.0.0
 
     net.airvantage
-    proxysocket-java
+    proxy-socket-java
     1.0.0-SNAPSHOT
     pom
 
-    ProxyProtocol Java implementation.
+    ProxySocket Java implementation.
 
     
 

From c1093d4f134d77f5051e2bfe8f77767e3a1b31fb Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Fri, 28 Nov 2025 11:11:52 +0100
Subject: [PATCH 10/25] naming

---
 proxy-socket-core/pom.xml | 2 +-
 proxy-socket-udp/pom.xml  | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/proxy-socket-core/pom.xml b/proxy-socket-core/pom.xml
index 07b3007..b101eed 100644
--- a/proxy-socket-core/pom.xml
+++ b/proxy-socket-core/pom.xml
@@ -5,7 +5,7 @@
     4.0.0
     
         net.airvantage
-        proxysocket-java
+        proxy-socket-java
         1.0.0-SNAPSHOT
     
     proxy-socket-core
diff --git a/proxy-socket-udp/pom.xml b/proxy-socket-udp/pom.xml
index 2439ec1..372385e 100644
--- a/proxy-socket-udp/pom.xml
+++ b/proxy-socket-udp/pom.xml
@@ -5,7 +5,7 @@
     4.0.0
     
         net.airvantage
-        proxysocket-java
+        proxy-socket-java
         1.0.0-SNAPSHOT
     
     proxy-socket-udp

From 022e27241467cce0bef1983438c29c3ce1149e33 Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Fri, 28 Nov 2025 18:10:29 +0100
Subject: [PATCH 11/25] Switch to warn for packets dropped due to cache
 expiration

---
 .gitignore                                                     | 3 +++
 .../net/airvantage/proxysocket/udp/ProxyDatagramSocket.java    | 2 +-
 2 files changed, 4 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index 524f096..37b730b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,6 @@
 # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
 hs_err_pid*
 replay_pid*
+
+# maven build directories
+target/*
diff --git a/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java b/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java
index e4009b0..f5e15e8 100644
--- a/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java
+++ b/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java
@@ -119,7 +119,7 @@ public void send(DatagramPacket packet) throws IOException {
             if (metrics != null) metrics.onCacheHit(client);
         } else if (addressCache != null) {
             // Cache miss: unable to map client to load balancer address,
-            LOG.debug("Cache miss for client {}; unable to map to load balancer address, dropping packet.", client);
+            LOG.warn("Cache miss for client {}; unable to map to load balancer address, dropping packet.", client);
             if (metrics != null) metrics.onCacheMiss(client);
             return;
         // } else {

From d9f0dc581260c07fa521aa598690225326eec144 Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Fri, 28 Nov 2025 18:12:36 +0100
Subject: [PATCH 12/25] fix name

---
 pom.xml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pom.xml b/pom.xml
index 15d329e..f699fb8 100755
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
     1.0.0-SNAPSHOT
     pom
 
-    ProxySocket Java implementation.
+    ProxyProtocol Java implementation.
 
     
 

From 59c285f5eb3210d53f81dbe13478c02576f0bc5e Mon Sep 17 00:00:00 2001
From: bplessis14821 
Date: Mon, 1 Dec 2025 11:25:25 +0100
Subject: [PATCH 13/25] Update
 proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java

Co-authored-by: cthirouin <113358856+cthirouin-swi@users.noreply.github.com>
---
 .../proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java   | 1 -
 1 file changed, 1 deletion(-)

diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
index ed53c2e..9f3958f 100644
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
@@ -11,7 +11,6 @@
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentCaptor;
 
 import java.net.DatagramPacket;
 import java.net.DatagramSocket;

From 7ec9ee9149a185d5b02b5e90e26e077258ef9354 Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Mon, 1 Dec 2025 11:45:46 +0100
Subject: [PATCH 14/25] review comments

---
 .../net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java  | 1 -
 1 file changed, 1 deletion(-)

diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
index 62a600b..3d88728 100644
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
@@ -27,7 +27,6 @@
  * Unit tests for ProxyDatagramSocket IP address mapping and cache behavior.
  */
 class ProxyDatagramSocketTest {
-    private static final Logger LOG = LoggerFactory.getLogger(ProxyDatagramSocket.class);
 
     private ProxyDatagramSocket socket;
     private ProxyAddressCache mockCache;

From 26bd11a63510973b3c302f501e11c8d553793c58 Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Mon, 1 Dec 2025 16:18:47 +0100
Subject: [PATCH 15/25] enforce address family and protocol in the build phase

---
 .../core/v2/AwsProxyEncoderHelper.java        | 26 +++++++++----------
 1 file changed, 12 insertions(+), 14 deletions(-)

diff --git a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/AwsProxyEncoderHelper.java b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/AwsProxyEncoderHelper.java
index 1bfa641..ae6e227 100644
--- a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/AwsProxyEncoderHelper.java
+++ b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/AwsProxyEncoderHelper.java
@@ -28,18 +28,10 @@ public final class AwsProxyEncoderHelper {
     private final Header header = new Header();
 
     public AwsProxyEncoderHelper command(ProxyHeader.Command cmd) {
-        if (cmd == ProxyHeader.Command.LOCAL) {
-            this.command = ProxyProtocolSpec.Command.LOCAL;
-
-            // Spec clearly state that for LOCAL command, we
-            // 1. must discard the protocol block including the family and
-            // 2. \x00 is expected to be used for the protocol field.
-            this.family = ProxyProtocolSpec.AddressFamily.AF_UNSPEC;
-            this.protocol = ProxyProtocolSpec.TransportProtocol.UNSPEC;
-        } else {
-            this.command = ProxyProtocolSpec.Command.PROXY;
-        }
-
+        this.command = switch (cmd) {
+            case LOCAL -> ProxyProtocolSpec.Command.LOCAL;
+            case PROXY -> ProxyProtocolSpec.Command.PROXY;
+        };
         return this;
     }
 
@@ -82,10 +74,10 @@ public AwsProxyEncoderHelper addTlv(int type, byte[] value) {
 
     public byte[] build() throws IOException {
         header.setCommand(command);
-        header.setAddressFamily(family);
-        header.setTransportProtocol(protocol);
 
         if (command != ProxyProtocolSpec.Command.LOCAL) {
+            header.setAddressFamily(family);
+            header.setTransportProtocol(protocol);
             if (source != null) {
                 header.setSrcAddress(source.getAddress().getAddress());
                 header.setSrcPort(source.getPort());
@@ -95,6 +87,12 @@ public byte[] build() throws IOException {
                 header.setDstAddress(destination.getAddress().getAddress());
                 header.setDstPort(destination.getPort());
             }
+        } else {
+            // Spec clearly state that for LOCAL command, we
+            // 1. must discard the protocol block including the family and
+            // 2. \x00 is expected to be used for the protocol field.
+            header.setAddressFamily(ProxyProtocolSpec.AddressFamily.AF_UNSPEC);
+            header.setTransportProtocol(ProxyProtocolSpec.TransportProtocol.UNSPEC);
         }
 
         ByteArrayOutputStream out = new ByteArrayOutputStream();

From 15bcf0303a23d08845c9fb3a488af442b17db7df Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Mon, 1 Dec 2025 16:40:25 +0100
Subject: [PATCH 16/25] review comments

---
 .../proxysocket/tools/SubnetPredicate.java          | 13 ++++++-------
 .../proxysocket/tools/SubnetPredicateTest.java      |  4 ++--
 2 files changed, 8 insertions(+), 9 deletions(-)

diff --git a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
index 9c8beea..09f841b 100644
--- a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
+++ b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
@@ -50,12 +50,12 @@ public SubnetPredicate(String cidr) {
         }
 
         String addressPart = cidr.substring(0, slashIndex);
-        String prefixPart = cidr.substring(slashIndex + 1);
+        String prefixLengthPart = cidr.substring(slashIndex + 1);
 
         try {
-            this.prefixLength = Integer.parseInt(prefixPart);
+            this.prefixLength = Integer.parseInt(prefixLengthPart);
         } catch (NumberFormatException e) {
-            throw new IllegalArgumentException("Invalid prefix length: " + prefixPart, e);
+            throw new IllegalArgumentException("Invalid prefix length: " + prefixLengthPart, e);
         }
 
         try {
@@ -118,14 +118,13 @@ public boolean test(InetSocketAddress socketAddress) {
      * Applies a subnet mask to an IP address.
      *
      * @param address the raw IP address bytes
-     * @param prefixLen the prefix length (number of network bits)
      * @return the masked address bytes
      */
-    private static byte[] applyMask(byte[] address, int prefixLen) {
+    private byte[] applyMask(byte[] address) {
         byte[] result = new byte[address.length];
 
-        int fullBytes = prefixLen / 8;
-        int remainingBits = prefixLen % 8;
+        int fullBytes = prefixLength / 8;
+        int remainingBits = prefixLength % 8;
 
         // Copy the full bytes
         System.arraycopy(address, 0, result, 0, fullBytes);
diff --git a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java
index fcdd14e..253ca51 100644
--- a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java
+++ b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java
@@ -1,5 +1,5 @@
-/*
- * MIT License
+/**
+ * BSD-3-Clause License.
  * Copyright (c) 2025 Semtech
  */
 package net.airvantage.proxysocket.tools;

From 68f3d59e2816d69336f1e6273577dc4df9726942 Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Mon, 1 Dec 2025 16:42:46 +0100
Subject: [PATCH 17/25] fix licences

---
 .../proxysocket/core/v2/AwsProxyEncoderHelper.java          | 4 ++--
 .../proxysocket/core/v2/ProxyProtocolV2DecoderTest.java     | 6 +++---
 .../airvantage/proxysocket/core/v2/ProxyProtocolV2Test.java | 6 +++---
 .../tools/cache/ConcurrentMapProxyAddressCacheTest.java     | 4 ++--
 .../proxysocket/guava/GuavaProxyAddressCacheTest.java       | 4 ++--
 .../airvantage/proxysocket/udp/ProxyDatagramSocketTest.java | 4 ++--
 .../udp/ProxyDatagramSocketUnTrustedProxyTest.java          | 4 ++--
 7 files changed, 16 insertions(+), 16 deletions(-)

diff --git a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/AwsProxyEncoderHelper.java b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/AwsProxyEncoderHelper.java
index ae6e227..5eedf99 100644
--- a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/AwsProxyEncoderHelper.java
+++ b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/AwsProxyEncoderHelper.java
@@ -1,5 +1,5 @@
-/*
- * MIT License
+/**
+ * BSD-3-Clause License.
  * Copyright (c) 2025 Semtech
  *
  * Helper class to encode PROXY protocol v2 headers using AWS ProProt library.
diff --git a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/ProxyProtocolV2DecoderTest.java b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/ProxyProtocolV2DecoderTest.java
index 03ed2b0..f5c33cc 100644
--- a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/ProxyProtocolV2DecoderTest.java
+++ b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/ProxyProtocolV2DecoderTest.java
@@ -1,7 +1,7 @@
-/*
- * MIT License
+/**
+ * BSD-3-Clause License.
  * Copyright (c) 2025 Semtech
-
+ *
  * Validation of ProxyProtocolV2Decoder against hardcoded headers for known cases
  */
 package net.airvantage.proxysocket.core.v2;
diff --git a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/ProxyProtocolV2Test.java b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/ProxyProtocolV2Test.java
index ce25b5d..9446234 100644
--- a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/ProxyProtocolV2Test.java
+++ b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/core/v2/ProxyProtocolV2Test.java
@@ -1,7 +1,7 @@
-/*
- * MIT License
+/**
+ * BSD-3-Clause License.
  * Copyright (c) 2025 Semtech
-
+ *
  * Validation of ProxyProtocolV2Decoder using AWS ProProt library
  */
 package net.airvantage.proxysocket.core.v2;
diff --git a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCacheTest.java b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCacheTest.java
index 78b5f17..158cd08 100644
--- a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCacheTest.java
+++ b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/cache/ConcurrentMapProxyAddressCacheTest.java
@@ -1,5 +1,5 @@
-/*
- * MIT License
+/**
+ * BSD-3-Clause License.
  * Copyright (c) 2025 Semtech
  */
 package net.airvantage.proxysocket.tools.cache;
diff --git a/proxy-socket-guava/src/test/java/net/airvantage/proxysocket/guava/GuavaProxyAddressCacheTest.java b/proxy-socket-guava/src/test/java/net/airvantage/proxysocket/guava/GuavaProxyAddressCacheTest.java
index 245beda..159b259 100644
--- a/proxy-socket-guava/src/test/java/net/airvantage/proxysocket/guava/GuavaProxyAddressCacheTest.java
+++ b/proxy-socket-guava/src/test/java/net/airvantage/proxysocket/guava/GuavaProxyAddressCacheTest.java
@@ -1,5 +1,5 @@
-/*
- * MIT License
+/**
+ * BSD-3-Clause License.
  * Copyright (c) 2025 Semtech
  */
 package net.airvantage.proxysocket.guava;
diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
index 3d88728..a05c502 100644
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
@@ -1,5 +1,5 @@
-/*
- * MIT License
+/**
+ * BSD-3-Clause License.
  * Copyright (c) 2025 Semtech
  */
 package net.airvantage.proxysocket.udp;
diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
index 9f3958f..e27122a 100644
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketUnTrustedProxyTest.java
@@ -1,5 +1,5 @@
-/*
- * MIT License
+/**
+ * BSD-3-Clause License.
  * Copyright (c) 2025 Semtech
  */
 package net.airvantage.proxysocket.udp;

From 8c187daf8f4f66a470a24f7668b4cda6390305e5 Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Mon, 1 Dec 2025 16:43:25 +0100
Subject: [PATCH 18/25] fix licences

---
 .../src/test/java/net/airvantage/proxysocket/udp/Utility.java | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/Utility.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/Utility.java
index 86d4ce8..fec669b 100644
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/Utility.java
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/Utility.java
@@ -1,5 +1,5 @@
-/*
- * MIT License
+/**
+ * BSD-3-Clause License.
  * Copyright (c) 2025 Semtech
  */
 package net.airvantage.proxysocket.udp;

From 34cfc9a9c06325c704f9d4529a0b44c42f4d94a7 Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Mon, 1 Dec 2025 16:54:30 +0100
Subject: [PATCH 19/25] process review

---
 .../airvantage/proxysocket/tools/SubnetPredicate.java  | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
index 09f841b..12ada52 100644
--- a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
+++ b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
@@ -72,7 +72,7 @@ public SubnetPredicate(String cidr) {
             }
 
             // Apply the mask to the network address to normalize it
-            this.networkAddress = applyMask(rawAddress, prefixLength);
+            this.networkAddress = applyMask(rawAddress);
         } catch (UnknownHostException e) {
             throw new IllegalArgumentException("Invalid IP address: " + addressPart, e);
         }
@@ -87,12 +87,12 @@ public SubnetPredicate(String cidr) {
     @Override
     public boolean test(InetSocketAddress socketAddress) {
         if (socketAddress == null) {
-            return false;
+            throw new IllegalArgumentException("Socket address cannot be null");
         }
 
         InetAddress address = socketAddress.getAddress();
         if (address == null) {
-            return false;
+            throw new IllegalArgumentException("Address cannot be null");
         }
 
         byte[] testAddress = address.getAddress();
@@ -102,7 +102,7 @@ public boolean test(InetSocketAddress socketAddress) {
             return false;
         }
 
-        byte[] maskedTestAddress = applyMask(testAddress, prefixLength);
+        byte[] maskedTestAddress = applyMask(testAddress);
 
         // Compare network portions
         for (int i = 0; i < networkAddress.length; i++) {
@@ -130,7 +130,7 @@ private byte[] applyMask(byte[] address) {
         System.arraycopy(address, 0, result, 0, fullBytes);
 
         // Apply mask to the partial byte if any
-        if (remainingBits > 0 && fullBytes < address.length) {
+        if (remainingBits > 0) {
             int mask = 0xFF << (8 - remainingBits);
             result[fullBytes] = (byte) (address[fullBytes] & mask);
         }

From c2bc5eab2d3faa69c6348874cc1c3f1be52eb583 Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Mon, 1 Dec 2025 16:57:54 +0100
Subject: [PATCH 20/25] revert throw

---
 .../net/airvantage/proxysocket/tools/SubnetPredicate.java     | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
index 12ada52..1b62ddd 100644
--- a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
+++ b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
@@ -87,12 +87,12 @@ public SubnetPredicate(String cidr) {
     @Override
     public boolean test(InetSocketAddress socketAddress) {
         if (socketAddress == null) {
-            throw new IllegalArgumentException("Socket address cannot be null");
+            return false;
         }
 
         InetAddress address = socketAddress.getAddress();
         if (address == null) {
-            throw new IllegalArgumentException("Address cannot be null");
+            return false;
         }
 
         byte[] testAddress = address.getAddress();

From 09b7c431c324a9f7992a8dcecc9f295484b191ff Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Mon, 1 Dec 2025 17:19:42 +0100
Subject: [PATCH 21/25] reduce try block

---
 .../proxysocket/tools/SubnetPredicate.java    | 28 ++++++++++---------
 1 file changed, 15 insertions(+), 13 deletions(-)

diff --git a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
index 1b62ddd..667b2d3 100644
--- a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
+++ b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
@@ -58,24 +58,26 @@ public SubnetPredicate(String cidr) {
             throw new IllegalArgumentException("Invalid prefix length: " + prefixLengthPart, e);
         }
 
+        byte[] rawAddress;
         try {
             InetAddress addr = InetAddress.getByName(addressPart);
-            byte[] rawAddress = addr.getAddress();
-            this.addressLength = rawAddress.length;
-
-            // Validate prefix length
-            int maxPrefixLength = addressLength * 8;
-            if (prefixLength < 0 || prefixLength > maxPrefixLength) {
-                throw new IllegalArgumentException(
-                    "Invalid prefix length " + prefixLength + " for address type (must be 0-" + maxPrefixLength + ")"
-                );
-            }
-
-            // Apply the mask to the network address to normalize it
-            this.networkAddress = applyMask(rawAddress);
+            rawAddress = addr.getAddress();
         } catch (UnknownHostException e) {
             throw new IllegalArgumentException("Invalid IP address: " + addressPart, e);
         }
+
+        this.addressLength = rawAddress.length;
+
+        // Validate prefix length
+        int maxPrefixLength = addressLength * 8; // converts address length to bits
+        if (prefixLength < 0 || prefixLength > maxPrefixLength) {
+            throw new IllegalArgumentException(
+                "Invalid prefix length " + prefixLength + " for address type (must be 0-" + maxPrefixLength + ")"
+            );
+        }
+
+        // Apply the mask to the network address to normalize it
+        this.networkAddress = applyMask(rawAddress);
     }
 
     /**

From 0eb3d7bb5048e077cf38064de3887e933f71c179 Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Mon, 1 Dec 2025 17:35:37 +0100
Subject: [PATCH 22/25] convert to parametized tests

---
 .../tools/SubnetPredicateTest.java            | 285 +++++++-----------
 1 file changed, 112 insertions(+), 173 deletions(-)

diff --git a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java
index 253ca51..cccff54 100644
--- a/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java
+++ b/proxy-socket-core/src/test/java/net/airvantage/proxysocket/tools/SubnetPredicateTest.java
@@ -5,155 +5,115 @@
 package net.airvantage.proxysocket.tools;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.UnknownHostException;
 import java.util.function.Predicate;
+import java.util.stream.Stream;
 
 import static org.junit.jupiter.api.Assertions.*;
 
 class SubnetPredicateTest {
 
-    // ========== IPv4 Tests ==========
-
-    @Test
-    void testIPv4_SingleHost_32BitMask() throws UnknownHostException {
-        SubnetPredicate predicate = new SubnetPredicate("192.168.1.100/32");
-
-        assertTrue(predicate.test(addr("192.168.1.100", 8080)));
-        assertFalse(predicate.test(addr("192.168.1.101", 8080)));
-        assertFalse(predicate.test(addr("192.168.1.99", 8080)));
-    }
-
-    @Test
-    void testIPv4_ClassC_24BitMask() throws UnknownHostException {
-        SubnetPredicate predicate = new SubnetPredicate("192.168.1.0/24");
-
-        assertTrue(predicate.test(addr("192.168.1.0", 8080)));
-        assertTrue(predicate.test(addr("192.168.1.1", 8080)));
-        assertTrue(predicate.test(addr("192.168.1.100", 8080)));
-        assertTrue(predicate.test(addr("192.168.1.255", 8080)));
-
-        assertFalse(predicate.test(addr("192.168.0.255", 8080)));
-        assertFalse(predicate.test(addr("192.168.2.0", 8080)));
-        assertFalse(predicate.test(addr("192.167.1.1", 8080)));
-    }
-
-    @Test
-    void testIPv4_ClassB_16BitMask() throws UnknownHostException {
-        SubnetPredicate predicate = new SubnetPredicate("172.16.0.0/16");
-
-        assertTrue(predicate.test(addr("172.16.0.0", 8080)));
-        assertTrue(predicate.test(addr("172.16.1.1", 8080)));
-        assertTrue(predicate.test(addr("172.16.255.255", 8080)));
-
-        assertFalse(predicate.test(addr("172.15.255.255", 8080)));
-        assertFalse(predicate.test(addr("172.17.0.0", 8080)));
-    }
-
-    @Test
-    void testIPv4_ClassA_8BitMask() throws UnknownHostException {
-        SubnetPredicate predicate = new SubnetPredicate("10.0.0.0/8");
-
-        assertTrue(predicate.test(addr("10.0.0.0", 8080)));
-        assertTrue(predicate.test(addr("10.0.0.1", 8080)));
-        assertTrue(predicate.test(addr("10.255.255.255", 8080)));
-        assertTrue(predicate.test(addr("10.123.45.67", 8080)));
-
-        assertFalse(predicate.test(addr("9.255.255.255", 8080)));
-        assertFalse(predicate.test(addr("11.0.0.0", 8080)));
-    }
-
-    @Test
-    void testIPv4_NonStandardMask_25Bits() throws UnknownHostException {
-        SubnetPredicate predicate = new SubnetPredicate("192.168.1.0/25");
-
-        // First half: 192.168.1.0 - 192.168.1.127
-        assertTrue(predicate.test(addr("192.168.1.0", 8080)));
-        assertTrue(predicate.test(addr("192.168.1.127", 8080)));
-
-        // Second half: 192.168.1.128 - 192.168.1.255
-        assertFalse(predicate.test(addr("192.168.1.128", 8080)));
-        assertFalse(predicate.test(addr("192.168.1.255", 8080)));
-    }
-
-    @Test
-    void testIPv4_NonStandardMask_23Bits() throws UnknownHostException {
-        SubnetPredicate predicate = new SubnetPredicate("192.168.0.0/23");
-
-        assertTrue(predicate.test(addr("192.168.0.0", 8080)));
-        assertTrue(predicate.test(addr("192.168.0.255", 8080)));
-        assertTrue(predicate.test(addr("192.168.1.0", 8080)));
-        assertTrue(predicate.test(addr("192.168.1.255", 8080)));
-
-        assertFalse(predicate.test(addr("192.168.2.0", 8080)));
-        assertFalse(predicate.test(addr("192.167.255.255", 8080)));
-    }
-
-    @Test
-    void testIPv4_ZeroMask_MatchesAll() throws UnknownHostException {
-        SubnetPredicate predicate = new SubnetPredicate("0.0.0.0/0");
-
-        assertTrue(predicate.test(addr("0.0.0.0", 8080)));
-        assertTrue(predicate.test(addr("1.2.3.4", 8080)));
-        assertTrue(predicate.test(addr("192.168.1.1", 8080)));
-        assertTrue(predicate.test(addr("255.255.255.255", 8080)));
-
-        // But not IPv6
-        assertFalse(predicate.test(addr("::1", 8080)));
-    }
-
-    // ========== IPv6 Tests ==========
-
-    @Test
-    void testIPv6_SingleHost_128BitMask() throws UnknownHostException {
-        SubnetPredicate predicate = new SubnetPredicate("2001:db8::1/128");
-
-        assertTrue(predicate.test(addr("2001:db8::1", 8080)));
-        assertFalse(predicate.test(addr("2001:db8::2", 8080)));
-    }
-
-    @Test
-    void testIPv6_CommonSubnet_64BitMask() throws UnknownHostException {
-        SubnetPredicate predicate = new SubnetPredicate("2001:db8::/64");
-
-        assertTrue(predicate.test(addr("2001:db8::1", 8080)));
-        assertTrue(predicate.test(addr("2001:db8::ffff:ffff:ffff:ffff", 8080)));
-        assertTrue(predicate.test(addr("2001:db8:0:0:1234:5678:9abc:def0", 8080)));
-
-        assertFalse(predicate.test(addr("2001:db8:0:1::1", 8080)));
-    }
-
-    @Test
-    void testIPv6_WideSubnet_32BitMask() throws UnknownHostException {
-        SubnetPredicate predicate = new SubnetPredicate("2001:db8::/32");
-
-        assertTrue(predicate.test(addr("2001:db8::", 8080)));
-        assertTrue(predicate.test(addr("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", 8080)));
-
-        assertFalse(predicate.test(addr("2001:db9::", 8080)));
-        assertFalse(predicate.test(addr("2001:db7:ffff:ffff:ffff:ffff:ffff:ffff", 8080)));
-    }
-
-    @Test
-    void testIPv6_Loopback() throws UnknownHostException {
-        SubnetPredicate predicate = new SubnetPredicate("::1/128");
-
-        assertTrue(predicate.test(addr("::1", 8080)));
-        assertFalse(predicate.test(addr("::2", 8080)));
-    }
-
-    @Test
-    void testIPv6_ZeroMask_MatchesAll() throws UnknownHostException {
-        SubnetPredicate predicate = new SubnetPredicate("::/0");
-
-        assertTrue(predicate.test(addr("::", 8080)));
-        assertTrue(predicate.test(addr("::1", 8080)));
-        assertTrue(predicate.test(addr("2001:db8::1", 8080)));
-        assertTrue(predicate.test(addr("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", 8080)));
-
-        // But not IPv4
-        assertFalse(predicate.test(addr("192.168.1.1", 8080)));
+    // ========== Parameterized Tests for Matching Addresses ==========
+
+    @ParameterizedTest(name = "{0} should match {1}")
+    @CsvSource({
+        // IPv4 - Single Host /32
+        "192.168.1.100/32, 192.168.1.100",
+        // IPv4 - Class C /24
+        "192.168.1.0/24, 192.168.1.0",
+        "192.168.1.0/24, 192.168.1.1",
+        "192.168.1.0/24, 192.168.1.100",
+        "192.168.1.0/24, 192.168.1.255",
+        // IPv4 - Class B /16
+        "172.16.0.0/16, 172.16.0.0",
+        "172.16.0.0/16, 172.16.1.1",
+        "172.16.0.0/16, 172.16.255.255",
+        // IPv4 - Class A /8
+        "10.0.0.0/8, 10.0.0.0",
+        "10.0.0.0/8, 10.0.0.1",
+        "10.0.0.0/8, 10.255.255.255",
+        "10.0.0.0/8, 10.123.45.67",
+        // IPv4 - /25
+        "192.168.1.0/25, 192.168.1.0",
+        "192.168.1.0/25, 192.168.1.127",
+        // IPv4 - /23
+        "192.168.0.0/23, 192.168.0.0",
+        "192.168.0.0/23, 192.168.0.255",
+        "192.168.0.0/23, 192.168.1.0",
+        "192.168.0.0/23, 192.168.1.255",
+        // IPv4 - /0 (matches all IPv4)
+        "0.0.0.0/0, 0.0.0.0",
+        "0.0.0.0/0, 1.2.3.4",
+        "0.0.0.0/0, 192.168.1.1",
+        "0.0.0.0/0, 255.255.255.255",
+        // IPv6 - Single Host /128
+        "2001:db8::1/128, 2001:db8::1",
+        // IPv6 - /64
+        "2001:db8::/64, 2001:db8::1",
+        "2001:db8::/64, 2001:db8::ffff:ffff:ffff:ffff",
+        "2001:db8::/64, 2001:db8:0:0:1234:5678:9abc:def0",
+        // IPv6 - /32
+        "2001:db8::/32, 2001:db8::",
+        "2001:db8::/32, 2001:db8:ffff:ffff:ffff:ffff:ffff:ffff",
+        // IPv6 - Loopback
+        "::1/128, ::1",
+        // IPv6 - /0 (matches all IPv6)
+        "::/0, ::",
+        "::/0, ::1",
+        "::/0, 2001:db8::1",
+        "::/0, ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
+    })
+    void testAddressShouldMatch(String cidr, String address) throws UnknownHostException {
+        SubnetPredicate predicate = new SubnetPredicate(cidr);
+        assertTrue(predicate.test(addr(address, 8080)),
+            address + " should match " + cidr);
+    }
+
+    @ParameterizedTest(name = "{0} should NOT match {1}")
+    @CsvSource({
+        // IPv4 - Single Host /32
+        "192.168.1.100/32, 192.168.1.101",
+        "192.168.1.100/32, 192.168.1.99",
+        // IPv4 - Class C /24
+        "192.168.1.0/24, 192.168.0.255",
+        "192.168.1.0/24, 192.168.2.0",
+        "192.168.1.0/24, 192.167.1.1",
+        // IPv4 - Class B /16
+        "172.16.0.0/16, 172.15.255.255",
+        "172.16.0.0/16, 172.17.0.0",
+        // IPv4 - Class A /8
+        "10.0.0.0/8, 9.255.255.255",
+        "10.0.0.0/8, 11.0.0.0",
+        // IPv4 - /25
+        "192.168.1.0/25, 192.168.1.128",
+        "192.168.1.0/25, 192.168.1.255",
+        // IPv4 - /23
+        "192.168.0.0/23, 192.168.2.0",
+        "192.168.0.0/23, 192.167.255.255",
+        // IPv4 - /0 (doesn't match IPv6)
+        "0.0.0.0/0, ::1",
+        // IPv6 - Single Host /128
+        "2001:db8::1/128, 2001:db8::2",
+        // IPv6 - /64
+        "2001:db8::/64, 2001:db8:0:1::1",
+        // IPv6 - /32
+        "2001:db8::/32, 2001:db9::",
+        "2001:db8::/32, 2001:db7:ffff:ffff:ffff:ffff:ffff:ffff",
+        // IPv6 - Loopback
+        "::1/128, ::2",
+        // IPv6 - /0 (doesn't match IPv4)
+        "::/0, 192.168.1.1"
+    })
+    void testAddressShouldNotMatch(String cidr, String address) throws UnknownHostException {
+        SubnetPredicate predicate = new SubnetPredicate(cidr);
+        assertFalse(predicate.test(addr(address, 8080)),
+            address + " should NOT match " + cidr);
     }
 
     // ========== Edge Cases ==========
@@ -238,39 +198,18 @@ void testInvalidCIDR_NullInput() {
         assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate(null));
     }
 
-    @Test
-    void testInvalidCIDR_EmptyString() {
-        assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate(""));
-    }
-
-    @Test
-    void testInvalidCIDR_MissingSlash() {
-        assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate("192.168.1.0"));
-    }
-
-    @Test
-    void testInvalidCIDR_InvalidPrefix() {
-        assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate("192.168.1.0/abc"));
-    }
-
-    @Test
-    void testInvalidCIDR_PrefixTooLarge_IPv4() {
-        assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate("192.168.1.0/33"));
-    }
-
-    @Test
-    void testInvalidCIDR_PrefixTooLarge_IPv6() {
-        assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate("2001:db8::/129"));
-    }
-
-    @Test
-    void testInvalidCIDR_NegativePrefix() {
-        assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate("192.168.1.0/-1"));
-    }
-
-    @Test
-    void testInvalidCIDR_InvalidIPAddress() {
-        assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate("256.256.256.256/24"));
+    @ParameterizedTest(name = "Invalid CIDR: ''{0}''")
+    @ValueSource(strings = {
+        "",
+        "192.168.1.0",              // Missing slash
+        "192.168.1.0/abc",          // Invalid prefix
+        "192.168.1.0/33",           // Prefix too large for IPv4
+        "2001:db8::/129",           // Prefix too large for IPv6
+        "192.168.1.0/-1",           // Negative prefix
+        "256.256.256.256/24"        // Invalid IP address
+    })
+    void testInvalidCIDR(String cidr) {
+        assertThrows(IllegalArgumentException.class, () -> new SubnetPredicate(cidr));
     }
 
     @Test

From 1923df8c6a014e7671fa2c7cf8dee17063f2aa1d Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Mon, 1 Dec 2025 17:39:17 +0100
Subject: [PATCH 23/25] currently unused

---
 .../net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java | 2 --
 1 file changed, 2 deletions(-)

diff --git a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
index a05c502..655f0c2 100644
--- a/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
+++ b/proxy-socket-udp/src/test/java/net/airvantage/proxysocket/udp/ProxyDatagramSocketTest.java
@@ -21,8 +21,6 @@
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.Mockito.*;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 /**
  * Unit tests for ProxyDatagramSocket IP address mapping and cache behavior.
  */

From 76664a76f33739e7deb1b393b65f315e7d388494 Mon Sep 17 00:00:00 2001
From: Benoit Plessis 
Date: Mon, 1 Dec 2025 17:47:57 +0100
Subject: [PATCH 24/25] add a little documentation about using hostnames

---
 .../net/airvantage/proxysocket/tools/SubnetPredicate.java   | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
index 667b2d3..7f8cde6 100644
--- a/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
+++ b/proxy-socket-core/src/main/java/net/airvantage/proxysocket/tools/SubnetPredicate.java
@@ -26,6 +26,12 @@
  * );
  * 
* + * Note: it's possible to create a SubnetPredicate with a hostname instead of an IP address, + * the address will be resolved to an IP address using InetAddress.getByName(hostname). + * If the hostname is not resolvable, an IllegalArgumentException will be thrown. + * But if the hostname resolves to a mix of IPv4/IPv6 addresses or multiple addresses, + * the predicate will only match the first address found. + * * Thread-safety: This class is immutable and thread-safe. */ public class SubnetPredicate implements Predicate { From 4358848bc4142b68293738516e99ee3df6754917 Mon Sep 17 00:00:00 2001 From: Benoit Plessis Date: Mon, 1 Dec 2025 18:13:05 +0100 Subject: [PATCH 25/25] reuse constructors --- .../proxysocket/udp/ProxyDatagramSocket.java | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java b/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java index f5e15e8..a26b9a2 100644 --- a/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java +++ b/proxy-socket-udp/src/main/java/net/airvantage/proxysocket/udp/ProxyDatagramSocket.java @@ -40,13 +40,6 @@ public class ProxyDatagramSocket extends DatagramSocket { private final ProxyProtocolMetricsListener metrics; private final Predicate trustedProxyPredicate; - public ProxyDatagramSocket(ProxyAddressCache cache, ProxyProtocolMetricsListener metrics, Predicate predicate) throws SocketException { - super(); - this.addressCache = cache; - this.metrics = metrics; - this.trustedProxyPredicate = predicate; - } - public ProxyDatagramSocket(SocketAddress bindaddr, ProxyAddressCache cache, ProxyProtocolMetricsListener metrics, Predicate predicate) throws SocketException { super(bindaddr); this.addressCache = cache; @@ -54,18 +47,16 @@ public ProxyDatagramSocket(SocketAddress bindaddr, ProxyAddressCache cache, Prox this.trustedProxyPredicate = predicate; } + public ProxyDatagramSocket(ProxyAddressCache cache, ProxyProtocolMetricsListener metrics, Predicate predicate) throws SocketException { + this(new InetSocketAddress(0), cache, metrics, predicate); + } + public ProxyDatagramSocket(int port, ProxyAddressCache cache, ProxyProtocolMetricsListener metrics, Predicate predicate) throws SocketException { - super(port); - this.addressCache = cache; - this.metrics = metrics; - this.trustedProxyPredicate = predicate; + this(port, null, cache, metrics, predicate); } public ProxyDatagramSocket(int port, java.net.InetAddress laddr, ProxyAddressCache cache, ProxyProtocolMetricsListener metrics, Predicate predicate) throws SocketException { - super(port, laddr); - this.addressCache = cache; - this.metrics = metrics; - this.trustedProxyPredicate = predicate; + this(new InetSocketAddress(laddr, port), cache, metrics, predicate); } @Override