feat(rwi): originate directly out a named carrier trunk#229
Open
tgeorge06 wants to merge 1 commit into
Open
Conversation
43e5e96 to
82ee3aa
Compare
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>
82ee3aa to
36ed5bb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
RWI
originate/parallel_originatecannot place a call out a carrier trunk. They build the rsipstackInviteOptionwithdestination: 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
trunkoverrideAdds
trunk: Option<String>toOriginateRequestandParallelOriginateRequest(#[serde(default)], so existing payloads are unchanged). When a non-blank trunk is named:data_context.get_trunk, rejecting unknown/unloaded anddisabledtrunks synchronously — before any call is spawned, and (for parallel originate) beforeParallelOriginateStartedis emitted.InviteOptionvia the existingproxy::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 duplicateCallModule'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:
FromURI.caller_idis commonly a bare phone number (+16142159851) — caller ID is a number. That parses into a user-less URI, and a trunk'srewrite_hostportthen restamps the host, yielding an invalidFromlike<carrier.example.com>(no user) that carriers reject with400 Invalid From.normalize_originate_caller_idwraps a bare token assip:<user>@<realm>(stripping any scheme, cutting at the first@/;/?souser@hostisn't double-affixed and params don't leak), preserves asip:/sips:URI that already has a user, and falls back tosip:rwi@<realm>forNone/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-dialogBYE/re-INVITEback to the proxy. Note: this applies to both originate paths, so it changesContactfor 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.start_peer_conference_bridgesends 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_originategates 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
#[serde(default)]; the no-trunk path is unchanged and byte-identical.apply_trunk_configunit tests (auth trunk stamps dest + credential + host-rewrite; IP-auth trunk gets a dest but no credential).cargo check --testsclean; 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:Authorization. Any RWI caller permitted to
originatecan 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 neworiginate:trunkscope), or is "whatever authorizesoriginate" sufficient for upstream? If you want a gate, we'll add it in this PR.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.
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