feat: add number library routing and cdr location support

This commit is contained in:
hectorzhao
2026-06-24 11:39:32 +08:00
parent 5fa1bd35e9
commit 7057fd3c42
63 changed files with 6361 additions and 566 deletions
@@ -0,0 +1,388 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import random
import re
import socket
import string
import struct
import sys
import threading
import time
from dataclasses import dataclass
CRLF = "\r\n"
STOP = threading.Event()
PRINT_LOCK = threading.Lock()
def log(message: str) -> None:
with PRINT_LOCK:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}", flush=True)
def token(length: int = 8) -> str:
alphabet = string.ascii_lowercase + string.digits
return "".join(random.choice(alphabet) for _ in range(length))
def header(message: str, name: str) -> str:
match = re.search(rf"^{re.escape(name)}\s*:\s*(.+)$", message, re.I | re.M)
return match.group(1).strip() if match else ""
def status(message: str) -> str:
return message.splitlines()[0] if message else "NO RESPONSE"
def call_id(index: int) -> str:
return f"s40-{index:02d}-{int(time.time() * 1000)}-{token()}@lisglosips-t"
def sdp(host: str, port: int) -> str:
return (
"v=0\r\n"
f"o=lisglosips-s40 0 0 IN IP4 {host}\r\n"
"s=lisglosips-s40\r\n"
f"c=IN IP4 {host}\r\n"
"t=0 0\r\n"
f"m=audio {port} RTP/AVP 0 8 101\r\n"
"a=rtpmap:0 PCMU/8000\r\n"
"a=rtpmap:8 PCMA/8000\r\n"
"a=rtpmap:101 telephone-event/8000\r\n"
)
def parse_sdp_media(message: str) -> tuple[str, int] | None:
body = message.split("\r\n\r\n", 1)[1] if "\r\n\r\n" in message else ""
media_host = ""
media_port = 0
for raw_line in body.splitlines():
line = raw_line.strip()
if line.startswith("c=IN IP4 "):
media_host = line.split()[-1]
elif line.startswith("m=audio "):
parts = line.split()
if len(parts) >= 2 and parts[1].isdigit():
media_port = int(parts[1])
if media_host and media_port:
return media_host, media_port
return None
def send_pcmu_rtp(target: tuple[str, int], duration: float, bind_host: str = "0.0.0.0") -> None:
payload = b"\xff" * 160
seq = random.randint(0, 65535)
timestamp = random.randint(0, 2**32 - 1)
ssrc = random.randint(1, 2**32 - 1)
deadline = time.time() + duration
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.bind((bind_host, 0))
while time.time() < deadline and not STOP.is_set():
rtp_header = struct.pack("!BBHII", 0x80, 0x00, seq, timestamp, ssrc)
sock.sendto(rtp_header + payload, target)
seq = (seq + 1) % 65536
timestamp = (timestamp + 160) % 2**32
time.sleep(0.02)
def response(code: int, reason: str, request: str, body: str = "", to_tag: str | None = None) -> str:
to_header = header(request, "To")
if "tag=" not in to_header:
to_header = f"{to_header};tag={to_tag or 's40uas' + token(6)}"
lines = [
f"SIP/2.0 {code} {reason}",
f"Via: {header(request, 'Via')}",
f"From: {header(request, 'From')}",
f"To: {to_header}",
f"Call-ID: {header(request, 'Call-ID')}",
f"CSeq: {header(request, 'CSeq')}",
"Server: lisglosips-s40-uas",
]
if body:
lines.extend(["Content-Type: application/sdp", f"Content-Length: {len(body.encode())}", "", body])
else:
lines.extend(["Content-Length: 0", "", ""])
return CRLF.join(lines)
def extract_callee(request: str) -> str:
first = request.splitlines()[0] if request else ""
match = re.search(r"sip:([^@;>\s]+)", first)
return match.group(1) if match else ""
def scenario_index(callee: str, callee_base: str) -> int:
if callee.startswith(callee_base):
suffix = callee[len(callee_base):]
if suffix.isdigit():
return int(suffix)
digits = re.sub(r"\D", "", callee)
return int(digits[-2:]) if digits else 0
def call_index(call: str) -> int:
match = re.search(r"s40-(\d{2})-", call)
return int(match.group(1)) if match else 0
@dataclass(frozen=True)
class UasConfig:
host: str
port: int
media_host: str
media_port_base: int
answer_count: int
answer_ring: float
no_answer_ring: float
callee_base: str
caller_prefix: str
def extract_user_from_header(value: str) -> str:
match = re.search(r"sip:([^@;>\s]+)", value)
return match.group(1) if match else ""
def handle_invite(sock: socket.socket, request: str, addr: tuple[str, int], config: UasConfig) -> None:
call = header(request, "Call-ID")
callee = extract_callee(request)
caller = extract_user_from_header(header(request, "From"))
index = call_index(call) or scenario_index(callee, config.callee_base) or scenario_index(caller, config.caller_prefix)
should_answer = 1 <= index <= config.answer_count
ring_seconds = config.answer_ring if should_answer else config.no_answer_ring
to_tag = f"s40uas{index:02d}{token(4)}"
sock.sendto(response(100, "Trying", request, to_tag=to_tag).encode(), addr)
sock.sendto(response(180, "Ringing", request, to_tag=to_tag).encode(), addr)
log(f"uas call_id={call} caller={caller} callee={callee} scenario={'answer' if should_answer else 'no-answer'} ringing={ring_seconds}s")
time.sleep(ring_seconds)
if STOP.is_set():
return
if not should_answer:
sock.sendto(response(480, "Temporarily Unavailable", request, to_tag=to_tag).encode(), addr)
log(f"uas call_id={call} final=480 Temporarily Unavailable")
return
media_port = config.media_port_base + max(index, 1)
body = sdp(config.media_host, media_port)
sock.sendto(response(200, "OK", request, body, to_tag=to_tag).encode(), addr)
media = parse_sdp_media(request)
if media:
threading.Thread(target=send_pcmu_rtp, args=(media, 720.0, config.host), daemon=True).start()
log(f"uas call_id={call} final=200 OK media_port={media_port}")
def run_uas(config: UasConfig) -> None:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((config.host, config.port))
sock.settimeout(1.0)
log(f"uas listening {config.host}:{config.port}")
while not STOP.is_set():
try:
data, addr = sock.recvfrom(65535)
except socket.timeout:
continue
request = data.decode(errors="replace")
method = request.split(" ", 1)[0]
if method == "INVITE":
threading.Thread(target=handle_invite, args=(sock, request, addr, config), daemon=True).start()
elif method == "BYE":
sock.sendto(response(200, "OK", request).encode(), addr)
log(f"uas call_id={header(request, 'Call-ID')} bye=200 OK")
@dataclass(frozen=True)
class InviteConfig:
index: int
host: str
port: int
bind_host: str
contact_host: str
caller: str
callee: str
to_domain: str
media_host: str
media_port: int
timeout: float
hold: float
def build_in_dialog(method: str, config: InviteConfig, cid: str, from_tag: str, to_header: str, cseq: int, route: str, local_port: int) -> str:
lines = [
f"{method} sip:{config.callee}@{config.to_domain} SIP/2.0",
f"Via: SIP/2.0/UDP {config.contact_host}:{local_port};branch=z9hG4bK-{token(10)};rport",
"Max-Forwards: 70",
f"From: <sip:{config.caller}@s40.customer.local>;tag={from_tag}",
f"To: {to_header}",
f"Call-ID: {cid}",
f"CSeq: {cseq} {method}",
f"Contact: <sip:{config.caller}@{config.contact_host}:{local_port}>",
]
if route:
lines.append(f"Route: {route}")
lines.extend(["Content-Length: 0", "", ""])
return CRLF.join(lines)
def run_invite(config: InviteConfig) -> None:
cid = call_id(config.index)
from_tag = token()
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.bind((config.bind_host, 0))
local_port = sock.getsockname()[1]
sock.settimeout(config.timeout)
body = sdp(config.media_host, config.media_port)
request = CRLF.join(
[
f"INVITE sip:{config.callee}@{config.to_domain} SIP/2.0",
f"Via: SIP/2.0/UDP {config.contact_host}:{local_port};branch=z9hG4bK-{token(10)};rport",
"Max-Forwards: 70",
f"From: <sip:{config.caller}@s40.customer.local>;tag={from_tag}",
f"To: <sip:{config.callee}@{config.to_domain}>",
f"Call-ID: {cid}",
"CSeq: 1 INVITE",
f"Contact: <sip:{config.caller}@{config.contact_host}:{local_port}>",
"User-Agent: lisglosips-s40",
"Content-Type: application/sdp",
f"Content-Length: {len(body.encode())}",
"",
body,
]
)
log(f"uac index={config.index:02d} call_id={cid} invite callee={config.callee}")
sock.sendto(request.encode(), (config.host, config.port))
final = ""
route = ""
to_header = ""
while not STOP.is_set():
try:
message = sock.recvfrom(65535)[0].decode(errors="replace")
except socket.timeout:
break
line = status(message)
log(f"uac index={config.index:02d} call_id={cid} status={line}")
if message.startswith("SIP/2.0 2"):
final = message
route = header(message, "Record-Route") or f"<sip:{config.host}:{config.port};lr>"
to_header = header(message, "To")
break
if message.startswith(("SIP/2.0 3", "SIP/2.0 4", "SIP/2.0 5", "SIP/2.0 6")):
final = message
break
if not final.startswith("SIP/2.0 2"):
log(f"uac index={config.index:02d} call_id={cid} final={status(final)}")
return
ack = build_in_dialog("ACK", config, cid, from_tag, to_header, 1, route, local_port)
sock.sendto(ack.encode(), (config.host, config.port))
media = parse_sdp_media(final)
rtp_thread = None
if media:
rtp_thread = threading.Thread(target=send_pcmu_rtp, args=(media, config.hold, config.contact_host), daemon=True)
rtp_thread.start()
log(f"uac index={config.index:02d} call_id={cid} connected hold={config.hold}s")
deadline = time.time() + config.hold
while time.time() < deadline and not STOP.is_set():
time.sleep(min(1.0, deadline - time.time()))
if rtp_thread:
rtp_thread.join(timeout=1.0)
bye = build_in_dialog("BYE", config, cid, from_tag, to_header, 2, route, local_port)
sock.sendto(bye.encode(), (config.host, config.port))
try:
bye_response = sock.recvfrom(65535)[0].decode(errors="replace")
except socket.timeout:
bye_response = ""
log(f"uac index={config.index:02d} call_id={cid} bye_status={status(bye_response)}")
def run(args: argparse.Namespace) -> int:
uas_config = UasConfig(
host=args.uas_host,
port=args.uas_port,
media_host=args.media_host,
media_port_base=args.uas_media_port_base,
answer_count=args.answer_count,
answer_ring=args.answer_ring,
no_answer_ring=args.no_answer_ring,
callee_base=args.callee_base,
caller_prefix=args.caller_prefix,
)
uas_thread = threading.Thread(target=run_uas, args=(uas_config,), daemon=True)
uas_thread.start()
time.sleep(1.0)
workers: list[threading.Thread] = []
for index in range(1, args.total + 1):
callee = args.callee or f"{args.callee_base}{index:02d}"
config = InviteConfig(
index=index,
host=args.host,
port=args.port,
bind_host=args.bind_host,
contact_host=args.contact_host,
caller=args.caller or f"{args.caller_prefix}{index:02d}",
callee=callee,
to_domain=args.to_domain,
media_host=args.media_host,
media_port=args.uac_media_port_base + index,
timeout=args.invite_timeout,
hold=args.hold,
)
worker = threading.Thread(target=run_invite, args=(config,), daemon=True)
worker.start()
workers.append(worker)
if index < args.total:
time.sleep(args.interval)
for worker in workers:
worker.join()
STOP.set()
log("scenario finished")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="LisgloSIPS S40 10-way virtual call scenario")
parser.add_argument("--host", default="100.90.90.90", help="OpenSIPS A host")
parser.add_argument("--port", type=int, default=15060, help="OpenSIPS A SIP port")
parser.add_argument("--bind-host", default="0.0.0.0")
parser.add_argument("--contact-host", default="100.93.185.30")
parser.add_argument("--to-domain", default="100.90.90.90:15060")
parser.add_argument("--media-host", default="100.93.185.30")
parser.add_argument("--uas-host", default="100.93.185.30")
parser.add_argument("--uas-port", type=int, default=50620)
parser.add_argument("--total", type=int, default=10)
parser.add_argument("--interval", type=float, default=15.0)
parser.add_argument("--answer-count", type=int, default=5)
parser.add_argument("--answer-ring", type=float, default=45.0)
parser.add_argument("--no-answer-ring", type=float, default=70.0)
parser.add_argument("--hold", type=float, default=600.0)
parser.add_argument("--invite-timeout", type=float, default=90.0)
parser.add_argument("--callee-base", default="13800140")
parser.add_argument("--callee", default="13800136036", help="Fixed called number. Empty string enables callee-base + index.")
parser.add_argument("--caller-prefix", default="s40-100")
parser.add_argument("--caller", default="s36-1001", help="Fixed caller. Empty string enables caller-prefix + index.")
parser.add_argument("--uac-media-port-base", type=int, default=33100)
parser.add_argument("--uas-media-port-base", type=int, default=34100)
args = parser.parse_args()
if args.total < 1:
parser.error("--total must be >= 1")
if args.answer_count > args.total:
parser.error("--answer-count must be <= --total")
try:
return run(args)
except KeyboardInterrupt:
STOP.set()
log("interrupted")
return 130
if __name__ == "__main__":
sys.exit(main())