Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ with the base install.
See [Authentication Methods](auth.md) for the full method matrix and
client snippets.

### Async runtime: asyncio and uvloop

`ServerAsync` and `ClientAsync` work on any event loop that implements
the asyncio transport protocols. In practice that means both **vanilla
asyncio** and **[uvloop](https://github.com/MagicStack/uvloop)** are supported.
Embedding pyrad2 inside a [FastAPI](https://fastapi.tiangolo.com/) /
[Uvicorn](https://www.uvicorn.org/) host (which defaults to uvloop on
Linux/macOS) needs no special setup.

## 2. RADIUS in one minute

If you've never worked with RADIUS, here's what you need to know before reading another line.
Expand Down
12 changes: 3 additions & 9 deletions examples/dictionary_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ def fresh_packet(dictionary: Dictionary) -> packet.Packet:
)


def encode_and_decode(
pkt: packet.Packet, dictionary: Dictionary
) -> packet.Packet:
def encode_and_decode(pkt: packet.Packet, dictionary: Dictionary) -> packet.Packet:
"""Encode ``pkt``'s attributes, then decode them into a fresh packet."""
attrs = pkt._pkt_encode_attributes()
header = struct.pack("!BBH", 1, 1, 20 + len(attrs)) + b"0123456789ABCDEF"
Expand Down Expand Up @@ -84,9 +82,7 @@ def demo_concat(dictionary: Dictionary) -> None:
avp_count += 1
offset += wire[offset + 1]
logger.info("Original value is {} bytes", len(payload))
logger.info(
"Wire output is {} bytes split across {} AVPs", len(wire), avp_count
)
logger.info("Wire output is {} bytes split across {} AVPs", len(wire), avp_count)

decoded = encode_and_decode(pkt, dictionary)
logger.info(
Expand Down Expand Up @@ -157,9 +153,7 @@ def demo_evs(dictionary: Dictionary) -> None:
pkt.add_attribute("Example-Bandwidth-Mbps", 1000)

wire = pkt._pkt_encode_attributes()
logger.info(
"Two EVS AVPs encoded to {} bytes: {}", len(wire), wire.hex()
)
logger.info("Two EVS AVPs encoded to {} bytes: {}", len(wire), wire.hex())

decoded = encode_and_decode(pkt, dictionary)
logger.info("Example-User-Tier: {}", decoded["Example-User-Tier"])
Expand Down
1 change: 1 addition & 0 deletions examples/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def format_attribute_values(values):
"""Return RADIUS attribute values in a readable form for the example."""
return [value.hex() if isinstance(value, bytes) else value for value in values]


srv = Client(
server="127.0.0.1",
authport=1812,
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ dev = [
# exercise the DES path locally; production users install the same
# dep via the ``[mschap]`` extra.
"cryptography>=43.0",
# tests/test_uvloop_compat.py pins uvloop compatibility so the
# nominal ``isinstance(..., asyncio.DatagramTransport)`` check
# can't sneak back in. uvloop has no Windows wheel.
"uvloop>=0.22.1 ; sys_platform != 'win32'",
]
docs = [
"mkdocs>=1.6.1",
Expand Down
22 changes: 11 additions & 11 deletions pyrad2/client_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import asyncio
import random
from datetime import datetime
from typing import Optional
from typing import Optional, cast

from loguru import logger

Expand Down Expand Up @@ -137,10 +137,16 @@ def send_packet(self, packet: PacketImplementation, future: asyncio.Future):
self.transport.sendto(packet.request_packet())

def connection_made(self, transport: asyncio.BaseTransport):
assert isinstance(transport, asyncio.DatagramTransport), (
"Expected DatagramTransport"
# Duck-typed instead of ``isinstance(transport, asyncio.DatagramTransport)``
# so the client works under non-asyncio loops (uvloop's UDPTransport
# implements the protocol structurally but is not a subclass).
if not hasattr(transport, "sendto"):
raise TypeError(
f"Expected a DatagramTransport-like object, got {type(transport).__name__}"
)
self.transport: asyncio.DatagramTransport = cast(
asyncio.DatagramTransport, transport
)
self.transport: asyncio.DatagramTransport = transport

socket = transport.get_extra_info("socket")
logger.info(
Expand Down Expand Up @@ -385,13 +391,7 @@ async def initialize_transports(
)
task_list.append(coa_connect)

await asyncio.ensure_future(
asyncio.gather(
*task_list,
return_exceptions=False,
),
loop=loop,
)
await asyncio.gather(*task_list, return_exceptions=False)

async def deinitialize_transports(
self,
Expand Down
4 changes: 1 addition & 3 deletions scenarios/_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,7 @@ def banner(text: str) -> None:
def trace_hint() -> None:
"""Surface the PYRAD2_TRACE knob unless the user already turned it on."""
if not os.environ.get("PYRAD2_TRACE"):
logger.info(
"tip: re-run with PYRAD2_TRACE=1 to see wire bytes + decoded AVPs"
)
logger.info("tip: re-run with PYRAD2_TRACE=1 to see wire bytes + decoded AVPs")


def attribute_bytes(value: bytes | str) -> bytes:
Expand Down
3 changes: 1 addition & 2 deletions scenarios/auth_eap_gtc.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,7 @@ def _send_gtc_prompt(self, protocol, pkt, addr, eap_id):
# EAP-Request/GTC: code(1) + id(1) + length(2) + type(1) + prompt
prompt = b"Password: "
eap_payload = (
struct.pack("!BBHB", 1, new_eap_id, 5 + len(prompt), EAP_TYPE_GTC)
+ prompt
struct.pack("!BBHB", 1, new_eap_id, 5 + len(prompt), EAP_TYPE_GTC) + prompt
)
logger.info(
"[server] EAP-Identity from {} → issuing GTC prompt id={}",
Expand Down
9 changes: 8 additions & 1 deletion scenarios/auth_eap_md5.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,14 @@ def _send_md5_challenge(self, protocol, pkt, addr, eap_id):
# code(1) + id(1) + length(2) + type(1) + size(1) + challenge(N) + name?
# We omit Name; the demo doesn't carry an EAP server name.
eap_payload = (
struct.pack("!BBHBB", 1, new_eap_id, 6 + len(challenge), EAP_TYPE_MD5, len(challenge))
struct.pack(
"!BBHBB",
1,
new_eap_id,
6 + len(challenge),
EAP_TYPE_MD5,
len(challenge),
)
+ challenge
)
reply = self.create_reply_packet(pkt)
Expand Down
4 changes: 3 additions & 1 deletion scenarios/auth_eap_mschapv2.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,9 @@ async def main() -> None:

try:
banner("Sending EAP-MSCHAPv2 Access-Request")
req = client.create_auth_packet(User_Name=DEMO_USER, User_Password=DEMO_PASSWORD)
req = client.create_auth_packet(
User_Name=DEMO_USER, User_Password=DEMO_PASSWORD
)
req["NAS-IP-Address"] = "192.168.1.10"
req.auth_type = "eap-mschapv2"
logger.info("[client] → Access-Request id={} (EAP-MSCHAPv2)", req.id)
Expand Down
4 changes: 1 addition & 3 deletions scenarios/coa.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,7 @@ async def main() -> None:
reply = await asyncio.wait_for(client.send_packet(req), timeout=2)

banner("Reply received")
verdict = (
"CoA-ACK" if reply.code == PacketType.CoAACK else f"code={reply.code}"
)
verdict = "CoA-ACK" if reply.code == PacketType.CoAACK else f"code={reply.code}"
logger.info("[client] ← {} id={}", verdict, reply.id)
finally:
await client.deinitialize_transports()
Expand Down
4 changes: 1 addition & 3 deletions scenarios/dedup.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,7 @@ async def main() -> None:

banner("Fresh request (different authenticator) — handler must run again")
fresh = _build_access_request(dictionary)
await _send_and_recv(
transport, collector, fresh, label="Access-Request (new)"
)
await _send_and_recv(transport, collector, fresh, label="Access-Request (new)")
logger.info(
"[client] handler invocations after fresh request: {}",
server.handler_calls,
Expand Down
4 changes: 2 additions & 2 deletions scenarios/radsec_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ async def handle_accounting(self, packet: AcctPacket):

async def _wait_for_listening(host: str, port: int, timeout: float = 2.0) -> None:
"""Poll the TCP socket until the server's TLS listener is accepting."""
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
try:
_, writer = await asyncio.open_connection(host, port)
writer.close()
Expand Down
4 changes: 2 additions & 2 deletions scenarios/radsec_v11.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ async def handle_accounting(self, packet: AcctPacket):


async def _wait_for_listening(host: str, port: int, timeout: float = 2.0) -> None:
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
try:
_, writer = await asyncio.open_connection(host, port)
writer.close()
Expand Down
6 changes: 3 additions & 3 deletions tests/test_client_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def _make_protocol(retries=2, timeout=0.05) -> DatagramProtocolClient:

def _make_request(proto: DatagramProtocolClient, *, packet_id=42, send_date=None):
"""Submit a synthetic pending request without touching the transport."""
fut: asyncio.Future = asyncio.get_event_loop().create_future()
fut: asyncio.Future = asyncio.get_running_loop().create_future()
pkt = MagicMock()
pkt.id = packet_id
pkt.request_packet.return_value = b"raw-bytes"
Expand Down Expand Up @@ -213,7 +213,7 @@ async def scenario():
pkt.secret = b""
pkt.verify_reply.return_value = False

fut: asyncio.Future = asyncio.get_event_loop().create_future()
fut: asyncio.Future = asyncio.get_running_loop().create_future()
proto.pending_requests[7] = {
"packet": pkt,
"creation_date": datetime.now(),
Expand Down Expand Up @@ -246,7 +246,7 @@ async def scenario():
pkt.secret = b""
pkt.verify_reply.return_value = True

fut: asyncio.Future = asyncio.get_event_loop().create_future()
fut: asyncio.Future = asyncio.get_running_loop().create_future()
proto.pending_requests[9] = {
"packet": pkt,
"creation_date": datetime.now(),
Expand Down
55 changes: 55 additions & 0 deletions tests/test_uvloop_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Regression test: pyrad2's async client must work under uvloop.

uvloop's ``UDPTransport`` satisfies the ``asyncio.DatagramTransport``
protocol structurally but does not subclass it. A previous nominal
``isinstance`` check in ``DatagramProtocolClient.connection_made`` broke
under uvloop (i.e. inside any FastAPI / Uvicorn host running on uvloop).
This test pins the duck-typed behaviour.
"""

import asyncio
import sys
from unittest.mock import MagicMock

import pytest

uvloop = pytest.importorskip("uvloop")

if sys.platform == "win32":
pytest.skip("uvloop does not support Windows", allow_module_level=True)


def test_connection_made_accepts_uvloop_transport():
from pyrad2.client_async import DatagramProtocolClient

async def scenario():
proto = DatagramProtocolClient(
server="127.0.0.1",
port=0,
client=MagicMock(),
retries=1,
timeout=0.1,
)
loop = asyncio.get_running_loop()
transport, _ = await loop.create_datagram_endpoint(
lambda: proto, local_addr=("127.0.0.1", 0)
)
try:
# The duck-typed check accepted uvloop's transport rather
# than failing the old ``isinstance(..., asyncio.DatagramTransport)``
# assertion. Confirm the transport really is uvloop's and not
# asyncio's, so a regression to the nominal check would be caught.
assert proto.transport is not None
assert hasattr(proto.transport, "sendto")
assert type(proto.transport).__module__.startswith("uvloop")
finally:
if proto.timeout_future is not None:
proto.timeout_future.cancel()
try:
await proto.timeout_future
except asyncio.CancelledError:
pass
transport.close()

with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
runner.run(scenario())
Loading
Loading