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 From and To headers 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 install has 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.

What a vCon is, and what sipnab is to it

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.

Produce one

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 vcon

The 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-out requires --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-out that 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.
  • A live capture that you stop writes nothing.
    • So a call that had not ended and settled leaves no --vcon-out file behind.
  • 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.

Write calls by rule

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-when with --export-vcon-dir still works. It is one more rule beside the configured ones.
  • --since and --until narrow every rule.
  • --content-deny-header applies before every rule.
  • Each rule's directory is a spool the forwarder can deliver. Run one --vcon-forward per 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 inline rule 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,
  • audio is not inline, url or omit,
  • audio_format is not wav or opus,
  • a rule whose audio is omit sets audio_format,
  • audio_format is opus and the build has no opus feature,
  • grouping is not leg or call,
  • when does 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 as Ogg Opus

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 mediatype is audio/ogg.
    • core-04 section 4.3.8 lists audio/ogg among the media types a Dialog Object SHOULD use.
    • It carries no codecs parameter. Section 2.2 defines a media type as type "/" subtype alone.
  • Its content_hash is 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 DESCRIPTION comment.

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_to encrypts 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:

  • opus is its own Cargo feature, in full. It compiles libopus from source, which needs cmake and a C compiler.
  • The gnu tarballs and packages, the macOS tarballs and the Docker image carry it.
  • The static musl tarballs and the -noaudio packages do not. They have no vCon export.
  • sipnab --version lists opus when the binary carries it.
  • A build without it refuses audio_format = "opus" at startup and names the feature.

Calls through a PBX or a B2BUA

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:
  • 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-legs attachment 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}]}
  • uuid is the other leg's container uuid. It stays the same under --redact.
  • identifier_match is false for 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 parties maps 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;
  • a sipnab-call-legs attachment. It names:
    • each leg's signaling object,
    • every tie, with its strategy, identifier_match and score,
    • truncated: true when 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 when does not match,
    • a leg that a --content-deny-header withheld.
    • So write when to match every leg of the calls you want.
  • 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 call states every tie sipnab built it on. So a reader can see when that happened.

Enrich a call before it is written

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, summarize reads the transcript transcribe returned.

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..."}]}
  • vcon is the container as sipnab writes it, with one exception. Its analysis holds only what earlier services added, not sipnab's own report.
    • It is present when send includes signaling.
  • audio is present when send includes audio and the run retained the call's audio.
    • It holds one WAV per leg.
    • dialog names 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, json or base64url),
  • body.

The shape of body follows its encoding.

  • With json the 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 none or base64url the 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:

  • encoding is json and 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, duration of 0 or more, and confidence from 0 to 1.
    • segments: each with a unique integer id, start, an end after start, text, and confidence from 0 to 1. A speaker is an integer or a string, and words is an array of integers.
    • metadata: created_at, processed_at, provider and model.
  • The optional sections, words, speakers, quality, extensions, alternatives, enrichments and streaming, 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 streaming section, section 6.2.2.7 of the WTF draft, can carry the session's session_id and is_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:
    • purpose is lawful_basis and encoding is json.
    • party and dialog index the container, and start is an RFC 3339 time, which the core draft's schema requires.
    • mediatype, when present, is application/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 or null, and purpose_grants, each with purpose, a boolean granted, granted_at, and conditions strings when present.
    • terms_of_service and status_interval are strings. content_hash and registry hold the strings section 5.2.2 lists, and each proof_mechanisms entry has proof_type, timestamp and a proof_data object.
  • sipnab carries the attachment as the service sent it, writing mediatype: "application/json" when the service left it out. The container lists lawful_basis in extensions, and not in critical: section 4.1 of the lawful basis draft classifies the extension as Compatible.
  • The service's line in the sipnab-enrichment attachment lists the attachment's index under attachments, 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 the content_hash member itself. sipnab does not judge expiration either, because it processes nothing under the lawful basis.
  • Without lawful_basis = true, an answer that holds any attachment is malformed, 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-..."}}
  • model and generated_at are the members section 4.2.1 of the provenance draft requires.
    • An answer entry may carry its own provenance with them. sipnab keeps its model, generated_at, parameters, prompt, software and registry.
    • Without one, model.vendor is the entry's vendor, and model.name its product. With no product, model.name is the [[vcon.enrich]] name, the only name sipnab holds for the service.
    • Without one, generated_at is when sipnab read the answer.
    • sipnab refuses an answer whose provenance has no model.vendor, model.name or RFC 3339 generated_at, as malformed.
  • sipnab always writes inputs itself, from what it sent the service, and replaces any the service sent. A service's own indices count the request's analysis list, which starts after sipnab's report.
    • With signaling: each Dialog, Attachment and earlier Analysis Object sent, by its index in the container. Each content_hash is over the element's RFC 8785 canonical JSON as sent.
    • With audio: each leg's signaling Dialog Object. Its content_hash is over the WAV sent, the value the Dialog Object's own content_hash takes when the rule inlines the audio.
  • sipnab always writes output_hash itself, 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 detail in words, when it did not succeed,
  • the indices in analysis of what it added,
  • the indices in attachments of 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 url and command
  • an http:// URL to anything but a loopback address (sipnab does not send call audio in the clear across a network)
  • a send value other than audio and signaling
  • a timeout_secs outside 1 to 3600
  • an auth_file that 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.

Hear a call while it is in progress

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/live lists the calls in progress the rule matches, and GET /v1/calls/live/events announces each one as a server-sent event.
  • GET /v1/calls/{call_id}/audio is 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_audio rule turns retention on.
  • Audio a key could not decrypt is never sent.
  • A call that --content-deny-header withholds is neither listed nor served.

Who may listen.

  • These routes need a token with the audio scope when the server authenticates (sipnab --mint-token --token-scope audio).
  • No other scope reaches them. A full token and the static --api-key read 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_audio rule unless the API has both:
    • TLS (--api-tls-cert and --api-tls-key),
    • a signing key (--api-signing-key or --api-signing-key-file).
  • 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.

Send a call's audio as RTP

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_audio serves the same audio for a client to fetch; live_rtp sends 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.
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 -c or 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 with the 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-header withholds. 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_rtp names an address other than loopback, unless the rule also sets live_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_rtp that does not resolve to an address. sipnab resolves it once, at startup.
  • A live_rtp rule turns audio retention on, as live_audio does.
  • live_rtp runs in a headless run (-N). A --cores run and the terminal UI send nothing, and say so at startup.

Follow a call while it is in progress

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_at stays the time of the first write.
  • Until the call ends, the completeness block records call_ended: false. The last write, after the call ends, records true.
  • A container written while the call is in progress carries no audio, whatever audio says.
    • 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.
  • live has an effect only on a live capture (-d or --hep-listen).
    • With -I, the rule writes each call once, as any rule does, with no updated_at.

A forwarder delivers each rewrite as a new copy of the same container.

Encrypt what a rule writes

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_cert chain in x5c, in the signature's unprotected header.
    • The protected header holds alg.
  • sipnab encrypts that signed container to the RSA key in the encrypt_to certificate.
    • It uses JWE: RSA-OAEP for the content key, A256CBC-HS512 for the content.
    • It uses a fresh content key for every write.

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 zip header.

The file holds the JWE: unprotected, recipients, iv, ciphertext and tag.

  • Its unprotected header carries:

    • the container's uuid;
    • enc: "A256CBC-HS512";
    • cty: "application/vcon", or application/vcon+gzip with compress = "gzip".
    • core-04 section 5.3.1 lists these.
    • A store and the forwarder's replace URL can use the uuid without the key.
  • Each recipient's header carries alg: "RSA-OAEP".

  • With nothing left for a protected header to hold, the JWE has no protected member.

  • sipnab 0.5.217 and 0.5.218 wrote alg and enc in a protected member, and x5c in the signature's protected header.

    • The fetcher reads those containers too.
  • sipnab names each file <uuid>.vcon.json rather than after the Call-ID.

    • A Call-ID can carry host names and addresses, and the file name is not encrypted.
  • Its unprotected header carries the container's uuid and cty: "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.json rather 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_to certificate.
  • Then the recipient verifies the signature against the certificate in x5c.
  • sipnab reads it back with -I and --vcon-key (A signed, encrypted or compressed container).
  • The working group's reference tool is the vcon command of vcon-dev/vcon.
    • It does this with vcon decrypt <key> <cert> and then vcon verify <ca cert>.
  • 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_to certificates as the container, with one recipients entry for each.
    • Its plaintext is the WAV, or the Ogg Opus stream with audio_format = "opus".
    • Its unprotected header carries cty, the media type of that plaintext, and no uuid.
    • sipnab encrypts it and does not sign it. The signed container names its content_hash.
  • sipnab names it <content_hash>.wav.jwe or <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_base followed by that file name;
    • mediatype: the media type of the audio inside, audio/x-wav or audio/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 mediatype describes that body or the media inside it. sipnab names the media inside, which a reader needs after decrypting.
  • 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. Under same-uuid, nothing names them.
  • To read one, --vcon-audio-fetch fetches the file, checks its SHA-512 against the container's content_hash, and decrypts it with the private key of the encrypt_to certificate. See Fetch the audio a vCon names.
    • --vcon-fetch fetches containers. It does not fetch the URLs inside them.

What the keys must be.

  • encrypt_to and sign_cert hold 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 the epk, 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).
  • sign_key is a PEM private key, readable by its owner only:
    • RSA (PRIVATE KEY or RSA PRIVATE KEY), which signs RS256, the value core-04 section 5.2.2 names;
    • EC P-256 (PRIVATE KEY or EC PRIVATE KEY), which signs ES256 (RFC 7518 section 3.4). Section 5.2.2 says alg SHOULD be RS256, so a reader that knows only RS256 cannot verify it.
    • The first certificate in sign_cert must be that key's certificate.
  • A reader verifies a signature with the algorithm of the certificate's key. sipnab refuses an alg that is not that algorithm.
  • --vcon-key and --vcon-fetch-decrypt-key take 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-ca and --vcon-fetch-signer-sha256 (see Check who signed it).

What it refuses at startup, naming the rule:

  • encrypt_to without sign_key and sign_cert, or either of those without encrypt_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_to list
  • a sign_cert whose first certificate is not the certificate of sign_key
  • a sign_key that other users can read (chmod 600 it)
  • a compress other than none or gzip, or compress on a rule without encrypt_to

Every other rule key works with encrypt_to.

  • A live = true rule 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.

When an encrypted call changes

A rule rewrites a call's container when the call changes after sipnab wrote it.

  • A live = true rule rewrites it on every change.
  • Any other rule on a live capture rewrites it on a late change. A BYE after 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_to writes 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 takes rand_a from 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.

Sign without encrypting

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, the sign_cert chain in x5c, the uuid in the signature's header.
  • compress = "gzip" signs the gzip of the vCon, as for an encrypting rule.
  • sipnab names each file <uuid>.vcon.json, and sealed_updates applies, as for an encrypting rule.
  • The rule takes sign_key, sign_cert and compress from [vcon]. It never takes [vcon] encrypt_to: it asked for the signed form alone.
  • sipnab refuses at startup:
    • sign_only beside the rule's own encrypt_to;
    • sign_only with no sign_key or no sign_cert.
  • Signing stays opt-in. sign_key without encrypt_to or sign_only is 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.
  • -I and 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_key wrote 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.

Name the chain by URL

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_url writes x5u beside the x5c:

    [[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://, and sign_cert_url on a rule that does not sign.
    • sipnab serves nothing at the URL: put the PEM chain there yourself.
  • A reader verifies a signature that carries x5c with it, and fetches nothing.

  • For a signature with x5u and no x5c, 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 with system.
    • 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 x5u that is not https:// before any fetch.
    • The signature must verify against the first certificate the URL serves. --vcon-signer-ca and --vcon-signer-sha256 then check that chain, as they check an x5c.

Re-sign for a long-term archive

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.json
INFO sipnab::app::bootstrap: --vcon-resign: wrote 'archived.vcon.json'
  • sipnab verifies every signed layer of the input. With --vcon-signer-ca or --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 header carries the new signer's x5c and the vCon's uuid.
  • The new key signs RS256 (RSA) or ES256 (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-ca and --vcon-signer-sha256 check 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.

Fetch the audio a vCon names

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.key
INFO 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:

  1. Opens the container the way -I does: unsigned, signed, encrypted or gzip, with --vcon-key, --vcon-signer-ca and --vcon-signer-sha256, or their [vcon_input] keys.
  2. Fetches the file over https://, trusting the host's CA bundle, --vcon-audio-fetch-ca alone, or --vcon-audio-fetch-extra-ca as well.
    • sipnab refuses a url that is not https:// 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 the url: for a spool whose files your storage does not serve yet.
  3. 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.
  4. Decrypts a JWE with the key that opens the container, through the same code. Its cty must be the recording's mediatype.
  5. Writes the audio to <uuid>-dialog<N>.wav, or .ogg for audio/ogg, in --vcon-audio-fetch-out, mode 0600. <N> is the recording's index in dialog. 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.key

An unencrypted rule's WAV goes through the same steps, without the decryption.

The spool contract, for a bridge that consumes it

--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 --filter speaks.
  • Each flag requires the other.
  • --export-vcon-when conflicts 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/vcon

The 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 (-d or --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 OK answers 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_ended field is false. 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 409 for 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.
  • 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-map and --content-deny-tombstone with --export-vcon-when,
  • --redact and --redact-map with --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:

  • rename is 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 *.json glob 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 is 1-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-digest lines,
    • the forwarder's delivered/ and failed/ 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 sha256sum format.
  • So sipnab ... --vcon-digest > SHA256SUMS and a later sha256sum -c SHA256SUMS both 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.

Deliver the spool to a store

sipnab --vcon-forward is a second sipnab process, started on its own.

  • It delivers the containers in an --export-vcon-dir spool to a vCon store over HTTP or HTTPS.
  • It needs the vcon feature, like the export.
  • It captures nothing. sipnab refuses --vcon-forward beside 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:

  • --redact on the export replaces identities and addresses with keyed tokens.
  • What may you conclude lists what a container carries.

Run it

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_AUTH holds exactly what the file would hold.
  • The --vcon-forward-auth flag 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-file or [vcon_forward] auth_file.
    • It refuses an empty one.
  • 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:

  • 0 when the store accepted every container, or the spool held none,
  • 1 when the store refused one, or one is still waiting,
  • 2 when sipnab refuses a setting given on the command line,
  • 3 when 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_file that sipnab cannot read or that holds no credential.
  • The same file named by --vcon-forward-auth-file exits 2.

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.

Choose the store kind

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 204 for a new container. GET /vcon/{uuid} then returned the container with an amended member added.
    • It answered 204 for the same uuid sent again. The read-back then showed the second copy.
    • It answered 422 for a body that is not JSON, and for one without uuid.
    • It answered 403 with no key or a wrong one.

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, not https://api.vcon.store/v1/vcons).
  • The forwarder sends a credential that is a full Header-Name: value line 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-url applies to any kind.
    • No kind sets one. Nobody has measured a PUT to vcon.store.
# 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-once

Keep the settings in sipnab.toml

Every 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 = 5
sipnab --vcon-forward /var/spool/sipnab-vcon --config /etc/sipnab/vcon-forward.toml

sipnab 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.

Deliver to two stores

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/delivered

The 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-store

A 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.

What it does with each container

The forwarder POSTs the file's bytes unchanged. It sends these headers:

  • Content-Type:
    • application/json for a JSON container, or the type --vcon-forward-content-type ([vcon_forward] content_type) names: application/vcon+json or application/vcon;
    • application/vcon+gzip for a gzip format container, known by its first two bytes 1f 8b, whatever that setting says. draft-ietf-vcon-vcon-core-04 section 5.4 says a gzip vCon should carry the media type application/vcon+gzip wherever a media type travels, such as an HTTPS Content-Type header.
  • User-Agent: sipnab/<version>,
  • the header from the file.

Why the default stays application/json:

  • On 2026-10-14 a self-hosted conserver answered 422 to a container sent as application/vcon, and 204 to the same container sent as application/json or application/vcon+json.
  • application/vcon is the type section 6.1 registers. A store that wants it gets it from the setting.
  • The forwarder reads the uuid a 409 replace 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 Error to a gzip format body sent as Content-Type: application/vcon+gzip, the type the forwarder sends;
  • 400 with {"detail":"There was an error parsing the body"} to the same body sent as application/json with Content-Encoding: gzip. The forwarder never sends Content-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 204 to 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 5xx the way it retries any 5xx:
    • 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 500 at most once per back-off, and it never moves to failed/.
    • With --vcon-forward-once, the file stays in the spool and the run exits 1.
    • The forwarder keeps the back-off in memory, so after a restart it starts again at --vcon-forward-backoff-first.

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-body bytes (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-head bytes (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:

All three give one answer.

  • It counts the containers pending in the spool and in the default delivered/ and failed/ directories.
  • It returns each failed container's <name>.error.json record.
  • It reads files and nothing else.
  • The answer is a snapshot dated by its as_of field. 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.

Trust a store's certificate

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.

What it guarantees, and what it does not

  • A 2xx means the store accepted the container, not that it kept it.
    • The forwarder logs delivered, never stored.
    • A self-hosted conserver once answered 204 for 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 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 4xx other than a 401, a 403 or a 409 it can replace.
    • That container stays in the failed directory until a person reads the record and decides.
  • A refused credential stops it, and moves nothing.
    • A wrong key draws a 401 or 403 for 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 403 with error code: 1010.
    • Fix the credential or the client, and start the forwarder again.
  • 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.

Register lifecycle statements with a transparency service

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 no updated_at, or one equal to created_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>/entries as application/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) is scitt_issuer, a URI of at most MAX_ISSUER_LEN (8192) characters, sub (2) is urn:uuid:<uuid>, and iat (6) is the time of signing.
    • x5chain (33): the certificate chain of scitt_sign_cert, leaf first.
  • The payload is a JSON object: event, vcon_uuid, vcon_hash (the sha512- hash of the container's bytes as delivered), at, and for vcon_sent the store's URL in to. It carries no party data.
  • The key is a PEM RSA or EC P-256 key, the same kinds an encrypting [[vcon.rule]] names in sign_key, readable by its owner only. The first certificate in scitt_sign_cert must 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-key named the key, 1 when scitt_sign_key did.

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 its Location.
  • 202: the Location of the receipt. sipnab polls it for the receipt, as section 2.4 of the SCRAPI draft describes. poll_status is the status of the last poll.
    • Each poll is a GET with Accept: application/cose and the scitt_auth_file header.
    • 200: the answer is the receipt, kept in receipt as a 201's is.
    • 204, or 202: the registration is still running; sipnab polls again.
    • 429 or a 5xx: sipnab polls again; poll_error holds the answer until a later poll replaces it.
    • Any other 4xx, such as the 404 of a registration that failed (section 2.4.3): error holds its problem, and polling ends.
  • 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 most MAX_POLLS (16) polls.
  • The polling stays within timeout (--vcon-forward-timeout), which counts from the statement's POST. sipnab starts no poll whose wait would reach it. When the time runs out, pending is true and location is 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 Location only on the service's own origin: the scheme, host and port of scitt_url. A path alone, such as /entries/67ee..., is on it. Any other Location is not fetched, so the scitt_auth_file header goes to no other host; poll_error says so.
  • A receipt longer than MAX_RECEIPT (65536) bytes: error says so, and sipnab keeps no part of it.
  • Any other status: error holds the title and detail of the service's Concise Problem Details, or the start of its answer.
  • No answer: error says why, and there is no status.

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_received and vcon_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>.scitt records it. Sign with an EC P-256 key for ES256.
  • Authentication is out of the SCRAPI draft's scope (section 4.3). scitt_auth_file sends one header line, as the store's auth_file does.

Fetch a stored vCon

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 vcon feature.
  • 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.

Fetch one

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.txt

Each 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.

The store kinds

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 GET on 2026-10-09 that returned the container's members and _meta.
  • conserver: api/api.py of the vCon server (vcon-dev/vcon-server).
  • vcon-mcp: src/api/routes/vcons.ts, src/api/rest-router.ts and src/api/auth.ts of vcon-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.

What it does with each answer

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-overwrite replaces it.
  • Neither form writes through a symbolic link.

Exit codes:

  • 0 when the fetcher wrote every container and the schema accepts each,
  • 1 when it wrote none for some uuid, or the schema refuses one,
  • 2 when sipnab refuses a setting given on the command line,
  • 3 when the store refused the credential or the client.

A config file sipnab refuses exits 1, as on any run.

Fetch an encrypted vCon

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.key

For each encrypted container the fetcher:

  • finds the uuid in the container's unprotected header;
  • 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-size bytes;
  • checks that the vCon inside holds the uuid asked for;
  • checks it against the vendored schema;
  • writes it to <uuid>.vcon.json, mode 0600.

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.

Check who signed it

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 in x5c, to a CA certificate in this PEM file, and every certificate in that chain must be within its validity period now. The value system uses the host's CA bundle, the one the fetcher trusts for HTTPS; write ./system for 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 as openssl x509 -noout -fingerprint -sha256 prints 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.pem

With 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 signed container

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-ca and --vcon-fetch-signer-sha256 when 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, mode 0600, 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.

Read what came back

Each file is one vCon, as JSON.

  • -I reads 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.

Fetch from an agent or a REST client

A running sipnab server can read one container from the same store by uuid and hand it back in the answer:

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, kind and auth_file say where and with which credential;
  • decrypt_key, signer_ca and signer_sha256 decrypt an encrypted container and check who signed it, as they do for the fetcher;
  • timeout, max_size, max_response_head, ca and extra_ca apply 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-fetch
curl -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.

Walk through one, end to end

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.

Check the build carries the feature

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_test
running 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

Build the container

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.pcap

Its 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 DialogStore the 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"));

Read what came out

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 base64url text, which is a string.
  • A store can return a json body 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 sip URI. sipnab sent no SIP, and a synthesized one would name a participant that never existed.
  • Its role says observer.
  • Both attachments point their party index 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 Contact and User-Agent off 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 mediatype or a url would name a file that does not exist.
  • The object's type is recording with 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.

What the Dialog Object says about the call

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.

  • parties is [0, 1]: the From party, then the To party.

    • Section 4.3.4 of draft-ietf-vcon-vcon-core-04 makes parties a SHOULD on a recording and optional on an incomplete.
    • On an incomplete object 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.
  • originator is 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 To tag.
    • 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, and originator names it. Section 4.3.5 gives that form for an originator that is not known.
  • duration is the conversation in seconds, from the 200 to the initial INVITE to the BYE.

    • Section 4.3.3 defines duration in seconds.
    • It is absent when the capture lacks the initial INVITE, its 200 or the BYE.
    • start stays the first message sipnab saw, so start plus duration is not the end of the call.
    • On an object with no body or url, duration is not the length of any recording. A consumer that reads duration to size media must check for content first.
  • sip_cseq is the CSeq number of the request that created the dialog.

  • party_history lists what the capture shows each party doing, in time order, per section 4.3.13.

    • Each entry has party, time and event.
    • 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 is sendonly or inactive puts the other party on hold, at the request.
    • unhold: a later sendrecv offer from the same sender takes that party off hold. A recvonly offer changes nothing.
    • keydown and keyup carry the digit in button, and appear only from a rule with dtmf_events = true.
      • An INFO with application/dtmf-relay is a keydown at the INFO and a keyup after its stated Duration.
      • An RFC 4733 telephone event is a keyup at its end packet and a keydown its 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.
    • sipnab writes no mute or unmute, 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.
    • --redact removes keydown and keyup, because a digit can be a PIN, and keeps the rest.
    • A container that holds several legs moves each entry's party with the rest of the party indices.

Text a party sent

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.
  • start is when sipnab captured the message. Section 4.3.2 uses the send time when the typing time is not known.
  • parties lists the sender first, then the other party, so the sender is the originator and originator stays absent.
    • It is absent when the message's tag names neither party.
  • mediatype is the message's Content-Type as written, and the body is inline.
    • A UTF-8 body uses encoding: "none"; any other body is base64url.
  • A MESSAGE with no body is not a text dialog, and neither is an application/im-iscomposing+xml typing indication (RFC 3994).
  • --redact removes the body, encoding and mediatype, and keeps start and parties, as it does for a recording.
  • sipnab does not parse MSRP, so text sent over an MSRP session has no text dialog.

Contact-center labels

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.
  • --redact turns interaction_id into a token, so two containers of one interaction still match, and sweeps campaign and skill as free text.
  • The parameter is interaction_id, as section 3.2.3 spells it. vcon-lib's list of Dialog parameters spells it interaction, so a vcon-lib reader that filters on that list drops it.

A SIPREC recording session

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 From and To of the recording session.

    • The party that sent the metadata has role: "recording-client", and the other one has role: "recording-server".
    • The role parameter comes from the CC extension, which sipnab already uses for "observer". The extension allows values other than the ones it lists.
  • Each <participant> becomes one party, in metadata order, after parties 0 and 1 and before the observer.

    • sip is the aor of the participant's <nameID> when it is a sip: or sips: URI. tel is the aor when it is a tel: URI, or a global number in the user part of a SIP AOR, by the rule in tel.
    • name is 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 first aor and the first <name>.
    • An AOR that is a host name or an IP address gives no sip or tel. RFC 7865 allows both forms.
    • validation is "none" and role is absent. The SRC asserted these identities, and sipnab cannot check them.
  • The sipnab-siprec-streams attachment 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.
    • party is absent on a stream that no <send> names.
    • A container that holds several legs moves each party with the rest of the party indices.
  • When the container carries audio, the recording's parties names the participant on each channel.

    • Each stream has an a=label on the m-line of the SRC's SDP that carries it. RFC 7866 section 7.1.1 requires the SRC to put an a=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 parties is usually absent.
  • 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-metadata part 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 attachments

The sip-signaling extension of draft-howe-vcon-sip-signaling-00 stores SIP in attachments. sipnab declares the extension in extensions.

The message trace

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.

  • version is the string "1.0", the trace format version the draft defines.
    • Until this release it was the number 1.
    • schema_version stays the number 1 for readers of sipnab's own shape.
  • call_id and sip_call_id both hold the Call-ID.
  • Each message carries the draft's fields.
    • timestamp is when sipnab captured it.
    • direction is "received" on every message. The draft defines it from the producer's side, and sipnab sent nothing.
    • party is the index of the party whose tag the message carries. It is absent when the tags match neither party, such as a 100 Trying from a proxy.
    • A request carries method. A response carries status_code and status_text instead.
    • headers maps each header name to a list of its values, without the credential headers.
    • body is the message body when there is one. A body that is not UTF-8 is base64url text, with body_encoding: "base64url".
  • Under --redact, no message keeps body or body_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 party with the rest of the party indices.

The INVITE and the final response

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-invite is the initial INVITE, and sip-response is 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 To tag. After a 407 challenge, 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.
  • sip-sdp holds the SDP body of each of the two, with mediatype: "application/sdp", per section 6.3.
  • Each attachment's party is the party whose tag the message carries.
    • It is the observer when the tag matches neither party, such as a proxy's own response.
    • start is when sipnab captured the message, and dialog is 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-Length still matches it.
  • UTF-8 messages use encoding: "none"; others use base64url.
  • The rule's inline budget, max_inline_media_mib or --vcon-max-inline-media, covers the attachments together.
    • An attachment that does not fit stays out. The sipnab-capture-completeness attachment names it, with its size, in sip_messages_refused.
  • --redact removes 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 -I reader takes the messages from the trace, and reads message/sip attachments only from a container without a trace.

The network's STIR verdict

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 To tag that carries verstat.
    • sipnab reads P-Asserted-Identity first, then From.
    • With no verstat, there is no report, even when the INVITE carries a PASSporT.
  • result maps the verstat value onto the draft's values.
    • TN-Validation-Passed is verified.
    • TN-Validation-Failed is failed.
    • No-TN-Validation is no-signature when the INVITE carried no Identity header. When it carried one, result is absent, because the draft has no value for "not verified".
  • attestation, orig_tn and dest_tn are the claims of the PASSporT in the same INVITE, when there is one.
  • timestamp is when sipnab captured the INVITE, which is after the network verified it.
  • verifier is absent, because the header does not name who verified.
  • reason states that sipnab transcribed the value and verified nothing.
  • verstat and verstat_header keep the value as written and the header it came from.
  • party is 0, the caller, and dialog is 0.
  • --redact removes orig_tn and dest_tn, as it removes the PASSporT from the party.

Tags

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's when references by @name and 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.
  • 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_diagnosis in the analysis body, such as final_failure or retransmissions, in that object's order.
    • A container that holds several legs names each finding once.
  • No value contains a colon. vcon-lib takes the text between the first colon and the next one as the value.
  • party is the observer and dialog is 0.
  • A container from --export-vcon-when, the REST API or MCP has no rule name and carries no tags attachment.
  • A withheld container carries none.

The container cannot say "this is an incomplete record", so sipnab says it twice

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 type enum 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 mediatype when 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 type at all. draft-ietf-vcon-vcon-core-03 allowed "a Dialog Object with no parameters in it".
  • -04 removed that sentence. It requires type on every Dialog Object.
  • So a container for a call with no media now carries "type": "recording" and no body or url.
  • 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_link transcription link reads dialog["url"] with a bracket, and raises on such an object.
    • The hugging_face_whisper and groq_whisper links read dialog["duration"] the same way.
    • The type-free object raised in all four transcription links, openai_transcribe included, at dialog["type"].

The last two rows move together.

  • Section 4.3.11 of the core draft makes disposition a MUST on an incomplete object.
  • No value in its closed set means "not observed".
  • So one decision fixes both fields.
  • disposition names 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 incomplete object. The section says disposition "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.

What the caveat looks like when the run lost something

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

When the call had not ended

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 -I capture,
  • 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 INCOMPLETE clause. 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.

When a container is missing on purpose

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}/vcon answers 403 for it.
    • MCP export_vcon refuses it by call_id. It leaves it out of a filtered export, counting it in withheld_by_deny.
  • --content-deny-tombstone makes the narrower reading available.
    • That is an identity-only container carrying a redacted object (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".

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.md for the runtime gate.

Someone handed you a sipnab vCon โ€” what may you conclude?

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.

What sipnab refuses to put in a container

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 name field. That was wrong.
  • Party::name exists. It carries the display name from From or To.
  • 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.

  • validation is 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 name AND sip_display_name.
  • An earlier version of this page would have led you to redact only the second.

The message trace carries the headers, and strips the credentials

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 Via or Record-Route keeps 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-ID and any custom header your platform sets all travel, exactly as the endpoint wrote them.

One thing sipnab does supply, narrowly: tel

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.
    • 1001 is 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 tags that tell forked legs apart

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.

Analyze a stored vCon

-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_vcon and POST /v1/vcon/analyze do the same for a container handed over inline.

Have a container

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 --quiet

It prints nothing and exits 0. It leaves call.vcon.json beside the capture.

Analyze it

sipnab -N -I call.vcon.json --analyze --no-cli-print --quiet
Capture 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.

A directory of them

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 --quiet

Add a container that stores no SIP at all:

printf '{"vcon": "0.4.0", "parties": []}\n' > stored/broken.vcon.json

Read 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.

Read many containers at once

--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 --quiet
Call-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, or skipped: 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: critical lists 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, encrypted or gzip, or unknown for 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 signed, encrypted or compressed container

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.
  • 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.
  • The encrypted form (section 5.3):
    • sipnab decrypts it with the private key --vcon-key names, 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.

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_vcon takes a signed or encrypted container as container, and any form, the gzip format included, as container_base64.
  • POST /v1/vcon/analyze and POST /v1/vcon/validate take a JSON body with Content-Type: application/json, application/vcon or application/vcon+json, and a gzip format body with Content-Type: application/vcon+gzip.
  • validate_vcon and POST /v1/vcon/validate validate the unsigned vCon inside.
  • A container that does not open is a verdict: unreadable answer from the analyze tool and route. The validate tool and route refuse it; the route answers 422.
  • They read with the server's --vcon-key, --vcon-signer-ca and --vcon-signer-sha256.

A container listing an extension sipnab does not support is not read

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-signaling and CC.
  • sipnab skips a container whose critical lists anything else, as it skips a container without readable SIP. The reason names each unsupported extension.
  • analyze_vcon and POST /v1/vcon/analyze answer verdict: unreadable with that reason. They run no schema check.
  • validate_vcon and POST /v1/vcon/validate refuse the container.

A container the schema refuses is still read

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 extensions back as an object rather than the array the schema requires:
jq '.extensions = {"sip-signaling": true, "CC": true}' call.vcon.json > store-shape.vcon.json
sipnab -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.

What sipnab reads, and what cannot come back

  • Two stored forms.
    • The first is the sip-message-trace attachment 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/sip attachments, 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.
    • 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.
  • 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.
    • extensions counts in either form, the core-04 array or the vcon.store object.
  • sipnab skips unreadable SIP, not a schema finding.
    • It skips a container with no sip-message-trace or message/sip attachment, 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.
  • 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_vcon and POST /v1/vcon/analyze return the producer's own media_quality numbers 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 .invalid domain.
  • 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-Length states 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 --cores or a BPF filter.
    • --cores shards captured frames. A BPF filter selects them.
    • A container holds neither. So sipnab refuses both, with the reason.
    • Select dialogs with --filter instead.
  • sipnab sends nothing anywhere. The addresses in a container are a record of a finished call. So sipnab never transmits a response to them.

The uuid under a domain

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.

What this does not do

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 recording Dialog Object with a sha512- content hash.
    • A rule with audio = "url" instead writes the audio file beside the container. It names the file by a url under the rule's audio_url_base, with the content_hash of the file and no body.
    • The operator's storage serves that URL. sipnab does not check that it does.
    • A --redact export never names a url.
    • 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-media sets that budget in MiB. It defaults to 5.
    • That figure comes from a measurement against a real vCon store. The store:
      • answered 204 for 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.
    • 0 refuses 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-media to what the store you send to accepts. Or write the WAV beside the container with the export_audio MCP tool.

    A --redact export 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.media says withheld-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.

  • 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 its sipnab-related-legs attachment, 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.
  • It does not write a .vconz bundle.

    • draft-miller-vcon-zip-bundle-00 names each external file by its content_hash, with an extension from the mediatype.
    • For an encrypted recording that rule gives the ciphertext an .ogg or .wav extension.
    • 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.
  • 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 uuid is safe for re-exports. That is what it exists for.
    • Until 0.5.125 rand_b digested the node alone. That left 12 bits to separate such dialogs.
    • [vcon] uuid_domain replaces rand_b with 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.

See also

  • 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 DialogStore from a capture
โš ๏ธ **GitHub.com Fallback** โš ๏ธ