#!/usr/bin/env python3 """ SIP server (UDP + TCP port 5060). Parses SIP REGISTER and INVITE messages, logs credentials from the Authorization header and call metadata, then responds with 401 Unauthorized. """ import asyncio import os import re from syslog_bridge import ( classify_authorization, forward_syslog, syslog_line, write_syslog_file, ) NODE_NAME = os.environ.get("NODE_NAME", "pbx") SERVICE_NAME = "sip" LOG_TARGET = os.environ.get("LOG_TARGET", "") _401 = ( "SIP/2.0 401 Unauthorized\r\n" "Via: {via}\r\n" "From: {from_}\r\n" "To: {to}\r\n" "Call-ID: {call_id}\r\n" "CSeq: {cseq}\r\n" 'WWW-Authenticate: Digest realm="{host}", nonce="{nonce}", algorithm=MD5\r\n' "Content-Length: 0\r\n\r\n" ) def _log(event_type: str, severity: int = 6, **kwargs) -> None: line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) write_syslog_file(line) forward_syslog(line, LOG_TARGET) def _parse_headers(msg: str) -> dict: headers = {} for line in msg.splitlines()[1:]: if ":" in line: k, _, v = line.partition(":") headers[k.strip().lower()] = v.strip() return headers def _handle_message(data: bytes, src_addr) -> bytes | None: try: msg = data.decode(errors="replace") except Exception: return None first_line = msg.splitlines()[0] if msg else "" method = first_line.split()[0] if first_line else "UNKNOWN" headers = _parse_headers(msg) auth_header = headers.get("authorization", "") username = "" if auth_header: m = re.search(r'username="([^"]+)"', auth_header) username = m.group(1) if m else "" # SIP Digest is the same shape as HTTP Digest (RFC 7616 derived from # RFC 2617). classify_authorization handles it identically — emits # secret_kind="http_digest_md5", which is correct: the cred is the # MD5 hash response, regardless of whether it rode in over SIP or # HTTP. Reuse-analytics correlates across both. cred = classify_authorization(auth_header) _log( "request", src=src_addr[0], src_port=src_addr[1], method=method, from_=headers.get("from", ""), to=headers.get("to", ""), username=username, auth=auth_header[:256], **(cred or {}), ) if method in ("REGISTER", "INVITE", "OPTIONS"): nonce = os.urandom(8).hex() response = _401.format( via=headers.get("via", ""), from_=headers.get("from", ""), to=headers.get("to", ""), call_id=headers.get("call-id", ""), cseq=headers.get("cseq", ""), host=NODE_NAME, nonce=nonce, ) return response.encode() return None class SIPUDPProtocol(asyncio.DatagramProtocol): def __init__(self): self._transport = None def connection_made(self, transport): self._transport = transport def datagram_received(self, data, addr): response = _handle_message(data, addr) if response and self._transport: self._transport.sendto(response, addr) class SIPTCPProtocol(asyncio.Protocol): def __init__(self): self._transport = None self._peer = None self._buf = b"" def connection_made(self, transport): self._transport = transport self._peer = transport.get_extra_info("peername", ("?", 0)) def data_received(self, data): self._buf += data if b"\r\n\r\n" in self._buf or b"\n\n" in self._buf: response = _handle_message(self._buf, self._peer) self._buf = b"" if response: self._transport.write(response) def connection_lost(self, exc): pass async def main(): _log("startup", msg=f"SIP server starting as {NODE_NAME}") loop = asyncio.get_running_loop() udp_transport, _ = await loop.create_datagram_endpoint( SIPUDPProtocol, local_addr=("0.0.0.0", 5060) # nosec B104 ) tcp_server = await loop.create_server(SIPTCPProtocol, "0.0.0.0", 5060) # nosec B104 async with tcp_server: await tcp_server.serve_forever() udp_transport.close() if __name__ == "__main__": asyncio.run(main())