[PATCH 4/4] tests: RADIUS/TLS accounting responses coalesced and split

Andrei-Alexandru Bleortu me at andrei-z.com
Sun Oct 4 06:19:48 PDT 2026


A RADIUS/TLS server in the test controls how its responses reach
hostapd: two responses in separate records of one TCP segment, two in a
single record, and a record split around a new request. Each checks that
every response is counted without retransmissions.

Enable CONFIG_RADIUS_TLS in the hwsim hostapd build.

Assisted-by: LLM
Signed-off-by: Andrei-Alexandru Bleortu <me at andrei-z.com>
---
 tests/hwsim/example-hostapd.config |   1 +
 tests/hwsim/test_radius.py         | 185 +++++++++++++++++++++++++++++
 2 files changed, 186 insertions(+)

diff --git a/tests/hwsim/example-hostapd.config b/tests/hwsim/example-hostapd.config
index 92338aae0..724d5e5f4 100644
--- a/tests/hwsim/example-hostapd.config
+++ b/tests/hwsim/example-hostapd.config
@@ -42,6 +42,7 @@ endif
 CONFIG_EAP_EKE=y
 CONFIG_PKCS12=y
 CONFIG_RADIUS_SERVER=y
+CONFIG_RADIUS_TLS=y
 CONFIG_IPV6=y
 CONFIG_TLSV11=y
 CONFIG_TLSV12=y
diff --git a/tests/hwsim/test_radius.py b/tests/hwsim/test_radius.py
index de88b0b18..329747e06 100644
--- a/tests/hwsim/test_radius.py
+++ b/tests/hwsim/test_radius.py
@@ -13,6 +13,8 @@ logger = logging.getLogger()
 import os
 import select
 import signal
+import socket
+import ssl
 import struct
 import subprocess
 import threading
@@ -1883,6 +1885,189 @@ def test_radius_tls_freeradius(dev, apdev, test_params):
             if pid > 0:
                 os.kill(pid, signal.SIGTERM)
 
+class RadSecServer(threading.Thread):
+    """RADIUS/TLS accounting server that controls how its responses are
+    split into TLS records and TCP segments"""
+    def __init__(self, port, mode):
+        threading.Thread.__init__(self)
+        self.mode = mode
+        self.requests = []
+        self.error = None
+        self.stop_event = threading.Event()
+        self.ready = threading.Event()
+        self.release = threading.Event()
+        self.lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+        self.lsock.bind(("127.0.0.1", port))
+        self.lsock.listen(1)
+        self.lsock.settimeout(10)
+        self.daemon = True
+
+    def flush(self):
+        data = self.outgoing.read()
+        if data:
+            self.sock.sendall(data)
+
+    def recv(self):
+        data = self.sock.recv(4096)
+        if not data:
+            raise Exception("Connection closed by hostapd")
+        self.incoming.write(data)
+
+    def response(self, req):
+        resp = struct.pack('>BBH', 5, req[1], 20)
+        auth = hashlib.md5(resp + req[4:20] + b"radsec").digest()
+        return resp + auth
+
+    def run(self):
+        try:
+            self._run()
+        except Exception as e:
+            if not self.stop_event.is_set():
+                self.error = str(e)
+
+    def _run(self):
+        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+        ctx.load_cert_chain("auth_serv/server.pem", "auth_serv/server.key")
+        ctx.load_verify_locations("auth_serv/ca.pem")
+        ctx.verify_mode = ssl.CERT_REQUIRED
+        self.sock, addr = self.lsock.accept()
+        self.sock.settimeout(1)
+        self.incoming = ssl.MemoryBIO()
+        self.outgoing = ssl.MemoryBIO()
+        tls = ctx.wrap_bio(self.incoming, self.outgoing, server_side=True)
+        while True:
+            try:
+                tls.do_handshake()
+                break
+            except ssl.SSLWantReadError:
+                self.flush()
+                self.recv()
+        self.flush()
+        self.ready.set()
+
+        # The first two responses go out as the mode says, the rest each in
+        # its own record and segment.
+        stream = b''
+        pending = []
+        first = True
+        while not self.stop_event.is_set():
+            try:
+                self.recv()
+            except socket.timeout:
+                pass
+            while True:
+                try:
+                    stream += tls.read(4096)
+                except ssl.SSLWantReadError:
+                    break
+            self.flush()
+            while len(stream) >= 4:
+                length = struct.unpack('>H', stream[2:4])[0]
+                if len(stream) < length:
+                    break
+                req = stream[0:length]
+                stream = stream[length:]
+                self.requests.append(req)
+                pending.append(self.response(req))
+            if not pending or (first and len(pending) < 2):
+                continue
+            if first and self.mode == "message":
+                tls.write(b''.join(pending))
+            else:
+                for resp in pending:
+                    tls.write(resp)
+            data = self.outgoing.read()
+            if first and self.mode == "split":
+                # Stop inside the second record, send the rest once hostapd
+                # sent another request
+                cut = len(data) * 3 // 4
+                self.sock.sendall(data[0:cut])
+                self.release.wait(5)
+                data = data[cut:]
+            self.sock.sendall(data)
+            pending = []
+            first = False
+
+    def stop(self):
+        self.stop_event.set()
+        self.lsock.close()
+        try:
+            self.sock.close()
+        except AttributeError:
+            pass
+
+def radsec_acct_ap(apdev, port):
+    params = hostapd.wpa2_params(ssid="radsec-acct", passphrase="12345678")
+    params['acct_server_addr'] = "127.0.0.1"
+    params['acct_server_port'] = str(port)
+    params['acct_server_type'] = "TLS"
+    params['acct_server_shared_secret'] = "radsec"
+    params['acct_server_ca_cert'] = "auth_serv/ca.pem"
+    params['acct_server_client_cert'] = "auth_serv/user.pem"
+    params['acct_server_private_key'] = "auth_serv/user.key"
+    return hostapd.add_ap(apdev, params)
+
+def radsec_acct_responses(hapd, requests):
+    for i in range(20):
+        mib = hapd.get_mib()
+        if int(mib['radiusAccClientResponses']) >= requests:
+            break
+        time.sleep(0.1)
+    if int(mib['radiusAccClientRequests']) != requests or \
+       int(mib['radiusAccClientResponses']) != requests:
+        raise Exception("Unexpected accounting counters: requests=%s responses=%s" %
+                        (mib['radiusAccClientRequests'],
+                         mib['radiusAccClientResponses']))
+    if int(mib['radiusAccClientRetransmissions']) != 0:
+        raise Exception("Accounting requests retransmitted: %s" %
+                        mib['radiusAccClientRetransmissions'])
+
+def run_radsec_acct_stream(dev, apdev, mode):
+    port = 18083
+    srv = RadSecServer(port, mode)
+    srv.start()
+    try:
+        hapd = radsec_acct_ap(apdev[0], port)
+        if not srv.ready.wait(5):
+            raise Exception("No RADIUS/TLS connection")
+        dev[0].connect("radsec-acct", psk="12345678", scan_freq="2412",
+                       wait_connect=False)
+        dev[1].connect("radsec-acct", psk="12345678", scan_freq="2412",
+                       wait_connect=False)
+        dev[0].wait_connected()
+        dev[1].wait_connected()
+        if mode == "split":
+            # hostapd encrypts a new request while half a record is pending
+            time.sleep(0.5)
+            dev[1].request("DISCONNECT")
+            dev[1].wait_disconnected()
+            time.sleep(0.2)
+            srv.release.set()
+        else:
+            # Accounting-On and the two Accounting-Starts
+            radsec_acct_responses(hapd, 3)
+            # The connection is still usable after the coalesced responses
+            dev[0].request("DISCONNECT")
+            dev[0].wait_disconnected()
+        radsec_acct_responses(hapd, 4)
+        if srv.error:
+            raise Exception("RADIUS/TLS server: " + srv.error)
+    finally:
+        srv.stop()
+
+def test_radius_tls_acct_records(dev, apdev):
+    """RADIUS/TLS accounting responses in separate records of one segment"""
+    run_radsec_acct_stream(dev, apdev, "records")
+
+def test_radius_tls_acct_message(dev, apdev):
+    """RADIUS/TLS accounting responses in a single TLS record"""
+    run_radsec_acct_stream(dev, apdev, "message")
+
+def test_radius_tls_acct_split(dev, apdev):
+    """RADIUS/TLS accounting response record split around a request"""
+    run_radsec_acct_stream(dev, apdev, "split")
+
 def test_radius_eapol_test(dev, apdev, test_params):
     """RADIUS testing with eapol_test"""
     et_path = "../../wpa_supplicant/eapol_test"
-- 
2.47.3





More information about the Hostap mailing list