siphon-sip 1.0.0__tar.gz
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.
- siphon_sip-1.0.0/.gitignore +18 -0
- siphon_sip-1.0.0/PKG-INFO +314 -0
- siphon_sip-1.0.0/README.md +288 -0
- siphon_sip-1.0.0/pyproject.toml +53 -0
- siphon_sip-1.0.0/siphon_sdk/__init__.py +34 -0
- siphon_sip-1.0.0/siphon_sdk/call.py +589 -0
- siphon_sip-1.0.0/siphon_sdk/mock_module.py +5636 -0
- siphon_sip-1.0.0/siphon_sdk/py.typed +0 -0
- siphon_sip-1.0.0/siphon_sdk/reply.py +341 -0
- siphon_sip-1.0.0/siphon_sdk/request.py +1012 -0
- siphon_sip-1.0.0/siphon_sdk/sdp.py +728 -0
- siphon_sip-1.0.0/siphon_sdk/srs.py +201 -0
- siphon_sip-1.0.0/siphon_sdk/testing.py +822 -0
- siphon_sip-1.0.0/siphon_sdk/types.py +274 -0
- siphon_sip-1.0.0/tests/test_b2bua_default.py +71 -0
- siphon_sip-1.0.0/tests/test_call_dial.py +82 -0
- siphon_sip-1.0.0/tests/test_ims_scripts.py +588 -0
- siphon_sip-1.0.0/tests/test_ims_ue_b2bua.py +88 -0
- siphon_sip-1.0.0/tests/test_inline_script.py +192 -0
- siphon_sip-1.0.0/tests/test_ipsec_pending_sa.py +85 -0
- siphon_sip-1.0.0/tests/test_li_mock.py +198 -0
- siphon_sip-1.0.0/tests/test_on_cancel.py +93 -0
- siphon_sip-1.0.0/tests/test_presence_mock.py +101 -0
- siphon_sip-1.0.0/tests/test_proxy_default.py +109 -0
- siphon_sip-1.0.0/tests/test_qos_mock.py +172 -0
- siphon_sip-1.0.0/tests/test_registrar_alias_chain.py +85 -0
- siphon_sip-1.0.0/tests/test_registration_aka.py +93 -0
- siphon_sip-1.0.0/tests/test_reply_reject.py +72 -0
- siphon_sip-1.0.0/tests/test_request_consumed_routes.py +103 -0
- siphon_sip-1.0.0/tests/test_request_reply_headers.py +118 -0
- siphon_sip-1.0.0/tests/test_rf_charging.py +187 -0
- siphon_sip-1.0.0/tests/test_rtpengine_media.py +189 -0
- siphon_sip-1.0.0/tests/test_save_proxy.py +109 -0
- siphon_sip-1.0.0/tests/test_sbi_bsf.py +93 -0
- siphon_sip-1.0.0/tests/test_sdp.py +404 -0
- siphon_sip-1.0.0/tests/test_stir_mock.py +152 -0
- siphon_sip-1.0.0/tests/test_timer_api.py +114 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/target
|
|
2
|
+
fuzz/target/
|
|
3
|
+
sbom.spdx.json
|
|
4
|
+
sbom.cdx.json
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.pyc
|
|
7
|
+
*.log
|
|
8
|
+
.venv/
|
|
9
|
+
sdk/.venv/
|
|
10
|
+
sdk/dist/
|
|
11
|
+
sdk/*.egg-info/
|
|
12
|
+
.claude/
|
|
13
|
+
CLAUDE.md
|
|
14
|
+
|
|
15
|
+
# Private deployment configs
|
|
16
|
+
|
|
17
|
+
# Private test fixtures
|
|
18
|
+
sipp/ipsec/
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: siphon-sip
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Mock library and type stubs for SIPhon SIP proxy/B2BUA scripts — enables unit testing and LLM-assisted script authoring
|
|
5
|
+
Project-URL: Homepage, https://github.com/siphon-project/siphon-sip
|
|
6
|
+
Project-URL: Documentation, https://github.com/siphon-project/siphon-sip/tree/main/sdk
|
|
7
|
+
Project-URL: Repository, https://github.com/siphon-project/siphon-sip
|
|
8
|
+
Author: SIPhon Authors
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: b2bua,ims,mock,proxy,sip,testing,voip
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Telecommunications Industry
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Communications :: Telephony
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing :: Mocking
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<img src="https://raw.githubusercontent.com/siphon-project/siphon-sip/main/assets/logo.svg" alt="SIPhon" width="100">
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
# siphon-sip
|
|
32
|
+
|
|
33
|
+
Mock library and type stubs for [SIPhon](https://github.com/siphon-project/siphon-sip) scripts — enables unit testing without the Rust binary and provides rich context for LLM-assisted script authoring.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install siphon-sip
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
> The PyPI distribution is **`siphon-sip`** (matching the `siphon-sip` crate). The import package is still **`siphon_sdk`** — `from siphon_sdk import …`.
|
|
42
|
+
|
|
43
|
+
## What is SIPhon?
|
|
44
|
+
|
|
45
|
+
SIPhon is a high-performance SIP proxy, B2BUA, and IMS platform written in Rust with Python scripting. Scripts use decorators to handle SIP events:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from siphon import proxy, registrar, auth, log
|
|
49
|
+
|
|
50
|
+
@proxy.on_request
|
|
51
|
+
def route(request):
|
|
52
|
+
if request.method == "REGISTER":
|
|
53
|
+
if not auth.require_digest(request, realm="example.com"):
|
|
54
|
+
return
|
|
55
|
+
registrar.save(request)
|
|
56
|
+
request.reply(200, "OK")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
contacts = registrar.lookup(request.ruri)
|
|
60
|
+
if not contacts:
|
|
61
|
+
request.reply(404, "Not Found")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
request.record_route()
|
|
65
|
+
request.fork([c.uri for c in contacts])
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
This SDK lets you **test** these scripts with pytest — no Rust binary needed.
|
|
69
|
+
|
|
70
|
+
## Quick start
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from siphon_sdk.testing import SipTestHarness
|
|
74
|
+
from siphon_sdk.types import Contact
|
|
75
|
+
|
|
76
|
+
harness = SipTestHarness(local_domains=["example.com"])
|
|
77
|
+
harness.load_script("scripts/proxy_default.py")
|
|
78
|
+
|
|
79
|
+
# Pre-populate the registrar
|
|
80
|
+
harness.registrar.add_contact(
|
|
81
|
+
"sip:alice@example.com",
|
|
82
|
+
Contact(uri="sip:alice@192.168.1.5:5060"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Test REGISTER challenge
|
|
86
|
+
result = harness.send_request("REGISTER", "sip:alice@example.com",
|
|
87
|
+
from_uri="sip:alice@example.com")
|
|
88
|
+
assert result.status_code == 401 # digest challenge
|
|
89
|
+
|
|
90
|
+
# Test INVITE routing
|
|
91
|
+
result = harness.send_request("INVITE", "sip:alice@example.com")
|
|
92
|
+
assert result.action == "fork"
|
|
93
|
+
assert "sip:alice@192.168.1.5:5060" in result.targets
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Testing B2BUA scripts
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
harness = SipTestHarness()
|
|
100
|
+
harness.load_script("scripts/b2bua_default.py")
|
|
101
|
+
|
|
102
|
+
harness.registrar.add_contact(
|
|
103
|
+
"sip:bob@example.com",
|
|
104
|
+
Contact(uri="sip:bob@10.0.0.2:5060"),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
result = harness.send_invite(ruri="sip:bob@example.com")
|
|
108
|
+
assert result.action == "fork"
|
|
109
|
+
assert result.targets == ["sip:bob@10.0.0.2:5060"]
|
|
110
|
+
|
|
111
|
+
# Test BYE handling
|
|
112
|
+
result = harness.send_bye(initiator_side="a")
|
|
113
|
+
assert result.was_terminated
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Inline scripts
|
|
117
|
+
|
|
118
|
+
Test scripts without separate files:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
harness = SipTestHarness()
|
|
122
|
+
harness.load_source("""
|
|
123
|
+
from siphon import proxy
|
|
124
|
+
|
|
125
|
+
@proxy.on_request
|
|
126
|
+
def route(request):
|
|
127
|
+
if request.source_ip_in(["10.0.0.0/8"]):
|
|
128
|
+
request.relay()
|
|
129
|
+
else:
|
|
130
|
+
request.reply(403, "Forbidden")
|
|
131
|
+
""")
|
|
132
|
+
|
|
133
|
+
result = harness.send_request("INVITE", "sip:bob@host", source_ip="10.1.2.3")
|
|
134
|
+
assert result.was_relayed
|
|
135
|
+
|
|
136
|
+
result = harness.send_request("INVITE", "sip:bob@host", source_ip="8.8.8.8")
|
|
137
|
+
assert result.status_code == 403
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Async handlers + RTPEngine
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
harness = SipTestHarness()
|
|
144
|
+
harness.load_source("""
|
|
145
|
+
from siphon import proxy, rtpengine
|
|
146
|
+
|
|
147
|
+
@proxy.on_request
|
|
148
|
+
async def route(request):
|
|
149
|
+
if request.method == "INVITE" and request.body:
|
|
150
|
+
await rtpengine.offer(request, profile="srtp_to_rtp")
|
|
151
|
+
request.relay()
|
|
152
|
+
""")
|
|
153
|
+
|
|
154
|
+
result = harness.send_request("INVITE", "sip:bob@host",
|
|
155
|
+
body=b"v=0\\r\\n...",
|
|
156
|
+
content_type="application/sdp")
|
|
157
|
+
assert result.was_relayed
|
|
158
|
+
assert harness.rtpengine.operations == [("offer", "srtp_to_rtp")]
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Controlling mock behavior
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
# Auth: allow or deny all
|
|
165
|
+
harness.auth._allow = True # all auth checks pass
|
|
166
|
+
|
|
167
|
+
# Rate limiting
|
|
168
|
+
harness.proxy._utils._rate_limit_allow = False # simulate overload
|
|
169
|
+
|
|
170
|
+
# Cache: pre-populate
|
|
171
|
+
harness.cache.set_data("cnam", {"key": "value"})
|
|
172
|
+
|
|
173
|
+
# Registrar: add contacts directly
|
|
174
|
+
harness.registrar.add_contact("sip:alice@host", Contact(uri="sip:alice@1.2.3.4"))
|
|
175
|
+
|
|
176
|
+
# Log: inspect captured messages
|
|
177
|
+
assert any("error" in msg for level, msg in harness.log.messages)
|
|
178
|
+
|
|
179
|
+
# Reset between tests
|
|
180
|
+
harness.reset()
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Result assertions
|
|
184
|
+
|
|
185
|
+
`RequestResult` provides convenient properties:
|
|
186
|
+
|
|
187
|
+
| Property | Description |
|
|
188
|
+
|----------|-------------|
|
|
189
|
+
| `.action` | Primary action: `"reply"`, `"relay"`, `"fork"`, `"silent_drop"` |
|
|
190
|
+
| `.status_code` | SIP status code (200, 401, 404, etc.) |
|
|
191
|
+
| `.reason` | Reason phrase |
|
|
192
|
+
| `.targets` | Fork targets list |
|
|
193
|
+
| `.strategy` | Fork strategy (`"parallel"` / `"sequential"`) |
|
|
194
|
+
| `.was_relayed` | `True` if `relay()` was called |
|
|
195
|
+
| `.was_forked` | `True` if `fork()` was called |
|
|
196
|
+
| `.was_dropped` | `True` if handler returned without action (silent drop) |
|
|
197
|
+
| `.record_routed` | `True` if `record_route()` was called |
|
|
198
|
+
| `.request` | The mock `Request` object for header inspection |
|
|
199
|
+
|
|
200
|
+
## API reference
|
|
201
|
+
|
|
202
|
+
### Namespaces
|
|
203
|
+
|
|
204
|
+
| Import | Description |
|
|
205
|
+
|--------|-------------|
|
|
206
|
+
| `proxy` | Stateful/stateless proxy decorators and utilities |
|
|
207
|
+
| `registrar` | Address-of-record contact store |
|
|
208
|
+
| `auth` | SIP digest authentication |
|
|
209
|
+
| `b2bua` | Back-to-back user agent call control |
|
|
210
|
+
| `log` | Structured logging |
|
|
211
|
+
| `cache` | Named cache (local LRU + Redis) |
|
|
212
|
+
| `rtpengine` | RTPEngine media proxy operations |
|
|
213
|
+
| `gateway` | Destination groups, load balancing, health probing |
|
|
214
|
+
| `cdr` | Call detail records |
|
|
215
|
+
| `diameter` | Diameter protocol (Cx, Ro, Rx, Rf, Sh) |
|
|
216
|
+
| `presence` | SUBSCRIBE/NOTIFY, PIDF presence |
|
|
217
|
+
| `li` | Lawful intercept (ETSI X1/X2/X3, SIPREC) |
|
|
218
|
+
| `registration` | Outbound REGISTER client (trunk registration) |
|
|
219
|
+
|
|
220
|
+
### Request properties
|
|
221
|
+
|
|
222
|
+
| Property | Type | Description |
|
|
223
|
+
|----------|------|-------------|
|
|
224
|
+
| `method` | `str` | SIP method (`"INVITE"`, `"REGISTER"`, etc.) |
|
|
225
|
+
| `ruri` | `SipUri` | Request-URI |
|
|
226
|
+
| `from_uri` | `SipUri \| None` | From header URI |
|
|
227
|
+
| `to_uri` | `SipUri \| None` | To header URI |
|
|
228
|
+
| `from_tag` | `str \| None` | From-tag |
|
|
229
|
+
| `to_tag` | `str \| None` | To-tag (`None` for initial requests) |
|
|
230
|
+
| `call_id` | `str \| None` | Call-ID |
|
|
231
|
+
| `cseq` | `(int, str) \| None` | CSeq tuple |
|
|
232
|
+
| `in_dialog` | `bool` | Both tags present |
|
|
233
|
+
| `max_forwards` | `int` | Max-Forwards value |
|
|
234
|
+
| `body` | `bytes \| None` | Message body |
|
|
235
|
+
| `content_type` | `str \| None` | Content-Type |
|
|
236
|
+
| `transport` | `str` | `"udp"`, `"tcp"`, `"tls"`, `"ws"`, `"wss"` |
|
|
237
|
+
| `source_ip` | `str` | Sender IP |
|
|
238
|
+
| `auth_user` | `str \| None` | Authenticated username |
|
|
239
|
+
| `event` | `str \| None` | Event header |
|
|
240
|
+
|
|
241
|
+
### Request methods
|
|
242
|
+
|
|
243
|
+
| Method | Description |
|
|
244
|
+
|--------|-------------|
|
|
245
|
+
| `reply(code, reason)` | Send SIP response |
|
|
246
|
+
| `relay(next_hop=None)` | Forward to destination |
|
|
247
|
+
| `fork(targets, strategy="parallel")` | Fork to multiple targets |
|
|
248
|
+
| `record_route()` | Insert Record-Route |
|
|
249
|
+
| `loose_route() -> bool` | RFC 3261 loose routing |
|
|
250
|
+
| `get_header(name) -> str \| None` | Get header value |
|
|
251
|
+
| `set_header(name, value)` | Set header |
|
|
252
|
+
| `remove_header(name)` | Remove header |
|
|
253
|
+
| `has_header(name) -> bool` | Check header exists |
|
|
254
|
+
| `has_body(content_type) -> bool` | Check body type |
|
|
255
|
+
| `set_ruri_user(value)` | Set R-URI user part |
|
|
256
|
+
| `set_ruri_host(value)` | Set R-URI host |
|
|
257
|
+
| `source_ip_in(cidrs) -> bool` | CIDR membership check |
|
|
258
|
+
| `generate_icid() -> str` | Generate charging ID |
|
|
259
|
+
| `add_path(uri)` | Prepend Path header |
|
|
260
|
+
| `prepend_route(uri)` | Prepend Route header |
|
|
261
|
+
| `fix_nated_register()` | NAT fixup for REGISTER |
|
|
262
|
+
| `fix_nated_contact()` | NAT fixup for Contact |
|
|
263
|
+
|
|
264
|
+
### Registrar
|
|
265
|
+
|
|
266
|
+
| Method | Description |
|
|
267
|
+
|--------|-------------|
|
|
268
|
+
| `save(request, force=False)` | Save REGISTER bindings |
|
|
269
|
+
| `lookup(uri) -> list[Contact]` | Look up contacts (sorted by q-value) |
|
|
270
|
+
| `is_registered(uri) -> bool` | Check if URI has contacts |
|
|
271
|
+
| `service_route(uri) -> list[str]` | Get stored service routes (RFC 3608) |
|
|
272
|
+
| `set_service_routes(aor, routes)` | Store service routes for an AoR |
|
|
273
|
+
| `save_pending(request)` | IMS: save binding in pending state |
|
|
274
|
+
| `confirm_pending(uri)` | IMS: promote pending to active after SAR |
|
|
275
|
+
| `asserted_identity(uri) -> str \| None` | IMS: stored P-Asserted-Identity |
|
|
276
|
+
| `reginfo_xml(aor, state, version) -> str` | Generate reginfo XML (RFC 3680) |
|
|
277
|
+
| `on_change` | Decorator: fires on registration state changes |
|
|
278
|
+
|
|
279
|
+
### Auth
|
|
280
|
+
|
|
281
|
+
| Method | Description |
|
|
282
|
+
|--------|-------------|
|
|
283
|
+
| `require_www_digest(request, realm) -> bool` | 401 challenge |
|
|
284
|
+
| `require_proxy_digest(request, realm) -> bool` | 407 challenge |
|
|
285
|
+
| `require_digest(request, realm) -> bool` | Alias for www_digest |
|
|
286
|
+
| `verify_digest(request, realm) -> bool` | Verify without challenge |
|
|
287
|
+
| `require_ims_digest(request, realm) -> bool` | IMS AKA via Diameter Cx MAR |
|
|
288
|
+
| `require_aka_digest(request, realm) -> bool` | Local Milenage AKA (no HSS) |
|
|
289
|
+
|
|
290
|
+
### B2BUA call
|
|
291
|
+
|
|
292
|
+
Each B-leg gets a fresh Call-ID and From-tag by default, fully decoupling the two SIP dialogs. Use `keep_call_id()` to opt out of Call-ID regeneration.
|
|
293
|
+
|
|
294
|
+
| Property/Method | Description |
|
|
295
|
+
|----------------|-------------|
|
|
296
|
+
| `call.id` | UUID |
|
|
297
|
+
| `call.state` | `"calling"`, `"ringing"`, `"answered"`, `"terminated"` |
|
|
298
|
+
| `call.from_uri` | A-leg From URI |
|
|
299
|
+
| `call.ruri` | A-leg Request-URI |
|
|
300
|
+
| `call.reject(code, reason)` | Reject call |
|
|
301
|
+
| `call.dial(uri, timeout=30)` | Dial single target |
|
|
302
|
+
| `call.fork(targets, strategy, timeout)` | Fork to multiple |
|
|
303
|
+
| `call.terminate()` | End call (BYE both legs) |
|
|
304
|
+
| `call.keep_call_id()` | Copy A-leg Call-ID to B-leg (From-tag always unique) |
|
|
305
|
+
| `call.set_credentials(user, pass)` | B-leg digest auth credentials (auto 401/407 retry) |
|
|
306
|
+
| `call.media.anchor(engine)` | Anchor media through RTPEngine |
|
|
307
|
+
| `call.media.release()` | Release media anchor |
|
|
308
|
+
| `call.session_timer(expires, min_se, refresher)` | Per-call RFC 4028 session timer |
|
|
309
|
+
| `call.record(srs_uri)` | Start SIPREC recording |
|
|
310
|
+
| `call.stop_recording()` | Stop SIPREC recording |
|
|
311
|
+
|
|
312
|
+
## License
|
|
313
|
+
|
|
314
|
+
MIT
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/siphon-project/siphon-sip/main/assets/logo.svg" alt="SIPhon" width="100">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# siphon-sip
|
|
6
|
+
|
|
7
|
+
Mock library and type stubs for [SIPhon](https://github.com/siphon-project/siphon-sip) scripts — enables unit testing without the Rust binary and provides rich context for LLM-assisted script authoring.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install siphon-sip
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
> The PyPI distribution is **`siphon-sip`** (matching the `siphon-sip` crate). The import package is still **`siphon_sdk`** — `from siphon_sdk import …`.
|
|
16
|
+
|
|
17
|
+
## What is SIPhon?
|
|
18
|
+
|
|
19
|
+
SIPhon is a high-performance SIP proxy, B2BUA, and IMS platform written in Rust with Python scripting. Scripts use decorators to handle SIP events:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from siphon import proxy, registrar, auth, log
|
|
23
|
+
|
|
24
|
+
@proxy.on_request
|
|
25
|
+
def route(request):
|
|
26
|
+
if request.method == "REGISTER":
|
|
27
|
+
if not auth.require_digest(request, realm="example.com"):
|
|
28
|
+
return
|
|
29
|
+
registrar.save(request)
|
|
30
|
+
request.reply(200, "OK")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
contacts = registrar.lookup(request.ruri)
|
|
34
|
+
if not contacts:
|
|
35
|
+
request.reply(404, "Not Found")
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
request.record_route()
|
|
39
|
+
request.fork([c.uri for c in contacts])
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This SDK lets you **test** these scripts with pytest — no Rust binary needed.
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from siphon_sdk.testing import SipTestHarness
|
|
48
|
+
from siphon_sdk.types import Contact
|
|
49
|
+
|
|
50
|
+
harness = SipTestHarness(local_domains=["example.com"])
|
|
51
|
+
harness.load_script("scripts/proxy_default.py")
|
|
52
|
+
|
|
53
|
+
# Pre-populate the registrar
|
|
54
|
+
harness.registrar.add_contact(
|
|
55
|
+
"sip:alice@example.com",
|
|
56
|
+
Contact(uri="sip:alice@192.168.1.5:5060"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Test REGISTER challenge
|
|
60
|
+
result = harness.send_request("REGISTER", "sip:alice@example.com",
|
|
61
|
+
from_uri="sip:alice@example.com")
|
|
62
|
+
assert result.status_code == 401 # digest challenge
|
|
63
|
+
|
|
64
|
+
# Test INVITE routing
|
|
65
|
+
result = harness.send_request("INVITE", "sip:alice@example.com")
|
|
66
|
+
assert result.action == "fork"
|
|
67
|
+
assert "sip:alice@192.168.1.5:5060" in result.targets
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Testing B2BUA scripts
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
harness = SipTestHarness()
|
|
74
|
+
harness.load_script("scripts/b2bua_default.py")
|
|
75
|
+
|
|
76
|
+
harness.registrar.add_contact(
|
|
77
|
+
"sip:bob@example.com",
|
|
78
|
+
Contact(uri="sip:bob@10.0.0.2:5060"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
result = harness.send_invite(ruri="sip:bob@example.com")
|
|
82
|
+
assert result.action == "fork"
|
|
83
|
+
assert result.targets == ["sip:bob@10.0.0.2:5060"]
|
|
84
|
+
|
|
85
|
+
# Test BYE handling
|
|
86
|
+
result = harness.send_bye(initiator_side="a")
|
|
87
|
+
assert result.was_terminated
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Inline scripts
|
|
91
|
+
|
|
92
|
+
Test scripts without separate files:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
harness = SipTestHarness()
|
|
96
|
+
harness.load_source("""
|
|
97
|
+
from siphon import proxy
|
|
98
|
+
|
|
99
|
+
@proxy.on_request
|
|
100
|
+
def route(request):
|
|
101
|
+
if request.source_ip_in(["10.0.0.0/8"]):
|
|
102
|
+
request.relay()
|
|
103
|
+
else:
|
|
104
|
+
request.reply(403, "Forbidden")
|
|
105
|
+
""")
|
|
106
|
+
|
|
107
|
+
result = harness.send_request("INVITE", "sip:bob@host", source_ip="10.1.2.3")
|
|
108
|
+
assert result.was_relayed
|
|
109
|
+
|
|
110
|
+
result = harness.send_request("INVITE", "sip:bob@host", source_ip="8.8.8.8")
|
|
111
|
+
assert result.status_code == 403
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Async handlers + RTPEngine
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
harness = SipTestHarness()
|
|
118
|
+
harness.load_source("""
|
|
119
|
+
from siphon import proxy, rtpengine
|
|
120
|
+
|
|
121
|
+
@proxy.on_request
|
|
122
|
+
async def route(request):
|
|
123
|
+
if request.method == "INVITE" and request.body:
|
|
124
|
+
await rtpengine.offer(request, profile="srtp_to_rtp")
|
|
125
|
+
request.relay()
|
|
126
|
+
""")
|
|
127
|
+
|
|
128
|
+
result = harness.send_request("INVITE", "sip:bob@host",
|
|
129
|
+
body=b"v=0\\r\\n...",
|
|
130
|
+
content_type="application/sdp")
|
|
131
|
+
assert result.was_relayed
|
|
132
|
+
assert harness.rtpengine.operations == [("offer", "srtp_to_rtp")]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Controlling mock behavior
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
# Auth: allow or deny all
|
|
139
|
+
harness.auth._allow = True # all auth checks pass
|
|
140
|
+
|
|
141
|
+
# Rate limiting
|
|
142
|
+
harness.proxy._utils._rate_limit_allow = False # simulate overload
|
|
143
|
+
|
|
144
|
+
# Cache: pre-populate
|
|
145
|
+
harness.cache.set_data("cnam", {"key": "value"})
|
|
146
|
+
|
|
147
|
+
# Registrar: add contacts directly
|
|
148
|
+
harness.registrar.add_contact("sip:alice@host", Contact(uri="sip:alice@1.2.3.4"))
|
|
149
|
+
|
|
150
|
+
# Log: inspect captured messages
|
|
151
|
+
assert any("error" in msg for level, msg in harness.log.messages)
|
|
152
|
+
|
|
153
|
+
# Reset between tests
|
|
154
|
+
harness.reset()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Result assertions
|
|
158
|
+
|
|
159
|
+
`RequestResult` provides convenient properties:
|
|
160
|
+
|
|
161
|
+
| Property | Description |
|
|
162
|
+
|----------|-------------|
|
|
163
|
+
| `.action` | Primary action: `"reply"`, `"relay"`, `"fork"`, `"silent_drop"` |
|
|
164
|
+
| `.status_code` | SIP status code (200, 401, 404, etc.) |
|
|
165
|
+
| `.reason` | Reason phrase |
|
|
166
|
+
| `.targets` | Fork targets list |
|
|
167
|
+
| `.strategy` | Fork strategy (`"parallel"` / `"sequential"`) |
|
|
168
|
+
| `.was_relayed` | `True` if `relay()` was called |
|
|
169
|
+
| `.was_forked` | `True` if `fork()` was called |
|
|
170
|
+
| `.was_dropped` | `True` if handler returned without action (silent drop) |
|
|
171
|
+
| `.record_routed` | `True` if `record_route()` was called |
|
|
172
|
+
| `.request` | The mock `Request` object for header inspection |
|
|
173
|
+
|
|
174
|
+
## API reference
|
|
175
|
+
|
|
176
|
+
### Namespaces
|
|
177
|
+
|
|
178
|
+
| Import | Description |
|
|
179
|
+
|--------|-------------|
|
|
180
|
+
| `proxy` | Stateful/stateless proxy decorators and utilities |
|
|
181
|
+
| `registrar` | Address-of-record contact store |
|
|
182
|
+
| `auth` | SIP digest authentication |
|
|
183
|
+
| `b2bua` | Back-to-back user agent call control |
|
|
184
|
+
| `log` | Structured logging |
|
|
185
|
+
| `cache` | Named cache (local LRU + Redis) |
|
|
186
|
+
| `rtpengine` | RTPEngine media proxy operations |
|
|
187
|
+
| `gateway` | Destination groups, load balancing, health probing |
|
|
188
|
+
| `cdr` | Call detail records |
|
|
189
|
+
| `diameter` | Diameter protocol (Cx, Ro, Rx, Rf, Sh) |
|
|
190
|
+
| `presence` | SUBSCRIBE/NOTIFY, PIDF presence |
|
|
191
|
+
| `li` | Lawful intercept (ETSI X1/X2/X3, SIPREC) |
|
|
192
|
+
| `registration` | Outbound REGISTER client (trunk registration) |
|
|
193
|
+
|
|
194
|
+
### Request properties
|
|
195
|
+
|
|
196
|
+
| Property | Type | Description |
|
|
197
|
+
|----------|------|-------------|
|
|
198
|
+
| `method` | `str` | SIP method (`"INVITE"`, `"REGISTER"`, etc.) |
|
|
199
|
+
| `ruri` | `SipUri` | Request-URI |
|
|
200
|
+
| `from_uri` | `SipUri \| None` | From header URI |
|
|
201
|
+
| `to_uri` | `SipUri \| None` | To header URI |
|
|
202
|
+
| `from_tag` | `str \| None` | From-tag |
|
|
203
|
+
| `to_tag` | `str \| None` | To-tag (`None` for initial requests) |
|
|
204
|
+
| `call_id` | `str \| None` | Call-ID |
|
|
205
|
+
| `cseq` | `(int, str) \| None` | CSeq tuple |
|
|
206
|
+
| `in_dialog` | `bool` | Both tags present |
|
|
207
|
+
| `max_forwards` | `int` | Max-Forwards value |
|
|
208
|
+
| `body` | `bytes \| None` | Message body |
|
|
209
|
+
| `content_type` | `str \| None` | Content-Type |
|
|
210
|
+
| `transport` | `str` | `"udp"`, `"tcp"`, `"tls"`, `"ws"`, `"wss"` |
|
|
211
|
+
| `source_ip` | `str` | Sender IP |
|
|
212
|
+
| `auth_user` | `str \| None` | Authenticated username |
|
|
213
|
+
| `event` | `str \| None` | Event header |
|
|
214
|
+
|
|
215
|
+
### Request methods
|
|
216
|
+
|
|
217
|
+
| Method | Description |
|
|
218
|
+
|--------|-------------|
|
|
219
|
+
| `reply(code, reason)` | Send SIP response |
|
|
220
|
+
| `relay(next_hop=None)` | Forward to destination |
|
|
221
|
+
| `fork(targets, strategy="parallel")` | Fork to multiple targets |
|
|
222
|
+
| `record_route()` | Insert Record-Route |
|
|
223
|
+
| `loose_route() -> bool` | RFC 3261 loose routing |
|
|
224
|
+
| `get_header(name) -> str \| None` | Get header value |
|
|
225
|
+
| `set_header(name, value)` | Set header |
|
|
226
|
+
| `remove_header(name)` | Remove header |
|
|
227
|
+
| `has_header(name) -> bool` | Check header exists |
|
|
228
|
+
| `has_body(content_type) -> bool` | Check body type |
|
|
229
|
+
| `set_ruri_user(value)` | Set R-URI user part |
|
|
230
|
+
| `set_ruri_host(value)` | Set R-URI host |
|
|
231
|
+
| `source_ip_in(cidrs) -> bool` | CIDR membership check |
|
|
232
|
+
| `generate_icid() -> str` | Generate charging ID |
|
|
233
|
+
| `add_path(uri)` | Prepend Path header |
|
|
234
|
+
| `prepend_route(uri)` | Prepend Route header |
|
|
235
|
+
| `fix_nated_register()` | NAT fixup for REGISTER |
|
|
236
|
+
| `fix_nated_contact()` | NAT fixup for Contact |
|
|
237
|
+
|
|
238
|
+
### Registrar
|
|
239
|
+
|
|
240
|
+
| Method | Description |
|
|
241
|
+
|--------|-------------|
|
|
242
|
+
| `save(request, force=False)` | Save REGISTER bindings |
|
|
243
|
+
| `lookup(uri) -> list[Contact]` | Look up contacts (sorted by q-value) |
|
|
244
|
+
| `is_registered(uri) -> bool` | Check if URI has contacts |
|
|
245
|
+
| `service_route(uri) -> list[str]` | Get stored service routes (RFC 3608) |
|
|
246
|
+
| `set_service_routes(aor, routes)` | Store service routes for an AoR |
|
|
247
|
+
| `save_pending(request)` | IMS: save binding in pending state |
|
|
248
|
+
| `confirm_pending(uri)` | IMS: promote pending to active after SAR |
|
|
249
|
+
| `asserted_identity(uri) -> str \| None` | IMS: stored P-Asserted-Identity |
|
|
250
|
+
| `reginfo_xml(aor, state, version) -> str` | Generate reginfo XML (RFC 3680) |
|
|
251
|
+
| `on_change` | Decorator: fires on registration state changes |
|
|
252
|
+
|
|
253
|
+
### Auth
|
|
254
|
+
|
|
255
|
+
| Method | Description |
|
|
256
|
+
|--------|-------------|
|
|
257
|
+
| `require_www_digest(request, realm) -> bool` | 401 challenge |
|
|
258
|
+
| `require_proxy_digest(request, realm) -> bool` | 407 challenge |
|
|
259
|
+
| `require_digest(request, realm) -> bool` | Alias for www_digest |
|
|
260
|
+
| `verify_digest(request, realm) -> bool` | Verify without challenge |
|
|
261
|
+
| `require_ims_digest(request, realm) -> bool` | IMS AKA via Diameter Cx MAR |
|
|
262
|
+
| `require_aka_digest(request, realm) -> bool` | Local Milenage AKA (no HSS) |
|
|
263
|
+
|
|
264
|
+
### B2BUA call
|
|
265
|
+
|
|
266
|
+
Each B-leg gets a fresh Call-ID and From-tag by default, fully decoupling the two SIP dialogs. Use `keep_call_id()` to opt out of Call-ID regeneration.
|
|
267
|
+
|
|
268
|
+
| Property/Method | Description |
|
|
269
|
+
|----------------|-------------|
|
|
270
|
+
| `call.id` | UUID |
|
|
271
|
+
| `call.state` | `"calling"`, `"ringing"`, `"answered"`, `"terminated"` |
|
|
272
|
+
| `call.from_uri` | A-leg From URI |
|
|
273
|
+
| `call.ruri` | A-leg Request-URI |
|
|
274
|
+
| `call.reject(code, reason)` | Reject call |
|
|
275
|
+
| `call.dial(uri, timeout=30)` | Dial single target |
|
|
276
|
+
| `call.fork(targets, strategy, timeout)` | Fork to multiple |
|
|
277
|
+
| `call.terminate()` | End call (BYE both legs) |
|
|
278
|
+
| `call.keep_call_id()` | Copy A-leg Call-ID to B-leg (From-tag always unique) |
|
|
279
|
+
| `call.set_credentials(user, pass)` | B-leg digest auth credentials (auto 401/407 retry) |
|
|
280
|
+
| `call.media.anchor(engine)` | Anchor media through RTPEngine |
|
|
281
|
+
| `call.media.release()` | Release media anchor |
|
|
282
|
+
| `call.session_timer(expires, min_se, refresher)` | Per-call RFC 4028 session timer |
|
|
283
|
+
| `call.record(srs_uri)` | Start SIPREC recording |
|
|
284
|
+
| `call.stop_recording()` | Stop SIPREC recording |
|
|
285
|
+
|
|
286
|
+
## License
|
|
287
|
+
|
|
288
|
+
MIT
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
# Distribution renamed siphon-sdk -> siphon-sip so the pip name matches the
|
|
7
|
+
# crate name (cargo add siphon-sip ↔ pip install siphon-sip). The import
|
|
8
|
+
# package stays `siphon_sdk` (see [tool.hatch.build.targets.wheel]) to avoid
|
|
9
|
+
# breaking existing `import siphon_sdk` test code.
|
|
10
|
+
name = "siphon-sip"
|
|
11
|
+
# Version comes from the git tag via hatch-vcs — there is intentionally no
|
|
12
|
+
# `version =` line to hand-bump. The release tag (e.g. v1.0.0) is the single
|
|
13
|
+
# source of truth, lockstep with the crate. This stops the per-change SDK
|
|
14
|
+
# version churn.
|
|
15
|
+
dynamic = ["version"]
|
|
16
|
+
description = "Mock library and type stubs for SIPhon SIP proxy/B2BUA scripts — enables unit testing and LLM-assisted script authoring"
|
|
17
|
+
readme = "README.md"
|
|
18
|
+
license = "MIT"
|
|
19
|
+
requires-python = ">=3.10"
|
|
20
|
+
authors = [{ name = "SIPhon Authors" }]
|
|
21
|
+
keywords = ["sip", "voip", "proxy", "b2bua", "ims", "testing", "mock"]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Intended Audience :: Telecommunications Industry",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Programming Language :: Python :: 3.14",
|
|
33
|
+
"Topic :: Communications :: Telephony",
|
|
34
|
+
"Topic :: Software Development :: Testing :: Mocking",
|
|
35
|
+
"Typing :: Typed",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/siphon-project/siphon-sip"
|
|
40
|
+
Documentation = "https://github.com/siphon-project/siphon-sip/tree/main/sdk"
|
|
41
|
+
Repository = "https://github.com/siphon-project/siphon-sip"
|
|
42
|
+
|
|
43
|
+
# Version from the repo's git tags. `root = ".."` points hatch-vcs at the repo
|
|
44
|
+
# root (this pyproject lives in sdk/). On a tagged commit (vX.Y.Z) the version
|
|
45
|
+
# is X.Y.Z; between tags it's a dev version that is never published.
|
|
46
|
+
[tool.hatch.version]
|
|
47
|
+
source = "vcs"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.version.raw-options]
|
|
50
|
+
root = ".."
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["siphon_sdk"]
|