Docs Send sipnab's vCons to a vCon server

Send sipnab's vCons to a vCon server

Have sipnab write a vCon for every finished call and forward each one to vcon-server, on the same machine or another, kept apart from recorded calls.

On this page

sipnab can write a vCon for every call it sees. Unlike the recording in Add a vCon server to an OpenSIPS voice stack, sipnab is not in the call path: it reads a copy of the traffic, so it needs nothing from OpenSIPS and changes nothing about how OpenSIPS handles calls. It writes each vCon to a directory. A small forwarder sends them from there to vcon-server and deletes each one once the server has taken it.

This guide starts where the vCon server guide ends, with vcon-server running. The first part puts sipnab on the same machine. The last section says what changes when sipnab runs on another machine.

Keep recorded and observed vCons apart

A vCon from the recorder holds the audio a party to the call handed over. A vCon from sipnab holds what a passive observer saw on the wire, which can be less: a packet the capture missed is not in it. Consumers should be able to tell the two apart, so this guide gives sipnab its own ingress list, sipnab, and its own chain, which tags each vCon vcon_role:observer and stores it in a separate table, vcons_observed. The recorder’s vCons stay in vcons_recorded.

Tested on

Run on 2026-09-25 on the Debian 13 x86_64 machine from the vCon server guide, with sipnab from its .deb.

1. Add an ingress list and a chain for sipnab

Add a key, a tag and a table for sipnab to /opt/vcon/config.yml, leaving the recorder’s settings as they are:

# Run all of these, in order.
cd /opt/vcon
. ./.env
python3 - "$(openssl rand -hex 32)" "$POSTGRES_PASSWORD" <<'EOF'
import sys
key, password = sys.argv[1], sys.argv[2]
path = "config.yml"
text = open(path).read()
text = text.replace("ingress_auth:\n", f'ingress_auth:\n  sipnab: "{key}"\n', 1)
text = text.replace("links:\n", "links:\n  mark_observer:\n    module: links.tag\n"
                    "    options:\n      tags: [vcon_role:observer]\n", 1)
text = text.replace("storages:\n", "storages:\n  postgres_observed:\n"
                    "    module: storage.postgres\n    options:\n      host: postgres\n"
                    "      port: 5432\n      database: vcon\n      user: vcon\n"
                    f'      password: "{password}"\n      table_name: vcons_observed\n', 1)
text += ("  sipnab_observer:\n    links: [mark_observer]\n    ingress_lists: [sipnab]\n"
         "    storages: [postgres_observed]\n    enabled: 1\n")
open(path, "w").write(text)
EOF
docker compose restart conserver api

config.yml now has two keys under ingress_auth, two links, two entries under storages and two chains.

2. Install sipnab

# Run all of these, in order.
V=$(curl -fsSL https://api.github.com/repos/NormB/sipnab/releases/latest \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"].lstrip("v"))')
curl -fsSLO https://github.com/NormB/sipnab/releases/download/v$V/sipnab_${V}_amd64.deb
curl -fsSL https://github.com/NormB/sipnab/releases/download/v$V/SHA256SUMS.txt \
  | grep " sipnab_${V}_amd64.deb$" | sha256sum -c -
sudo apt-get install -y ./sipnab_${V}_amd64.deb

3. Have sipnab write a vCon for every finished call

sipnab’s package runs it as the sipnab user under systemd. A drop-in adds three things to what it does:

  • --export-vcon-when "state == 'Completed'" picks the calls to write, in the same language --filter uses (Filter language). This one writes every call that ended with a BYE; state == 'Failed' would write the failed ones instead.
  • --export-vcon-dir /var/spool/sipnab-vcon is where the files go.
  • --retain-audio keeps each call’s audio so that the vCon can carry it. Without it the vCon holds the signaling only.

For the audio to be there at all, sipnab has to capture it. By default it captures only the SIP ports, so give it a capture filter that also admits the media ports. rtpengine relays media on 30000-39999 in the vCon server guide:

# Run all of these, in order.
sudo install -d -o sipnab -g sipnab -m 0750 /var/spool/sipnab-vcon
sudo mkdir -p /etc/systemd/system/sipnab.service.d
sudo tee /etc/systemd/system/sipnab.service.d/vcon.conf >/dev/null <<'EOF'
[Service]
ExecStart=
ExecStart=/usr/bin/sipnab -N -d any --no-cli-print --syslog --metrics 127.0.0.1:9090 \
  --retain-audio \
  --export-vcon-when "state == 'Completed'" \
  --export-vcon-dir /var/spool/sipnab-vcon \
  "port 5060 or port 5090 or udp portrange 30000-39999"
ReadWritePaths=/var/spool/sipnab-vcon
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now sipnab

sipnab needs ReadWritePaths because the unit makes the rest of the file system read-only to sipnab.

Following Let sipnab see and control TFPS as well? Both guides replace sipnab’s ExecStart in a drop-in, and systemd uses the last ExecStart= it reads, taking drop-ins in file-name order. So one silently replaces the other. Put the flags from both into one drop-in instead.

When the file appears. On a live capture, sipnab writes a call’s vCon within about ten seconds of the call ending: it checks every five seconds, and waits until the call has been quiet for five. If you stop sipnab, it writes nothing more: calls that ended in the last few seconds before the stop are not written. Stopping means stopping, and sipnab keeps no call data behind.

4. Install the forwarder

sipnab never sends a vCon anywhere itself. vcon_forward.py, from sipnab’s repository, posts each file in the directory to vcon-server’s sipnab ingress list and deletes it once the server has taken it. It needs only Python’s standard library.

# Run all of these, in order.
V=$(curl -fsSL https://api.github.com/repos/NormB/sipnab/releases/latest \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"].lstrip("v"))')
sudo install -d /usr/local/lib/sipnab
sudo curl -fsSL -o /usr/local/lib/sipnab/vcon_forward.py \
  https://raw.githubusercontent.com/NormB/sipnab/v$V/clients/python/vcon_forward.py
KEY=$(sed -n 's/^  sipnab: "\(.*\)"/\1/p' /opt/vcon/config.yml)
sudo sh -c "umask 077; printf 'VCON_INGRESS_TOKEN=%s\n' '$KEY' > /etc/sipnab/vcon-forward.env"
sudo tee /etc/systemd/system/sipnab-vcon-forward.service >/dev/null <<'EOF'
[Unit]
Description=Forward sipnab vCons to the vCon server
After=network-online.target sipnab.service
Wants=network-online.target

[Service]
User=sipnab
Group=sipnab
EnvironmentFile=/etc/sipnab/vcon-forward.env
ExecStart=/usr/bin/python3 /usr/local/lib/sipnab/vcon_forward.py \
  --spool /var/spool/sipnab-vcon --url http://127.0.0.1:8000 --ingress-list sipnab
Restart=on-failure
RestartSec=10
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/spool/sipnab-vcon

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now sipnab-vcon-forward

The key lives in a file only root can read. systemd reads it before starting the forwarder, so it never appears on a command line.

What the forwarder does with each file:

The server answersThe forwarder
2xxdeletes the file, unless sipnab replaced it during the send, in which case it sends the new one on the next pass
401, 403 or 404stops: the key or the list name is wrong, and retrying would not help
another 4xxmoves the file to rejected/ in the directory, for a person to read
5xx, or no answerkeeps the file and tries again five seconds later

5. Place a call and find its vCon

Place the test call from the vCon server guide. After about ten seconds:

# Run all of these, in order.
sudo journalctl -u sipnab-vcon-forward -n 5
sudo ls /var/spool/sipnab-vcon
cd /opt/vcon
docker compose exec -T postgres psql -U vcon -d vcon -c \
  "select id, vcon_json->'attachments'->-1->>'body' as tags,
          jsonb_array_length(vcon_json->'dialog') as dialogs
     from vcons_observed order by created_at desc limit 5"

The forwarder logs sent <file> for each vCon, the directory is empty, and vcons_observed has the vCons tagged ["vcon_role:observer"].

The test call makes two vCons in vcons_observed, not one. sipnab sees the call and also the SIPREC session OpenSIPS opens to the recorder, which is a SIP call of its own. The SIPREC session’s first message goes to the recorder’s port, 5090, so this expression keeps the call and leaves the recording session out:

state == 'Completed' and dst.port != 5090

Run sipnab on a different machine

sipnab has to see the calls, so it runs on a machine the SIP and media traffic reaches, or on one that receives a copy of it (a mirror port, or a HEP feed). The forwarder runs beside sipnab, because it reads sipnab’s directory.

On that machine, do steps 2, 3 and 4 as they are, with two changes:

  • In the forwarder’s unit, point --url at the vCon server: --url http://192.0.2.10:8000.
  • Copy the sipnab key from the vCon server’s config.yml into /etc/sipnab/vcon-forward.env by hand, as VCON_INGRESS_TOKEN=<key>.

On the vCon server, port 8000 is now reachable from the network. Allow it only from the machines that post vCons. The key is for posting to one list. The CONSERVER_API_TOKEN in /opt/vcon/.env reads every vCon, so it never leaves the vCon server.

When something does not work

  • The directory stays empty after a call. Check that the call matches the expression: sipnab -N -I on a capture of it, with --report, shows its state. On a stopped sipnab, see “When the file appears” above.
  • The vCon has no audio. Either --retain-audio is missing, or the capture filter does not admit the media ports.
  • The forwarder stops with answered 403. The key in /etc/sipnab/vcon-forward.env does not match the sipnab key in config.yml.
  • Files pile up in rejected/. vcon-server refused them. Its log says why: docker compose logs conserver.