vCon Capture Stack - NormB/sipnab GitHub Wiki
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.
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.
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. |
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=30050OpenSIPS 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.
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-ngPoint 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();
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-5061Do 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.
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.jsonQuery 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) 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.jsonPost 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.jsonRead it back with its audio:
python3 ../clients/python/vcon_view.py <uuid> --audio call.wavDay-to-day operation once the stack is up. Every task here reads and changes nothing unless it says otherwise.
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'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'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.jsonStore 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.jsonList the containers the store holds:
python3 ../clients/python/vcon_view.pyShow one container's parties, dialog and recording:
python3 ../clients/python/vcon_view.py <uuid>Extract its audio to a WAV:
python3 ../clients/python/vcon_view.py <uuid> --audio c.wavThree 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. |
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.
| 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.
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.
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.
From a clone to a running stack. Docker and Docker Compose are the only prerequisites, and the repository builds everything else.
Every command below runs from the harness directory.
cd harnessTwo secrets: a long-lived HMAC key that mints rotating MCP bearer tokens, and a
REST API key. Both land in the harness secrets/ directory, which the
repository ignores by pattern. Both targets are idempotent.
make signing-keymake api-keyThe 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. The script
defaults to en_US-ryan-medium with SSRC 0x0CA11E12 for the caller and
en_US-amy-medium with SSRC 0x5AFE1234 for the callee.
./scripts/make-speech-pcaps.shThe sipnab image must carry vcon, audio and hep. Without hep the relay
cannot decode the control plane at all.
docker compose builddocker compose up -ddocker compose psDifferent capture_identity.node values 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_identitycurl -s -H "Authorization: Bearer $KEY" http://127.0.0.1:8081/v1/stats | jq .capture_identityIt places calls, waits for the relay to name the media, queries both nodes over
REST and MCP, and writes a timestamped report to results/e2e-<UTC>.md. CALLS
sets how many calls to place, and defaults to 3.
CALLS=3 ./scripts/run-e2e.shThe same call, the same audio, measured on both arrangements. One node or two changes where the evidence lives, not what it says.
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.
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.
Both run on the host, outside the containers. Read-only.
| Client | Door | What it proves |
|---|---|---|
../clients/python/leg_correlate.py |
REST 8080 / 8081 | Joins proxy dialogs to relay streams on associated_dialog; reports unnamed media rather than dropping it |
../clients/python/mcp_probe.py |
MCP 8731 / 8732 | Drives the door an agent uses; compares both nodes' answers |
../clients/python/vcon_view.py |
conserver 8000 | Lists stored vCons, renders one, extracts its audio as WAV |
The two 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. vcon_view.py reads the conserver instead, so the check does not apply to it.
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.
before without a mandatory message
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.
A relay capture makes it easy to export one leg twice. The relay sees each direction arriving and leaving, so four stream records exist for two audio streams, and an exporter that simply takes the first two it finds picks 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.
sipnab closed that in its own exporter. It walks the exportable streams in first-seen order, keeps one per SSRC, takes two, and falls back to mono when only one source is present — so a two-source call exports as two voices and a one-source capture says so by being mono rather than by faking a second channel. The omission caveat counts SOURCES rather than records for the same reason: counting records would report two of four streams missing on a call where sipnab captured both parties.
The trap still applies to anything building containers by another route. Apply the same rule there. To check a file you already hold, compare the channels — identical RMS across a whole recording means one leg, not a quiet line.
Everything the rest of the page refers to, in one place.
| 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 |
| 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 |
| 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 |
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.
Read that capture_quality block as the keys this page uses rather than as the whole object. Current builds also report invalid_timestamps, the unanswered STUN/TURN counters and ice_role_conflicts there. The REST reference carries the full list.
[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.
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.
Neither is a bug. Both produce output that passes inspection while meaning something other than it appears to.
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 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.
- 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.