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
3 changes: 3 additions & 0 deletions docs/feature_roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ SIP User Agent Client (UAC) over TLS/TCP ([RFC 3261]). Handles incoming
`INVITE`, `BYE`, `ACK`, `CANCEL`, and `OPTIONS` requests, carrier
`REGISTER` with digest authentication ([RFC 8760]: MD5, SHA-256,
SHA-512/256), and double-CRLF keepalive ping/pong ([RFC 5626 §4.4.1]).
Client-initiated keepalive pings, `Supported: outbound` and `;ob` Contact
parameter ([RFC 5626 §5]), and automatic reconnection with exponential
back-off ensure robust long-running sessions.

Comment thread
codingjoe marked this conversation as resolved.
### Media Transport (RTP/SRTP)

Expand Down
16 changes: 8 additions & 8 deletions docs/rfc_status.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

## SIP Signaling

| RFC | Title | Status | Notes |
| --------------------------------------------------------- | --------------------------------------------- | -------- | ----------------------------------------------------------------------- |
| [RFC 3261](https://datatracker.ietf.org/doc/html/rfc3261) | SIP: Session Initiation Protocol | Partial | UAC only; REGISTER, INVITE, BYE, and digest authentication over TLS/TCP |
| [RFC 5626](https://datatracker.ietf.org/doc/html/rfc5626) | Managing Client-Initiated Connections in SIP | Partial | Double-CRLF keepalive ping/pong (§4.4.1) |
| [RFC 8760](https://datatracker.ietf.org/doc/html/rfc8760) | SIP Digest Authentication Using AES-HMAC-SHA2 | Complete | MD5, SHA-256, and SHA-512/256 digest responses |
| [RFC 3824](https://datatracker.ietf.org/doc/html/rfc3824) | Using E.164 Numbers with SIP | Planned | Phone number mapping into SIP/ENUM |
| [RFC 3966](https://datatracker.ietf.org/doc/html/rfc3966) | The tel URI for Telephone Numbers | Planned | Canonical `tel:` URI scheme |
| [RFC 6116](https://datatracker.ietf.org/doc/html/rfc6116) | The E.164 to URI DDDS Application (ENUM) | Planned | DNS-based E.164 number-to-URI mapping |
| RFC | Title | Status | Notes |
| --------------------------------------------------------- | --------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [RFC 3261](https://datatracker.ietf.org/doc/html/rfc3261) | SIP: Session Initiation Protocol | Partial | UAC only; REGISTER, INVITE, BYE, and digest authentication over TLS/TCP |
| [RFC 5626](https://datatracker.ietf.org/doc/html/rfc5626) | Managing Client-Initiated Connections in SIP | Complete | Double-CRLF keepalive ping/pong (§4.4.1); client keepalive task; `Supported: outbound` and `;ob` Contact parameter (§5); reconnect with exponential back-off |
| [RFC 8760](https://datatracker.ietf.org/doc/html/rfc8760) | SIP Digest Authentication Using AES-HMAC-SHA2 | Complete | MD5, SHA-256, and SHA-512/256 digest responses |
| [RFC 3824](https://datatracker.ietf.org/doc/html/rfc3824) | Using E.164 Numbers with SIP | Planned | Phone number mapping into SIP/ENUM |
| [RFC 3966](https://datatracker.ietf.org/doc/html/rfc3966) | The tel URI for Telephone Numbers | Planned | Canonical `tel:` URI scheme |
| [RFC 6116](https://datatracker.ietf.org/doc/html/rfc6116) | The E.164 to URI DDDS Application (ENUM) | Planned | DNS-based E.164 number-to-URI mapping |

## Media Transport

Expand Down
110 changes: 103 additions & 7 deletions tests/sip/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import dataclasses
import datetime
import hashlib
import ipaddress
import re
Expand Down Expand Up @@ -1651,6 +1652,75 @@ def call_received(self, request):
await asyncio.sleep(0.05)
assert len(protocol._sent) == 1

async def test_run_keepalive__sends_double_crlf(self):
"""Keep-alive task sends double-CRLF after the configured interval."""
protocol = SIP(
outbound_proxy=("127.0.0.1", 5061),
aor="sip:test@example.com",
rtp_stun_server_address=None,
keepalive_interval=datetime.timedelta(seconds=0.01),
)
transport = make_mock_transport()
protocol.connection_made(transport)
transport.write.reset_mock()
await asyncio.sleep(0.05)
transport.write.assert_any_call(b"\r\n\r\n")
protocol._keepalive_task.cancel()
protocol._initialize_task.cancel()

async def test_run_keepalive__stops_when_transport_cleared(self):
"""Keep-alive loop exits cleanly when the transport is set to None."""
protocol = SIP(
outbound_proxy=("127.0.0.1", 5061),
aor="sip:test@example.com",
rtp_stun_server_address=None,
keepalive_interval=datetime.timedelta(seconds=0.01),
)
transport = make_mock_transport()
protocol.connection_made(transport)
transport.write.reset_mock()
protocol.transport = None
await asyncio.sleep(0.05)
transport.write.assert_not_called()
protocol._initialize_task.cancel()

async def test_connection_lost__cancels_and_clears_keepalive_task(self):
"""connection_lost cancels the keepalive task and clears _keepalive_task."""
protocol = SIP(
outbound_proxy=("127.0.0.1", 5061),
aor="sip:test@example.com",
rtp_stun_server_address=None,
)

async def _long_running() -> None:
await asyncio.sleep(100)

task = asyncio.get_running_loop().create_task(_long_running())
protocol._keepalive_task = task
protocol.connection_lost(None)
assert protocol._keepalive_task is None
await asyncio.sleep(0)
assert task.done()

async def test_connection_lost__sets_disconnected_event(self):
"""connection_lost sets the disconnected_event."""
protocol = SIP(outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com")
assert not protocol.disconnected_event.is_set()
protocol.connection_lost(None)
assert protocol.disconnected_event.is_set()

async def test_disconnected_event__resolves_after_connection_lost(self):
"""disconnected_event resolves once connection_lost is called."""
protocol = SIP(outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com")

async def lose_connection() -> None:
await asyncio.sleep(0.01)
protocol.connection_lost(None)

asyncio.create_task(lose_connection())
await asyncio.wait_for(protocol.disconnected_event.wait(), timeout=1.0)
assert protocol.disconnected_event.is_set()


# ---------------------------------------------------------------------------
# Tests for SIP REGISTER / digest-auth / response handling
Expand Down Expand Up @@ -1759,9 +1829,11 @@ async def test_register__includes_required_headers(self):
(data,) = transport.write.call_args[0]
assert b"From: sip:alice@example.com" in data
assert b"To: sip:alice@example.com" in data
# sip: AOR over TLS → sip:;transport=tls Contact
assert b"Contact: <sip:alice@127.0.0.1:5061;transport=tls>" in data
# sip: AOR over TLS → sip:;transport=tls Contact with RFC 5626 ;ob parameter
assert b"Contact: <sip:alice@127.0.0.1:5061;transport=tls;ob>" in data
assert b"Expires: 3600" in data
# RFC 5626 §5 outbound keep-alive support advertised
assert b"Supported: outbound" in data

async def test_register__increments_cseq(self):
"""CSeq increments with each REGISTER sent."""
Expand Down Expand Up @@ -1936,26 +2008,26 @@ async def test_register__via_branch_is_unique_per_request(self):
assert branch1 != branch2

async def test_register__contact_uses_local_addr(self):
"""Contact header uses sip:;transport=tls when AOR is sip: over TLS."""
"""Contact header uses sip:;transport=tls;ob when AOR is sip: over TLS."""
p = make_register_session()
p.local_address = (ipaddress.IPv4Address("10.0.0.5"), 5061)
transport = make_mock_transport("10.0.0.5", 5061)
p.transport = transport
p._is_tls = True
await p.register()
(data,) = transport.write.call_args[0]
assert b"Contact: <sip:alice@10.0.0.5:5061;transport=tls>" in data
assert b"Contact: <sip:alice@10.0.0.5:5061;transport=tls;ob>" in data

async def test_register__contact_uses_sips_when_aor_is_sips(self):
"""Contact header uses sips: when AOR scheme is sips:."""
"""Contact header uses sips: with ;ob when AOR scheme is sips:."""
p = make_register_session(aor="sips:alice@example.com")
p.local_address = (ipaddress.IPv4Address("10.0.0.5"), 5061)
transport = make_mock_transport("10.0.0.5", 5061)
p.transport = transport
p._is_tls = True
await p.register()
(data,) = transport.write.call_args[0]
assert b"Contact: <sips:alice@10.0.0.5:5061>" in data
assert b"Contact: <sips:alice@10.0.0.5:5061;ob>" in data

async def test_register__contact_wraps_ipv6_in_brackets(self):
"""Contact header wraps an IPv6 local address in square brackets."""
Expand All @@ -1966,7 +2038,7 @@ async def test_register__contact_wraps_ipv6_in_brackets(self):
p._is_tls = True
await p.register()
(data,) = transport.write.call_args[0]
assert b"Contact: <sips:alice@[2001:db8::1]:5061>" in data
assert b"Contact: <sips:alice@[2001:db8::1]:5061;ob>" in data

async def test_register__via_wraps_ipv6_in_brackets(self):
"""Via header wraps an IPv6 local address in square brackets."""
Expand Down Expand Up @@ -2085,6 +2157,30 @@ async def test_response_received__unexpected_status__raises_registration_error(
("192.0.2.2", 5060),
)

def test_build_contact__default__no_ob_param(self):
"""Contact without ob=True has no ;ob parameter."""
p = make_register_session()
p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061)
p._is_tls = True
contact = p._build_contact("alice")
assert ";ob" not in contact

def test_build_contact__ob_true__includes_ob_uri_param(self):
"""Contact with ob=True includes the ;ob URI parameter (RFC 5626 §5)."""
p = make_register_session()
p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061)
p._is_tls = True
assert p._build_contact("alice", ob=True) == (
"<sip:alice@127.0.0.1:5061;transport=tls;ob>"
)

def test_build_contact__sips_with_ob__includes_ob_before_closing_bracket(self):
"""sips: Contact with ob=True places ;ob inside the angle brackets."""
p = make_register_session(aor="sips:alice@example.com")
p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061)
p._is_tls = True
assert p._build_contact("alice", ob=True) == "<sips:alice@127.0.0.1:5061;ob>"


# ---------------------------------------------------------------------------
# Tests for digest_response (RFC 3261 §22, RFC 8760)
Expand Down
68 changes: 68 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,74 @@ async def _run_whisper():

asyncio.run(_run_whisper())

def test_transcribe__retries_on_os_error(self):
"""_connect_sip retries when the outbound connection fails with OSError."""
from unittest.mock import AsyncMock

call_count = 0

async def fake_connection(factory, *, host, port, ssl):
nonlocal call_count
call_count += 1
if call_count < 3:
raise OSError("Connection refused")
raise KeyboardInterrupt

with (
patch.dict(sys.modules, _WHISPER_STUBS),
patch("asyncio.get_event_loop"),
patch("voip.__main__.asyncio.get_running_loop") as mock_loop,
patch("voip.__main__.asyncio.sleep", new=AsyncMock()),
):
mock_loop.return_value.create_connection = fake_connection
make_runner().invoke(
voip,
[
"sip",
"--password=p",
"--stun-server=none",
"sips:alice@example.com",
"transcribe",
],
catch_exceptions=False,
)
assert call_count == 3

def test_transcribe__reconnects_after_disconnect(self):
"""_connect_sip reconnects when the disconnected_event is set."""
from unittest.mock import AsyncMock

call_count = 0

async def fake_connection(factory, *, host, port, ssl):
nonlocal call_count
call_count += 1
protocol = factory()
if call_count == 1:
protocol.disconnected_event.set()
return MagicMock(), protocol
raise KeyboardInterrupt

with (
patch.dict(sys.modules, _WHISPER_STUBS),
patch("asyncio.get_event_loop"),
patch("voip.__main__.asyncio.get_running_loop") as mock_loop,
patch("voip.__main__.asyncio.sleep", new=AsyncMock()),
):
mock_loop.return_value.create_connection = fake_connection
make_runner().invoke(
voip,
[
"sip",
"--password=p",
"--stun-server=none",
"sips:alice@example.com",
"transcribe",
],
catch_exceptions=False,
)
assert call_count == 2


class TestAgentCLI:
def test_agent__sips_aor_uses_tls(self):
Expand Down
35 changes: 27 additions & 8 deletions voip/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
) from e


logger = logging.getLogger("voip")


#: Standard SIP/TCP port — plain text, no TLS (RFC 3261 §18.2).
SIP_TCP_PORT = 5060
#: Standard SIP/TLS port (RFC 3261 §26.2.2).
Expand Down Expand Up @@ -244,21 +247,37 @@ async def _connect_sip(
use_tls: bool,
no_verify_tls: bool,
) -> None:
"""Connect to a SIP proxy and wait indefinitely."""
"""Connect to a SIP proxy with automatic reconnection on failure.

Retries with exponential back-off (1 s → 2 s → … → 60 s) after each
failed connection or dropped session so the process stays running without
manual intervention.
"""
loop = asyncio.get_running_loop()
ssl_context: ssl.SSLContext | None = None
if use_tls:
ssl_context = ssl.create_default_context()
if no_verify_tls:
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
await loop.create_connection(
session_factory,
host=str(proxy_addr[0]),
port=proxy_addr[1],
ssl=ssl_context,
)
await asyncio.Future()
backoff_secs = 1
while True:
try:
_, protocol = await loop.create_connection(
session_factory,
host=str(proxy_addr[0]),
port=proxy_addr[1],
ssl=ssl_context,
)
backoff_secs = 1
await protocol.disconnected_event.wait()
logger.info("SIP connection closed; reconnecting in %s s", backoff_secs)
except (OSError, ssl.SSLError) as exc:
logger.warning(
"SIP connection failed (%s); retrying in %s s", exc, backoff_secs
)
await asyncio.sleep(backoff_secs)
backoff_secs = min(backoff_secs * 2, 60)


Comment thread
codingjoe marked this conversation as resolved.
@sip.command()
Expand Down
Loading
Loading