From be0281a3551071abfba48dda423a509ccfdac165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Mon, 24 Nov 2025 20:07:00 +0100 Subject: [PATCH] feat: LISTEN_ADDRESS accepts multiple addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Matej VaĊĦek --- src/func_python/cloudevent.py | 13 ++----- src/func_python/http.py | 13 ++----- src/func_python/sock.py | 73 +++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 18 deletions(-) create mode 100644 src/func_python/sock.py diff --git a/src/func_python/cloudevent.py b/src/func_python/cloudevent.py index 5034f776..26a4cc4e 100644 --- a/src/func_python/cloudevent.py +++ b/src/func_python/cloudevent.py @@ -2,7 +2,7 @@ import logging import os import signal -import socket + import hypercorn.config import hypercorn.asyncio @@ -10,8 +10,9 @@ from cloudevents.conversion import to_structured, to_binary from cloudevents.exceptions import MissingRequiredFields, InvalidRequiredFields +import func_python.sock + DEFAULT_LOG_LEVEL = logging.INFO -DEFAULT_LISTEN_ADDRESS = "[::]:8080" logging.basicConfig(level=DEFAULT_LOG_LEVEL) @@ -75,13 +76,7 @@ def serve(self): """serve serving this ASGIhandler, delegating implementation of methods as necessary to the wrapped Function instance""" cfg = hypercorn.config.Config() - - la = os.getenv('LISTEN_ADDRESS', DEFAULT_LISTEN_ADDRESS) - [host, port] = la.rsplit(":", 1) - # fixup for IPv4-only machines - if not socket.has_ipv6 and host == '[::]': - la = "0.0.0.0:" + port - cfg.bind = [la] + cfg.bind = func_python.sock.bind() logging.info(f"function starting on {cfg.bind}") return asyncio.run(self._serve(cfg)) diff --git a/src/func_python/http.py b/src/func_python/http.py index 9f47d3e4..4133f51d 100644 --- a/src/func_python/http.py +++ b/src/func_python/http.py @@ -3,12 +3,13 @@ import logging import os import signal -import socket + import hypercorn.config import hypercorn.asyncio +import func_python.sock + DEFAULT_LOG_LEVEL = logging.INFO -DEFAULT_LISTEN_ADDRESS = "[::]:8080" logging.basicConfig(level=DEFAULT_LOG_LEVEL) @@ -70,13 +71,7 @@ def serve(self): """serve serving this ASGIhandler, delegating implementation of methods as necessary to the wrapped Function instance""" cfg = hypercorn.config.Config() - - la = os.getenv('LISTEN_ADDRESS', DEFAULT_LISTEN_ADDRESS) - [host, port] = la.rsplit(":", 1) - # fixup for IPv4-only machines - if not socket.has_ipv6 and host == '[::]': - la = "0.0.0.0:" + port - cfg.bind = [la] + cfg.bind = func_python.sock.bind() logging.debug(f"function starting on {cfg.bind}") return asyncio.run(self._serve(cfg)) diff --git a/src/func_python/sock.py b/src/func_python/sock.py new file mode 100644 index 00000000..4e82b022 --- /dev/null +++ b/src/func_python/sock.py @@ -0,0 +1,73 @@ +import ipaddress +import logging +import os +import socket + + +DEFAULT_LISTEN_ADDRESS = '[::]:8080,0.0.0.0:8080' + +def bind() -> list[str]: + """ + This function reads the 'LISTEN_ADDRESS' environment variable and binds sockets according to it's content. + This function gives us some more control over how sockets are created. + We creat them ourselves here, and forward them in the "fd://{fd}" format to the hypercorn server. + :return: Sequence of "bind" strings in format expected by the hypercorn server config. + """ + + listen_addresses = os.getenv('LISTEN_ADDRESS', DEFAULT_LISTEN_ADDRESS).split(",") + + fixup_ipv4_unspecified(listen_addresses) + + ipv4_only = not socket.has_dualstack_ipv6() + result: list[str] = [] + + for address in listen_addresses: + if address.startswith("unix://") or address.startswith("fd://"): + logging.error(f'unsupported schema: <{address}>') + continue + sock: socket.socket + + [host, port] = address.rsplit(":", 1) + if '[' in host: + if ipv4_only: + logging.warning(f'not binding <{address}> since IPv6 is not available') + continue + host = host.strip('[]') + sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + except socket.error as e: + logging.warning(f"cannot set IPV6_V6ONLY: {e}") + else: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + try: + sock.bind((host, int(port))) + result.append(f'fd://{sock.detach()}') + except socket.error as e: + logging.error(f"cannot bind socket <{address}>: {e}") + + if len(result) <= 0: + raise Exception('failed to bind any sockets') + + return result + +def fixup_ipv4_unspecified(listen_addresses: list[str]) -> None: + """ + This function checks if the listen addresses contains unspecified IPv6 address but not unspecified IPv4 address. + If that's the case the function will insert appropriate unspecified IPv4 address into the list. + """ + ipv6_unspecified_port = None + ipv4_unspecified = False + for la in listen_addresses: + if la.startswith("unix://") or la.startswith("fd://"): + continue + [host,port] = la.rsplit(":", 1) + ip = ipaddress.ip_address(host.strip('[]')) + if ip.is_unspecified: + if isinstance(ip, ipaddress.IPv6Address): + ipv6_unspecified_port = port + if isinstance(ip, ipaddress.IPv4Address): + ipv4_unspecified = True + if ipv6_unspecified_port is not None and not ipv4_unspecified: + listen_addresses.append('0.0.0.0:' + ipv6_unspecified_port)