2026-09-24 01:10:49 +03:00
|
|
|
#!/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: <sip:{user}@{host}>;tag={from_tag}',
|
|
|
|
|
f"To: {to_header or f'<sip:{target}@{host}>'}",
|
|
|
|
|
f"Call-ID: {call_id}",
|
|
|
|
|
f"CSeq: {cseq} {method}",
|
|
|
|
|
f"Contact: <sip:{user}@127.0.0.1:{local_port};transport=udp>",
|
|
|
|
|
"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(
|
|
|
|
|
[
|
2026-09-28 21:14:38 +00:00
|
|
|
"docker", "compose", "exec", "-T", "sip", "cat", "/var/lib/lct-sip/credentials.env",
|
2026-09-24 01:10:49 +03:00
|
|
|
],
|
|
|
|
|
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())
|