#!/usr/bin/env python3 """Local SIP/RTP acceptance check for the bundled Asterisk service. The check registers extension 6001, calls the Echo application at 7000 and measures round-trip time for PCMU RTP packets. It intentionally uses only the Python standard library so the offline delivery does not need a SIP test tool. """ from __future__ import annotations import argparse import hashlib import json import os import random import re import socket import statistics import struct import subprocess import time import uuid from pathlib import Path CRLF = "\r\n" def md5(value: str) -> str: return hashlib.md5(value.encode("utf-8")).hexdigest() # noqa: S324 - SIP Digest requires MD5 def parse_message(data: bytes) -> tuple[str, dict[str, str], str]: text = data.decode("utf-8", "replace") head, _, body = text.partition(CRLF + CRLF) lines = head.split(CRLF) headers: dict[str, str] = {} for line in lines[1:]: if ":" in line: key, value = line.split(":", 1) headers[key.strip().lower()] = value.strip() return lines[0], headers, body def digest_header(challenge: str, user: str, password: str, method: str, uri: str) -> str: parsed = {key: quoted or plain for key, quoted, plain in re.findall( r'(\w+)=(?:"([^"]*)"|([^,\s]+))', challenge )} realm = parsed["realm"] nonce = parsed["nonce"] response_parts = [md5(f"{user}:{realm}:{password}"), nonce] attrs = [ f'username="{user}"', f'realm="{realm}"', f'nonce="{nonce}"', f'uri="{uri}"', ] qop = parsed.get("qop", "").split(",")[0] if qop: nc = "00000001" cnonce = uuid.uuid4().hex[:16] response_parts.extend([nc, cnonce, qop]) attrs.extend([f"qop={qop}", f"nc={nc}", f'cnonce="{cnonce}"']) response_parts.append(md5(f"{method}:{uri}")) attrs.extend([f'response="{md5(":".join(response_parts))}"', "algorithm=MD5"]) return "Digest " + ", ".join(attrs) def request( method: str, uri: str, host: str, local_port: int, user: str, call_id: str, cseq: int, branch: str, *, authorization: str | None = None, body: str = "", to_user: str | None = None, from_tag: str | None = None, to_header: str | None = None, ) -> bytes: target = to_user or user from_tag = from_tag or uuid.uuid4().hex[:10] headers = [ f"{method} {uri} SIP/2.0", f"Via: SIP/2.0/UDP 127.0.0.1:{local_port};branch={branch};rport", "Max-Forwards: 70", f'From: ;tag={from_tag}', f"To: {to_header or f''}", f"Call-ID: {call_id}", f"CSeq: {cseq} {method}", f"Contact: ", "User-Agent: LCT-offline-smoke/1.0", ] if authorization: headers.append(f"Authorization: {authorization}") if body: headers.append("Content-Type: application/sdp") headers.extend([f"Content-Length: {len(body.encode())}", "", body]) return CRLF.join(headers).encode() def receive_final(sock: socket.socket, timeout: float = 3.0) -> tuple[str, dict[str, str], str]: deadline = time.monotonic() + timeout last = None while time.monotonic() < deadline: sock.settimeout(max(0.05, deadline - time.monotonic())) parsed = parse_message(sock.recvfrom(65535)[0]) last = parsed status = parsed[0] if status.startswith("SIP/2.0") and not status.startswith("SIP/2.0 1"): return parsed raise TimeoutError(f"No final SIP response; last={last and last[0]}") def compose_password(user: str) -> str: result = subprocess.run( [ "docker", "compose", "exec", "-T", "sip", "cat", "/var/lib/lct-sip/credentials.env", ], check=True, capture_output=True, text=True, ) prefix = f"SIP_{user}_PASSWORD=" for line in result.stdout.splitlines(): if line.startswith(prefix): return line[len(prefix):] raise RuntimeError(f"No password for SIP extension {user}") def send_ack( sock: socket.socket, host: str, port: int, user: str, call_id: str, cseq: int, target: str, from_tag: str, to_header: str | None = None, ) -> None: payload = request( "ACK", f"sip:{target}@{host}", host, sock.getsockname()[1], user, call_id, cseq, f"z9hG4bK{uuid.uuid4().hex}", to_user=target, from_tag=from_tag, to_header=to_header, ) sock.sendto(payload, (host, port)) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=5060) parser.add_argument("--rtp-host-offset", type=int, default=0, help="Host-to-container RTP port offset (1000 for 11000:10000 mappings)") parser.add_argument("--user", default="6001") parser.add_argument("--password", default=os.getenv("SIP_PASSWORD")) parser.add_argument("--packets", type=int, default=30) parser.add_argument("--output", type=Path) args = parser.parse_args() password = args.password or compose_password(args.user) sip = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sip.bind(("127.0.0.1", 0)) sip_port = sip.getsockname()[1] registrar = f"sip:{args.host}" register_id = f"{uuid.uuid4().hex}@lct-smoke" packet = request("REGISTER", registrar, args.host, sip_port, args.user, register_id, 1, f"z9hG4bK{uuid.uuid4().hex}") sip.sendto(packet, (args.host, args.port)) status, headers, _ = receive_final(sip) if "401" not in status: raise RuntimeError(f"Expected REGISTER challenge, got {status}") challenge = headers.get("www-authenticate") if not challenge: raise RuntimeError("REGISTER challenge has no WWW-Authenticate header") auth = digest_header(challenge, args.user, password, "REGISTER", registrar) packet = request("REGISTER", registrar, args.host, sip_port, args.user, register_id, 2, f"z9hG4bK{uuid.uuid4().hex}", authorization=auth) sip.sendto(packet, (args.host, args.port)) status, _, _ = receive_final(sip) if "200" not in status: raise RuntimeError(f"REGISTER failed: {status}") rtp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) rtp.bind(("0.0.0.0", 0)) rtp.settimeout(1.0) rtp_port = rtp.getsockname()[1] sdp = CRLF.join([ "v=0", f"o=lct 1 1 IN IP4 host.docker.internal", "s=LCT RTP smoke", "c=IN IP4 host.docker.internal", "t=0 0", f"m=audio {rtp_port} RTP/AVP 0", "a=rtpmap:0 PCMU/8000", "a=sendrecv", "", ]) call_id = f"{uuid.uuid4().hex}@lct-smoke" from_tag = uuid.uuid4().hex[:10] invite_uri = f"sip:7000@{args.host}" invite_cseq = 1 packet = request("INVITE", invite_uri, args.host, sip_port, args.user, call_id, invite_cseq, f"z9hG4bK{uuid.uuid4().hex}", body=sdp, to_user="7000", from_tag=from_tag) sip.sendto(packet, (args.host, args.port)) status, headers, _ = receive_final(sip) if "401" in status or "407" in status: send_ack(sip, args.host, args.port, args.user, call_id, invite_cseq, "7000", from_tag, headers.get("to")) challenge = headers.get("www-authenticate") or headers.get("proxy-authenticate") if not challenge: raise RuntimeError("INVITE challenge has no authentication header") invite_cseq += 1 auth = digest_header(challenge, args.user, password, "INVITE", invite_uri) packet = request("INVITE", invite_uri, args.host, sip_port, args.user, call_id, invite_cseq, f"z9hG4bK{uuid.uuid4().hex}", authorization=auth, body=sdp, to_user="7000", from_tag=from_tag) sip.sendto(packet, (args.host, args.port)) status, answer_headers, answer_sdp = receive_final(sip) else: answer_headers = headers answer_sdp = "" if "200" not in status: raise RuntimeError(f"INVITE failed: {status}") send_ack(sip, args.host, args.port, args.user, call_id, invite_cseq, "7000", from_tag, answer_headers.get("to")) match = re.search(r"^m=audio\s+(\d+)", answer_sdp, re.MULTILINE) if not match: raise RuntimeError("SIP 200 response contains no RTP audio port") asterisk_rtp_port = int(match.group(1)) target = (args.host, asterisk_rtp_port + args.rtp_host_offset) ssrc = random.getrandbits(32) sequence = random.getrandbits(16) timestamp = random.getrandbits(32) samples: list[float] = [] # Prime symmetric RTP, then measure matched echoed sequence numbers. for index in range(args.packets + 3): seq = (sequence + index) & 0xFFFF packet = struct.pack("!BBHII", 0x80, 0x00, seq, (timestamp + index * 160) & 0xFFFFFFFF, ssrc) marker = struct.pack("!I", index) packet += marker + b"\xff" * 156 started = time.perf_counter() rtp.sendto(packet, target) deadline = started + 0.5 while time.perf_counter() < deadline: try: response, _ = rtp.recvfrom(2048) except socket.timeout: break if len(response) >= 16 and response[12:16] == marker: if index >= 3: samples.append((time.perf_counter() - started) * 1000) break rtp.settimeout(max(0.001, deadline - time.perf_counter())) rtp.settimeout(0.5) time.sleep(0.02) if not samples: raise RuntimeError(f"No echoed RTP received from {target}") ordered = sorted(samples) p95 = ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))] result = { "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "sip_registration": "ok", "sip_echo_call": "ok", "rtp_packets_received": len(samples), "rtp_rtt_ms_median": round(statistics.median(samples), 3), "rtp_rtt_ms_p95": round(p95, 3), "requirement_ms": 150, "passed": p95 <= 150, } bye = request( "BYE", invite_uri, args.host, sip_port, args.user, call_id, invite_cseq + 1, f"z9hG4bK{uuid.uuid4().hex}", to_user="7000", from_tag=from_tag, to_header=answer_headers.get("to"), ) sip.sendto(bye, (args.host, args.port)) bye_status, _, _ = receive_final(sip) result["sip_hangup"] = "ok" if "200" in bye_status else bye_status result["passed"] = result["passed"] and result["sip_hangup"] == "ok" rendered = json.dumps(result, ensure_ascii=False, indent=2) print(rendered) if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(rendered + "\n", encoding="utf-8") return 0 if result["passed"] else 1 if __name__ == "__main__": raise SystemExit(main())