From a44b0013bc7438994eb71377c2e17f1d6cf70491 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Wed, 15 Apr 2026 16:03:42 -0700 Subject: [PATCH 1/4] Add additional integration tests for more comprehensive testing --- tests/test-client-verify-cn.py | 50 +++++++++ tests/test-client-verify-ou.py | 53 +++++++++ tests/test-server-allow-all.py | 68 ++++++++++++ tests/test-server-max-concurrent-conns.py | 102 +++++++++++++++++ tests/test-server-proxy-protocol.py | 129 ++++++++++++++++++++++ tests/test-server-target-status.py | 98 ++++++++++++++++ 6 files changed, 500 insertions(+) create mode 100644 tests/test-client-verify-cn.py create mode 100644 tests/test-client-verify-ou.py create mode 100644 tests/test-server-allow-all.py create mode 100644 tests/test-server-max-concurrent-conns.py create mode 100644 tests/test-server-proxy-protocol.py create mode 100644 tests/test-server-target-status.py diff --git a/tests/test-client-verify-cn.py b/tests/test-client-verify-cn.py new file mode 100644 index 0000000000..0e59a95707 --- /dev/null +++ b/tests/test-client-verify-cn.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 + +""" +Tests that --verify-cn flag works correctly on the client. +""" + +from common import LOCALHOST, RootCert, STATUS_PORT, SocketPair, TcpClient, \ + TlsServer, print_ok, run_ghostunnel, terminate, LISTEN_PORT, TARGET_PORT, \ + assert_connection_rejected + +ghostunnel = None +try: + # create certs + root = RootCert('root') + root.create_signed_cert('client') + root.create_signed_cert( + 'server1', + san='IP:127.0.0.1,IP:::1,DNS:localhost') + root.create_signed_cert( + 'server2', + san='IP:127.0.0.1,IP:::1,DNS:localhost') + + # start ghostunnel with --verify-cn=server1 + ghostunnel = run_ghostunnel(['client', + '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), + '--target=localhost:{0}'.format(TARGET_PORT), + '--keystore=client.p12', + '--verify-cn=server1', + '--cacert=root.crt', + '--status={0}:{1}'.format(LOCALHOST, + STATUS_PORT)]) + + # connect to server1 (CN=server1), should succeed + pair = SocketPair(TcpClient(LISTEN_PORT), TlsServer( + 'server1', 'root', TARGET_PORT)) + pair.validate_can_send_from_client( + "hello world", "1: client -> server") + pair.validate_can_send_from_server( + "hello world", "1: server -> client") + pair.validate_closing_client_closes_server( + "1: client closed -> server closed") + + # connect to server2 (CN=server2), should be rejected + assert_connection_rejected( + TcpClient(LISTEN_PORT), TlsServer('server2', 'root', TARGET_PORT), + "server2", timeout_ok=False) + + print_ok("OK") +finally: + terminate(ghostunnel) diff --git a/tests/test-client-verify-ou.py b/tests/test-client-verify-ou.py new file mode 100644 index 0000000000..b5834f4f2e --- /dev/null +++ b/tests/test-client-verify-ou.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +""" +Tests that --verify-ou flag works correctly on the client. + +Note: create_signed_cert(name) sets both CN and OU to the given name, +so --verify-ou=server1 checks the OU field of the server certificate. +""" + +from common import LOCALHOST, RootCert, STATUS_PORT, SocketPair, TcpClient, \ + TlsServer, print_ok, run_ghostunnel, terminate, LISTEN_PORT, TARGET_PORT, \ + assert_connection_rejected + +ghostunnel = None +try: + # create certs + root = RootCert('root') + root.create_signed_cert('client') + root.create_signed_cert( + 'server1', + san='IP:127.0.0.1,IP:::1,DNS:localhost') + root.create_signed_cert( + 'server2', + san='IP:127.0.0.1,IP:::1,DNS:localhost') + + # start ghostunnel with --verify-ou=server1 + ghostunnel = run_ghostunnel(['client', + '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), + '--target=localhost:{0}'.format(TARGET_PORT), + '--keystore=client.p12', + '--verify-ou=server1', + '--cacert=root.crt', + '--status={0}:{1}'.format(LOCALHOST, + STATUS_PORT)]) + + # connect to server1 (OU=server1), should succeed + pair = SocketPair(TcpClient(LISTEN_PORT), TlsServer( + 'server1', 'root', TARGET_PORT)) + pair.validate_can_send_from_client( + "hello world", "1: client -> server") + pair.validate_can_send_from_server( + "hello world", "1: server -> client") + pair.validate_closing_client_closes_server( + "1: client closed -> server closed") + + # connect to server2 (OU=server2), should be rejected + assert_connection_rejected( + TcpClient(LISTEN_PORT), TlsServer('server2', 'root', TARGET_PORT), + "server2", timeout_ok=False) + + print_ok("OK") +finally: + terminate(ghostunnel) diff --git a/tests/test-server-allow-all.py b/tests/test-server-allow-all.py new file mode 100644 index 0000000000..23ff8c3964 --- /dev/null +++ b/tests/test-server-allow-all.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 + +""" +Tests that --allow-all flag accepts any client with a valid certificate, +regardless of CN/OU/SAN, but still rejects clients with untrusted CAs. +""" + +from common import LOCALHOST, RootCert, STATUS_PORT, SocketPair, TcpServer, \ + TlsClient, print_ok, run_ghostunnel, terminate, LISTEN_PORT, TARGET_PORT, \ + assert_connection_rejected + +ghostunnel = None +try: + # create certs + root = RootCert('root') + root.create_signed_cert('server') + root.create_signed_cert('client1') + root.create_signed_cert( + 'client2', + san='DNS:other-client,IP:127.0.0.1,IP:::1,DNS:localhost') + + # create a cert signed by a different CA (not trusted by ghostunnel) + other_root = RootCert('other_root') + other_root.create_signed_cert('untrusted_client') + + # create a combined CA bundle so the untrusted client can verify the + # server cert (signed by root) while presenting its own cert (signed + # by other_root) + with open('combined_ca.crt', 'w') as f: + with open('root.crt') as r: + f.write(r.read()) + with open('other_root.crt') as r: + f.write(r.read()) + + # start ghostunnel with --allow-all + ghostunnel = run_ghostunnel(['server', + '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), + '--target={0}:{1}'.format(LOCALHOST, TARGET_PORT), + '--keystore=server.p12', + '--cacert=root.crt', + '--allow-all', + '--status={0}:{1}'.format(LOCALHOST, + STATUS_PORT)]) + + # client1 should be accepted + pair1 = SocketPair( + TlsClient('client1', 'root', LISTEN_PORT), TcpServer(TARGET_PORT)) + pair1.validate_can_send_from_client("toto", "client1 accepted") + pair1.validate_can_send_from_server("toto", "client1 accepted") + pair1.cleanup() + + # client2 (different OU/SAN) should also be accepted with --allow-all + pair2 = SocketPair( + TlsClient('client2', 'root', LISTEN_PORT), TcpServer(TARGET_PORT)) + pair2.validate_can_send_from_client("toto", "client2 accepted (different OU)") + pair2.validate_can_send_from_server("toto", "client2 accepted (different OU)") + pair2.cleanup() + + # client signed by a different CA should still be rejected + # use combined_ca so the TLS client trusts the server cert, but the + # server won't trust the client cert (signed by other_root) + assert_connection_rejected( + TlsClient('untrusted_client', 'combined_ca', LISTEN_PORT), TcpServer(TARGET_PORT), + "untrusted_client") + + print_ok("OK") +finally: + terminate(ghostunnel) diff --git a/tests/test-server-max-concurrent-conns.py b/tests/test-server-max-concurrent-conns.py new file mode 100644 index 0000000000..c4e22e38e4 --- /dev/null +++ b/tests/test-server-max-concurrent-conns.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 + +""" +Tests that --max-concurrent-conns limits the number of simultaneous connections. +""" + +from common import LOCALHOST, RootCert, STATUS_PORT, SocketPair, TcpClient, TcpServer, \ + TlsClient, print_ok, run_ghostunnel, terminate, LISTEN_PORT, TARGET_PORT +import socket +import ssl +import time + +ghostunnel = None +try: + # create certs + root = RootCert('root') + root.create_signed_cert('server') + root.create_signed_cert('client') + + # start ghostunnel with --max-concurrent-conns=2 + ghostunnel = run_ghostunnel(['server', + '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), + '--target={0}:{1}'.format(LOCALHOST, TARGET_PORT), + '--keystore=server.p12', + '--cacert=root.crt', + '--allow-ou=client', + '--max-concurrent-conns=2', + '--status={0}:{1}'.format(LOCALHOST, + STATUS_PORT)]) + + # open first connection + pair1 = SocketPair( + TlsClient('client', 'root', LISTEN_PORT), TcpServer(TARGET_PORT)) + pair1.validate_can_send_from_client("hello1", "pair1 works") + print_ok("connection 1 established") + + # open second connection + pair2 = SocketPair( + TlsClient('client', 'root', LISTEN_PORT), TcpServer(TARGET_PORT)) + pair2.validate_can_send_from_client("hello2", "pair2 works") + print_ok("connection 2 established") + + # third connection: the semaphore is full so ghostunnel won't even accept + # the TCP connection. A TLS connect attempt should time out. + blocked = False + sock3 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock3.settimeout(2) + try: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.load_verify_locations(cafile='root.crt') + ctx.load_cert_chain('client.crt', 'client.key') + tls_sock = ctx.wrap_socket(sock3, server_hostname=LOCALHOST) + tls_sock.connect((LOCALHOST, LISTEN_PORT)) + # if we get here, connection was accepted — check if backend is reachable + # (it shouldn't be since semaphore is full) + backend3 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + backend3.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + so_reuseport = getattr(socket, 'SO_REUSEPORT', None) + if so_reuseport is not None: + backend3.setsockopt(socket.SOL_SOCKET, so_reuseport, 1) + backend3.settimeout(2) + backend3.bind((LOCALHOST, TARGET_PORT)) + backend3.listen(1) + try: + backend3.accept() + raise Exception("3rd connection should not have reached backend") + except socket.timeout: + blocked = True + finally: + backend3.close() + tls_sock.close() + except (socket.timeout, ssl.SSLError, ConnectionError, OSError): + blocked = True + finally: + try: + sock3.close() + except Exception: + pass + + if not blocked: + raise Exception("3rd connection was not blocked by concurrency limit") + print_ok("3rd connection correctly blocked by concurrency limit") + + # close first connection to free up a slot + pair1.cleanup() + print_ok("connection 1 closed") + + # give ghostunnel a moment to release the semaphore + time.sleep(1) + + # now a new connection should succeed + pair3 = SocketPair( + TlsClient('client', 'root', LISTEN_PORT), TcpServer(TARGET_PORT)) + pair3.validate_can_send_from_client("hello3", "pair3 works after slot freed") + print_ok("connection 3 established after freeing slot") + + pair2.cleanup() + pair3.cleanup() + + print_ok("OK") +finally: + terminate(ghostunnel) diff --git a/tests/test-server-proxy-protocol.py b/tests/test-server-proxy-protocol.py new file mode 100644 index 0000000000..1a6b1b9bd9 --- /dev/null +++ b/tests/test-server-proxy-protocol.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 + +""" +Tests that --proxy-protocol sends a valid PROXY protocol v2 header +to the backend before forwarding application data. +""" + +from common import LOCALHOST, RootCert, STATUS_PORT, TcpClient, TcpServer, \ + TlsClient, print_ok, run_ghostunnel, terminate, \ + LISTEN_PORT, TARGET_PORT, TIMEOUT +import socket +import struct + +# PROXY protocol v2 signature (12 bytes) +PP2_SIGNATURE = b'\r\n\r\n\x00\r\nQUIT\n' + +ghostunnel = None +try: + # create certs + root = RootCert('root') + root.create_signed_cert('server') + root.create_signed_cert('client') + + # start ghostunnel with --proxy-protocol + ghostunnel = run_ghostunnel(['server', + '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), + '--target={0}:{1}'.format(LOCALHOST, TARGET_PORT), + '--keystore=server.p12', + '--cacert=root.crt', + '--allow-ou=client', + '--proxy-protocol', + '--status={0}:{1}'.format(LOCALHOST, + STATUS_PORT)]) + + # set up backend listener manually (not via SocketPair, since we need + # to read raw bytes before application data) + backend = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + backend.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + so_reuseport = getattr(socket, 'SO_REUSEPORT', None) + if so_reuseport is not None: + backend.setsockopt(socket.SOL_SOCKET, so_reuseport, 1) + backend.settimeout(TIMEOUT) + backend.bind((LOCALHOST, TARGET_PORT)) + backend.listen(1) + + # wait for ghostunnel to start + TcpClient(STATUS_PORT).connect(20) + + # connect a TLS client through the tunnel + client = TlsClient('client', 'root', LISTEN_PORT) + client.connect() + + # accept the backend connection + conn, _ = backend.accept() + conn.settimeout(TIMEOUT) + + # read the PROXY protocol v2 header + # minimum header is 16 bytes: 12-byte signature + version/command + family + length + header = b'' + while len(header) < 16: + chunk = conn.recv(16 - len(header)) + if not chunk: + raise Exception("connection closed before full header received") + header += chunk + + # verify signature (first 12 bytes) + signature = header[:12] + if signature != PP2_SIGNATURE: + raise Exception("invalid PROXY protocol v2 signature: {0}".format( + signature.hex())) + print_ok("PROXY protocol v2 signature verified") + + # verify version and command (byte 12) + # version = high nibble (should be 0x2), command = low nibble (0x1 = PROXY) + ver_cmd = header[12] + version = (ver_cmd & 0xF0) >> 4 + command = ver_cmd & 0x0F + if version != 2: + raise Exception("expected PROXY protocol version 2, got {0}".format(version)) + if command != 1: + raise Exception("expected PROXY command, got {0}".format(command)) + print_ok("version=2, command=PROXY verified") + + # verify address family and protocol (byte 13) + # 0x11 = AF_INET + STREAM, 0x21 = AF_INET6 + STREAM + fam_proto = header[13] + if fam_proto not in (0x11, 0x21): + raise Exception("unexpected family/protocol: 0x{0:02x}".format(fam_proto)) + print_ok("address family/protocol verified: 0x{0:02x}".format(fam_proto)) + + # read address data (length is in bytes 14-15) + addr_len = struct.unpack('!H', header[14:16])[0] + addr_data = b'' + while len(addr_data) < addr_len: + chunk = conn.recv(addr_len - len(addr_data)) + if not chunk: + raise Exception("connection closed before address data received") + addr_data += chunk + + if fam_proto == 0x11: + # IPv4: 4+4+2+2 = 12 bytes (src_addr, dst_addr, src_port, dst_port) + if addr_len < 12: + raise Exception("IPv4 address data too short: {0}".format(addr_len)) + src_addr = socket.inet_ntoa(addr_data[0:4]) + dst_addr = socket.inet_ntoa(addr_data[4:8]) + src_port = struct.unpack('!H', addr_data[8:10])[0] + dst_port = struct.unpack('!H', addr_data[10:12])[0] + print_ok("src={0}:{1} dst={2}:{3}".format(src_addr, src_port, dst_addr, dst_port)) + if src_addr != '127.0.0.1': + raise Exception("expected source 127.0.0.1, got {0}".format(src_addr)) + print_ok("PROXY protocol address data verified") + + # send application data through the tunnel and verify it arrives + test_data = b'hello proxy protocol' + client.get_socket().send(test_data) + received = conn.recv(len(test_data)) + if received != test_data: + raise Exception("application data mismatch: expected {0}, got {1}".format( + test_data, received)) + print_ok("application data passed through correctly after PROXY header") + + # cleanup + conn.close() + backend.close() + client.cleanup() + + print_ok("OK") +finally: + terminate(ghostunnel) diff --git a/tests/test-server-target-status.py b/tests/test-server-target-status.py new file mode 100644 index 0000000000..8ce052c93d --- /dev/null +++ b/tests/test-server-target-status.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 + +""" +Tests that --target-status flag enables HTTP health checking of the backend, +and that /_status reflects backend health. + +Note: ghostunnel's --target-status HTTP client uses the backend dialer (i.e. +it connects to --target and sends the HTTP request from --target-status). +So the health HTTP server must be reachable at the --target address. +""" + +from common import LOCALHOST, RootCert, STATUS_PORT, TcpClient, print_ok, \ + run_ghostunnel, terminate, status_info, wait_for_status, \ + LISTEN_PORT, get_free_port +import http.server +import socket +import threading + +ghostunnel = None +health_server = None +try: + # create certs + root = RootCert('root') + root.create_signed_cert('server') + root.create_signed_cert('client') + + # Allocate a fresh port for the health server (release=True so we can bind it) + BACKEND_PORT = get_free_port(release=True) + + # start a simple HTTP server for health checks. + # ghostunnel's --target-status HTTP client dials --target to send + # the health check request, so the HTTP server must live there. + class HealthHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b'OK') + def log_message(self, format, *args): + pass # suppress logs + + class ReuseHTTPServer(http.server.HTTPServer): + allow_reuse_address = True + def server_bind(self): + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + so_reuseport = getattr(socket, 'SO_REUSEPORT', None) + if so_reuseport is not None: + self.socket.setsockopt(socket.SOL_SOCKET, so_reuseport, 1) + super().server_bind() + + health_server = ReuseHTTPServer((LOCALHOST, BACKEND_PORT), HealthHandler) + health_thread = threading.Thread(target=health_server.serve_forever) + health_thread.daemon = True + health_thread.start() + print_ok("health check server started on port {0}".format(BACKEND_PORT)) + + # start ghostunnel with --target-status pointing at a health endpoint + ghostunnel = run_ghostunnel(['server', + '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), + '--target={0}:{1}'.format(LOCALHOST, BACKEND_PORT), + '--keystore=server.p12', + '--cacert=root.crt', + '--allow-ou=client', + '--target-status=http://{0}:{1}/healthz'.format(LOCALHOST, BACKEND_PORT), + '--status={0}:{1}'.format(LOCALHOST, + STATUS_PORT)]) + + # wait for ghostunnel status port to come up + TcpClient(STATUS_PORT).connect(20) + + # verify status is OK + info = wait_for_status(lambda s: s.get('ok') is True) + print_ok("/_status reports ok=true with healthy backend") + + # stop the health check server + health_server.shutdown() + health_server = None + print_ok("health check server stopped") + + # wait for /_status to report not-ok + info = wait_for_status(lambda s: s.get('ok') is False) + print_ok("/_status reports ok=false after backend health check fails") + + # restart the health check server on the same port + health_server = ReuseHTTPServer((LOCALHOST, BACKEND_PORT), HealthHandler) + health_thread = threading.Thread(target=health_server.serve_forever) + health_thread.daemon = True + health_thread.start() + print_ok("health check server restarted on port {0}".format(BACKEND_PORT)) + + # wait for /_status to recover + info = wait_for_status(lambda s: s.get('ok') is True) + print_ok("/_status reports ok=true after backend recovery") + + print_ok("OK") +finally: + if health_server: + health_server.shutdown() + terminate(ghostunnel) From bb3062e3cdb89cec88ff1991576b5d4cfeafaf90 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Wed, 15 Apr 2026 16:15:06 -0700 Subject: [PATCH 2/4] Address review comments --- tests/test-server-max-concurrent-conns.py | 36 ++++++++++++++++------- tests/test-server-proxy-protocol.py | 2 +- tests/test-server-target-status.py | 18 +++++++----- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/tests/test-server-max-concurrent-conns.py b/tests/test-server-max-concurrent-conns.py index c4e22e38e4..9eb1e24edb 100644 --- a/tests/test-server-max-concurrent-conns.py +++ b/tests/test-server-max-concurrent-conns.py @@ -4,7 +4,7 @@ Tests that --max-concurrent-conns limits the number of simultaneous connections. """ -from common import LOCALHOST, RootCert, STATUS_PORT, SocketPair, TcpClient, TcpServer, \ +from common import LOCALHOST, RootCert, STATUS_PORT, SocketPair, TcpServer, \ TlsClient, print_ok, run_ghostunnel, terminate, LISTEN_PORT, TARGET_PORT import socket import ssl @@ -74,8 +74,8 @@ finally: try: sock3.close() - except Exception: - pass + except OSError: + pass # best-effort cleanup, socket may already be closed if not blocked: raise Exception("3rd connection was not blocked by concurrency limit") @@ -85,14 +85,30 @@ pair1.cleanup() print_ok("connection 1 closed") - # give ghostunnel a moment to release the semaphore - time.sleep(1) + # retry until ghostunnel observes the closed connection and releases + # the semaphore, or fail after a bounded timeout + deadline = time.time() + 5 + pair3 = None + last_error = None + while time.time() < deadline: + try: + pair3 = SocketPair( + TlsClient('client', 'root', LISTEN_PORT), TcpServer(TARGET_PORT)) + pair3.validate_can_send_from_client("hello3", "pair3 works after slot freed") + print_ok("connection 3 established after freeing slot") + break + except (socket.timeout, ssl.SSLError, ConnectionError, OSError) as exc: + last_error = exc + if pair3 is not None: + try: + pair3.cleanup() + except Exception: + pass + pair3 = None + time.sleep(0.2) - # now a new connection should succeed - pair3 = SocketPair( - TlsClient('client', 'root', LISTEN_PORT), TcpServer(TARGET_PORT)) - pair3.validate_can_send_from_client("hello3", "pair3 works after slot freed") - print_ok("connection 3 established after freeing slot") + if pair3 is None: + raise Exception("3rd connection did not succeed after freeing slot") from last_error pair2.cleanup() pair3.cleanup() diff --git a/tests/test-server-proxy-protocol.py b/tests/test-server-proxy-protocol.py index 1a6b1b9bd9..3c5e228556 100644 --- a/tests/test-server-proxy-protocol.py +++ b/tests/test-server-proxy-protocol.py @@ -5,7 +5,7 @@ to the backend before forwarding application data. """ -from common import LOCALHOST, RootCert, STATUS_PORT, TcpClient, TcpServer, \ +from common import LOCALHOST, RootCert, STATUS_PORT, TcpClient, \ TlsClient, print_ok, run_ghostunnel, terminate, \ LISTEN_PORT, TARGET_PORT, TIMEOUT import socket diff --git a/tests/test-server-target-status.py b/tests/test-server-target-status.py index 8ce052c93d..36cdbe0e40 100644 --- a/tests/test-server-target-status.py +++ b/tests/test-server-target-status.py @@ -10,7 +10,7 @@ """ from common import LOCALHOST, RootCert, STATUS_PORT, TcpClient, print_ok, \ - run_ghostunnel, terminate, status_info, wait_for_status, \ + run_ghostunnel, terminate, wait_for_status, \ LISTEN_PORT, get_free_port import http.server import socket @@ -24,8 +24,10 @@ root.create_signed_cert('server') root.create_signed_cert('client') - # Allocate a fresh port for the health server (release=True so we can bind it) - BACKEND_PORT = get_free_port(release=True) + # Allocate a fresh port for the health server and keep the reservation. + # ReuseHTTPServer enables SO_REUSEPORT in server_bind(), so it can bind + # safely without reopening a port-collision race in parallel test runs. + BACKEND_PORT = get_free_port() # start a simple HTTP server for health checks. # ghostunnel's --target-status HTTP client dials --target to send @@ -68,16 +70,17 @@ def server_bind(self): TcpClient(STATUS_PORT).connect(20) # verify status is OK - info = wait_for_status(lambda s: s.get('ok') is True) + wait_for_status(lambda s: s.get('ok') is True) print_ok("/_status reports ok=true with healthy backend") - # stop the health check server + # stop the health check server and close the listening socket health_server.shutdown() + health_server.server_close() health_server = None print_ok("health check server stopped") # wait for /_status to report not-ok - info = wait_for_status(lambda s: s.get('ok') is False) + wait_for_status(lambda s: s.get('ok') is False) print_ok("/_status reports ok=false after backend health check fails") # restart the health check server on the same port @@ -88,11 +91,12 @@ def server_bind(self): print_ok("health check server restarted on port {0}".format(BACKEND_PORT)) # wait for /_status to recover - info = wait_for_status(lambda s: s.get('ok') is True) + wait_for_status(lambda s: s.get('ok') is True) print_ok("/_status reports ok=true after backend recovery") print_ok("OK") finally: if health_server: health_server.shutdown() + health_server.server_close() terminate(ghostunnel) From 6ad45a0bfa3677576472c01a3fb282ef95d26bd9 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Wed, 15 Apr 2026 17:18:43 -0700 Subject: [PATCH 3/4] Address more comments --- tests/test-server-max-concurrent-conns.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test-server-max-concurrent-conns.py b/tests/test-server-max-concurrent-conns.py index 9eb1e24edb..02fa2b1455 100644 --- a/tests/test-server-max-concurrent-conns.py +++ b/tests/test-server-max-concurrent-conns.py @@ -47,6 +47,7 @@ sock3.settimeout(2) try: ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.load_verify_locations(cafile='root.crt') ctx.load_cert_chain('client.crt', 'client.key') tls_sock = ctx.wrap_socket(sock3, server_hostname=LOCALHOST) @@ -103,7 +104,7 @@ try: pair3.cleanup() except Exception: - pass + pass # best-effort cleanup during retry pair3 = None time.sleep(0.2) From a5ceed53239e3ff6f3d8bd0341ae69fb66310b3c Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Wed, 15 Apr 2026 19:41:42 -0700 Subject: [PATCH 4/4] Set timeout in max concurrent conns test --- tests/test-server-max-concurrent-conns.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/test-server-max-concurrent-conns.py b/tests/test-server-max-concurrent-conns.py index 02fa2b1455..a8abdf6d97 100644 --- a/tests/test-server-max-concurrent-conns.py +++ b/tests/test-server-max-concurrent-conns.py @@ -17,7 +17,9 @@ root.create_signed_cert('server') root.create_signed_cert('client') - # start ghostunnel with --max-concurrent-conns=2 + # start ghostunnel with --max-concurrent-conns=2 and a short connect + # timeout so that any stale TCP connections in the backlog are cleaned + # up quickly by ghostunnel ghostunnel = run_ghostunnel(['server', '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), '--target={0}:{1}'.format(LOCALHOST, TARGET_PORT), @@ -25,6 +27,7 @@ '--cacert=root.crt', '--allow-ou=client', '--max-concurrent-conns=2', + '--connect-timeout=1s', '--status={0}:{1}'.format(LOCALHOST, STATUS_PORT)]) @@ -86,9 +89,11 @@ pair1.cleanup() print_ok("connection 1 closed") - # retry until ghostunnel observes the closed connection and releases - # the semaphore, or fail after a bounded timeout - deadline = time.time() + 5 + # Wait for ghostunnel to fully release the semaphore slot. The stale + # sock3 TCP connection may still be in the listen backlog — ghostunnel + # will accept it, fail the handshake (connect-timeout=1s), and release + # the semaphore. We need to wait for that cycle to complete. + deadline = time.time() + 10 pair3 = None last_error = None while time.time() < deadline: @@ -98,7 +103,7 @@ pair3.validate_can_send_from_client("hello3", "pair3 works after slot freed") print_ok("connection 3 established after freeing slot") break - except (socket.timeout, ssl.SSLError, ConnectionError, OSError) as exc: + except Exception as exc: last_error = exc if pair3 is not None: try: @@ -106,7 +111,7 @@ except Exception: pass # best-effort cleanup during retry pair3 = None - time.sleep(0.2) + time.sleep(0.5) if pair3 is None: raise Exception("3rd connection did not succeed after freeing slot") from last_error