Skip to content

RadSec Server

RadSec uses RADIUS over TLS on TCP port 2083. PyRad2's RadSec server uses secure TLS defaults:

  • client certificates are required by default (ssl.CERT_REQUIRED)
  • TLS 1.2 or newer is required by default
  • the server can optionally restrict clients by SHA-256 certificate fingerprint

The examples include a local development CA, server certificate, and client certificate under examples/certs. The bundled server certificate is valid for localhost, 127.0.0.1, ::1, and radsec-server, so the example client can run with hostname validation enabled.

These certificates and private keys are for local development only. For real deployments, generate certificates from your own CA and make sure the server certificate contains a subjectAltName entry for the DNS name or IP address clients use to connect.

To pin which client certificates may connect, pass one or more SHA-256 certificate fingerprints with allowed_client_fingerprints:

server = RadSecServer(
    hosts=hosts,
    dictionary=dictionary,
    certfile="certs/server/server.cert.pem",
    keyfile="certs/server/server.key.pem",
    ca_certfile="certs/ca/ca.cert.pem",
    allowed_client_fingerprints={
        "sha256:12:34:56:...",
    },
)

Fingerprints may be plain lowercase/uppercase hex, colon-separated hex, or prefixed with sha256:. PyRad2 normalizes the value before comparing it with the SHA-256 fingerprint of the presented client certificate. If allowed_client_fingerprints is omitted or empty, any certificate trusted by ca_certfile is accepted.

The server reads packets in a loop on each accepted TLS connection. By default, the connection stays open until the client disconnects. You can bound long-lived connections with connection_read_timeout and max_packets_per_connection:

server = RadSecServer(
    hosts=hosts,
    dictionary=dictionary,
    certfile="certs/server/server.cert.pem",
    keyfile="certs/server/server.key.pem",
    ca_certfile="certs/ca/ca.cert.pem",
    connection_read_timeout=30,
    max_packets_per_connection=1000,
)

Use verify_packet=True when the server should verify request authenticators before dispatching to your handlers. Access-Request, Accounting, CoA, and Disconnect packets are verified with their packet-specific verifier.

RadSec carries all RADIUS packet types on the same TLS/TCP listener. A subclass must implement handle_access_request() and handle_accounting(). CoA and Disconnect handlers are optional because they are Dynamic Authorization Server behavior; by default PyRad2 responds to unsupported requests with CoA-NAK or Disconnect-NAK and Error-Cause = Unsupported-Extension.

If a subclass does implement those handlers but you want to disable dispatch, set enable_coa=False or enable_disconnect=False:

server = RadSecServer(
    hosts=hosts,
    dictionary=dictionary,
    certfile="certs/server/server.cert.pem",
    keyfile="certs/server/server.key.pem",
    ca_certfile="certs/ca/ca.cert.pem",
    enable_coa=False,
    enable_disconnect=False,
)

Message-Authenticator policy

PyRad2 validates Message-Authenticator whenever the attribute is present. By default, packets containing EAP-Message must include a valid Message-Authenticator, while non-EAP packets remain compatible with older clients.

To require Message-Authenticator on every incoming packet, enable require_message_authenticator:

server = RadSecServer(
    hosts=hosts,
    dictionary=dictionary,
    certfile="certs/server/server.cert.pem",
    keyfile="certs/server/server.key.pem",
    ca_certfile="certs/ca/ca.cert.pem",
    require_message_authenticator=True,
)

Replies automatically include Message-Authenticator when the request included one, when require_message_authenticator=True, or when the reply contains EAP-Message.

RadSec servers answer RFC 5997 Status-Server health checks directly with Access-Accept. Status-Server requests must include a valid Message-Authenticator and do not invoke normal authentication, accounting, or CoA handlers.

Use examples/status_radsec.py to send a Status-Server request to examples/server_radsec.py. The plain examples/status.py script uses UDP RADIUS on port 1812 and will not reach a RadSec server listening on TLS/TCP port 2083.

RadSecServer

A RadSec as per RFC6614.

UDP + MD5 has proven to be a combination that has not survived the test of time. Hence, the RADIUS standard adopted RADSEC as a fundamentally more secure approach.

RADSEC effectively means performing communications over TCP instead of UDP (generally on port 2083) and use TLS as a security layer.

RADSEC is the same as “Radius Over TLS” or Radius/TLS.

The default destination port number for RADIUS over TLS is TCP/2083. There are no separate ports for authentication, accounting, and dynamic authorization changes.

Source code in pyrad2/radsec/server.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
class RadSecServer:
    """A RadSec as per RFC6614.

    UDP + MD5 has proven to be a combination that has not survived
    the test of time. Hence, the RADIUS standard adopted RADSEC
    as a fundamentally more secure approach.

    RADSEC effectively means performing communications over TCP instead of UDP
    (generally on port 2083) and use TLS as a security layer.

    RADSEC is the same as “Radius Over TLS” or Radius/TLS.

    The default destination port number for RADIUS over TLS is TCP/2083.
    There are no separate ports for authentication, accounting, and
    dynamic authorization changes.
    """

    DEFAULT_MINIMUM_TLS_VERSION = ssl.TLSVersion.TLSv1_2

    def __init__(
        self,
        listen_address: str = "0.0.0.0",
        listen_port: int = 2083,
        hosts: Optional[dict[str, RemoteHost]] = None,
        dictionary: Optional[Dictionary] = None,
        verify_packet: bool = False,
        certfile: str = "certs/server/server.cert.pem",
        keyfile: str = "certs/server/server.key.pem",
        ca_certfile: str = "certs/ca/ca.cert.pem",
        verify_mode: ssl.VerifyMode = ssl.CERT_REQUIRED,
        minimum_tls_version: ssl.TLSVersion = DEFAULT_MINIMUM_TLS_VERSION,
        ciphers: Optional[str] = None,
        allowed_client_fingerprints: Optional[Iterable[str]] = None,
        connection_read_timeout: Optional[float] = None,
        max_packets_per_connection: Optional[int] = None,
        require_message_authenticator: bool = False,
        require_eap_message_authenticator: bool = True,
        enable_coa: bool = True,
        enable_disconnect: bool = True,
        radius_versions: Sequence[RadiusVersion] = (RadiusVersion.V1_0,),
    ):
        """Initializes a RadSec server.

        Args:
            listen_address (str): IP address to bind to, defaults to 0.0.0.0
            listen_port (int): Deafaults to 2083.
            hosts (dict[str, RemoteHost]): Hosts who we can talk to. A dictionary mapping IP to RemoteHost class instances.
            dictionary (Dictionary): RADIUS dictionary to use.
            verify_packet (bool): If true, the packet will be verified against its secret
            certfile (str): Path to server SSL certificate
            keyfile (str): Path to server SSL certificate
            ca_certfile (str): Path to server CA certfificate
            verify_mode (ssl.VerifyMode): Client certificate verification mode.
            minimum_tls_version (ssl.TLSVersion): Lowest TLS version to negotiate.
            ciphers (str): Optional OpenSSL cipher string override.
            allowed_client_fingerprints (Iterable[str]): Optional SHA-256 certificate
                fingerprint allowlist for client certificates.
            connection_read_timeout (float): Optional timeout while waiting for the
                next packet on an established TLS connection.
            max_packets_per_connection (int): Optional packet limit before closing
                an accepted TLS connection.
            require_message_authenticator (bool): Require Message-Authenticator
                on incoming packets.
            require_eap_message_authenticator (bool): Require
                Message-Authenticator on packets containing EAP-Message.
            enable_coa (bool): Dispatch CoA-Request packets to `handle_coa`;
                disabled requests receive CoA-NAK.
            enable_disconnect (bool): Dispatch Disconnect-Request packets to
                `handle_disconnect`; disabled requests receive Disconnect-NAK.
            radius_versions (Sequence[RadiusVersion]): RFC 9765 protocol
                versions to advertise via ALPN. Defaults to ``(V1_0,)`` —
                identical handshake behavior to historic RadSec. Pass
                ``(V1_0, V1_1)`` to advertise both; the highest mutually
                supported version is chosen by Python's TLS stack.
                **Experimental.**
        """
        self.listen_address = listen_address
        self.listen_port = listen_port
        self.hosts = {} if hosts is None else hosts
        self.dict = dictionary
        self.verify_packet = verify_packet
        self.connection_read_timeout = connection_read_timeout
        self.max_packets_per_connection = max_packets_per_connection
        self.require_message_authenticator = require_message_authenticator
        self.require_eap_message_authenticator = require_eap_message_authenticator
        self.enable_coa = enable_coa
        self.enable_disconnect = enable_disconnect
        self.allowed_client_fingerprints = {
            normalize_cert_fingerprint(fingerprint)
            for fingerprint in (allowed_client_fingerprints or [])
        }
        self.radius_versions: tuple[RadiusVersion, ...] = tuple(radius_versions)
        if not self.radius_versions:
            raise ValueError("radius_versions must contain at least one entry")
        # RFC 9765 §3.4: RADIUS/1.1 requires TLS 1.3+. Promote the floor
        # silently when v1.1 is configured to keep the constructor friendly,
        # but if the caller pinned something higher, respect that.
        minimum_tls_version = enforce_tls_version_floor(
            minimum_tls_version, self.radius_versions
        )

        self.setup_ssl(
            certfile, keyfile, ca_certfile, verify_mode, minimum_tls_version, ciphers
        )

    async def run(self):
        server = await asyncio.start_server(
            self._handle_client,
            host=self.listen_address,
            port=self.listen_port,
            ssl=self.ssl_ctx,
        )

        addr = server.sockets[0].getsockname()
        logger.info("RADSEC Server with mutual TLS running on {}", addr)

        try:
            async with server:
                await server.serve_forever()
        except asyncio.CancelledError:
            logger.info("Task cancelled")
        except KeyboardInterrupt:
            logger.info("Server killed manually")
        finally:
            server.close()
            await server.wait_closed()
            logger.info("Server shutdown")

    def setup_ssl(
        self,
        certfile: str,
        keyfile: str,
        ca_certfile: str,
        verify_mode: ssl.VerifyMode,
        minimum_tls_version: ssl.TLSVersion,
        ciphers: Optional[str],
    ):
        ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
        try:
            ssl_ctx.load_cert_chain(certfile=certfile, keyfile=keyfile)
        except FileNotFoundError as e:
            ssl_paths = ", ".join([certfile, keyfile, ca_certfile])
            msg = "One or more SSL files could not be found. Current paths: {}"
            logger.error(msg, ssl_paths)
            raise FileNotFoundError(msg.format(ssl_paths)) from e

        ssl_ctx.verify_mode = verify_mode
        ssl_ctx.minimum_version = minimum_tls_version
        ssl_ctx.load_verify_locations(cafile=ca_certfile)
        if ciphers is not None:
            ssl_ctx.set_ciphers(ciphers)

        # RFC 9765 §3.1: advertise the supported RADIUS protocol versions via
        # ALPN. No-op when only V1_0 is configured, so historic deployments
        # see byte-identical TLS hellos.
        apply_alpn(ssl_ctx, self.radius_versions)

        self.ssl_ctx = ssl_ctx

    def _verify_client_fingerprint(self, cert: bytes | None) -> bool:
        """Verify a client certificate against the fingerprint allowlist.

        If no fingerprints were configured, the certificate trust decision is
        left to Python's TLS verification.
        """
        if not self.allowed_client_fingerprints:
            return True
        if cert is None:
            return False
        return cert_fingerprint_matches(cert, self.allowed_client_fingerprints)

    async def _read_packet(self, reader: asyncio.StreamReader) -> bytes:
        """Read one RADIUS packet from a RadSec stream.

        When `connection_read_timeout` is configured, the read must complete
        within that many seconds.
        """
        if self.connection_read_timeout is None:
            return await read_radius_packet(reader)
        return await asyncio.wait_for(
            read_radius_packet(reader), timeout=self.connection_read_timeout
        )

    @staticmethod
    async def _close_writer(writer: asyncio.StreamWriter) -> None:
        """Close a stream writer and wait until the close completes."""
        writer.close()
        await writer.wait_closed()

    async def _handle_client(
        self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
    ) -> None:
        """Handle one accepted RadSec TLS connection.

        The method reads and responds to packets until the peer closes the
        stream, a read timeout/malformed packet occurs, or
        `max_packets_per_connection` is reached.
        """
        peername = writer.get_extra_info("peername")
        cert_bin = writer.get_extra_info("peercert", default=None)

        client_id = None
        if cert_bin:
            client_id = writer.get_extra_info("ssl_object").getpeercert(
                binary_form=True
            )
            logger.info(
                "Client {} fingerprint: {}", peername, get_cert_fingerprint(client_id)
            )
        else:
            logger.warning("No certificate from client {}", peername)

        if not self._verify_client_fingerprint(client_id):
            logger.warning("Client {} certificate fingerprint is not allowed", peername)
            writer.close()
            await writer.wait_closed()
            return

        ssl_object = writer.get_extra_info("ssl_object")
        selected_alpn = (
            ssl_object.selected_alpn_protocol() if ssl_object is not None else None
        )
        try:
            radius_version = negotiate(self.radius_versions, selected_alpn)
        except NoCommonRadiusVersion as exc:
            # RFC 9765 §3.3: a strict-mode server (no v1.0 in
            # radius_versions) MUST close when the client didn't pick a
            # version we support. The MAY-send-Protocol-Error path is
            # left out for now.
            logger.warning(
                "Closing RADSEC connection from {}: {}", peername, exc
            )
            writer.close()
            await writer.wait_closed()
            return
        logger.info(
            "RADSEC connection established from {} (ALPN={}, RADIUS/{})",
            peername,
            selected_alpn or "none",
            "1.1" if radius_version == RadiusVersion.V1_1 else "1.0",
        )

        packets_processed = 0
        try:
            while True:
                try:
                    data = await self._read_packet(reader)
                except asyncio.IncompleteReadError:
                    logger.info("RADSEC connection closed by {}", peername)
                    return
                except asyncio.TimeoutError:
                    logger.warning("RADSEC connection from {} timed out", peername)
                    return
                except ValueError as exc:
                    logger.warning("Invalid RADSEC packet from {}: {}", peername, exc)
                    return

                logger.info("Received {} bytes from {}", len(data), peername)
                logger.debug("Data (hex): {}", data.hex())

                try:
                    reply = await self.packet_received(
                        data, host=peername[0], radius_version=radius_version
                    )
                except UnknownHost:
                    logger.warning("Drop package from unknown source {}", peername[0])
                    return

                writer.write(reply.reply_packet())
                await writer.drain()
                logger.info("Sent reply to {}: {}", peername, reply.code)

                packets_processed += 1
                if (
                    self.max_packets_per_connection is not None
                    and packets_processed >= self.max_packets_per_connection
                ):
                    logger.info(
                        "Closing RADSEC connection from {} after {} packets",
                        peername,
                        packets_processed,
                    )
                    return
        finally:
            await self._close_writer(writer)

    def _verify_packet(self, packet: Packet) -> bool:
        """Verify a parsed request packet using its packet-specific verifier."""
        if isinstance(packet, AuthPacket):
            return packet.verify_auth_request()
        if isinstance(packet, AcctPacket):
            return packet.verify_acct_request()
        if isinstance(packet, CoAPacket):
            return packet.verify_coa_request()
        if isinstance(packet, StatusPacket):
            return packet.verify_status_request()
        return packet.verify_packet()

    def _validate_message_authenticator_policy(self, packet: Packet) -> None:
        """Validate incoming Message-Authenticator policy for a packet."""
        packet.validate_message_authenticator_policy(
            require_message_authenticator=self.require_message_authenticator,
            require_eap_message_authenticator=self.require_eap_message_authenticator,
        )

    def _prepare_reply_packet(self, request: Packet, reply: Packet) -> None:
        """Apply outgoing Message-Authenticator policy to a reply packet."""
        prepare_reply_message_authenticator(
            request,
            reply,
            require_message_authenticator=self.require_message_authenticator,
            require_eap_message_authenticator=self.require_eap_message_authenticator,
        )

    @staticmethod
    def _add_error_cause(reply: Packet, cause: ErrorCause) -> None:
        """Add an RFC 5176 Error-Cause value without requiring dictionary support."""
        reply[ERROR_CAUSE_ATTRIBUTE] = [int(cause).to_bytes(4, "big")]

    def _create_unsupported_coa_reply(self, packet: CoAPacket, code: PacketType) -> Packet:
        """Create a NAK response for unsupported Dynamic Authorization requests."""
        reply = packet.create_reply()
        reply.code = code
        self._add_error_cause(reply, ErrorCause.UnsupportedExtension)
        return reply

    async def packet_received(
        self,
        data: bytes,
        host: str,
        radius_version: RadiusVersion = RadiusVersion.V1_0,
    ) -> Packet:
        if host in self.hosts:
            remote_host = self.hosts[host]
        elif "0.0.0.0" in self.hosts:
            remote_host = self.hosts["0.0.0.0"]
        else:
            raise UnknownHost

        packet = parse_packet(
            data, remote_host.secret, self.dict, radius_version=radius_version
        )

        if self.verify_packet:
            if not self._verify_packet(packet):
                raise PacketError("Packet verification failed")

        self._validate_message_authenticator_policy(packet)

        if packet.code == PacketType.StatusServer:
            reply = packet.create_reply(code=PacketType.AccessAccept)
            logger.debug(
                "Received RadSec Status-Server from {}; replying with {}",
                host,
                PacketType(reply.code).name,
            )
        elif packet.code == PacketType.AccessRequest:
            reply = await self.handle_access_request(packet)
        elif packet.code in (
            PacketType.AccountingRequest,
            PacketType.AccountingResponse,
        ):
            reply = await self.handle_accounting(packet)
        elif packet.code == PacketType.CoARequest:
            if self.enable_coa:
                reply = await self.handle_coa(packet)
            else:
                reply = self._create_unsupported_coa_reply(packet, PacketType.CoANAK)
        elif packet.code == PacketType.DisconnectRequest:
            if self.enable_disconnect:
                reply = await self.handle_disconnect(packet)
            else:
                reply = self._create_unsupported_coa_reply(
                    packet, PacketType.DisconnectNAK
                )
        else:
            raise ServerPacketError("Unsupported packet code: {}".format(packet.code))

        self._prepare_reply_packet(packet, reply)
        return reply

    @abstractmethod
    async def handle_access_request(self, packet: AuthPacket) -> Packet:
        """Handle an Access-Request packet."""
        raise NotImplementedError("Subclasses must implement this method")

    @abstractmethod
    async def handle_accounting(self, packet: AcctPacket) -> Packet:
        """Handle an Accounting-Request or Accounting-Response packet."""
        raise NotImplementedError("Subclasses must implement this method")

    async def handle_coa(self, packet: CoAPacket) -> Packet:
        """Handle an unsupported CoA-Request with a CoA-NAK by default.

        Override this method when the RadSec server is acting as a Dynamic
        Authorization Server and can apply authorization changes.
        """
        return self._create_unsupported_coa_reply(packet, PacketType.CoANAK)

    async def handle_disconnect(self, packet: CoAPacket) -> Packet:
        """Handle an unsupported Disconnect-Request with a NAK by default.

        Override this method when the RadSec server is acting as a Dynamic
        Authorization Server and can terminate sessions.
        """
        return self._create_unsupported_coa_reply(packet, PacketType.DisconnectNAK)

__init__(listen_address='0.0.0.0', listen_port=2083, hosts=None, dictionary=None, verify_packet=False, certfile='certs/server/server.cert.pem', keyfile='certs/server/server.key.pem', ca_certfile='certs/ca/ca.cert.pem', verify_mode=ssl.CERT_REQUIRED, minimum_tls_version=DEFAULT_MINIMUM_TLS_VERSION, ciphers=None, allowed_client_fingerprints=None, connection_read_timeout=None, max_packets_per_connection=None, require_message_authenticator=False, require_eap_message_authenticator=True, enable_coa=True, enable_disconnect=True, radius_versions=(RadiusVersion.V1_0,))

Initializes a RadSec server.

Parameters:

Name Type Description Default
listen_address str

IP address to bind to, defaults to 0.0.0.0

'0.0.0.0'
listen_port int

Deafaults to 2083.

2083
hosts dict[str, RemoteHost]

Hosts who we can talk to. A dictionary mapping IP to RemoteHost class instances.

None
dictionary Dictionary

RADIUS dictionary to use.

None
verify_packet bool

If true, the packet will be verified against its secret

False
certfile str

Path to server SSL certificate

'certs/server/server.cert.pem'
keyfile str

Path to server SSL certificate

'certs/server/server.key.pem'
ca_certfile str

Path to server CA certfificate

'certs/ca/ca.cert.pem'
verify_mode VerifyMode

Client certificate verification mode.

CERT_REQUIRED
minimum_tls_version TLSVersion

Lowest TLS version to negotiate.

DEFAULT_MINIMUM_TLS_VERSION
ciphers str

Optional OpenSSL cipher string override.

None
allowed_client_fingerprints Iterable[str]

Optional SHA-256 certificate fingerprint allowlist for client certificates.

None
connection_read_timeout float

Optional timeout while waiting for the next packet on an established TLS connection.

None
max_packets_per_connection int

Optional packet limit before closing an accepted TLS connection.

None
require_message_authenticator bool

Require Message-Authenticator on incoming packets.

False
require_eap_message_authenticator bool

Require Message-Authenticator on packets containing EAP-Message.

True
enable_coa bool

Dispatch CoA-Request packets to handle_coa; disabled requests receive CoA-NAK.

True
enable_disconnect bool

Dispatch Disconnect-Request packets to handle_disconnect; disabled requests receive Disconnect-NAK.

True
radius_versions Sequence[RadiusVersion]

RFC 9765 protocol versions to advertise via ALPN. Defaults to (V1_0,) — identical handshake behavior to historic RadSec. Pass (V1_0, V1_1) to advertise both; the highest mutually supported version is chosen by Python's TLS stack. Experimental.

(V1_0,)
Source code in pyrad2/radsec/server.py
def __init__(
    self,
    listen_address: str = "0.0.0.0",
    listen_port: int = 2083,
    hosts: Optional[dict[str, RemoteHost]] = None,
    dictionary: Optional[Dictionary] = None,
    verify_packet: bool = False,
    certfile: str = "certs/server/server.cert.pem",
    keyfile: str = "certs/server/server.key.pem",
    ca_certfile: str = "certs/ca/ca.cert.pem",
    verify_mode: ssl.VerifyMode = ssl.CERT_REQUIRED,
    minimum_tls_version: ssl.TLSVersion = DEFAULT_MINIMUM_TLS_VERSION,
    ciphers: Optional[str] = None,
    allowed_client_fingerprints: Optional[Iterable[str]] = None,
    connection_read_timeout: Optional[float] = None,
    max_packets_per_connection: Optional[int] = None,
    require_message_authenticator: bool = False,
    require_eap_message_authenticator: bool = True,
    enable_coa: bool = True,
    enable_disconnect: bool = True,
    radius_versions: Sequence[RadiusVersion] = (RadiusVersion.V1_0,),
):
    """Initializes a RadSec server.

    Args:
        listen_address (str): IP address to bind to, defaults to 0.0.0.0
        listen_port (int): Deafaults to 2083.
        hosts (dict[str, RemoteHost]): Hosts who we can talk to. A dictionary mapping IP to RemoteHost class instances.
        dictionary (Dictionary): RADIUS dictionary to use.
        verify_packet (bool): If true, the packet will be verified against its secret
        certfile (str): Path to server SSL certificate
        keyfile (str): Path to server SSL certificate
        ca_certfile (str): Path to server CA certfificate
        verify_mode (ssl.VerifyMode): Client certificate verification mode.
        minimum_tls_version (ssl.TLSVersion): Lowest TLS version to negotiate.
        ciphers (str): Optional OpenSSL cipher string override.
        allowed_client_fingerprints (Iterable[str]): Optional SHA-256 certificate
            fingerprint allowlist for client certificates.
        connection_read_timeout (float): Optional timeout while waiting for the
            next packet on an established TLS connection.
        max_packets_per_connection (int): Optional packet limit before closing
            an accepted TLS connection.
        require_message_authenticator (bool): Require Message-Authenticator
            on incoming packets.
        require_eap_message_authenticator (bool): Require
            Message-Authenticator on packets containing EAP-Message.
        enable_coa (bool): Dispatch CoA-Request packets to `handle_coa`;
            disabled requests receive CoA-NAK.
        enable_disconnect (bool): Dispatch Disconnect-Request packets to
            `handle_disconnect`; disabled requests receive Disconnect-NAK.
        radius_versions (Sequence[RadiusVersion]): RFC 9765 protocol
            versions to advertise via ALPN. Defaults to ``(V1_0,)`` —
            identical handshake behavior to historic RadSec. Pass
            ``(V1_0, V1_1)`` to advertise both; the highest mutually
            supported version is chosen by Python's TLS stack.
            **Experimental.**
    """
    self.listen_address = listen_address
    self.listen_port = listen_port
    self.hosts = {} if hosts is None else hosts
    self.dict = dictionary
    self.verify_packet = verify_packet
    self.connection_read_timeout = connection_read_timeout
    self.max_packets_per_connection = max_packets_per_connection
    self.require_message_authenticator = require_message_authenticator
    self.require_eap_message_authenticator = require_eap_message_authenticator
    self.enable_coa = enable_coa
    self.enable_disconnect = enable_disconnect
    self.allowed_client_fingerprints = {
        normalize_cert_fingerprint(fingerprint)
        for fingerprint in (allowed_client_fingerprints or [])
    }
    self.radius_versions: tuple[RadiusVersion, ...] = tuple(radius_versions)
    if not self.radius_versions:
        raise ValueError("radius_versions must contain at least one entry")
    # RFC 9765 §3.4: RADIUS/1.1 requires TLS 1.3+. Promote the floor
    # silently when v1.1 is configured to keep the constructor friendly,
    # but if the caller pinned something higher, respect that.
    minimum_tls_version = enforce_tls_version_floor(
        minimum_tls_version, self.radius_versions
    )

    self.setup_ssl(
        certfile, keyfile, ca_certfile, verify_mode, minimum_tls_version, ciphers
    )

handle_access_request(packet) abstractmethod async

Handle an Access-Request packet.

Source code in pyrad2/radsec/server.py
@abstractmethod
async def handle_access_request(self, packet: AuthPacket) -> Packet:
    """Handle an Access-Request packet."""
    raise NotImplementedError("Subclasses must implement this method")

handle_accounting(packet) abstractmethod async

Handle an Accounting-Request or Accounting-Response packet.

Source code in pyrad2/radsec/server.py
@abstractmethod
async def handle_accounting(self, packet: AcctPacket) -> Packet:
    """Handle an Accounting-Request or Accounting-Response packet."""
    raise NotImplementedError("Subclasses must implement this method")

handle_coa(packet) async

Handle an unsupported CoA-Request with a CoA-NAK by default.

Override this method when the RadSec server is acting as a Dynamic Authorization Server and can apply authorization changes.

Source code in pyrad2/radsec/server.py
async def handle_coa(self, packet: CoAPacket) -> Packet:
    """Handle an unsupported CoA-Request with a CoA-NAK by default.

    Override this method when the RadSec server is acting as a Dynamic
    Authorization Server and can apply authorization changes.
    """
    return self._create_unsupported_coa_reply(packet, PacketType.CoANAK)

handle_disconnect(packet) async

Handle an unsupported Disconnect-Request with a NAK by default.

Override this method when the RadSec server is acting as a Dynamic Authorization Server and can terminate sessions.

Source code in pyrad2/radsec/server.py
async def handle_disconnect(self, packet: CoAPacket) -> Packet:
    """Handle an unsupported Disconnect-Request with a NAK by default.

    Override this method when the RadSec server is acting as a Dynamic
    Authorization Server and can terminate sessions.
    """
    return self._create_unsupported_coa_reply(packet, PacketType.DisconnectNAK)