Skip to content

feat(rwi): originate directly out a named carrier trunk#229

Open
tgeorge06 wants to merge 1 commit into
restsend:mainfrom
tgeorge06:feat/rwi-originate-direct-to-trunk
Open

feat(rwi): originate directly out a named carrier trunk#229
tgeorge06 wants to merge 1 commit into
restsend:mainfrom
tgeorge06:feat/rwi-originate-direct-to-trunk

Conversation

@tgeorge06

@tgeorge06 tgeorge06 commented Jun 30, 2026

Copy link
Copy Markdown

Problem

RWI originate / parallel_originate cannot place a call out a carrier trunk. They build the rsipstack InviteOption with destination: None / credential: None, so rsipstack resolves the next hop from the callee request-URI and self-INVITEs this proxy. The proxy's own auth then 407-challenges the call (no credentials attached) and it dies before reaching the PSTN.

The path works against a registered/reachable UA (a self-INVITE reaches it), which is the only way it's been exercised — but there's currently no way to originate out a trunk.

Fix — opt-in trunk override

Adds trunk: Option<String> to OriginateRequest and ParallelOriginateRequest (#[serde(default)], so existing payloads are unchanged). When a non-blank trunk is named:

  1. It's resolved via data_context.get_trunk, rejecting unknown/unloaded and disabled trunks synchronously — before any call is spawned, and (for parallel originate) before ParallelOriginateStarted is emitted.
  2. The trunk's destination, transport, digest credential, host rewrite, and P-Asserted-Identity header are stamped onto the InviteOption via the existing proxy::routing::matcher::apply_trunk_config — the same pure mutator the inbound proxy path uses.

rsipstack then sends one INVITE straight to the carrier and auto-answers its 401/407 from the credential. When no trunk is named the option is untouched and the legacy direct-to-callee behavior is byte-identical.

This follows the FreeSWITCH originate sofia/gateway/<gw>/<num> model: the API caller declares the gateway by name. The proxy route table is deliberately not consulted from the RWI path — that would duplicate CallModule's direction + admission semantics.

Three originate-path correctness fixes the trunk override surfaces

Placing a real INVITE out a carrier trunk exposed three latent issues on the originate path; all are fixed here since they're only reachable once trunk origination exists:

  • From URI. caller_id is commonly a bare phone number (+16142159851) — caller ID is a number. That parses into a user-less URI, and a trunk's rewrite_hostport then restamps the host, yielding an invalid From like <carrier.example.com> (no user) that carriers reject with 400 Invalid From. normalize_originate_caller_id wraps a bare token as sip:<user>@<realm> (stripping any scheme, cutting at the first @/;/? so user@host isn't double-affixed and params don't leak), preserves a sip:/sips: URI that already has a user, and falls back to sip:rwi@<realm> for None/blank/degenerate empty-user input.
  • Contact. Use the proxy's own reachable address (server.default_contact_uri(), falling back to the caller URI) instead of the caller-id URI, so the carrier routes in-dialog BYE/re-INVITE back to the proxy. Note: this applies to both originate paths, so it changes Contact for non-trunk originates too — it's the correct value in both cases (the caller-id URI was never a proxy endpoint), but flagging it since it's slightly broader than the trunk feature itself. Happy to scope it trunk-only if you'd prefer.
  • Media codec. start_peer_conference_bridge sends the bridge's RTP with a fixed PCMU payload type (payload_type: 0), but the default originate offer advertises the full set (opus, G729, G722, PCMU, PCMA) with opus/G729 ahead of PCMU. A PSTN carrier that can't do opus then answers G.729, which the PCMU-only bridge mis-handles → garbled audio both directions. When routing out a named trunk we offer PCMU only (is_trunk_originate gates it on both paths; PCMA excluded because the bridge can't speak a-law either), so negotiation converges on what the bridge handles. Non-trunk originates keep the full codec set, so opus still works for WebRTC agents. (If you'd rather the bridge transcode arbitrary codecs instead, that's a larger change we're happy to take on — this is the minimal, PSTN-safe fix.)

Backward compatibility & tests

  • Both new fields are #[serde(default)]; the no-trunk path is unchanged and byte-identical.
  • Adds two apply_trunk_config unit tests (auth trunk stamps dest + credential + host-rewrite; IP-auth trunk gets a dest but no credential).
  • cargo check --tests clean; existing originate tests unchanged.

Questions for maintainers — and we're happy to implement whichever way you prefer

This direct-to-trunk path intentionally bypasses route-layer controls that normally wrap apply_trunk_config. We've documented both as known limitations inline, but they're really design decisions that are yours to make, and we'll do the work to match your call:

  1. Authorization. Any RWI caller permitted to originate can select any enabled trunk by name. RWI validates bearer tokens but (as far as we can see) doesn't enforce per-command scopes in dispatch, so there's no per-token trunk allowlist. Do you want a scope/allowlist gate (e.g. per-token allowed-trunks, or a new originate:trunk scope), or is "whatever authorizes originate" sufficient for upstream? If you want a gate, we'll add it in this PR.

  2. Admission. Per-trunk CAC/CPS/max-duration and route-layer media policy are not enforced on this direct path (the caller paces its own calls). Acceptable as a documented limitation, or would you prefer we route trunk admission through the existing enforcement before the INVITE? Either way, we'll implement it.

  3. Shape. Is trunk: Option<String> on the originate request the API you'd want, or would you rather express this differently (e.g. a structured route hint)? Happy to reshape.

We'd rather match your direction than guess — point us at the approach you want and we'll push the changes.

🤖 Generated with Claude Code

@tgeorge06 tgeorge06 force-pushed the feat/rwi-originate-direct-to-trunk branch from 43e5e96 to 82ee3aa Compare July 1, 2026 05:14
RWI `originate` / `parallel_originate` built the InviteOption with
destination:None / credential:None, so rsipstack resolved the next hop from
the callee request-URI and self-INVITEd this proxy -- which 407-challenges a
non-local callee. A call therefore could never be placed out a carrier trunk;
the path only ever reached a registered/reachable UA.

Add an opt-in `trunk: Option<String>` to OriginateRequest and
ParallelOriginateRequest (serde-default, so existing payloads are unchanged).
When a non-blank trunk is named it is resolved via data_context.get_trunk --
rejecting unknown/unloaded and disabled trunks synchronously, before any call
is spawned (and, for parallel originate, before ParallelOriginateStarted is
emitted) -- and its destination, transport, digest credential, host rewrite,
and P-Asserted-Identity header are stamped onto the InviteOption via the
existing proxy::routing::matcher::apply_trunk_config. rsipstack then sends one
INVITE straight to the carrier and auto-answers its 401/407 from the
credential. When no trunk is named the option is left untouched and the legacy
direct-to-callee behavior is byte-identical.

This is the FreeSWITCH `originate sofia/gateway/<gw>/<num>` model: the API
caller declares the gateway by name. The proxy route table is deliberately
not consulted from the RWI path (that would duplicate CallModule's direction +
admission semantics). Known limitations are documented inline: this direct
path enforces neither a per-token trunk allowlist/scope (any originate-capable
caller may select any enabled trunk) nor route-layer admission (CAC/CPS/
max-duration/media policy); a deployment can add a scope/allowlist if its
threat model requires one.

Three correctness fixes on the originate path that the trunk override surfaces
(each is only reachable once trunk origination exists):

* From URI: caller_id is commonly a bare phone number ("+16142159851"), which
  parses into a user-less URI; a trunk's rewrite_hostport then restamps the
  host, yielding an invalid From like `<carrier.example.com>` (no user) that
  carriers reject with "400 Invalid From". normalize_originate_caller_id wraps
  a bare token as `sip:<user>@<realm>` (stripping any scheme and cutting at the
  first @/;/? so user@host is not double-affixed and params do not leak),
  preserves a sip/sips URI that already has a user, and falls back to
  `sip:rwi@<realm>` for None/blank or degenerate empty-user input.

* Contact: use the proxy's own reachable address (server.default_contact_uri,
  falling back to the caller URI) instead of the caller-id URI, so the carrier
  routes in-dialog BYE / re-INVITE back to the proxy. Applied to both the
  single and parallel originate InviteOption builds.

* Media codec: the RWI conference bridge sends RTP with a fixed PCMU payload
  type (start_peer_conference_bridge), but the default offer advertises the
  full set (opus, G729, G722, PCMU, PCMA) with opus/G729 ahead of PCMU, so a
  PSTN carrier can answer G.729 -- which the PCMU bridge then mis-handles,
  garbling audio both ways. Offer PCMU ONLY when routing out a named trunk
  (is_trunk_originate) so negotiation converges on PCMU; PCMA is excluded for
  the same reason. Non-trunk originates keep the full codec set so opus still
  works for WebRTC agents.

Tests: two apply_trunk_config unit tests (auth trunk stamps
destination + credential + host-rewrite; IP-auth trunk gets a destination but
no credential); a normalize_originate_caller_id table test (bare numbers, full
URIs, scheme-without-user, user@host, params, blank, degenerate empty-user);
and a media-track test asserting a PCMU-only preference offer advertises PCMU
and excludes opus/G729/G722/PCMA.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tgeorge06 tgeorge06 force-pushed the feat/rwi-originate-direct-to-trunk branch from 82ee3aa to 36ed5bb Compare July 1, 2026 17:44
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.

1 participant