Files
siprouter/CLAUDE.md

7.4 KiB

Project Notes

Architecture: Hub Model in Rust (Call as Centerpiece)

The call hub lives in the Rust proxy-engine (rust/crates/proxy-engine/). TypeScript is the control plane only — it configures the engine, sends high-level commands (hangup, make_call, webrtc_offer, etc.), and receives events (incoming_call, call_answered, device_registered, webrtc_audio_rx, …). No raw SIP/RTP ever touches TypeScript.

The Call is still the central entity: it owns N legs and a central mixer task that provides mix-minus audio to all participants. Legs can be SipProvider, SipDevice, WebRtc, or Tool (recording/transcription observer).

Key Rust files (rust/crates/proxy-engine/src/)

  • call_manager.rs — singleton registry, call factory methods, SIP routing (inbound/outbound/passthrough), B2BUA state machine, inbound route resolution
  • call.rs — the Call hub + LegInfo struct, owns legs and the mixer task
  • sip_leg.rs — full SIP dialog management for B2BUA legs (INVITE, 407 auth retry, BYE, CANCEL, early media)
  • rtp.rs — RTP port pool (uses Weak<UdpSocket> so calls auto-release ports on drop) + RTP header helpers
  • mixer.rs — 20 ms-tick mix-minus engine (48 kHz f32 internal, per-leg transcoding via codec-lib, per-leg denoising)
  • jitter_buffer.rs — per-leg reordering/packet-loss compensation
  • leg_io.rs — spawns inbound/outbound RTP I/O tasks per SIP leg
  • webrtc_engine.rs — browser WebRTC sessions (werift-rs based), ICE/DTLS/SRTP
  • provider.rs — SIP trunk registrations, public-IP detection via Via received=
  • registrar.rs — accepts REGISTER from SIP phones, tracks contacts (push-based device status)
  • config.rsAppConfig deserialized from TS, route resolvers (resolve_outbound_route, resolve_inbound_route)
  • main.rs — IPC command dispatcher (handle_command), event emitter, top-level SIP packet router
  • sip_transport.rs — owning wrapper around the main SIP UDP socket
  • voicemail.rs / recorder.rs / audio_player.rs / tts.rs — media subsystems
  • tool_leg.rs — per-source observer audio for recording/transcription tools
  • ipc.rs — event-emission helper used throughout

Key TS files (control plane)

  • ts/sipproxy.ts — entrypoint, wires the proxy engine bridge + web UI + WebRTC signaling
  • ts/proxybridge.ts@push.rocks/smartrust bridge to the Rust binary, typed TProxyCommands map
  • ts/config.ts — JSON config loader (IAppConfig, IProviderConfig, etc.), sent to Rust via configure
  • ts/voicebox.ts — voicemail metadata persistence (WAV files live in .nogit/voicemail/{boxId}/)
  • ts/webrtcbridge.ts — browser WebSocket signaling, browser device registry (deviceIdToWs)
  • ts/call/prompt-cache.ts — the only remaining file under ts/call/ (IVR prompt caching)

Rust SIP protocol library

rust/crates/sip-proto/ is a zero-dependency SIP data library (parse/build/mutate/serialize messages, dialog management, SDP helpers, digest auth). Do not add transport or timer logic there — it's purely data-level.

Event-push architecture for device status

Device status flows via push events, not pull-based IPC queries:

  1. Rust emits device_registered when a phone REGISTERs
  2. TS sipproxy.ts maintains a deviceStatuses Map, updated from the event
  3. Map snapshot goes into the WebSocket status broadcast
  4. Web UI (ts_web/elements/sipproxy-devices.ts) reads it from the push stream

There used to be a get_status pull IPC for this, but it was never called from TS and has been removed. If a new dashboard ever needs a pull-based snapshot, the push Map is the right source to read from.

Inbound routing (wired in Commit 4 of the cleanup PR)

Inbound route resolution goes through config.resolve_inbound_route(provider_id, called_number, caller_number) inside create_inbound_call (call_manager.rs). The result carries a ring_browsers flag that propagates to the incoming_call event; ts/sipproxy.ts gates the webrtc-incoming browser fan-out behind that flag.

Known limitations / TODOs (documented in code at create_inbound_call):

  • Multi-target inbound fork is not yet implemented — only the first registered device from route.device_ids is rung.
  • ring_browsers is informational only: browsers see a toast but do not race the SIP device to answer. True first-to-answer-wins requires a multi-leg fork + per-leg CANCEL, which is not built yet.
  • voicemail_box, ivr_menu_id, no_answer_timeout are resolved but not yet honored downstream.

WebRTC Browser Call Flow (Critical)

The browser call signaling order is strict:

  1. Browser initiates outbound via a TS API (e.g. POST /api/call) — TS creates a pending call in the Rust engine via make_call and notifies the browser with a webrtc-incoming push.
  2. Browser sends webrtc-offer (with its own sessionId) → Rust handle_webrtc_offer creates a standalone WebRTC session (NOT attached to any call yet).
  3. Browser sends webrtc_link (with callId + sessionId) → Rust links the standalone session to the Call and wires the WebRTC leg through the mixer.

The WebRTC leg cannot be fully attached at call-creation time because the browser's session ID is unknown until the webrtc-offer arrives.

WebRTC audio return channel (Critical)

The SIP→browser audio path goes through the mixer, not a direct RTP relay:

  1. Provider sends RTP → received on the provider leg's UDP socket (leg_io::spawn_sip_inbound)
  2. Packet flows through jitter_buffer → mixer's inbound mpsc channel
  3. Mixer decodes/resamples/denoises, computes mix-minus per leg
  4. WebRTC leg receives its mix-minus frame, encodes to Opus, and pushes via the WebRTC engine's peer connection sender

Browser→SIP works symmetrically: ontrack.onReceiveRtp → WebRTC leg's outbound mpsc → mixer → other legs' inbound channels.

SDP/Record-Route NAT (fixed in Commit 3 of the cleanup PR)

The proxy tracks a public_ip: Option<String> on every LegInfo (populated from provider-leg construction sites). When route_passthrough_message rewrites SDP (c= line) or emits a Record-Route, it picks advertise_ip based on the destination leg's kind:

  • SipProviderother.public_ip.unwrap_or(lan_ip) (provider reaches us via public IP)
  • SipDevice / WebRtc / Tool / Medialan_ip (everything else is LAN or proxy-internal)

This fixed a real NAT-traversal bug where the proxy advertised its RFC1918 LAN IP to the provider in SDP, causing one-way or no audio for device-originated inbound traffic behind NAT.

Build & development

  • Build: pnpm run buildRust (never cargo build directly — tsrust cross-compiles for both x86_64-unknown-linux-gnu and aarch64-unknown-linux-gnu)
  • Cross-compile setup: the aarch64 target requires gcc-aarch64-linux-gnu + libstdc++6-arm64-cross (Debian/Ubuntu). See rust/.cargo/config.toml for the linker wiring. A committed symlink at rust/.cargo/crosslibs/aarch64/libstdc++.so/usr/aarch64-linux-gnu/lib/libstdc++.so.6 avoids needing the libstdc++-13-dev-arm64-cross package.
  • Bundle web UI: pnpm run bundle (esbuild, output: dist_ts_web/bundle.js)
  • Full build: pnpm run build (= buildRust && bundle)
  • Start server: pnpm run start (runs tsx ts/sipproxy.ts)

Persistent files

  • .nogit/config.json — app config (providers, devices, routes, voiceboxes, IVR menus)
  • .nogit/voicemail/{boxId}/ — voicemail WAV files + messages.json index
  • .nogit/prompts/ — cached TTS prompts for IVR menus