# server.py
#
# Copyright 2003-2004,2007,2016 Wichert Akkerman <wichert@wiggy.net>
import ipaddress
import select
import socket
from pyrad import host
from pyrad import packet
import logging
logger = logging.getLogger('pyrad')
[docs]
class RemoteHost:
"""A RADIUS client or server and its shared secret.
Used as the values of the ``hosts`` mapping of :class:`Server`,
:class:`pyrad.server_async.ServerAsync` and :class:`pyrad.proxy.Proxy`.
pyrad only uses ``secret``: the servers look up the client by the
key of the ``hosts`` mapping (the IP address as the socket reports it),
not by ``address``. The address, name and ports are stored for the
application and are not used by pyrad.
"""
def __init__(self, address, secret, name, authport=1812, acctport=1813, coaport=3799):
"""
:param address: IP address of the host (informational)
:type address: str
:param secret: RADIUS shared secret
:type secret: bytes
:param name: short name of the host (informational)
:type name: str
:param authport: authentication port of the host (informational)
:type authport: int
:param acctport: accounting port of the host (informational)
:type acctport: int
:param coaport: CoA port of the host (informational)
:type coaport: int
"""
self.address = address
self.secret = secret
self.authport = authport
self.acctport = acctport
self.coaport = coaport
self.name = name
[docs]
class ServerPacketError(Exception):
"""A received packet is dropped.
Raised by :class:`Server`, :class:`pyrad.server_async.ServerAsync` and
:class:`pyrad.proxy.Proxy` for packets they don't accept: packets from
an unknown client, packets of the wrong type for the port they were
received on, and Access-Requests with an invalid or (with
``enforce_ma``) a missing Message-Authenticator.
A packet handler may raise it to drop a request: the server then logs
a single line with the message instead of a traceback.
"""
def CheckMessageAuthenticator(pkt, enforce_ma=False):
"""Check the Message-Authenticator of a received Access-Request.
This is the BlastRADIUS countermeasure (CVE-2024-3596) of the servers.
An invalid Message-Authenticator is always rejected, a missing one only
with ``enforce_ma``.
:param pkt: received packet with the secret set
:type pkt: pyrad.packet.Packet
:param enforce_ma: require a Message-Authenticator
:type enforce_ma: bool
:raise ServerPacketError: if the packet should be dropped
"""
if pkt.message_authenticator:
if not pkt.verify_message_authenticator():
raise ServerPacketError(
'Received Access-Request with invalid Message-Authenticator')
elif enforce_ma:
raise ServerPacketError(
'Received Access-Request without Message-Authenticator')
def _HostAddresses(address):
"""Return the keys under which a source address is looked up in hosts.
These are the address as received and, for an IPv4-mapped IPv6 address
(an IPv4 client of a dual stack socket bound to '::'), its IPv4 form.
:param address: source address of a packet
:type address: str
:return: addresses to look up, in order
:rtype: list[str]
"""
addresses = [address]
try:
mapped = ipaddress.ip_address(address).ipv4_mapped
except (ValueError, AttributeError, TypeError):
mapped = None
if mapped is not None:
addresses.append(str(mapped))
return addresses
def _LookupHost(hosts, address):
"""Return the hosts entry of a source address, or None.
:param hosts: hosts who are allowed to talk to us
:type hosts: dict[str, RemoteHost]
:param address: source address of a packet
:type address: str
"""
for key in _HostAddresses(address):
if key in hosts:
return hosts[key]
return None
[docs]
class Server(host.Host):
"""Basic blocking RADIUS server.
The server receives and decodes requests, checks that they come from a
known client and are valid, and passes them to the packet handlers.
Derive from it and override :meth:`HandleAuthPacket`,
:meth:`HandleAcctPacket`, :meth:`HandleCoaPacket` and
:meth:`HandleDisconnectPacket`; the default handlers ignore the
request. Then call :meth:`Run`.
:ivar hosts: clients allowed to send requests, see the ``hosts``
parameter
:vartype hosts: dict[str, RemoteHost]
:cvar MaxPacketSize: size of the receive buffer (RADIUS packets are at
most 4096 octets)
:vartype MaxPacketSize: int
"""
MaxPacketSize = 8192
def __init__(self, addresses=[], authport=1812, acctport=1813, coaport=3799,
hosts=None, dict=None, auth_enabled=True, acct_enabled=True, coa_enabled=False,
enforce_ma=False, enable_pkt_verify=True):
"""
:param addresses: IP addresses or host names to listen on. The
constructor binds them with :meth:`~pyrad.server.Server.BindToAddress`. Without
addresses no packets are received unless :meth:`~pyrad.server.Server.BindToAddress`
is called.
:type addresses: list[str]
:param authport: port to listen on for authentication packets
:type authport: int
:param acctport: port to listen on for accounting packets
:type acctport: int
:param coaport: port to listen on for CoA and Disconnect packets
:type coaport: int
:param hosts: clients allowed to send requests, mapping their IP
address to a :class:`~pyrad.server.RemoteHost` with the shared secret. The
entry ``'0.0.0.0'`` is used for all other clients. IPv4 clients
of a socket bound to ``'::'`` are looked up by their IPv4
address. Requests of other clients are dropped.
:type hosts: dict[str, pyrad.server.RemoteHost]
:param dict: RADIUS dictionary used to decode the requests
:type dict: pyrad.dictionary.Dictionary
:param auth_enabled: listen for authentication packets
:type auth_enabled: bool
:param acct_enabled: listen for accounting packets
:type acct_enabled: bool
:param coa_enabled: listen for CoA and Disconnect packets
:type coa_enabled: bool
:param enforce_ma: drop Access-Requests without a Message-Authenticator
(an invalid Message-Authenticator is always dropped)
:type enforce_ma: bool
:param enable_pkt_verify: drop Accounting, CoA and Disconnect requests
with an invalid Request Authenticator
:type enable_pkt_verify: bool
"""
host.Host.__init__(self, authport, acctport, coaport, dict)
if hosts is None:
self.hosts = {}
else:
self.hosts = hosts
self.auth_enabled = auth_enabled
self.authfds = []
self.acct_enabled = acct_enabled
self.acctfds = []
self.coa_enabled = coa_enabled
self.coafds = []
self.enforce_ma = enforce_ma
self.enable_pkt_verify = enable_pkt_verify
for addr in addresses:
self.BindToAddress(addr)
def _GetAddrInfo(self, addr):
"""Use getaddrinfo to look up all addresses of an address.
Returns a set of (family, address) tuples, or an empty list if the
address can't be resolved.
:param addr: IP address or host name to look up
:type addr: str
"""
results = set()
try:
tmp = socket.getaddrinfo(addr, 80)
except socket.gaierror:
return []
for el in tmp:
results.add((el[0], el[4][0]))
return results
[docs]
def BindToAddress(self, addr):
"""Listen on an address.
Opens a socket for each enabled port (authentication, accounting,
CoA). ``'0.0.0.0'`` listens on all IPv4 addresses, ``'::'`` on all
IPv6 addresses and, on a dual stack host, on all IPv4 addresses as
well. A host name is resolved and every address it resolves to is
bound; a name that can't be resolved is ignored silently. Call it
before :meth:`Run`.
:param addr: IP address or host name to listen on
:type addr: str
"""
addrFamily = self._GetAddrInfo(addr)
for (family, address) in addrFamily:
if self.auth_enabled:
authfd = socket.socket(family, socket.SOCK_DGRAM)
authfd.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
authfd.bind((address, self.authport))
self.authfds.append(authfd)
if self.acct_enabled:
acctfd = socket.socket(family, socket.SOCK_DGRAM)
acctfd.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
acctfd.bind((address, self.acctport))
self.acctfds.append(acctfd)
if self.coa_enabled:
coafd = socket.socket(family, socket.SOCK_DGRAM)
coafd.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
coafd.bind((address, self.coaport))
self.coafds.append(coafd)
[docs]
def HandleAuthPacket(self, pkt):
"""Handle an Access-Request.
Called by :meth:`Run` for each Access-Request from a known client: a
client in ``hosts`` or, if present, the ``'0.0.0.0'`` entry.
A Message-Authenticator in the request has been verified, and with
``enforce_ma`` requests without one have been dropped. The
User-Password is not verified: decrypt it with
:meth:`pkt.PwDecrypt <pyrad.packet.AuthPacket.PwDecrypt>` and check
it yourself.
To answer, create the reply with :meth:`CreateReplyPacket` and send
it with ``self.SendReplyPacket(pkt.fd, reply)``
(:meth:`pyrad.host.Host.SendReplyPacket`). If the handler doesn't
reply, the request is dropped. An exception raised by the handler
is logged with a traceback and the request is dropped; raise
:class:`ServerPacketError` to drop it with a one-line log message.
The packet has the attributes ``pkt.source`` (the client address
as returned by :meth:`socket.socket.recvfrom`), ``pkt.fd`` (the
socket it was received on) and ``pkt.secret`` (the secret of the
client).
The default implementation ignores the request.
:param pkt: received request
:type pkt: pyrad.packet.AuthPacket
"""
[docs]
def HandleAcctPacket(self, pkt):
"""Handle an Accounting-Request.
Called by :meth:`Run` for each Accounting-Request from a known client: a
client in ``hosts`` or, if present, the ``'0.0.0.0'`` entry.
With ``enable_pkt_verify`` its Request Authenticator has been
verified.
To answer, create the reply with :meth:`CreateReplyPacket` and send
it with ``self.SendReplyPacket(pkt.fd, reply)``
(:meth:`pyrad.host.Host.SendReplyPacket`). If the handler doesn't
reply, the request is dropped. An exception raised by the handler
is logged with a traceback and the request is dropped; raise
:class:`ServerPacketError` to drop it with a one-line log message.
The packet has the attributes ``pkt.source`` (the client address
as returned by :meth:`socket.socket.recvfrom`), ``pkt.fd`` (the
socket it was received on) and ``pkt.secret`` (the secret of the
client).
The default implementation ignores the request.
:param pkt: received request
:type pkt: pyrad.packet.AcctPacket
"""
[docs]
def HandleCoaPacket(self, pkt):
"""Handle a CoA-Request.
Called by :meth:`Run` for each CoA-Request from a known client: a
client in ``hosts`` or, if present, the ``'0.0.0.0'`` entry.
With ``enable_pkt_verify`` its Request Authenticator has been
verified.
To answer, create the reply with :meth:`CreateReplyPacket` and send
it with ``self.SendReplyPacket(pkt.fd, reply)``
(:meth:`pyrad.host.Host.SendReplyPacket`). If the handler doesn't
reply, the request is dropped. An exception raised by the handler
is logged with a traceback and the request is dropped; raise
:class:`ServerPacketError` to drop it with a one-line log message.
The packet has the attributes ``pkt.source`` (the client address
as returned by :meth:`socket.socket.recvfrom`), ``pkt.fd`` (the
socket it was received on) and ``pkt.secret`` (the secret of the
client).
The default implementation ignores the request.
:param pkt: received request
:type pkt: pyrad.packet.CoAPacket
"""
[docs]
def HandleDisconnectPacket(self, pkt):
"""Handle a Disconnect-Request.
Called by :meth:`Run` for each Disconnect-Request from a known client: a
client in ``hosts`` or, if present, the ``'0.0.0.0'`` entry.
With ``enable_pkt_verify`` its Request Authenticator has been
verified. :meth:`CreateReplyPacket` creates a Disconnect-ACK; set
``reply.code`` to ``pyrad.packet.DisconnectNAK`` to refuse it.
To answer, create the reply with :meth:`CreateReplyPacket` and send
it with ``self.SendReplyPacket(pkt.fd, reply)``
(:meth:`pyrad.host.Host.SendReplyPacket`). If the handler doesn't
reply, the request is dropped. An exception raised by the handler
is logged with a traceback and the request is dropped; raise
:class:`ServerPacketError` to drop it with a one-line log message.
The packet has the attributes ``pkt.source`` (the client address
as returned by :meth:`socket.socket.recvfrom`), ``pkt.fd`` (the
socket it was received on) and ``pkt.secret`` (the secret of the
client).
The default implementation ignores the request.
:param pkt: received request
:type pkt: pyrad.packet.CoAPacket
"""
def _AddSecret(self, pkt):
"""Add the secret of the client to a received packet.
Raises :class:`ServerPacketError` for unknown hosts.
:param pkt: packet to process
:type pkt: pyrad.packet.Packet
"""
remote_host = _LookupHost(self.hosts, pkt.source[0])
if remote_host is not None:
pkt.secret = remote_host.secret
elif '0.0.0.0' in self.hosts:
pkt.secret = self.hosts['0.0.0.0'].secret
else:
raise ServerPacketError('Received packet from unknown host')
def _HandleAuthPacket(self, pkt):
"""Process a packet received on the authentication port.
If this packet should be dropped instead of processed a
ServerPacketError exception should be raised. The main loop will
drop the packet and log the reason.
:param pkt: packet to process
:type pkt: pyrad.packet.Packet
"""
self._AddSecret(pkt)
if pkt.code != packet.AccessRequest:
raise ServerPacketError(
'Received non-authentication packet on authentication port')
CheckMessageAuthenticator(pkt, self.enforce_ma)
self.HandleAuthPacket(pkt)
def _HandleAcctPacket(self, pkt):
"""Process a packet received on the accounting port.
If this packet should be dropped instead of processed a
ServerPacketError exception should be raised. The main loop will
drop the packet and log the reason.
:param pkt: packet to process
:type pkt: pyrad.packet.Packet
"""
self._AddSecret(pkt)
if pkt.code != packet.AccountingRequest:
raise ServerPacketError(
'Received non-accounting packet on accounting port')
if self.enable_pkt_verify and not pkt.VerifyAcctRequest():
raise packet.PacketError('Packet verification failed')
self.HandleAcctPacket(pkt)
def _HandleCoaPacket(self, pkt):
"""Process a packet received on the CoA port.
If this packet should be dropped instead of processed a
ServerPacketError exception should be raised. The main loop will
drop the packet and log the reason.
:param pkt: packet to process
:type pkt: pyrad.packet.Packet
"""
self._AddSecret(pkt)
if pkt.code not in (packet.CoARequest, packet.DisconnectRequest):
raise ServerPacketError('Received non-coa packet on coa port')
if self.enable_pkt_verify and not pkt.VerifyCoARequest():
raise packet.PacketError('Packet verification failed')
if pkt.code == packet.CoARequest:
self.HandleCoaPacket(pkt)
else:
self.HandleDisconnectPacket(pkt)
def _GrabPacket(self, pktgen, fd):
"""Read a packet from a network connection.
This method assumes there is data waiting to be read.
:param fd: socket to read packet from
:type fd: socket.socket
:return: RADIUS packet
:rtype: pyrad.packet.Packet
"""
(data, source) = fd.recvfrom(self.MaxPacketSize)
try:
pkt = pktgen(data)
except packet.PacketError:
raise
except Exception as err:
# the packet is not authenticated yet, never let it stop the
# main loop
raise packet.PacketError('%s: %s' % (type(err).__name__, err))
pkt.source = source
pkt.fd = fd
return pkt
def _PrepareSockets(self):
"""Prepare all sockets to receive packets."""
for fd in self.authfds + self.acctfds + self.coafds:
self._fdmap[fd.fileno()] = fd
self._poll.register(fd.fileno(), select.POLLIN | select.POLLPRI | select.POLLERR)
if self.auth_enabled:
self._realauthfds = list(map(lambda x: x.fileno(), self.authfds))
if self.acct_enabled:
self._realacctfds = list(map(lambda x: x.fileno(), self.acctfds))
if self.coa_enabled:
self._realcoafds = list(map(lambda x: x.fileno(), self.coafds))
[docs]
def CreateReplyPacket(self, pkt, **attributes):
"""Create a reply to a received request.
The reply is an Access-Accept for an Access-Request, an
Accounting-Response for an Accounting-Request, a CoA-ACK for a
CoA-Request and a Disconnect-ACK for a Disconnect-Request; change
``reply.code`` for another reply, e.g. ``pyrad.packet.AccessReject``
or ``pyrad.packet.CoANAK``. The Proxy-State attributes of
the request are copied, replies to Access-Requests get a
Message-Authenticator, and ``reply.source`` is set to the client
address so the reply can be sent with
:meth:`pyrad.host.Host.SendReplyPacket`.
:param pkt: received request
:type pkt: pyrad.packet.Packet
:param attributes: attributes to add to the reply, as keyword
arguments like for :class:`pyrad.packet.Packet`
:return: reply packet
:rtype: pyrad.packet.Packet
"""
reply = pkt.CreateReply(**attributes)
reply.source = pkt.source
return reply
def _ProcessInput(self, fd):
"""Process available data.
If this packet should be dropped instead of processed a
PacketError exception should be raised. The main loop will
drop the packet and log the reason.
This function calls _HandleAuthPacket(), _HandleAcctPacket() or
_HandleCoaPacket() depending on which socket is being processed.
:param fd: socket to read packet from
:type fd: socket.socket
"""
if self.auth_enabled and fd.fileno() in self._realauthfds:
pkt = self._GrabPacket(lambda data, s=self: s.CreateAuthPacket(packet=data), fd)
self._HandleAuthPacket(pkt)
elif self.acct_enabled and fd.fileno() in self._realacctfds:
pkt = self._GrabPacket(lambda data, s=self: s.CreateAcctPacket(packet=data), fd)
self._HandleAcctPacket(pkt)
elif self.coa_enabled:
pkt = self._GrabPacket(lambda data, s=self: s.CreateCoAPacket(packet=data), fd)
self._HandleCoaPacket(pkt)
else:
raise ServerPacketError('Received packet for unknown handler')
[docs]
def Run(self):
"""Run the server; this method never returns.
Waits for packets on the sockets opened by :meth:`BindToAddress`
and passes valid requests to the packet handlers. Invalid packets
are dropped and logged, and so are packets for which a handler
raises an exception, so that a malformed packet can't stop the
server. Uses :func:`select.poll`, which is not available on Windows.
"""
self._poll = select.poll()
self._fdmap = {}
self._PrepareSockets()
while True:
for (fd, event) in self._poll.poll():
if event == select.POLLIN:
try:
fdo = self._fdmap[fd]
self._ProcessInput(fdo)
except ServerPacketError as err:
logger.info('Dropping packet: ' + str(err))
except packet.PacketError as err:
logger.info('Received a broken packet: ' + str(err))
except Exception:
logger.exception('Error processing packet')
else:
logger.error('Unexpected event in server main loop')