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

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-rtp for 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 attributionSDP in the signalingrtpengine ng control plane
hep build featureNot neededRequired
HOMER mirroringNot neededRequired
A sink to absorb the mirrorNot neededRequired
Control ports in the BPFNot neededRequired
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.

RoleFileWhat it contains
Base · alwaysharness/docker-compose.ymlEvery service: OpenSIPS, rtpengine, both sipnabs, the SIPp endpoints and the HEP sink. On its own it stands up the two-node arrangement.
Overlay · co-locatedharness/docker-compose.single.ymlPuts 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-parse to name relay media

It is for HEP-encapsulated SIP: it unwraps the datagram and replaces the packet with the payload inside. sipnab recognizes sniffed rtpengine ng by 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 the hep build 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-audio the container carries a Dialog Object with no type and 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 /vcon with no ingress_lists writes 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.

QuestionWhere to lookA bad answer means
Did the relay see the control plane?caveats.media_creating_commands on /v1/statsZero 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 streamNull means the container describes a call with no audio.
Did the container survive?A row in the durable tablePresent 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

TaskHow
List what persistedQuery vcons_observed. The cache-backed listing route also returns entries that expired and now answer 404.
Fetch oneGET /vcon/{uuid}
Play the audioBase64url-decode dialog[].body where type is recording — it is a WAV.
Verify integrityCheck the recording’s content_hash against the body you hold.
Separate rolesQuery 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
ServiceRoleNamespace
opensips-proxyRegistrar and stateful proxy; anchors all media through rtpengineown
rtpengineMedia relay in userspace; mirrors its ng control plane as HEPown
sipnab-proxySignaling capture pointjoins opensips-proxy
sipnab-relayMedia capture point; decodes HEP to name streamsjoins rtpengine
sipp-uacPlaces calls, plays caller audioown
sipp-uasAnswers, plays different callee audio backown
hep-sinkAbsorbs the HEP mirror so rtpengine keeps sending itown

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: null and 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.

  1. 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
  2. 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
  3. Build and start The sipnab image must carry vcon, audio and hep. Without hep the relay cannot decode the control plane at all. docker compose build docker compose up -d docker compose ps
  4. Confirm both nodes answer Different 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_identity curl -s -H "Authorization: Bearer $KEY" \ http://127.0.0.1:8081/v1/stats | jq .capture_identity
  5. 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.

MeasuredWhat it is
4stream records
4 / 4named by Call-ID
2distinct SSRCs
4.36MOS, 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.

ClientDoorWhat it proves
clients/leg_correlate.pyREST 8080 / 8081Joins proxy dialogs to relay streams on associated_dialog; reports unnamed media rather than dropping it
clients/mcp_probe.pyMCP 8731 / 8732Drives the door an agent uses; compares both nodes’ answers
clients/vcon_view.pyconserver 8000Lists 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.

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.

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

PortServiceBind
5060/udpSIP — OpenSIPSloopback unless deliberately exposed
22222/udprtpengine ng controlreachable from the proxy only
30000-30050/udprtpengine mediareachable from endpoints
9060/udpHEP mirror destinationa collector, or any UDP sink
8080 / 8081sipnab REST — proxy / relayloopback
8731 / 8732sipnab MCP — proxy / relayloopback
8000conserver APIloopback

sipnab flags that decide whether this works

FlagDefaultWithout it
--hep-parseoffFor HEP-encapsulated SIP. Leave OFF on a relay — it strips the wrapper that identifies mirrored ng
--retain-audiooffContainers carry a dialog with no type and no bytes
--api <addr>offNo REST surface. A non-loopback bind requires an API key
--portrange5060-5061Names which ports carry SIP, separate from the capture filter

Endpoints

EndpointPurpose
GET /v1/statsNode identity, counts, capture quality
GET /v1/dialogsCalls this node observed
GET /v1/streamsMedia, with the call it belongs to
GET /v1/dialogs/{call-id}/vconBuild 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.0 answers calls from anyone who can route to the host, and scanners find it.
  • The file store’s permissions are part of the design. 0600 files in 0700 directories — 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.