Docs vCon Capture Stack
vCon Capture Stack
Build a two-point vCon capture stack -- OpenSIPS, rtpengine, sipnab and a conserver -- then operate it, verify a stored call carries its media, and recognize the failures that look like success.
On this page
- Why two capture points
- Choosing one node or two
- The three switches people miss
- Store a call
- User guide
- Manage what you have stored
- Topology
- One call, end to end
- Setup
- What a good run looks like
- The two clients
- Traps
- Known limitation
- Reference
- Two behaviors that surprise people
- Before this carries real calls
OpenSIPS routes the call, rtpengine carries the media, sipnab watches both without taking part, and a conserver keeps what comes out. This page is how to build that, operate it, prove it works, and recognize the failure modes that look like success.
Hostnames in the sample output below appear as <proxy-host> and
<relay-host>. Neither is a literal value. Both stand in for something taken
out. node carries
whatever hostname the container has, which is a private lab machine on one
side and an ephemeral container ID on the other. Everything else in those
samples is verbatim.
It describes a test harness rather than a production deployment. Every command here ran against a real build. The addresses belong to a private lab bridge, and the arrangement is deliberately more separated than a real one needs, so each half can prove itself on its own.
OpenSIPS routes the call, rtpengine carries the media, sipnab watches both without taking part, and a conserver keeps what comes out. Here is how to build that, operate it, prove it works, and recognize the failure modes that look like success.
Why two capture points
A proxy and a media relay see different halves of a call. sipnab-proxy shares the OpenSIPS network namespace and sees INVITE, 200 OK, ACK, BYE — and no media at all. sipnab-relay shares the rtpengine namespace and sees RTP — and never a SIP message.
Neither can answer “was this call healthy?” alone. Joining them is the thing under test, and it only works because rtpengine mirrors its ng control plane where the relay can read it.
Why separate namespaces, and why that is a workaround
Two sipnabs on one interface read the same packets and return the same answers — two copies of one capture point, which cannot show that a leg seen at the proxy is the leg seen at the relay. Giving rtpengine its own namespace makes them genuinely different.
It should not have to. sipnab has
--no-rtpfor a signaling-only capture and no counterpart for media, so nothing expresses “watch only the relay half” and the topology has to carry what a flag should. Two instances in one namespace would be a perfectly good arrangement if each could learn what it is for. Treat the split as the current answer, not the right one.
Choosing one node or two
Answer this before deploying. It decides four other things.
When OpenSIPS and rtpengine run on the same host, one sipnab is enough and is the simpler build. A single capture point sees the signaling and the media together, so it learns which streams belong to which call from the SDP — the way sipnab does it everywhere else.
Two nodes exist for the case that cannot cover: a relay on separate hardware, which never sees an INVITE. Attribution then has to come from the rtpengine ng control plane instead, and that carries four requirements with it.
| Single node (co-located) | Two nodes (separate relay) | |
|---|---|---|
| Stream attribution | SDP in the signaling | rtpengine ng control plane |
hep build feature | Not needed | Required |
| HOMER mirroring | Not needed | Required |
| A sink to absorb the mirror | Not needed | Required |
| Control ports in the BPF | Not needed | Required |
flowchart LR
CALLER["caller"] --> OS
subgraph H["one host, one namespace"]
OS["OpenSIPS"]
RE["rtpengine"]
SN["sipnab · sees SIP and RTP"]
OS -- "ng on loopback" --> RE
end
OS --> CALLEE["callee"]
CALLER <-- "RTP" --> RE
RE <-- "RTP" --> CALLEE
SN --> CS["conserver"]
Two files, and Compose merges them in order — the base defines every service, the overlay changes only what co-location makes different. Open either to see exactly what it sets.
| Role | File | What it contains |
|---|---|---|
| Base · always | harness/docker-compose.yml | Every service: OpenSIPS, rtpengine, both sipnabs, the SIPp endpoints and the HEP sink. On its own it stands up the two-node arrangement. |
| Overlay · co-located | harness/docker-compose.single.yml | Puts rtpengine back in the proxy’s namespace, moves the ng socket to loopback, turns mirroring off, and scales the relay-side sipnab and the HEP sink to zero. |
MEDIA — rtpengine, co-located — the simple case
On the same host as OpenSIPS, the control socket stays on loopback and nothing needs mirroring. sipnab reads the SDP that describes this media, so it can attribute streams without ever seeing an ng message.
rtpengine \
--interface=<host-ip> \
--listen-ng=127.0.0.1:22222 \
--port-min=30000 --port-max=30050
OpenSIPS then points at udp:127.0.0.1:22222, and one sipnab watches the interface both use:
sipnab -N -d eth0 \
--api 127.0.0.1:8080 \
--mcp --mcp-transport http --mcp-bind 127.0.0.1:8731 \
--retain-audio \
--portrange 5060-5061 \
"udp and (portrange 5060-5061 or portrange 30000-30050)"
No hep feature, no --hep-parse, no control ports in the filter. Everything below this point applies only when the relay runs somewhere else.
MEDIA — rtpengine on separate hardware — mirroring its control plane
Signaling cannot attribute a relay that never sees an INVITE, so the mirror becomes the only link. Without it every captured stream is media nobody can name.
rtpengine \
--interface=<relay-ip> \
--listen-ng=<relay-ip>:22222 \
--port-min=30000 --port-max=30050 \
--homer=<collector>:9060 \
--homer-protocol=udp \
--homer-enable-ng
SIGNAL — OpenSIPS, anchoring every call
Point the control socket at the relay. Co-located that is udp:127.0.0.1:22222. On separate hardware it is the relay’s address, and loopback fails to reach it without saying so.
loadmodule "rtpengine.so"
modparam("rtpengine", "rtpengine_sock", "udp:<relay-ip>:22222")
# in the INVITE route
rtpengine_offer();
# in the reply route
rtpengine_answer();
# on dialog teardown
rtpengine_delete();
SIGNAL — sipnab on a separate relay
Only for the split arrangement — the co-located invocation is above. Build with the features you need — vcon to produce containers, audio to carry media, hep to decode the control plane. A build without hep refuses --hep-parse outright.
Build it:
cargo build --release --features "native,tls,api,mcp,mcp-http,audio,vcon,hep"
At the RELAY — media, plus the control plane that names it:
sipnab -N -d eth0 \
--api 127.0.0.1:8081 \
--mcp --mcp-transport http --mcp-bind 127.0.0.1:8732 \
--hep-parse \
--retain-audio \
--portrange 5060-5061 \
"udp and (portrange 5060-5061 or portrange 30000-30050 \
or port 22222 or port 9060)"
At the PROXY — signaling only:
sipnab -N -d eth0 --api 127.0.0.1:8080 \
--mcp --mcp-transport http --mcp-bind 127.0.0.1:8731 \
--portrange 5060-5061
The three switches people miss
Do not reach for
--hep-parseto name relay mediaIt is for HEP-encapsulated SIP: it unwraps the datagram and replaces the packet with the payload inside. sipnab recognizes sniffed rtpengine
ngby its HEP wrapper, so unwrapping destroys the very thing that identifies it — the streams stay unnamed and nothing says why. What relay naming actually needs is thehepbuild feature and the control-plane ports in the capture filter.
The default filter excludes the control plane
Port 22222 and the HEP destination port are not in a SIP-and-media filter. The kernel drops them where no counter reports it, so the capture looks healthy and names nothing.
sipnab discards RTP payloads unless you ask for them
Without
--retain-audiothe container carries a Dialog Object with notypeand zero bytes. That is a run that kept no audio, not a call that was silent, and the completeness caveat is the only thing that says which — read it before concluding anything about the media.Before 0.5.128 sipnab typed that object
incomplete, which the vCon draft defines as a call that failed to reach conversation. A signaling-only container therefore reported every answered call as a failure.
Store a call
The step that decides whether the vCon still exists tomorrow.
sequenceDiagram
autonumber
participant OP as your job
participant SN as sipnab
participant CS as conserver
participant PG as Postgres
OP->>SN: GET /v1/dialogs/{call-id}/vcon
SN-->>OP: vCon 0.4.0 + audio (base64url)
OP->>CS: POST /vcon?ingress_lists=sipnab
CS->>CS: chain sipnab_observer tags the role
CS->>PG: write vcons_observed
CS-->>OP: 201 Created
OP->>PG: confirm the row exists
Posting without an ingress list stores nothing durable
POST /vconwith noingress_listswrites the cache only. It answers 201, it reads back correctly for about an hour, and then it vanishes — nothing ever wrote the durable tables. Name the ingress list, and confirm the row, every time.
curl -s -X POST "http://<conserver>/vcon?ingress_lists=sipnab" \
-H "x-conserver-api-token: $TOKEN" \
-H 'Content-Type: application/json' \
--data-binary @call.json
Query the table, not the API: select count(*) from vcons_observed where uuid = '<uuid>'. A 201 tells you the store accepted the request. Only the row tells you it kept the call.
Concretely, against a running harness:
Set up the variables. The Call-ID needs percent-encoding because it contains
an @, which a URL parser otherwise treats as the start of a host. jq -rR @uri does it without a helper script:
KEY=$(cat secrets/api.key) CID='[email protected]' ENC=$(jq -rR @uri <<<'[email protected]')
Ask the node that holds both the Call-ID and the audio for the container:
curl -s -H "Authorization: Bearer $KEY" "http://127.0.0.1:8081/v1/dialogs/$ENC/vcon" > call.json
Post it, naming the ingress list:
curl -s -X POST "http://127.0.0.1:8000/vcon?ingress_lists=sipnab" -H "x-conserver-api-token: $TOKEN" -H 'Content-Type: application/json' --data-binary @call.json
Read it back with its audio:
python3 clients/vcon_view.py <uuid> --audio call.wav
User guide
Day-to-day operation once the stack is up. Every task here reads and changes nothing unless it says otherwise.
Find the call you care about
The proxy node knows calls by Call-ID and knows nothing about their audio. Start there.
curl -s -H "Authorization: Bearer $KEY" \
"http://127.0.0.1:8080/v1/dialogs?limit=50" | jq -r \
'.dialogs[] | [.call_id, .state, .final_status_code] | @tsv'
See whether sipnab captured its media
The relay node knows streams and, once it has decoded the control plane, which call each belongs to. A stream with a null associated_dialog is media the relay could not attribute — usually the control plane, not the media, is what went missing.
curl -s -H "Authorization: Bearer $KEY" \
"http://127.0.0.1:8081/v1/streams?limit=50" | jq -r \
'.streams[] | [.associated_dialog // "UNNAMED", .codec, .packets, .mos] | @tsv'
Build and keep the container
Ask the node that holds both the Call-ID and the audio — on this topology, the relay.
Encode the Call-ID for the URL path — the @ in it would otherwise read as a
host separator:
ENC=$(jq -rR @uri <<<'[email protected]')
Build the container:
curl -s -H "Authorization: Bearer $KEY" "http://127.0.0.1:8081/v1/dialogs/$ENC/vcon" > call.json
Store it, naming the ingress list so it reaches the durable tables:
curl -s -X POST "http://127.0.0.1:8000/vcon?ingress_lists=sipnab" -H "x-conserver-api-token: $TOKEN" -H 'Content-Type: application/json' --data-binary @call.json
Listen to a stored call
List the containers the store holds:
python3 clients/vcon_view.py
Show one container’s parties, dialog and recording:
python3 clients/vcon_view.py <uuid>
Extract its audio to a WAV:
python3 clients/vcon_view.py <uuid> --audio c.wav
Answer “did we capture this call properly?”
Three checks, in the order that fails fastest.
| Question | Where to look | A bad answer means |
|---|---|---|
| Did the relay see the control plane? | caveats.media_creating_commands on /v1/stats | Zero means HEP decoding is off or a filter drops the ports, and no stream ever gets a name. |
| Is the media attributed? | associated_dialog on each stream | Null means the container describes a call with no audio. |
| Did the container survive? | A row in the durable table | Present in the API but absent from the table means the store cached it rather than keeping it. |
Read the caveat before trusting the audio
Every container carries a completeness note built from that run’s own counters. It states what the capture missed, and it distinguishes what sipnab failed to see from what an operator chose not to keep. A container that says PARTIAL is not broken — it is telling you which question it can answer.
Manage what you have stored
| Task | How |
|---|---|
| List what persisted | Query vcons_observed. The cache-backed listing route also returns entries that expired and now answer 404. |
| Fetch one | GET /vcon/{uuid} |
| Play the audio | Base64url-decode dialog[].body where type is recording — it is a WAV. |
| Verify integrity | Check the recording’s content_hash against the body you hold. |
| Separate roles | Query the role view. An observation and a recording of one call are two UUIDs and stay that way. |
Stereo means two sources, or it means nothing
A relay sees each direction twice, arriving and leaving, so a two-party call yields four stream records carrying two SSRCs. A container whose two channels are byte-identical is one leg duplicated. sipnab selects one stream per SSRC to avoid exactly that. If you build containers another way, do the same.
Topology
Four containers on one bridge, plus two sipnab sidecars that join existing namespaces rather than getting their own.
flowchart LR
UAC["sipp-uac · 172.28.0.21"]
UAS["sipp-uas · 172.28.0.20"]
subgraph PX["proxy namespace · 172.28.0.10"]
OS["opensips-proxy"]
SNP["sipnab-proxy · MCP 8731 · REST 8080"]
end
subgraph RL["relay namespace · 172.28.0.11"]
RE["rtpengine"]
SNR["sipnab-relay · MCP 8732 · REST 8081"]
end
HEP["hep-sink · 172.28.0.30:9060"]
UAC -- "INVITE" --> OS
OS -- "INVITE" --> UAS
OS -- "ng control :22222" --> RE
UAC <-- "RTP" --> RE
RE <-- "RTP" --> UAS
RE -. "ng mirrored as HEP" .-> HEP
SNR -. "reads HEP off the wire" .-> HEP
| Service | Role | Namespace |
|---|---|---|
opensips-proxy | Registrar and stateful proxy; anchors all media through rtpengine | own |
rtpengine | Media relay in userspace; mirrors its ng control plane as HEP | own |
sipnab-proxy | Signaling capture point | joins opensips-proxy |
sipnab-relay | Media capture point; decodes HEP to name streams | joins rtpengine |
sipp-uac | Places calls, plays caller audio | own |
sipp-uas | Answers, plays different callee audio back | own |
hep-sink | Absorbs the HEP mirror so rtpengine keeps sending it | own |
hep-sink is not optional
rtpengine connects its Homer socket. A destination that answers ICMP port-unreachable makes it give up and send nothing — which looks exactly like the feature not working. Something must absorb the datagrams even though sipnab reads them passively off the wire rather than receiving them.
One call, end to end
What each node observes, and the moment the two halves become one call.
sequenceDiagram autonumber participant UAC as sipp-uac participant OS as opensips-proxy participant RE as rtpengine participant UAS as sipp-uas participant SNP as sipnab-proxy participant SNR as sipnab-relay UAC->>OS: INVITE (SDP: PCMA, port 6000) SNP-->>SNP: dialog created OS->>RE: ng offer RE-->>SNR: HEP mirror of ng offer SNR-->>SNR: learns Call-ID + media ports RE-->>OS: allocated ports 30000-30050 OS->>UAS: INVITE (SDP rewritten to relay) UAS-->>OS: 200 OK (PCMA) OS->>RE: ng answer OS-->>UAC: 200 OK SNP-->>SNP: dialog Completed / 200 UAC->>RE: RTP ssrc=0x0e330af3 RE->>UAS: RTP (relayed) UAS->>RE: RTP ssrc=0x5afe1234 RE->>UAC: RTP (relayed) SNR-->>SNR: 4 streams, named by Call-ID UAC->>OS: BYE OS->>UAS: BYE
Read step 5 twice
That is the only step linking the two nodes. Signaling never transits rtpengine, so without the HEP mirror every relay stream carries
associated_dialog: nulland no downstream correlation can recover a Call-ID it was never told.
Setup
From a clone to a running stack. Docker and Docker Compose are the only prerequisites, and the repository builds everything else.
- Mint the credentials Two secrets: a long-lived HMAC key that mints rotating MCP bearer tokens, and a REST API key. Both land in
harness/secrets/, which is git-ignored by pattern.cd harness make signing-key make api-key - Generate the two voices The caller and callee must play different media, or the two legs are indistinguishable. Real speech rather than a tone, because nobody can tell a well-recorded sine from a badly recorded one — a male caller counting up and a female callee counting down make a captured call audible as a call.
[./scripts/make-speech-pcaps.sh](https://github.com/NormB/sipnab/blob/main/harness/scripts/make-speech-pcaps.sh) # caller: en_US-ryan (male) ssrc=0x0CA11E12 # callee: en_US-amy (female) ssrc=0x5AFE1234 - Build and start The sipnab image must carry
vcon,audioandhep. Withouthepthe relay cannot decode the control plane at all.docker compose build docker compose up -d docker compose ps - Confirm both nodes answer Different
capture_identity.nodevalues are the proof that they are two capture points and not one reached twice.KEY=$(cat secrets/api.key) curl -s -H "Authorization: Bearer $KEY" \ http://127.0.0.1:8080/v1/stats | jq .capture_identity curl -s -H "Authorization: Bearer $KEY" \ http://127.0.0.1:8081/v1/stats | jq .capture_identity - Run the test Places calls, waits for the relay to name the media, queries both nodes over REST and MCP, and writes a timestamped report.
CALLS=3 [./scripts/run-e2e.sh](https://github.com/NormB/sipnab/blob/main/harness/scripts/run-e2e.sh) # → results/e2e-<UTC>.md
What a good run looks like
The same call, the same audio, measured on both arrangements. One node or two changes where the evidence lives, not what it says.
Co-located — one sipnab
Successful call: 1 Failed call: 0
node=<proxy-host> dialogs=1 streams=4
streams=4 named=4 ssrcs=['0x0ca11e12', '0x5afe1234']
[email protected] 172.28.0.20:6000 -> 172.28.0.10:30034 4500p mos=4.36
[email protected] 172.28.0.10:30012 -> 172.28.0.21:6000 4500p mos=4.36
[email protected] 172.28.0.21:6000 -> 172.28.0.10:30012 4500p mos=4.36
[email protected] 172.28.0.10:30034 -> 172.28.0.20:6000 4500p mos=4.36
One node holds the whole call. Nothing needs correlating, because nothing split it apart.
Separate relay — two sipnabs
Successful call: 1 Failed call: 0
proxy: node=<proxy-host> dialogs=1 streams=0
relay: node=<relay-host> dialogs=0 streams=4
Call-ID State Code Strm Pkts Codec MOS 2-way
----------------------------------------------------------------------------
[email protected] Completed 200 4 18000 PCMA 4.36 yes
1 call(s) correlated across both nodes
The proxy reports zero streams and the relay zero dialogs. That asymmetry is the arrangement working — neither node holds the other’s half, and the join is what puts the call back together.
| Measured | What it is |
|---|---|
| 4 | stream records |
| 4 / 4 | named by Call-ID |
| 2 | distinct SSRCs |
| 4.36 | MOS, both arrangements |
Four stream records for two audio streams is correct, not duplication: a relay sees each direction arriving and leaving. Two SSRCs is the number of voices.
The two clients
Both run on the host, outside the containers. Read-only.
| Client | Door | What it proves |
|---|---|---|
clients/leg_correlate.py | REST 8080 / 8081 | Joins proxy dialogs to relay streams on associated_dialog; reports unnamed media rather than dropping it |
clients/mcp_probe.py | MCP 8731 / 8732 | Drives the door an agent uses; compares both nodes’ answers |
clients/vcon_view.py | conserver 8000 | Lists stored vCons, renders one, extracts its audio as WAV |
Both capture clients refuse to report on one node reached twice — leg_correlate.py compares capture instances, mcp_probe.py compares capture_identity.instance. Point them both at the same URL and they exit rather than present a node agreeing with itself as corroboration.
Traps
Each of these fails silently or reports something misleading. None announces itself in a log.
HEP support requires –features hep
The build omitted the hep feature, so the relay could never decode the control plane and every stream stayed orphaned.
media_creating_commands: 0
Control traffic is on the wire and in the filter, yet nothing decodes. Usually --hep-parse is on: it strips the HEP wrapper the ng detector matches against.
associated_dialog: null on every stream
The default capture filter admits SIP and the media range only. The kernel drops the ng port and its HEP mirror where no counter reports it.
Everything correct, still nothing named
Feature built in, ports in the filter, datagrams on the wire, and no stream ever gets a Call-ID. Before 0.5.128, --hep-parse unwrapped the HEP datagram and discarded the wrapper that identifies mirrored ng.
Correlation empty after a container restart
A sidecar shares the namespace of the service it watches. Recreating that service kills the sidecar, and a join over the surviving node reads as “no calls matched”.
Build says “Built”, version unchanged
Three layers set the OpenSIPS ref: the Dockerfile ARG, the compose args: block, and a git-ignored .env. The last wins, so editing the first two produces a full cache hit.
Unsupported link-type 12
SIPp refuses a pcap written with DLT_RAW and plays nothing.
SIPp rejects a <nop> that follows an optional="true" <recv>.
GET /vcons → 500
The plural path is in the conserver’s OpenAPI schema but fails, which reads as a broken store rather than as a caller using the wrong path.
Both WAV channels identical
Both ends replayed the same pcap, SSRC included, so the legs carried byte-identical audio.
Counts far higher than the calls placed
A background call loop keeps dialing during a measurement, so every figure is the calls under test plus whatever the loop fit in the same window.
Known limitation
The exported WAV is stereo, but on a relay capture both channels can carry the same leg. The relay sees each direction twice, arriving and leaving, so four stream records exist for two audio streams. Stereo export selects the first two exportable streams, which may be two copies of one SSRC.
The stored audio is real and provably intact. It is simply one side of the conversation duplicated across both channels. Selecting two streams with distinct SSRCs is the fix, and it belongs in sipnab rather than in the harness.
Reference
Everything the rest of the page refers to, in one place.
Ports
| Port | Service | Bind |
|---|---|---|
| 5060/udp | SIP — OpenSIPS | loopback unless deliberately exposed |
| 22222/udp | rtpengine ng control | reachable from the proxy only |
| 30000-30050/udp | rtpengine media | reachable from endpoints |
| 9060/udp | HEP mirror destination | a collector, or any UDP sink |
| 8080 / 8081 | sipnab REST — proxy / relay | loopback |
| 8731 / 8732 | sipnab MCP — proxy / relay | loopback |
| 8000 | conserver API | loopback |
sipnab flags that decide whether this works
| Flag | Default | Without it |
|---|---|---|
--hep-parse | off | For HEP-encapsulated SIP. Leave OFF on a relay — it strips the wrapper that identifies mirrored ng |
--retain-audio | off | Containers carry a dialog with no type and no bytes |
--api <addr> | off | No REST surface. A non-loopback bind requires an API key |
--portrange | 5060-5061 | Names which ports carry SIP, separate from the capture filter |
Endpoints
| Endpoint | Purpose |
|---|---|
GET /v1/stats | Node identity, counts, capture quality |
GET /v1/dialogs | Calls this node observed |
GET /v1/streams | Media, with the call it belongs to |
GET /v1/dialogs/{call-id}/vcon | Build a container for one call |
POST /vcon?ingress_lists=<list> | Store durably through a chain |
GET /vcon/{uuid} | Fetch a stored container |
Real output
Captured from a running stack, not written by hand. A synthetic sample teaches you the shape you expected rather than the one you get.
{
"capture_identity": {
"instance": "118cfa82b3c2df7bc-1",
"node": "<proxy-host>",
"dialog_generation": 1089,
"stream_generation": 0
},
"capture_name": "eth0",
"capture_quality": {
"degraded": false,
"kernel_dropped_packets": 0,
"interface_dropped_packets": 0,
"undecodable_frames": 0,
"snapped_frames": 0
},
"source": "live"
}
instance is what tells two nodes apart. Compare it before trusting any answer that claims to join them — two clients pointed at one sipnab otherwise agree with each other perfectly.
[email protected] 172.28.0.20:6000 -> 172.28.0.11:30006 PCMA 4500p jitter=0.00ms mos=4.36
[email protected] 172.28.0.11:30036 -> 172.28.0.21:6000 PCMA 4500p jitter=0.01ms mos=4.36
[email protected] 172.28.0.21:6000 -> 172.28.0.11:30036 PCMA 4500p jitter=0.34ms mos=4.36
[email protected] 172.28.0.11:30006 -> 172.28.0.20:6000 PCMA 4500p jitter=0.34ms mos=4.36
Four rows, two audio streams. Each direction appears twice because the relay sees it arrive and sees it leave. All four carry a Call-ID, which is what the mirrored control plane bought — without it every row reads associated_dialog: null.
{
"vcon": "0.4.0",
"uuid": "01a04347-5cdf-8d9a-a321-110075df1ef6",
"parties": [
{ "sip": "sip:[email protected]:5060",
"sip_display_name": "sipp",
"validation": "none" },
{ "sip": "sip:[email protected]:5060",
"sip_display_name": "sut",
"validation": "none" },
{ "role": "observer",
"sip_user_agent": "sipnab/0.5.127 (observer; node 5f65193f0712)",
"validation": "none" }
],
"dialog": [
{ "type": "recording-set",
"sip_call_id": "[email protected]",
"duration": 89.988,
"recordings": [1] },
{ "type": "recording",
"mediatype": "audio/x-wav",
"encoding": "base64url",
"duration": 30.0,
"content_hash": "sha512-ofYp0T-fqTjwjfX0hqYI3Rp0OB7c5X_6PbQRZBMizxsKEpU25ATg5mJzVN_2j0_uIvr952TIO7FbOrTsav9Ryg",
"body": "<1280638 base64url chars>" }
]
}
Three things to read here. Every party carries validation: "none" — the wire carried these names, and sipnab identified nobody. The third party is sipnab itself, declared as an observer, so a consumer can see that something which never took part produced the container. And the recording is 30.0 s of an 89.988 s call, which the container states rather than leaves you to notice.
Build features
cargo build --release \
--features "native,tls,api,mcp,mcp-http,audio,vcon,hep"
Drop hep and the relay cannot decode the control plane at all: sipnab refuses --hep-parse outright.
Two behaviors that surprise people
Neither is a bug. Both produce output that passes inspection while meaning something other than it appears to.
A stereo recording is not proof of two speakers
A relay observes each direction of a call twice — once arriving, once leaving — so a two-party call reaches an exporter as four stream records carrying two SSRCs, and the two records of one leg can precede either record of the other.
An exporter that takes the first two streams it finds therefore selects the same voice twice. The result is a stereo file whose channels are byte-identical: it claims a conversation and carries one side of it, and nothing in the file marks the difference.
sipnab selects one stream per SSRC and falls back to mono when only one source is present, because duplicating a leg across two channels reads as a two-party recording to anyone who opens it. If you build containers by another route, apply the same rule. To check an existing file, compare the channels — identical RMS across a whole recording means one leg, not a quiet line.
The same reasoning governs the omission caveat: it counts sources, not records. Counting records reports two of four streams missing on a call where sipnab captured both parties, and a caveat naming a loss that never happened trains a reader to disregard caveats.
A create response is not a retention guarantee
A conserver can accept a container, answer 201, and return it byte-identical on the next request while never having written it to durable storage. Cache-backed writes behave this way by design, and the cache commonly expires in about an hour.
A round-trip performed immediately after the write proves acceptance and says nothing about retention. Every check a careful reader runs — matching UUID, matching content hash, a WAV that decodes and plays — passes against a container that vanishes overnight.
Two habits close the gap. Post through an ingress chain, naming the list explicitly, so the container enters a pipeline that writes durably rather than landing in a cache. Then confirm by querying the durable table for the UUID, which asks a different question than the API does and takes no longer.
The listing route deserves the same suspicion: an index that only ever grows commonly backs it, so it offers UUIDs that expired hours ago and now answer 404.
Before this carries real calls
- Recording law is not a deployment detail. Consent requirements differ by jurisdiction and by who is on the call. Decide what the law lets you keep before building something that keeps it.
- Give each producer its own scoped key. The key decides the role, so a shared key is a producer that can claim any role.
- Audio is the payload, so size the retention. Ninety seconds of stereo G.711 is roughly a megabyte of base64 per call. Set a retention policy before the first busy day.
- Bind to loopback unless you mean otherwise. A SIP service on
0.0.0.0answers calls from anyone who can route to the host, and scanners find it. - The file store’s permissions are part of the design.
0600files in0700directories — call audio is not world-readable. - Never commit the secrets directory. It holds the signing key, the per-node tokens and the REST API key. Match by pattern, so a token for a node nobody has added yet is still covered.
- Captures and results are local artifacts. A pcap from a shared network may hold traffic that was never yours to publish.
Harness lives in harness/. Run make help for the full target list.