Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b077f76
site: Pull Uri-Path-Abbrev handling into dedicated function
chrysn Apr 17, 2026
47fb9ba
proxy: Handle Uri-Path-Abbrev
chrysn Apr 17, 2026
e016cba
rd: Fix AttributeError on message.request.opt
chrysn Apr 17, 2026
79e0c2a
udp6/MessageInterface: provide __repr__ that tells the IP/port
chrysn Apr 17, 2026
615b45b
rd: Render errors from role reversal
chrysn Apr 17, 2026
cb828a2
slipmux: Fix recognize_remote
chrysn Apr 17, 2026
b0c2bb7
rd/proxy: Support draft-amsuess-core-resource-directory-extensions-11…
chrysn Apr 17, 2026
9253a56
Small fixes for multi-address RD operation
chrysn Apr 23, 2026
499cd56
proxy: Handle Uri-Path-Abbrev
chrysn Apr 23, 2026
fb41a52
tests: Relax timing for connection-refused error
chrysn Apr 23, 2026
e628498
tests: Relax timing for connection-refused error
chrysn Apr 24, 2026
1b39d2a
tests/blockwise: Set test_client_hints to slow_empty_ack
chrysn Apr 24, 2026
ce544c4
interfaces: Clarifications around RequestInterface
chrysn Apr 23, 2026
313b3a3
interfaces: Split fill_and_recognize_remote
chrysn Apr 23, 2026
43dac7a
transport selection: Use two-pass strategy
chrysn Apr 23, 2026
9718b81
interfaces: Provide fill_or_recognize_remote
chrysn Apr 23, 2026
299a7fd
tests: Cover goal of recent fixes
chrysn Apr 23, 2026
637ca25
tests: Set slow_empty_ack to reduce false alarms
chrysn Apr 24, 2026
f867dc4
messagemanager: Preserve remote through two-pass remote-to-transport …
chrysn Apr 24, 2026
ba7166a
tests: Run on GitHub Windows runners
chrysn Apr 29, 2026
45e0104
tests/commandline: Be robust against line endings
chrysn Apr 29, 2026
a7ddca7
udp6: Make repr more robust
chrysn Apr 29, 2026
d00143d
DNM: Hack in recvpktinfo for Windows
chrysn Apr 29, 2026
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
24 changes: 24 additions & 0 deletions .github/workflows/test-on-windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Test Python package

on: [push]

jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
# not bothering with old versions on Windows; this is tested more for
# curiosity anyway.
python-version: 3.14
- name: Install dependencies
run: |
pip install tox
python3 -m tox --notest -e py314-noextras
- name: Run tests
# -allextras would be nice, but DTLSSocket doesn't built
# out-of-the-box, and I don't want to cut it up too much
run: |
python3 -m tox -e py314-noextras
18 changes: 14 additions & 4 deletions aiocoap/cli/rd.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,9 +324,15 @@ def initialize_endpoint(self, network_remote, registration_parameters):

proxy = pop_single_arg(registration_parameters, "proxy")

if proxy is not None and proxy != "on":
if proxy is not None and proxy not in ("on", "yes", "ondemand"):
raise error.BadRequest("Unsupported proxy value")

if proxy == "on":
# FIXME: Deprecate more visibly -- but this has been in the impl
# for quite some time, and is presumably used, given that the
# missing "yes" went uncontested for years.
self.log.warning("Client uses old proprietary value proxy=on")

key = (ep, d)

if static_registration_parameters.pop("proxy", None):
Expand Down Expand Up @@ -407,7 +413,7 @@ def link_format_from_message(message):
This expects an explicit media type set on the response (or was explicitly requested)
"""
certain_format = message.opt.content_format
if certain_format is None and hasattr(message, "request"):
if certain_format is None and message.request is not None:
certain_format = message.request.opt.accept
try:
if certain_format == ContentFormat.LINKFORMAT:
Expand Down Expand Up @@ -692,8 +698,12 @@ async def process_request(self, network_remote, registration_parameters):
get.code = aiocoap.GET
get.opt.accept = ContentFormat.LINKFORMAT

# not trying to catch anything here -- the errors are most likely well renderable into the final response
response = await self.context.request(get).response_raising
try:
response = await self.context.request(get).response_raising
except error.ResponseWrappingError as e:
# Note that ResponseWrappingError is *not* itself renderable
raise error.BadRequest(f"got error {e.coapmessage.code.dotted}")
# No handling needed here: This raises renderable error
links = link_format_from_message(response)

if self.registration_warning:
Expand Down
10 changes: 5 additions & 5 deletions aiocoap/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@ def get_default_clienttransports(*, loop=None, use_env=True):
# There, the remaining ones would just raise NotImplementedError all over the place
return

if sys.platform != "linux":
# udp6 was never reported to work on anything but linux; would happily
# add more platforms.
yield "simple6"
return
# if sys.platform != "linux":
# # udp6 was never reported to work on anything but linux; would happily
# # add more platforms.
# yield "simple6"
# return

# on android it seems that it's only the AI_V4MAPPED that causes trouble,
# that should be manageable in udp6 too.
Expand Down
63 changes: 56 additions & 7 deletions aiocoap/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,28 @@ def send_message(
Currently, it is up to the TokenInterface to unset the no_response
option in response messages, and to possibly not send them."""

@abc.abstractmethod
async def fill_or_recognize_remote(self, message):
"""Return True if the message is recognized to already have a .remote
managedy by this TokenInterface, or return True and set a .remote on
message if it should (by its unresolved remote or Uri-* options) be
routed through this TokenInterface, or return False otherwise."""
"""Deprecated; like :meth:`RequestInterface.fill_or_recognize_remote`"""

warnings.warn(
"fill_or_recognize_remote has been split into recognize_remote and determine_remote",
DeprecationWarning,
)
if self.recognize_remote(message):
return True
remote = self.determine_remote(message)
if remote is not None:
message.remote = remote
return True
return False

@abc.abstractmethod
async def recognize_remote(self, message):
"""Like :meth:`RequestInterface.recognize_remote`"""

@abc.abstractmethod
async def determine_remote(self, message):
"""Like :meth:`RequestInterface.determine_remote`"""


class TokenManager(metaclass=abc.ABCMeta):
Expand All @@ -272,14 +288,47 @@ class TokenManager(metaclass=abc.ABCMeta):


class RequestInterface(metaclass=abc.ABCMeta):
@abc.abstractmethod
"""A transport of CoAP."""

async def fill_or_recognize_remote(self, message):
pass
"""Deprecated method to perform
:meth:`RequestInterface.recognize_remote` and
:meth:`RequestInterface.determine_remote` in one go.

The implementation is only provided for compatibility, and will be
removed after the next release.

Return True if the message is recognized to already have a .remote
managedy by this TokenInterface, or return True and set a .remote on
message if it should (by its unresolved remote or Uri-* options) be
routed through this TokenInterface, or return False otherwise."""

warnings.warn(
"fill_or_recognize_remote has been split into recognize_remote and determine_remote",
DeprecationWarning,
)
if self.recognize_remote(message):
return True
remote = self.determine_remote(message)
if remote is not None:
message.remote = remote
return True
return False

@abc.abstractmethod
def request(self, request: Pipe):
pass

@abc.abstractmethod
async def recognize_remote(self, message):
"""Return True if the remote of this message is currently expected to
be usable with this transport."""

@abc.abstractmethod
async def determine_remote(self, message):
"""Return a remote if the transport expects to be able to deliver the
request based on its URI components."""


class RequestProvider(metaclass=abc.ABCMeta):
"""
Expand Down
14 changes: 5 additions & 9 deletions aiocoap/messagemanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,15 +402,11 @@ def _process_response(self, response):
# outgoing messages
#

async def fill_or_recognize_remote(self, message):
if message.remote is not None:
if await self.message_interface.recognize_remote(message.remote):
return True
remote = await self.message_interface.determine_remote(message)
if remote is not None:
message.remote = remote
return True
return False
async def recognize_remote(self, message):
return await self.message_interface.recognize_remote(message.remote)

async def determine_remote(self, message):
return await self.message_interface.determine_remote(message)

def send_message(self, message, messageerror_monitor):
"""Encode and send message. This takes care of retransmissions (if
Expand Down
8 changes: 6 additions & 2 deletions aiocoap/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def __init__(

self.serversite = serversite

self.request_interfaces = []
self.request_interfaces: list[interfaces.RequestInterface] = []

self.client_credentials = client_credentials or CredentialsMap()
self.server_credentials = server_credentials or CredentialsMap()
Expand Down Expand Up @@ -543,7 +543,11 @@ async def find_remote_and_interface(self, message):
if message.remote is None:
raise error.MissingRemoteError()
for ri in self.request_interfaces:
if await ri.fill_or_recognize_remote(message):
if await ri.recognize_remote(message):
return ri
for ri in self.request_interfaces:
if remote := await ri.determine_remote(message):
message.remote = remote
return ri
raise error.NoRequestInterface()

Expand Down
9 changes: 9 additions & 0 deletions aiocoap/proxy/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,15 @@ async def render(self, request):
# resolution tree (it can't deal with None requests, which are used among
# proxy implementations)
async def render_to_pipe(self, pipe: Pipe) -> None:
# I'd rather not expand unconditionally, but if we don't do this here,
# we're too deep into render() etc to get out again. This is working on
# the assumption that all proxies are really at root.
#
# Workaround-For: https://github.com/chrysn/aiocoap/issues/414
try:
resource._expand_upa(pipe.request)
except error.BadOption:
pass
await resource.Resource.render_to_pipe(self, pipe) # type: ignore


Expand Down
32 changes: 20 additions & 12 deletions aiocoap/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,18 +467,12 @@ async def render_to_pipe(self, request: Pipe):
# need to agree on how the path is processed, even before we go into
# the site dispatch -- so we better resolve this now (exercising our
# liberties as a library proxy) and provide a consistent view.
if request.request.opt.uri_path_abbrev is not None:
if request.request.opt.uri_path:
# Conflicting options
raise error.BadOption()
try:
request.request.opt.uri_path = uri_path_abbrev._map[
request.request.opt.uri_path_abbrev
]
except KeyError:
# Unknown option
raise error.BadOption() from None
request.request.opt.uri_path_abbrev = None
#
# Factored out because other components that don't yet go through
# render_to_pipe
#
# Workaround-For: https://github.com/chrysn/aiocoap/issues/414
_expand_upa(request.request)

try:
child, subrequest = self._find_child_and_pathstripped_message(
Expand All @@ -491,3 +485,17 @@ async def render_to_pipe(self, request: Pipe):
# It probably is.
request.request = subrequest
return await child.render_to_pipe(request)


def _expand_upa(request):
"""Expands the Uri-Path-Abbrev option in-place, or raises BadOption"""
if request.opt.uri_path_abbrev is not None:
if request.opt.uri_path:
# Conflicting options
raise error.BadOption()
try:
request.opt.uri_path = uri_path_abbrev._map[request.opt.uri_path_abbrev]
except KeyError:
# Unknown option
raise error.BadOption() from None
request.opt.uri_path_abbrev = None
7 changes: 5 additions & 2 deletions aiocoap/tokenmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,11 @@ def process_response(self, response):
# implement RequestInterface
#

async def fill_or_recognize_remote(self, message):
return await self.token_interface.fill_or_recognize_remote(message)
async def recognize_remote(self, message):
return await self.token_interface.recognize_remote(message)

async def determine_remote(self, message):
return await self.token_interface.determine_remote(message)

def request(self, request):
if self.outgoing_requests is None:
Expand Down
23 changes: 12 additions & 11 deletions aiocoap/transports/oscore.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# reasons why RequestInterface, TokenInterface and MessageInterface were split
# in the first place.

"""This module implements a RequestProvider for OSCORE. As such, it takes
"""This module implements a RequestInterface for OSCORE. As such, it takes
routing ownership of requests that it has a security context available for, and
sends off the protected messages via another transport.

Expand Down Expand Up @@ -114,7 +114,7 @@ def blockwise_key(self):
return (self.underlying_address.blockwise_key, detail)


class TransportOSCORE(interfaces.RequestProvider):
class TransportOSCORE(interfaces.RequestInterface):
def __init__(self, context, forward_context):
self._context = context
self._wire = forward_context
Expand All @@ -137,35 +137,36 @@ def __init__(self, context, forward_context):
# implement RequestInterface
#

async def fill_or_recognize_remote(self, message):
if isinstance(message.remote, OSCOREAddress):
return True
async def recognize_remote(self, message):
# There is just one of those
return isinstance(message.remote, OSCOREAddress)

async def determine_remote(self, message):
if message.opt.oscore is not None:
# double oscore is not specified; using this fact to make `._wire
# is ._context` an option
return False
return None
if message.opt.uri_path == (".well-known", "edhoc"):
# FIXME better criteria based on next-hop?
return False
return None

try:
secctx = self._context.client_credentials.credentials_from_request(message)
except credentials.CredentialsMissingError:
return False
return None

# FIXME: it'd be better to have a "get me credentials *of this type* if they exist"
if isinstance(secctx, oscore.CanProtect) or isinstance(
secctx, edhoc.EdhocCredentials
):
message.remote = OSCOREAddress(secctx, message.remote)
self.log.debug(
"Selecting OSCORE transport based on context %r for new request %r",
secctx,
message,
)
return True
return OSCOREAddress(secctx, message.remote)
else:
return False
return None

def request(self, request):
t = self.loop.create_task(
Expand Down
1 change: 1 addition & 0 deletions aiocoap/transports/slipmux.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ async def recognize_remote(self, remote):
# See determine_remote: from the current API, this is the only
# asynchronous point before a send.
await self._get(remote)
return True
return False

async def determine_remote(self, message):
Expand Down
Loading
Loading