Skip to content

docs: operate sipnab: service, install, listener security, troubleshooting - #638

Merged
NormB merged 6 commits into
mainfrom
docs/howto-ops-service
Nov 4, 2026
Merged

NormB merged 6 commits into
mainfrom
docs/howto-ops-service

Conversation

@NormB

@NormB NormB commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Task pages for operating sipnab, from the documentation gap analysis (ops area). Commands and outputs come from runs against the v0.5.232 release artifacts. Those ran in throwaway containers (systemd on Debian 13, Fedora 42, Prometheus 3.5.0) or on loopback. The source differs from v0.5.232 only in the content-persistence gate's config handling (e637453) and one doc comment, none of which these pages describe.

Release procedure: the download marker forms changed

Phase two of a release rewrites the download markers in docs/install.md by pattern. This PR changes them:

Marker main this PR
SIPNAB_VERSION= 1 2
VERSION= 2 2
e.g. <version> 2 2
rpm -i sipnab-<v> 4 0
dnf install ./sipnab-<v> 0 4
ghcr.io/normb/sipnab:<v> 0 6

Why dnf install, on Fedora 42 against the 0.5.232 rpm:

$ rpm -i sipnab-0.5.232-1.aarch64.rpm
error: Failed dependencies:
	libpcap is needed by sipnab-0.5.232-1.aarch64
	libpcap.so.1()(64bit) is needed by sipnab-0.5.232-1.aarch64
exit=1
$ dnf install ./sipnab-0.5.232-1.aarch64.rpm
Installing dependencies: libibverbs 55.0-1.fc42, libnl3 3.11.0-3.fc42, libpcap 14:1.10.6-1.fc42
Complete!
exit=0

The image tags are new markers because the Docker recipes now pin a release: latest follows main, not releases. On 2026-11-03 it was main commit 0d8eea1, attested from refs/heads/main.

Every consumer of the marker set moves with this change:

  • docs_current_version_markers_match_cargo;
  • every_download_instruction_names_the_advertised_version;
  • no_cut_marker_pattern_also_matches_a_published_marker, which now also checks the dnf and image forms;
  • the marker list in docs/internals/build-ci-release.md.

Mutating one dnf line, and then one image tag, to 0.5.231 failed docs_current_version_markers_match_cargo ("names 0.5.231 but the last PUBLISHED release is 0.5.232"). Both restores were checked with cmp. No script and no site template reads the rpm lines.

Pages

  • New docs/run-as-a-service.md:
    • the packaged unit and how to check it;
    • a drop-in that turns on the REST API with its key in an EnvironmentFile=;
    • a unit for installer and tarball installs;
    • where the log goes and what --syslog sends;
    • signals, measured: SIGHUP is unhandled, and the packaged service stays down after it;
    • crash reports;
    • audit file rotation with logrotate copytruncate;
    • the refusals a service meets;
    • health checks.
  • Corrected units in docs/examples.md and docs/mcp-deploy.md. These were measured failing:
    • setcap under NoNewPrivileges=true cannot capture;
    • a root-only token file under User=sipnab stops the run with exit 2;
    • one unit had no User=, yet the page said it ran without root.
  • docs/install.md:
    • installer failures;
    • dnf recipes;
    • the audio plugin missing from the Linux packages since 0.5.118;
    • attestation and cosign output and failures;
    • Docker: amd64 only, a pinned tag, --user root for live capture, and image provenance;
    • what uninstall leaves behind;
    • source build failures.
    • Homebrew is marked as not run (no Mac available).
  • docs/auth.md: "Secure every listener":
    • the four listeners' credentials, TLS flags and refusals;
    • a private CA;
    • all four listeners over TLS, with checks;
    • a Prometheus HTTPS scrape;
    • root runs reading key files after the privilege drop.
    • rest-api.md and prometheus-metrics.md examples now pass --user sipnab.
  • docs/troubleshooting.md: operations sections.
  • docs/config-reference.md [crash], and contrib/observability/sipnab-hep.service's privilege comment.

Product defects found (docs state the measured behavior; no code or packaging changed)

  1. The packaged unit cannot write a crash report: failed to write crash report: Read-only file system (os error 30).

  2. ghcr.io/normb/sipnab:latest follows main, not releases.

  3. The image is linux/amd64 only.

  4. The image's sipnab user cannot capture, even with --cap-add.

  5. SIGHUP is unhandled. systemd records Result=success, so Restart=on-failure leaves the service down.

  6. Log lines carry ANSI color codes when stderr is not a terminal. NO_COLOR=1 removes them.

  7. The Permission denied on <dev> hint suggests --setup-caps, sudo and setcap even under NoNewPrivileges or in a container.

  8. --chroot breaks every root run. The drop user is looked up inside the new root: User 'nobody' not found, exit 1.

  9. Since 0.5.118 the Linux gnu tarballs, .deb and .rpm ship no audio plugin and no libasound Recommends. release.yml adds ,bpf to the gnu feature set and then tests features = "full".

  10. apt remove and dnf remove neither stop nor disable the service.

  11. A root run reads these AFTER dropping privileges, so root-only files stop it with exit 2:

    • --api-signing-key-file, --mcp-signing-key-file, --mcp-token-file, --metrics-auth-file;
    • the REST and MCP TLS certificate and key;
    • a HEP sender's --hep-auth-file.

    Many sudo sipnab ... /etc/sipnab/... examples in cli-reference.md fail this way.

  12. An unreadable MCP TLS key reports only the bare path.

  13. The HEP listener's routable-bind refusal exits 1, while the REST, MCP and metrics refusals exit 2.

  14. --mcp --mcp-transport http with --json is refused with the stdio message.

  15. The runtime image has no CA bundle.

  16. The installer installs the -gnu build without libpcap present.

Ratchets

Attributed per file in the commit message.

Gates

The full pre-commit hook passed on this commit before the rebase onto 4d85bd7. After the rebase these passed in the quick slot:

  • release_completeness_test;
  • link_integrity_test;
  • docs_drift_test;
  • link_relevance_test;
  • doc_ellipsis_quote_test.

The pre-push hook runs on push.

After the rebase, the commit message's statement about the base was out of date, so the message was replaced with git commit-tree -S on the same tested tree and parent. bash .githooks/commit-msg passed on that exact message: Commit message names no private identity... OK.

Least-privilege confinement (--sandbox, --seccomp) is the capture area's page. This PR links its reference rows: that page is not on main yet.

POST /v1/tfps/ban against a TFPS whose tfps_ctl wrote `expires` as an
RFC 3339 string: TFPS applied the ban, sipnab answered 502 "TFPS could
not be asked: ... cannot read", and journaled the outcome as `failed`.
Every later unban answered 409 "sipnab holds no ban on this address",
admin token included. sipnab had lost track of a ban it caused, and no
surface could lift it.

Root cause: once tfps_ctl has run, TFPS may have acted whatever becomes
of its answer, but action_reply() turned every TfpsError into the same
Err, and finish() journaled every Err as `failed`. The ledger reads
`failed` as "did not happen": it drops the intent from in-doubt and
takes no effect. So a timeout, a non-zero exit with no result, too much
output, or an answer outside the JSON contract all recorded an unknown
effect as no effect. The parse error only exposed it. Only a tfps_ctl
that never started (TfpsError::Spawn) is known to have done nothing.

Fix:
- TfpsError::ran(): every error but Spawn. action_reply() maps an error
  from a peer that ran to TfpsReply::Unanswered.
- outcome_of(), one rule for action and revert records: Unanswered is
  journaled as `unknown`; `failed` is kept for a peer that never ran.
- The ledger keeps an action whose outcome is `unknown` in doubt. It
  carries its address, caller and time, and `sipnab --journal-show`
  lists it.
- unban(): an action in doubt on the address being unbanned no longer
  refuses the unban (one on any other address still does). The caller
  of a ban in doubt may lift it, as may an admin token; anyone else gets
  403 naming the owner. TFPS's answer settles the doubt with `reconciled`
  records: an applied unban means the address was banned; not-blocked
  means it was not; any other answer leaves the doubt in place.
- ActionError::Unanswered: still 502 (REST) and refusal `tfps` (MCP),
  now worded "TFPS was asked, and sipnab could not read what it did",
  and saying the action is held in doubt and how to settle it.
  InDoubt's wording covers both causes.
- An unparseable answer names the contract sipnab reads: sippulse/tfps
  e7ee57a (2026-09-16) and later, where every time is epoch seconds; the
  draft of that contract (before 54dc12d on json-output-contract-history)
  wrote `expires` as RFC 3339. sipnab does not parse RFC 3339.

Other mutating actions: TFPS ban and unban are the only action target
(ActionTarget::Tfps); revert sends an unban and now records a lost
answer as `unknown` too. The kill-scanner packet expects no answer.

Tests (written first, seen failing for the right reason):
- tests/actions_tfps_ctl_test.rs: a fake tfps_ctl that bans and then
  answers garbage, a pre-contract RFC 3339 answer, a non-zero exit, or
  hangs past the timeout gives Unanswered; one that cannot start is
  still an error.
- tests/actions_service_test.rs: the lost ban is in doubt with address,
  caller and time; the caller and an admin can lift it and TFPS settles
  it; another caller gets NotYours and TFPS is not asked; not-blocked
  settles it as never applied; a lost unban answer keeps the doubt;
  other actions wait; a restart resolves it against TFPS's list; a
  revert whose answer is lost is held in doubt too.
- sipnab --journal-show and the InDoubt refusal now name both causes of
  doubt and the unban that settles one.
- tests/actions_journal_rest_test.rs: over REST with the real binary,
  for garbage, a non-zero exit and a timeout, each lifted by its caller
  and by an admin: 502 saying in doubt, outcome `unknown`, the unban
  reaches TFPS, and a new ban runs afterwards.
- tests/support/server.rs gains post_json_bearer_waiting: the hang case
  answers only after sipnab's own 10 s tfps_ctl timeout, which the
  shared client's 10 s read timeout could not outlast (it failed with
  EAGAIN under load). The ban step waits 30 s; under the ledger mutation
  the hang cases now fail on the 409 the report describes.
- tfps_failing_is_a_bad_gateway_and_the_outcome_is_journaled_as_failed
  asserted the defect itself (a tfps_ctl that ran and exited 1 with no
  result journaled as `failed`); it now asserts `unknown` and is renamed.

Found against a real TFPS during the 2026-11-03 documentation review.

Ratchet (tests/rust_style_known.tsv), per file:
- AssertWithoutMessage tests/actions_journal_rest_test.rs 15 -> 14: the
  corrected `unknown` assertion carries a message; the `failed` one it
  replaces did not.

(cherry picked from commit d032017)
@codecov

codecov Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

@NormB
NormB force-pushed the docs/howto-ops-service branch from 450e94b to 83ab49a Compare November 4, 2026 05:32
Comment thread src/output/api.rs Fixed
Comment thread src/output/prometheus.rs Fixed
@NormB
NormB force-pushed the docs/howto-ops-service branch from 83ab49a to 5e0f784 Compare November 4, 2026 06:48
NormB added 5 commits November 4, 2026 02:30
…eet inventory

Fleet management phase P0, slice 1 of docs/design/fleet-management.local.md:
read-only inventory, pulled from a static hosts file, behind a new
least-privilege token scope.

What it adds:
- GET /v1/inventory and the MCP tool fleet_inventory, built by one
  function (output::inventory::current): build, SHA-256 of the running
  binary, signature state (always unverified), configuration digests,
  privilege, uptime, interface, capture and HEP counters, HEP senders.
- Configuration digests only: file_sha256 of the bytes read,
  parsed_sha256 of the parsed configuration with sorted keys,
  argv_sha256 of the command line, and effective_sha256 over the last
  two. No value appears, and a secret given as a value changes
  effective_sha256.
- Token scope inventory: reaches GET /v1/inventory and nothing else.
  full, actions and admin reach it too. Refused at mint time for MCP.
- sipnab fleet inventory --hosts FILE [--json] [--timeout SECS]: asks
  every listed instance at once, marks unreachable, refused and
  incompatible instances, reports version_mismatch and
  unlisted_hep_sender, exits 1 when any instance did not answer or there
  is a finding.

The how-to is docs/fleet.md ("Manage many sipnab instances"): purpose,
prerequisites (API TLS, a token per instance, the hosts file, network
reachability, no change on other systems), steps, verification and
troubleshooting (unreachable, untrusted CA, 401, 404 from an older build,
a token file other users can read, a mixed HEP HMAC version,
unlisted_hep_sender, a newer schema_version). Its commands and output come
from a run on 2026-11-03: four instances in containers on 192.0.2.0/24,
three on this build over TLS and one on release 0.5.130, with the
controller on the host. The newer schema_version output came from a test
server, since no build answers schema_version 2. It is linked from
docs/README.md, the "Manage the fleet" section of docs/mcp-estate.md, and
the reference rows in docs/rest-api.md, docs/cli-reference.md,
docs/auth.md, docs/mcp-tools-server.md and docs/errors.md.

Changes from the earlier draft of this slice:
- effective_sha256 is new. The design names an effective configuration
  digest; the draft compared parsed_sha256 only, so two instances with
  one file and different flags showed one CONFIG. The table's CONFIG
  column now shows effective_sha256.
- fleet_inventory joins SOURCE_INDEPENDENT_TOOLS. The MCP dispatch
  stamped source_exhausted and source_stopped_early onto its answer, so
  MCP and REST returned different documents. The draft's parity test
  called the tool method directly and never passed through the dispatch;
  the_mcp_dispatch_answers_the_rest_document drives a real sipnab --mcp
  and failed on exactly those two members before the change.
- The tool description is shorter: the server bundle measured 6,347
  bytes with the draft's description and 5,976 with this one; the output
  schema already lists every field.
- LoadedConfig's new field was joined onto the line of the field before
  it; split.

Tests (each seen failing for its fault):
- effective digest, REST/MCP dispatch parity, secret rotation and
  build-versus---version checks were written before their code and
  failed for the missing behavior first.
- 24 guarded mutations, one per fault in the design's test table and the
  extra faults above, each turned at least one test red; each restore
  was proved with cmp -s.
- tests/fleet_inventory_test.rs spawns two sipnab --api instances with
  different node names and config files; sipnab fleet inventory lists
  both with different effective digests, marks a third address
  unreachable, still lists the other two and exits 1.

Ratchets moved, attributed per file:
- tests/docs_drift_test.rs registered MCP tools 76 -> 77:
  fleet_inventory (src/mcp/tools/inventory.rs).
- tests/docs_drift_test.rs EXPECTED_MARKDOWN_FILES 322 -> 324:
  docs/fleet.md and website/content/docs/fleet.md.
- tests/docs_drift_test.rs EXPECTED_TABLES 1311 -> 1318: docs/fleet.md
  +3, website/content/docs/fleet.md +3 and
  docs/design/surface-capability-matrix.md +1 (the "Fleet inventory"
  row); no other changed page gained or lost a table against origin/main.
- src/mcp/server.rs byte budget server 5,500 -> 6,000 (measured 5,976)
  and full 90,000 -> 90,500 (measured 90,456): fleet_inventory.
- tests/link_integrity_test.rs EXPECTED_WIKI_LINKS 2079 -> 2097:
  docs/fleet.md +4; docs/README.md and docs/auth.md +1 each;
  docs/cli-reference.md, docs/errors.md, docs/mcp-estate.md,
  docs/mcp-tools-server.md, docs/mcp-tools.md and docs/rest-api.md +2 each.
  Counted per page against origin/main.
- tests/link_integrity_test.rs EXPECTED_DOCS_PAGES 100 -> 101:
  docs/fleet.md.
- tests/error_reference_test.rs EXPECTED_TEMPLATES 542 -> 546: four
  messages in src/app/fleet.rs, each with a docs/errors.md entry.
- tests/mcp_stdio_test.rs tool count base 69 -> 70: fleet_inventory.

Counts in prose moved for the 77th tool, 64 of them read-only: README.md,
docs/mcp.md, docs/mcp-protocol.md, docs/mcp-tools.md,
docs/design/mcp-write-back.md and website/templates/index.html.
docs/fleet.md is registered as a site page (weight 66), a wiki page, a
DOCS_TO_SITE entry and a docs nav entry. sipnab fleet's own flags are
registered for the two documented-flag gates the way sipnab g729's are,
its documented commands are never run by doc_commands_run_test (it
contacts other hosts; tests/fleet_inventory_test.rs runs it), its hosts
file is foreign to the sipnab-config sample gate and is parsed by its own
parser in the_documented_hosts_files_parse, /v1/inventory is exempt from
capture_identity with its MCP counterpart checked over the wire,
fleet_inventory has an output-schema probe and a row in
docs/design/surface-capability-matrix.md, and doc_jq_fields_test checks a
sipnab fleet recipe against a report built by the command's own merge over
a real inventory record. Two new bounds are documented:
MAX_HEP_PEERS (rest-api.md) and MAX_HEAD_BYTES (fleet.md).
docs/design/implementation-plan-phases-8-10.md's citation of get_stats is
re-pointed by scripts/check-line-drift.py. man/sipnab.1 and
docs/design/surface-capability-inventory.md are regenerated.
docs/design/testing-matrix.md, website/static/openapi.json and the site
mirror are regenerated by their scripts.

Not covered: a secret held in a file (a key, token or password file
named by path) is digested by its path only, so rotating its contents
changes no digest. docs/fleet.md says so.

(cherry picked from commit 4a59fe6)
ones. Each one reads a function the others share.

- Each correlated leg carries its own th_callid. It is filled from
  DialogStore::th_callid_status in two places that every surface reads:
  output::query::correlated for --correlate, /correlated and
  find_correlated, and DialogStore::correlation_tree for --call-tree,
  /tree and get_call_tree. Before, only the source of a /correlated
  answer had one. The --correlate and --call-tree tables gain a last
  column, TH.
- The hidden legs held, by status, are on REST /v1/stats and MCP
  capture_status as th_callid, and in Prometheus as
  sipnab_th_callid_legs{status} and
  sipnab_th_callid_undecoded_with_peer, with no per-call label. All of
  them come from DialogStore::th_callid_summary. Before, only the end of
  a run reported these counts, and only when legs had not joined.
- The mappings themselves are listed, one row each with state
  (conflict, pending, joined) and which legs are captured, so an
  operator can see why one call did not join. The listing is
  output::query::th_mappings::th_mappings, served by GET
  /v1/th-mappings, the MCP tool th_mappings and --th-mappings at the end
  of a headless --hep-listen run. The route and the tool are in hep
  builds.

Ratchets, by file:
- CLI flags 396 -> 397: src/cli.rs adds --th-mappings.
- REST routes 47 -> 48: src/output/api.rs adds /v1/th-mappings.
- MCP tools 76 -> 77: src/mcp/tools/hep.rs adds th_mappings.
- The signaling bundle grows from 20 to 21: src/mcp_profile.rs.
- docs/design/surface-capability-inventory.md is regenerated with
  scripts/capability-matrix.py --write to match these counts.
- MCP tool counts 76 -> 77, and read-only tools 63 -> 64. th_mappings is
  read-only, and the thirteen tools that write or act are unchanged.
  The counts move in tests/docs_drift_test.rs, tests/mcp_stdio_test.rs and
  tests/homepage_link_landing_test.rs,
  in README.md, website/templates/index.html, docs/mcp.md,
  docs/mcp-protocol.md and docs/design/mcp-write-back.md.
- MCP bundle byte ceilings, in src/mcp/server.rs: signaling
  22_500 -> 24_000 and full 90_000 -> 91_500, from th_mappings' 1,102
  bytes on tools/list. Measured: signaling 23,576 bytes over 21 tools,
  full 91,074 bytes over 77. docs/mcp-tools.md's table is re-measured.
- Prometheus metrics 49 -> 51: sipnab_th_callid_legs and
  sipnab_th_callid_undecoded_with_peer. The prose count moves in
  docs/rest-api.md, docs/mcp-tools-server.md, src/output/runtime.rs and
  src/mcp/server.rs.
- Error templates 542 -> 543: src/app/batch.rs adds
  "--th-mappings: the mappings did not serialize: {e}", with an entry in
  docs/errors.md.
- Documentation tables 1311 -> 1318, counted on top of #629:
  - docs/rest-api.md +2 and its mirror +2;
  - docs/mcp-tools-signaling.md +1 and its mirror +1;
  - docs/design/surface-capability-matrix.md +1.
- Wiki links 2079 -> 2095, counted on top of #629:
  - README.md +1;
  - cli-reference.md +1;
  - mcp-tools-core.md +2;
  - mcp-tools-signaling.md +4;
  - mcp-tools.md +2;
  - prometheus-metrics.md +1;
  - rest-api.md +5.
- Line citations in docs/design re-pointed: the inserted code moved
  export_vcon, process_message, generation, get, get_stats,
  build_router and parse_bind_addr. Two were re-pointed by
  scripts/check-line-drift.py --apply, the rest by hand to the lines it
  reported.
- The matrix gap ledger gains one line:
  tests/surface_capability_matrix_test.rs records the TUI cell of the
  new "Topology-hiding mappings" row as a gap. The TUI statistics view
  shows the counts but does not list the mappings.

How-to: docs/rest-api.md "Find why a call through topology hiding did
not join" covers purpose, the OpenSIPS and sipnab prerequisites, steps,
verification and troubleshooting. Its output comes from a live listener
that received the committed OpenSIPS 4.0.2 capture, and a second live run
added a conflicting and a pending event. It is linked from docs/README.md,
the --th-mappings row and the th_mappings tool section.

Documentation defect fixed: 0.5.232 added the th_callid values mapped
and conflict, the method mapping and the strategy th_callid_mapping.
The doc comments that feed the OpenAPI and MCP schemas, the REST and
MCP references and the CLI reference still listed the older four
values each, and docs/mcp-estate.md listed eight strategies instead of
nine.

Test changes beyond the new tests:
- tests/cli_correlate_test.rs and tests/cli_call_tree_test.rs expect the
  new TH column.
- The schema-description comparison in tests/cli_correlate_test.rs reads
  a nullable reference's description from inside oneOf. utoipa puts the
  text there for Option<enum>. This is the first leg field of that kind.
- /v1/th-mappings carries capture_identity, as the MCP tool does, and
  tests/api_capture_identity_test.rs checks the two against each other.
- The tool is probed in tests/mcp_completeness_test.rs and in
  tests/mcp_protocol_features_test.rs.
- tests/population_claim_test.rs names th_mappings as not probed, because
  its file-replay session cannot hold a mapping.
  a_page_size_never_moves_the_mapping_counts drives that property over a
  real table instead.

Tests:
- Unit tests cover each surface.
- tests/th_mapping_test.rs spawns the binary with --hep-listen to cover
  GET /v1/th-mappings and --th-mappings, in text and --json form.
- Each new test was seen failing first.
- Each was mutation-checked, and every restore was proved with cmp.

(cherry picked from commit 1cdd8a3)
Three parts, combined into one change to save a hook run on a shared
build queue.

== 1. Stale statements fixed at their source ==

Five statements no longer matched the code. Each is fixed at its
source, and the three that live in the binary have a test.

- sipnab sends rtpengine `statistics` as well as `list` and `query`
  since --relay-stats (0.5.169). docs/rtpengine.md, the cookbook, the
  CLI reference, the --rtpengine-control help, the features page and
  one note said two commands only. A test reads the help against the
  ReadOnlyCommand type; its match is exhaustive, so a new command
  fails it until the help names it.
- The multi-relay REST recipe in docs/rtpengine.md built
  H="-H 'Authorization: Bearer $KEY'" and expanded $H unquoted. Bash
  does not re-read quotes in an expansion, so curl received
  'Authorization:, Bearer and the key as three arguments. Run against
  sipnab 0.5.232 with --api-key, the old form answered 401 and the new
  -H "$AUTH" form answered 200 with the same capture_identity shape the
  page shows.
- The cookbook said no command-line flag writes a WAV file
  (--export-audio exists since 0.5.231) and that a G.729 stream cannot
  be recovered (decoded since 0.5.222). The recipe now shows
  --export-audio and GET /v1/dialogs/{call_id}/audio with real output,
  exit codes and the file-name rule.
- Shift+P playback decodes G.711 and Opus; the F1 help, the
  walkthrough, the key reference and the features page said G.711.
  Playback's codecs are now one PlaybackCodec list, and a test holds
  the help line to it.
- The log line a run writes when it keeps audio named the MCP
  export_audio tool on every run, including --export-audio and
  --retain-audio without --mcp.

Root cause for the prose: each surface added after a page was written
(statistics, --export-audio, G.729 decoding, Opus playback) changed
what the page described, and nothing tied the page to it.

Every command and output in the changed docs ran against the binary
built from this branch: relay commands against rtpengine 12.5.1 in the
repository harness, audio exports against tests/pcap-samples.
Multi-relay: the curl header mechanics ran against one node; two
relays were not available.

The cookbook's --export-audio recipe is pinned by a new golden,
tests/cli/cookbook/bc0fdabae7.trycmd. scripts/check-cookbook.py gains
--export-audio-out in OUTPUT_FLAGS: it names a file sipnab writes, and
without it the golden run wrote into the shared directory, which
cli_goldens refuses.

== 2. Ask a media relay about itself ==

The relay surfaces on main had reference rows and no task page:
--relay-stats, --relay-stats-call, --relay-stats-list,
--relay-stats-interval, --relay-compare, --relay-holdings, the six
/v1/relay/ REST routes, the MCP tools query_relay, relay_stats and
relay_compare, and the TUI relay view (S, ?, H, K).

docs/relay-statistics.md shows each in use, with what to check first
(rtpengine only, no change to rtpengine, a live source, privileges,
build features), the counters to read first, and a troubleshooting list
of the refusals with their exit codes. Its anchors are stable for
other pages to link: #ask-the-relay-for-its-counters,

Every command and output on the page ran against sipnab 0.5.232 built
from main at 0d8eea1, and rtpengine 12.5.1 (the Debian 13 package) in
the repository harness. The commits between 0d8eea1 and 9124a6f
touch no relay code (the one source change is src/app/vcon_scitt.rs). The relay's address is written as
127.0.0.1 and the SIP test client's as 192.0.2.21. K's comparison was
run from a capture that sees the SIP and not the media, so the page
shows that case.

The relay rows of the CLI reference, the REST and MCP references, the
key reference, docs/rtpengine.md and docs/rtpengine-sipnab.md link to
the page, and docs/README.md lists it under How-to guides.

The cookbook's relay recipes captured an interface without sudo and
showed no output, and "Ask a relay what it is still holding" read
created and destroyed counters rtpengine 12.5.1 does not publish. They
now use sudo, show real output, and that recipe uses --relay-holdings
and the session counters the relay reports.

The page is registered in build-site-pages.py (weight 68),
build-site-internals.py, build-wiki.py and the "capture" group of the
docs nav, beside docs/rtpengine.md.

== 3. Measure call quality ==

Call quality had reference rows and scattered mentions, and no task
page: the R-factor, the quality intervals and --quality-interval, the
TUI quality dashboard (D) and packet loss map (L), GET
/v1/streams?mos_below=, the far end's RTCP and RTCP XR figures, the
[quality] thresholds and --on-quality-exec.

docs/call-quality.md covers each, with real output from sipnab 0.5.232
(built from main at 0d8eea1; the commits to 9124a6f change one
source file, src/app/vcon_scitt.rs). Anchors: #read-each-streams-score,
and #when-something-does-not-work.

No committed capture carried RTCP XR, so tests/fixtures/rtcp-xr-call.pcap
is generated by rtcp_xr_call in tests/support/synthetic_captures.rs and
listed in OWNED and tests/PROVENANCE.md: a ten-second PCMU call whose
callee sends a sender report and an XR VoIP Metrics block about the
caller's stream. media_diagnostics_reports_the_far_ends_rtcp_xr_apart
checks the answer the page shows: the far end's figures under
endpoint_reported, and the MOS delay taken from half the XR round trip.
The test failed first because the fixture did not exist. With
record_extended_report made to drop every block, it fails on the
missing r_factor; restored and compared with cmp.

Corrections found while running the page:
- docs/mos-and-codecs.md said MCP rtp_stats carries endpoint_reported
  and the MOS delay. media_diagnostics carries both; rtp_stats carries
  round_trip_ms and round_trip_source.
- The TUI section is titled "Reported by far end (RTCP XR)", not "Far
  End", in the walkthrough and the filter reference.
- The cookbook's media_diagnostics sample lacked the qos `expedited`
  member the tool returns.

Also: docs/rtpproxy-sipnab.md shows the retried-command warning, run
against rtpproxy 2.1.1 in the harness with one command sent twice by
hand; docs/g729.md says which builds carry the g729 feature; the
troubleshooting quality section names the IMPOSSIBLE RATE warning.

The page's TUI screens came from a capture file. The active-stream
marker was checked on a live capture of a harness call through
rtpengine.

== Ratchets ==

Attributed per file against origin/main 9124a6f:
- EXPECTED_DOCS_PAGES 100 -> 102: docs/relay-statistics.md and
  docs/call-quality.md.
- EXPECTED_WIKI_LINKS 2079 -> 2132: relay-statistics.md +8,
  call-quality.md +7, cli-reference.md +22, examples.md +4, README.md +2,
  rtpengine.md +2, troubleshooting.md +2, and keybindings.md,
  mcp-tools-relay.md, mos-and-codecs.md, rest-api.md, rtpengine-sipnab.md
  and tui-walkthrough.md +1 each.
- EXPECTED_MARKDOWN_FILES 322 -> 326: the two pages and their site
  mirrors.
- EXPECTED_TABLES 1311 -> 1321: relay-statistics.md +4, call-quality.md
  +1, and the same on their mirrors. No other changed page gained or lost
  a table.

(cherry picked from commit bff27f9)
"Collect captures from several SIP servers in one place" is the fleet
guide's setup page. Each step now ran against OpenSIPS 3.6.9, Kamailio
6.1.4 and sipnab 0.5.232 in throwaway containers on one Docker network.

- The OpenSIPS tracer line was wrong. OpenSIPS 3.6.9 refuses
  "[sipnab]uri=hep:...;version=3;transport=udp" with "Invalid key
  <version> in trace id!" and does not start. The page now gives the
  form homer.md uses: a hep_udp socket (without it OpenSIPS stops with
  "No HEP listener defined!"), the address in proto_hep hep_id, and
  trace_id naming it. Two packets per OPTIONS reached sipnab, with a
  host name in hep_id.
- The Kamailio step said "the handful of modparams" without them. It now
  lists them. Kamailio did not resolve a duplicate_uri host name that
  only /etc/hosts carried ("could not resolve hostname"), so the example
  uses an address and says so. With two duplicate_uri lines, only the
  last listener received anything, as deployments.md states.
- The firewall step had no command. It now gives the nftables rule,
  measured with the input policy set to drop: the allowed sender's 10
  packets arrived, and a sender outside the range never reached the
  roster. ufw could not be exercised in a container and is not named.
- "Verify HEP is arriving" sent an MCP initialize, which proves nothing
  about HEP. It now calls the hep_senders tool in a session and prints
  one line per admitted sender (real output from the Kamailio and
  OpenSIPS above), and says the capture id there is the sender's, not
  --node-name.
- A sizing step lists the three limits a busy estate meets first, with
  their defaults and the counters that show them. No throughput number
  was measured, so none is given.

Site mirror and llms-full.txt regenerated.

Ratchets, attributed per file against origin/main 9124a6f:
- tests/docs_drift_test.rs EXPECTED_TABLES 1311 -> 1313: docs/mcp-estate.md
  +1 (the listener limits table) and website/content/docs/mcp-estate.md +1.
- tests/link_integrity_test.rs EXPECTED_WIKI_LINKS 2079 -> 2081:
  docs/mcp-estate.md +2 (homer.md, and the cookbook's memory budget recipe).

(cherry picked from commit e9f0399)
…oting

Add docs/run-as-a-service.md, a task page for operating sipnab under
systemd: the packaged unit and how to check it, a drop-in that turns on
the REST API with its key in an EnvironmentFile, a unit of your own for
installer and tarball installs, where the log goes and what --syslog
sends, the signals sipnab handles, crash reports, audit file rotation,
the refusals a service meets, and health checks. Every output on the
page was measured with the v0.5.232 arm64 .deb on Debian 13 with
systemd 257, in a throwaway container. src/, Cargo.toml, Cargo.lock,
packaging/ and Dockerfile differ between v0.5.232 and this branch's
base 4d85bd7 in the content-persistence gate's config handling
(e637453) and a doc comment in src/app/vcon_scitt.rs, none of which
these pages describe.

Corrections, each measured:

- examples.md "Run sipnab as a service" had no User=, so the unit ran as
  root (sipnab then dropped to nobody), while the page said
  AmbientCapabilities let it run without root. It also said --syslog
  makes the journal useful; --syslog sends security alerts only, and the
  log goes to stderr. The unit now runs as User=sipnab with ambient
  capabilities, verified to capture eth0 as sipnab.
- mcp-deploy.md granted capture with setcap for units that set
  NoNewPrivileges=true. The capture fails there ("Permission denied on
  'eth0'"). The units now grant AmbientCapabilities.
- mcp-deploy.md made /etc/sipnab/mcp.token root-owned mode 600 and then
  ran the unit as User=sipnab, which stops with "--mcp-token-file
  '/etc/sipnab/mcp.token': Permission denied (os error 13)", exit 2. The
  file is now root:sipnab mode 640.
- mcp-deploy.md said the packaged unit starts REST and metrics; it
  starts metrics only.
- examples.md "Run a live capture without giving sipnab root" ran
  `sudo sipnab ... --user sipnab --chroot /var/empty` after telling the
  reader to run without sudo. --chroot stops every root run at the
  privilege drop (the user lookup happens inside the new root), and
  Debian 13 has no /var/empty. The recipe now documents both, and no
  longer says sipnab opens output files before the drop.
- config-reference.md [crash] said release builds abort without
  unwinding (they unwind since 0.5.228), and its example report_dir is
  not writable under the packaged unit. It now names /var/lib/sipnab.
- contrib/observability/sipnab-hep.service said privilege drop happens
  internally next to --no-priv-drop, which keeps root for the run.
- mcp-deploy.md ran `sudo sipnab ... --mcp-token-file` and the MCP TLS
  example as root with root-only files. A root run drops to nobody before
  it reads the token, the certificate and the key, and stops with exit 2
  (for the key the message is the bare path). Both now pass --user sipnab
  with root:sipnab 640 files, verified in a container.

Registration of the new page: scripts/build-site-pages.py (weight 66),
scripts/build-site-internals.py, scripts/build-wiki.py, website/config.toml
and docs/README.md. Site mirrors and llms files regenerated.

Every command and output below was run
with the v0.5.232 release artifacts or a binary built from this branch's
base, in throwaway containers or on loopback.

install.md:
- The one-line installer: a table of its own failure messages (no GitHub
  API, a version with no release, a checksum mismatch, no checksum tool,
  an unwritable directory without sudo), and that it installs the -gnu
  build without libpcap, whose first run then fails to load
  libpcap.so.0.8.
- The .rpm recipes use `dnf install ./<file>`. `rpm -i` refuses on a host
  without libpcap ("Failed dependencies"), measured on Fedora 42; dnf
  warns that the packages carry no OpenPGP signature.
- The .deb unit "logs to syslog" was wrong: --syslog sends security alerts.
- The audio plugin: since 0.5.118 the Linux gnu tarballs, standard .deb
  and .rpm hold no libsipnab_audio and no libasound Recommends (0.5.117's
  arm64 .deb had both). The artifact table and package text now say so.
- Homebrew is marked as not run for the page: no Mac was available.
- Verify a download: real gh attestation output, that gh 2.102.0 prints
  nothing to a pipe so a script must read the exit status, the failure
  for a modified file, cosign 3.1.4 working as written and 2.4.1 needing
  --new-bundle-format, and cosign's two failure messages.
- Docker: the image is linux/amd64 only; `latest` follows main, not
  releases (on 2026-11-03 it was main commit 0d8eea1, attested from
  refs/heads/main), so every recipe pins a release tag; live capture needs
  --user root, because the image's sipnab user gets no capabilities even
  with --cap-add; image provenance with gh attestation verify oci://.
- Uninstall: apt and dnf remove neither stop nor disable the service, and
  leave the user, /etc/sipnab, /var/lib/sipnab and drop-ins.
- Building from source: the two late failures (no libpcap headers, no
  cmake for --features full), from building v0.5.232 in rust:1.99.

auth.md, rest-api.md, prometheus-metrics.md:
- A "Secure every listener" section: the four listeners' credentials, TLS
  flags, config keys and refusal exit codes; making a private CA and a
  certificate; one run serving all four over TLS with its checks; what a
  client sees when something is missing; the HEP sender's TLS failure; a
  Prometheus 3.5.0 scrape of the HTTPS metrics endpoint with Basic auth,
  reported up.
- A root run reads the REST and MCP keys and tokens, the metrics
  credential, the REST and MCP certificates and keys, and a HEP sender's
  secret AFTER dropping to nobody, so root-only files stop it with exit 2.
  The REST and metrics TLS examples now pass --user sipnab with 640 files,
  verified in a container.
- --mcp-revoked-file, measured; the 503 row names
  --api-rate-limit-per-peer; MCP's /health needs the token.

troubleshooting.md:
- Operations sections: a service that does not start, live capture
  Permission denied by cause, key files a root run cannot read after the
  drop, the answers a listener gives a client, TLS handshake failures, a
  setting with no effect, a long run filling disk or memory, crash
  reports under a service, and contained parser panics.

Test changes:
- tests/docs_drift_test.rs: the rpm version markers and the upgrade-route
  check follow the `dnf install ./sipnab-...` recipes; a marker gates the
  pinned image tag against published_version; FOREIGN_FLAGS scopes curl's
  --cacert and --data-urlencode, useradd's flags, docker's --cap-add and
  cosign's --new-bundle-format to the pages that use them.

- tests/docs_drift_test.rs FOREIGN_FLAGS also scopes useradd's --system,
  --home and --shell and systemctl's --now, --no-pager and --value to the
  service page and docs/examples.md.
- The release download markers in docs/install.md change form. Phase two
  of a release rewrites them by pattern, so every consumer moves with them:
  docs_current_version_markers_match_cargo's published list,
  release_completeness_test's every_download_instruction_names_the_
  advertised_version, no_cut_marker_pattern_also_matches_a_published_marker
  (now also checks the dnf and image forms), and the marker list in
  docs/internals/build-ci-release.md. Markers on main: SIPNAB_VERSION= 1,
  VERSION= 2, "e.g." 2, `rpm -i sipnab-` 4. Here: SIPNAB_VERSION= 2,
  VERSION= 2, "e.g." 2, `dnf install ./sipnab-` 4, `ghcr.io/normb/sipnab:`
  6. Each new line carries the version. Mutating one dnf line, and one
  image tag, to 0.5.231 failed docs_current_version_markers_match_cargo
  with "names 0.5.231 but the last PUBLISHED release is 0.5.232"; both
  restores were checked with cmp. No script and no site template reads the
  rpm lines (grep of tests/, scripts/, docs/internals/, website/, .github/).
  The reason for the rpm change, on Fedora 42 against the 0.5.232 rpm:
    $ rpm -i sipnab-0.5.232-1.aarch64.rpm
    error: Failed dependencies:
    	libpcap is needed by sipnab-0.5.232-1.aarch64
    	libpcap.so.1()(64bit) is needed by sipnab-0.5.232-1.aarch64
    exit=1
    $ dnf install ./sipnab-0.5.232-1.aarch64.rpm
    Installing dependencies: libibverbs, libnl3, libpcap 14:1.10.6-1.fc42
    Complete!   exit=0
  The image tag is pinned because `latest` follows main, not releases.

Ratchets, attributed per file against origin/main 9124a6f (the rebase
onto 4d85bd7 moved none of them):
- EXPECTED_MARKDOWN_FILES 322 -> 324: docs/run-as-a-service.md and its
  mirror website/content/docs/run-as-a-service.md.
- EXPECTED_DOCS_PAGES 100 -> 101: docs/run-as-a-service.md.
- EXPECTED_TABLES 1311 -> 1335: docs/run-as-a-service.md +4,
  docs/install.md +4, docs/auth.md +2, docs/troubleshooting.md +2, and the
  site mirror of each +4, +4, +2, +2.
- EXPECTED_WIKI_LINKS 2079 -> 2145: docs/run-as-a-service.md +22,
  docs/troubleshooting.md +20, docs/install.md +10, docs/auth.md +3,
  docs/examples.md +2, docs/mcp-deploy.md +2, docs/prometheus-metrics.md +2,
  docs/rest-api.md +2, docs/README.md +1, docs/config-reference.md +1,
  docs/mcp-estate.md +1.

(cherry picked from commit 450e94b)
@NormB
NormB force-pushed the docs/howto-ops-service branch from 5e0f784 to e76c324 Compare November 4, 2026 07:55
@NormB
NormB merged commit 706e0e0 into main Nov 4, 2026
60 checks passed
@NormB
NormB deleted the docs/howto-ops-service branch November 4, 2026 08:27
@NormB NormB mentioned this pull request Nov 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants