v0.48.0

thevoiceguy/siphon-aiv0.48.0Jul 30, 2026by github-actions[bot]

Upgrading?

AI Summary

Adds Prometheus metric for the outbound delayed-offer path and fixes SRTP negotiation, conference hold logic, and registration attribution issues.

Key Highlights

  • New `siphon_ai_outbound_delayed_offer_total` metric for diagnosing offerless INVITEs.
  • Fixed `hold` on a conference member to reject with `error { hold_failed }` instead of evicting the leg.
  • Fixed SRTP policy evaluation to check the set of offered alternatives and patch the selected m-line.
  • Fixed registration attribution to run before the trunk allowlist walk.
  • Fixed outbound calls through digest-authenticated gateways to retry challenges.

New Features

  • New metric `siphon_ai_outbound_delayed_offer_total{result=…}`
  • Conference join/exit tones now discoverable in configuration

Full Release Notes

### Added

- **`siphon_ai_outbound_delayed_offer_total{result=…}`** (issue #406 §3) — the outbound delayed-offer path (offerless INVITE we sent; peer offers in its 2xx, we answer in the ACK) previously emitted no delayed-offer metric at all: every increment of `siphon_ai_delayed_offer_total` was on the *inbound* path. Results: `answered`, `srtp_policy` (the gateway's `srtp` mode refused every offered audio alternative), `srtp_setup` (selected secure alternative failed to negotiate/install), `invalid_remote_media`, `missing_sdp_offer`. Both live failure modes in #406 would have been one-line diagnoses with it. Documented in `docs/DEPLOY.md`.

### Documentation

- **Conference enter/exit tones already exist — `[conference].join_tones` (0.7.0) — and are now discoverable** (issue #404). The live test that motivated the issue ran with the knob at its default (`false`), and nothing outside the CONFIG.md table mentioned it. PROTOCOL.md §4.8 now documents the built-in knob (distinct join/leave pitches, fires on hang-up departures too, mixed room-wide so recordings capture it) alongside the server-side recipe (play audio on `participant_joined`/`participant_left` — a member's WS audio reaches the whole room). A new regression test pins the exact live-observed case: the remaining member hears the leave chime when the other member hangs up (teardown departure, not an explicit `conference_leave`). The issue's proposed `join_tone`/`leave_tone` split was not adopted — the existing combined knob covers the use case with distinct pitches per direction.

### Fixed

- **`hold` on a conference member is now rejected with `error { hold_failed }` instead of silently evicting the leg from its room** (issue #403). Holding a conferenced call dropped its room membership — every *other* member got `participant_left` and the admin roster updated, but the held leg itself never received a `conference_left`, leaving its server with a stale room model in violation of PROTOCOL.md §3.12's every-exit-is-reported contract. Ruling: hold does not stack on room membership (option (a) from the issue — matching the existing no-stacking-on-peer-hold policy, and no protocol shape change): the tap, which owns membership state, refuses the hold; the call stays in the room completely untouched (no fan-out, no roster change), and a server that wants hold semantics sends `conference_leave` first, explicitly. `resume`'s "restores the direct caller↔server pair" contract is unaffected since a held call can now never be in a room. Documented in PROTOCOL.md §4.10.

- **A hold longer than `[media].inactivity_timeout_secs` no longer kills the held call** (issue #402). The inbound-RTP watchdog kept running while the call was held, and a bot-hold's `a=sendonly` re-INVITE tells the caller to stop sending — RFC 3264-compliant silence — so at exactly held+60 s (default) the daemon emitted `error { rtp_timeout }` + `stop { error }` and tore the call down mid-hold, making §4.10's "bot holds → bot resumes" primitive unusable for real-world holds. The watchdog is now parked while the call is held — bot-initiated hold *or* peer-hold (an `a=inactive` peer-hold stops inbound RTP too, same kill) — checked at watchdog-fire time so no hold↔resume transition can race an expired deadline into a spurious teardown, and re-armed with a fresh full window on resume. Genuine RTP loss outside a hold still tears down exactly as before; a resumed call that goes silent gets one fresh window, then dies. Documented in PROTOCOL.md §3.10/§4.10 and CONFIG.md.

- **SRTP policy is now evaluated against the *set* of offered audio m-lines, and the SDES/DTLS answer wiring patches the *selected* m-line instead of the first** (issue #406 §1–2). FreeSWITCH's late-negotiation offer advertises secure and plaintext audio alternatives (`RTP/SAVP` + `RTP/AVP` on one port); the gate, the offer tweaks, and the answer post-processing all read only the **first** audio m-line. Consequences, both measured live: with `srtp` unset (`off`) the whole call was refused with 488 even though the peer offered plaintext (taking an explicitly offered option is not a downgrade); with `srtp = "preferred"` the call answered but crypto was patched onto whichever m-line came first rather than the one the negotiator accepted. The gate now refuses only when *no* offered alternative satisfies the mode; the tweaks select the first satisfying alternative (peer's declared preference order — plaintext-first under `preferred` yields a plaintext call), neutralize plaintext alternatives under `required` so the negotiator can't accept them ahead of the secure line, and the post-processors patch exactly the selected (accepted, nonzero-port) m-line, restoring the offered protocol on rejected echoes per RFC 3264 §6. `media-glue`'s `negotiate_answer` and `audio_remote_addr` likewise now locate the *accepted* audio stream instead of assuming the first m-line is it (the first may legitimately be a port-0 rejection post siphon-rs #85). Builds on the upstream one-m-line-per-type negotiator fix in this release's pin bump. FreeSWITCH profile prerequisites for delayed offer (`enable-3pcc`, `inbound-late-negotiation`) and their distinct failure signatures (instant `480` vs `488`) documented in `docs/FREESWITCH_INTEGRATION.md` (#406 §4).
- **Registration attribution now runs before the `[[trunk]]` allowlist walk, so `register_source = "<register name>"` routes are reachable in configs that also declare trunks** (issue #405). With any `[[trunk]]` block present, an inbound INVITE's `register_source` was computed exclusively by the trunk walk: a registrar whose IP also matched a trunk CIDR got the *trunk* name (silently killing every route matching the registration name), and a registrar outside every trunk allowlist got `403 Forbidden` — making registered-phone mode effectively unusable in production configs, contradicting `docs/REGISTRATION.md`. The gate now consults the registration manager's exact `ip:port` lookup first, then the trunk walk, then 403. A peer we register *to* is operator-declared (`[[register]].server`/`port`) and implicitly trusted. The exact-port match preserves the useful split: a PBX's other profiles (same IP, different source port, e.g. FreeSWITCH's external profile on :5080) keep identifying via the trunk walk. Unregistered peers and no-match 403 behavior are unchanged; precedence documented in `docs/CONFIG.md`.
- **Outbound calls through a digest-authenticated gateway no longer fail instantly as `rejected`** (siphon-rs #83, fixed upstream in siphon-rs #86; pin bump). The UAC never auto-retried an INVITE on a 401/407 challenge — the flag existed but was dead code on the INVITE path — so a perfectly credentialed call through FreeSWITCH/Asterisk-style PBXes that digest-challenge INVITE surfaced the challenge as a terminal rejection ~1 ms after dial (`outbound_failed{cause:"rejected"}`). IP-ACL trunks (e.g. Twilio) masked it. The UAC now builds the authenticated re-INVITE (same Call-ID, CSeq+1, fresh branch) transparently, honoring `max_auth_retries`. `CallHandle::invite_request()` became async upstream (the live attempt is replaced on retry); the one call site adapted.
- **An offer carrying secure and plaintext audio alternatives (e.g. stock FreeSWITCH late negotiation offering `RTP/SAVP` + `RTP/AVP` on one port) no longer gets both m-lines accepted on the same local port** (siphon-rs #84, fixed upstream in siphon-rs #85; reaches the media path via forge-media #100's submodule bump). Both alternatives were accepted in the answer — indistinguishable on the wire, one encrypted and one not — so the dialog established and then no media flowed in either direction for the life of the call (siphon-ai #406 §2's root). The negotiator now accepts the first acceptable alternative per media type and rejects the rest with port 0, per RFC 3264 §6. The remaining siphon-ai-side hardening for delayed-offer alternatives (SRTP policy gate over the m-line *set*, patching the *selected* m-line) is tracked in #406.

---

**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.48.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