From 78ae620aa0a9248ff557cb6c41b26a368a39f6b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:58:20 +0000 Subject: [PATCH 1/5] Initial plan From 30a83c06f301b578bfaeea88f0ba1703a0ac79e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:03:44 +0000 Subject: [PATCH 2/5] Add TestParseHostport tests and error path for unbracketed IPv6 in _parse_hostport Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- tests/test_main.py | 23 +++++++++++++++++++++++ voip/__main__.py | 5 +++++ 2 files changed, 28 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index bea31a8..59472b9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -64,6 +64,29 @@ def test_parse_stun_server__without_port_uses_stun_default(self): ) +class TestParseHostport: + def test_parse_hostport__bracketed_ipv6_without_port_uses_default(self): + """Return default port when bracketed IPv6 address has no port.""" + from voip.__main__ import _parse_hostport + + assert _parse_hostport(None, None, "[::1]", default_port=5061) == ("::1", 5061) + + def test_parse_hostport__bracketed_ipv6_with_port(self): + """Return explicit port when bracketed IPv6 address includes a port.""" + from voip.__main__ import _parse_hostport + + assert _parse_hostport(None, None, "[::1]:5061") == ("::1", 5061) + + def test_parse_hostport__unbracketed_ipv6_raises_bad_parameter(self): + """Raise BadParameter when an unbracketed IPv6 literal is given.""" + import click + + from voip.__main__ import _parse_hostport + + with pytest.raises(click.BadParameter, match="enclosed in brackets"): + _parse_hostport(None, None, "::1") + + class TestVoIPCommand: def test_voip__verbose_flag(self): """Accept -v flag without error.""" diff --git a/voip/__main__.py b/voip/__main__.py index dfb5b92..a2c8bb0 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -69,6 +69,11 @@ def _parse_hostport( raise click.BadParameter( f"Invalid port in {value!r}.", param=param ) from None + if value.count(":") > 1: + # Multiple colons without a leading bracket indicate an unbracketed IPv6 literal. + raise click.BadParameter( + f"IPv6 address must be enclosed in brackets, e.g. [{value}].", param=param + ) host, _, port_str = value.rpartition(":") if not host: return value, default_port From 56e13463f672453abe8ed458dc10d5428b743334 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:29:33 +0000 Subject: [PATCH 3/5] Refactor _parse_hostport to use regex and return typed IPv4Address | IPv6Address | str Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- tests/test_main.py | 15 +++++++--- voip/__main__.py | 70 ++++++++++++++++++++------------------------ voip/sip/protocol.py | 2 +- 3 files changed, 44 insertions(+), 43 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 59472b9..7af952f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import ipaddress import sys from unittest.mock import MagicMock, patch @@ -66,16 +67,22 @@ def test_parse_stun_server__without_port_uses_stun_default(self): class TestParseHostport: def test_parse_hostport__bracketed_ipv6_without_port_uses_default(self): - """Return default port when bracketed IPv6 address has no port.""" + """Return default port and IPv6Address when bracketed IPv6 address has no port.""" from voip.__main__ import _parse_hostport - assert _parse_hostport(None, None, "[::1]", default_port=5061) == ("::1", 5061) + assert _parse_hostport(None, None, "[::1]", default_port=5061) == ( + ipaddress.IPv6Address("::1"), + 5061, + ) def test_parse_hostport__bracketed_ipv6_with_port(self): - """Return explicit port when bracketed IPv6 address includes a port.""" + """Return explicit port and IPv6Address when bracketed IPv6 address includes a port.""" from voip.__main__ import _parse_hostport - assert _parse_hostport(None, None, "[::1]:5061") == ("::1", 5061) + assert _parse_hostport(None, None, "[::1]:5061") == ( + ipaddress.IPv6Address("::1"), + 5061, + ) def test_parse_hostport__unbracketed_ipv6_raises_bad_parameter(self): """Raise BadParameter when an unbracketed IPv6 literal is given.""" diff --git a/voip/__main__.py b/voip/__main__.py index a2c8bb0..df01670 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -3,6 +3,7 @@ import dataclasses import ipaddress import logging +import re import ssl import time @@ -29,13 +30,24 @@ SIP_TLS_PORT = 5061 +#: Regex that parses ``[IPv6HOST][:PORT]`` or ``HOST[:PORT]`` strings. +#: Named groups: ``ipv6`` (bare address inside brackets) or ``host`` (plain hostname / +#: IPv4 literal), and an optional ``port`` suffix. +HOSTPORT_PATTERN: re.Pattern[str] = re.compile( + r"^(?:\[(?P[0-9a-fA-F:]+)\]|(?P[^:\[\]]+))" + r"(?::(?P\d+))?$" +) + + def _parse_hostport( ctx, param, value: str, default_port: int = 5061 -) -> tuple[str, int]: - """Parse `HOST[:PORT]` or `[IPv6HOST][:PORT]` into a `(host, port)` tuple. +) -> tuple[ipaddress.IPv4Address | ipaddress.IPv6Address | str, int]: + """Parse `HOST[:PORT]` or `[IPv6HOST][:PORT]` into a typed `(host, port)` tuple. IPv6 addresses must be enclosed in square brackets per RFC 2732, e.g. - ``[::1]:5061``. The returned host is the bare address without brackets. + ``[::1]:5061``. The returned host is an + [`IPv4Address`][ipaddress.IPv4Address] or [`IPv6Address`][ipaddress.IPv6Address] + when the value is a numeric IP address, otherwise a plain hostname string. Args: ctx: Click context. @@ -44,43 +56,25 @@ def _parse_hostport( default_port: Port to use when not specified. Returns: - Tuple of (host, port). + Tuple of (host, port) where host is an IP address object or hostname string. Raises: - click.BadParameter: When port is invalid. + click.BadParameter: When value is malformed (unbracketed IPv6 or invalid port). """ - if value.startswith("["): - bracket_end = value.find("]") - if bracket_end == -1: - raise click.BadParameter( - f"Unclosed bracket in IPv6 address: {value!r}.", param=param - ) - host = value[1:bracket_end] - remainder = value[bracket_end + 1 :] - if not remainder: - return host, default_port - if not remainder.startswith(":"): + match = HOSTPORT_PATTERN.fullmatch(value) + if not match: + if value.count(":") > 1: raise click.BadParameter( - f"Expected ':port' after ']' in {value!r}.", param=param + f"IPv6 address must be enclosed in brackets, e.g. [{value}].", param=param ) - try: - return host, int(remainder[1:]) - except ValueError: - raise click.BadParameter( - f"Invalid port in {value!r}.", param=param - ) from None - if value.count(":") > 1: - # Multiple colons without a leading bracket indicate an unbracketed IPv6 literal. - raise click.BadParameter( - f"IPv6 address must be enclosed in brackets, e.g. [{value}].", param=param - ) - host, _, port_str = value.rpartition(":") - if not host: - return value, default_port + raise click.BadParameter(f"Invalid host:port value: {value!r}.", param=param) + raw_host = match.group("ipv6") or match.group("host") + port = int(match.group("port")) if match.group("port") else default_port try: - return host, int(port_str) + # Parse numeric IP literals into typed address objects; hostnames stay as str. + return ipaddress.ip_address(raw_host), port except ValueError: - raise click.BadParameter(f"Invalid port in {value!r}.", param=param) from None + return raw_host, port def _parse_stun_server(ctx, param, value: str | None) -> tuple[str, int] | None: @@ -96,7 +90,8 @@ def _parse_stun_server(ctx, param, value: str | None) -> tuple[str, int] | None: """ if value is None or value.lower() == "none": return None - return _parse_hostport(ctx, param, value, default_port=3478) + host, port = _parse_hostport(ctx, param, value, default_port=3478) + return str(host), port class ConsoleMessageProtocol(SessionInitiationProtocol): @@ -225,8 +220,7 @@ def sip(ctx, aor, password, username, proxy, stun_server, no_tls, no_verify_tls) else: default_port = SIP_TCP_PORT if parsed_aor.scheme == "sip" else SIP_TLS_PORT port = parsed_aor.port if parsed_aor.port is not None else default_port - # asyncio.create_connection requires a plain str host, not an ipaddress object. - proxy_addr = (str(parsed_aor.host), port) + proxy_addr = (parsed_aor.host, port) use_tls = not no_tls and proxy_addr[1] != SIP_TCP_PORT # Build the canonical AOR; IPv6 hosts must be enclosed in brackets per RFC 2732. @@ -250,7 +244,7 @@ def sip(ctx, aor, password, username, proxy, stun_server, no_tls, no_verify_tls) async def _connect_sip( session_factory, - proxy_addr: tuple[str, int], + proxy_addr: tuple[ipaddress.IPv4Address | ipaddress.IPv6Address | str, int], use_tls: bool, no_verify_tls: bool, ) -> None: @@ -264,7 +258,7 @@ async def _connect_sip( ssl_context.verify_mode = ssl.CERT_NONE await loop.create_connection( session_factory, - host=proxy_addr[0], + host=str(proxy_addr[0]), port=proxy_addr[1], ssl=ssl_context, ) diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 6101a58..513b5eb 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -163,7 +163,7 @@ def call_received(self, request: Request) -> None: #: When ``None`` the caller connects directly to the registrar server. #: The address may differ from the registrar domain derived from #: `aor` (e.g. ``proxy.carrier.com`` vs ``carrier.com``). - outbound_proxy: tuple[str, int] | None = None + outbound_proxy: tuple[ipaddress.IPv4Address | ipaddress.IPv6Address | str, int] | None = None aor: str username: str | None = None password: str | None = None From 7e469a2759e68c61f2eaae4c148209d6f8bc794b Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Thu, 19 Mar 2026 00:37:57 +0100 Subject: [PATCH 4/5] cleanup --- voip/__main__.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/voip/__main__.py b/voip/__main__.py index df01670..b0f4510 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -30,9 +30,6 @@ SIP_TLS_PORT = 5061 -#: Regex that parses ``[IPv6HOST][:PORT]`` or ``HOST[:PORT]`` strings. -#: Named groups: ``ipv6`` (bare address inside brackets) or ``host`` (plain hostname / -#: IPv4 literal), and an optional ``port`` suffix. HOSTPORT_PATTERN: re.Pattern[str] = re.compile( r"^(?:\[(?P[0-9a-fA-F:]+)\]|(?P[^:\[\]]+))" r"(?::(?P\d+))?$" @@ -61,17 +58,16 @@ def _parse_hostport( Raises: click.BadParameter: When value is malformed (unbracketed IPv6 or invalid port). """ - match = HOSTPORT_PATTERN.fullmatch(value) - if not match: + if not (match := HOSTPORT_PATTERN.fullmatch(value)): if value.count(":") > 1: raise click.BadParameter( - f"IPv6 address must be enclosed in brackets, e.g. [{value}].", param=param + f"IPv6 address must be enclosed in brackets, e.g. [{value}].", + param=param, ) raise click.BadParameter(f"Invalid host:port value: {value!r}.", param=param) raw_host = match.group("ipv6") or match.group("host") port = int(match.group("port")) if match.group("port") else default_port try: - # Parse numeric IP literals into typed address objects; hostnames stay as str. return ipaddress.ip_address(raw_host), port except ValueError: return raw_host, port From 5b7b26cb2a3260ef9cab45f1c2c391ec447ae6f1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:38:25 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_main.py | 1 - voip/sip/protocol.py | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 7af952f..ee0d55e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -87,7 +87,6 @@ def test_parse_hostport__bracketed_ipv6_with_port(self): def test_parse_hostport__unbracketed_ipv6_raises_bad_parameter(self): """Raise BadParameter when an unbracketed IPv6 literal is given.""" import click - from voip.__main__ import _parse_hostport with pytest.raises(click.BadParameter, match="enclosed in brackets"): diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 513b5eb..e9fdb1b 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -163,7 +163,9 @@ def call_received(self, request: Request) -> None: #: When ``None`` the caller connects directly to the registrar server. #: The address may differ from the registrar domain derived from #: `aor` (e.g. ``proxy.carrier.com`` vs ``carrier.com``). - outbound_proxy: tuple[ipaddress.IPv4Address | ipaddress.IPv6Address | str, int] | None = None + outbound_proxy: ( + tuple[ipaddress.IPv4Address | ipaddress.IPv6Address | str, int] | None + ) = None aor: str username: str | None = None password: str | None = None