rtpbench 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. rtpbench/.dockerignore +4 -0
  2. rtpbench/Dockerfile +12 -0
  3. rtpbench/__init__.py +6 -0
  4. rtpbench/__main__.py +3 -0
  5. rtpbench/agent.py +746 -0
  6. rtpbench/analyze.py +520 -0
  7. rtpbench/bargein.py +272 -0
  8. rtpbench/bench.py +1069 -0
  9. rtpbench/caller.py +781 -0
  10. rtpbench/capture.py +33 -0
  11. rtpbench/carrier/compose.yaml +68 -0
  12. rtpbench/carrier/opensips.cfg +226 -0
  13. rtpbench/carrier/tls.cfg +25 -0
  14. rtpbench/carrier/tls.yaml +9 -0
  15. rtpbench/cases.py +1989 -0
  16. rtpbench/check.py +369 -0
  17. rtpbench/cli.py +616 -0
  18. rtpbench/clock.py +174 -0
  19. rtpbench/compose.yaml +55 -0
  20. rtpbench/corpus/README.md +55 -0
  21. rtpbench/corpus/cough-c1-0.wav +0 -0
  22. rtpbench/corpus/cough-c1-1.wav +0 -0
  23. rtpbench/corpus/cough-c2-0.wav +0 -0
  24. rtpbench/corpus/cough-c2-1.wav +0 -0
  25. rtpbench/corpus/cough-man-0.wav +0 -0
  26. rtpbench/corpus/cough-man-1.wav +0 -0
  27. rtpbench/corpus/cough-short-0.wav +0 -0
  28. rtpbench/corpus/cough-short-1.wav +0 -0
  29. rtpbench/corpus/cough-woman_three_times-0.wav +0 -0
  30. rtpbench/corpus/human-en-dr-0.wav +0 -0
  31. rtpbench/corpus/human-en-dr-1.wav +0 -0
  32. rtpbench/corpus/human-en-sc-0.wav +0 -0
  33. rtpbench/corpus/human-en-sc-1.wav +0 -0
  34. rtpbench/corpus/human-en-sc-2.wav +0 -0
  35. rtpbench/corpus/human-es-f012-0.wav +0 -0
  36. rtpbench/corpus/human-es-f012-1.wav +0 -0
  37. rtpbench/corpus/human-es-f012-2.wav +0 -0
  38. rtpbench/corpus/human-es-f016-0.wav +0 -0
  39. rtpbench/corpus/human-es-f016-1.wav +0 -0
  40. rtpbench/corpus/human-es-f016-2.wav +0 -0
  41. rtpbench/corpus/lines/en/interrupt-00.wav +0 -0
  42. rtpbench/corpus/lines/en/interrupt-01.wav +0 -0
  43. rtpbench/corpus/lines/en/interrupt-02.wav +0 -0
  44. rtpbench/corpus/lines/en/interrupt-03.wav +0 -0
  45. rtpbench/corpus/lines/en/prompt-00.wav +0 -0
  46. rtpbench/corpus/lines/en/prompt-01.wav +0 -0
  47. rtpbench/corpus/lines/en/prompt-02.wav +0 -0
  48. rtpbench/corpus/lines/en/prompt-03.wav +0 -0
  49. rtpbench/corpus/lines/en/prompt-04.wav +0 -0
  50. rtpbench/corpus/lines/en/prompt-05.wav +0 -0
  51. rtpbench/corpus/lines/en/prompt-06.wav +0 -0
  52. rtpbench/corpus/lines/en/prompt-07.wav +0 -0
  53. rtpbench/corpus/lines/en/prompt-08.wav +0 -0
  54. rtpbench/corpus/lines/en/prompt-09.wav +0 -0
  55. rtpbench/corpus/lines/en/prompt.wav +0 -0
  56. rtpbench/corpus/lines/en/transfer.wav +0 -0
  57. rtpbench/corpus/lines/en/turn-00.wav +0 -0
  58. rtpbench/corpus/lines/en/turn-01.wav +0 -0
  59. rtpbench/corpus/lines/en/turn-02.wav +0 -0
  60. rtpbench/corpus/lines/en/turn-03.wav +0 -0
  61. rtpbench/corpus/lines/en/turn-04.wav +0 -0
  62. rtpbench/corpus/lines/en/turn-05.wav +0 -0
  63. rtpbench/corpus/lines/en/turn-06.wav +0 -0
  64. rtpbench/corpus/lines/en/turn-07.wav +0 -0
  65. rtpbench/corpus/lines/en/turn-08.wav +0 -0
  66. rtpbench/corpus/lines/en/turn-09.wav +0 -0
  67. rtpbench/corpus/lines/en/turn-10.wav +0 -0
  68. rtpbench/corpus/lines/en/turn-11.wav +0 -0
  69. rtpbench/corpus/tts-ald-bc.wav +0 -0
  70. rtpbench/corpus/tts-ald-long.wav +0 -0
  71. rtpbench/corpus/tts-ald-s03.wav +0 -0
  72. rtpbench/corpus/tts-ald-s07.wav +0 -0
  73. rtpbench/corpus/tts-ald-s11.wav +0 -0
  74. rtpbench/corpus/tts-cori-bc.wav +0 -0
  75. rtpbench/corpus/tts-cori-long.wav +0 -0
  76. rtpbench/corpus/tts-cori-s01.wav +0 -0
  77. rtpbench/corpus/tts-cori-s05.wav +0 -0
  78. rtpbench/corpus/tts-cori-s09.wav +0 -0
  79. rtpbench/corpus/tts-davefx-bc.wav +0 -0
  80. rtpbench/corpus/tts-davefx-long.wav +0 -0
  81. rtpbench/corpus/tts-davefx-s00.wav +0 -0
  82. rtpbench/corpus/tts-davefx-s04.wav +0 -0
  83. rtpbench/corpus/tts-davefx-s08.wav +0 -0
  84. rtpbench/corpus/tts-libritts0-bc.wav +0 -0
  85. rtpbench/corpus/tts-libritts0-long.wav +0 -0
  86. rtpbench/corpus/tts-libritts0-s02.wav +0 -0
  87. rtpbench/corpus/tts-libritts0-s06.wav +0 -0
  88. rtpbench/corpus/tts-libritts0-s10.wav +0 -0
  89. rtpbench/corpus/tts-libritts3-bc.wav +0 -0
  90. rtpbench/corpus/tts-libritts3-long.wav +0 -0
  91. rtpbench/corpus/tts-libritts3-s03.wav +0 -0
  92. rtpbench/corpus/tts-libritts3-s07.wav +0 -0
  93. rtpbench/corpus/tts-libritts3-s11.wav +0 -0
  94. rtpbench/corpus/tts-ljspeech-bc.wav +0 -0
  95. rtpbench/corpus/tts-ljspeech-long.wav +0 -0
  96. rtpbench/corpus/tts-ljspeech-s00.wav +0 -0
  97. rtpbench/corpus/tts-ljspeech-s04.wav +0 -0
  98. rtpbench/corpus/tts-ljspeech-s08.wav +0 -0
  99. rtpbench/corpus/tts-sharvardf-bc.wav +0 -0
  100. rtpbench/corpus/tts-sharvardf-long.wav +0 -0
  101. rtpbench/corpus/tts-sharvardf-s02.wav +0 -0
  102. rtpbench/corpus/tts-sharvardf-s06.wav +0 -0
  103. rtpbench/corpus/tts-sharvardf-s10.wav +0 -0
  104. rtpbench/corpus/tts-sharvardm-bc.wav +0 -0
  105. rtpbench/corpus/tts-sharvardm-long.wav +0 -0
  106. rtpbench/corpus/tts-sharvardm-s01.wav +0 -0
  107. rtpbench/corpus/tts-sharvardm-s05.wav +0 -0
  108. rtpbench/corpus/tts-sharvardm-s09.wav +0 -0
  109. rtpbench/detect.py +145 -0
  110. rtpbench/g711.py +82 -0
  111. rtpbench/mock/compose.yaml +18 -0
  112. rtpbench/pcap.py +106 -0
  113. rtpbench/progress.py +184 -0
  114. rtpbench/realtime.py +1015 -0
  115. rtpbench/report.py +450 -0
  116. rtpbench/rtp.py +166 -0
  117. rtpbench/sip.py +653 -0
  118. rtpbench/speech.py +100 -0
  119. rtpbench/speechcal.py +367 -0
  120. rtpbench/srtp.py +191 -0
  121. rtpbench/targets/aivc/aivc.ini +25 -0
  122. rtpbench/targets/aivc/compose.yaml +72 -0
  123. rtpbench/targets/aivc/constraints.txt +52 -0
  124. rtpbench/targets/aivc/opensips.cfg +45 -0
  125. rtpbench/targets/ava/ai-agent.yaml +275 -0
  126. rtpbench/targets/ava/ari.conf +7 -0
  127. rtpbench/targets/ava/compose.yaml +128 -0
  128. rtpbench/targets/ava/constraints.txt +75 -0
  129. rtpbench/targets/ava/extensions.conf +10 -0
  130. rtpbench/targets/ava/http.conf +4 -0
  131. rtpbench/targets/ava/pjsip.conf +19 -0
  132. rtpbench/targets/ava/websocket_client.conf +15 -0
  133. rtpbench/targets/livekit/agent.py +111 -0
  134. rtpbench/targets/livekit/compose.yaml +86 -0
  135. rtpbench/targets/livekit/constraints.txt +72 -0
  136. rtpbench/tls.py +284 -0
  137. rtpbench/uri/advertise.yaml +7 -0
  138. rtpbench/uri/network.yaml +14 -0
  139. rtpbench/voice.py +18 -0
  140. rtpbench-0.3.0.dist-info/METADATA +123 -0
  141. rtpbench-0.3.0.dist-info/RECORD +144 -0
  142. rtpbench-0.3.0.dist-info/WHEEL +4 -0
  143. rtpbench-0.3.0.dist-info/entry_points.txt +2 -0
  144. rtpbench-0.3.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,4 @@
1
+ [general]
2
+ enabled=yes
3
+ bindaddr=127.0.0.1
4
+ bindport=8088
@@ -0,0 +1,19 @@
1
+ ; Asterisk for rtpbench: plain SIP over UDP and TCP, any caller, G.711 only
2
+ [udp]
3
+ type=transport
4
+ protocol=udp
5
+ bind=0.0.0.0:5060
6
+
7
+ [tcp]
8
+ type=transport
9
+ protocol=tcp
10
+ bind=0.0.0.0:5060
11
+
12
+ [anonymous]
13
+ type=endpoint
14
+ context=from-caller
15
+ disallow=all
16
+ allow=ulaw,alaw
17
+ direct_media=no
18
+ rtp_symmetric=yes
19
+ dtmf_mode=rfc4733
@@ -0,0 +1,15 @@
1
+ ; AVA_TRANSPORT=websocket: for each call, AVA asks ARI for a WebSocket media channel on this
2
+ ; client and Asterisk connects to the engine's listener, on loopback as they share a network
3
+ ; namespace (stanza as in AVA's docs/WebSocket-Transport.md; the password is the engine's
4
+ ; ASTERISK_MEDIA_WS_PASSWORD, a test value like ARI's)
5
+ [aava_media]
6
+ type = websocket_client
7
+ uri = ws://127.0.0.1:8787/media
8
+ protocols = media
9
+ username = aava_media
10
+ password = asterisk
11
+ connection_type = per_call_config
12
+ connection_timeout = 500
13
+ reconnect_interval = 500
14
+ reconnect_attempts = 5
15
+ tls_enabled = no
@@ -0,0 +1,111 @@
1
+ """rtpbench's LiveKit target: a LiveKit Agents worker on the OpenAI Realtime model through Azure
2
+ OpenAI (`RealtimeModel.with_azure`, as LiveKit's Azure docs set it), on the structure of LiveKit's
3
+ starter agent, set up as targets/aivc sets AI Voice Connector CE: the same instructions, welcome
4
+ message, voice and tool descriptions, transfers to TRANSFER_TO (LiveKit's documented cold
5
+ transfer, a SIP REFER). Before the worker starts, an inbound trunk for the number rtpbench
6
+ dials (from any address, no auth: LiveKit wants a number, addresses or auth) and a dispatch rule
7
+ that puts each call in its own room, as LiveKit's SIP docs create them.
8
+
9
+ Env: AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_OPENAI_DEPLOYMENT, TRANSFER_TO,
10
+ LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET.
11
+ """
12
+
13
+ import asyncio
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from livekit import api, rtc
19
+ from livekit.agents import (
20
+ Agent,
21
+ AgentServer,
22
+ AgentSession,
23
+ JobContext,
24
+ RunContext,
25
+ cli,
26
+ function_tool,
27
+ get_job_context,
28
+ )
29
+ from livekit.agents.beta import EndCallTool
30
+ from livekit.plugins import openai
31
+
32
+ NUMBER = "agent" # the Request-URI user rtpbench calls (bench.TARGETS)
33
+ # the compose healthcheck's: the server can dispatch to this worker. Its own health check answers
34
+ # once its HTTP server is up, seconds before it registers; a call in between gets a room no
35
+ # worker ever joins, and rings unanswered
36
+ REGISTERED = Path("/tmp/registered")
37
+
38
+
39
+ class Assistant(Agent):
40
+ def __init__(self) -> None:
41
+ super().__init__(instructions="You are a helpful assistant.", tools=[EndCallTool()])
42
+
43
+ async def on_enter(self) -> None:
44
+ self.session.generate_reply(
45
+ instructions="Please greet the user with the following: Hello! How can I help you?"
46
+ )
47
+
48
+ @function_tool()
49
+ async def transfer_call(self, context: RunContext) -> str:
50
+ """call the function if a request was received to transfer a call with an operator, a
51
+ person"""
52
+ job = get_job_context()
53
+ caller = next(
54
+ p
55
+ for p in job.room.remote_participants.values()
56
+ if p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP
57
+ )
58
+ request = api.TransferSIPParticipantRequest(
59
+ room_name=job.room.name,
60
+ participant_identity=caller.identity,
61
+ transfer_to=os.environ["TRANSFER_TO"],
62
+ )
63
+ try:
64
+ await job.api.sip.transfer_sip_participant(request)
65
+ except Exception as e:
66
+ return f"transfer failed: {e}"
67
+ return "transferred"
68
+
69
+
70
+ server = AgentServer()
71
+
72
+
73
+ @server.on("worker_registered")
74
+ def registered(worker_id: str, info: object) -> None:
75
+ REGISTERED.touch()
76
+
77
+
78
+ @server.rtc_session()
79
+ async def entrypoint(ctx: JobContext) -> None:
80
+ session: AgentSession = AgentSession(
81
+ llm=openai.realtime.RealtimeModel.with_azure(
82
+ azure_deployment=os.environ["AZURE_OPENAI_DEPLOYMENT"], voice="alloy"
83
+ )
84
+ )
85
+ await session.start(room=ctx.room, agent=Assistant())
86
+
87
+
88
+ async def setup() -> None:
89
+ async with api.LiveKitAPI() as lk:
90
+ for _ in range(60): # the server may still be starting
91
+ try:
92
+ await lk.sip.list_inbound_trunk(api.ListSIPInboundTrunkRequest())
93
+ break
94
+ except Exception:
95
+ await asyncio.sleep(1)
96
+ await lk.sip.create_inbound_trunk(
97
+ api.CreateSIPInboundTrunkRequest(
98
+ trunk=api.SIPInboundTrunkInfo(name="rtpbench", numbers=[NUMBER])
99
+ )
100
+ )
101
+ rule = api.SIPDispatchRule(
102
+ dispatch_rule_individual=api.SIPDispatchRuleIndividual(room_prefix="call-")
103
+ )
104
+ await lk.sip.create_dispatch_rule(api.CreateSIPDispatchRuleRequest(rule=rule))
105
+
106
+
107
+ if __name__ == "__main__":
108
+ if sys.argv[1:] == ["setup"]:
109
+ asyncio.run(setup())
110
+ else:
111
+ cli.run_app(server)
@@ -0,0 +1,86 @@
1
+ # LiveKit (server, SIP bridge, an Agents worker on the OpenAI Realtime model) as a target, merged
2
+ # over ../../compose.yaml. As livekit/sip's docker-compose.yaml runs it (redis, livekit-server
3
+ # --dev, sip; host networking there), but in the network namespace of `target` (agentnet, plus
4
+ # egress to the AI engine), so everything is on 127.0.0.1 as upstream's is. rtpbench routes the
5
+ # caller via the router, so SIP and RTP cross netem. Needs AZURE_OPENAI_ENDPOINT
6
+ # (https://<resource>.openai.azure.com) and AZURE_OPENAI_API_KEY. Paths are relative to the first
7
+ # compose file's directory.
8
+ services:
9
+ target:
10
+ image: ${RTPBENCH_IMAGE:?}
11
+ cap_add: [NET_ADMIN]
12
+ init: true
13
+ depends_on: [router]
14
+ networks: [agentnet, egress]
15
+ volumes: ["livekit-sip:/sip"]
16
+ # upstream's SIP config, except the address in the SDP: the one facing the caller (the
17
+ # router's side), not one found by STUN (use_external_ip: true there)
18
+ command:
19
+ - sh
20
+ - -c
21
+ - >-
22
+ python3 -c 'import socket as s; u = s.socket(2, 2);
23
+ u.connect((s.gethostbyname("router"), 9)); ip = u.getsockname()[0];
24
+ open("/sip/config.yaml", "w").write("api_key: devkey\napi_secret: secret\n"
25
+ "ws_url: ws://localhost:7880\nredis:\n address: localhost:6379\nsip_port: 5060\n"
26
+ "rtp_port: 10000-20000\nnat_1_to_1_ip: " + ip + "\nlogging:\n level: debug\n")'
27
+ && exec sleep infinity
28
+ healthcheck:
29
+ test: test -e /sip/config.yaml
30
+ interval: 1s
31
+ retries: 20
32
+
33
+ redis:
34
+ image: redis:8.2-alpine@sha256:b51665e66f00759be7c3152ad5ac3c66fb2f619c13ef62dea7cc1f9914524635
35
+ network_mode: service:target
36
+ depends_on: [target]
37
+
38
+ livekit:
39
+ image: livekit/livekit-server:v1.13.9@sha256:d0c04791bf63ca8dcea123571827d56ba9504bd57c8d5de60ee03b5408cc3154
40
+ network_mode: service:target
41
+ depends_on: [redis]
42
+ command: --dev --redis-host localhost:6379
43
+
44
+ sip:
45
+ image: livekit/sip:v1.19.0@sha256:4398c3794cfb0c62fd118ffa8fec42f386bf906106e656da011e0961223cb8b8
46
+ network_mode: service:target
47
+ depends_on:
48
+ target: { condition: service_healthy }
49
+ livekit: { condition: service_started }
50
+ volumes: ["livekit-sip:/sip:ro"]
51
+
52
+ agent-worker:
53
+ build:
54
+ context: ./targets/livekit
55
+ dockerfile_inline: |
56
+ FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f
57
+ COPY constraints.txt /tmp/constraints.txt
58
+ RUN pip install --no-cache-dir "livekit-agents[openai]==1.8.7" -c /tmp/constraints.txt
59
+ WORKDIR /app
60
+ COPY agent.py .
61
+ image: ${RTPBENCH_REGISTRY:?}/rtpbench-livekit-agent:1.8.7${RTPBENCH_BUILD_AGENT_WORKER:-}
62
+ network_mode: service:target
63
+ depends_on: [livekit, sip]
64
+ environment:
65
+ LIVEKIT_URL: ws://localhost:7880
66
+ LIVEKIT_API_KEY: devkey
67
+ LIVEKIT_API_SECRET: secret
68
+ AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT:?set AZURE_OPENAI_ENDPOINT}
69
+ AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:?set AZURE_OPENAI_API_KEY}
70
+ AZURE_OPENAI_DEPLOYMENT: ${AZURE_OPENAI_DEPLOYMENT:-gpt-realtime-2.1}
71
+ TRANSFER_TO: sip:operator@operator
72
+ command: sh -c 'python agent.py setup && exec python agent.py start'
73
+ # ready once the worker has registered with the server (agent.py REGISTERED), while its own
74
+ # health check passes (it fails once the worker has lost the server for good)
75
+ healthcheck:
76
+ test: >-
77
+ test -e /tmp/registered &&
78
+ python -c 'import urllib.request as u; u.urlopen("http://127.0.0.1:8081")'
79
+ interval: 1s
80
+ retries: 60
81
+
82
+ networks:
83
+ egress:
84
+
85
+ volumes:
86
+ livekit-sip:
@@ -0,0 +1,72 @@
1
+ aiofiles==25.1.0
2
+ aiohappyeyeballs==2.7.1
3
+ aiohttp==3.14.5
4
+ aiosignal==1.4.0
5
+ annotated-doc==0.0.5
6
+ annotated-types==0.8.0
7
+ anyio==4.15.1
8
+ attrs==26.1.0
9
+ av==19.0.1
10
+ certifi==2026.7.22
11
+ cffi==2.1.1
12
+ charset-normalizer==3.5.2
13
+ click==8.5.0
14
+ colorama==0.4.6
15
+ docstring_parser==0.18.0
16
+ eval_type_backport==0.4.0
17
+ frozenlist==1.8.0
18
+ googleapis-common-protos==1.75.5
19
+ grpcio==1.84.0
20
+ h11==0.16.0
21
+ httpcore2==2.13.1
22
+ httpx2==2.13.1
23
+ idna==3.20
24
+ jiter==0.17.0
25
+ json_repair==0.63.5
26
+ livekit==1.1.20
27
+ livekit-agents==1.8.7
28
+ livekit-api==1.2.2
29
+ livekit-blingfire==1.2.0
30
+ livekit-local-inference==0.2.7
31
+ livekit-plugins-openai==1.8.7
32
+ livekit-protocol==1.1.27
33
+ markdown-it-py==4.2.0
34
+ mdurl==0.1.2
35
+ multidict==7.1.0
36
+ nest-asyncio==1.6.0
37
+ numpy==2.5.4
38
+ openai==3.33.0
39
+ opentelemetry-api==1.45.1
40
+ opentelemetry-exporter-http-transport==0.66b1
41
+ opentelemetry-exporter-otlp==1.45.1
42
+ opentelemetry-exporter-otlp-common==0.66b1
43
+ opentelemetry-exporter-otlp-proto-common==1.45.1
44
+ opentelemetry-exporter-otlp-proto-grpc==1.45.1
45
+ opentelemetry-exporter-otlp-proto-http==1.45.1
46
+ opentelemetry-proto==1.45.1
47
+ opentelemetry-sdk==1.45.1
48
+ opentelemetry-semantic-conventions==0.66b1
49
+ pillow==13.0.0
50
+ prometheus_client==0.26.0
51
+ propcache==0.5.4
52
+ protobuf==7.36.2
53
+ psutil==7.2.2
54
+ pycparser==3.11
55
+ pydantic==2.14.1
56
+ pydantic_core==2.50.1
57
+ Pygments==2.21.0
58
+ PyJWT==2.15.1
59
+ PyYAML==6.0.3
60
+ requests==2.34.2
61
+ rich==15.0.0
62
+ shellingham==1.5.4
63
+ sniffio==1.3.1
64
+ sounddevice==0.5.6
65
+ truststore==0.10.5
66
+ typer==0.27.3
67
+ types-protobuf==7.35.1.20260906
68
+ typing-inspection==0.4.4
69
+ typing_extensions==4.16.0
70
+ urllib3==2.8.0
71
+ watchfiles==1.3.0
72
+ yarl==1.25.1
rtpbench/tls.py ADDED
@@ -0,0 +1,284 @@
1
+ """SIP over TLS read back from a capture: TLS connections in the TCP streams decrypted with the
2
+ caller's key log (the SSLKEYLOGFILE format OpenSSL and Wireshark use), so judging reads the
3
+ messages that crossed the wire at the times they crossed it. TLS 1.3, and TLS 1.2 with the
4
+ AEAD suites the caller offers. Also the stack's test certificates."""
5
+
6
+ import bisect
7
+ import datetime
8
+ import hmac
9
+ import struct
10
+ from pathlib import Path
11
+
12
+ from cryptography import x509
13
+ from cryptography.exceptions import InvalidTag
14
+ from cryptography.hazmat.primitives import hashes, serialization
15
+ from cryptography.hazmat.primitives.asymmetric import ec
16
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305
17
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
18
+ from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
19
+
20
+ from rtpbench import pcap
21
+
22
+ Aead = type[AESGCM] | type[ChaCha20Poly1305]
23
+ # cipher suite: AEAD, key length, hash
24
+ TLS13: dict[int, tuple[Aead, int, str]] = {
25
+ 0x1301: (AESGCM, 16, "sha256"),
26
+ 0x1302: (AESGCM, 32, "sha384"),
27
+ 0x1303: (ChaCha20Poly1305, 32, "sha256"),
28
+ }
29
+ TLS12: dict[int, tuple[Aead, int, str]] = {
30
+ 0xC02B: (AESGCM, 16, "sha256"), # ECDHE-ECDSA-AES128-GCM-SHA256
31
+ 0xC02F: (AESGCM, 16, "sha256"), # ECDHE-RSA-AES128-GCM-SHA256
32
+ 0xC02C: (AESGCM, 32, "sha384"), # ECDHE-ECDSA-AES256-GCM-SHA384
33
+ 0xC030: (AESGCM, 32, "sha384"), # ECDHE-RSA-AES256-GCM-SHA384
34
+ 0xCCA8: (ChaCha20Poly1305, 32, "sha256"), # ECDHE-RSA-CHACHA20-POLY1305
35
+ 0xCCA9: (ChaCha20Poly1305, 32, "sha256"), # ECDHE-ECDSA-CHACHA20-POLY1305
36
+ }
37
+ CIPHERS_12 = "ECDHE+AESGCM:ECDHE+CHACHA20" # what the caller offers below TLS 1.3: AEAD only
38
+ _HASHES = {"sha256": hashes.SHA256, "sha384": hashes.SHA384}
39
+ _HANDSHAKE, _CHANGE_CIPHER_SPEC, _APPLICATION_DATA, _KEY_UPDATE = 22, 20, 23, 24
40
+
41
+ Flow = tuple[str, int, str, int]
42
+ Records = list[tuple[int, int, bytes, bytes]] # offset in the stream, type, header, fragment
43
+
44
+
45
+ def keylog(path: Path) -> dict[tuple[str, bytes], bytes]:
46
+ """(label, client random) -> secret, from a key log."""
47
+ out = {}
48
+ for line in path.read_text().splitlines():
49
+ fields = line.split()
50
+ if len(fields) == 3 and not line.startswith("#"):
51
+ out[fields[0], bytes.fromhex(fields[1])] = bytes.fromhex(fields[2])
52
+ return out
53
+
54
+
55
+ def streams(grams: list[pcap.Datagram], keys: Path | None) -> list[pcap.Datagram]:
56
+ """`pcap.streams(grams)`, but each TLS connection's application data in its place,
57
+ decrypted with the key log `keys`: one piece per record, at the capture time of the
58
+ segment that brought the record's first byte."""
59
+ segs = pcap.streams(grams)
60
+ flows: dict[Flow, tuple[bytearray, list[int], list[int]]] = {} # stream, offsets, times
61
+ for g in segs:
62
+ data, offs, times = flows.setdefault(
63
+ (g.src, g.sport, g.dst, g.dport), (bytearray(), [], [])
64
+ )
65
+ offs.append(len(data))
66
+ times.append(g.t_ns)
67
+ data += g.payload
68
+ clients = [f for f, (data, _, _) in flows.items() if _hello(data)]
69
+ tls = {*clients, *map(_back, clients)}
70
+ out = [g for g in segs if (g.src, g.sport, g.dst, g.dport) not in tls]
71
+ secrets = keylog(keys) if keys and keys.exists() else {}
72
+ for flow in clients:
73
+ back = _back(flow)
74
+ server = bytes(flows[back][0]) if back in flows else b""
75
+ sides = _connection(bytes(flows[flow][0]), server, secrets, flow)
76
+ for f, pieces in zip((flow, back), sides, strict=True):
77
+ for off, plain in pieces:
78
+ _, offs, times = flows[f]
79
+ t = times[bisect.bisect_right(offs, off) - 1]
80
+ out.append(pcap.Datagram(t, *f, plain, True))
81
+ return sorted(out, key=lambda g: g.t_ns)
82
+
83
+
84
+ def _back(flow: Flow) -> Flow:
85
+ return flow[2], flow[3], flow[0], flow[1]
86
+
87
+
88
+ def _hello(data: bytes | bytearray) -> bool:
89
+ """The stream opens with a TLS ClientHello."""
90
+ return len(data) > 5 and data[0] == _HANDSHAKE and data[1] == 3 and data[5] == 1
91
+
92
+
93
+ def _records(data: bytes) -> Records:
94
+ out, pos = [], 0
95
+ while pos + 5 <= len(data):
96
+ length = struct.unpack_from("!H", data, pos + 3)[0]
97
+ if pos + 5 + length > len(data): # cut short: the capture ended inside it
98
+ break
99
+ out.append((pos, data[pos], data[pos : pos + 5], data[pos + 5 : pos + 5 + length]))
100
+ pos += 5 + length
101
+ return out
102
+
103
+
104
+ def _connection(
105
+ client: bytes, server: bytes, secrets: dict[tuple[str, bytes], bytes], flow: Flow
106
+ ) -> tuple[list[tuple[int, bytes]], list[tuple[int, bytes]]]:
107
+ """Application data of each side (stream offset of its record, plaintext)."""
108
+ sent, got = _records(client), _records(server)
109
+ hello = got[0][3] if got and got[0][1] == _HANDSHAKE else b""
110
+ if len(hello) < 39 or hello[0] != 2 or len(hello) < 41 + hello[38]:
111
+ return [], [] # no ServerHello (an alert, or nothing): nothing was ever encrypted
112
+ random = sent[0][3][6:38] # ClientHello: type, length (3), version (2), random
113
+ # ServerHello (`hello`): likewise, then the session id and the cipher suite
114
+ suite = struct.unpack_from("!H", hello, 39 + hello[38])[0]
115
+ where = f"TLS {flow[0]}:{flow[1]} -> {flow[2]}:{flow[3]}"
116
+ if ("CLIENT_TRAFFIC_SECRET_0", random) in secrets:
117
+ if suite not in TLS13:
118
+ raise ValueError(f"{where}: TLS 1.3 cipher suite {suite:#06x} not supported")
119
+
120
+ def side(name: str, records: Records) -> list[tuple[int, bytes]]:
121
+ hs, app = (
122
+ secrets[f"{name}_{s}", random]
123
+ for s in ("HANDSHAKE_TRAFFIC_SECRET", "TRAFFIC_SECRET_0")
124
+ )
125
+ return _open13(records, suite, hs, app, where)
126
+
127
+ return side("CLIENT", sent), side("SERVER", got)
128
+ if (master := secrets.get(("CLIENT_RANDOM", random))) is None:
129
+ raise ValueError(f"{where}: no keys for it in the key log")
130
+ if suite not in TLS12:
131
+ raise ValueError(f"{where}: TLS 1.2 cipher suite {suite:#06x} not supported")
132
+ aead, size, h = TLS12[suite]
133
+ iv = 4 if aead is AESGCM else 12
134
+ block = _prf(h, master, b"key expansion", hello[6:38] + random, 2 * (size + iv))
135
+ keys = block[: 2 * size]
136
+ ivs = block[2 * size :]
137
+ return (
138
+ _open12(sent, aead, keys[:size], ivs[:iv], where),
139
+ _open12(got, aead, keys[size:], ivs[iv:], where),
140
+ )
141
+
142
+
143
+ def _expand(h: str, secret: bytes, label: bytes, length: int) -> bytes:
144
+ """HKDF-Expand-Label (RFC 8446 7.1), with an empty context."""
145
+ full = b"tls13 " + label
146
+ info = struct.pack("!HB", length, len(full)) + full + b"\x00"
147
+ return HKDFExpand(_HASHES[h](), length, info).derive(secret)
148
+
149
+
150
+ def _open13(
151
+ records: Records, suite: int, handshake: bytes, application: bytes, where: str
152
+ ) -> list[tuple[int, bytes]]:
153
+ """One side's records of a TLS 1.3 connection: handshake keys first, then the application
154
+ keys (whichever opens the record; AEAD tags make the choice unambiguous), following any
155
+ KeyUpdate (RFC 8446 7.2)."""
156
+ aead, size, h = TLS13[suite]
157
+
158
+ def keys(secret: bytes) -> tuple[AESGCM | ChaCha20Poly1305, int]:
159
+ return aead(_expand(h, secret, b"key", size)), int.from_bytes(_expand(h, secret, b"iv", 12))
160
+
161
+ (cipher, iv), secret, seq, stage = keys(handshake), handshake, 0, 0
162
+ out = []
163
+ for off, kind, head, frag in records:
164
+ if kind != _APPLICATION_DATA: # hellos and change_cipher_spec, in the clear
165
+ continue
166
+ while True:
167
+ try:
168
+ plain = cipher.decrypt((iv ^ seq).to_bytes(12), frag, head)
169
+ break
170
+ except InvalidTag:
171
+ if stage:
172
+ raise ValueError(f"{where}: a record does not decrypt") from None
173
+ (cipher, iv), secret, seq, stage = keys(application), application, 0, 1
174
+ seq += 1
175
+ plain = plain.rstrip(b"\x00") # padding, then the real content type
176
+ inner, plain = plain[-1], plain[:-1]
177
+ if inner == _APPLICATION_DATA:
178
+ out.append((off, plain))
179
+ elif inner == _HANDSHAKE and stage and plain[:1] == bytes([_KEY_UPDATE]):
180
+ secret = _expand(h, secret, b"traffic upd", _HASHES[h]().digest_size)
181
+ (cipher, iv), seq = keys(secret), 0
182
+ return out
183
+
184
+
185
+ def _prf(h: str, secret: bytes, label: bytes, seed: bytes, length: int) -> bytes:
186
+ """The TLS 1.2 PRF (RFC 5246 5)."""
187
+ seed, a, out = label + seed, label + seed, b""
188
+ while len(out) < length:
189
+ a = hmac.digest(secret, a, h)
190
+ out += hmac.digest(secret, a + seed, h)
191
+ return out[:length]
192
+
193
+
194
+ def _open12(
195
+ records: Records, aead: Aead, key: bytes, iv: bytes, where: str
196
+ ) -> list[tuple[int, bytes]]:
197
+ """One side's records of a TLS 1.2 connection, encrypted from its ChangeCipherSpec on."""
198
+ cipher, seq, on, out = aead(key), 0, False, []
199
+ for off, kind, head, frag in records:
200
+ if kind == _CHANGE_CIPHER_SPEC:
201
+ on = True
202
+ continue
203
+ if not on:
204
+ continue
205
+ if aead is AESGCM: # RFC 5288: 4 bytes of the key block, 8 sent with each record
206
+ nonce, body = iv + frag[:8], frag[8:]
207
+ else: # RFC 7905
208
+ nonce, body = (int.from_bytes(iv) ^ seq).to_bytes(12), frag
209
+ aad = struct.pack("!QB", seq, kind) + head[1:3] + struct.pack("!H", len(body) - 16)
210
+ try:
211
+ plain = cipher.decrypt(nonce, body, aad)
212
+ except InvalidTag:
213
+ raise ValueError(f"{where}: a record does not decrypt") from None
214
+ seq += 1
215
+ if kind == _APPLICATION_DATA:
216
+ out.append((off, plain))
217
+ return out
218
+
219
+
220
+ def certificates(directory: Path, names: list[str]) -> None:
221
+ """A test CA (stack-ca.pem) and one certificate it signs for every name (server.crt, with
222
+ the CA, and server.key), valid for two days: the stack's TLS servers present it."""
223
+ directory.mkdir(parents=True, exist_ok=True)
224
+ now = datetime.datetime.now(datetime.UTC)
225
+ ca_key, key = ec.generate_private_key(ec.SECP256R1()), ec.generate_private_key(ec.SECP256R1())
226
+ ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "rtpbench test CA")])
227
+ ski = x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key())
228
+
229
+ def cert(subject: x509.Name, public: ec.EllipticCurvePublicKey) -> x509.CertificateBuilder:
230
+ return (
231
+ x509.CertificateBuilder()
232
+ .subject_name(subject)
233
+ .issuer_name(ca_name)
234
+ .public_key(public)
235
+ .serial_number(x509.random_serial_number())
236
+ .not_valid_before(now - datetime.timedelta(minutes=5))
237
+ .not_valid_after(now + datetime.timedelta(days=2))
238
+ .add_extension(x509.SubjectKeyIdentifier.from_public_key(public), critical=False)
239
+ )
240
+
241
+ ca = (
242
+ cert(ca_name, ca_key.public_key())
243
+ .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
244
+ .add_extension(_usage(cert_sign=True), critical=True)
245
+ .sign(ca_key, hashes.SHA256())
246
+ )
247
+ leaf = (
248
+ cert(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, names[0])]), key.public_key())
249
+ .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
250
+ .add_extension(_usage(cert_sign=False), critical=True)
251
+ .add_extension(
252
+ x509.ExtendedKeyUsage(
253
+ [ExtendedKeyUsageOID.SERVER_AUTH, ExtendedKeyUsageOID.CLIENT_AUTH]
254
+ ),
255
+ critical=False,
256
+ )
257
+ .add_extension(
258
+ x509.SubjectAlternativeName([x509.DNSName(n) for n in names]), critical=False
259
+ )
260
+ .add_extension(
261
+ x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski), critical=False
262
+ )
263
+ .sign(ca_key, hashes.SHA256())
264
+ )
265
+ pem = serialization.Encoding.PEM
266
+ (directory / "stack-ca.pem").write_bytes(ca.public_bytes(pem))
267
+ (directory / "server.crt").write_bytes(leaf.public_bytes(pem) + ca.public_bytes(pem))
268
+ (directory / "server.key").write_bytes(
269
+ key.private_bytes(pem, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
270
+ )
271
+
272
+
273
+ def _usage(cert_sign: bool) -> x509.KeyUsage:
274
+ return x509.KeyUsage(
275
+ digital_signature=not cert_sign,
276
+ content_commitment=False,
277
+ key_encipherment=False,
278
+ data_encipherment=False,
279
+ key_agreement=False,
280
+ key_cert_sign=cert_sign,
281
+ crl_sign=cert_sign,
282
+ encipher_only=False,
283
+ decipher_only=False,
284
+ )
@@ -0,0 +1,7 @@
1
+ # `rtpbench target URI --advertise HOST:PORT`, merged over ../compose.yaml: the host publishes the
2
+ # caller's port range on the router, which forwards it to the caller (RTPBENCH_PORTS: first-last).
3
+ services:
4
+ router:
5
+ ports:
6
+ - "${RTPBENCH_PORTS:?}:${RTPBENCH_PORTS:?}/udp"
7
+ - "${RTPBENCH_PORTS:?}:${RTPBENCH_PORTS:?}/tcp"
@@ -0,0 +1,14 @@
1
+ # `rtpbench target URI --network NAME`, merged over ../compose.yaml: the router also joins an
2
+ # existing Docker network, where the agent runs (Docker drops traffic between its networks
3
+ # otherwise). agentnet stays the router's way out whatever the networks are called.
4
+ services:
5
+ router:
6
+ networks:
7
+ callnet: {}
8
+ agentnet: {gw_priority: 1}
9
+ user: {}
10
+
11
+ networks:
12
+ user:
13
+ external: true
14
+ name: ${RTPBENCH_NETWORK:?}
rtpbench/voice.py ADDED
@@ -0,0 +1,18 @@
1
+ """Synthetic voiced audio with a crisp onset, and the energy gate both ends agree on."""
2
+
3
+ import math
4
+
5
+ RATE = 8000
6
+ GATE = 1000 # |sample| on the 16-bit scale; the synthetic voice never dips below it for 20 ms
7
+
8
+
9
+ def voice(seconds: float, f0: float, amplitude: int = 12000) -> list[int]:
10
+ """Eight cosine harmonics under a 4 Hz syllable envelope; sample 0 is the waveform's peak."""
11
+ norm = sum(1 / h for h in range(1, 9))
12
+ out = []
13
+ for i in range(round(seconds * RATE)):
14
+ t = i / RATE
15
+ env = 0.6 + 0.4 * math.cos(2 * math.pi * 4 * t)
16
+ x = sum(math.cos(2 * math.pi * h * f0 * t) / h for h in range(1, 9)) / norm
17
+ out.append(round(amplitude * env * x))
18
+ return out