feat(trunk): TwilioCallControlClient (live REST impl behind twilio-live) + env parser (slice-5 T6)

The live Twilio call-control client. Originates outbound calls via Twilio's
Calls.json API (TwiML <Connect><Stream> instructs Twilio to fork audio back
to our /twilio/media-stream WSS endpoint); hangs up via Status=completed.
HTTP basic auth over HTTPS; auth_token is NEVER logged (ADR-0009 -- provider
credentials never reach the brain) -- tracing fields expose only caller-
controlled values (to, from) + the CallSid's last 4 chars.

Feature-gated behind `twilio-live` so the routine CI gate stays feature-
default-off: MockCallControlClient is the per-PR test surface; the maintainer
runs cargo test --features=twilio-live when validating a release. reqwest +
tracing + serde_json are optional deps (dep:foo syntax) -- pulled in only
when the feature is on, keeping the default resolve lean.

The env parser (config::twilio_credentials) follows slice-5/seams' pure-
function pattern: takes Option<String> inputs (testable without env mutation),
returns Ok(None) when all four RUTSTER_TWILIO_* vars are unset (WebRTC-only
mode), Ok(Some) when all four present + parse, Err on partial config (fail-
fast at startup) or malformed values. TwilioCredentials is imported by the
binary but never re-exported through the workspace (ADR-0009).

T6 of slice-5. Depends on T2 (TwilioCredentials + CallControlClient trait).

Signed-off-by: Aaron D. Lee <himself@adlee.work>
This commit is contained in:
2026-07-05 03:04:22 -04:00
parent de0a3ffff7
commit a7203ab51b
8 changed files with 859 additions and 12 deletions

416
Cargo.lock generated
View File

@@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.2.17",
]
[[package]]
@@ -118,7 +118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
dependencies = [
"aws-lc-sys",
"untrusted",
"untrusted 0.7.1",
"zeroize",
]
@@ -282,6 +282,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -326,6 +343,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
@@ -492,7 +518,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -597,8 +623,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -620,8 +648,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -693,6 +724,23 @@ dependencies = [
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -701,13 +749,21 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
]
[[package]]
@@ -729,6 +785,12 @@ dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is"
version = "0.10.0"
@@ -802,6 +864,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -843,7 +911,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"wasi",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -871,7 +939,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1016,6 +1084,62 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.46"
@@ -1058,6 +1182,17 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
@@ -1096,6 +1231,21 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rcgen"
version = "0.14.7"
@@ -1135,6 +1285,58 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted 0.9.0",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -1160,7 +1362,21 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
@@ -1169,9 +1385,21 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted 0.9.0",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -1191,6 +1419,7 @@ dependencies = [
"rutster-media",
"rutster-tap",
"rutster-tap-echo",
"rutster-trunk",
"serde",
"serde_json",
"thiserror 1.0.69",
@@ -1282,7 +1511,10 @@ name = "rutster-trunk"
version = "0.0.0"
dependencies = [
"async-trait",
"reqwest",
"serde_json",
"tokio",
"tracing",
"url",
]
@@ -1398,7 +1630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -1452,7 +1684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1534,6 +1766,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@@ -1556,7 +1791,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1668,7 +1903,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1682,6 +1917,16 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
@@ -1710,6 +1955,24 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
"url",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -1784,6 +2047,12 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.24.0"
@@ -1835,6 +2104,12 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.2"
@@ -1875,6 +2150,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -1903,6 +2187,16 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
@@ -1935,12 +2229,50 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -1950,6 +2282,70 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.57.1"

View File

@@ -59,3 +59,10 @@ async-trait = "0.1"
# client sends before WS upgrade; `openai_client::openai_headers` produces
# the header pairs the caller stuffs into it.
http = "1"
# reqwest 0.12: HTTP client for the green-zone Twilio REST call-control
# client (slice-5 T6, feature-gated behind `twilio-live`). `rustls-tls`
# (not native-tls/OpenSSL) keeps the TLS stack pure-Rust + consistent with
# str0m's existing aws-lc-rs crypto provider (ADR-0001 "memory-safe by
# construction"). `default-features = false` drops the default
# native-tls/OpenSSL backend. `json` for the Calls.json response parsing.
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

View File

@@ -17,6 +17,16 @@ async-trait = { workspace = true }
# url: the `TwilioCredentials::webhook_base` field is a `url::Url` (the
# operator's public base URL Twilio calls back). Parsed in `config::twilio_credentials`.
url = { workspace = true }
# reqwest + tracing + serde_json are OPTIONAL — only pulled in when the
# `twilio-live` feature is enabled (the live `TwilioCallControlClient`, T6).
# This keeps the default CI build (default-features-off) free of reqwest's
# transitive dep tree, so per-PR `cargo deny check` stays lean + cargo's
# resolve is fast for the common case. The maintainer's manual
# `cargo test --features=twilio-live` + the twilio-live CI job (T10) pull them in.
# `serde_json` parses the Calls.json REST response body (the `sid` field).
reqwest = { workspace = true, optional = true }
tracing = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
[dev-dependencies]
# tokio: only the dev-dep is needed for the mock's `#[tokio::test]` attribute
@@ -34,4 +44,7 @@ default = []
# the per-PR test surface; the maintainer runs `cargo test --features=twilio-live`
# + the e2e suite only when validating a release (against real Twilio creds).
# Spec §1.2 + plan T6 + ADR-0009 (credentials never reach the brain).
twilio-live = []
# `dep:reqwest` + `dep:tracing` are the optional-dep enable syntax
# (`feature = "dep:foo"` enables `foo` without exposing it as an implicit
# feature named `foo` — the explicit form is forward-compatible + unambiguous).
twilio-live = ["dep:reqwest", "dep:tracing", "dep:serde_json"]

View File

@@ -27,12 +27,14 @@ pub mod provider;
#[cfg(test)]
mod tests {
use crate::provider::MockCallControlClient;
/// Stub crates lock boundaries; the compile-test is the lock. Now that the
/// provider module is populated (T2), this test also guards the crate-level
/// re-exports compile against the public API surface.
#[test]
fn crate_compiles() {
// Touch the public API so the test exercises the re-export wiring.
let _mock = crate::provider::MockCallControlClient::new();
let _mock = MockCallControlClient::new();
}
}

View File

@@ -43,6 +43,18 @@ pub mod mock;
pub use mock::{MockCallControlClient, OriginateRecord};
// The live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live`.
// Including it unconditionally would pull `reqwest` into the default CI build's
// dep graph (against the lean-default-features-off posture); the `#[cfg]` here
// keeps the routine gate clean. `#[cfg(feature = ...)]` on a `pub mod` declaration
// means the module is only compiled when the feature is enabled — `lib.rs`'s
// `pub mod provider;` is unconditional (the trait + mock + credentials are always
// available); only the live REST client is gated.
#[cfg(feature = "twilio-live")]
pub mod twilio;
#[cfg(feature = "twilio-live")]
pub use twilio::TwilioCallControlClient;
/// Call-control operations a rented-transport provider exposes.
///
/// `Send + Sync` so it can live behind an `Arc<dyn CallControlClient>` shared

View File

@@ -0,0 +1,280 @@
//! # TwilioCallControlClient — the live Twilio REST call-control client (green zone)
//!
//! This module is **feature-gated behind `twilio-live`** — it is NOT compiled in
//! the default CI build (which uses [`MockCallControlClient`](super::MockCallControlClient)).
//! The maintainer enables it with `cargo test --features=twilio-live` when validating
//! a release against real Twilio credentials.
//!
//! ## Green zone (ADR-0008)
//!
//! This client is arm's-length from the FOB hot path: it fires only on
//! originate (outbound call) + hangup (teardown). It is NOT on the 20 ms media
//! tick. The brain never holds a reference to it; the brain never sees the
//! [`TwilioCredentials`] (ADR-0009). Only the binary's route handlers (T5) construct
//! + call this client.
//!
//! ## What it talks to
//!
//! Twilio's REST API (<https://api.twilio.com/2010-04-01/Accounts/{sid}/Calls.json>)
//! over HTTPS with HTTP basic auth (`account_sid:auth_token`). The `originate` call
//! POSTs a form body containing `To`, `From`, + a `Twiml` parameter whose value is a
//! `<Response><Connect><Stream url="…"/></Connect></Response>` document instructing
//! Twilio to open a Media Streams WebSocket fork against our `/twilio/media-stream`
//! endpoint. `hangup` POSTs `Status=completed` to the per-call resource.
//!
//! ## Credential isolation (ADR-0009 — load-bearing)
//!
//! `auth_token` is used ONLY for the HTTP basic-auth header on the REST call. It is
//! **never** logged: `tracing::debug!` fields expose only caller-controlled values
//! (`to`, `from`) + the last 4 chars of the `CallSid` (for log correlation). The
//! `TwilioCredentials::Debug` impl (in [`super::TwilioCredentials`]) redacts the
//! token. This is the codebase's standing posture on secrets (cf. `api_key.rs`,
//! slice-3).
//!
//! ## Why `reqwest` (not `hyper` direct or hand-rolled)
//!
//! `reqwest` is the de-facto maintained Rust HTTP client; it wraps `hyper` + a TLS
//! backend. We pin `rustls-tls` (not native-tls/OpenSSL) at the workspace level to
//! keep the TLS stack pure-Rust, consistent with `str0m`'s existing `aws-lc-rs`
//! crypto provider (ADR-0001 "memory-safe by construction"). The client is optional
//! (gated by `dep:reqwest`) so the default CI resolve stays lean.
use async_trait::async_trait;
use super::{CallControlClient, CallControlError, TwilioCredentials};
/// Live Twilio call-control client. REST against Twilio's Calls API.
///
/// Construct with [`TwilioCallControlClient::new`] (passing a [`TwilioCredentials`]
/// built by `config::twilio_credentials` in the binary crate). The `reqwest::Client`
/// is reused across calls (connection pooling); construct once at startup, not per-call.
pub struct TwilioCallControlClient {
credentials: TwilioCredentials,
http: reqwest::Client,
}
impl TwilioCallControlClient {
/// Construct a live client. The `reqwest::Client` is created with default options
/// (rustls-tls backend, connection pool). The caller passes the credentials built
/// from env by the binary's `config::twilio_credentials` parser.
pub fn new(credentials: TwilioCredentials) -> Self {
Self {
credentials,
http: reqwest::Client::new(),
}
}
/// Derive the public Media Streams WSS endpoint URL the TwiML `<Stream>` directive
/// points Twilio at, from the operator's `webhook_base`. Twilio connects BACK to us
/// over WebSocket to fork the call's audio, so the URL must be the *public* address
/// (a reverse proxy maps the public `:443` → the local `media_streams_bind`).
///
/// `https` → `wss`, `http` → `ws` (local dev may use plaintext WS per spec §1.2's
/// TLS-deferral; production uses `wss`). The path `/twilio/media-stream` matches the
/// route `TwilioMediaStreamsServer::router` (T3) mounts.
fn stream_url(&self) -> String {
let scheme = match self.credentials.webhook_base.scheme() {
"https" => "wss",
"http" => "ws",
other => other,
};
let host = self
.credentials
.webhook_base
.host_str()
.unwrap_or("localhost");
match self.credentials.webhook_base.port() {
Some(port) => format!("{scheme}://{host}:{port}/twilio/media-stream"),
None => format!("{scheme}://{host}/twilio/media-stream"),
}
}
}
#[async_trait]
impl CallControlClient for TwilioCallControlClient {
async fn originate(
&self,
to_phone: &str,
from_phone: &str,
_spend_token: Option<super::SpendToken>,
) -> Result<String, CallControlError> {
// The TwiML instructs Twilio to <Connect> a <Stream> back to our WSS endpoint.
// Twilio forks the call's audio (µ-law @ 8 kHz) over the WS in real time;
// TwilioMediaStreamsServer (T3) receives + decodes via G711Codec (T1).
let twiml = format!(
"<Response><Connect><Stream url=\"{}\"/></Connect></Response>",
self.stream_url()
);
let url = format!(
"https://api.twilio.com/2010-04-01/Accounts/{}/Calls.json",
self.credentials.account_sid
);
let response = self
.http
.post(&url)
.basic_auth(
&self.credentials.account_sid,
Some(&self.credentials.auth_token),
)
.form(&[("To", to_phone), ("From", from_phone), ("Twiml", &twiml)])
.send()
.await
.map_err(|e| CallControlError(format!("twilio originate transport: {e}")))?;
let status = response.status();
let body: serde_json::Value = response
.json()
.await
.map_err(|e| CallControlError(format!("twilio originate body parse: {e}")))?;
if !status.is_success() {
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("(no message field)");
return Err(CallControlError(format!(
"twilio originate rejected ({status}): {msg}"
)));
}
let call_sid = body
.get("sid")
.and_then(|v| v.as_str())
.ok_or_else(|| CallControlError("twilio response missing 'sid' field".into()))?
.to_owned();
// Log ONLY caller-controlled fields + the last 4 chars of the CallSid
// (for log correlation). auth_token is NEVER logged (ADR-0009).
let last4 = &call_sid[call_sid.len().saturating_sub(4)..];
tracing::debug!(
to = to_phone,
from = from_phone,
sid_last4 = last4,
"twilio originate acknowledged"
);
Ok(call_sid)
}
async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError> {
let url = format!(
"https://api.twilio.com/2010-04-01/Accounts/{}/Calls/{}.json",
self.credentials.account_sid, correlation_id
);
let response = self
.http
.post(&url)
.basic_auth(
&self.credentials.account_sid,
Some(&self.credentials.auth_token),
)
.form(&[("Status", "completed")])
.send()
.await
.map_err(|e| CallControlError(format!("twilio hangup transport: {e}")))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(CallControlError(format!(
"twilio hangup rejected ({status}): {body}"
)));
}
let last4 = &correlation_id[correlation_id.len().saturating_sub(4)..];
tracing::debug!(sid_last4 = last4, "twilio hangup acknowledged");
Ok(())
}
}
#[cfg(all(test, feature = "twilio-live"))]
mod live_tests {
//! Live e2e tests against real Twilio. `#[ignore]` because they place a real
//! outbound call (charged against your Twilio account). Run manually:
//!
//! ```bash
//! RUTSTER_TWILIO_ACCOUNT_SID=AC... \
//! RUTSTER_TWILIO_AUTH_TOKEN=... \
//! RUTSTER_TWILIO_MEDIA_BIND=0.0.0.0:8081 \
//! RUTSTER_TWILIO_WEBHOOK_BASE=https://your.public.host \
//! RUTSTER_TWILIO_TEST_TO=+1555... \
//! RUTSTER_TWILIO_TEST_FROM=+1555... \
//! cargo test --features=twilio-live -p rutster-trunk -- --include-ignored live_tests
//! ```
//!
//! These read env vars directly (not via the binary's `config::twilio_credentials`)
//! because the trunk crate cannot depend on the binary crate. `#[ignore]` tests are
//! single-run (the maintainer invokes one at a time), so the env-mutation race that
//! the pure-function `config.rs` pattern guards against does not apply here.
use super::*;
use std::net::SocketAddr;
fn creds_from_env() -> Option<TwilioCredentials> {
let account_sid = std::env::var("RUTSTER_TWILIO_ACCOUNT_SID").ok()?;
let auth_token = std::env::var("RUTSTER_TWILIO_AUTH_TOKEN").ok()?;
let media_streams_bind: SocketAddr = std::env::var("RUTSTER_TWILIO_MEDIA_BIND")
.ok()?
.parse()
.ok()?;
let webhook_base: url::Url = std::env::var("RUTSTER_TWILIO_WEBHOOK_BASE")
.ok()?
.parse()
.ok()?;
Some(TwilioCredentials {
account_sid,
auth_token,
media_streams_bind,
webhook_base,
})
}
#[tokio::test]
#[ignore = "places a real outbound Twilio call (billed). manual run only."]
async fn originate_real_call_returns_call_sid() {
let creds = creds_from_env()
.expect("set RUTSTER_TWILIO_{ACCOUNT_SID,AUTH_TOKEN,MEDIA_BIND,WEBHOOK_BASE}");
let client = TwilioCallControlClient::new(creds);
let to = std::env::var("RUTSTER_TWILIO_TEST_TO").expect("set RUTSTER_TWILIO_TEST_TO");
let from = std::env::var("RUTSTER_TWILIO_TEST_FROM").expect("set RUTSTER_TWILIO_TEST_FROM");
let call_sid = client.originate(&to, &from, None).await.expect("originate");
assert!(
call_sid.starts_with("CA"),
"expected CA-prefixed CallSid, got {call_sid}"
);
// Tear down immediately so the test number doesn't actually ring.
client.hangup(&call_sid).await.expect("hangup");
}
#[test]
fn stream_url_derives_wss_from_https_webhook_base() {
let creds = TwilioCredentials {
account_sid: "AC_test".into(),
auth_token: "tok".into(),
media_streams_bind: "0.0.0.0:8081".parse().unwrap(),
webhook_base: "https://example.com".parse().unwrap(),
};
let client = TwilioCallControlClient::new(creds);
assert_eq!(client.stream_url(), "wss://example.com/twilio/media-stream");
}
#[test]
fn stream_url_derives_ws_from_http_webhook_base() {
let creds = TwilioCredentials {
account_sid: "AC_test".into(),
auth_token: "tok".into(),
media_streams_bind: "127.0.0.1:8081".parse().unwrap(),
webhook_base: "http://localhost:8080".parse().unwrap(),
};
let client = TwilioCallControlClient::new(creds);
assert_eq!(
client.stream_url(),
"ws://localhost:8080/twilio/media-stream"
);
}
}

View File

@@ -11,6 +11,13 @@ description = "Rutster binary: axum signaling + media driver + static browser te
rutster-media = { path = "../rutster-media" }
rutster-call-model = { path = "../rutster-call-model" }
rutster-tap = { path = "../rutster-tap" }
# rutster-trunk: the rented-transport crate owns `TwilioCredentials` (T2) +
# the env parser's return type. The binary's `config::twilio_credentials`
# (T6) constructs it from env vars + hands it to the live
# `TwilioCallControlClient` (when `--features=twilio-live` is enabled on the
# trunk crate). ADR-0009: `TwilioCredentials` is imported here but never
# re-exported through the workspace root / never sent over the tap WS protocol.
rutster-trunk = { path = "../rutster-trunk" }
axum = { workspace = true }
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }

View File

@@ -10,6 +10,8 @@
use std::net::{IpAddr, SocketAddr};
use rutster_media::MediaAddressConfig;
use rutster_trunk::provider::TwilioCredentials;
use url::Url;
/// Resolve the HTTP bind address from `RUTSTER_HTTP_BIND`.
///
@@ -93,6 +95,74 @@ pub fn drain_deadline(raw: Option<String>) -> Result<std::time::Duration, String
}
}
/// Parse the four `RUTSTER_TWILIO_*` env vars into a [`TwilioCredentials`].
///
/// Returns `Ok(None)` when ALL four vars are unset — the binary runs WebRTC-only
/// (slice-13 ingress + slice-4 barge-in + slice-4½ sim) without trunk support.
/// Returns `Ok(Some(creds))` when all four are present + parse cleanly. Returns
/// `Err(msg)` on partial config (some vars set, some unset — an operator
/// misconfiguration that must fail-fast at startup, not silently run WebRTC-only)
/// or on malformed values (a non-parseable `MEDIA_BIND` or `WEBHOOK_BASE`).
///
/// Pure function over `Option<String>` inputs (matching the rest of this module's
/// convention) so unit tests never mutate process env. `main.rs` is the only caller
/// that touches `std::env` — it passes the `std::env::var(...).ok()` results here.
///
/// `TwilioCredentials` lives in `rutster-trunk` (ADR-0009 — provider credentials
/// never reach the brain); this parser only constructs the struct, it does not
/// re-export it through the workspace.
///
/// The match destructures all four `Option`s at once: the `(None,None,None,None)`
/// arm is the all-unset → disabled case; the `(Some,Some,Some,Some)` arm extracts
/// owned `String`s (so no `.unwrap()` — the pattern itself proves non-`None`); the
/// catch-all arm reports which subset was set, for an actionable operator error.
pub fn twilio_credentials(
account_sid: Option<String>,
auth_token: Option<String>,
media_streams_bind: Option<String>,
webhook_base: Option<String>,
) -> Result<Option<TwilioCredentials>, String> {
let (account_sid, auth_token, media_streams_bind_raw, webhook_base_raw) =
match (account_sid, auth_token, media_streams_bind, webhook_base) {
(None, None, None, None) => return Ok(None),
(Some(a), Some(t), Some(m), Some(w)) => (a, t, m, w),
(a, t, m, w) => {
let mut got: Vec<&str> = Vec::new();
if a.is_some() {
got.push("RUTSTER_TWILIO_ACCOUNT_SID");
}
if t.is_some() {
got.push("RUTSTER_TWILIO_AUTH_TOKEN");
}
if m.is_some() {
got.push("RUTSTER_TWILIO_MEDIA_BIND");
}
if w.is_some() {
got.push("RUTSTER_TWILIO_WEBHOOK_BASE");
}
return Err(format!(
"partial Twilio config: set all four RUTSTER_TWILIO_* vars or none \
(got only: {})",
got.join(", ")
));
}
};
let media_streams_bind = media_streams_bind_raw
.parse::<SocketAddr>()
.map_err(|e| format!("RUTSTER_TWILIO_MEDIA_BIND is not a socket address: {e}"))?;
let webhook_base = webhook_base_raw
.parse::<Url>()
.map_err(|e| format!("RUTSTER_TWILIO_WEBHOOK_BASE is not a URL: {e}"))?;
Ok(Some(TwilioCredentials {
account_sid,
auth_token,
media_streams_bind,
webhook_base,
}))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -186,4 +256,64 @@ mod tests {
assert_eq!(cfg.advertised_ip.unwrap().to_string(), "203.0.113.7");
assert_eq!(cfg.port_range, Some((10000, 10100)));
}
#[test]
fn twilio_credentials_none_when_all_unset() {
assert!(
twilio_credentials(None, None, None, None)
.unwrap()
.is_none()
);
}
#[test]
fn twilio_credentials_parses_all_four() {
let creds = twilio_credentials(
Some("AC_test_sid".into()),
Some("secret-token".into()),
Some("0.0.0.0:8081".into()),
Some("https://example.com".into()),
)
.unwrap()
.expect("all-four-present should yield Some");
assert_eq!(creds.account_sid, "AC_test_sid");
assert_eq!(
creds.media_streams_bind,
"0.0.0.0:8081".parse::<SocketAddr>().unwrap()
);
assert_eq!(creds.webhook_base.as_str(), "https://example.com/");
}
#[test]
fn twilio_credentials_error_on_partial_config_names_missing_vars() {
// Only MEDIA_BIND set — the error must name what WAS set so the operator
// knows which subset to complete or clear.
let err = twilio_credentials(None, None, Some("0.0.0.0:8081".into()), None).unwrap_err();
assert!(err.contains("partial"), "msg: {err}");
assert!(err.contains("RUTSTER_TWILIO_MEDIA_BIND"));
}
#[test]
fn twilio_credentials_error_on_malformed_media_bind() {
let err = twilio_credentials(
Some("AC_x".into()),
Some("tok".into()),
Some("not-a-socket-addr".into()),
Some("https://example.com".into()),
)
.unwrap_err();
assert!(err.contains("RUTSTER_TWILIO_MEDIA_BIND"));
}
#[test]
fn twilio_credentials_error_on_malformed_webhook_base() {
let err = twilio_credentials(
Some("AC_x".into()),
Some("tok".into()),
Some("0.0.0.0:8081".into()),
Some("not-a-url".into()),
)
.unwrap_err();
assert!(err.contains("RUTSTER_TWILIO_WEBHOOK_BASE"));
}
}