vCon Export - NormB/sipnab GitHub Wiki
Something downstream wants the call rather than the packets.
- Examples are a conversation archive, a compliance store, or an agent that reasons over calls.
- Hand any of them a pcap, and you have made the decoding their problem.
- vCon is the interchange container those systems already read.
- sipnab writes one per observed dialog.
The short version. A vCon is a JSON object about one conversation. It holds:
- the parties,
- what passed between them,
- what some tool concluded about it.
sipnab fills that shape from signaling it watched go past a tap. Each container carries:
- the SIP ladder,
- the parties the
FromandToheaders named, - a diagnosis of the call,
- a statement of what the capture missed. This is in two places, deliberately.
When the run retained the RTP payload, the container also carries the audio, inline.
-
sipnab decodes PCMU, PCMA, G.722 and Opus, and G.729 on a host where
sipnab g729 installhas installed its tables (see G.729 decoding). -
sipnab leaves a stream in any other codec out, and the container's caveat names its codec. For a G.729 stream without the tables, the caveat says
G.729 not decoded: codebook tables not installed; run sipnab g729 install. -
It is the whole call, from its first frame, unless a retention limit cut it.
-
If a limit cut it, the container says so (see Audio retention budget).
-
sipnab writes no claim that somebody consented to anything.
-
A lawful basis appears only when an enrichment service you allow supplies one.
- The container names that service as its source.
-
None carries a signature unless you configure a rule to encrypt its containers (see Encrypt what a rule writes).
The lawful basis is in Enrich a call before it is written.
A sipnab vCon records what an instrument saw, not what the parties said. Read What may you conclude, below, before you trust a container. It is the one part of this page to read first.
vCon means "Conversation Data Container". It travels between systems as four arrays:
-
parties: who took part. -
dialog: what passed between them, and when. -
analysis: what a machine concluded about that. -
attachments: documents riding alongside.
The ecosystem around the format assumes a recorder.
- A recorder is something inside the conversation.
- It took the media from a party, and it can say what that party agreed to.
- A recorder can write "I received this audio from the caller."
sipnab was never inside the conversation. It reads a mirror port.
- So the strongest sentence it can honestly write is "I saw packets claiming to be this call go past this tap."
- Those two sentences look alike in JSON. They mean entirely different things.
So sipnab emits observer vCons.
- The container names sipnab as a passive party.
- That party contributes to a record somebody else owns. The format itself defines this role.
- The container stops there. It never claims to be the conversation.
- The Phase 0 decision argues that position in full. That includes every refusal summarized further down this page.
The export sits behind the non-default vcon Cargo feature. So a stock build
does not carry it. The full feature includes it. On its own, it looks like
this:
cargo build --release --features vconThe feature is non-default for the reason every capture-side feature is:
- A container that leaves the machine is a publication surface.
- A capture tool should not grow one unless an operator asks for it.
sipnab builds a container from one dialog. Every surface that offers the export writes the same bytes. Pick the door that suits you:
| You are | Ask this way | Answers with |
|---|---|---|
| At a shell, reading a capture file | sipnab -N -I call.pcap --export-vcon 'CALL-ID' --vcon-out out.json |
the container in out.json
|
| At a shell, wanting it on stdout | sipnab -N -I call.pcap --export-vcon 'CALL-ID' |
the container on stdout |
| An agent holding an MCP session, one call | the export_vcon tool, with call_id
|
one container, its SHA-256, and what the capture missed |
| An agent holding an MCP session, a set | the export_vcon tool, with filter
|
one entry per matching dialog, bounded by --mcp-max-rows
|
| An agent checking a container before sending it | the validate_vcon tool |
a verdict against the working group's draft-ietf-vcon-vcon-core-04 schema, with every finding named |
| A program over HTTP | GET /v1/dialogs/{call_id}/vcon |
200 and the container, or 404
|
| Rust, in-process | sipnab::output::vcon::export_dialog |
a Vcon value |
Two notes on the CLI pair:
-
--vcon-outrequires--export-vcon. A path with nothing to write to it is a mistake. sipnab would rather name it than ignore it. - sipnab refuses a
--vcon-outthat names the capture it is reading. An export that overwrites its own evidence is not an export.
On a live capture (-d or --hep-listen), --export-vcon writes the call's
container when that call ends.
- It uses the same timing the spool below follows:
- the call has ended,
- it has been quiet for five seconds,
- sipnab keeps capturing meanwhile.
- sipnab writes a call that has not ended once it has carried no SIP
and no RTP for 120 seconds.
- It writes it again if it changes later (see When the call had not ended).
- A live capture that you stop writes nothing.
- So a call that had not ended and settled leaves no
--vcon-outfile behind.
- So a call that had not ended and settled leaves no
- A live run can end on its own (
--duration,--autostop).- It then writes the call, if it was not already written.
- It still fails if that Call-ID never appeared.
- With
-I, sipnab writes the container at the end of the run, as before.
The TUI does not offer the export.
- It is a live view of a running capture.
- It points at the doors above. Its help screen names the REST route and the feature flag.
- It does not grow a key binding. That binding would do nothing in a build without the feature.
Read on for what the container means once you hold one.
A [[vcon.rule]] in the config file pairs a filter with two things:
- the directory its containers go to,
- what they carry.
A run can hold several rules. sipnab writes a call that matches several rules once per rule, into each rule's directory.
[filters]
support = "to.user == '7000' OR header.X-Queue == 'support'"
[[vcon.rule]]
name = "support-with-audio"
when = "@support"
dir = "/var/spool/sipnab-vcon/support"
audio = "inline"
[[vcon.rule]]
name = "all-calls"
when = "method == 'INVITE'"
dir = "/var/spool/sipnab-vcon/all"| Key | Meaning |
|---|---|
name |
The rule's name, used in the run's report and to pick its spool over REST and MCP. 1 to 64 ASCII letters, digits, - or _
|
when |
The filter a call must match. Named filters work here as @name
|
dir |
The directory the rule writes to. sipnab creates it if it is missing |
audio |
inline: the audio the run retained travels inside the container, within --vcon-max-inline-media. omit (the default): no audio, and the container records media: "not-considered". A rule with inline turns audio retention on for the run, as --retain-audio does |
audio = url
|
sipnab writes the audio to a file beside the container, <container name>.wav (or .ogg with audio_format = "opus"), and the container's recording names audio_url_base followed by that file name, with the content_hash of the file and no body; the completeness block records media: "referenced". sipnab writes the file and serves nothing: the operator's storage serves the URL. Turns audio retention on, as inline does |
audio_url_base |
For audio = "url": the HTTPS base URL that serves the audio files. Required with url; core-04 section 2.4.1 requires HTTPS for a reference |
audio_format |
wav (the default): the recording is a WAV, audio/x-wav. opus: Ogg Opus, audio/ogg, about a fifth of the size. Needs audio = "inline" or "url". See Audio as Ogg Opus
|
max_inline_media_mib |
The largest audio this rule's containers inline, in MiB. sipnab writes a call above it without its audio and the container records media: "refused-over-budget". Unset: --vcon-max-inline-media
|
live_audio |
true: serve each matching call's audio while it is in progress, over the REST API. See Hear a call while it is in progress
|
live_rtp |
A UDP host:port: send each matching call's audio there as RTP while it is in progress. See Send a call's audio as RTP
|
live_rtp_allow_remote |
true: let live_rtp name an address other than loopback. See Send a call's audio as RTP
|
live |
true: on a live capture, write each matching call's container as the call starts and rewrite it in place each time the call changes. See Follow a call while it is in progress
|
encrypt_to |
A PEM certificate, or a list of them. Set, the rule writes only the encrypted form, once, to every certificate's key. Needs sign_key and sign_cert. See Encrypt what a rule writes
|
sign_key, sign_cert
|
The PEM RSA or EC P-256 private key an encrypting or sign_only rule signs with, and its certificate chain, leaf first |
sign_only |
true: write the signed form alone, with no encryption. See Sign without encrypting
|
compress |
For an encrypting rule: none (the default) or gzip, which signs the gzip of the vCon instead of the vCon. See Encrypt what a rule writes
|
sealed_updates |
What an encrypting rule writes when a call it already wrote changes: new-uuid (the default), same-uuid or final-only. No effect without encrypt_to. See When an encrypted call changes
|
grouping |
leg (the default): one container per dialog. call: one container per call, holding every leg the rule selects. See Calls through a PBX or a B2BUA
|
Other options work with rules:
-
--export-vcon-whenwith--export-vcon-dirstill works. It is one more rule beside the configured ones. -
--sinceand--untilnarrow every rule. -
--content-deny-headerapplies before every rule. - Each rule's directory is a spool the forwarder can deliver. Run one
--vcon-forwardper directory.
A rule runs the same way as --export-vcon-when.
- With
-I, sipnab writes every matching call at the end of the run. - On a live capture, sipnab writes each call on the timing described under
When a container appears.
- It uses each rule's own record of what it has written.
- On a live capture, sipnab gives back a call's retained audio only once every
inlinerule that matches the call has written it.
sipnab checks every rule when it starts. It refuses to start, with exit code 1, and names the rule, when:
- a name is unusable or used twice,
- a rule has no
dir, -
audiois notinline,urloromit, -
audio_formatis notwavoropus, - a rule whose
audioisomitsetsaudio_format, -
audio_formatisopusand the build has noopusfeature, -
groupingis notlegorcall, -
whendoes not resolve.
sipnab also refuses a rule with any other key. So a misspelled key cannot leave a rule that writes nowhere.
A live capture with a rule refuses --redact, --redact-map
and --content-deny-tombstone. It does the same with --export-vcon-when.
The end of a run reports each rule on its own line, for example
[[vcon.rule]] 'all-calls': Wrote 2 vCon container(s) to '/var/spool/sipnab-vcon/all'.
audio_format = "opus" writes a rule's recordings in Ogg Opus instead of WAV:
[[vcon.rule]]
name = "support-with-audio"
when = "@support"
dir = "/var/spool/sipnab-vcon/support"
audio = "inline"
audio_format = "opus"What the recording is:
- The Opus codec (RFC 6716) in an Ogg stream (RFC 7845).
- Its
mediatypeisaudio/ogg.-
core-04 section 4.3.8 lists
audio/oggamong the media types a Dialog Object SHOULD use. - It carries no
codecsparameter. Section 2.2 defines a media type astype "/" subtypealone.
-
core-04 section 4.3.8 lists
- Its
content_hashis over the Ogg bytes. - It holds the same audio the WAV would. sipnab encodes the PCM it decoded for the WAV.
- The provenance note the WAV carries travels as the stream's
DESCRIPTIONcomment.
The bitrate is 24 kbit/s per channel, constant.
- RFC 7587 section 3.1.1 recommends 8 to 12 kbit/s for narrowband speech and 16 to 20 kbit/s for wideband speech. 24 kbit/s is above both.
- Constant, because RFC 7587 section 3.1.2 names an encrypted stream as the reason to choose a constant bitrate. A rule with
encrypt_toencrypts the recording. - A two-way call is two channels: 6,000 bytes of audio a second.
- Each Opus packet is at most 1275 bytes (
MAX_PACKET(1275) in the source), the largest RFC 6716 section 3.4 allows.
The inline budget applies to the encoded size. Measured on tests/pcap-samples/SIP_CALL_RTP_G711, a two-way G.711 call of 17.502 seconds exported as 8 kHz stereo:
| Format | File | base64url in the container | Seconds in the 5 MiB default |
|---|---|---|---|
wav |
560,444 bytes | 747,259 bytes | 122 |
opus |
106,917 bytes | 142,556 bytes | 643 |
Which builds carry it:
-
opusis its own Cargo feature, infull. It compiles libopus from source, which needscmakeand a C compiler. - The gnu tarballs and packages, the macOS tarballs and the Docker image carry it.
- The static musl tarballs and the
-noaudiopackages do not. They have no vCon export. -
sipnab --versionlistsopuswhen the binary carries it. - A build without it refuses
audio_format = "opus"at startup and names the feature.
A call through a PBX or a B2BUA is two dialogs or more. There is one per leg, and each has its own Call-ID.
sipnab ties the legs with the same correlation strategies that
get_call_tree and GET /v1/dialogs/{call_id}/tree report:
- an identifier both legs carry. This is one of:
-
RFC 7989
Session-ID, - a header listed in
[sip] xcid_headers, - the SDP origin,
- a charging vector,
- a Via branch.
-
RFC 7989
- when the legs share none, a timing guess from shared addresses and close start times.
With grouping = "leg", the default, each leg is its own container.
- Every export adds a
sipnab-related-legsattachment to a leg that has others. The exports are:- a rule,
-
--export-vcon-when, -
--export-vcon, - REST
/v1/dialogs/{call_id}/vcon, - MCP
export_vcon.
- Its body lists each other leg, strongest tie first:
{"legs": [{"uuid": "018bcfe5-6800-88c0-b9c0-24e4c93555ff",
"strategy": "x_call_id", "identifier_match": true, "score": 100}]}-
uuidis the other leg's container uuid. It stays the same under--redact. -
identifier_matchisfalsefor a timing guess. A busy server can get a timing guess wrong.
With grouping = "call", the rule writes one container per call. It holds
every leg the rule selects:
- the parties of each leg in leg order, then the sipnab observer, last;
- each leg's Dialog Objects in leg order;
- each leg's message trace, completeness attachment and analysis. These point at that leg's objects;
- with
audio = "inline"or"url", each leg's own two-channel recording.- Its
partiesmaps each channel to a party of that leg. So every party of the call has a channel. - sipnab does not mix the legs into one file. They start at different times and can use different sample rates.
- Each recording keeps the timing sipnab captured it with;
- Its
- a
sipnab-call-legsattachment. It names:- each leg's signaling object,
- every tie, with its strategy,
identifier_matchand score, -
truncated: truewhen the call had more than 16 legs and the rest went into further containers.
More about the per-call container:
- The container has its own uuid, derived from every leg's Call-ID.
- So it is the same each time sipnab exports the capture.
- It differs from every leg container's uuid.
- sipnab names it after the earliest leg.
- A PBX can be the callee on one leg and the caller on the next.
- It appears once in each leg's parties.
- Nothing in the signaling proves the two are one party.
- Some legs are not in the container:
- a leg the rule's
whendoes not match, - a leg that a
--content-deny-headerwithheld. - So write
whento match every leg of the calls you want.
- a leg the rule's
- On a live capture, sipnab writes a call once every leg the rule selects has ended or gone idle.
- It writes it again when a leg changes.
leg is the default for this reason:
- A PBX that copies no identifier gives only one tie: the timing guess.
- Grouping on a wrong guess puts two unrelated calls in one container.
- A container written with
callstates every tie sipnab built it on. So a reader can see when that happened.
A [[vcon.enrich]] names a service of yours, such as a transcriber or a summarizer.
- sipnab sends that service each call of the rules it lists. It does this before it writes the call's container.
- sipnab appends the service's answer to the container's
analysis. The answer is one or more Analysis Objects. - sipnab chooses no model and runs none. The service is whatever you run at the URL or program you name.
[[vcon.rule]]
name = "support"
when = "@support"
dir = "/var/spool/sipnab-vcon/support"
audio = "inline"
[[vcon.enrich]]
name = "transcribe"
rules = ["support"]
url = "http://127.0.0.1:9000/transcribe"
timeout_secs = 120
[[vcon.enrich]]
name = "summarize"
rules = ["support"]
command = "/usr/local/bin/summarize-call"
send = ["signaling"]The services a rule names run in the order the config file lists them, one call at a time.
- So sipnab sends a later service what an earlier one added.
- In the example,
summarizereads the transcripttranscribereturned.
The request is one JSON document, POSTed to url or written to the
command's standard input:
{"schema": "sipnab-enrichment-request/1", "service": "transcribe",
"vcon": {"uuid": "...", "parties": [], "dialog": [], "analysis": []},
"audio": [{"dialog": 0, "sip_call_id": "...", "mediatype": "audio/wav",
"encoding": "base64url", "body": "UklGR..."}]}-
vconis the container as sipnab writes it, with one exception. Itsanalysisholds only what earlier services added, not sipnab's own report.- It is present when
sendincludessignaling.
- It is present when
-
audiois present whensendincludesaudioand the run retained the call's audio.- It holds one WAV per leg.
-
dialognames the leg's signaling Dialog Object. - Audio a key could not decrypt is never sent, because it is never decoded.
The answer is a JSON document with an analysis list. Each entry is a
core-04 Analysis Object:
{"analysis": [{"type": "transcript", "vendor": "example-stt",
"product": "stt-2", "encoding": "none",
"mediatype": "text/plain", "body": "Hello, this is support..."}]}Each Analysis Object must carry:
-
type, -
vendor, -
encoding(none,jsonorbase64url), -
body.
The shape of body follows its encoding.
- With
jsonthe body is any JSON value, as section 2.3.2 of the core draft defines it. sipnab keeps it as sent, a string included. - With
noneorbase64urlthe body must be a string.
product, schema, mediatype and dialog are optional. A missing dialog is the first leg's signaling object.
A WTF transcription. An Analysis Object of type wtf_transcription is a
transcription in the World Transcription Format of
draft-howe-vcon-wtf-extension-02.
sipnab checks it before it keeps it:
-
encodingisjsonand the body is a JSON object, as section 6.1 of the WTF draft requires. - The body has the three sections
section 4.2 of the WTF draft
requires, with the members
section 6.2.1 of the WTF draft
lists:
-
transcript:text,language,durationof 0 or more, andconfidencefrom 0 to 1. -
segments: each with a unique integerid,start, anendafterstart,text, andconfidencefrom 0 to 1. Aspeakeris an integer or a string, andwordsis an array of integers. -
metadata:created_at,processed_at,providerandmodel.
-
- The optional sections,
words,speakers,quality,extensions,alternatives,enrichmentsandstreaming, stay as the service sent them.
A container that holds a WTF transcription lists wtf_transcription in
extensions. It is not in critical:
section 5.1 of the WTF draft
classifies the extension as Compatible. An answer whose WTF transcription
fails the check is malformed, and its detail names what is wrong.
A transcriber can listen to a call on the live audio WebSocket while the call is in progress:
- sipnab sends the service nothing during the call, and reads nothing from it.
- At the call's end, sipnab sends the service its
[[vcon.enrich]]request. - The service answers with its final transcription. A
streamingsection, section 6.2.2.7 of the WTF draft, can carry the session'ssession_idandis_final: true. - Interim results stay with the service: sipnab has no route that receives them.
A lawful basis from a service. sipnab writes no lawful basis of its
own. A [[vcon.enrich]] with lawful_basis = true may answer with an
attachments list beside analysis, each entry a lawful basis attachment of
draft-howe-vcon-lawful-basis-02:
{"analysis": [],
"attachments": [{"purpose": "lawful_basis", "start": "2026-10-13T12:15:30Z",
"party": 0, "dialog": 0, "encoding": "json",
"body": {"lawful_basis": "consent", "expiration": null,
"purpose_grants": [{"purpose": "recording", "granted": true,
"granted_at": "2026-10-13T12:15:30Z"}]}}]}- sipnab checks the shape
section 5 of the lawful basis draft
gives:
-
purposeislawful_basisandencodingisjson. -
partyanddialogindex the container, andstartis an RFC 3339 time, which the core draft's schema requires. -
mediatype, when present, isapplication/json. No other member is allowed, because sipnab could not carry it as sent. - The body has
lawful_basis, one of the six values section 5.2.1 names,expiration, a time ornull, andpurpose_grants, each withpurpose, a booleangranted,granted_at, andconditionsstrings when present. -
terms_of_serviceandstatus_intervalare strings.content_hashandregistryhold the strings section 5.2.2 lists, and eachproof_mechanismsentry hasproof_type,timestampand aproof_dataobject.
-
- sipnab carries the attachment as the service sent it, writing
mediatype: "application/json"when the service left it out. The container listslawful_basisinextensions, and not incritical: section 4.1 of the lawful basis draft classifies the extension as Compatible. - The service's line in the
sipnab-enrichmentattachment lists the attachment's index underattachments, so a reader knows which service supplied it. sipnab asserts nothing about it. - sipnab does not verify
content_hash.value: section 5.2.2 hashes the canonical form of the body and does not say whether that body includes thecontent_hashmember itself. sipnab does not judgeexpirationeither, because it processes nothing under the lawful basis. - Without
lawful_basis = true, an answer that holds any attachment ismalformed, and sipnab keeps none of it.
Where each analysis came from. Each Analysis Object a service adds carries
a provenance record, as
draft-howe-vcon-provenance-00
defines it, and the container lists provenance in extensions. It is not
in critical:
section 3 of the provenance draft
classifies the extension as Compatible.
{"type": "transcript", "vendor": "example-stt", "product": "stt-2",
"encoding": "none", "body": "Hello, this is support...",
"provenance": {
"model": {"vendor": "example-stt", "name": "stt-2"},
"generated_at": "2026-10-13T14:22:05Z",
"inputs": [{"element": "dialog", "index": 0, "content_hash": "sha512-..."},
{"element": "attachment", "index": 0, "content_hash": "sha512-..."}],
"output_hash": "sha512-..."}}-
modelandgenerated_atare the members section 4.2.1 of the provenance draft requires.- An answer entry may carry its own
provenancewith them. sipnab keeps itsmodel,generated_at,parameters,prompt,softwareandregistry. - Without one,
model.vendoris the entry'svendor, andmodel.nameitsproduct. With noproduct,model.nameis the[[vcon.enrich]]name, the only name sipnab holds for the service. - Without one,
generated_atis when sipnab read the answer. - sipnab refuses an answer whose
provenancehas nomodel.vendor,model.nameor RFC 3339generated_at, asmalformed.
- An answer entry may carry its own
- sipnab always writes
inputsitself, from what it sent the service, and replaces any the service sent. A service's own indices count the request'sanalysislist, which starts after sipnab's report.- With
signaling: each Dialog, Attachment and earlier Analysis Object sent, by its index in the container. Eachcontent_hashis over the element's RFC 8785 canonical JSON as sent. - With
audio: each leg's signaling Dialog Object. Itscontent_hashis over the WAV sent, the value the Dialog Object's owncontent_hashtakes when the rule inlines the audio.
- With
- sipnab always writes
output_hashitself, over the body it carries:-
json: the RFC 8785 canonical JSON. -
base64url: the decoded bytes. -
none: the text's UTF-8 bytes.
-
- Every hash is
sha512-and the base64url digest, the form section 4.3 of the provenance draft gives.
The record says what sipnab sent the service and what it received. It does not say what the service's model read: a service may use part of what sipnab sent it.
What the container says. Every enriched container carries a
sipnab-enrichment attachment. It lists each service sipnab sent it to, with:
- the service's name,
- its
target(the URL or program), - what was
sent, - the
outcome, - a
detailin words, when it did not succeed, - the indices in
analysisof what it added, - the indices in
attachmentsof any lawful basis it supplied.
So a reader of the container knows the call's audio left the host, and to which service.
The attachment also records the services that added nothing, which no provenance record can.
outcome |
Meaning |
|---|---|
ok |
The service answered and sipnab appended its analysis |
failed |
sipnab could not reach the service, the service answered with an HTTP status other than 2xx, or the program exited non-zero |
timeout |
No answer within timeout_secs
|
malformed |
The answer was not an analysis list of Analysis Objects; sipnab kept none of it |
queue-full |
On a live capture, 64 calls were already waiting for enrichment; sipnab sent nothing |
A service that does not succeed never loses the call:
- sipnab writes the container without that service's analysis.
- The attachment records why.
- The run says so on stderr. It names the service and the Call-ID. It also names the rule when it is a
[[vcon.rule]].
When it runs.
- With
-I, sipnab enriches each matching call as it writes the call's container at the end of the run. - On a live capture, a call that is due goes to a queue of at most 64 calls.
- One worker enriches and writes them in turn.
- So a slow service delays containers and never the capture.
- sipnab writes a call that finds the queue full at once, unenriched, saying so.
- A live run that ends on its own (
--duration) waits for the queue before it exits. - A run that you stop does not wait. sipnab does not write the calls still waiting.
What sipnab refuses at startup, naming the enrichment:
- an unusable or repeated name
- no
rules, or a rule name no[[vcon.rule]]defines - both or neither of
urlandcommand - an
http://URL to anything but a loopback address (sipnab does not send call audio in the clear across a network) - a
sendvalue other thanaudioandsignaling - a
timeout_secsoutside 1 to 3600 - an
auth_filethat does not load
sipnab also refuses a run with --redact and any [[vcon.enrich]].
- sipnab sends a service the call as it captured it.
- It cannot replace anything the service answers with pseudonyms.
A dialog that --content-deny-header withheld is never sent to a service. It
is not exported at all.
A rule with live_audio = true serves the audio of the calls it matches as
sipnab captures them.
- It serves them for a transcriber or any other program of yours.
- It serves them on the REST API (
--api):
[[vcon.rule]]
name = "support"
when = "@support"
dir = "/var/spool/sipnab-vcon/support"
audio = "inline"
live_audio = true-
GET /v1/calls/livelists the calls in progress the rule matches, andGET /v1/calls/live/eventsannounces each one as a server-sent event. -
GET /v1/calls/{call_id}/audiois a WebSocket that sends the call's audio as 16-bit PCM, one message per RTP packet, each direction a stream of its own, and closes when the call ends.
The GET /v1/calls/:call_id/audio section of the
REST API reference gives every message.
- The audio is what sipnab retained, decoded. A
live_audiorule turns retention on. - Audio a key could not decrypt is never sent.
- A call that
--content-deny-headerwithholds is neither listed nor served.
Who may listen.
- These routes need a token with the
audioscope when the server authenticates (sipnab --mint-token --token-scope audio). - No other scope reaches them. A
fulltoken and the static--api-keyread dialogs and not call audio. - On a loopback address, a server without keys serves them to any local process.
- Off loopback, sipnab refuses to start a
live_audiorule unless the API has both:- TLS (
--api-tls-certand--api-tls-key), - a signing key (
--api-signing-keyor--api-signing-key-file).
- TLS (
- So the audio never crosses the network in the clear. It never goes to a client that has not proved it may hear it.
A [[vcon.enrich]] service on the same
rule can return, at the call's end, what a transcriber produces during the call.
So the transcript is in the call's container.
A rule with live_rtp sends the audio of the calls it matches, as sipnab
captures them, to a UDP address as RTP.
- It suits a program that takes audio in on a UDP port, such as a local speech-to-text service.
-
live_audioserves the same audio for a client to fetch;live_rtpsends it without a client asking.
[[vcon.rule]]
name = "support"
when = "@support"
dir = "/var/spool/sipnab-vcon/support"
live_rtp = "127.0.0.1:40000"What arrives at the address.
- Each leg of the call is one RTP stream (RFC 3550).
- A leg is one direction of the call's audio: one captured RTP stream.
- Each stream has an SSRC of its own. Every stream goes to the one address, so a receiver opens one port and tells the legs apart by SSRC.
- The payload is L16 (RFC 3551 section 4.5.11):
16-bit signed samples in network byte order, 16 kHz, one channel.
- Payload type 96, a dynamic type. The static L16 types, 10 and 11, are 44.1 kHz.
- 320 samples (20 ms) a packet. Only the last packet of a leg can be shorter.
- sipnab interpolates G.711 (8 kHz) up to 16 kHz, and Opus (48 kHz) down. G.722 decodes at 16 kHz.
- The first packet of each leg carries the marker bit. The sequence number steps by one and the timestamp by the samples in the packet before.
- Where the captured RTP timestamps show time passed with no audio, a lost packet or the sender pausing, the leg carries silence of that length, as the live audio route does.
- Control messages arrive at the same address, one JSON object a datagram.
- A control message starts with
{. An RTP packet never does: its first byte is 0x80 to 0xBF, the range RFC 7983 section 7 gives RTP on a shared port.
- A control message starts with
type |
When | Carries |
|---|---|---|
start |
Once per call, before its first packet |
call_id, rule, and legs: each leg sipnab holds so far |
leg |
A leg first seen after the start, before its first packet |
call_id, rule, and the leg
|
stop |
Once per call, after its last packet |
call_id, rule, reason, and legs with what each one sent |
Each leg is an object:
| Key | Meaning |
|---|---|
index |
The leg's place in the call, from 0, in the order sipnab first saw them |
status |
sending, withheld (SRTP that no key decrypted) or skipped (a codec sipnab does not decode) |
reason |
Why a withheld or skipped leg sends nothing |
ssrc |
The SSRC of the stream sipnab sends, for a sending leg |
payload_type, encoding, rate, channels
|
96, "L16", 16000, 1, for a sending leg |
captured |
The captured stream: its ssrc, src and dst addresses, codec, rate and packets so far |
packets, samples
|
What sipnab has sent of the leg |
missed_frames |
Frames sipnab released from retention before it sent them |
failed_frames |
Frames the decoder refused |
undecrypted_packets |
Captured SRTP packets that no key decrypted |
The reason of a stop is one of:
-
the call ended, -
the call is no longer held, -
the call carries the --content-deny-header header, -
the capture ended: the input ended, or a live capture reached-cor its duration, with the call still up, -
sipnab stopped: sipnab received a stop request, such as SIGTERM. It sends each call's stop at once, and none of the audio still queued.
A control message is one UDP datagram, and UDP does not resend a lost one. The
RTP packets still carry their SSRC, and the stop repeats every leg.
When sipnab sends the audio.
- As sipnab captures it. sipnab looks for new audio every 20 ms.
- Audio that waits, such as a capture file read faster than real time, goes
at up to
MAX_PACKETS_PER_PASS(10) packets a leg every 20 ms: ten times real time. - With
-I, sipnab sends every queued packet and each call's stop before it exits. The calls that had not ended stop withthe capture ended. - sipnab tells you at startup when a rule sends the audio of a capture file.
What sipnab never sends.
- Audio a key could not decrypt. Such a leg is
withheld, with the reason, and gets no stream. - A call that
--content-deny-headerwithholds. A call that gains the header while sipnab sends it stops at once.
Where it may go.
- sipnab refuses to start, with exit code 1, when
live_rtpnames an address other than loopback, unless the rule also setslive_rtp_allow_remote = true.- RTP has no encryption and no authentication. Off loopback, anyone on the path can hear the call.
- Set the opt-in only on a network you control.
- It also refuses a
live_rtpthat does not resolve to an address. sipnab resolves it once, at startup. - A
live_rtprule turns audio retention on, aslive_audiodoes. -
live_rtpruns in a headless run (-N). A--coresrun and the terminal UI send nothing, and say so at startup.
By default, a rule writes a call once, after it has ended (or gone quiet for 120 seconds).
A rule with live = true works differently:
- It writes the call's container as soon as sipnab sees the call start.
- It writes it again each time the call changes. It uses the same file name and
the same
uuid. - So a program watching the spool sees the call as it goes:
[[vcon.rule]]
name = "follow"
when = "@support"
dir = "/var/spool/sipnab-vcon/follow"
live = true- Each write carries
updated_at, the time of that write.created_atstays the time of the first write. - Until the call ends, the completeness block records
call_ended: false. The last write, after the call ends, recordstrue. - A container written while the call is in progress carries no audio, whatever
audiosays.- The write after the call ends carries it.
- Enrichment (
[[vcon.enrich]]) runs only on that last write.
- sipnab checks for changes every five seconds. So a write follows a change
within about five seconds.
- A sweep in which nothing changed writes nothing.
-
livehas an effect only on a live capture (-dor--hep-listen).- With
-I, the rule writes each call once, as any rule does, with noupdated_at.
- With
A forwarder delivers each rewrite as a new copy of the same container.
- A store that refuses a
uuidit already holds needs the forwarder to replace it. - See What it does with each container.
- A rule with
encrypt_todoes not rewrite in place by default. See When an encrypted call changes.
A rule with encrypt_to writes each container in the encrypted form of
draft-ietf-vcon-vcon-core-04 section 5.3.
So the spool, the forwarder's directories and the store hold nothing a reader
without the key can open:
[[vcon.rule]]
name = "support"
when = "@support"
dir = "/var/spool/sipnab-vcon/support"
audio = "inline"
encrypt_to = "/etc/sipnab/vcon-recipient.crt"
sign_key = "/etc/sipnab/vcon-signing.key"
sign_cert = "/etc/sipnab/vcon-signing.crt"The draft requires the producer to sign a container before it encrypts it. So the rule does both, in this order:
- sipnab signs the container with
sign_key(JWS,RS256).- The signature carries the
sign_certchain inx5c, in the signature's unprotectedheader.- core-04 section 5.2 requires it there.
- The protected header holds
alg.
- The signature carries the
- sipnab encrypts that signed container to the RSA key in the
encrypt_tocertificate.- It uses JWE:
RSA-OAEPfor the content key,A256CBC-HS512for the content. - It uses a fresh content key for every write.
- It uses JWE:
Compress before encrypting.
-
compress = "gzip"compresses the vCon with gzip (RFC 1952) before sipnab signs it.- The JWS payload is then the gzip of the vCon, and the signature covers those bytes.
- core-04 section 5.2 allows this.
- The encrypted form then names
cty: "application/vcon+gzip", the second value section 5.3.1 lists.
- Encrypted data does not compress, so a rule that wants a smaller file compresses before encryption.
- Section 6.2 says applications SHOULD accept a gzip vCon and need not send one.
-
compress = "none", the default, writes the vCon uncompressed. - sipnab writes no JWE
zipheader.
The file holds the JWE: unprotected, recipients, iv, ciphertext and
tag.
-
Its
unprotectedheader carries:- the container's
uuid; -
enc: "A256CBC-HS512"; -
cty: "application/vcon", orapplication/vcon+gzipwithcompress = "gzip". - core-04 section 5.3.1 lists these.
- A store and the forwarder's replace URL can use the uuid without the key.
- the container's
-
Each recipient's
headercarriesalg: "RSA-OAEP". -
With nothing left for a protected header to hold, the JWE has no
protectedmember. -
sipnab 0.5.217 and 0.5.218 wrote
algandencin aprotectedmember, andx5cin the signature's protected header.- The fetcher reads those containers too.
-
sipnab names each file
<uuid>.vcon.jsonrather than after the Call-ID.- A Call-ID can carry host names and addresses, and the file name is not encrypted.
-
Its
unprotectedheader carries the container'suuidandcty: "application/vcon+json".- So a store and the forwarder's replace URL can use the uuid without the key.
-
sipnab names each file
<uuid>.vcon.jsonrather than after the Call-ID.- A Call-ID can carry host names and addresses.
- The file name is not encrypted.
Decrypt one.
- The recipient opens the container with the private key of
the
encrypt_tocertificate. - Then the recipient verifies the signature against the
certificate in
x5c. - sipnab reads it back with
-Iand--vcon-key(A signed, encrypted or compressed container). - The working group's reference tool is the
vconcommand of vcon-dev/vcon.- It does this with
vcon decrypt <key> <cert>and thenvcon verify <ca cert>.
- It does this with
- sipnab's tests check its output against the draft's own encrypted example (Appendix A.7):
- sipnab decrypts that example and verifies its signature.
- It signs the same bytes with the same key to the same signature.
Encrypt to several recipients. encrypt_to takes a list of
certificates. sipnab encrypts the container once, with one content key, and
writes one entry per certificate in its recipients array
(RFC 7516 section 7.2.1).
Each recipient opens it with its own private key:
[[vcon.rule]]
name = "support"
when = "@support"
dir = "/var/spool/sipnab-vcon/support"
encrypt_to = ["/etc/sipnab/vcon-store.crt", "/etc/sipnab/vcon-legal.crt"]
sign_key = "/etc/sipnab/vcon-signing.key"
sign_cert = "/etc/sipnab/vcon-signing.crt"Audio by URL. With audio = "url", an encrypting rule writes the audio
beside the container as its own JWE:
- sipnab encrypts it to the same
encrypt_tocertificates as the container, with onerecipientsentry for each.- Its plaintext is the WAV, or the Ogg Opus stream with
audio_format = "opus". - Its
unprotectedheader carriescty, the media type of that plaintext, and nouuid. - sipnab encrypts it and does not sign it. The signed container names its
content_hash.
- Its plaintext is the WAV, or the Ogg Opus stream with
- sipnab names it
<content_hash>.wav.jweor<content_hash>.ogg.jwe, by the hash of the file as written.- The name carries no Call-ID, for the reason the container's does not.
- The container's recording names:
-
url:audio_url_basefollowed by that file name; -
mediatype: the media type of the audio inside,audio/x-wavoraudio/ogg; -
content_hash: the hash of the file as written, which is the ciphertext.
-
- Why the hash covers the ciphertext:
-
core-04 section 2.2 defines the hash over the body of the content at the
url, and the body your storage serves is the JWE. - The draft does not say whether
mediatypedescribes that body or the media inside it. sipnab names the media inside, which a reader needs after decrypting.
-
core-04 section 2.2 defines the hash over the body of the content at the
- Each write uses a fresh content key, so each write of a call writes a new file.
- sipnab deletes none of them.
- Under
sealed_updates = "new-uuid", the earlier instances name the earlier files. Undersame-uuid, nothing names them.
- To read one,
--vcon-audio-fetchfetches the file, checks its SHA-512 against the container'scontent_hash, and decrypts it with the private key of theencrypt_tocertificate. See Fetch the audio a vCon names.-
--vcon-fetchfetches containers. It does not fetch the URLs inside them.
-
What the keys must be.
-
encrypt_toandsign_certhold PEM certificates. Each key is RSA, at least 2048 bits, or EC on the P-256 curve.- An RSA recipient's entry names
alg: "RSA-OAEP", the value core-04 section 5.3.3 names. - An EC recipient's entry names
alg: "ECDH-ES+A256KW"and carries theepk, the ephemeral key sipnab agreed the key-encryption key with (RFC 7518 section 4.6). sipnab wraps the content key with AES key wrap (RFC 3394).
- An RSA recipient's entry names
-
sign_keyis a PEM private key, readable by its owner only:- RSA (
PRIVATE KEYorRSA PRIVATE KEY), which signsRS256, the value core-04 section 5.2.2 names; - EC P-256 (
PRIVATE KEYorEC PRIVATE KEY), which signsES256(RFC 7518 section 3.4). Section 5.2.2 saysalgSHOULD beRS256, so a reader that knows onlyRS256cannot verify it. - The first certificate in
sign_certmust be that key's certificate.
- RSA (
- A reader verifies a signature with the algorithm of the certificate's key.
sipnab refuses an
algthat is not that algorithm. -
--vcon-keyand--vcon-fetch-decrypt-keytake an RSA or an EC P-256 private key, and open the entry for that key. - sipnab does not check who issued either certificate or when it expires.
- Whoever decrypts a container decides whether to trust the signer's chain.
- The fetcher checks it with
--vcon-fetch-signer-caand--vcon-fetch-signer-sha256(see Check who signed it).
What it refuses at startup, naming the rule:
-
encrypt_towithoutsign_keyandsign_cert, or either of those withoutencrypt_to - a key or certificate file that sipnab cannot read, that holds no key or certificate, or that holds a key other than RSA of at least 2048 bits or EC P-256
- an empty
encrypt_tolist - a
sign_certwhose first certificate is not the certificate ofsign_key - a
sign_keythat other users can read (chmod 600it) - a
compressother thannoneorgzip, orcompresson a rule withoutencrypt_to
Every other rule key works with encrypt_to.
- A
live = truerule writes a new encrypted container each time the call changes. When an encrypted call changes says what that container is. - Enrichment services receive the call before sipnab encrypts it, as without
encrypt_to.
Set the keys once for every rule. encrypt_to, sign_key, sign_cert
and compress in the [vcon] section are the values of each rule that does
not set its own. A rule's own value overrides the section's, key by key:
[vcon]
encrypt_to = "/etc/sipnab/vcon-recipient.crt"
sign_key = "/etc/sipnab/vcon-signing.key"
sign_cert = "/etc/sipnab/vcon-signing.crt"
compress = "gzip"
[[vcon.rule]]
name = "support"
when = "@support"
dir = "/var/spool/sipnab-vcon/support"
[[vcon.rule]]
name = "legal"
when = "@legal"
dir = "/var/spool/sipnab-vcon/legal"
encrypt_to = "/etc/sipnab/vcon-legal.crt"
compress = "none"Here both rules sign with the section's key. support encrypts to the
section's certificate with gzip, and legal to its own certificate without
it. A rule encrypts when it has an encrypt_to, its own or the section's, so
with [vcon] encrypt_to set every rule encrypts. The section's sign_key,
sign_cert and compress apply only to a rule that encrypts: without
[vcon] encrypt_to, a rule with no encrypt_to of its own writes the
unsigned form. The rule --export-vcon-when with --export-vcon-dir makes
takes none of them. sipnab refuses at startup a [vcon] compress other than
none or gzip. sealed_updates is not one of the section's keys: each rule
sets its own.
A rule rewrites a call's container when the call changes after sipnab wrote it.
- A
live = truerule rewrites it on every change. - Any other rule on a live capture rewrites it on a late change. A
BYEafter an idle write is one. A retransmitted final response is another.
Section 4.1.9 of draft-ietf-vcon-vcon-core-04 rules that out for a signed or encrypted container.
- A change invalidates the container.
- So the draft requires a new vCon instance version for each change.
- It says the new version SHOULD name the prior one by its uuid.
- A rule without
encrypt_towrites the unsigned form. The draft lets a producer modify that form, so such a rule rewrites it in place.
sealed_updates sets what an encrypting rule does instead. A rule that does
not set it uses new-uuid, whether its encrypt_to is its own or the
section's.
| Value | What the rule writes |
|---|---|
new-uuid (the default) |
Each later version of the call is a new instance in a new file, <uuid>.vcon.json. Its amended object names the uuid of the version before it. It keeps the first version's created_at, and its updated_at is when sipnab wrote it. The earlier files stay |
same-uuid |
Each later version replaces the file under the same uuid, as releases before this setting did. This departs from section 4.1.9. It also departs from section 4.1.2, which requires every uuid to be globally unique. sipnab logs one warning at startup naming the rule |
final-only |
Nothing while the call is in progress. One encrypted container once every leg has ended, and none after it. When the run ends during a call, sipnab writes that call once then, and its completeness block records call_ended: false
|
sipnab refuses at startup any other sealed_updates value, naming the three.
The uuid of a later version.
- The first version has the call's uuid.
- The uuid of version n (1, 2 and so on) keeps the first uuid's 48-bit timestamp.
- It takes its other 74 bits from SHA-256 over
amended, the first uuid and n. - It depends on nothing else. So two runs over the same capture write the same chain of uuids.
- Under
[vcon] uuid_domain, version n keeps the first uuid's timestamp and the domain's 62 bits, and takesrand_afrom the same SHA-256. See The uuid under a domain.
To read a call's latest version, follow the chain. The latest version is the
one that no other version names in amended.
A rule with sign_only = true writes the signed form of
draft-ietf-vcon-vcon-core-04 section 5.2
alone: a JWS with no encryption.
[[vcon.rule]]
name = "archive"
when = "@support"
dir = "/var/spool/sipnab-vcon/archive"
sign_only = true
sign_key = "/etc/sipnab/vcon-signing.key"
sign_cert = "/etc/sipnab/vcon-signing.crt"- sipnab signs each container as an encrypting rule does:
RS256, thesign_certchain inx5c, the uuid in the signature'sheader. -
compress = "gzip"signs the gzip of the vCon, as for an encrypting rule. - sipnab names each file
<uuid>.vcon.json, andsealed_updatesapplies, as for an encrypting rule. - The rule takes
sign_key,sign_certandcompressfrom[vcon]. It never takes[vcon] encrypt_to: it asked for the signed form alone. - sipnab refuses at startup:
-
sign_onlybeside the rule's ownencrypt_to; -
sign_onlywith nosign_keyor nosign_cert.
-
- Signing stays opt-in.
sign_keywithoutencrypt_toorsign_onlyis still refused. - Anyone can read the container: the signature protects its integrity, not its content.
- With
audio = "url", the rule writes the audio file beside the container as it is, a WAV or an Ogg stream, not as a JWE: it has no recipient to encrypt to.- The signed container's recording names the file's URL and the SHA-512 of the
file in
content_hash. The signature covers that hash, so a changed file no longer matches it.
- The signed container's recording names the file's URL and the SHA-512 of the
file in
-
-Iand the other readers verify it with no key: see A signed, encrypted or compressed container.
What the signature says.
- It says the holder of
sign_keywrote these bytes, and that nobody changed them since. - It attests sipnab's record of what it observed. It does not attest who took part in the call, or that they wrote anything.
- The container names sipnab as its observer party. The parties are what the From and To headers said, not identities anyone established.
Section 5.2.2 of draft-ietf-vcon-vcon-core-04
has each signature carry its certificate chain in x5c, or name it by an
HTTPS URL in x5u.
-
A rule's
sign_cert_urlwritesx5ubeside thex5c:[[vcon.rule]] name = "archive" when = "@support" dir = "/var/spool/sipnab-vcon/archive" sign_only = true sign_key = "/etc/sipnab/vcon-signing.key" sign_cert = "/etc/sipnab/vcon-signing.crt" sign_cert_url = "https://pki.example.com/vcon-signing.pem"
- sipnab keeps the
x5c, so a reader with no network access still verifies the signature. - sipnab refuses at startup a URL that is not
https://, andsign_cert_urlon a rule that does not sign. - sipnab serves nothing at the URL: put the PEM chain there yourself.
- sipnab keeps the
-
A reader verifies a signature that carries
x5cwith it, and fetches nothing. -
For a signature with
x5uand nox5c, from another producer:-
--vcon-x5u-ca <FILE|system>([vcon_input] x5u_ca) lets the readers fetch the chain over HTTPS, trusting a server that chains to the CA in the file, or to the host's CA bundle withsystem. - sipnab reads at most 256 KiB of the answer and waits at most 10 seconds, and fetches each URL once per run.
- Without the flag sipnab fetches nothing, and refuses the container naming the flag. A container someone hands to the MCP or REST reader never makes the server connect anywhere unless its operator gave the flag.
- sipnab refuses an
x5uthat is nothttps://before any fetch. - The signature must verify against the first certificate the URL serves.
--vcon-signer-caand--vcon-signer-sha256then check that chain, as they check anx5c.
-
A signing certificate expires in a few years, and an archive may keep a vCon for ten or more. Section 5.2.3 of draft-ietf-vcon-vcon-core-04 keeps its integrity by nesting: before the signing certificate of JWS1 expires, the archive verifies the vCon and signs JWS1 whole as the payload of a new JWS2, and so on for the life of the archive.
--vcon-resign does one step:
sipnab --vcon-resign signed/0158a120-0392-8337-ae55-829bc914b643.vcon.json --vcon-resign-key archive.key --vcon-resign-cert archive.crt --vcon-resign-out archived.vcon.jsonINFO sipnab::app::bootstrap: --vcon-resign: wrote 'archived.vcon.json'
- sipnab verifies every signed layer of the input. With
--vcon-signer-caor--vcon-signer-sha256, it also checks the outermost signer. - The outermost signing certificate must be within its validity period now. Section 5.2.3 re-signs before it expires, so sipnab refuses an expired one.
- The new JWS carries the input byte for byte as its payload. Its signature
headercarries the new signer'sx5cand the vCon's uuid. - The new key signs
RS256(RSA) orES256(EC P-256). - sipnab writes the result mode
0600, and never replaces a file that exists. - It refuses an unsigned or encrypted input: section 5.2.3 re-signs the signed form.
Every reader walks the nesting, -I among them, up to 16 signed layers
(MAX_SIGNED_LAYERS (16) in the source), and refuses a container with more.
To read one:
sipnab -N -I archived.vcon.json --json-dialogs --no-cli-print --quiet | jq -c '{call_id, msg_count}'{"call_id":"[email protected]","msg_count":6}
- Each layer's signature must verify.
-
--vcon-signer-caand--vcon-signer-sha256check the outermost signer. The section says the inner layers are then "assumed to have valid integrity", so an inner certificate that has expired since does not stop the read.
A rule with audio = "url" writes each recording beside its container, and
the container names it by url, mediatype and content_hash. An
encrypting rule writes the file as its own JWE.
--vcon-audio-fetch gets the audio back from a container and its key:
sipnab --vcon-audio-fetch fetched/0158a120-0392-891a-9455-bc2e6fad94df.vcon.json --vcon-audio-fetch-out audio --vcon-key recipient.keyINFO sipnab::app::bootstrap: --vcon-audio-fetch: wrote 'audio/0158a120-0392-891a-9455-bc2e6fad94df-dialog0.wav'
The INFO line shows with SIPNAB_LOG=info. For each recording Dialog
Object with a url, sipnab:
- Opens the container the way
-Idoes: unsigned, signed, encrypted or gzip, with--vcon-key,--vcon-signer-caand--vcon-signer-sha256, or their[vcon_input]keys. - Fetches the file over
https://, trusting the host's CA bundle,--vcon-audio-fetch-caalone, or--vcon-audio-fetch-extra-caas well.- sipnab refuses a
urlthat is nothttps://without connecting. Section 2.4.1 of draft-ietf-vcon-vcon-core-04 requires HTTPS for retrieval, to protect the privacy of the file's contents. - With
--vcon-audio-fetch-local, sipnab reads the file from the directory that holds the container instead, by the last segment of theurl: for a spool whose files your storage does not serve yet.
- sipnab refuses a
- Checks the file against the
content_hash, the hash of the file as written (section 2.2), before anything else reads it. sipnab refuses a file that does not match, names the dialog, and writes nothing for it. - Decrypts a JWE with the key that opens the container, through the same code. Its
ctymust be the recording'smediatype. - Writes the audio to
<uuid>-dialog<N>.wav, or.oggforaudio/ogg, in--vcon-audio-fetch-out, mode0600.<N>is the recording's index indialog. sipnab never replaces a file that exists.
sipnab carries on to the next recording after a refusal. Exit 0 when sipnab
wrote every recording, 1 when the container does not open, names no
recording by url, or sipnab refused any recording, and 2 when sipnab
cannot use a key, CA or output directory.
To read the spool on the host that wrote it:
sipnab --vcon-audio-fetch spool/0158a120-0392-891a-9455-bc2e6fad94df.vcon.json --vcon-audio-fetch-out audio --vcon-audio-fetch-local --vcon-key recipient.keyAn unencrypted rule's WAV goes through the same steps, without the decryption.
--export-vcon-dir is a queue, and it never runs alone.
- It pairs with
--export-vcon-when. That flag names the dialogs to emit, in the same expression language--filterspeaks. - Each flag requires the other.
-
--export-vcon-whenconflicts with the single-call--export-vcon.- One writes a container per matching dialog.
- The other writes exactly one.
sipnab -N -d eth0 --export-vcon-when "state == 'Failed'" --export-vcon-dir /var/spool/vconThe capture process never sends a container anywhere.
- The export path writes files and makes no outbound connection.
- Delivering them to a store is the job of a separate process that watches the directory.
- sipnab includes one,
sipnab --vcon-forward. Deliver the spool to a store describes it. - Any other program can do the same job.
These are the guarantees a forwarder may rely on.
When a container appears depends on where the calls come from.
- On a live capture (
-dor--hep-listen), sipnab writes a matching call's container while it keeps capturing.- It checks every five seconds.
- It writes a call once the call has ended and has been quiet for five seconds. Ended means Completed, Canceled, Failed, Redirected, Expired or Terminated.
- Expect the container about ten seconds after the call's last message, at the latest.
- The call can change after that. For example, a late
200 OKanswers a canceled call. sipnab then writes the container again under the same name.
- sipnab writes a call that has not ended once it has carried no SIP message
and no RTP packet for 120 seconds.
- This covers a call whose BYE sipnab never saw. For example, a phone lost power, or the capture point missed the packet.
- Without it, sipnab would never write that call.
- The container's
call_endedfield isfalse. Its completeness note says the call had not ended when sipnab wrote it (see When the call had not ended). - If the BYE, or any other message, arrives later, sipnab writes the container again under the same name.
- A forwarder that already delivered the first copy may be
refused the second by its store. For example, vcon.store answers
409for a uuid it already holds. - sipnab writes a call on hold with no media for 120 seconds early, in the same way.
- Idle compaction keeps a quiet call's opening request and outcome. It drops the messages between them.
- It runs after
[limits] idle_compact_after_secs(600 seconds by default). - So it does not reach a call before the live export writes it.
- A live export run with that setting below 120 seconds warns at startup. Compaction could then drop a call's middle messages before sipnab writes its container.
- It runs after
- A live capture that you stop writes nothing more. Stop means
systemctl stop, SIGTERM, Ctrl-C, or an MCP client that goes away.- sipnab does not write calls it had not yet written on the way out. Stopping sipnab must leave no call data behind.
- A live run can end on its own (
--duration,--autostop).- It writes the matching calls the checks had not reached yet.
- It does not write a second copy of any container already written.
- Its last line counts both, for example
Wrote no further vCon containers to 'spool' at the end of the run; 1 container(s) were written there as their calls ended.
- With
-I, sipnab reads the whole capture and writes every container at the end, as before.
sipnab refuses some flags on a live capture:
-
--redact,--redact-mapand--content-deny-tombstonewith--export-vcon-when, -
--redactand--redact-mapwith--export-vcon.
These flags write their files at the end of the run. A stopped live run never gets there. Use them with -I.
A name resolves to a whole container, or to nothing. Every write:
- stages the bytes under a dot-prefixed sibling,
- flushes them to the filesystem,
- renames into place.
So a reader polling the directory never sees a truncated container. A write that fails leaves the previous one intact.
Staging is deliberately in the DESTINATION directory rather than in the system temp dir:
-
renameis atomic only within one filesystem. - Across a mount boundary, it either fails outright or degrades into a copy.
- A copy puts the partial file back.
A staging file you can see is litter.
- sipnab writes it as
.<container>.partial. So an ordinary listing and a*.jsonglob both skip it. - A failed write removes it.
- One left behind means sipnab died mid-write. It is safe to delete.
Names are stable, and sipnab reuses them. A container's file name comes from its Call-ID:
- An underscore replaces every character outside
[A-Za-z0-9._-]. - An underscore also replaces a leading dot. So no container name starts with one.
- The name ends in a hyphen, 16 hexadecimal characters of the SHA-256 of the Call-ID,
and
.vcon.json. An example is1-1966_10.0.2.20-1cc03f180ff8a774.vcon.json. - Re-exporting the same dialog to the same directory overwrites its file.
- It does not accumulate a second one.
- That is what makes the directory a queue and not a log.
Two dialogs can have Call-IDs that differ only outside that character set, or only beyond the first 180 characters. They still get two names:
- The readable part of the name can match.
- The SHA-256 suffix differs.
With --redact, the name comes from the redacted Call-ID.
- sipnab names the file after the pseudonym the container carries in
sip_call_id. - So the original Call-ID and the host address inside it appear in none of these:
- the spool,
- the
--vcon-digestlines, - the forwarder's
delivered/andfailed/directories and log lines.
- The name still matches the Call-ID inside the container.
- With the same
--redact-key-file, a re-export produces the same name and overwrites the file.
A container is complete when it appears. There is no partial state, no lock file and no sentinel to wait for. Read it, forward it, delete it.
Deleting is the consumer's job. sipnab never removes a container it wrote. So a spool nobody drains grows without bound.
--vcon-digest gives you something to reconcile against.
- It prints a SHA-256 of every container written, in
sha256sumformat. - So
sipnab ... --vcon-digest > SHA256SUMSand a latersha256sum -c SHA256SUMSboth work with no glue. - It is deliberately not a signature, and deliberately outside the container.
- A store adds fields on ingest.
- So a signature over sipnab's bytes would fail against the object the store holds.
- It would tell an operator nothing.
sipnab --vcon-forward is a second sipnab process, started on its own.
- It delivers the containers in an
--export-vcon-dirspool to a vCon store over HTTP or HTTPS. - It needs the
vconfeature, like the export. - It captures nothing. sipnab refuses
--vcon-forwardbeside any of these:-
-d,-I,--hep-listen, - a capture filter,
- a listener,
- an export flag.
-
- So the capture process keeps making no outbound connection.
Captures carry personal data, and a store belongs to another party. Decide what may leave the machine before you forward anything:
-
--redacton the export replaces identities and addresses with keyed tokens. - What may you conclude lists what a container carries.
The forwarder needs two things:
- where the store is,
- the credential that authenticates you.
Give the store's URL once, in a shell variable. Put the one header that authenticates you in a file that only you can read.
- The forwarder refuses the file when your group or other users can read it. This is the same rule sipnab applies to its other secret files.
- The file holds one line,
Header-Name: value:
# Run all of these, in order.
STORE_URL='http://127.0.0.1:8000/vcon/external-ingress?ingress_list=sipnab'
umask 077
printf 'x-conserver-api-token: %s\n' "$KEY" > vcon-forward.auth
sipnab --vcon-forward ./spool --vcon-forward-url "$STORE_URL" \
--vcon-forward-auth-file vcon-forward.auth --vcon-forward-once
echo "exit $?"$KEY is the key the store gave you.
- For a store that takes a bearer token, the line is
Authorization: Bearer <token>instead. - The value never appears in a log line, an error message or a failure record.
- The forwarder removes it from any store answer it keeps, even one that echoes the request back.
The credential can come from the environment instead of a file.
-
SIPNAB_VCON_FORWARD_AUTHholds exactly what the file would hold. - The
--vcon-forward-authflag takes the same value.- But a flag's value is visible in the process list to other users of the host.
- So prefer the variable or the file.
- The credential has one source.
- sipnab refuses the variable or the flag beside
--vcon-forward-auth-fileor[vcon_forward] auth_file. - It refuses an empty one.
- sipnab refuses the variable or the flag beside
- A capture run ignores the variable.
# Run all of these, in order.
export SIPNAB_VCON_FORWARD_AUTH="x-conserver-api-token: $KEY"
sipnab --vcon-forward ./spool --vcon-forward-url "$STORE_URL" --vcon-forward-once--vcon-forward-once makes one pass and exits. The exit codes are:
-
0when the store accepted every container, or the spool held none, -
1when the store refused one, or one is still waiting, -
2when sipnab refuses a setting given on the command line, -
3when the store refused the credentials or the client.
Some configuration errors exit differently:
- A config file sipnab refuses exits
1, as on any run. - So does an
[vcon_forward] auth_filethat sipnab cannot read or that holds no credential. - The same file named by
--vcon-forward-auth-fileexits2.
Without --vcon-forward-once, the forwarder makes a pass every
--vcon-forward-interval seconds (default 5) until SIGTERM or SIGINT.
- Stopping it means stopping. It sends nothing more after the signal.
- A container it had not reached stays in the spool for the next run.
Stores differ in three ways:
- where they take a container,
- how they authenticate,
- what they answer for a uuid they hold.
--vcon-forward-kind (or [vcon_forward] kind) names the kind of store.
- The kind supplies those values.
- So you give the store's base URL and the bare key:
| Kind | Ingest path added to a base URL | The header a bare key goes in | A uuid it holds |
|---|---|---|---|
generic (default) |
none: the URL is the endpoint | not accepted: give Header-Name: value
|
not measured |
vcon-store |
/v1/vcons |
Authorization: Bearer <key> |
500, which the forwarder retries like any 5xx
|
conserver |
/vcon/external-ingress?ingress_list=sipnab |
x-conserver-api-token: <key> |
204, and the store replaces its copy |
No kind changes the container sent. Each value is what the store did when measured:
- vcon.store on 2026-10-09, in Send sipnab's vCons to vcon.store.
- conserver on 2026-10-07. This is a self-hosted vCon server. It received a synthetic container from sipnab's test
fixtures.
- It answered
204for a new container.GET /vcon/{uuid}then returned the container with anamendedmember added. - It answered
204for the same uuid sent again. The read-back then showed the second copy. - It answered
422for a body that is not JSON, and for one withoutuuid. - It answered
403with no key or a wrong one.
- It answered
Every kind counts any 2xx as delivered. The
sipnab ingress list is the one that
Run sipnab with a vCon server creates.
An explicit setting overrides each value the kind supplies:
- A URL that names a path is the endpoint as written.
- The kind adds its path only to a URL with none (
https://api.vcon.store, nothttps://api.vcon.store/v1/vcons).
- The kind adds its path only to a URL with none (
- The forwarder sends a credential that is a full
Header-Name: valueline as written.- The kind forms the header only from a bare key.
- So give a key that itself holds a colon as the full line.
-
--vcon-forward-replace-urlapplies to any kind.- No kind sets one. Nobody has measured a
PUTto vcon.store.
- No kind sets one. Nobody has measured a
# Run all of these, in order.
STORE_URL=http://127.0.0.1:8000
umask 077
printf '%s\n' "$KEY" > conserver.key
sipnab --vcon-forward ./spool --vcon-forward-kind conserver \
--vcon-forward-url "$STORE_URL" --vcon-forward-auth-file conserver.key \
--vcon-forward-onceEvery forwarder flag has a key in the [vcon_forward] section of the
config file. The exceptions are --vcon-forward, --vcon-forward-once and
--vcon-forward-auth.
- A flag overrides its key.
- So a file holds the standing settings, and a command line changes one for one run:
[vcon_forward]
kind = "conserver"
url = "http://127.0.0.1:8000"
auth_file = "/etc/sipnab/conserver.key"
done = "/var/spool/sipnab-vcon-sent"
failed = "/var/spool/sipnab-vcon-held"
interval = 5sipnab --vcon-forward /var/spool/sipnab-vcon --config /etc/sipnab/vcon-forward.tomlsipnab checks each key by the rule its flag follows.
- Some errors stop any run that loads the file:
- a number out of range,
- an unknown kind,
- a first back-off longer than the cap.
- A URL or a credential the forwarder cannot use stops the forwarder at startup. The message names the key.
One forwarder delivers to one store.
- It moves each container out of the spool once the store accepts it.
- So two forwarders cannot share one spool. The first would take every container from the second.
To deliver to two stores, chain them:
- The second forwarder's spool is the first one's delivered directory.
- Each has its own delivered directory.
- Run each as a process of its own, in two terminals or as two services.
The first delivers to a conserver:
sipnab --vcon-forward ./spool --vcon-forward-kind conserver \
--vcon-forward-url http://127.0.0.1:8000 --vcon-forward-auth-file conserver.key \
--vcon-forward-done ./spool/deliveredThe second delivers what the first delivered to vcon.store:
sipnab --vcon-forward ./spool/delivered --vcon-forward-kind vcon-store \
--vcon-forward-url https://api.vcon.store --vcon-forward-auth-file vcon-store.key \
--vcon-forward-done ./spool/delivered/vcon-storeA container reaches the second store only after the first accepted it.
- A container the first refused stays in the first one's failed directory.
- It never reaches the second.
The forwarder POSTs the file's bytes unchanged. It sends these headers:
-
Content-Type:-
application/jsonfor a JSON container, or the type--vcon-forward-content-type([vcon_forward] content_type) names:application/vcon+jsonorapplication/vcon; -
application/vcon+gzipfor a gzip format container, known by its first two bytes1f 8b, whatever that setting says. draft-ietf-vcon-vcon-core-04 section 5.4 says a gzip vCon should carry the media typeapplication/vcon+gzipwherever a media type travels, such as an HTTPSContent-Typeheader.
-
-
User-Agent: sipnab/<version>, - the header from the file.
Why the default stays application/json:
- On 2026-10-14 a self-hosted conserver answered
422to a container sent asapplication/vcon, and204to the same container sent asapplication/jsonorapplication/vcon+json. -
application/vconis the type section 6.1 registers. A store that wants it gets it from the setting. - The forwarder reads the uuid a
409replace needs from inside a gzip format container too.
A conserver does not accept the gzip format. On 2026-10-14 the same
self-hosted conserver (POST /vcon/external-ingress) answered:
-
500 Internal Server Errorto a gzip format body sent asContent-Type: application/vcon+gzip, the type the forwarder sends; -
400with{"detail":"There was an error parsing the body"}to the same body sent asapplication/jsonwithContent-Encoding: gzip. The forwarder never sendsContent-Encoding.
So do not put a gzip format file in a spool that a conserver reads.
- sipnab's own rules never write the gzip format.
-
compress = "gzip"compresses the vCon inside the signed or encrypted form. That file is JSON, so the forwarder sends it as JSON. - On 2026-10-13 that conserver answered
204to an encrypted container. It runs a vcon-server branch that adds encrypted vCon ingest. How an unmodified vcon-server answers a signed or encrypted container was not measured.
-
- The forwarder retries a
5xxthe way it retries any5xx:- It leaves the file in the spool, and the back-off doubles up to
--vcon-forward-backoff-cap(default 300 seconds). - It sets no limit on the number of tries. While the forwarder runs, the
file draws a
500at most once per back-off, and it never moves tofailed/. - With
--vcon-forward-once, the file stays in the spool and the run exits1. - The forwarder keeps the back-off in memory, so after a restart it starts
again at
--vcon-forward-backoff-first.
- It leaves the file in the spool, and the back-off doubles up to
Then it moves the file by the answer:
| The store answers | The forwarder |
|---|---|
2xx |
moves the file to --vcon-forward-done, delivered/ in the spool by default, under its own name, and logs a delivered line |
401 or 403
|
stops. The answer refuses the credentials or the client, so every container would draw it: the forwarder sends nothing more, moves no file, logs one error line naming the status and the first 200 bytes of the answer, and exits 3, with or without --vcon-forward-once
|
409, with --vcon-forward-replace-url
|
PUTs the same bytes to that URL, with {uuid} replaced by the container's uuid, and acts on that answer |
any other 4xx, 409 included without a replace URL |
moves the file to --vcon-forward-failed, failed/ in the spool by default, beside <name>.error.json, and logs one line naming the file and the status |
5xx, a timeout, or no connection |
leaves the file in the spool and tries it again after --vcon-forward-backoff-first seconds (default 2), then twice that, and so on up to --vcon-forward-backoff-cap seconds (default 300). The other containers are not held up |
<name>.error.json holds:
- the status,
- the method and URL,
- the first
--vcon-forward-max-error-bodybytes (default 8192) of the store's answer.
Some answers count as no answer:
- An answer whose status line and headers exceed
--vcon-forward-max-response-headbytes (default 65536) counts as no answer. - The container then waits for a retry.
The delivered and failed directories must be on the spool's file system. That way, every move is a rename.
To see the spool's state, use any of these:
- run
sipnab --vcon-spool-status <SPOOL_DIR>, - call
vcon_spool_statuson the capture's MCP server, - read
GET /v1/vcon/spoolfrom its REST server.
All three give one answer.
- It counts the containers pending in the spool and in the default
delivered/andfailed/directories. - It returns each failed container's
<name>.error.jsonrecord. - It reads files and nothing else.
- The answer is a snapshot dated by its
as_offield. The forwarder may move a file a moment later.
The forwarder reads the spool by the contract above.
- It skips dot-prefixed names and names that do not end in
.json. So it never reads a staging file. - sipnab can rewrite a container under the same name because the call changed.
- The rewritten file is a new container to the forwarder, and it goes out again.
- That holds when the rewrite lands after delivery.
- It also holds when the rewrite lands while the send is in flight.
- The forwarder checks that the file it moves is the file it sent. It leaves a newer one in the spool for the next pass.
Over https://, the forwarder and the fetcher verify the store's
certificate against the host's CA bundle. Two settings change that, the same
pair the HEP sender has (--hep-tls-ca and --hep-tls-extra-ca):
| Forwarder | Fetcher | What the client trusts |
|---|---|---|
| -- | -- | the host's CA bundle |
--vcon-forward-ca <FILE> |
--vcon-fetch-ca <FILE> |
only the CA certificates in the file |
--vcon-forward-extra-ca <FILE> |
--vcon-fetch-extra-ca <FILE> |
the host's CA bundle and the CA certificates in the file |
Use the second form for a store whose certificate a private CA issued, on a
host whose public roots should still count. sipnab refuses the two together,
and refuses the second form on a host with no CA bundle to add to. In the
config file the keys are ca and extra_ca in [vcon_forward] and
[vcon_fetch]. A flag of either form replaces both keys of its section.
-
A
2xxmeans the store accepted the container, not that it kept it.- The forwarder logs
delivered, neverstored. - A self-hosted conserver once answered
204for a container it then failed to write. Nothing in the answer said so. - To know the store kept a container, look in the store.
- The forwarder logs
- The bytes are sipnab's. The forwarder never changes a container. It sends the file's bytes, whatever the kind.
-
It does not retry a refusal.
- This covers a
4xxother than a401, a403or a409it can replace. - That container stays in the failed directory until a person reads the record and decides.
- This covers a
-
A refused credential stops it, and moves nothing.
- A wrong key draws a
401or403for every container. - So the forwarder stops at the first one and leaves the whole spool where it is.
- The log line quotes the start of the answer. For example, a Cloudflare front that refuses the client answers
403witherror code: 1010. - Fix the credential or the client, and start the forwarder again.
- A wrong key draws a
-
It does not delete anything.
- The delivered directory keeps a copy of every container. Every copy is call data.
- Delete them on a schedule that suits you.
-
It adds no signature and no consent.
- It sends each container as the rule wrote it.
- A store may sign what it receives. That signature is the store's, over an observation.
- sipnab records no consent.
- It sends one container at a time, in name order, one connection each.
With --vcon-forward-scitt-url (or [vcon_forward] scitt_url), the
forwarder registers SCITT lifecycle statements for each container it delivers.
Section 5 of draft-howe-vcon-lifecycle-01
names the events:
-
vcon_created, for a container sipnab wrote once: it has noupdated_at, or one equal tocreated_at. The capture process makes no outbound connection, so the forwarder registers the creation when it delivers the container. -
vcon_sent, for every delivery: what the forwarder sent, to which store, and when.
[vcon_forward]
url = "https://vcon.example.com/v1/vcons"
auth_file = "/etc/sipnab/vcon.auth"
scitt_url = "https://ts.example.com"
scitt_auth_file = "/etc/sipnab/scitt.auth"
scitt_sign_key = "/etc/sipnab/scitt.key"
scitt_sign_cert = "/etc/sipnab/scitt.crt"
scitt_issuer = "https://pbx.example.com/sipnab"The statement. The lifecycle draft names the events and defines no statement format. sipnab follows the SCITT drafts that define one:
- Each statement is a COSE_Sign1 Signed Statement, POSTed to
<scitt_url>/entriesasapplication/cose, as section 2.3 of draft-ietf-scitt-scrapi-11 defines. - The protected header carries what
section 6 of draft-ietf-scitt-architecture-22
requires:
-
alg(1): ES256,-7(RFC 9053 section 2.1), for an EC P-256 key; RS256,-257(RFC 8812 section 2), for an RSA key. -
content type(3):application/json. - CWT Claims (15):
iss(1) isscitt_issuer, a URI of at mostMAX_ISSUER_LEN(8192) characters,sub(2) isurn:uuid:<uuid>, andiat(6) is the time of signing. -
x5chain(33): the certificate chain ofscitt_sign_cert, leaf first.
-
- The payload is a JSON object:
event,vcon_uuid,vcon_hash(thesha512-hash of the container's bytes as delivered),at, and forvcon_sentthe store's URL into. It carries no party data. - The key is a PEM RSA or EC P-256 key, the same kinds an encrypting
[[vcon.rule]]names insign_key, readable by its owner only. The first certificate inscitt_sign_certmust be the key's own. sipnab refuses a pair that does not match before it sends anything, naming both settings: exit 2 when--vcon-forward-scitt-sign-keynamed the key, 1 whenscitt_sign_keydid.
What the service answered goes into <name>.scitt beside the delivered
container, one entry per statement:
{"statements": [
{"event": "vcon_created", "status": 201,
"location": "https://ts.example.com/entries/67ed...", "receipt": "0oRDoQEm..."},
{"event": "vcon_sent", "status": 202, "poll_status": 204, "pending": true,
"location": "https://ts.example.com/entries/67ee..."}]}-
201: the receipt, base64url, as the service returned it, and itsLocation. -
202: theLocationof the receipt. sipnab polls it for the receipt, as section 2.4 of the SCRAPI draft describes.poll_statusis the status of the last poll.- Each poll is a
GETwithAccept: application/coseand thescitt_auth_fileheader. -
200: the answer is the receipt, kept inreceiptas a201's is. -
204, or202: the registration is still running; sipnab polls again. -
429or a5xx: sipnab polls again;poll_errorholds the answer until a later poll replaces it. - Any other
4xx, such as the404of a registration that failed (section 2.4.3):errorholds its problem, and polling ends.
- Each poll is a
- Before each poll sipnab waits for the last answer's
Retry-After, as a minimum (section 5.1). Without one, it waits 125 to 250 milliseconds, then twice as long after each poll ("exponential backoff with jitter"), for at mostMAX_POLLS(16) polls. -
The polling stays within
timeout(--vcon-forward-timeout), which counts from the statement'sPOST. sipnab starts no poll whose wait would reach it. When the time runs out,pendingistrueandlocationis kept: the registration is running, and you can fetch the receipt from that URL later. A stop does the same, and sipnab sends no further statement. -
sipnab polls a
Locationonly on the service's own origin: the scheme, host and port ofscitt_url. A path alone, such as/entries/67ee..., is on it. Any otherLocationis not fetched, so thescitt_auth_fileheader goes to no other host;poll_errorsays so. - A receipt longer than
MAX_RECEIPT(65536) bytes:errorsays so, and sipnab keeps no part of it. - Any other status:
errorholds thetitleanddetailof the service's Concise Problem Details, or the start of its answer. - No answer:
errorsays why, and there is nostatus.
The file name does not end in .json, so the delivered directory still counts
containers only. sipnab logs and records a statement that does not register,
and does not retry it. The store already holds the container, so the
delivery stands.
What sipnab does not register.
-
vcon_deleted: sipnab deletes no container. The delivered directory keeps each one until you remove it. - The other events of section 5, such as
vcon_receivedandvcon_consent_revoked, describe acts sipnab does not perform.
What the drafts leave open, and sipnab chose.
- The lifecycle draft defines no payload, no subject and no content type. sipnab's choices are the ones listed under the statement.
- The architecture draft lets a transparency service's registration policy
refuse an algorithm. A service that admits only ECDSA answers an RS256
statement with
400, and<name>.scittrecords it. Sign with an EC P-256 key for ES256. - Authentication is out of the SCRAPI draft's scope
(section 4.3).
scitt_auth_filesends one header line, as the store'sauth_filedoes.
sipnab --vcon-fetch is the forwarder's counterpart.
- It is a separate sipnab process.
- It reads containers back from a store by uuid, and writes each to a file.
- It needs the
vconfeature. - Like the forwarder, it captures nothing.
- sipnab refuses it beside any capture source, listener or export flag.
- So the capture process keeps making no outbound connection, unless the operator opts a server in as Fetch from an agent or a REST client describes.
Give the store's base URL, its kind, and a file holding the key. The file is readable only
by you. The fetcher writes each container to <uuid>.vcon.json in
--vcon-fetch-out:
# Run all of these, in order.
umask 077
printf '%s\n' "$KEY" > conserver.key
sipnab --vcon-fetch 018bcfe5-6800-8a6b-a667-78f1c5213800 \
--vcon-fetch-kind conserver --vcon-fetch-url http://127.0.0.1:8000 \
--vcon-fetch-auth-file conserver.key --vcon-fetch-out fetched
echo "exit $?"$KEY is the key the store gave you. Give several uuids after --vcon-fetch.
Or give - to read them from standard input, one per line:
sipnab --vcon-fetch - --vcon-fetch-kind conserver --vcon-fetch-url http://127.0.0.1:8000 --vcon-fetch-auth-file conserver.key --vcon-fetch-out fetched < uuids.txtEach fetcher flag has a key in the [vcon_fetch] section of the
config file. The flag overrides the key. The exceptions are --vcon-fetch, --vcon-fetch-out and
--vcon-fetch-overwrite. The
CLI reference lists every flag.
| Kind | Read path added after the path in the URL | The header a bare key goes in | What the store wraps around the container |
|---|---|---|---|
generic (default) |
none: the URL is a template holding {uuid}
|
not accepted: give Header-Name: value
|
nothing |
vcon-store |
/v1/vcons/{uuid} |
Authorization: Bearer <key> |
a top-level _meta member, which the fetcher removes |
conserver |
/vcon/{uuid} |
x-conserver-api-token: <key> |
nothing |
vcon-mcp |
/api/v1/vcons/{uuid} |
Authorization: Bearer <key> |
{"success": true, "vcon": {...}}; the fetcher keeps the vcon member |
Sources:
- vcon.store: its OpenAPI document, and a
GETon 2026-10-09 that returned the container's members and_meta. - conserver:
api/api.pyof the vCon server (vcon-dev/vcon-server). - vcon-mcp:
src/api/routes/vcons.ts,src/api/rest-router.tsandsrc/api/auth.tsofvcon-dev/vcon-mcp.
A URL that names a path keeps it. The kind's read path follows it.
- For example, for a conserver served under
/api, give--vcon-fetch-url http://127.0.0.1:8000/api. - The fetcher takes a URL holding
{uuid}as written, for any kind.
| The store answers | The fetcher |
|---|---|
2xx with the container |
writes it to <uuid>.vcon.json, mode 0600, without the kind's envelope, every other byte as the store sent it. With --vcon-fetch-decrypt-key, it writes an encrypted container decrypted: see Fetch an encrypted vCon. It verifies a signed container first: see A signed container
|
2xx with a body larger than --vcon-fetch-max-size, not JSON, without the kind's envelope, or holding another uuid |
writes nothing for that uuid |
404 |
writes nothing for that uuid, and logs that the store holds none |
401 or 403
|
stops: asks for no further uuid, and exits 3
|
| another status, a timeout or no connection | writes nothing for that uuid, logs the status and the start of the answer with the credential removed, and goes on to the next uuid. It does not retry |
The fetcher checks each container it writes against the vendored vCon schema.
- A container the schema refuses is still written, as the store holds it.
- It is the store's record. You need it to see what is wrong.
- The log line lists each finding, and the run exits
1.
A file that already exists is not replaced.
- The fetcher does not ask the store for that uuid, and the run exits
1. -
--vcon-fetch-overwritereplaces it. - Neither form writes through a symbolic link.
Exit codes:
-
0when the fetcher wrote every container and the schema accepts each, -
1when it wrote none for some uuid, or the schema refuses one, -
2when sipnab refuses a setting given on the command line, -
3when the store refused the credential or the client.
A config file sipnab refuses exits 1, as on any
run.
A container that an encrypting rule wrote is in
the encrypted form, and a store hands it back as the forwarder sent it. Give the
fetcher the private key of the encrypt_to certificate, readable by its owner
only, and it writes the vCon inside:
sipnab --vcon-fetch 0158a120-0392-81a5-a0a3-98c35ae1193c \
--vcon-fetch-kind conserver --vcon-fetch-url http://127.0.0.1:8000 \
--vcon-fetch-auth-file conserver.key --vcon-fetch-out fetched \
--vcon-fetch-decrypt-key /etc/sipnab/vcon-recipient.keyFor each encrypted container the fetcher:
- finds the uuid in the container's
unprotectedheader; - decrypts it with the key;
- verifies the signature of the signed form inside against the first
certificate in its
x5c; - inflates the payload when it is gzip (it starts with the gzip magic bytes
1f 8b), stopping at--vcon-fetch-max-sizebytes; - checks that the vCon inside holds the uuid asked for;
- checks it against the vendored schema;
- writes it to
<uuid>.vcon.json, mode0600.
The log line gives the SHA-256 fingerprint of the certificate the signature
verified against. Compare it with your signing certificate:
openssl x509 -noout -fingerprint -sha256 -in vcon-signing.crt.
If the key does not decrypt the container, the signature does not verify,
a header carries a JWE zip, or a gzip payload inflates past
--vcon-fetch-max-size, the fetcher writes nothing for that uuid and the run exits 1. Without
--vcon-fetch-decrypt-key, it writes an encrypted container as the store
holds it, and says so in its log line.
A valid signature shows that the holder of the key in x5c signed the
container. It does not show who that is. Two settings make the fetcher check
the signer before it writes anything. Neither needs a key for a signed container.
An encrypted container needs --vcon-fetch-decrypt-key as well: without it, the
signer is out of reach, and the fetcher writes nothing for it and names the flag.
-
--vcon-fetch-signer-ca <FILE>([vcon_fetch] signer_ca): the signing certificate must chain, through the intermediate certificates the container carries inx5c, to a CA certificate in this PEM file, and every certificate in that chain must be within its validity period now. The valuesystemuses the host's CA bundle, the one the fetcher trusts for HTTPS; write./systemfor a file of that name. The fetcher does not check the signing certificate's extended key usage. -
--vcon-fetch-signer-sha256 <HEX>([vcon_fetch] signer_sha256): the signing certificate's SHA-256 fingerprint must be this one. Write it asopenssl x509 -noout -fingerprint -sha256prints it, or as 64 hex digits without colons, in either case.
sipnab --vcon-fetch 0158a120-0392-81a5-a0a3-98c35ae1193c \
--vcon-fetch-kind conserver --vcon-fetch-url http://127.0.0.1:8000 \
--vcon-fetch-auth-file conserver.key --vcon-fetch-out fetched \
--vcon-fetch-decrypt-key /etc/sipnab/vcon-recipient.key \
--vcon-fetch-signer-ca /etc/sipnab/vcon-signing-ca.pemWith both, the fetcher applies both, to an encrypted container and to a
signed one alike. With either, it writes nothing for a container whose signer
fails the check, nor for a container that is not signed, which has no signer
to check. The log line says why and the run
exits 1. When a container carries more than one signature, the checks
apply to the first, the one the log line names.
A container in the signed form that is not encrypted
(draft-ietf-vcon-vcon-core-04 section 5.2)
needs no key. The fetcher verifies it as -I, analyze_vcon and
validate_vcon do, by the same function:
- it verifies the signature of every signed layer against the first
certificate in that layer's
x5c; - it checks the outermost signer against
--vcon-fetch-signer-caand--vcon-fetch-signer-sha256when the operator sets them; - it checks that the vCon inside holds the uuid asked for, and checks that vCon against the vendored schema;
- it writes the container as the store holds it, signature included, to
<uuid>.vcon.json, mode0600, and the log line gives the signer's SHA-256 fingerprint.
The fetcher writes nothing for a signed container whose signature does not
verify, or whose signer fails a check, and the run exits 1, as -I refuses
to read it.
Each file is one vCon, as JSON.
-
-Ireads it the way it reads a capture. For example:sipnab -N -I fetched/<uuid>.vcon.json --analyze(see Analyze a stored vCon). - Any JSON tool reads it too.
For example:
jq '.parties, .dialog[].type' fetched/<uuid>.vcon.json.
A running sipnab server can read one container from the same store by uuid and hand it back in the answer:
- the MCP tool
fetch_vcon, with--mcp-allow-vcon-fetch; -
GET /v1/vcon/fetch/{uuid}, with--api-allow-vcon-fetch.
Each door is off unless the run has its flag, because this is the one way a
server process connects to a vCon store. The store comes from the
[vcon_fetch] keys in the config file and from nowhere else:
-
url,kindandauth_filesay where and with which credential; -
decrypt_key,signer_caandsigner_sha256decrypt an encrypted container and check who signed it, as they do for the fetcher; -
timeout,max_size,max_response_head,caandextra_caapply as they do for the fetcher.
The caller gives a uuid and nothing else. It never names a URL, a kind or a
key, and the answer never holds a key or the credential. Each request makes
the checks the fetcher makes, by the same function, and writes no file. With a
door's flag and no [vcon_fetch] url, that door refuses each request and names
the keys. A [vcon_fetch] store whose credential or key file sipnab cannot
read stops the run at startup, naming the flag.
[vcon_fetch]
kind = "conserver"
url = "http://127.0.0.1:8765"
auth_file = "store.key"sipnab -N -I sip-rtp-g711.pcap --config sipnab.toml --api 127.0.0.1:18080 --api-key "$SIPNAB_API_KEY" --api-allow-vcon-fetchcurl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
http://127.0.0.1:18080/v1/vcon/fetch/0158a120-0392-8751-9a04-9decf1485b22 \
| jq -c 'del(.container)'{"decrypted":false,"schema_findings":[],"schema_version":1,"signature_verified":false,"signer_sha256":null,"stored_form":"unsigned","uuid":"0158a120-0392-8751-9a04-9decf1485b22"}container holds the vCon: decrypted when the store held it encrypted and
the config sets decrypt_key, else as the store holds it. decrypted,
signature_verified and signer_sha256 say which checks it passed. A signed
container that is not encrypted comes back as stored, signature included,
with signature_verified: true once its signature verifies, as
A signed container describes.
The capture is tests/fixtures/sip_call.pcap.
This repository holds it as a committed file.
- It holds one INVITE, a 100, a 180, a 200, the ACK, a BYE and its 200.
- That is seven messages, one Call-ID, two parties.
The committed integration test drives the whole export through the public API against that capture. So running it confirms both the feature and the fixture in one command:
cargo test --features vcon --test vcon_export_testrunning 2 tests
test a_real_capture_exports_a_complete_signaling_only_container ... ok
test re_exporting_one_dialog_from_one_capture_keeps_its_identifier ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
The shortest path is the committed example. It does everything below against that same capture, and it prints the container:
cargo run --features vcon --example export_vcon -- tests/fixtures/sip_call.pcapIts source is examples/export_vcon.rs.
- It compiles as part of the build.
- So it cannot drift from the API the way a fragment on a page can.
In your own program:
- Read the capture into a
DialogStorethe ordinary way. The library page covers that part. - Then hand one dialog and the run's own counters to
export_dialog:
use sipnab::analysis::CaptureFacts;
use sipnab::output::vcon::{ExportContext, export_dialog};
let dialog = store.get(&call_id).expect("the dialog is in the store");
let vcon = export_dialog(
dialog,
&ExportContext {
// Derive this from the capture's CONTENT โ a file name, a frame
// digest. Never from a per-process identifier: the container's uuid
// hashes this value, so a rotating one mints a fresh uuid every time
// you reopen the same file.
capture_id: "sip_call.pcap",
// What the RUN saw outside this one dialog: frames read, SIP a port
// gate discarded, dialogs the store rotated away.
facts: &facts,
// The ranked capture analysis, when the run did one. `None` is an
// honest answer, and the container reports it as one rather than as
// a clean bill.
analysis: None,
// `None` takes the default inline-media budget.
max_inline_media_bytes: None,
// The dialog's RTP quality, for the report's `media_quality`:
// `sipnab::output::vcon::media_quality_for(&streams, &call_id)` builds
// it from a `StreamStore`. Empty leaves the key out.
media: &[],
},
);
println!("{}", vcon.to_json().expect("the container serializes"));This is real output from that fixture. The page elides it only where the message trace repeats itself. Every value below came off a run against the committed capture.
One reading convention, stated once. Every attachment and analysis body
below declares "encoding": "json".
- Section 2.3.2 of the core draft defines that encoding as "The value of the body parameter is a JSON value (object, array, number, string, or one of the literals true, false, or null)".
- sipnab writes each of these bodies as the JSON object itself. A consumer indexes into it directly.
- A recording's body is
base64urltext, which is a string. - A store can return a
jsonbody as a string holding the JSON text. sipnab's own readers accept both shapes.
{
"vcon": "0.4.0",
"uuid": "018bcfe5-6800-8a6b-a667-78f1c5213800",
"created_at": "2026-08-24T17:05:12+00:00",
"extensions": [
"sip-signaling",
"CC"
],
"parties": [
{
"sip": "sip:[email protected]",
"validation": "none",
"sip_contact": "<sip:[email protected]:5060>",
"sip_user_agent": "sipnab-test/1.0"
},
{
"sip": "sip:[email protected]",
"validation": "none"
},
{
"validation": "none",
"role": "observer",
"sip_user_agent": "sipnab/0.5.124 (observer; node capture-01)"
}
],
"dialog": [
{
"type": "recording",
"sip_call_id": "[email protected]",
"sip_from_tag": "1928301774",
"sip_to_tag": "a6c85cf",
"sip_cseq": 1,
"start": "2023-11-14T22:13:20+00:00",
"duration": 58.0,
"parties": [
0,
1
]
}
],
"attachments": [
{
"purpose": "sip-message-trace",
"party": 2,
"mediatype": "application/json",
"encoding": "json",
"body": {
"messages": [
{
"call_id": "[email protected]",
"contact": "<sip:[email protected]:5060>",
"cseq": {
"method": "INVITE",
"number": 1
},
"direction": "received",
"dscp": 0,
"dst": "192.0.2.2",
"dst_port": 5060,
"frame": "โฆ/tests/fixtures/sip_call.pcap#0@4ed7f4560fa79e74",
"from": "<sip:[email protected]>;tag=1928301774",
"is_request": true,
"method": "INVITE",
"party": 0,
"schema_version": 1,
"sdp": "",
"src": "192.0.2.1",
"src_port": 5060,
"timestamp": "2023-11-14T22:13:20+00:00",
"to": "<sip:[email protected]>",
"transport": "UDP",
"ua": "sipnab-test/1.0"
}
โฆ ELIDED: the 100, the 180, the 200, the ACK, the BYE and its 200,
six more objects of the same shape โฆ
],
"call_id": "[email protected]",
"schema_version": 1,
"sip_call_id": "[email protected]",
"version": "1.0"
}
},
{
"purpose": "sipnab-capture-completeness",
"party": 2,
"mediatype": "application/json",
"encoding": "json",
"body": {
"dialogs_refused": 0,
"dialogs_rotated": 0,
"frames_read": 22,
"messages_evicted": 0,
"node": "capture-01",
"note": "Produced by sipnab 0.5.127 on node capture-01. sipnab OBSERVED this dialog and took no part in it: the parties below are what the From and To headers said, not identities anyone established, and nothing here is signed. This container carries SIGNALING ONLY โ no media, and no reference to media held elsewhere. sipnab read 22 frame(s) for this capture. No omissions recorded: every message sipnab held for this dialog is in this container. No capture-level analysis was supplied for this export, so nothing here rules out a blind spot.",
"sip_discarded_by_port_gate": 0,
"sip_discarded_by_websocket_gate": 0,
"sipnab_version": "0.5.124",
"undecodable_frames": 0
}
}
],
"analysis": [
{
"type": "report",
"dialog": 0,
"vendor": "sipnab",
"product": "sipnab 0.5.160 (passive observer; not a recording system)",
"schema": "sipnab-dialog-diagnosis/1",
"mediatype": "application/json",
"encoding": "json",
"body": {
"capture_completeness": {
โฆ ELIDED: byte for byte the completeness body above โฆ
},
"final_status_code": 200,
"schema_version": 1,
"sip_call_id": "[email protected]"
}
}
]
}Four things in that output repay a second look.
Three parties for a two-party call. The last entry is sipnab.
- It carries no
sipURI. sipnab sent no SIP, and a synthesized one would name a participant that never existed. - Its
rolesaysobserver. - Both attachments point their
partyindex at it (index 2). So every document in the container has a stated origin.
The callee has fewer fields than the caller. Party 1 carries a sip and
nothing else.
- sipnab reads a party's
ContactandUser-Agentoff a message that party sent. - In this capture, the first message coming back is a bare
100 Trying. It carries neither. - An absent field means the capture never saw one. It does not mean the endpoint has none.
The Dialog Object describes no media, and that is correct here.
- This run retained no RTP payload. So there is nothing to describe.
- Inventing a
mediatypeor aurlwould name a file that does not exist. - The object's type is
recordingwith no content. This is the placeholder of the type rule below. - It has no
disposition. The call in this fixture completed, and nothing about it failed.
The completeness body appears twice. It appears once as an attachment, and once inside the report. The next section is about why.
The first Dialog Object describes the call's signaling. sipnab fills these fields from what the capture shows, and leaves a field out when the capture does not show it.
-
partiesis[0, 1]: theFromparty, then theToparty.-
Section 4.3.4 of draft-ietf-vcon-vcon-core-04 makes
partiesa SHOULD on arecordingand optional on anincomplete. - On an
incompleteobject it names the two parties the call tried to connect. - A container that carries audio lists the parties per channel instead, and only when sipnab can attribute every channel.
-
Section 4.3.4 of draft-ietf-vcon-vcon-core-04 makes
-
originatoris absent when party 0 is the caller.- Section 4.3.5 writes it only when the first listed party is not the originator.
- Party 0 is the caller when the capture holds the request that created the dialog, which carries no
Totag. - When the capture joined the call later, the caller is not known.
sipnab then adds an empty party, which carries only
validation, before the observer, andoriginatornames it. Section 4.3.5 gives that form for an originator that is not known.
-
durationis the conversation in seconds, from the 200 to the initial INVITE to the BYE.-
Section 4.3.3 defines
durationin seconds. - It is absent when the capture lacks the initial INVITE, its 200 or the BYE.
-
startstays the first message sipnab saw, sostartplusdurationis not the end of the call. - On an object with no
bodyorurl,durationis not the length of any recording. A consumer that readsdurationto size media must check for content first.
-
Section 4.3.3 defines
-
sip_cseqis the CSeq number of the request that created the dialog.- Section 5.4 of draft-howe-vcon-sip-signaling-00 defines it as "the sequence number from the CSeq header field of the INVITE or other dialog-creating request".
- It is absent when the capture joined after that request.
-
party_historylists what the capture shows each party doing, in time order, per section 4.3.13.- Each entry has
party,timeandevent. -
join: both parties, at the 200 to the initial INVITE, when the capture holds that INVITE and its 200. -
drop: the sender of the first BYE, at the BYE, and the other party at the 2xx that answers it. -
hold: a re-INVITE or UPDATE whose audio issendonlyorinactiveputs the other party on hold, at the request. -
unhold: a latersendrecvoffer from the same sender takes that party off hold. Arecvonlyoffer changes nothing. -
keydownandkeyupcarry the digit inbutton, and appear only from a rule withdtmf_events = true.- An INFO with
application/dtmf-relayis akeydownat the INFO and akeyupafter its statedDuration. - An RFC 4733 telephone event is a
keyupat its end packet and akeydownits duration before. The event belongs to the party whose own SDP advertised the sending socket. When no SDP advertised the sending socket, the party whose SDP advertised the destination socket received the event, and the other party of the two sent it. This is the same SDP endpoint that links the stream to its dialog. - The rule turns telephone-event decoding on for the run.
- sipnab keeps at most 256 telephone events per RTP stream (
MAX_KEY_PRESSES(256) in the source). It drops later ones.
- An INFO with
- sipnab writes no
muteorunmute, because SIP carries no signal for either. - A message whose tags match neither party adds nothing. An event adds nothing when neither party advertised its sending socket and no single party advertised its destination socket, because then sipnab cannot name a party as the receiver.
-
--redactremoveskeydownandkeyup, because a digit can be a PIN, and keeps the rest. - A container that holds several legs moves each entry's
partywith the rest of the party indices.
- Each entry has
Each SIP MESSAGE (RFC 3428) that carries content becomes a Dialog Object of type text, per section 4.3.1.3 of draft-ietf-vcon-vcon-core-04.
These objects come after every other Dialog Object, so they move no index.
- There is one per message, and a retransmission with the same sender and CSeq is the same message.
-
startis when sipnab captured the message. Section 4.3.2 uses the send time when the typing time is not known. -
partieslists the sender first, then the other party, so the sender is the originator andoriginatorstays absent.- It is absent when the message's tag names neither party.
-
mediatypeis the message'sContent-Typeas written, and the body is inline.- A UTF-8 body uses
encoding: "none"; any other body is base64url.
- A UTF-8 body uses
- A MESSAGE with no body is not a text dialog, and neither is an
application/im-iscomposing+xmltyping indication (RFC 3994). -
--redactremoves the body,encodingandmediatype, and keepsstartandparties, as it does for a recording. - sipnab does not parse MSRP, so text sent over an MSRP session has no text dialog.
A rule can copy three SIP headers into the campaign, skill and interaction_id parameters of draft-ietf-vcon-cc-extension-02 section 3.2.
sipnab declares the CC extension in extensions already.
[[vcon.rule]]
name = "queues"
when = "method == 'INVITE'"
dir = "/var/spool/sipnab-vcon/queues"
campaign_header = "X-Campaign"
skill_header = "X-Queue"
interaction_id_header = "X-Interaction-ID"- Each value comes from the first message in the call that carries the header, matched without regard to case.
- A header the call never carried, or one with no value, leaves its parameter absent.
- The values go on the first Dialog Object, the signaling one.
- A key that is not a SIP header name, such as one holding a space or a colon, refuses the run.
-
--redactturnsinteraction_idinto a token, so two containers of one interaction still match, and sweepscampaignandskillas free text. - The parameter is
interaction_id, as section 3.2.3 spells it. vcon-lib's list of Dialog parameters spells itinteraction, so a vcon-lib reader that filters on that list drops it.
On a tap beside a session recording server (SRS), sipnab sees the recording session that a session recording client (SRC) opens, not the recorded call itself.
RFC 7866 section 6.1.1 puts the SRC in the From header and the SRS in the To header.
The people on the recorded call appear only in the RFC 7865 metadata, the application/rs-metadata+xml part that the SRC sends beside its SDP.
When the recording session's INVITE, or a later request from the SRC, carries metadata that names at least one participant, the container shows the recorded call.
-
Parties 0 and 1 stay the
FromandToof the recording session.- The party that sent the metadata has
role: "recording-client", and the other one hasrole: "recording-server". - The
roleparameter comes from theCCextension, which sipnab already uses for"observer". The extension allows values other than the ones it lists.
- The party that sent the metadata has
-
Each
<participant>becomes one party, in metadata order, after parties 0 and 1 and before the observer.-
sipis theaorof the participant's<nameID>when it is asip:orsips:URI.telis theaorwhen it is atel:URI, or a global number in the user part of a SIP AOR, by the rule intel. -
nameis the<name>inside the<nameID>. RFC 7865 section 6.5.1 defines the pair: "nameID - This attribute is a list of (Name, AoR) tuples." sipnab reads the firstaorand the first<name>. - An AOR that is a host name or an IP address gives no
siportel. RFC 7865 allows both forms. -
validationis"none"androleis absent. The SRC asserted these identities, and sipnab cannot check them.
-
-
The
sipnab-siprec-streamsattachment maps each recorded stream to the party that sends it:{ "streams": [ { "label": "0", "stream_id": "1a2b3c4d", "party": 2 }, { "label": "1", "stream_id": "2b3c4d5e", "party": 2 }, { "label": "2", "stream_id": "3c4d5e6f", "party": 3 } ] }- The sender is the participant whose
<participantstreamassoc>lists the stream under<send>. RFC 7865 section 6.8.1: "The presence of this attribute indicates that a participant is contributing to a stream." A<recv>entry does not make a participant the sender. -
partyis absent on a stream that no<send>names. - A container that holds several legs moves each
partywith the rest of the party indices.
- The sender is the participant whose
-
When the container carries audio, the recording's
partiesnames the participant on each channel.- Each stream has an
a=labelon the m-line of the SRC's SDP that carries it. RFC 7866 section 7.1.1 requires the SRC to put ana=label(RFC 4574) on each media stream, to tie the recorded stream to the metadata. - The
<label>in the metadata names that m-line. RFC 7865 section 6.7.1: "The 'label' attribute within the 'stream' XML element references an SDP "a=label" attribute that identifies an m-line within the RS SDP." - sipnab matches the address and port that sent the RTP to the m-line that names them, then follows the label to the stream and the stream to its sender.
- The labels come from the SDP in the same message as the metadata. RFC 7866 section 7.1.1 limits a label's scope to "the SDP and metadata conveyed in the bodies of the SIP request or response that the label appeared in."
- When a channel's address and port match no labeled m-line, the rule for an ordinary call applies, and
partiesis usually absent.
- Each stream has an
-
The completeness note says that the dialog is a SIPREC recording session, and where the participants came from.
A recording session whose metadata names no participant keeps the container of an ordinary call: three parties, no roles and no stream map.
The completeness note then says the dialog is a SIPREC recording session and why the participants are missing.
sipnab treats a dialog as a recording session in this case when its opening INVITE has Require: siprec or a Contact with +sip.src.
The note names one of two causes:
- no message in the dialog carried an
rs-metadatapart that sipnab could read, or - the metadata named no participant, which is what a malformed or truncated XML body gives.
--redact rewrites a participant's sip, tel and name as it rewrites every other party's.
The metadata XML itself leaves the container with the other message bodies (see The message trace).
A container whose content a deny header withheld carries no participants. Its parties are the SRC, the SRS and the observer.
The sip-signaling extension of draft-howe-vcon-sip-signaling-00 stores SIP in attachments.
sipnab declares the extension in extensions.
The sip-message-trace attachment holds every message sipnab kept for the dialog.
Its body follows the trace format of section 6.2.1, and keeps sipnab's own fields beside it.
-
versionis the string"1.0", the trace format version the draft defines.- Until this release it was the number
1. -
schema_versionstays the number1for readers of sipnab's own shape.
- Until this release it was the number
-
call_idandsip_call_idboth hold the Call-ID. - Each message carries the draft's fields.
-
timestampis when sipnab captured it. -
directionis"received"on every message. The draft defines it from the producer's side, and sipnab sent nothing. -
partyis the index of the party whose tag the message carries. It is absent when the tags match neither party, such as a100 Tryingfrom a proxy. - A request carries
method. A response carriesstatus_codeandstatus_textinstead. -
headersmaps each header name to a list of its values, without the credential headers. -
bodyis the message body when there is one. A body that is not UTF-8 is base64url text, withbody_encoding: "base64url".
-
- Under
--redact, no message keepsbodyorbody_encoding.- A body can be the conversation itself, such as the text of a SIP MESSAGE.
- The SDP is still in
sdp, which redaction rewrites like every other field.
- A container that holds several legs moves each message's
partywith the rest of the party indices.
A rule with sip_messages = true attaches the two messages section 8.1 asks of a minimal producer.
[[vcon.rule]]
name = "with-wire-messages"
when = "method == 'INVITE'"
dir = "/var/spool/sipnab-vcon/wire"
sip_messages = true-
sip-inviteis the initial INVITE, andsip-responseis its final response.- Both use
mediatype: "message/sip"and hold the message as captured, per section 6.1.2. - The final response is the last 200 to 699 response to an INVITE without a
Totag. After a407challenge, that is the answer to the INVITE sent again with credentials. - The INVITE is the one with that response's CSeq number.
- With no final response in the capture, sipnab attaches the last initial INVITE alone.
- Both use
-
sip-sdpholds the SDP body of each of the two, withmediatype: "application/sdp", per section 6.3. - Each attachment's
partyis the party whose tag the message carries.- It is the observer when the tag matches neither party, such as a proxy's own response.
-
startis when sipnab captured the message, anddialogis 0.
- The bytes lose every credential header line, such as
Proxy-Authorization, as the trace does.- sipnab keeps the body byte for byte, so
Content-Lengthstill matches it.
- sipnab keeps the body byte for byte, so
- UTF-8 messages use
encoding: "none"; others usebase64url. - The rule's inline budget,
max_inline_media_mibor--vcon-max-inline-media, covers the attachments together.- An attachment that does not fit stays out.
The
sipnab-capture-completenessattachment names it, with its size, insip_messages_refused.
- An attachment that does not fit stays out.
The
-
--redactremoves all three kinds of attachment.- The bytes hold every identifier the trace holds, and redaction cannot rewrite them without changing the message they copy.
- sipnab's own
-Ireader takes the messages from the trace, and readsmessage/sipattachments only from a container without a trace.
sipnab checks no PASSporT signature and fetches no certificate.
When the network has verified a call, it says so in a verstat parameter (ATIS-1000074) on P-Asserted-Identity or From.
sipnab copies that verdict into a stir-verification-report attachment, the report of section 7.3.
- The report comes from the first INVITE without a
Totag that carriesverstat.- sipnab reads
P-Asserted-Identityfirst, thenFrom. - With no
verstat, there is no report, even when the INVITE carries a PASSporT.
- sipnab reads
-
resultmaps theverstatvalue onto the draft's values.-
TN-Validation-Passedisverified. -
TN-Validation-Failedisfailed. -
No-TN-Validationisno-signaturewhen the INVITE carried noIdentityheader. When it carried one,resultis absent, because the draft has no value for "not verified".
-
-
attestation,orig_tnanddest_tnare the claims of the PASSporT in the same INVITE, when there is one. -
timestampis when sipnab captured the INVITE, which is after the network verified it. -
verifieris absent, because the header does not name who verified. -
reasonstates that sipnab transcribed the value and verified nothing. -
verstatandverstat_headerkeep the value as written and the header it came from. -
partyis 0, the caller, anddialogis 0. -
--redactremovesorig_tnanddest_tn, as it removes the PASSporT from the party.
Every container a [[vcon.rule]] writes carries a tags attachment.
Its body is a list of "name:value" strings, the shape vcon-lib's Vcon.add_tag writes and Vcon.get_tag reads.
-
rule:<name>names the rule that wrote the container. -
filter:<name>names each named filter the rule'swhenreferences by@nameand the call matched on its own.- The names follow the order of their first appearance in
when. - A call matches a filter when at least one of its legs does.
- The names follow the order of their first appearance in
-
finding:<name>names each detection of the signaling diagnosis the analysis reports, at most five (MAX_FINDING_TAGS(5) in the source).- The names are the field names of
signaling_diagnosisin the analysis body, such asfinal_failureorretransmissions, in that object's order. - A container that holds several legs names each finding once.
- The names are the field names of
- No value contains a colon. vcon-lib takes the text between the first colon and the next one as the value.
-
partyis the observer anddialogis 0. - A container from
--export-vcon-when, the REST API or MCP has no rule name and carries notagsattachment. - A withheld container carries none.
This is the point of the page.
sipnab's central discipline is reporting what it understood rather than what the wire held.
- Its ranked problem list puts incompleteness findings in the
list at
Severity::Blind, above every call fault. - So a capture that failed to decode or hit a retention cap can never render as clean.
vCon has no field for that. Not a weak one: none.
- The
typeenum admits five values and no others:recording,recording-set,text,transfer,incomplete. - Four of the five promise content the object holds.
- The fifth names a call that "failed to be setup". That is a claim about the conversation rather than about the object.
So sipnab types the object by what it carries. It also uses the one outcome it can observe, a final failure:
| The object holds | type |
disposition |
|---|---|---|
| audio | recording |
absent โ it is an incomplete field |
| no content, no observed failure |
recording, with no content |
absent |
| no content, an observed final failure | incomplete |
the reason, always |
The middle row is the placeholder of section 4.3 of the core draft.
- It is for a dialog the producer knows occurred but holds little or nothing from.
- Such a Dialog Object "contains only
the type parameter". For a call that reached setup, its type is
recording. -
Section 4.3.8 waives
mediatypewhen the content is absent. - "No observed failure" covers:
- an answered call,
- a call whose final response the capture never saw,
- a redirect.
- sipnab cannot tell from the wire whether the second reached setup. It does not report a failure it did not see.
Reaching for incomplete in that row is the mistake sipnab shipped until
0.5.128.
- It is not a matter of taste.
- It made every container for a call that answered assert a setup failure that never happened.
Changed in this release, for draft-ietf-vcon-vcon-core-04.
- Until now, that row had no
typeat all.draft-ietf-vcon-vcon-core-03allowed "a Dialog Object with no parameters in it". -
-04removed that sentence. It requirestypeon every Dialog Object. - So a container for a call with no media now carries
"type": "recording"and nobodyorurl. - A consumer that selects
type == "recording"and then reads the content must check that the content is there. - Measured in the vcon-server conserver source at commit
8ffbfcf:- The
deepgram_linktranscription link readsdialog["url"]with a bracket, and raises on such an object. - The
hugging_face_whisperandgroq_whisperlinks readdialog["duration"]the same way. - The type-free object raised in all four transcription links,
openai_transcribeincluded, atdialog["type"].
- The
The last two rows move together.
-
Section 4.3.11 of the core draft makes
dispositiona MUST on an incomplete object. - No value in its closed set means "not observed".
- So one decision fixes both fields.
-
dispositionnames a failure only when sipnab saw the final response that caused it. - Its absence never means "the call succeeded".
sipnab maps the final response to a value of that closed set:
| Final response | disposition |
What section 4.3.11 defines the value as |
|---|---|---|
486, 600
|
busy |
the party was busy with another conversation |
503 |
congestion |
system load stopped the attempt from completing |
408, 480, and 487 after a CANCEL sipnab saw |
no-answer |
nobody answered or accepted the attempt |
any other 4xx, 5xx or 6xx, a 487 with no CANCEL in the capture included |
failed |
the attempt failed, the value for a reason sipnab does not know |
A caller who gives up while the call rings sends CANCEL.
- The call ends with
487 Request Terminated. - Nobody answered, so that call is
no-answer. - sipnab never writes
hung-up. The section defines it for a call that connected and then ended before any conversation. - An answered call is not an
incompleteobject. The section saysdisposition"SHOULD NOT be present for other dialog types".
The extension mechanism offers no repair either. An extension is one of two kinds:
- ignorable: the consumer that most needs the caveat drops it,
- fatal: an ordinary consumer refuses the whole container.
Nothing in between means "read this before trusting the contents".
So sipnab duplicates the caveat into two surfaces a consumer walks past. Both come from one source value:
| Surface | Where it sits | Who reads it |
|---|---|---|
capture_completeness, inside analysis[0].body
|
inside the report, beside the diagnosis | anything that reads what sipnab concluded |
the sipnab-capture-completeness attachment |
a document in attachments, attributed to the observer |
anything that walks the attachments |
Both hold the same value byte for byte. A test fails if they diverge.
- Two caveats that disagreed would read as authoritative while contradicting themselves.
- That is worse than carrying none.
This is the same capture and the same dialog. The run's parser rejected three frames. Its idle compaction discarded four messages it had already captured:
{
"purpose": "sipnab-capture-completeness",
"party": 2,
"mediatype": "application/json",
"encoding": "json",
"body": {
"dialogs_refused": 0,
"dialogs_rotated": 0,
"frames_read": 22,
"messages_evicted": 4,
"node": "capture-01",
"note": "Produced by sipnab 0.5.127 on node capture-01. โฆ sipnab read 22 frame(s) for this capture. โ INCOMPLETE: 3 frame(s) reached the parser and produced nothing, so any count in this container is a floor. โ INCOMPLETE: idle compaction discarded 4 message(s) sipnab had already captured, so the trace in this container may be shorter than the call was. Raise [limits] keep_messages_per_idle_dialog to hold more. No capture-level analysis was supplied for this export, so nothing here rules out a blind spot.",
"sip_discarded_by_port_gate": 0,
"sip_discarded_by_websocket_gate": 0,
"sipnab_version": "0.5.124",
"undecodable_frames": 3
}
}Elided at the โฆ only. The fixed preamble is identical to the clean run's.
Read the shape rather than the wording.
- Every clause is a measurement of this run.
- None of them says the call was short, silent or broken. None of that follows from a capture that missed something.
-
messages_evicted: 4and the sentence naming it move together. So a consumer that reads fields reaches the same verdict as one that reads prose.
Note the last clause in both runs: no capture analysis reached this export, so the note says so.
- "Nobody checked" and "checked and found nothing" are different answers.
- The container keeps them apart:
- an analysis that ranked nothing emits
blind_spots: [], - an export that skipped the analysis omits the field entirely.
- an analysis that ranked nothing emits
call_ended is true when sipnab had seen the call's closing message before it wrote the container.
That message is the BYE, CANCEL or final failure response that closes the call.
It is false for:
- a call still running at the end of a
-Icapture, - a call a live export wrote after 120 seconds with no SIP and no RTP.
When it is false, the note adds this sentence:
The call had not ended when this container was written: sipnab had seen no BYE, CANCEL or final failure for it, so the trace and any media end where this container was written, not where the call ended.
This is a statement about the call, not about the capture.
- So it is not an
INCOMPLETEclause. It adds no entry to the omission rows. - Every message sipnab held can be in the container while the call continued after sipnab wrote it.
- The field is always present, including as
true.
Two of the fields in that block report a decision rather than a capture fault. The note says so in words.
- Every other clause describes something the run failed to see.
- These two describe something an operator chose.
- A reader who cannot tell them apart goes looking for a fault that does not exist.
| Field | What it means |
|---|---|
gate_closed_during_run |
An operator stopped this run writing content partway through, over POST /v1/persistence. Containers are absent on one side of a hole this capture does not otherwise record. |
dialogs_suppressed_by_deny |
How many dialogs carried the --content-deny-header name and produced no container. |
--content-deny-header suppresses the whole dialog, not merely its content.
- The default is the conservative reading: a denied dialog leaves this process
entirely.
- The REST route
GET /v1/dialogs/{call_id}/vconanswers403for it. - MCP
export_vconrefuses it bycall_id. It leaves it out of a filtered export, counting it inwithheld_by_deny.
- The REST route
-
--content-deny-tombstonemakes the narrower reading available.- That is an identity-only container carrying a
redactedobject (section 4.1.8 of the core draft). - It has no message trace, no media and no bodies.
- The trade is explicit, because a tombstone reveals that the call existed.
- Leave it off when the header means "this call must leave no trace".
- That is an identity-only container carrying a
Both are always present, including as false and 0.
- A missing key and "nothing happened" are the same fact here.
- There is no reason to make a consumer distinguish them.
The absence is what makes them necessary.
- A container written after recording stopped looks exactly like one from a run where those calls never happened.
- So a reader comparing the set against a switch's records concludes sipnab missed them.
- The same goes for a deny flag. The containers that DID get written read as the complete set for their predicate.
- Neither fact survives in the capture itself. So the container carries it.
Suppression narrows and never widens.
- A header asking sipnab to RECORD is an assertion by whoever sent the request.
- This tool refuses that class of claim.
- There is no permit flag.
- No REST call can enable persistence a command line did not authorize.
- See
docs/rest-api.mdfor the runtime gate.
Read the container as evidence from an instrument. Do not read it as a record from a participant.
| What you see | What it licenses you to say | What it does not |
|---|---|---|
parties[].sip |
the From or To header carried this URI |
that anyone with that URI took part |
parties[].sip_display_name |
the sender wrote this string in its header | that this names a person |
parties[].sip_user_agent and parties[].sip_contact
|
a message this party sent carried the User-Agent or Server and Contact header: for the caller, the opening request; for the callee, a response from 101 to 699 carrying the dialog's remote To tag, or a request carrying it as the From tag. A proxy's 100 Trying and any response without that tag are never read for the callee |
that the software named is what the party runs |
validation: "none" on every party |
nobody checked anything about this party | that a check failed โ the field never carries another value |
a role: "observer" party |
this container came from a tap | that the tap was in the media path |
attachments[].party |
the observer contributed this document | that a participant did |
| the message trace | these messages reached sipnab's parser | that they are all the messages |
analysis[0].body |
sipnab's diagnosis of what it held | a diagnosis of the call |
analysis[0].body.media_quality |
each RTP stream's MOS and R-factor, computed from the packets sipnab read, with mos_grounded saying whether the MOS rests on a published impairment value |
that a listener heard that quality |
| an absent field | the capture did not carry it | that the call lacked it |
a dialog[] object with no media fields |
this export carries no media | that the call had none |
parties on a recording with a body
|
the stream on each channel came from the address and port the named party advertised in its own SDP | that the party spoke, or that the channel holds only that party's audio |
a recording with a body and no parties
|
at least one channel came from an address and port no party advertised in its SDP, such as a media relay's | that nobody was on that channel |
Three rules follow from that table.
A missing thing is a missing observation.
- Every absence in a sipnab vCon describes the capture. It never describes the conversation.
- That distinction is the whole reason the completeness caveat exists.
- A consumer that collapses it turns a configuration choice on a capture host into a claim about somebody's call.
An unsigned container attributes nothing. Trust it exactly as much as you trust whoever handed it to you.
- sipnab signs an encrypted container with the key and certificate its operator configured.
- The signature says which operator's sipnab wrote those bytes.
- The container inside still says that sipnab observed the call and recorded nothing.
Two containers about one call do not merge themselves.
- sipnab writes the parties of the dialog it observed. It infers none.
- One tap on a proxied call sees two legs of one conversation, or one leg of three.
- A second tap that saw the other leg writes its own container.
- Reconciling them belongs to the consumer. The consumer is the only one holding both.
Each refusal has an argument behind it in the design decision. The one-line version:
| Refused | Why |
|---|---|
| a JWS signature on an unsigned container | a signature over an observation verifies as a signature over a recording, so sipnab signs only as the inner layer of an encrypted container you configure, with your key (Encrypt what a rule writes) |
| consent attachments | sipnab obtained no consent, and an empty consent field reads as "none recorded" |
| lawful-basis attachments of its own | the same, with a named regulatory reader on the other end. sipnab carries one only from an enrichment service with lawful_basis = true, and records that service as its source |
a party name that sipnab vouches for
|
From and To are the caller's claim about the caller, trivially spoofed |
hosted artefacts by url, other than audio the operator serves |
sipnab hosts nothing, so it cannot promise where a file lives. The one url it writes is a rule's audio = "url" recording, under the audio_url_base the operator's storage serves |
sipnab EMITS a party name when the wire carried one.
- This page previously said the Rust type has no
namefield. That was wrong. -
Party::nameexists. It carries the display name fromFromorTo. - It sits alongside
sip_display_name, never instead of it. - It travels under the declared key. So a generic vCon reader shows a named party rather than an anonymous one.
What sipnab refuses is not the field but the claim.
-
validationis unconditionally"none". - That says a header asserted this name and nobody checked it.
Treat it as personal data.
- A display name is the caller's own words about who they are.
- A container is a thing operators forward.
- If you are writing a redaction step, key it on
nameANDsip_display_name. - An earlier version of this page would have led you to redact only the second.
Each message in the sip-message-trace attachment carries a headers object.
- It holds the header name, and an array of its values.
- So a repeated
ViaorRecord-Routekeeps the path the message actually took.
Four headers never travel:
-
Authorization, -
Proxy-Authorization, -
WWW-Authenticate, -
Proxy-Authenticate.
A digest challenge and its response are credential material. A container is a publication surface.
That filter is worth one honest sentence:
- Until 0.5.125, it did nothing. The trace carried no raw headers for it to find.
- The test guarding it passed for that reason, not because the filter worked.
- Headers, and the filter that makes them safe to publish, landed together, deliberately.
- The same test now fails if anyone drops either half.
Everything else on the wire is in the container.
- Decide whether that is acceptable for your traffic before you hand one to anybody.
-
P-Asserted-Identity,Diversion,Remote-Party-IDand any custom header your platform sets all travel, exactly as the endpoint wrote them.
A conserver indexes parties by tel, mailto and name, and by nothing else.
- So a consumer can retrieve a container carrying none of the three by its UUID and by nothing else.
- A party search never surfaces it.
- An operator who knows only the phone number cannot find the capture.
tel is the only one of the three sipnab can supply from evidence.
- It supplies it only when the SIP user part is unambiguously a telephone number.
That means
+followed by digits, an RFC 3966 global number. - Everything else gets nothing, including bare digit runs.
-
1001is an extension. - Indexing it as a telephone number would put a wrong answer in a search index rather than no answer.
-
- The observer party never carries one at all.
The dialog object carries sip_from_tag and sip_to_tag when the capture
observed them.
- A Call-ID alone cannot distinguish one leg of a forked INVITE from another. Every fork shares it.
- So a consumer correlating legs across nodes needs the tags.
sip_to_tag is the To tag of the response that established the dialog. That is:
- a 2xx answer to the opening request,
- or, when the capture holds none, a 101-199 response to it.
sipnab falls back to a tag from a response that establishes no dialog, such as a 401 or 407 challenge.
- It does this only when the capture holds neither.
- An answer under another tag replaces it.
- RFC 3261 section 12.1 is the rule.
sip_to_tag is absent when no message carried a To tag. That
is itself a signal: nothing established a dialog.
-I reads a vCon container the way it reads a capture.
- It reads a file, a directory of them, or a glob.
- It reads them alone or beside pcap files.
- Each SIP message the container stores is rebuilt and analyzed by the same code that analyzes packets.
- So all of these work on it:
- the dialog list,
- the ladder,
-
--report,--analyze,--json,--json-dialogs, - the TUI.
- The MCP tool
analyze_vconandPOST /v1/vcon/analyzedo the same for a container handed over inline.
Any container with SIP in it works. This writes one from the committed sample capture:
sipnab -N -I sip-rtp-g711.pcap --export-vcon [email protected] --vcon-out call.vcon.json --node-name tap-a --no-cli-print --quietIt prints nothing and exits 0. It leaves call.vcon.json beside the capture.
sipnab -N -I call.vcon.json --analyze --no-cli-print --quietCapture analysis: 1 finding(s) across 1 dialog(s), 0 stream(s), 6 frame(s) read.
THIS ANALYSIS IS INCOMPLETE โ sipnab did not read all of this capture. The findings below describe what it UNDERSTOOD, so their counts are floors and the ABSENCE of a finding is not evidence that the problem is absent. The blind finding(s) are listed first.
1. [BLIND] Rebuilt from a stored vCon โ 1 container(s)
These dialogs were rebuilt from stored vCon containers, not read from packets. A vCon holds no RTP, so no media statistic here was computed, and whatever its producer did not store is absent. The evidence names each container, what it lacks, and the producer's own caveat.
Evidence:
- call.vcon.json, vcon 0158a120-0392-857f-8193-d90b5d03d6c4 | messages=6 | 6 SIP message(s) rebuilt from this container. Not recomputed or not stored: media: a vCon carries no RTP, so no stream, MOS, jitter or loss was computed from it; the producer's analysis holds 1 media_quality row(s), its own numbers, not recomputed; request-URI: not stored for 3 request(s), which carry sip:request-uri-not-stored.invalid; header order: the trace keys headers by name, so their order across names is the container's, not the wire's, and headers the producer did not store (credential headers, when sipnab produced it) are absent. The producer's caveat: Produced by sipnab 0.5.208 on node tap-a. sipnab OBSERVED this dialog and took no part in it: the parties below are what the From and To headers said, not identities anyone established, and nothing here is signed. No media is in this container because this run decoded none for the dialog; the capture_completeness media_note says what was measured. That is a statement about what this run kept, not a finding that the call was silent. sipnab read 852 frame(s) for this capture. No omissions recorded: every message sipnab held for this dialog is in this container. A capture-level analysis ran and ranked no blind spots.
The first finding is rebuilt_from_vcon. It is a blind finding.
- The analysis is of what the container stored.
- The finding names what that leaves out.
- Then it quotes the producer's own caveat.
Every message says where it came from:
sipnab -N -I call.vcon.json --json --quiet | head -1 | jq '{timestamp, src, dst, transport, method, input_origin}'{
"timestamp": "2016-11-26T14:52:59.666393+00:00",
"src": "10.0.2.20",
"dst": "10.0.2.15",
"transport": "UDP",
"method": "INVITE",
"input_origin": "vcon"
}The -N summary line and the TUI's raw message view mark the same messages
origin=vcon.
Write one container per INVITE dialog into stored. It prints
Wrote 2 vCon container(s) to 'stored'.
sipnab -N -I sip-rtp-g711.pcap --export-vcon-when 'method == "INVITE"' --export-vcon-dir stored --node-name tap-a --no-cli-print --quietAdd a container that stores no SIP at all:
printf '{"vcon": "0.4.0", "parties": []}\n' > stored/broken.vcon.jsonRead the directory. sipnab names the container it cannot read, with the reason. It reads the rest:
sipnab -N -I stored --json-dialogs --no-cli-print --quiet | jq -c '{call_id, msg_count, final_status_code, input_origin}'2026-10-09T15:22:57.015657Z WARN sipnab::capture::input_set: Skipping 'stored/broken.vcon.json': not a readable capture (a vCon container with no SIP signaling sipnab can read: it has no sip-message-trace or message/sip attachment)
2026-10-09T15:22:57.017252Z WARN sipnab::capture::input_set: -I resolved to 2 capture file(s), 1 file(s) that are not readable captures
{"call_id":"[email protected]","msg_count":6,"final_status_code":200,"input_origin":"vcon"}
{"call_id":"[email protected]","msg_count":4,"final_status_code":200,"input_origin":"vcon"}
A container named directly with -I that sipnab cannot read stops the run
instead, with the same reason. A file you named is one you expected
sipnab to read.
--report over a directory of containers prints one row per container after
the dialog table. Each row says whether sipnab read the container or skipped
it, and then the totals:
sipnab -N -I stored --report --no-cli-print --quietCall-ID From To State Code Duration Msgs PDD Tags
-------------------------------------------------------------------------------------------------------------------------
[email protected] sipp test Completed 200 8s 6 - -
[email protected] sipp test InCall 200 0s 4 - -
vCon containers:
Status Form Findings Calls Duration Disposition UUID File
----------------------------------------------------------------------------------------------------------------------------------
read unsigned 0 1 8s Completed 1 0158a120-0392-8a4b-8522-17eac0c8ec4b stored/1-1966_10.0.2.20-1cc03f180ff8a774.vcon.json
read unsigned 0 1 0s InCall 1 0158a120-253e-8af9-a5b5-76781dbeae49 stored/1-1968_10.0.2.20-cdd887baa4507e9a.vcon.json
skipped: no_sip_signaling unsigned 1 0 0s - - stored/broken.vcon.json
Totals: 3 container(s): 2 read, 1 skipped
Skipped by reason: no_sip_signaling 1
Failed validation: 1 container(s)
Calls: 2 (Completed 1, InCall 1)
Total duration: 8s
Skipped:
stored/broken.vcon.json: a vCon container with no SIP signaling sipnab can read: it has no sip-message-trace or message/sip attachment
The Skipping warnings print on standard error as before. --quiet hides
them, and the report keeps each reason.
The columns:
-
Status.
read, orskipped:and the reason in one word:-
no_key: the container is in the encrypted form and the run has no--vcon-key. Give the flag to read it. -
no_sip_signaling: the container stores no SIP message sipnab can rebuild. -
not_json: the file starts with{and is not JSON. -
not_a_vcon: the JSON is not a vCon. -
not_opened: a signed or encrypted layer did not open, for a reason other than a missing key. -
unsupported_critical:criticallists an extension sipnab does not support. -
unreadable: sipnab cannot read the file, or the file is over 64 MiB.
-
-
Form. The outermost form the file arrived in:
unsigned,signed,encryptedorgzip, orunknownfor bytes that are not a JSON object. -
Findings. How many places the container disagrees with the vendored
draft-ietf-vcon-vcon-core-04 schema.
-means the schema check did not run, because the file did not open or is not JSON. - Calls. The calls rebuilt from the container, by Call-ID.
- Duration. Those calls' durations added up, each as the dialog table's Duration column computes it.
- Disposition. Those calls counted by state, the word the dialog table's State column prints.
-
UUID and File. The container's
uuid, and the file.
The totals add up the rows. A call that two containers both hold counts once
in the totals and once in each of their rows. Failed validation counts the
containers with at least one schema finding, read or skipped.
The section prints only when the run met a container. A report over a
capture prints what it printed before. --filter does not narrow it: a container is an input,
like a capture file.
When sipnab skips every file in the directory, for example a directory that
holds only encrypted containers and the run has no --vcon-key, the run
still fails. It prints no readable capture files on standard error and
exits 1, as it does without --report. Before it exits, --report prints
the vCon containers: section, with no dialog table, so each container
still has its row, the totals and the reason in full. With --json or
--json-pretty, it prints the sipnab_vcon_containers object in place of
the section. Without --report, or when the directory holds no vCon
container, the run prints nothing on standard output.
With --json or --json-pretty, the same rows and totals come as one JSON
object under the key sipnab_vcon_containers, in place of the text section.
The dialog table stays text:
sipnab -N -I stored --report --json --no-cli-print --quiet | grep sipnab_vcon_containers | jq '.sipnab_vcon_containers.totals'{
"calls": 2,
"containers": 3,
"dispositions": {
"Completed": 1,
"InCall": 1
},
"duration_secs": 8,
"failed_validation": 1,
"read": 2,
"skipped": 1,
"skipped_by_reason": {
"no_sip_signaling": 1
}
}Each row of containers carries status, file, uuid, form,
validation_findings (null where the text prints -), calls_rebuilt,
call_ids, dispositions and duration_secs. A skipped row also carries
skip_reason and skip_detail, the reason in full.
The MCP tool vcon_directory_report
answers the same report for a directory of --mcp-file-root, with the rows
paged.
A vCon arrives in one of three JSON forms, and a producer may compress any of them
(draft-ietf-vcon-vcon-core-04 section 5.4).
Every reader opens each one with the same step: -I, analyze_vcon,
validate_vcon, POST /v1/vcon/analyze and POST /v1/vcon/validate.
- The gzip format (section 6.2):
- sipnab knows it by the gzip magic bytes
1f 8b, under any file name. - It inflates it, up to 64 MiB, and reads the form inside.
- A gzip-compressed capture inflates to a capture and stays a capture.
- sipnab knows it by the gzip magic bytes
- The signed form (section 5.2):
- sipnab verifies the signature against the first certificate in
x5c. - It refuses a container whose signature does not verify.
- It needs no key.
- sipnab verifies the signature against the first certificate in
- The encrypted form (section 5.3):
- sipnab decrypts it with the private key
--vcon-keynames, or[vcon_input] key. The key file must be readable by its owner only. - It then verifies the signed form inside. It refuses an encrypted container that does not hold the signed form.
- Without a key it refuses the container and names the flag. It never analyzes the encrypted JSON as a container with no SIP.
- sipnab decrypts it with the private key
So sipnab reads its own encrypted output back. This sipnab.toml writes the
two INVITE dialogs of the sample capture encrypted and gzip compressed, as
Encrypt what a rule writes describes.
recipient.key is the private key of recipient.crt, mode 0600:
[[vcon.rule]]
name = "sealed"
when = "method == \"INVITE\""
dir = "sealed"
encrypt_to = "recipient.crt"
sign_key = "signer.key"
sign_cert = "signer-chain.pem"
compress = "gzip"# Run all of these, in order.
sipnab -N -I sip-rtp-g711.pcap --config sipnab.toml --no-cli-print --quiet
sipnab -N -I sealed --vcon-key recipient.key --json-dialogs --no-cli-print --quiet | jq -c '{call_id, msg_count, input_origin}'[[vcon.rule]] 'sealed': Wrote 2 vCon container(s) to 'sealed'.
{"call_id":"[email protected]","msg_count":6,"input_origin":"vcon"}
{"call_id":"[email protected]","msg_count":4,"input_origin":"vcon"}
Without --vcon-key, a container named with -I stops the run with exit 1:
cannot open the vCon container: the container is encrypted (draft-ietf-vcon-vcon-core-04 section 5.3), and no decryption key was given: give --vcon-key, or [vcon_input] key in the config file
A gzip format vCon reads like any other:
# Run all of these, in order.
gzip -k call.vcon.json
sipnab -N -I call.vcon.json.gz --json-dialogs --no-cli-print --quiet | jq -c '{call_id, msg_count}'{"call_id":"[email protected]","msg_count":6}
The rebuilt_from_vcon finding names each layer sipnab opened, and who signed it:
Opened from: encrypted form, decrypted; signed form, verified, signer SHA-256 4A:27:CF:...
Two settings check who signed it, as the fetcher's do (Check who signed it):
-
--vcon-signer-ca <FILE|system>([vcon_input] signer_ca): the signing certificate must chain to a CA in the file, every certificate within its validity period. -
--vcon-signer-sha256 <HEX>([vcon_input] signer_sha256): the signing certificate must have this fingerprint. - With either set, sipnab refuses an unsigned container: it has no signer to check.
The MCP and REST readers:
-
analyze_vcontakes a signed or encrypted container ascontainer, and any form, the gzip format included, ascontainer_base64. -
POST /v1/vcon/analyzeandPOST /v1/vcon/validatetake a JSON body withContent-Type: application/json,application/vconorapplication/vcon+json, and a gzip format body withContent-Type: application/vcon+gzip. -
validate_vconandPOST /v1/vcon/validatevalidate the unsigned vCon inside. - A container that does not open is a
verdict: unreadableanswer from the analyze tool and route. The validate tool and route refuse it; the route answers422. - They read with the server's
--vcon-key,--vcon-signer-caand--vcon-signer-sha256.
A container's critical parameter lists the extensions a reader must support
to interpret it.
- Section 4.1.4 of draft-ietf-vcon-vcon-core-04 says a reader that does not support one "MUST NOT attempt to process or operate on the vCon, except to reject it or report unsupported content".
- sipnab supports the two extensions its own containers declare,
sip-signalingandCC. - sipnab skips a container whose
criticallists anything else, as it skips a container without readable SIP. The reason names each unsupported extension. -
analyze_vconandPOST /v1/vcon/analyzeanswerverdict: unreadablewith that reason. They run no schema check. -
validate_vconandPOST /v1/vcon/validaterefuse the container.
What sipnab skips is a container whose SIP it cannot read.
- Some containers disagree with the vendored draft-ietf-vcon-vcon-core-04 schema but store readable SIP. Such a container still reads.
- sipnab reports the finding with it.
- The common case is a container that went through vcon.store. vcon.store hands
extensionsback as an object rather than the array the schema requires:
jq '.extensions = {"sip-signaling": true, "CC": true}' call.vcon.json > store-shape.vcon.jsonsipnab -N -I store-shape.vcon.json --json-dialogs --no-cli-print --quiet | jq -c '{call_id, msg_count, input_origin}'2026-10-09T15:22:57.067344Z WARN sipnab::capture::file: 'store-shape.vcon.json' fails the draft-ietf-vcon-vcon-core-04 schema (/extensions (type): expected type "array", found object); its SIP is read regardless, and the analysis carries the finding(s)
{"call_id":"[email protected]","msg_count":6,"input_origin":"vcon"}
The rebuilt_from_vcon finding in --analyze carries the same schema finding
for that container. The MCP tool and the REST route list it in
schema_findings.
-
Two stored forms.
- The first is the
sip-message-traceattachment sipnab writes, and the trace form of draft-howe-vcon-sip-signaling-00 it extends. These give each message its time, addresses, ports and transport. - The second is
message/sipattachments, the draft's other form.- They carry each message whole, timed by the attachment's
start. - That form stores no addressing. So those messages carry
0.0.0.0:0, and the finding says so.
- They carry each message whole, timed by the attachment's
- A container with neither form holds nothing to analyze. An example is the recording-only example in core-04 Appendix A. sipnab skips it with that reason.
- The first is the
-
Detection is by content.
- sipnab reads a file whose first character other
than whitespace is
{as a container, whatever its name. - Some JSON objects have none of the members a vCon has (
vcon,uuid,created_at,parties,dialog,analysis,attachments). Such an object is not one. sipnab skips it with that reason. -
extensionscounts in either form, the core-04 array or the vcon.store object.
- sipnab reads a file whose first character other
than whitespace is
-
sipnab skips unreadable SIP, not a schema finding.
- It skips a container with no
sip-message-traceormessage/sipattachment, naming the reason. - It also skips a container whose attachments hold no message it can rebuild, naming the reason.
- It reads a container that fails the schema and stores readable SIP. It reports the finding.
- It skips a container with no
-
One container is at most 64 MiB.
- sipnab reads a container whole to validate it.
- So it refuses a file larger than
MAX_CONTAINER_BYTES(67,108,864 bytes) without reading it. It names the size and the limit. - As with any container it cannot read, sipnab skips it in a directory or a glob. It stops the run when you name it directly.
- A container carrying inline audio within the 5 MiB default budget is far below the limit.
-
No media.
- A vCon stores no RTP. So sipnab computes no stream, MOS, jitter or loss from it.
- It reports no media fault for a call that had none to measure.
-
analyze_vconandPOST /v1/vcon/analyzereturn the producer's ownmedia_qualitynumbers verbatim, as its numbers.
-
No request-URI. The trace stores the method, not the request line.
- So a rebuilt request carries
sip:request-uri-not-stored.invalid. - That is a name in the reserved
.invaliddomain.
- So a rebuilt request carries
-
Some things do not come back:
- header order across names,
- bodies other than SDP,
- headers the producer did not store (sipnab removes credential headers).
-
Content-Lengthstates the body the container holds.
- No frame pointers. A rebuilt message was never a frame anyone can read again. So it carries none.
-
--redacted containers read back with their pseudonyms. Nothing can recover the originals from them. -
Not with
--coresor a BPF filter.-
--coresshards captured frames. A BPF filter selects them. - A container holds neither. So sipnab refuses both, with the reason.
- Select dialogs with
--filterinstead.
-
- sipnab sends nothing anywhere. The addresses in a container are a record of a finished call. So sipnab never transmits a response to them.
By default sipnab keys a container's uuid on the node, the dialog and the capture (see What this does not do). That departs from draft-ietf-vcon-vcon-core-04 section 4.1.2, which says:
The value of the string SHOULD be generated using the version 8 UUID defined in [UUID] which is generated identically to a version 7 UUID with the exception that:
- rand_b/custom_c is generated from the high 62 bits of the SHA-1 hash of the FQHN string
- the variant and version fields are set as described for version 8 UUID
The DNS name string used in generating the uuid value SHOULD be the same FQHN, or a subdomain to allow for more distributed generation of UUIDs, as would used in the signing certificate as they are the same domains of uniqueness.
[vcon] uuid_domain writes that form:
[vcon]
uuid_domain = "node1.pbx.example.com"Every container uuid the process writes then has this layout. That covers the
batch export, GET /v1/dialogs/{id}/vcon, MCP export_vcon and the
related-legs attachment.
| Bits | Field (RFC 9562 section 5.7) | What sipnab puts there |
|---|---|---|
| 0-47 | unix_ts_ms |
The call's first observed message, in milliseconds since the Unix epoch. The same as the default |
| 48-51 | ver |
8 |
| 52-63 | rand_a |
12 bits seeded from SHA-256 over the Call-ID and the capture identity, the same seed as the default. See below for when it is not |
| 64-65 | var |
10 |
| 66-127 | rand_b |
The high 62 bits of SHA-1 over the domain. sipnab hashes the name in lower case, without a trailing dot |
sipnab refuses at startup, with exit code 1 and a message naming
[vcon] uuid_domain, a value that is not a host name of two or more labels.
Each label must be 1 to 63 ASCII letters, digits or hyphens, and must not start or end
with a hyphen. The whole name is at most 253 characters. Write an
internationalized name in its xn-- form. sipnab also refuses an IPv4
address. See the error reference.
What the layout leaves for uniqueness. Section 4.1.2 also says "The UUID
value MUST be globally unique". Under one domain, the timestamp and the 62
domain bits are the same for every call that starts in the same millisecond.
Only rand_a's 12 bits separate those calls: 4096 values per millisecond per
domain.
- Seeded from the dialog alone, two calls in one millisecond would share a uuid once in 4096 pairs. So sipnab keeps a table of the values it has given out. A call whose seed picks a value already given in that millisecond takes the next free one.
- The table is per process. A second sipnab process, or a second node, under the same domain cannot see it. Give each node its own subdomain, as the draft's "or a subdomain to allow for more distributed generation of UUIDs" allows.
- The table holds at most 1,048,576 uuids. Past that it drops its oldest milliseconds, and sipnab seeds a call from one of them afresh.
- When calls hold all 4096 values of a millisecond, the next call repeats a uuid. The layout has no room to prevent that.
What stays deterministic. The seed is a function of the dialog and the capture. So re-exporting one call, or running the same capture again, writes the same uuid. The exception is a call that met a seed collision in its millisecond: its value depends on which of the two calls sipnab minted first.
Later versions. A sealed_updates = "new-uuid" rule's version n keeps the
first version's timestamp and the domain's 62 bits. sipnab seeds its rand_a
from SHA-256 over amended, the first uuid and n, through the same table. So
two versions of one call differ in those 12 bits alone.
Not done: the domain from sign_cert. The draft asks for the name "as
would used in the signing certificate". sipnab does not read it from
sign_cert. Set uuid_domain to the DNS name in that certificate, or to a
subdomain of it.
Stated here rather than discovered later.
-
It does not host audio.
- When the run retained the RTP payload, the container carries the audio INLINE.
It is a
recordingDialog Object with asha512-content hash. - A rule with
audio = "url"instead writes the audio file beside the container. It names the file by aurlunder the rule'saudio_url_base, with thecontent_hashof the file and no body. - The operator's storage serves that URL. sipnab does not check that it does.
- A
--redactexport never names aurl. - When the run retained no audio, the container says so in words and carries none.
Audio over the inline budget draws an out-loud refusal rather than a silent truncation.
-
--vcon-max-inline-mediasets that budget in MiB. It defaults to 5. - That figure comes from a measurement against a real vCon store. The store:
- answered
204for a container of roughly 12 MB, - wrote it to its database,
- had its own file spool refuse the payload,
- and neither side reported the partial write.
- answered
-
0refuses every inline body without turning the exporter off.
sipnab retains whole calls. So a call's audio grows with its length.
- A two-way 20 ms G.711 call exports as 8 kHz stereo. That is 32,000 bytes of WAV a second.
- It fits the 5 MiB default for up to 122 seconds.
- At 123 seconds, the container carries the refusal instead.
- As Ogg Opus (
audio_format = "opus") the same call fits for up to 643 seconds. See Audio as Ogg Opus. - Raise
--vcon-max-inline-mediato what the store you send to accepts. Or write the WAV beside the container with theexport_audioMCP tool.
A
--redactexport carries no audio at all.- Redaction deletes audio rather than replacing it with a token.
- Its Dialog Object is the signaling one, typed by the rule above.
-
capture_completeness.mediasayswithheld-by-redaction.
Every door (batch export, REST and MCP) reads the one value. So the same call cannot come back carrying audio through one and a refusal through another.
- When the run retained the RTP payload, the container carries the audio INLINE.
It is a
-
It ties the legs of a B2BUA call together only by its correlation.
- With
grouping = "leg", the default, two Call-IDs produce two containers. Each names the other in itssipnab-related-legsattachment, with the strategy that tied them. - With
grouping = "call"one container holds every leg the rule selects. - A tie that is a timing guess carries
identifier_match: false. A busy server can get it wrong. - See Calls through a PBX or a B2BUA.
- With
-
It does not write a
.vconzbundle.- draft-miller-vcon-zip-bundle-00 names each external file by its
content_hash, with an extension from themediatype. - For an encrypted recording that rule gives the ciphertext an
.oggor.wavextension. - For an encrypted vCon the draft says both that a bundle creator MUST NOT decrypt and that it SHOULD decrypt when it holds the keys.
- The draft expired on May 10, 2026.
- draft-miller-vcon-zip-bundle-00 names each external file by its
-
It does not assemble a call across hops. One container describes one observed dialog from one capture.
-
It does not deduplicate across taps.
- Two sipnab boxes that saw the same dialog mint different uuids.
- The uuid mixes in the node and the capture identity along with the Call-ID.
-
Its uuid is a function of the dialog, the node and the capture, not a random value.
- The 48-bit timestamp is the dialog's first observed message.
- The other 74 bits are digests.
rand_a(12 bits) digests the Call-ID and the capture identity.rand_b(62 bits) digests the node name, the Call-ID and the capture identity. - Two dialogs that opened in the same millisecond on one node differ in those 74 bits.
- Re-exporting one dialog from one capture yields the same uuid. So
deduplication on
uuidis safe for re-exports. That is what it exists for. - Until 0.5.125
rand_bdigested the node alone. That left 12 bits to separate such dialogs. -
[vcon] uuid_domainreplacesrand_bwith the high 62 bits of SHA-1 over a host name, the form section 4.1.2 asks for. See The uuid under a domain.
-
It does not store anything. sipnab writes a container and forgets it. There is no vCon store, no index, no retention.
- The Phase 0 decision โ what sipnab may put in a vCon, what it refuses to, and the structural gap in the format that decides both
- vCon internals โ for anyone changing the exporter
- Output formats โ the NDJSON and report surfaces the message trace shares its projection with
-
Library API โ filling a
DialogStorefrom a capture