v0.51.0
macro-inc/macrov0.51.0Sep 3, 2026by github-actions[bot]
Upgrading?
AI Summary
Enhances observability by exposing dead collector status and enforcing log filtering rules, while adding Mutual TLS support for SIP trunking to verify caller identities. The release also updates forge-media and siphon-rs dependencies.
Key Highlights
- Visibility of dead collectors via new OTLP and HEP gauges
- Enforcement of `warn` log floor for unqualified filters
- Mutual TLS support for SIP trunking (client auth and outbound cert)
- New `peer_cert_san` route match key for authentication
New Features
- Dead collector visibility
- Log filtering enforcement
- Mutual TLS for SIP trunking
Full Release Notes
### Added
- **A dead collector is now visible on `/metrics`, for both exporters**
([#596](https://github.com/thevoiceguy/siphon-ai/issues/596)). The 0.51.0 OTLP soak
killed the collector for 46 minutes under 200 concurrent calls and **no metric moved**:
`siphon_ai_otlp_log_records_dropped_total{reason="queue_full"}` guards the daemon's own
queue, which never fills against a dead collector because the SDK accepts records
instantly and discards the batch one hop later; the only evidence was an
`opentelemetry_sdk` ERROR line that a target-named log filter mutes. HEP had the same
blind spot since #460, and CLAUDE.md §4.7 has described a `siphon_ai_hep_collector_up`
gauge that did not exist.
- **OTLP**: the span and log exporters are wrapped so every batch's outcome is observed.
New gauge `siphon_ai_otlp_collector_up` (1/0, shared by spans and logs — same
endpoint), new `reason="collector_down"` on `siphon_ai_otlp_log_records_dropped_total`
counting every record in a failed batch, and new
`siphon_ai_otlp_spans_dropped_total{reason="collector_down"}` for spans. All present
from startup. `queue_full` keeps meaning what it meant.
- **HEP**: hep-rs grew `UdpHepSink::send_failures` (hep-rs #3), so a send the socket
refused — `ECONNREFUSED` after a dead collector's ICMP port-unreachable — is now
`siphon_ai_hep_packets_dropped_total{reason="collector_down"}`, and
`siphon_ai_hep_collector_up` is derived from it each 10 s sample: 0 when any send was
refused in the interval, 1 otherwise. Derived over the window because connected UDP
reports the refusal on the *following* send, so a point sample would flap. Every packet
handed to the sink now lands in exactly one of sent / queue_full / collector_down. UDP
caveat, documented: a black-holed collector (no ICMP back) still looks up.
- Reference alerts `SiphonAIOtlpCollectorDown` and `SiphonAIHepCollectorDown` in
`examples/observability/rules/alerting.yml`; CONFIG.md, DEPLOY.md, HEP.md, DEV_PLAN R12
and §4.7 now describe what exists.
- **The `warn` log floor is enforced, not just default**
([#597](https://github.com/thevoiceguy/siphon-ai/issues/597)). A `--log` / `RUST_LOG` /
`SIPHON_AI_LOG` directive that names only targets (`siphon_ai=debug,siphon=info` — the
shape of every example the repo used to ship) *replaced* the built-in filter, floor
included, and silently muted every crate it did not name: `hep_rs` reporting a dead
collector, `opentelemetry_sdk` reporting a failed export. The fail-safe held only while
nobody set a filter. Now such a directive gets `warn,` prepended — it can turn crates up
but cannot mute the ones it did not name — while a directive that states its own global
level (`info,siphon_ai=debug`, or a bare `off`) is left alone. The same rule applies to
`PUT /admin/v1/log`, whose response `filter` is what was actually installed. The daemon
logs `log filter active` with the effective directive and its source at startup.
**`--log-no-floor`** (`SIPHON_AI_LOG_NO_FLOOR`) restores the old replace-everything
behaviour for anyone who genuinely wants silence, and says so at startup with the one
`warn` that still gets out.
- **Mutual TLS on the SIP trunk: verify who is calling, and route on it**
(siphon-rs [#129](https://github.com/thevoiceguy/siphon-rs/issues/129) /
[#130](https://github.com/thevoiceguy/siphon-rs/pull/130), pin `v2026.08.25` →
`v2026.09.03`). Until now a TLS trunk was encrypted but anonymous: the listener never
asked for a client certificate, so nothing distinguished a partner node from anyone who
found port 5061, and the only handles for a route were what the INVITE *claimed* about
itself. Now:
- **`[sip.tls].client_ca` + `client_auth = "optional" | "required"`** make the TLS
listener request a client certificate and verify it against a PEM CA bundle.
`required` fails the handshake for a peer with no certificate — it never sends a SIP
message; `optional` admits such a peer (no identity) while still refusing a
certificate that does not chain, so a fleet can roll certificates out before the
switch is thrown. Both halves must be set together; the bundle must exist at load;
the mode is validated at load; all three by `siphon-ai check`. Applies to the SIP TLS
listener only (browsers on `[sip.wss]` do not present client certificates).
- **`[sip.tls_client].client_cert` + `client_key`** present a certificate on every
outgoing TLS connection to a peer that asks — mutual TLS *toward* a trunk. Key must
be `0600`, as the listener key already is.
- **`peer_cert_san` route match key** (`docs/DIALPLAN.md` §4.6): matches when any name
the verified certificate asserts — URI SANs (where RFC 5922 puts a SIP identity), DNS,
IP, e-mail SANs, then the CN — satisfies the predicate. A call with no verified
certificate has no candidate names and can never match it, so a `.*` regex cannot
admit an unauthenticated caller the way it can for an absent header. A dialplan naming
the key on a daemon whose listener never asks for a certificate is **refused at load**,
naming the route — such a route could never fire. `siphon-ai route-test
--peer-cert-san <name>` (repeatable) models it offline.
- **Observability**: every INVITE on a verified connection logs `peer_identity` (subject
+ SANs) and the SHA-256 fingerprint at `info` with the peer address, and the routed /
no-match lines carry `peer_cert_names`; new counter
`siphon_ai_tls_peer_identity_total{result="verified"|"none"}` per INVITE on TLS/WSS
(documented in DEPLOY.md, with the rollout recipe); `GET /admin/v1/calls` rows gain
an optional `peer_identity` field (additive — absent for every call that presented
none, so existing rows are byte-identical); `print-config` shows `tls_client_auth` and
`tls_client_identity` under `sip`.
- **Not in this release**: the identity is not yet on the CDR (would be v10) and
`[sip.admission]` / `[[trunk]]` do not consult it — routing is the authorization
point for now; the default route (or the absence of one → 404) is where an
unrecognized certificate ends up.
### Changed
- **forge-media pin `v2026.08.26` → `v2026.09.02`** — a routine roll-up.
Nothing in it was asked for by this project and nothing in it changes
this daemon's behaviour; the bump is to stop the pin drifting.
Three tags passed (`v2026.09.01` was superseded untagged by
`v2026.09.01.1`). Across the whole range only three files we build
changed. **forge-engine** gives `MediaSession` a dynamic payload-type
map — a B2BUA learns the far leg's negotiated payload types only when
its SDP answer arrives, and a re-INVITE can change them mid-call — held
in an `AtomicU32` so the per-packet forwarding path stays lock-free.
It is additive: every constructor seeds it from the frozen
`transcoding_config.payload_type_map` the forwarding path already read,
so a consumer that never calls the setter behaves identically, and we
have no leg whose payload types arrive late. **forge-sdp** moves its
`sip-sdp` dependency from a submodule-relative path to
`tag = "v2026.08.25"` — the same tag this workspace pins every other
`sip-*` crate to, so the lock still holds exactly one `sip-sdp`
(verified; differing tags would have linked two copies of the SDP
negotiator).
Everything else in the range is **forge-conference** — DTMF dial-out,
PIN auth, wait-for-moderator, an 80 ms RFC 2833 duplicate window. We
do not depend on that crate (our conferencing is forge-mixer), and the
dedup lives in `forge-conference`, not `forge-dtmf`, so this daemon's
DTMF path is untouched.
**siphon-rs is deliberately not bumped**: its newest tag is still
`v2026.08.25`, which we already pin — and which is the tag this forge
release records in `external/siphon-rs`, so the embedded pair matches.
Verified with the full workspace suite, a `--features webrtc` build,
and all 60 SIPp signaling scenarios.
### Added
- **Log records now ship over OTLP carrying the trace context of the span
they were emitted inside** — `[observability.otlp.logs]`, off by default
(#589).
Since 0.22.0 the daemon exports one trace per call, and since 0.23.0 that
context reaches the WS server. Logs never came along: a line scraped from
journald carries no `trace_id`, because the context lives in the process
and is gone by the time the text is written. Only an in-process bridge can
attach it. Now opening a call's trace in Tempo / Jaeger / Cloud Trace shows
the lines that explain each span, rather than sending you to a separate log
search to match on Call-ID and timestamp by hand.
Same endpoint, timeout and resource attributes as the span pipeline, with
its own export `level` so the console and the collector can have different
appetites (narrower only — the global filter is still a floor over every
layer, which `docs/CONFIG.md` states rather than papers over). Enabling it
without `[observability.otlp]` is a fatal config error: with the span layer
off nothing attaches a context, so every record would ship uncorrelated —
which is exactly what journald already gives you.
**Two bugs found by running it against a real collector rather than
trusting the wiring:**
- The OTel batch processor raises `tracing` events of its own from inside
`emit` ("queue full", "emitted after Shutdown"). Dispatched from within a
layer's `on_event`, a nested event runs a second filter pass over the
same thread-local state the outer event is still using and leaves its
bits behind — a `debug_assert` panic in debug builds, and *silently wrong
per-layer filtering* in release, which is worse. So nothing calls into
the SDK from `on_event`: the layer stamps the trace context, which must
happen there while the span's context is still current, and hands the
record to a bounded queue; a worker thread does the rest. That is also
the shape §4.7 asks for — queue, return, never block — and it gives drops
a real counter, `siphon_ai_otlp_log_records_dropped_total`.
- Teardown flushed the provider while the layer was still feeding it. The
layer is now closed and its queue drained *before* the flush, so the
records that say why the daemon is going away are not the ones lost.
The SDK's own diagnostics and its gRPC stack (`opentelemetry`, `tonic`,
`h2`, `hyper`, `tower`, `reqwest`) are muted on the export layer only —
exporting makes them talk, and shipping that would make export cause
export. They still reach the console and `GET /admin/v1/errors`, which is
where `BatchLogProcessor.ExportError` shows up when a collector is
unreachable.
- **The daemon can emit its logs as JSON** — `--log-format json`, or
`SIPHON_AI_LOG_FORMAT=json` for a systemd unit (#588). One object per
line, the event's own fields flattened to the top level and the current
span's fields under `span`, which is where `call_id` lives.
The point is what happens when logs leave the box. The daemon attaches a
lot of structure to its events — `call_id`, `route`, `from_user`,
`request_uri_user`, `register_source` — and in text form every one of
those is a substring that a shipper has to regex back apart, badly, and
that silently stops carrying a field the day a call site gains one. As
keys they land as queryable fields in Cloud Logging / Loki / Elastic and
survive new call sites without anyone touching a regex.
**`text` remains the default and its output is unchanged**, byte for
byte — it is the right thing for `journalctl` on one node, and the
shipped fail2ban filter's `<HOST>` extractor string-matches it.
Switching a node to `json` breaks that regex silently (bans stop, the
jail still looks healthy), which is called out in both
`docs/CONFIG.md` → *CLI flags & environment* (a new section covering
`--config` / `--log` / `--log-format` and their env twins) and the
`docs/OPERATIONS.md` quick-reference table.
Format and filter stay orthogonal: `--log-format` never changes which
events are emitted, and `PUT /admin/v1/log` never changes how they are
rendered. The `check` / `print-config` / `route-test` reports are
written straight to stdout, not through `tracing`, so they stay
human-readable in both modes.
- **Three real browsers now call the daemon every night and have to
prove audio in both directions** (`DEV_PLAN_WebRTC.md` §5, item 2).
`test-harness/browser/` is a Playwright harness — Chromium, Firefox
and WebKit each REGISTER over WSS, place a call the daemon answers
with a forge-webrtc leg, exchange audio, hang up, and give the RTP
port pair back. It runs nightly
(`.github/workflows/browser-interop.yml`), not per-commit: the
per-commit media coverage is the in-process loopback, and what a real
browser adds is whether *its* stacks agree with ours — which changes
when browsers ship.
Two-way audio is proved by **frequency, not level**, so neither
direction can be satisfied by an echo. The page sends an identifiable
tone; the harness's WS server *measures* the dominant frequency of
what arrives and plays 900 Hz back; the page asserts inbound audio
energy from `getStats()`.
The interesting part was that every engine difference was in the
browser's *audio plumbing*, not in DTLS or Opus — the media plane has
been identical across engines so far. Chief among them: **headless
Firefox has no audio output device**, so its `AudioContext` never
leaves `suspended` and `resume()` never settles — with or without a
user gesture, with or without every `media.autoplay.*` pref. Awaiting
it hung the call before the INVITE was built. The lab page now waits
with a budget and falls back to the engine's capture device, which is
why the server measures the frequency instead of assuming it.
`examples/browser-sip/index.html` gained the parameters that make
this possible — `?tone=1` (send a tone, report what comes back on
`window.__labAudio`) and `?sipjs=<url>` (load the SIP stack from
anywhere, so a nightly run never depends on a CDN) — and its copy no
longer says the test call carries no audio, which stopped being true
when the browser leg landed.
- **A browser call is now tested end to end on every commit, and CI
builds the `webrtc` feature at all** (`DEV_PLAN_WebRTC.md` §5, Phase
3 item 1). Two loopback layers, ~2 s each, no browser and no
container:
- `crates/webrtc-glue/tests/loopback.rs` — a browser-shaped peer
dialing a real `WebRtcLeg`: ICE nomination, DTLS, SRTP both
directions, the Opus transcode, teardown, and fifteen calls back to
back as a leak tripwire.
- `crates/core/tests/webrtc_call.rs` — the same peer dialing the
**acceptor**: an INVITE carrying a real forge-webrtc offer, the
controller against a real WS server, and audio making the full
round trip (browser → SRTP → decode → WS bridge → echo → encode →
SRTP → browser). Plus the plan's acceptance test on this
transport: drop the peer with no BYE and the inactivity watchdog
must tear the call down **and** hand the RTP port pair back.
**CI now runs `clippy` and `cargo test` with `--features
siphon-ai/webrtc`** alongside the default pass. Until now none of the
browser-leg code — the acceptor branch, the tap, the ICE/DTLS metrics
— was compiled or linted in CI, while the release artifacts ship it
enabled. That gap is closed.
The loopback immediately pinned something the design had only
reasoned about: **a vanished peer produces no event whatsoever** — no
`Closed`, no `Failed`, no RTP, for as long as you care to watch.
forge-webrtc sends RFC 7675 keepalives but never fails a transport
when the replies stop, so `[media].inactivity_timeout_secs` really is
the only thing that reclaims the slot. There is now a test asserting
that gap, which fails with instructions if forge-webrtc ever learns
to report it.
- **A browser call now says where it is in ICE/DTLS, live — in the
admin API and in sightglass** (`DEV_PLAN_WebRTC.md` §4.6, completing
it). `GET /admin/v1/calls` rows and `GET /admin/v1/calls/{id}/stats`
carry `webrtc_state`: `connecting` (ICE checks running — no path
yet), `ice_connected` (a pair is nominated and DTLS is handshaking),
`connected` (SRTP keys installed, media flowing), `failed`, `closed`.
**A call stuck on `ice_connected` has a working network path and
failing crypto**; one stuck on `connecting` has no path at all. That
is the same split `siphon_ai_webrtc_legs_total{result}` reports after
the fact — this is it while the call is still up, which is when an
operator can do something about it.
The key is **absent for a classic SIP leg** rather than empty: absent
means "not a browser call", never "unknown". Both are additive
optional fields, so an older client ignores them and a newer client
against an older daemon sees nothing.
Sightglass grows a **MEDIA** column on the calls tab (`ice` → `dtls`
→ `webrtc`, `—` for a classic leg) and an `ice/dtls` line in the call
detail pane. The column **hides itself when no visible call is a
browser call**, the way the Node column does on a single-node fleet:
the table is always short of width, and a fleet with no browser
traffic should not pay for the column.
### Changed
- **CDR schema v8 → v9: every record now says how the call arrived
(`leg_transport`) and how its audio was carried (`media_type`)**
(`DEV_PLAN_WebRTC.md` §4.6). **Read this if you parse CDRs.**
`leg_transport` is `udp` | `tcp` | `tls` | `ws` | `wss` — the
transport an inbound INVITE arrived on, or an outbound gateway's
configured transport. `media_type` is `rtp` | `srtp` | `webrtc`.
The bump exists because with browser calls the record stopped being
able to answer an obvious question. A WebRTC leg and a plaintext UDP
leg produced *identical* CDRs apart from the codec, even though one is
DTLS-SRTP over WSS and the other is cleartext RTP over the open
internet — and `media_type` is the field a compliance report reads,
where `rtp` is the only value meaning the audio was in the clear.
Two things a consumer should know:
- **`media_type` reports what happened, not what was configured.** A
`[[gateway]].srtp = "preferred"` trunk whose peer answered plaintext
records `rtp`, because that is what crossed the network.
- **Both fields are omitted where they are genuinely unknown** rather
than defaulted: a record written by an older daemon, and a
delayed-offer call that failed negotiation before any media existed
(that one has `leg_transport` and no `media_type` — claiming `rtp`
would say cleartext audio flowed when none did). Gate on
`version >= 9` rather than probing for the keys.
**CSV sinks gain two columns (50 → 52)**, appended as always, so
position-keyed ingestors keep working and anything asserting an exact
column count does not. `webrtc` is a distinct value rather than `srtp`
with a flag because the negotiation, the failure modes and the metrics
all differ — see `siphon_ai_webrtc_legs_total`.
### Added
- **Browser calls are now observable: ICE and DTLS are timed and
counted apart, and a leg's codec cost is measured**
(`DEV_PLAN_WebRTC.md` §4.6). Until now a browser call that never got
audio produced one log line and no metric, and "connect failed"
covered two unrelated faults.
Five metrics, all emitted only by a WebRTC leg:
`siphon_ai_webrtc_legs_total{codec,result}`,
`siphon_ai_webrtc_legs_ended_total{reason}`,
`siphon_ai_webrtc_ice_seconds`, `siphon_ai_webrtc_dtls_seconds`, and
`siphon_ai_webrtc_transcode_seconds{direction}`. All five are
documented in `docs/DEPLOY.md`.
**The ice/dtls split is the point.** `result="ice_timeout"` means the
setup budget expired with *no* candidate pair nominated — there is no
path, so the answer is NAT, `[webrtc].stun_servers`, or a
`[media].rtp_port_range` the firewall never opened. `dtls_timeout`
means a pair *was* nominated and the handshake still did not finish:
the path works and the crypto does not. Those have different fixes,
and a single "connect failed" counter would have hidden which one you
had. Getting the split required making the setup wait **event-driven**
(`SetupOutcome`, `webrtc-glue::leg::await_setup`) rather than polling
the connection state the way it did before — polling collapses ICE
nomination and DTLS completion into one "connected" and throws away
the transition an operator needs.
`siphon_ai_webrtc_legs_ended_total{reason="inactivity"}` is this
daemon's **consent-freshness signal**. The plan asked for consent
failures; forge-webrtc sends RFC 7675 keepalives but does not fail a
transport when the replies stop, so there is no upstream event to
count. Silence is what actually tells us a browser is gone, and the
inactivity watchdog is what gives the call slot and its RTP port pair
back — so it is counted as the real detector rather than a metric
named after a mechanism we do not have.
`siphon_ai_webrtc_transcode_seconds` is the plan's per-call transcode
cost: wall time spent inside the codec, which for pure computation
with no I/O is CPU time in all but name. It excludes SRTP protect and
the socket write, and is recorded **once per leg** rather than per
frame — 50 histogram observations a second per call would cost more
than the work being measured (CLAUDE.md §4.3). Read next to the
`codec` label, it is the capacity number for browser traffic, and the
case for `[webrtc].prefer_g711` on a busy node.
- **Browser calls now draw an RTP port pair from the same pool SIP calls
do** (`DEV_PLAN_WebRTC.md` §4.4). A WebRTC leg binds **one** socket
(BUNDLE + `a=rtcp-mux`) where a classic leg binds an RTP/RTCP pair,
and until now that socket came from wherever the OS put it —
outside `[media].rtp_port_range` entirely.
The accounting reason is the obvious one: `siphon_ai_rtp_port_pairs_allocated`
would under-report real load, and `[media].reserved_outbound_calls`
would be silently bypassed by browser traffic while still losing the
slot it protects — the exact starvation the setting exists to
prevent, made invisible. But the reason an operator hits **first** is
simpler and worse: a socket outside `rtp_port_range` is unreachable
through the firewall they opened for RTP. Media accounting can be
reconciled after the fact; a blocked media path is a broken call in a
deployment that did everything right.
A browser leg now holds a whole **pair** for its lifetime, under the
same floor an inbound SIP call uses, and forge-webrtc binds its
socket to that pair's RTP port. It holds a pair rather than a single
port because a pair is the unit the pool, the gauge and the reserved
band all count in — charging a browser call half a slot would make
`reserved_outbound_calls` mean two different things depending on who
called. A browser call refused by the floor gets the same `503` +
`Retry-After` and the same `siphon_ai_rtp_reserve_blocks_total`
increment a SIP call does.
The reservation releases on **`Drop`**, so no teardown path can leak
a slot — and because the capacity gauge is *sampled from pool truth*
rather than incremented at call sites, browser calls show up in it
with no new metric, and Phase 0's soak assertion that the gauge
returns to zero now covers WebRTC legs for free.
Needs forge-media [#134](https://github.com/thevoiceguy/forge-media/pull/134)
(`TransportConfig::local_port`, `SessionManager::reserve_port_pair`).
- **A browser can now place a call that carries audio** — the end of
Phase 2's core path in `docs/design/DEV_PLAN_WebRTC.md`. An INVITE
arriving over WS/WSS with a WebRTC-shaped offer is answered by
forge-webrtc, and the resulting peer connection is handed to the
controller as a `MediaLeg::WebRtc` alongside the classic RTP one.
Verified with a real headless-Chrome call end to end: Chrome → WSS →
the WebRTC leg → Opus decode at 16 kHz → the WS bridge → the echo
server → encode → Chrome, **1216 frames each direction, zero decode
or encode errors**.
Three things the classic path does are deliberately skipped for a
browser leg, because doing them would be wrong rather than merely
unnecessary: the **SRTP policy gate** (it would reject
`UDP/TLS/RTP/SAVPF` under the default `srtp_mode = off` and turn a
working call into a 488 — the exact misdiagnosis §4.1 exists to
end), the **profile tweaks** that rewrite an m-line for sip-sdp's
negotiator, and **forge's session start**, which has no session to
operate on. `[webrtc].setup_timeout` bounds ICE/DTLS.
The leg also gets its **own inactivity watchdog**, wired to the same
`[media].inactivity_timeout_secs` a SIP call uses. This was found by
testing rather than reasoning: when a browser tab closes there is no
BYE and no FIN we can see, and the first working call sat in
`calls_active` indefinitely after Chrome was killed. Now silence
reclaims the slot, and the leg logs the frames it moved in each
direction so a silent call can be told apart from no media at all.
### Added
- **The controller can run a browser call's media** (Phase 2 §4.3 of
`docs/design/DEV_PLAN_WebRTC.md`). `CallController` now holds a
`MediaLeg` — `Classic` (forge-engine RTP, every SIP call, unchanged)
or `WebRtc` (a forge-webrtc peer connection) — instead of a concrete
tap. The seam turned out to be far narrower than the acceptor's size
suggests: the controller already exchanged 20 ms PCM16LE frames over
plain channels and issued `TapCommand`s, and `MediaSession` never
appeared in it at all, so this is an enum over ~6 methods rather than
a refactor of the tap's internals.
`WebRtcTap` drives a browser leg the way `MediaTap` drives a SIP one:
wait for ICE/DTLS inside `[webrtc].setup_timeout`, then pump SRTP ⇄
the bridge's frames on a 20 ms tick. What a browser leg **cannot** do
is refused explicitly rather than half-done — bot-initiated hold and
park are SIP re-INVITE dances, outbound RFC 2833 DTMF is meaningless
to a browser, and conference rooms are fed by the classic frame loop.
Hold's refusal reuses the existing `accepted: false` path (the one
the conference-hold conflict already uses, #403), so the WS server
gets a normal `hold_failed` and no new protocol surface appears.
`Clear`, `Mute`/`Unmute`, `Mark` and the barge-in verdicts all work,
because those are about this leg's own playout.
Behaviour for existing calls is unchanged — every SIP call takes the
`Classic` variant, and the full SIPp suite passes untouched.
### Added
- **Leg selection for browser calls** (Phase 2 §4.1 of
`docs/design/DEV_PLAN_WebRTC.md`). The acceptor now applies the
plan's rule — **transport selects eligibility, SDP shape selects the
leg** — before the SRTP policy gate: an INVITE over WS/WSS carrying a
WebRTC-shaped offer is recognised as a browser rather than
mis-diagnosed. Until the media leg is bridged it is still refused
`488`, but with a message naming the actual problem and telling the
operator which situation they are in (`[webrtc]` disabled vs enabled
but not yet terminating media), and under its own metric label
`siphon_ai_invites_total{result="rejected_webrtc"}` — separately
alertable, because a browser reaching a daemon that cannot serve it
is a deployment gap, not routing noise.
This is the seam the leg plugs into. It matters on its own because
the Phase 1 browser call hit `488 offer profile UDP/TLS/RTP/SAVPF
rejected under srtp_mode = Off` — true, and completely misleading.
Verified with a real headless-Chrome call against both configurations.
A non-browser RFC 7118 client sending plain RTP still gets the classic
leg, and a WebRTC-shaped offer arriving over UDP still does too:
neither half of the rule decides alone.
### Added
- **The WebRTC media leg's lifecycle and audio path** (`crates/webrtc-glue`,
Phase 2 §4.2–4.3 of `docs/design/DEV_PLAN_WebRTC.md`). `WebRtcLeg`
answers a browser offer with **complete gathering before the answer**
(v1 does no SIP trickle, so both ends gather fully before signalling
— bounded, because a black-holing STUN server must degrade the
candidate list rather than stall a call), then exposes connect and
teardown on the `[webrtc].setup_timeout` budget. `InboundAudio` /
`OutboundAudio` convert between SRTP and the bridge's fixed 20 ms
PCM16LE frames, reusing the **same** `Reframer` / `pack_pcm16_le` as
the classic tap so the frame contract cannot drift between leg types,
and forge-rtp's `JitterBuffer` rather than a bespoke reorder queue.
Two things this pinned down that the plan had sketched differently:
**no resampler is needed** — libopus decodes 48 kHz straight to the
bridge's 16 kHz, exactly as the classic Opus path already relies on,
so a browser leg hands the bridge the same PCM rate a classic Opus
call does; and Opus's **RTP timestamp advances at 48 kHz even when
decoding at 16 kHz** (RFC 7587 §4.1), so a 20 ms frame is 320 samples
but 960 timestamp units — the increment comes from forge-webrtc's
`samples_per_20ms()` and never from the PCM length, because deriving
it from the frame would run the clock at a third of real time and
slowly starve the browser's jitter buffer.
Not yet wired into the acceptor — that is the next step, and the
reason this lands as a self-contained, separately-tested unit.
### Changed
- **forge-media pinned to `v2026.08.25`** (from `v2026.08.24`) —
forge-rtp 0.3.1, whose `JitterBuffer::pop` no longer aborts the
process on a reordered RTP packet
([forge-media #132](https://github.com/thevoiceguy/forge-media/pull/132)).
Found building the leg above, which is that buffer's first consumer;
nothing released used it, so no shipped siphon-ai was affected.
### Added
- **`crates/webrtc-glue` + `[webrtc]` config** — the foundation of
Phase 2 in `docs/design/DEV_PLAN_WebRTC.md`, and the answer to that
plan's top risk. The new crate holds the §4.1 leg-selection rule
(**transport selects eligibility, SDP shape selects the leg**) and
the `[webrtc]` → forge-webrtc `PeerConfig` mapping. Everything is
checked against the **real Chrome offer captured off the wire**
during the Phase 1 browser run — kept verbatim as a fixture — and
forge-webrtc answers it correctly: right DTLS role for
`a=setup:actpass`, mirrored `mid`/BUNDLE/`rtcp-mux`, our own
fingerprint, and exactly one pinned codec (Opus by default; PCMU
with `prefer_g711`, which makes the whole call 8 kHz end-to-end and
skips transcoding). Two findings the plan did not anticipate are
pinned by tests: Chrome's host candidate is an **mDNS `.local`
name** (a server without an mDNS resolver needs the peer's srflx
candidate — an ICE failure from this otherwise looks like a network
problem), and a DTLS-SRTP offer *without* ICE (some SBCs) must stay
on the classic path, which is why ICE — not the profile — is the
discriminator. Behind a `webrtc` cargo feature: a plain
`cargo build` links neither forge-webrtc nor forge-ice, while a
config that enables it on a binary built without it now fails
**both at startup and at `siphon-ai check`** (the pre-upgrade gate
previously only compiled the config, so it could pass where boot
would fail). No media yet — offer/answer lifecycle and the audio
path are next.
- **`examples/browser-sip/headless-check.sh` — the Phase 1 exit check,
self-driving on a browserless box**, and the check itself **passed**
(recorded in `DEV_PLAN_WebRTC.md` §3.3): headless Chromium + SIP.js
digest-REGISTERed over WSS with Origin enforcement, the binding
expired via connection-loss grace when the browser died, the
exchange appeared in Homer with `Via: SIP/2.0/WSS`, and the
browser's test INVITE authenticated, routed, and drew the precise
`488 offer profile UDP/TLS/RTP/SAVPF rejected` that marks Phase 2's
seam. The script needs no root: system `chromium` if present, else
Playwright's download with the two missing NSS libraries fetched via
`apt-get download` into a local dir. The page loads SIP.js as
jsdelivr's ESM build (npm ships no UMD bundle — the unpkg `dist/`
path 404s) and gains `?auto=1` / `?auto=1&call=1` self-driving
modes.
- **`examples/browser-sip/`** — the hands-on Phase 1 exit check for
`docs/design/DEV_PLAN_WebRTC.md`: a SIP.js test page, a lab config
(`[sip.wss]` + `[sip.auth]` + `[registrar]` with an Origin
allow-list), and a mkcert-or-openssl certificate script. Five
minutes from checkout to a browser tab REGISTERed over WSS, binding
visible in `siphon_ai_registrar_bindings` and expiring when the tab
closes; optional Homer leg via `examples/homer-stack/`. The whole
path short of the browser itself is machine-verified: a WSS probe
(self-signed TLS, page Origin, digest 401→200→unregister, wrong
origin refused 403) runs against the exact lab config.
`ws_sip_call.py` gained a `__main__` guard so the probe logic is
importable.
- **`[registrar]` — the daemon serves REGISTER** (Phase 1 of
`docs/design/DEV_PLAN_WebRTC.md` §3.2; distinct from `[[register]]`,
which is client-side). Thin adapter over upstream `sip-registrar`
(`BasicRegistrar` does header validation, digest auth with the
AOR-to-identity authorization step — user A cannot register user
B's AOR — expires clamping, and the location store); what siphon-ai
adds is connection awareness: a registration arriving over a stream
transport (WS/WSS/TCP/TLS) is bound to its connection — the only
route back to a browser — and expired ~32 s after that connection
dies (a reload re-REGISTERs inside the window). Off by default;
enabling it without `[sip.auth]` is a load error unless the
explicit `allow_unauthenticated` lab flag is set. Credentials are
`[sip.auth]`'s users, so INVITE and REGISTER share one set. New
metrics: `siphon_ai_registrar_registers_total{result}` (alert on
`forbidden` — someone's credentials claiming someone else's
identity) and `siphon_ai_registrar_bindings`. The suite gains a
`ws_registrar` phase driving the SIP.js-shaped flow over WS:
REGISTER → 401 digest challenge → authenticated REGISTER → 200 →
`Expires: 0` unregister. siphon-rs pinned to `v2026.08.25`
(sip-uas 0.5.0: `on_register` now receives the `TransportContext`,
[siphon-rs #127](https://github.com/thevoiceguy/siphon-rs/pull/127)).
- **SIP over WebSocket listeners (RFC 7118)** — Phase 1 of
`docs/design/DEV_PLAN_WebRTC.md`. `[sip].transports` accepts `"ws"`
and `"wss"`, each with its own listener block: `[sip.ws]` /
`[sip.wss]` (`listen`, `cert`/`key` for WSS, `allowed_origins`).
Upgrades must offer the `sip` subprotocol; a non-empty
`allowed_origins` refuses unlisted (or absent) `Origin`s `403` at
the upgrade, pinning which pages may open signalling (browsers
always send it; `[sip.auth]` remains the real authentication).
Replies and in-dialog requests to a WS client route down the
connection the client established — browser Via/Contacts are
unroutable, so there is deliberately no dial-back fallback. Off
unless configured; the WSS cert is deliberately independent of
`[sip.tls]` (browsers must trust it) and rotating it wants a
restart. The signalling suite gains a `ws_signaling` phase
(`ws_sip_call.py` — SIPp can't speak RFC 7118): a full
INVITE/ACK/BYE call over one WS connection, origin-refusal
assertions, and the settle-to-zero check. Verified end-to-end:
OPTIONS and a full call answered over WS, wrong/absent origin
refused, teardown clean. siphon-rs pinned to `v2026.08.24.1`
(sip-transport 0.5.0: `WsAcceptPolicy` + WS idle timeout,
[siphon-rs #125](https://github.com/thevoiceguy/siphon-rs/pull/125));
the `ws` feature joins `tls` on the sip-transport dependency.
- **RTP port-pool gauges + a teardown-soak harness phase** (Phase 0 of
`docs/design/DEV_PLAN_WebRTC.md`). Two new gauges,
`siphon_ai_rtp_port_pairs_allocated` and `_capacity`, published every
2 s by a sampler that reads the pool itself — deliberately not
incremented at alloc/free sites, since a site-updated gauge
under-counts under exactly the leak it exists to catch. Allocated
diverging from `siphon_ai_calls_active` is a leaked media session;
alert on it. The SIPp suite gains a `teardown_soak` phase: twelve
calls across clean-BYE / CANCEL / abrupt-vanish (established dialog,
peer exits with no BYE — the browser-shaped teardown) over UDP and
TCP, then an assertion that live calls, the dialog store, and
allocated port pairs all return to zero. Lab result on this baseline:
eleven distinct teardown shapes (including delayed-offer and no-ACK
vanishes, and with the inactivity watchdog disabled) all settle to
zero — teardown is currently defense-in-depth (media watchdog,
forge's own session sweep, dialog reaper, BYE-failure-tolerant
teardown), and the phase exists to keep it that way once a WebRTC
leg multiplies the abrupt-teardown rate.
### Changed
- **forge-media pinned by tag: `v2026.08.24`** (was `rev = "c277860cd123"`) —
forge-media's first tagged release under its new
[RELEASING.md](https://github.com/thevoiceguy/forge-media/blob/main/RELEASING.md)
(the siphon-rs model, adopted in
[forge-media #131](https://github.com/thevoiceguy/forge-media/pull/131)).
Both upstream pins now read as tags, and this pair is self-consistent by
construction: forge-media `v2026.08.24` embeds siphon-rs `v2026.08.24`,
the same tag `[workspace.dependencies]` pins directly. Content over the
old rev: forge-webrtc 0.4.0's G.711 negotiation
([forge-media #130](https://github.com/thevoiceguy/forge-media/pull/130))
and crate-version stamps (forge-ice 0.3.0, forge-rtp 0.3.0) — none of
which siphon-ai compiles today (forge-webrtc is not yet a dependency;
the version bumps are manifest-only for the crates we use), so no
behaviour change.
### Fixed
- **`examples/browser-sip/gen-cert.sh` works on a fresh clone** (#587) — it
assumed state a previous run left behind; the lab's first-time path now
generates the browser-trusted cert without it.
---
**Artifacts**
- Static musl binaries for `x86_64` and `aarch64` (`.tar.gz`).
- Debian packages (`.deb`) for `amd64` and `arm64` — `apt install ./siphon-ai_*_amd64.deb`.
- `SHA256SUMS` — verify with `sha256sum -c SHA256SUMS`.
- `*-sbom.cdx.json` — CycloneDX SBOM.
- `SHA256SUMS.cosign.bundle` — cosign keyless signature over the checksums.
- Container: `ghcr.io/thevoiceguy/siphon-ai:v0.51.0` (multi-arch, cosign-signed).
Verify the checksums signature:
```
cosign verify-blob --bundle SHA256SUMS.cosign.bundle \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp 'release.yml@' SHA256SUMS
```
Related Repositories
A cross-platform desktop All-in-One assistant for Claude Code, Codex, OpenCode, OpenClaw, Grok Build & Hermes Agent. Only official website: ccswitch.io
An open-source remote desktop application designed for self-hosting, as an alternative to TeamViewer.
Build smaller, faster, and more secure desktop and mobile applications with a web frontend.
π RuView turns commodity WiFi signals into real-time spatial intelligence, vital sign monitoring, and presence detection — all without a single pixel of video.
Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one